import { parseReadModelOutboxPayload, type ReadModelOutboxPayloadV1 } from '@sammo-ts/common'; import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; export interface ReadModelOutboxDatabase extends Pick { readModelOutbox: GamePrisma.ReadModelOutboxDelegate; } export interface ClaimedReadModelOutbox { id: bigint; payload: unknown; attempts: number; } type ClaimedRow = { id: bigint; payload: unknown; attempts: number; }; export interface ReadModelOutboxDispatchOptions { owner: string; limit?: number; leaseMs?: number; retryBaseMs?: number; retryMaxMs?: number; now?: () => Date; } export interface ReadModelOutboxDispatchResult { claimed: number; delivered: number; failed: number; } const normalizeLimit = (value: number | undefined): number => Math.min(500, Math.max(1, Math.floor(value ?? 50))); const normalizeDuration = (value: number | undefined, fallback: number): number => Math.max(1, Math.floor(value ?? fallback)); const formatDispatchError = (error: unknown): string => { const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error); return text.replaceAll(/\s+/gu, ' ').slice(0, 1_000); }; const retryDelayMs = (attempts: number, baseMs: number, maxMs: number): number => { const exponent = Math.min(20, Math.max(0, attempts - 1)); return Math.min(maxMs, baseMs * 2 ** exponent); }; export const claimReadModelOutboxBatch = async ( db: ReadModelOutboxDatabase, options: Pick & { now?: Date } ): Promise => { if (!options.owner.trim()) { throw new Error('Read-model outbox owner must not be empty.'); } const limit = normalizeLimit(options.limit); const leaseMs = normalizeDuration(options.leaseMs, 30_000); const nowSql = options.now ? GamePrisma.sql`${options.now}` : GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`; const rows = await db.$queryRaw(GamePrisma.sql` WITH candidates AS ( SELECT "id" FROM "read_model_outbox" WHERE "delivered_at" IS NULL AND "available_at" <= ${nowSql} AND ("locked_at" IS NULL OR "locked_at" < ${nowSql} - ${leaseMs} * INTERVAL '1 millisecond') ORDER BY "id" LIMIT ${limit} FOR UPDATE SKIP LOCKED ) UPDATE "read_model_outbox" AS outbox SET "attempts" = outbox."attempts" + 1, "locked_at" = ${nowSql}, "lock_owner" = ${options.owner}, "last_error" = NULL FROM candidates WHERE outbox."id" = candidates."id" RETURNING outbox."id", outbox."payload", outbox."attempts" `); return rows.map((row) => ({ id: BigInt(row.id), payload: row.payload, attempts: row.attempts })); }; export const markReadModelOutboxDelivered = async ( db: ReadModelOutboxDatabase, input: { id: bigint; owner: string; deliveredAt?: Date } ): Promise => { const deliveredAtSql = input.deliveredAt ? GamePrisma.sql`${input.deliveredAt}` : GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`; return ( (await db.$executeRaw(GamePrisma.sql` UPDATE "read_model_outbox" SET "delivered_at" = ${deliveredAtSql}, "locked_at" = NULL, "lock_owner" = NULL, "last_error" = NULL WHERE "id" = ${input.id} AND "lock_owner" = ${input.owner} AND "delivered_at" IS NULL `)) === 1 ); }; export const releaseReadModelOutbox = async ( db: ReadModelOutboxDatabase, input: { id: bigint; owner: string; error: unknown; availableAt?: Date; availableAfterMs?: number } ): Promise => { const availableAtSql = input.availableAt ? GamePrisma.sql`${input.availableAt}` : GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + ${normalizeDuration(input.availableAfterMs, 1_000)} * INTERVAL '1 millisecond'`; return ( (await db.$executeRaw(GamePrisma.sql` UPDATE "read_model_outbox" SET "available_at" = ${availableAtSql}, "locked_at" = NULL, "lock_owner" = NULL, "last_error" = ${formatDispatchError(input.error)} WHERE "id" = ${input.id} AND "lock_owner" = ${input.owner} AND "delivered_at" IS NULL `)) === 1 ); }; export const dispatchReadModelOutboxBatch = async ( db: ReadModelOutboxDatabase, publish: (payload: ReadModelOutboxPayloadV1, outboxId: bigint) => Promise, options: ReadModelOutboxDispatchOptions ): Promise => { const testNow = options.now; const retryBaseMs = normalizeDuration(options.retryBaseMs, 1_000); const retryMaxMs = Math.max(retryBaseMs, normalizeDuration(options.retryMaxMs, 60_000)); const claimed = await claimReadModelOutboxBatch(db, { owner: options.owner, limit: options.limit, leaseMs: options.leaseMs, ...(testNow ? { now: testNow() } : {}), }); let delivered = 0; let failed = 0; for (const item of claimed) { try { const payload = parseReadModelOutboxPayload(item.payload); if (!payload) { throw new Error(`Read-model outbox ${item.id.toString()} has an invalid payload.`); } await publish(payload, item.id); if ( !(await markReadModelOutboxDelivered(db, { id: item.id, owner: options.owner, ...(testNow ? { deliveredAt: testNow() } : {}), })) ) { throw new Error(`Read-model outbox ${item.id.toString()} lost its delivery lease.`); } delivered += 1; } catch (error) { failed += 1; await releaseReadModelOutbox(db, { id: item.id, owner: options.owner, error, ...(testNow ? { availableAt: new Date(testNow().getTime() + retryDelayMs(item.attempts, retryBaseMs, retryMaxMs)) } : { availableAfterMs: retryDelayMs(item.attempts, retryBaseMs, retryMaxMs) }), }); } } return { claimed: claimed.length, delivered, failed }; }; export const pruneDeliveredReadModelOutbox = async ( db: ReadModelOutboxDatabase, input: { deliveredBefore: Date; limit?: number } ): Promise => { const limit = normalizeLimit(input.limit); const rows = await db.$queryRaw>(GamePrisma.sql` DELETE FROM "read_model_outbox" WHERE "id" IN ( SELECT "id" FROM "read_model_outbox" WHERE "delivered_at" < ${input.deliveredBefore} ORDER BY "id" LIMIT ${limit} ) RETURNING "id" `); return rows.length; };