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 now = options.now ?? new Date(); const leaseExpiredBefore = new Date(now.getTime() - leaseMs); const rows = await db.$queryRaw(GamePrisma.sql` WITH candidates AS ( SELECT "id" FROM "read_model_outbox" WHERE "delivered_at" IS NULL AND "available_at" <= ${now} AND ("locked_at" IS NULL OR "locked_at" < ${leaseExpiredBefore}) ORDER BY "id" LIMIT ${limit} FOR UPDATE SKIP LOCKED ) UPDATE "read_model_outbox" AS outbox SET "attempts" = outbox."attempts" + 1, "locked_at" = ${now}, "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 result = await db.readModelOutbox.updateMany({ where: { id: input.id, lockOwner: input.owner, deliveredAt: null }, data: { deliveredAt: input.deliveredAt ?? new Date(), lockedAt: null, lockOwner: null, lastError: null, }, }); return result.count === 1; }; export const releaseReadModelOutbox = async ( db: ReadModelOutboxDatabase, input: { id: bigint; owner: string; error: unknown; availableAt: Date } ): Promise => { const result = await db.readModelOutbox.updateMany({ where: { id: input.id, lockOwner: input.owner, deliveredAt: null }, data: { availableAt: input.availableAt, lockedAt: null, lockOwner: null, lastError: formatDispatchError(input.error), }, }); return result.count === 1; }; export const dispatchReadModelOutboxBatch = async ( db: ReadModelOutboxDatabase, publish: (payload: ReadModelOutboxPayloadV1, outboxId: bigint) => Promise, options: ReadModelOutboxDispatchOptions ): Promise => { const now = options.now ?? (() => new Date()); 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, now: now(), }); 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, deliveredAt: now() }))) { 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, availableAt: new Date(now().getTime() + 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; };