124 lines
3.6 KiB
TypeScript
124 lines
3.6 KiB
TypeScript
// 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
|
|
},
|
|
}
|
|
}
|