202 lines
6.5 KiB
TypeScript
202 lines
6.5 KiB
TypeScript
import type { DataStoreKind, PrismaClient, PrismaReplicaClient } from "@trigger.dev/database";
|
|
import {
|
|
ClickhouseDataStoreConfig,
|
|
type ParsedDataStore,
|
|
} from "./organizationDataStoreConfigSchemas.server";
|
|
import { getSecretStore } from "../secrets/secretStore.server";
|
|
import { ClickhouseConnectionSchema } from "../clickhouse/clickhouseSecretSchemas.server";
|
|
|
|
export class OrganizationDataStoresRegistry {
|
|
/**
|
|
* Writer client — used by every method that mutates state
|
|
* (`addDataStore` / `updateDataStore` / `deleteDataStore` and their backing
|
|
* SecretStore writes). Must be the primary connection; replica-targeted
|
|
* writes are rejected by Postgres with code 25006 (read-only transaction).
|
|
*/
|
|
private _writer: PrismaClient;
|
|
/**
|
|
* Read client used by the polling `loadFromDatabase()` (and its
|
|
* `SecretStore.getSecret` lookups). Can be a replica — these are
|
|
* cache-fillers, not on hot user-facing paths.
|
|
*/
|
|
private _replica: PrismaClient | PrismaReplicaClient;
|
|
/** Keyed by `${organizationId}:${kind}` */
|
|
private _lookup: Map<string, ParsedDataStore> = new Map();
|
|
private _loaded = false;
|
|
private _readyResolve!: () => void;
|
|
|
|
/**
|
|
* Resolves once the initial `loadFromDatabase()` completes successfully.
|
|
* At process startup the singleton loads the registry with unbounded retries
|
|
* (exponential backoff, capped delay) until Postgres is reachable; until then
|
|
* this promise stays pending and callers that await readiness will block.
|
|
*/
|
|
readonly isReady: Promise<void>;
|
|
|
|
constructor(writer: PrismaClient, replica: PrismaClient | PrismaReplicaClient) {
|
|
this._writer = writer;
|
|
this._replica = replica;
|
|
this.isReady = new Promise<void>((resolve) => {
|
|
this._readyResolve = resolve;
|
|
});
|
|
}
|
|
|
|
get isLoaded(): boolean {
|
|
return this._loaded;
|
|
}
|
|
|
|
async loadFromDatabase(): Promise<void> {
|
|
// Sort by `key` (unique, immutable) to ensure a deterministic winner when the
|
|
// same `${orgId}:${kind}` appears in multiple rows. The registry must never
|
|
// throw on overlap — failing the load would break every customer, not just the
|
|
// misconfigured orgs — so we keep the first entry and log an error instead.
|
|
const rows = await this._replica.organizationDataStore.findMany({
|
|
orderBy: { key: "asc" },
|
|
});
|
|
const secretStore = getSecretStore("DATABASE", { prismaClient: this._replica });
|
|
|
|
const lookup = new Map<string, ParsedDataStore>();
|
|
/** Tracks which row's `key` already owns each `${orgId}:${kind}` so we can log conflicts. */
|
|
const winnerByLookupKey = new Map<string, string>();
|
|
|
|
for (const row of rows) {
|
|
let parsed: ParsedDataStore | null = null;
|
|
|
|
switch (row.kind) {
|
|
case "CLICKHOUSE": {
|
|
const result = ClickhouseDataStoreConfig.safeParse(row.config);
|
|
if (!result.success) {
|
|
console.warn(
|
|
`[OrganizationDataStoresRegistry] Invalid config for OrganizationDataStore "${row.key}" (kind=CLICKHOUSE): ${result.error.message}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const connection = await secretStore.getSecret(
|
|
ClickhouseConnectionSchema,
|
|
result.data.data.secretKey
|
|
);
|
|
|
|
if (!connection) {
|
|
console.warn(
|
|
`[OrganizationDataStoresRegistry] Secret "${result.data.data.secretKey}" not found for OrganizationDataStore "${row.key}" — skipping`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
parsed = { kind: "CLICKHOUSE", url: connection.url };
|
|
break;
|
|
}
|
|
default: {
|
|
console.warn(
|
|
`[OrganizationDataStoresRegistry] Unknown kind "${row.kind}" for OrganizationDataStore "${row.key}" — skipping`
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
for (const orgId of row.organizationIds) {
|
|
const lookupKey = `${orgId}:${row.kind}`;
|
|
const existingWinner = winnerByLookupKey.get(lookupKey);
|
|
if (existingWinner) {
|
|
console.error(
|
|
`[OrganizationDataStoresRegistry] Overlapping OrganizationDataStore assignment for orgId="${orgId}" kind=${row.kind}: already routed to "${existingWinner}", ignoring "${row.key}". Pick one store per (org, kind) to resolve.`
|
|
);
|
|
continue;
|
|
}
|
|
winnerByLookupKey.set(lookupKey, row.key);
|
|
lookup.set(lookupKey, parsed);
|
|
}
|
|
}
|
|
|
|
this._lookup = lookup;
|
|
|
|
if (!this._loaded) {
|
|
this._loaded = true;
|
|
this._readyResolve();
|
|
}
|
|
}
|
|
|
|
async reload(): Promise<void> {
|
|
await this.loadFromDatabase();
|
|
}
|
|
|
|
#secretKey(key: string, kind: DataStoreKind) {
|
|
return `data-store:${key}:${kind.toLocaleLowerCase()}`;
|
|
}
|
|
|
|
async addDataStore({
|
|
key,
|
|
kind,
|
|
organizationIds,
|
|
config,
|
|
}: {
|
|
key: string;
|
|
kind: DataStoreKind;
|
|
organizationIds: string[];
|
|
config: any;
|
|
}) {
|
|
const secretKey = this.#secretKey(key, kind);
|
|
|
|
const secretStore = getSecretStore("DATABASE", { prismaClient: this._writer });
|
|
await secretStore.setSecret(secretKey, config);
|
|
|
|
return this._writer.organizationDataStore.create({
|
|
data: {
|
|
key,
|
|
organizationIds,
|
|
kind,
|
|
config: { version: 1, data: { secretKey } },
|
|
},
|
|
});
|
|
}
|
|
|
|
async updateDataStore({
|
|
key,
|
|
kind,
|
|
organizationIds,
|
|
config,
|
|
}: {
|
|
key: string;
|
|
kind: DataStoreKind;
|
|
organizationIds: string[];
|
|
config?: any;
|
|
}) {
|
|
const secretKey = this.#secretKey(key, kind);
|
|
|
|
if (config) {
|
|
const secretStore = getSecretStore("DATABASE", { prismaClient: this._writer });
|
|
await secretStore.setSecret(secretKey, config);
|
|
}
|
|
|
|
return this._writer.organizationDataStore.update({
|
|
where: {
|
|
key,
|
|
},
|
|
data: {
|
|
organizationIds,
|
|
kind: "CLICKHOUSE",
|
|
},
|
|
});
|
|
}
|
|
|
|
async deleteDataStore({ key, kind }: { key: string; kind: DataStoreKind }) {
|
|
const secretKey = this.#secretKey(key, kind);
|
|
const secretStore = getSecretStore("DATABASE", { prismaClient: this._writer });
|
|
await secretStore.deleteSecret(secretKey).catch(() => {
|
|
// Secret may not exist — proceed with deletion
|
|
});
|
|
|
|
await this._writer.organizationDataStore.delete({ where: { key } });
|
|
}
|
|
|
|
/**
|
|
* Returns the parsed data store config for the given organization and kind,
|
|
* or `null` if no override is configured (caller should use the default).
|
|
*/
|
|
get(organizationId: string, kind: DataStoreKind): ParsedDataStore | null {
|
|
if (!this._loaded) return null;
|
|
return this._lookup.get(`${organizationId}:${kind}`) ?? null;
|
|
}
|
|
}
|