import { PgAssetStore, ensureAssetSchema } from '@openmaic/storage/asset/pg';
import {
PgDocumentStore,
ensureDocumentSchema,
type Queryable,
type WithTransaction,
} from '@openmaic/storage/document/pg';
import { PgRuntimeStore, ensureSchema } from '@openmaic/storage/runtime/pg';
import {
nodePostgresTransaction,
type ConnectableQueryable,
} from '@openmaic/storage/server/reference';
import { Pool } from 'pg';
import { validateAppScene, validateAppStage } from '@/lib/document-store/validators';
import { configuredLazyAssetByteStore } from '@/lib/persistence/asset-byte-store';
import { resolveAssetPendingTtlMs } from '@/lib/persistence/asset-pending-ttl';
import { resolveAssetQuotaBytes } from '@/lib/persistence/asset-quota';
import { ensureLegacyClassroomImportSchema } from '@/lib/persistence/legacy-classroom-imports';
import { ensureOwnerMaterialSchema } from '@/lib/persistence/owner-materials';
import { fenceOwnerWrite } from '@/lib/persistence/owner-merges';
import { withSchemaBootstrapLock } from '@/lib/persistence/schema-bootstrap-lock';
import { ensureWorkspaceModelConfigSchema } from '@/lib/persistence/workspace-model-config';
import { ensureStageMetaSchema } from '@/lib/persistence/stage-meta';
import { APP_RUNTIME_PAYLOAD_VALIDATORS } from '@/lib/runtime/payload-validators';
import { ensureOwnerAgentSchema } from '@/lib/server/agents/store';
export type PersistencePoolFactory = (connectionString: string) => Pool;
* The process's persistence stores. There is deliberately no document store
* here: an unscoped `PgDocumentStore` would write courses with no owner check
* and no host create hooks. Documents are reached only through the
* owner-bound store (`createOwnerBoundDocumentStore`,
* `getOwnerScopedDocumentStore`).
*/
export interface ServerPersistenceProvider {
pool: Pool;
runtimeStore: PgRuntimeStore;
assetStore: PgAssetStore;
withTransaction: WithTransaction;
* The asset registry with every statement on `queryable`, an already open
* transaction, for a caller that must check and mutate atomically.
*/
assetStoreIn(queryable: Queryable): ServerPersistenceProvider['assetStore'];
}
interface ProviderState {
connectionString?: string;
providerPromise?: Promise<ServerPersistenceProvider>;
}
const PROVIDER_STATE_KEY = Symbol.for('openmaic.persistence.provider');
const globalState = globalThis as typeof globalThis & {
[key: symbol]: ProviderState | undefined;
};
const providerState = (globalState[PROVIDER_STATE_KEY] ??= {});
async function createServerPersistenceProvider(
connectionString: string,
poolFactory: PersistencePoolFactory,
): Promise<ServerPersistenceProvider> {
const quotaBytes = resolveAssetQuotaBytes();
const pendingTtlMs = resolveAssetPendingTtlMs();
const pool = poolFactory(connectionString);
const queryable = pool as unknown as ConnectableQueryable;
try {
await withSchemaBootstrapLock(queryable, async (locked) => {
await ensureSchema(locked);
await ensureDocumentSchema(locked);
await ensureStageMetaSchema(locked);
await ensureOwnerMaterialSchema(locked);
await ensureAssetSchema(locked);
await ensureLegacyClassroomImportSchema(locked);
await ensureWorkspaceModelConfigSchema(locked);
await ensureOwnerAgentSchema(locked);
});
const withTransaction = nodePostgresTransaction(queryable);
const byteStore = configuredLazyAssetByteStore(queryable);
const documentStore = new PgDocumentStore(queryable, {
withTransaction,
validateScene: validateAppScene,
validateStage: validateAppStage,
trackAssetReferences: true,
});
await documentStore.declareAssetReferenceTracking();
const assetRegistry = (on: Queryable, transaction: WithTransaction) =>
new PgAssetStore(on, {
withTransaction: transaction,
byteStore,
pendingTtlMs,
...(quotaBytes === undefined ? {} : { quotaBytes }),
});
return {
pool,
runtimeStore: new PgRuntimeStore(queryable, {
withTransaction,
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
resolveFinalLearner: async (transaction, learnerKey) => {
await fenceOwnerWrite(transaction, learnerKey);
return learnerKey;
},
}),
assetStore: assetRegistry(queryable, withTransaction),
withTransaction,
assetStoreIn: (pinned) => assetRegistry(pinned, (body) => body(pinned)),
};
} catch (error) {
await pool.end().catch(() => {});
throw error;
}
}
* Shared server bootstrap used by both HTTP persistence and Pi composition.
*
* The memo is keyed on the TRIMMED connection string, and the pool is built
* from it, because callers disagree about whose job that is: the persistence
* route and the agent runtime pass `process.env.DATABASE_URL` as it is, while
* the collector schedule and the shutdown hook trim it first — they need the
* trimmed value anyway, to tell a blank variable from an unset one. A
* whitespace-padded `DATABASE_URL` would otherwise memoise twice and open two
* pools for one database in one process, and every one of those callers would
* be individually correct. Normalizing at the one seam they all go through
* costs nothing and leaves no spelling that can split them.
*/
export function getServerPersistenceProvider(
connectionString: string,
poolFactory: PersistencePoolFactory = (value) => new Pool({ connectionString: value }),
): Promise<ServerPersistenceProvider> {
const key = connectionString.trim();
if (providerState.providerPromise && providerState.connectionString === key) {
return providerState.providerPromise;
}
providerState.connectionString = key;
const initialization = createServerPersistenceProvider(key, poolFactory).catch((error) => {
if (providerState.providerPromise === initialization) {
providerState.providerPromise = undefined;
providerState.connectionString = undefined;
}
throw error;
});
providerState.providerPromise = initialization;
return initialization;
}