import { DatabaseSync } from 'node:sqlite' /** * Where one (request, actor) pair stands in the claim protocol (design §11, plan §Phase 7): * * - `claiming` — we mean to hold this request; the create is in flight or has yet to succeed. * - `confirming` — a claim record exists in our repo, and we are waiting to see it come back * through ingestion AND win the tie-break before doing any work (the "claim, * confirm, then work" rule that bounds wasted work to one ingestion cycle). * - `held` — our claim won the fold. This is the only state that gates a dispatch. * - `lost` — somebody else's claim won. Terminal: stop renewing, kill any container. * - `settled` — the request is no longer ours to do (fulfilled, retracted, shelved, or the turn * gave up), or this claim reached the protocol horizon and was retired. Terminal, * and the ordinary end of a claim's life. * * `lost` and `settled` are terminal by design. A claim record is never deleted — `RepoWriter` has * no `deleteRecord` and the store would not adopt a shortened lease anyway (`store.ts`) — so * "release" IS "stop renewing", and the row is the durable record of that decision. */ export type ClaimState = 'claiming' | 'confirming' | 'held' | 'lost' | 'settled' export interface ClaimRow { requestUri: string requestCid: string actorDid: string /** The space the request lives in. One ledger serves every space a daemon polls and `pump` runs * once per space, so a row can only be reconciled against the index it came from: judged against * another space's index its request is simply absent, which reads as "no longer open". Empty only * for a row written before this column existed — `ClaimManager` adopts those on sight. */ spaceUri: string rkey: string /** Which claim record of ours this row describes: `claimRkey(request, generation)`. A claim's life * is bounded by the protocol horizon (`MAX_CLAIM_HORIZON_MS`), so a request that still needs work * a day later is re-claimed under the NEXT generation — a fresh record beside the retired one, * since nothing deletes records. Zero for every row written before this column existed, which is * also the generation those rows are at. */ generation: number /** The first generation this pair may NOT use — the ceiling the walk stops at. Undefined for * every ordinary row, which means the daemon's own `MAX_CLAIM_GENERATIONS`. `reset` raises it, so * an operator's "start this over" resumes the walk ABOVE the spent generations instead of * re-discovering each of them through a create that collides and a read that says why. */ generationCap?: number claimUri?: string claimCid?: string state: ClaimState /** The claim record's OWN `createdAt`. Renewal must rewrite `expiresAt` and nothing else, so the * original value has to survive a restart — recomputing it from the clock would rewrite a second * field and the store would file the whole renewal as a rejected edit (design §4). */ createdAt: string expiresAt?: string /** The claim record's OWN `renewedAt` — the writer timestamp of the version currently in the repo, * not "when we last tried". A restart has to reproduce the current version exactly to know whether * it is in contract, and to swap against it; `lastRenewedAt` below is local bookkeeping. */ renewedAt?: string lastRenewedAt?: string updatedAt: string } interface Row { request_uri: string request_cid: string actor_did: string space_uri: string | null rkey: string generation: number | null generation_cap: number | null claim_uri: string | null claim_cid: string | null state: string | null created_at: string | null expires_at: string | null renewed_at: string | null last_renewed_at: string | null updated_at: string | null } function toRow(row: Row): ClaimRow { return { requestUri: row.request_uri, requestCid: row.request_cid, actorDid: row.actor_did, spaceUri: row.space_uri ?? '', rkey: row.rkey, generation: row.generation ?? 0, ...(row.generation_cap !== null && row.generation_cap !== undefined ? { generationCap: row.generation_cap } : {}), ...(row.claim_uri !== null ? { claimUri: row.claim_uri } : {}), ...(row.claim_cid !== null ? { claimCid: row.claim_cid } : {}), state: (row.state ?? 'claiming') as ClaimState, createdAt: row.created_at ?? '', ...(row.expires_at !== null ? { expiresAt: row.expires_at } : {}), ...(row.renewed_at !== null && row.renewed_at !== undefined ? { renewedAt: row.renewed_at } : {}), ...(row.last_renewed_at !== null ? { lastRenewedAt: row.last_renewed_at } : {}), updatedAt: row.updated_at ?? '', } } export interface ClaimLedgerOptions { now?: () => string } /** * Durable record of every claim this daemon has written, one row per (request, actor). * * Its own database file and table, for the same reason `CheckLedger` has one: claims and turns are * independent workstreams with independent lifecycles, and neither may clobber the other's rows. * * Durability is what lets a daemon that restarts mid-lease RENEW rather than re-claim. The * deterministic rkey already makes a second create collide instead of duplicating (see * `claimRkey`), but a collision is an error to recover from; the row is what turns the restart into * an ordinary renewal, and it carries the one thing the rkey cannot — the claim's original * `createdAt`, which every renewal has to reproduce byte-for-byte. */ export class ClaimLedger { readonly #database: DatabaseSync readonly #now: () => string constructor(path = ':memory:', options: ClaimLedgerOptions = {}) { this.#database = new DatabaseSync(path) this.#database.exec(` CREATE TABLE IF NOT EXISTS claims_ledger ( request_uri TEXT NOT NULL, request_cid TEXT NOT NULL, actor_did TEXT NOT NULL, space_uri TEXT NOT NULL DEFAULT '', rkey TEXT NOT NULL, generation INTEGER NOT NULL DEFAULT 0, generation_cap INTEGER, claim_uri TEXT, claim_cid TEXT, state TEXT, created_at TEXT, expires_at TEXT, renewed_at TEXT, last_renewed_at TEXT, updated_at TEXT, PRIMARY KEY (request_uri, actor_did) ) STRICT; `) const columns = this.#database.prepare('PRAGMA table_info(claims_ledger)').all() as Array<{ name: string }> if (!columns.some((column) => column.name === 'space_uri')) { this.#database.exec(`ALTER TABLE claims_ledger ADD COLUMN space_uri TEXT NOT NULL DEFAULT ''`) } // Nullable: a row written before the column existed describes a claim record that has no // `renewedAt` either, and both mean the same thing — the lease was declared from `createdAt`. if (!columns.some((column) => column.name === 'renewed_at')) { this.#database.exec('ALTER TABLE claims_ledger ADD COLUMN renewed_at TEXT') } // A row written before the column existed describes a first-generation claim — which is what // zero means — so the default needs no backfill. if (!columns.some((column) => column.name === 'generation')) { this.#database.exec('ALTER TABLE claims_ledger ADD COLUMN generation INTEGER NOT NULL DEFAULT 0') } // Nullable, and null is the ordinary state: a row written before the column existed has never // been reset, so its ceiling is the daemon's default. if (!columns.some((column) => column.name === 'generation_cap')) { this.#database.exec('ALTER TABLE claims_ledger ADD COLUMN generation_cap INTEGER') } this.#now = options.now ?? (() => new Date().toISOString()) } get(requestUri: string, actorDid: string): ClaimRow | undefined { const row = this.#database .prepare('SELECT * FROM claims_ledger WHERE request_uri = ? AND actor_did = ?') .get(requestUri, actorDid) as Row | undefined return row ? toRow(row) : undefined } /** Every row, ordered by (request uri, actor did) so a caller's iteration is deterministic. */ all(): ClaimRow[] { const rows = this.#database .prepare('SELECT * FROM claims_ledger ORDER BY request_uri, actor_did') .all() as Row[] return rows.map(toRow) } /** Rows in any of `states`, in the same deterministic order as `all`. */ inState(...states: ClaimState[]): ClaimRow[] { return this.all().filter((row) => states.includes(row.state)) } /** Rows claimed in `spaceUri`, in the same deterministic order as `all`. */ inSpace(spaceUri: string): ClaimRow[] { return this.all().filter((row) => row.spaceUri === spaceUri) } /** Request URIs whose claim this daemon has CONFIRMED — the set the dispatch gate reads. */ heldRequests(): Set { return new Set(this.inState('held').map((row) => row.requestUri)) } /** * Opens (or reopens) a row at `claiming`. Reopening matters: a request whose claim was `lost` or * `settled` can legitimately come round again — the winner crashed and its lease lapsed — and the * row must then start over rather than sit terminal forever. */ markClaiming(key: { requestUri: string requestCid: string actorDid: string spaceUri: string rkey: string generation?: number createdAt: string }): void { const now = this.#now() this.#database .prepare(` INSERT INTO claims_ledger (request_uri, request_cid, actor_did, space_uri, rkey, generation, claim_uri, claim_cid, state, created_at, expires_at, renewed_at, last_renewed_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, 'claiming', ?, NULL, NULL, NULL, ?) ON CONFLICT (request_uri, actor_did) DO UPDATE SET request_cid = excluded.request_cid, space_uri = excluded.space_uri, rkey = excluded.rkey, generation = excluded.generation, claim_uri = NULL, claim_cid = NULL, state = 'claiming', created_at = excluded.created_at, expires_at = NULL, renewed_at = NULL, last_renewed_at = NULL, updated_at = excluded.updated_at `) .run( key.requestUri, key.requestCid, key.actorDid, key.spaceUri, key.rkey, key.generation ?? 0, key.createdAt, now, ) } /** The claim record exists in our repo; we are waiting for ingestion to confirm it won. */ markConfirming( requestUri: string, actorDid: string, claim: { uri: string; cid: string; createdAt: string; expiresAt: string; renewedAt?: string }, ): void { this.#update(requestUri, actorDid, { state: 'confirming', claimUri: claim.uri, claimCid: claim.cid, createdAt: claim.createdAt, expiresAt: claim.expiresAt, // Explicitly cleared when the adopted record carries none, so a row cannot keep a `renewedAt` // from a version that is no longer in the repo and mis-measure its own lease. renewedAt: claim.renewedAt ?? null, }) } /** Our claim won the tie-break in an index we have actually ingested. */ markHeld(requestUri: string, actorDid: string): void { this.#update(requestUri, actorDid, { state: 'held' }) } /** A renewal landed: a new CID for the same record, and a later lease. */ markRenewed( requestUri: string, actorDid: string, renewal: { claimCid: string; expiresAt: string; renewedAt?: string; at: string }, ): void { this.#update(requestUri, actorDid, { claimCid: renewal.claimCid, expiresAt: renewal.expiresAt, renewedAt: renewal.renewedAt ?? null, lastRenewedAt: renewal.at, }) } /** * Fill in the space of a row written before `space_uri` existed. Idempotent, and only ever called * with the space whose index actually contains the request (see `ClaimManager.pump`), so it * cannot mislabel a row. */ adoptSpace(requestUri: string, actorDid: string, spaceUri: string): void { this.#database .prepare( `UPDATE claims_ledger SET space_uri = ?, updated_at = ? WHERE request_uri = ? AND actor_did = ? AND space_uri = ''`, ) .run(spaceUri, this.#now(), requestUri, actorDid) } markLost(requestUri: string, actorDid: string): void { this.#update(requestUri, actorDid, { state: 'lost' }) } /** * Terminal, and the ordinary end of a claim's life. `generation` is written only when the caller * is recording where the generation walk STOPPED — at the cap, so `selectClaimable` can skip the * row rather than have every pump re-walk to the same dead end. */ markSettled(requestUri: string, actorDid: string, options: { generation?: number } = {}): void { this.#update(requestUri, actorDid, { state: 'settled', ...(options.generation !== undefined ? { generation: options.generation } : {}), }) } /** * Operator escape hatch, mirroring `turn reset`: forget where every claim row for a request stands, * so the daemon may claim it again from a clean state. * * It does NOT forget which generations are spent, and that is the whole difference between an * escape hatch and a no-op. A claim record cannot be deleted, so the records this daemon already * wrote at generations 0…n are still there and still unusable — that is why the walk stopped. A * reset that dropped the row would send the next walk back to generation 0 to rediscover each of * them (two PDS round trips apiece) and stop at exactly the same place. So the row keeps its * `generation` and its ceiling moves up by `generations`: the walk resumes at the first generation * nothing was ever written at, and gets that many fresh ones before it stops again. */ reset(requestUri: string, options: { generations: number }): void { this.#database .prepare( `UPDATE claims_ledger SET state = 'settled', claim_uri = NULL, claim_cid = NULL, expires_at = NULL, renewed_at = NULL, last_renewed_at = NULL, generation_cap = generation + ?, updated_at = ? WHERE request_uri = ?`, ) .run(options.generations, this.#now(), requestUri) } close(): void { this.#database.close() } #update( requestUri: string, actorDid: string, // `null` is "clear this column", distinct from `undefined`'s "leave it alone" — a record with no // `renewedAt` has to be recordable as such, not just unmentioned. `generation` is the one // numeric column, and the one that is never cleared: it is `NOT NULL`, and zero already means // "the first generation". fields: Partial< Record< | 'state' | 'claimUri' | 'claimCid' | 'createdAt' | 'expiresAt' | 'renewedAt' | 'lastRenewedAt' | 'generation', string | number | null > >, ): void { const columns: Record = { state: 'state', claimUri: 'claim_uri', claimCid: 'claim_cid', createdAt: 'created_at', expiresAt: 'expires_at', renewedAt: 'renewed_at', lastRenewedAt: 'last_renewed_at', generation: 'generation', } const assignments: string[] = [] const parameters: Array = [] for (const [name, column] of Object.entries(columns)) { const value = fields[name as keyof typeof fields] if (value === undefined) continue assignments.push(`${column} = ?`) parameters.push(value) } assignments.push('updated_at = ?') parameters.push(this.#now()) this.#database .prepare(`UPDATE claims_ledger SET ${assignments.join(', ')} WHERE request_uri = ? AND actor_did = ?`) .run(...parameters, requestUri, actorDid) } }