This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user