// The revocation outbox over kit-data: one global table, every change written against the row's // version, so a claim or a settlement that lost a race to another process writes nothing. import { AppError } from "@conusai-kit/core" import type { DataSource } from "@conusai-kit/data" import { accountSessionRevocations } from "../contract/entities.js" import type { RevocationRecord, RevocationStore } from "../core/ports.js" const isConflict = (error: unknown): boolean => error instanceof AppError && error.status === 409 const isGone = (error: unknown): boolean => error instanceof AppError && error.status === 404 const recordOf = (row: { id: string version: number session: string state: RevocationRecord["state"] pending: string[] delivered: string[] attempts: number nextAttemptAt: string claimedUntil: string | null lastFailure: string | null requestedAt: string completedAt: string | null }): RevocationRecord => ({ id: row.id, version: row.version, session: row.session, state: row.state, pending: row.pending, delivered: row.delivered, attempts: row.attempts, nextAttemptAt: row.nextAttemptAt, claimedUntil: row.claimedUntil, lastFailure: row.lastFailure, requestedAt: row.requestedAt, completedAt: row.completedAt, }) /** The most finished rows one purge removes; the next run removes the rest. */ const PURGE_LIMIT = 200 export function createRevocationStore(data: DataSource): RevocationStore { const table = () => data.global(accountSessionRevocations) return { async record({ session, modules, at, claimedUntil }) { try { return recordOf( await table().insert({ session, state: "pending", pending: [...modules], delivered: [], attempts: 0, nextAttemptAt: at, claimedUntil, lastFailure: null, requestedAt: at, completedAt: null, }), ) } catch (error) { // Unique on the session: a second notification for the same revocation. if (isConflict(error)) return null throw error } }, async due(now, limit) { const page = await table().list({ where: [ { field: "state", op: "eq", value: "pending" }, { field: "nextAttemptAt", op: "range", lte: now }, ], order: [{ field: "nextAttemptAt", direction: "asc" }], limit, }) return page.items.map(recordOf).filter((record) => record.claimedUntil === null || record.claimedUntil <= now) }, async change(record, patch) { const { pending, delivered, ...rest } = patch try { return recordOf( await table().update( record.id, { ...rest, ...(pending ? { pending: [...pending] } : {}), ...(delivered ? { delivered: [...delivered] } : {}), }, record.version, ), ) } catch (error) { if (isConflict(error) || isGone(error)) return null throw error } }, async purge(before) { let removed = 0 for (const state of ["delivered", "lapsed"] as const) { const page = await table().list({ where: [ { field: "state", op: "eq", value: state }, { field: "completedAt", op: "range", lt: before }, ], limit: PURGE_LIMIT, }) for (const row of page.items) { try { await table().delete(row.id) removed += 1 } catch (error) { if (!isGone(error)) throw error } } } return removed }, } }