feat: productionize learning engine and classroom
This commit is contained in:
@@ -1,283 +1,7 @@
|
||||
import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { PgAssetStore, ensureAssetSchema } from '@openmaic/storage/asset/pg';
|
||||
import { PgDocumentStore, ensureDocumentSchema } from '@openmaic/storage/document/pg';
|
||||
import { PgRuntimeStore, ensureSchema } from '@openmaic/storage/runtime/pg';
|
||||
import { createStorageHttpHandler } from '@openmaic/storage/server';
|
||||
import {
|
||||
nodePostgresTransaction,
|
||||
type ConnectableQueryable,
|
||||
} from '@openmaic/storage/server/reference';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
import { validateAppScene, validateAppStage } from '@/lib/document-store/validators';
|
||||
import { lazyAssetByteStore } from '@/lib/persistence/asset-byte-store';
|
||||
import { authenticatePersistenceRequest } from '@/lib/persistence/server-auth';
|
||||
import { APP_RUNTIME_PAYLOAD_VALIDATORS } from '@/lib/runtime/payload-validators';
|
||||
import { handlePersistenceRequest } from '@/lib/persistence/http-route';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const ROUTE_PREFIX = '/api/persistence';
|
||||
|
||||
type PoolFactory = (connectionString: string) => Pool;
|
||||
|
||||
interface PersistenceHandlerState {
|
||||
connectionString?: string;
|
||||
handlerPromise?: Promise<RequestListener>;
|
||||
}
|
||||
|
||||
const HANDLER_STATE_KEY = Symbol.for('openmaic.persistence-route.handler');
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[key: symbol]: PersistenceHandlerState | undefined;
|
||||
};
|
||||
const handlerState = (globalState[HANDLER_STATE_KEY] ??= {});
|
||||
|
||||
function jsonError(status: number, code: string, message: string): Response {
|
||||
return Response.json({ error: { code, message } }, { status });
|
||||
}
|
||||
|
||||
async function createPersistenceHandler(
|
||||
connectionString: string,
|
||||
poolFactory: PoolFactory,
|
||||
): Promise<RequestListener> {
|
||||
const pool = poolFactory(connectionString);
|
||||
const queryable = pool as unknown as ConnectableQueryable;
|
||||
try {
|
||||
await ensureSchema(queryable);
|
||||
await ensureDocumentSchema(queryable);
|
||||
await ensureAssetSchema(queryable);
|
||||
const withTransaction = nodePostgresTransaction(queryable);
|
||||
// Deferred to first asset use: the asset backend is optional, so its
|
||||
// misconfiguration (an invalid ASSET_S3_BUCKET, an unresolvable AWS SDK)
|
||||
// must fail asset requests only, never this handler's initialization.
|
||||
const byteStore = lazyAssetByteStore(process.env.ASSET_S3_BUCKET, queryable);
|
||||
const runtimeStore = new PgRuntimeStore(queryable, {
|
||||
withTransaction,
|
||||
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
|
||||
});
|
||||
const documentStore = new PgDocumentStore(queryable, {
|
||||
withTransaction,
|
||||
validateScene: validateAppScene,
|
||||
validateStage: validateAppStage,
|
||||
});
|
||||
// The asset contract requires a server-derived principal; this development
|
||||
// authenticator instead takes the partition key from a client-supplied header.
|
||||
// Cross-principal isolation is therefore not in force: asset bytes are as
|
||||
// reachable as documents and runtime records under this authenticator. Before
|
||||
// asset routes carry anything that matters, production must replace
|
||||
// authenticatePersistenceRequest with real session verification. See
|
||||
// lib/persistence/server-auth.ts for the token's limits.
|
||||
const assetStore = new PgAssetStore(queryable, { withTransaction, byteStore });
|
||||
// Reclamation is not scheduled from here, and must not be: a route module
|
||||
// has no once-per-process guarantee and no shutdown hook. AssetCollector
|
||||
// runs from instrumentation.ts instead, over the byte store this same
|
||||
// lib/persistence/asset-byte-store selection produces, so the collector
|
||||
// always deletes through the layer the request path wrote through.
|
||||
return createStorageHttpHandler(runtimeStore, documentStore, {
|
||||
authenticate: authenticatePersistenceRequest,
|
||||
authorizeMerge: async () => false,
|
||||
authorizeAdmin: async () => false,
|
||||
authorizeDocuments: async () => true,
|
||||
validateScene: validateAppScene,
|
||||
validateStage: validateAppStage,
|
||||
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
|
||||
assetStore,
|
||||
});
|
||||
} catch (error) {
|
||||
await pool.end().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getPersistenceHandler(
|
||||
connectionString: string,
|
||||
poolFactory: PoolFactory,
|
||||
): Promise<RequestListener> {
|
||||
if (handlerState.handlerPromise && handlerState.connectionString === connectionString) {
|
||||
return handlerState.handlerPromise;
|
||||
}
|
||||
|
||||
handlerState.connectionString = connectionString;
|
||||
const initialization = createPersistenceHandler(connectionString, poolFactory).catch((error) => {
|
||||
// Do not poison the singleton with a rejected promise. createPersistenceHandler
|
||||
// has already closed its failed pool, and the next request gets a clean retry.
|
||||
if (handlerState.handlerPromise === initialization) {
|
||||
handlerState.handlerPromise = undefined;
|
||||
handlerState.connectionString = undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
handlerState.handlerPromise = initialization;
|
||||
return initialization;
|
||||
}
|
||||
|
||||
function nodeRequest(request: Request): IncomingMessage {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname.startsWith(ROUTE_PREFIX)
|
||||
? url.pathname.slice(ROUTE_PREFIX.length) || '/'
|
||||
: url.pathname;
|
||||
const body = request.body
|
||||
? Readable.fromWeb(
|
||||
request.body as unknown as import('node:stream/web').ReadableStream<Uint8Array>,
|
||||
)
|
||||
: Readable.from([]);
|
||||
return Object.assign(body, {
|
||||
method: request.method,
|
||||
url: `${pathname}${url.search}`,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
}) as IncomingMessage;
|
||||
}
|
||||
|
||||
function setHeaders(target: Headers, source: Record<string, string | number | string[]>): void {
|
||||
for (const [name, value] of Object.entries(source)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) target.append(name, item);
|
||||
} else {
|
||||
target.set(name, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ResponseCallback = () => void;
|
||||
|
||||
function responseEncoding(encodingOrCallback?: BufferEncoding | ResponseCallback): BufferEncoding {
|
||||
const encoding = typeof encodingOrCallback === 'string' ? encodingOrCallback : 'utf8';
|
||||
if (!Buffer.isEncoding(encoding)) {
|
||||
// Let Buffer produce Node's ERR_UNKNOWN_ENCODING TypeError.
|
||||
Buffer.from('', encoding);
|
||||
}
|
||||
return encoding;
|
||||
}
|
||||
|
||||
function responseCallback(
|
||||
encodingOrCallback?: BufferEncoding | ResponseCallback,
|
||||
callback?: ResponseCallback,
|
||||
): ResponseCallback | undefined {
|
||||
return typeof encodingOrCallback === 'function' ? encodingOrCallback : callback;
|
||||
}
|
||||
|
||||
function suppressesResponseBody(request: Request, status: number): boolean {
|
||||
return request.method === 'HEAD' || status === 204 || status === 205 || status === 304;
|
||||
}
|
||||
|
||||
function runNodeHandler(handler: RequestListener, request: Request): Promise<Response> {
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
let status = 200;
|
||||
const headers = new Headers();
|
||||
let headersSent = false;
|
||||
// Buffered as bytes rather than as a string. A handler may end with a
|
||||
// `Uint8Array`, which `ServerResponse.end` accepts and which is not
|
||||
// necessarily valid UTF-8; decoding it would replace every unpaired byte
|
||||
// with U+FFFD and silently corrupt the response.
|
||||
const body: Buffer[] = [];
|
||||
|
||||
const appendChunk = (chunk: string | Uint8Array, encoding: BufferEncoding) => {
|
||||
body.push(typeof chunk === 'string' ? Buffer.from(chunk, encoding) : Buffer.from(chunk));
|
||||
};
|
||||
|
||||
const response = {
|
||||
get headersSent() {
|
||||
return headersSent;
|
||||
},
|
||||
writeHead(
|
||||
statusCode: number,
|
||||
statusMessageOrHeaders?: string | Record<string, string | number | string[]>,
|
||||
outgoingHeaders?: Record<string, string | number | string[]>,
|
||||
) {
|
||||
status = statusCode;
|
||||
headersSent = true;
|
||||
const values =
|
||||
typeof statusMessageOrHeaders === 'string' ? outgoingHeaders : statusMessageOrHeaders;
|
||||
if (values) setHeaders(headers, values);
|
||||
return this;
|
||||
},
|
||||
write(
|
||||
chunk: string | Uint8Array,
|
||||
encodingOrCallback?: BufferEncoding | ResponseCallback,
|
||||
callback?: ResponseCallback,
|
||||
) {
|
||||
// `write` is part of the `ServerResponse` surface this object claims to
|
||||
// implement. Omitting it made any chunked handler a runtime TypeError
|
||||
// that the `as unknown as ServerResponse` cast hid from the compiler.
|
||||
headersSent = true;
|
||||
appendChunk(chunk, responseEncoding(encodingOrCallback));
|
||||
const done = responseCallback(encodingOrCallback, callback);
|
||||
if (done) process.nextTick(done);
|
||||
return true;
|
||||
},
|
||||
end(
|
||||
chunkOrCallback?: string | Uint8Array | ResponseCallback,
|
||||
encodingOrCallback?: BufferEncoding | ResponseCallback,
|
||||
callback?: ResponseCallback,
|
||||
) {
|
||||
headersSent = true;
|
||||
const chunk = typeof chunkOrCallback === 'function' ? undefined : chunkOrCallback;
|
||||
const done =
|
||||
typeof chunkOrCallback === 'function'
|
||||
? chunkOrCallback
|
||||
: responseCallback(encodingOrCallback, callback);
|
||||
if (chunk !== undefined) appendChunk(chunk, responseEncoding(encodingOrCallback));
|
||||
resolve(
|
||||
new Response(
|
||||
suppressesResponseBody(request, status) || body.length === 0
|
||||
? undefined
|
||||
: Buffer.concat(body),
|
||||
{
|
||||
status,
|
||||
headers,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (done) process.nextTick(done);
|
||||
return this;
|
||||
},
|
||||
destroy(error?: Error) {
|
||||
reject(error ?? new Error('Persistence HTTP handler destroyed the response'));
|
||||
return this;
|
||||
},
|
||||
} as unknown as ServerResponse;
|
||||
|
||||
try {
|
||||
handler(nodeRequest(request), response);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface PersistenceRequestDeps {
|
||||
poolFactory?: PoolFactory;
|
||||
}
|
||||
|
||||
export async function handlePersistenceRequest(
|
||||
request: Request,
|
||||
deps: PersistenceRequestDeps = {},
|
||||
): Promise<Response> {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
return jsonError(404, 'PERSISTENCE_NOT_CONFIGURED', 'server persistence not configured');
|
||||
}
|
||||
if (!process.env.PERSISTENCE_DEV_TOKEN) {
|
||||
return jsonError(
|
||||
503,
|
||||
'PERSISTENCE_DEV_TOKEN_MISSING',
|
||||
'server persistence requires PERSISTENCE_DEV_TOKEN (development auth only)',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const poolFactory = deps.poolFactory ?? ((value) => new Pool({ connectionString: value }));
|
||||
return await runNodeHandler(
|
||||
await getPersistenceHandler(connectionString, poolFactory),
|
||||
request,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Embedded persistence route initialization failed', error);
|
||||
return jsonError(500, 'PERSISTENCE_INIT_FAILED', 'server persistence initialization failed');
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = (request: Request) => handlePersistenceRequest(request);
|
||||
export const POST = (request: Request) => handlePersistenceRequest(request);
|
||||
export const PUT = (request: Request) => handlePersistenceRequest(request);
|
||||
|
||||
Reference in New Issue
Block a user