import { createHash } from 'node:crypto'; import { GamePrisma, type DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra'; import type { DatabaseClient } from './context.js'; const API_INPUT_PAYLOAD_VERSION = 1 as const; const BUSINESS_SAVEPOINT = 'api_input_event_business'; export interface ApiInputPayloadIdentity { version: typeof API_INPUT_PAYLOAD_VERSION; digest: string; } interface LockedInputEvent { target: 'API' | 'ENGINE'; eventType: string; payload: GamePrisma.JsonValue; actorUserId: string | null; status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED'; result: GamePrisma.JsonValue | null; attempts: number; } type InputEventOutcome = { kind: 'executed'; value: T } | { kind: 'replayed'; value: T } | { kind: 'failed'; error: unknown }; type SavepointDatabaseClient = InfraDatabaseClient & { $executeRawUnsafe(query: string): Promise; }; const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; const canonicalJson = (value: unknown): string => JSON.stringify(value, (_key, entry: unknown) => { if (typeof entry === 'bigint') { return entry.toString(); } if (entry && typeof entry === 'object' && !Array.isArray(entry)) { return Object.fromEntries( Object.entries(entry as Record).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0 ) ); } return entry; }) ?? 'null'; const canonicalJsonValue = (value: unknown): GamePrisma.InputJsonValue => JSON.parse(canonicalJson(value)) as GamePrisma.InputJsonValue; export const createApiInputPayloadIdentity = (payload: unknown): ApiInputPayloadIdentity => ({ version: API_INPUT_PAYLOAD_VERSION, digest: `sha256:${createHash('sha256').update(canonicalJson(payload)).digest('hex')}`, }); const isLegacyEmptyPayload = (payload: GamePrisma.JsonValue): boolean => payload !== null && !Array.isArray(payload) && typeof payload === 'object' && Object.keys(payload).length === 0; const sameJson = (left: unknown, right: unknown): boolean => canonicalJson(left) === canonicalJson(right); export class DuplicateInputEventError extends Error { constructor(readonly requestId: string) { super(`Input event ${requestId} conflicts with an existing request.`); this.name = 'DuplicateInputEventError'; } } const insertPendingIfAbsent = async ( db: DatabaseClient, options: { requestId: string; eventType: string; actorUserId: string | null; payloadIdentity: ApiInputPayloadIdentity; } ): Promise => { await db.$executeRaw( GamePrisma.sql` INSERT INTO input_event ( request_id, target, event_type, payload, actor_user_id, status, attempts, created_at ) VALUES ( ${options.requestId}, 'API'::"InputEventTarget", ${options.eventType}, CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb), ${options.actorUserId}, 'PENDING'::"InputEventStatus", 0, CURRENT_TIMESTAMP AT TIME ZONE 'UTC' ) ON CONFLICT (request_id) DO NOTHING ` ); }; const lockInputEvent = async (db: DatabaseClient, requestId: string): Promise => { const rows = await db.$queryRaw( GamePrisma.sql` SELECT target, event_type AS "eventType", payload, actor_user_id AS "actorUserId", status, result, attempts FROM input_event WHERE request_id = ${requestId} FOR UPDATE ` ); const row = rows[0]; if (!row) { throw new Error(`Input event ${requestId} disappeared while being claimed.`); } return row; }; const hasMatchingBaseIdentity = ( row: LockedInputEvent, options: { eventType: string; actorUserId: string | null } ): boolean => row.target === 'API' && row.eventType === options.eventType && row.actorUserId === options.actorUserId; const canAdoptLegacyFailedPayload = ( row: LockedInputEvent, options: { eventType: string; actorUserId: string | null } ): boolean => row.status === 'FAILED' && row.result === null && isLegacyEmptyPayload(row.payload) && hasMatchingBaseIdentity(row, options); const isMatchingIdentity = ( row: LockedInputEvent, options: { eventType: string; actorUserId: string | null; payloadIdentity: ApiInputPayloadIdentity; } ): boolean => hasMatchingBaseIdentity(row, options) && sameJson(row.payload, options.payloadIdentity); const claimInputEvent = async ( db: DatabaseClient, requestId: string, payloadIdentity: ApiInputPayloadIdentity ): Promise => { await db.inputEvent.update({ where: { requestId }, data: { payload: asJson(payloadIdentity), status: 'PROCESSING', result: GamePrisma.DbNull, error: null, attempts: { increment: 1 }, lockedBy: null, leaseUntil: null, processingAt: new Date(), completedAt: null, }, }); }; const markUnexpectedFailure = async ( db: DatabaseClient, options: { requestId: string; eventType: string; actorUserId: string | null; payloadIdentity: ApiInputPayloadIdentity; error: unknown; } ): Promise => { if (!db.$transaction) return; const message = options.error instanceof Error ? options.error.message : 'Unknown API input event error.'; try { await db.$transaction(async (transaction) => { await insertPendingIfAbsent(transaction, options); const row = await lockInputEvent(transaction, options.requestId); const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options); if (!identityMatches || row.status === 'SUCCEEDED' || row.status === 'PROCESSING') { // A retry may have committed while the failed caller was unwinding. A // late failure recorder must never replace its durable success. return; } await transaction.inputEvent.update({ where: { requestId: options.requestId }, data: { payload: asJson(options.payloadIdentity), status: 'FAILED', result: GamePrisma.DbNull, error: message, attempts: { increment: 1 }, lockedBy: null, leaseUntil: null, processingAt: new Date(), completedAt: new Date(), }, }); }); } catch { // Preserve the transaction failure that the caller actually observed. If // the database is unavailable, the prior PENDING/FAILED state (or absence // of a newly rolled-back row) remains safely retryable. } }; export const executeInputEvent = async (options: { db: DatabaseClient; requestId: string; eventType: string; payload: unknown; actorUserId?: string | null; execute(db: DatabaseClient): Promise; }): Promise => { const { db, requestId, eventType, payload, execute } = options; const actorUserId = options.actorUserId ?? null; const payloadIdentity = createApiInputPayloadIdentity(payload); if (!db.$transaction) { return execute(db); } let businessStarted = false; let outcome: InputEventOutcome; try { outcome = await db.$transaction(async (transaction) => { await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity }); const row = await lockInputEvent(transaction, requestId); const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity }); if (row.status === 'SUCCEEDED') { if (!identityMatches) throw new DuplicateInputEventError(requestId); return { kind: 'replayed', value: row.result as T }; } // A visible PROCESSING row was committed by the legacy boundary. It // may still have an active business request and its {} payload cannot // prove identity, so automatic reclaim would risk duplicate writes. if (row.status === 'PROCESSING') { throw new DuplicateInputEventError(requestId); } if (!identityMatches && !canAdoptLegacyFailedPayload(row, { eventType, actorUserId })) { throw new DuplicateInputEventError(requestId); } await claimInputEvent(transaction, requestId, payloadIdentity); const savepointDb = transaction as SavepointDatabaseClient; await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`); businessStarted = true; try { const value = await execute(transaction); const durableResult = canonicalJsonValue(value); await transaction.inputEvent.update({ where: { requestId }, data: { status: 'SUCCEEDED', result: asJson(durableResult), error: null, completedAt: new Date(), }, }); await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`); return { kind: 'executed', value }; } catch (error) { await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`); const message = error instanceof Error ? error.message : 'Unknown API input event error.'; await transaction.inputEvent.update({ where: { requestId }, data: { status: 'FAILED', result: GamePrisma.DbNull, error: message, completedAt: new Date(), }, }); return { kind: 'failed', error }; } }); } catch (error) { if (businessStarted && !(error instanceof DuplicateInputEventError)) { await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error }); } throw error; } if (outcome.kind === 'failed') { throw outcome.error; } return outcome.value; };