experimental-a2 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/ai-server.d.ts +1 -1
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +13 -11
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/scheduler-qstash.d.ts +2 -2
  9. package/dist/scheduler-qstash.js +1 -1
  10. package/dist/scheduler-vercel.d.ts +2 -2
  11. package/dist/scheduler-vercel.js +1 -1
  12. package/dist/{server-286j79Mt.js → server-B2XNevQA.js} +123 -53
  13. package/dist/server-B2XNevQA.js.map +1 -0
  14. package/dist/{server-DgXmORIq.d.ts → server-DjPhHnbI.d.ts} +7 -4
  15. package/dist/server-DjPhHnbI.d.ts.map +1 -0
  16. package/dist/server.d.ts +3 -3
  17. package/dist/server.js +1 -1
  18. package/dist/store-N8PXxDAS.js.map +1 -1
  19. package/dist/{store-flRz1OWh.d.ts → store-RJO35BMj.d.ts} +25 -8
  20. package/dist/store-RJO35BMj.d.ts.map +1 -0
  21. package/dist/store-memory.d.ts +1 -1
  22. package/dist/store-memory.d.ts.map +1 -1
  23. package/dist/store-memory.js +79 -19
  24. package/dist/store-memory.js.map +1 -1
  25. package/dist/store-postgres.d.ts +1 -1
  26. package/dist/store-postgres.d.ts.map +1 -1
  27. package/dist/store-postgres.js +230 -101
  28. package/dist/store-postgres.js.map +1 -1
  29. package/dist/{store-redis-core-DEYO8Ryv.js → store-redis-core-DT01r4GZ.js} +167 -29
  30. package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
  31. package/dist/store-redis-http.d.ts +1 -1
  32. package/dist/store-redis-http.js +2 -2
  33. package/dist/store-redis-http.js.map +1 -1
  34. package/dist/store-redis.d.ts +1 -1
  35. package/dist/store-redis.js +2 -2
  36. package/dist/store-redis.js.map +1 -1
  37. package/dist/store-sqlite.d.ts +1 -1
  38. package/dist/store-sqlite.d.ts.map +1 -1
  39. package/dist/store-sqlite.js +103 -19
  40. package/dist/store-sqlite.js.map +1 -1
  41. package/docs/concepts/02-handlers.mdx +4 -0
  42. package/docs/concepts/04-state.mdx +57 -9
  43. package/docs/guides/06-ai-agents.mdx +2 -1
  44. package/docs/reference/01-api.mdx +39 -16
  45. package/package.json +1 -1
  46. package/src/ai-server.ts +27 -10
  47. package/src/server.ts +242 -87
  48. package/src/store-memory.ts +138 -20
  49. package/src/store-postgres.ts +355 -138
  50. package/src/store-redis-core.ts +201 -27
  51. package/src/store-redis-http.ts +1 -1
  52. package/src/store-redis.ts +1 -1
  53. package/src/store-sqlite.ts +191 -34
  54. package/src/store.ts +27 -9
  55. package/dist/server-286j79Mt.js.map +0 -1
  56. package/dist/server-DgXmORIq.d.ts.map +0 -1
  57. package/dist/store-flRz1OWh.d.ts.map +0 -1
  58. package/dist/store-redis-core-DEYO8Ryv.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"store-sqlite.js","names":[],"sources":["../src/store-sqlite.ts"],"sourcesContent":["/**\n * experimental-a2/store-sqlite — sqlite store backend (the dev default, `.a2/dev.db`).\n *\n * Built on `node:sqlite` (Node ≥ 22.13) so it ships with zero\n * dependencies. Implements the A2Store interface; the conformance suite\n * in test/conformance is the executable contract.\n *\n * The spec's `index` column is stored as `idx` (a2-implementation.md\n * §11 — `index` collides with a reserved word) and mapped back at the\n * API boundary. Timestamps are epoch milliseconds written from the\n * injected clock — never SQL `now()` — so tests can time-travel against\n * real storage.\n */\n\nimport { mkdirSync } from 'node:fs'\nimport { dirname, resolve } from 'node:path'\nimport { DatabaseSync } from 'node:sqlite'\nimport { A2Error } from './errors.ts'\nimport { idempotentReplay } from './idempotent-replay.ts'\nimport { decodeReturnedEventIds } from './store-codec.ts'\nimport { pollingStream } from './store-polling.ts'\nimport {\n RANDOM_IDS,\n SYSTEM_CLOCK,\n type A2Store,\n type Clock,\n type Event,\n type EventCause,\n type IdSource,\n type StoredEvent,\n} from './store.ts'\n\nexport type SqliteStoreOptions = {\n /** Database file path. Defaults to `.a2/dev.db`; `:memory:` works. */\n path?: string\n /** Injectable clock — every stored timestamp comes from here. */\n clock?: Clock\n /** Injectable id source for generated event ids. */\n ids?: IdSource\n}\n\nexport type SqliteStore = A2Store & {\n /** Close the underlying database handle. */\n close(): void\n}\n\nconst SCHEMA = `\ncreate table if not exists a2_events (\n session_id text not null,\n idx integer not null,\n event_type text not null,\n payload text not null,\n event_id text not null,\n created_at integer not null,\n cause text,\n lane text,\n lane_ready integer not null default 0,\n processed_at integer,\n processed_by_attempt integer,\n returned_event_ids text,\n first_claimed_at integer,\n last_claimed_at integer,\n attempt_count integer not null default 0,\n failure_count integer not null default 0,\n last_failed_at integer,\n last_failed_attempt integer,\n last_error text,\n failed_at integer,\n claim_holder text,\n claim_expires_at integer,\n primary key (session_id, idx)\n) strict;\n\ncreate unique index if not exists a2_events_event_id on a2_events (event_id);\ncreate index if not exists a2_events_unprocessed\n on a2_events (session_id, idx) where processed_at is null;\ncreate index if not exists a2_events_lane_pending\n on a2_events (session_id, lane, idx) where processed_at is null;\ncreate index if not exists a2_events_dispatch_ready\n on a2_events (session_id, idx)\n where lane_ready = 1 and processed_at is null and failed_at is null;\ncreate index if not exists a2_events_claim_expiry\n on a2_events (session_id, claim_expires_at)\n where lane_ready = 1 and processed_at is null and failed_at is null;\n\ncreate table if not exists a2_snapshots (\n session_id text not null,\n reducer_name text not null,\n up_to_index integer not null,\n state text not null,\n updated_at integer not null,\n primary key (session_id, reducer_name)\n) strict;\n\ncreate table if not exists a2_presence (\n ns text not null,\n participant text not null,\n field text not null,\n value text not null,\n seen integer not null,\n at integer not null,\n expires_at integer not null,\n primary key (ns, participant, field)\n) strict;\n`\n\n/**\n * Switching journal modes takes an exclusive lock, and — unlike normal\n * statements — the switch can return SQLITE_BUSY without consulting the\n * busy handler while several connections race it (instances cold-booting\n * against one file). Bounded synchronous retry; the window is boot-only\n * and tiny. Found by the multi-process torture test.\n */\nconst setWalJournalMode = (db: DatabaseSync): void => {\n for (let attempt = 0; ; attempt += 1) {\n try {\n db.exec('pragma journal_mode = wal')\n return\n } catch (err) {\n const busy = (err as { errcode?: number }).errcode === 5\n if (!busy || attempt >= 100) throw err\n // Synchronous 5ms sleep — sqlite() is a sync constructor.\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)\n }\n }\n}\n\nconst wrap = <T>(fn: () => T): T => {\n try {\n return fn()\n } catch (err) {\n if (err instanceof A2Error || err instanceof TypeError) throw err\n throw new A2Error('STORE_UNAVAILABLE', 'sqlite store operation failed', {\n cause: err,\n })\n }\n}\n\nconst toDate = (ms: number | bigint | null): Date | null =>\n ms === null ? null : new Date(Number(ms))\n\nconst toCause = (json: string | null): EventCause | null => {\n if (json === null) return null\n const value = JSON.parse(json) as {\n index?: unknown\n attempt?: unknown\n batchSize?: unknown\n }\n if (\n !Number.isInteger(value.index) ||\n Number(value.index) < 1 ||\n !Number.isInteger(value.attempt) ||\n Number(value.attempt) < 1\n ) {\n throw new TypeError('stored event has an invalid cause')\n }\n if (\n value.batchSize !== undefined &&\n (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)\n ) {\n throw new TypeError('stored event has an invalid cause')\n }\n return {\n index: Number(value.index),\n attempt: Number(value.attempt),\n ...(value.batchSize === undefined\n ? {}\n : { batchSize: Number(value.batchSize) }),\n }\n}\n\nconst toStored = (row: EventRow): StoredEvent => ({\n id: row.event_id,\n type: row.event_type,\n payload: JSON.parse(row.payload),\n index: Number(row.idx),\n sessionId: row.session_id,\n createdAt: new Date(Number(row.created_at)),\n cause: toCause(row.cause),\n lane: row.lane,\n processedAt: toDate(row.processed_at),\n processedByAttempt:\n row.processed_by_attempt === null ? null : Number(row.processed_by_attempt),\n returnedEventIds:\n row.returned_event_ids === null\n ? null\n : decodeReturnedEventIds(row.returned_event_ids),\n firstClaimedAt: toDate(row.first_claimed_at),\n lastClaimedAt: toDate(row.last_claimed_at),\n attemptCount: Number(row.attempt_count),\n failureCount: Number(row.failure_count),\n lastFailedAt: toDate(row.last_failed_at),\n lastFailedAttempt:\n row.last_failed_attempt === null ? null : Number(row.last_failed_attempt),\n lastError: row.last_error,\n failedAt: toDate(row.failed_at),\n claimHolder: row.claim_holder,\n claimExpiresAt: toDate(row.claim_expires_at),\n})\n\nconst toEvent = (row: EventRow): Event => ({\n id: row.event_id,\n type: row.event_type,\n payload: JSON.parse(row.payload),\n index: Number(row.idx),\n sessionId: row.session_id,\n createdAt: new Date(Number(row.created_at)),\n})\n\ntype EventRow = {\n session_id: string\n idx: number | bigint\n event_type: string\n payload: string\n event_id: string\n created_at: number | bigint\n cause: string | null\n lane: string | null\n processed_at: number | bigint | null\n processed_by_attempt: number | bigint | null\n returned_event_ids: string | null\n first_claimed_at: number | bigint | null\n last_claimed_at: number | bigint | null\n attempt_count: number | bigint\n failure_count: number | bigint\n last_failed_at: number | bigint | null\n last_failed_attempt: number | bigint | null\n last_error: string | null\n failed_at: number | bigint | null\n claim_holder: string | null\n claim_expires_at: number | bigint | null\n}\n\ntype PresenceDbRow = {\n participant: string\n field: string\n value: string\n seen: number | bigint\n at: number | bigint\n expires_at: number | bigint\n}\n\ntype StateReadRow = Partial<EventRow> & {\n row_kind: 'snapshot' | 'event'\n snapshot_index: number | bigint | null\n snapshot_state: string | null\n}\n\ntype AppendStatusRow = {\n max: number | bigint\n has_pending: number | bigint\n}\n\ntype ExistingEventRow = EventRow & {\n session_has_pending: number | bigint\n}\n\nexport function sqlite(options: SqliteStoreOptions = {}): SqliteStore {\n const path = options.path ?? '.a2/dev.db'\n const clock = options.clock ?? SYSTEM_CLOCK\n const generateId = options.ids ?? RANDOM_IDS\n\n if (path !== ':memory:') {\n // The dev-default backend only ever runs outside production, but\n // bundlers can't know that: without the ignore, Turbopack sees a\n // dynamic fs path and traces the entire project into the output.\n mkdirSync(dirname(resolve(/* turbopackIgnore: true */ path)), {\n recursive: true,\n })\n }\n const db = new DatabaseSync(path)\n // busy_timeout before anything that takes locks: concurrent\n // cold-opens (several instances booting at once) race the WAL switch\n // and schema creation.\n db.exec('pragma busy_timeout = 5000')\n setWalJournalMode(db)\n db.exec('pragma synchronous = normal')\n db.exec(SCHEMA)\n\n const insertEvent = db.prepare(\n `insert into a2_events\n (session_id, idx, event_type, payload, event_id, created_at,\n cause, lane, lane_ready, processed_at)\n select ?, ?, ?, ?, ?, ?, ?, ?,\n case\n when ? is null then 1\n when not exists (\n select 1 from a2_events\n where session_id = ? and lane = ? and processed_at is null\n ) then 1\n else 0\n end,\n ?`,\n )\n const promoteLaneHead = db.prepare(\n `update a2_events set lane_ready = 1\n where session_id = ?\n and idx = (\n select idx from a2_events\n where session_id = ? and lane = ? and processed_at is null\n order by idx limit 1\n )`,\n )\n const appendStatus = db.prepare(\n `select coalesce(\n (select max(idx) from a2_events where session_id = ?),\n 0\n ) as max,\n exists(\n select 1 from a2_events\n where session_id = ? and processed_at is null\n ) as has_pending`,\n )\n const upsertPresence = db.prepare(\n `insert into a2_presence (ns, participant, field, value, seen, at, expires_at)\n values (?, ?, ?, ?, ?, ?, ?)\n on conflict (ns, participant, field) do update set\n value = excluded.value,\n seen = excluded.seen,\n at = excluded.at,\n expires_at = excluded.expires_at\n where a2_presence.at <= excluded.at`,\n )\n const deletePresence = db.prepare(\n `delete from a2_presence\n where ns = ? and participant = ? and field = ? and at <= ?`,\n )\n const sweepPresence = db.prepare(\n 'delete from a2_presence where ns = ? and expires_at <= ?',\n )\n const selectPresence = db.prepare(\n `select participant, field, value, seen, at, expires_at\n from a2_presence\n where ns = ? and expires_at > ?\n order by participant, field`,\n )\n const selectByIds = (count: number) =>\n db.prepare(\n `select stored.*,\n exists(\n select 1 from a2_events pending\n where pending.session_id = ?\n and pending.processed_at is null\n ) as session_has_pending\n from a2_events stored\n where stored.event_id in (${Array.from({ length: count }, () => '?').join(', ')})\n order by stored.idx`,\n )\n\n const tx = <T>(fn: () => T): T => {\n db.exec('begin immediate')\n try {\n const result = fn()\n db.exec('commit')\n return result\n } catch (err) {\n try {\n db.exec('rollback')\n } catch {\n // Rollback can fail if the transaction never started; the\n // original error is what matters.\n }\n throw err\n }\n }\n\n return {\n async append(sessionId, events) {\n if (events.length === 0) {\n return wrap(() => {\n const status = appendStatus.get(\n sessionId,\n sessionId,\n ) as AppendStatusRow\n return {\n events: [],\n hasPending: Number(status.has_pending) === 1,\n }\n })\n }\n return wrap(() =>\n tx(() => {\n const supplied = events.filter((e) => e.id !== undefined)\n const suppliedIds = supplied.map((e) => e.id as string)\n if (new Set(suppliedIds).size !== suppliedIds.length) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'batch contains the same event id more than once',\n )\n }\n if (suppliedIds.length > 0) {\n const existing = selectByIds(suppliedIds.length).all(\n sessionId,\n ...suppliedIds,\n ) as unknown as ExistingEventRow[]\n if (existing.length > 0) {\n const foreign = existing.find(\n (row) => row.session_id !== sessionId,\n )\n if (foreign) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n `event id '${foreign.event_id}' already exists in another session`,\n )\n }\n if (existing.length === events.length) {\n return {\n events: idempotentReplay(events, existing.map(toStored)),\n hasPending: Number(existing[0]!.session_has_pending) === 1,\n }\n }\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,\n )\n }\n }\n\n // Attempt-currency fence: a fresh handler append commits only\n // while its causal attempt is still the parent's latest\n // (store.ts `append`). `begin immediate` serializes this read\n // against a concurrent claim.\n for (const e of events) {\n if (!e.cause) continue\n const parent = db\n .prepare(\n 'select attempt_count, failed_at from a2_events where session_id = ? and idx = ?',\n )\n .get(sessionId, e.cause.index) as\n { attempt_count: number | bigint; failed_at: unknown } | undefined\n if (!parent) {\n throw new TypeError(\n `no event at index ${e.cause.index} in session '${sessionId}'`,\n )\n }\n if (\n Number(parent.attempt_count) !== e.cause.attempt ||\n parent.failed_at !== null\n ) {\n throw new A2Error(\n 'SUPERSEDED_ATTEMPT',\n `attempt ${e.cause.attempt} no longer owns event ${e.cause.index} in session '${sessionId}'`,\n )\n }\n }\n\n const status = appendStatus.get(\n sessionId,\n sessionId,\n ) as AppendStatusRow\n const base = Number(status.max)\n const now = clock.now().getTime()\n const inserted: StoredEvent[] = events.map((e, i) => {\n const id = e.id ?? generateId()\n const index = base + 1 + i\n insertEvent.run(\n sessionId,\n index,\n e.type,\n JSON.stringify(e.payload) ?? 'null',\n id,\n now,\n e.cause ? JSON.stringify(e.cause) : null,\n e.lane ?? null,\n e.lane ?? null,\n sessionId,\n e.lane ?? null,\n e.settled ? now : null,\n )\n return {\n id,\n type: e.type,\n payload: structuredClone(e.payload),\n index,\n sessionId,\n createdAt: new Date(now),\n cause: e.cause ? { ...e.cause } : null,\n lane: e.lane ?? null,\n processedAt: e.settled ? new Date(now) : null,\n processedByAttempt: null,\n returnedEventIds: null,\n firstClaimedAt: null,\n lastClaimedAt: null,\n attemptCount: 0,\n failureCount: 0,\n lastFailedAt: null,\n lastFailedAttempt: null,\n lastError: null,\n failedAt: null,\n claimHolder: null,\n claimExpiresAt: null,\n }\n })\n return {\n events: inserted,\n hasPending:\n Number(status.has_pending) === 1 ||\n events.some((event) => event.settled !== true),\n }\n }),\n )\n },\n\n async read(sessionId, opts) {\n return wrap(() => {\n const conditions = ['session_id = ?']\n const params: (string | number)[] = [sessionId]\n if (opts?.afterIndex !== undefined) {\n conditions.push('idx > ?')\n params.push(opts.afterIndex)\n }\n if (opts?.throughIndex !== undefined) {\n conditions.push('idx <= ?')\n params.push(opts.throughIndex)\n }\n const rows = db\n .prepare(\n `select * from a2_events where ${conditions.join(' and ')} order by idx`,\n )\n .all(...params) as unknown as EventRow[]\n return rows.map(toStored)\n })\n },\n\n async claimAvailable({\n sessionId,\n holder,\n ttlMs,\n expiresAtMs,\n excludeIndexes = [],\n }) {\n return wrap(() =>\n tx(() => {\n const now = clock.now().getTime()\n const expiresAt = expiresAtMs ?? now + ttlMs\n const exclusion =\n excludeIndexes.length === 0\n ? ''\n : `and event.idx not in (${excludeIndexes.map(() => '?').join(', ')})`\n const eligible = db\n .prepare(\n `select event.idx\n from a2_events event\n where event.session_id = ?\n and event.processed_at is null\n and event.failed_at is null\n and event.lane_ready = 1\n and (event.claim_expires_at is null or event.claim_expires_at <= ?)\n ${exclusion}\n order by event.idx`,\n )\n .all(sessionId, now, ...excludeIndexes) as unknown as Array<{\n idx: number | bigint\n }>\n if (eligible.length > 0) {\n const update = db.prepare(\n `update a2_events set\n attempt_count = attempt_count + 1,\n first_claimed_at = coalesce(first_claimed_at, ?),\n last_claimed_at = ?,\n claim_holder = ?,\n claim_expires_at = ?\n where session_id = ? and idx = ?\n returning *`,\n )\n const events = eligible.map((row) => {\n const claimed = update.get(\n now,\n now,\n holder,\n expiresAt,\n sessionId,\n row.idx,\n ) as EventRow\n return toStored(claimed)\n })\n return { outcome: 'claimed', events }\n }\n\n const active = db\n .prepare(\n `select min(claim_expires_at) as retry_at\n from a2_events\n where session_id = ?\n and processed_at is null\n and failed_at is null\n and lane_ready = 1\n and claim_expires_at > ?`,\n )\n .get(sessionId, now) as {\n retry_at: number | bigint | null\n }\n return active.retry_at === null\n ? { outcome: 'settled' }\n : { outcome: 'busy', retryAt: new Date(Number(active.retry_at)) }\n }),\n )\n },\n\n async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {\n if (claims.length === 0) return { renewed: [], superseded: [] }\n return wrap(() =>\n tx(() => {\n const now = clock.now().getTime()\n const expiresAt = expiresAtMs ?? now + ttlMs\n const renewed: number[] = []\n const superseded: number[] = []\n const currentAttempt = db.prepare(\n 'select attempt_count from a2_events where session_id = ? and idx = ?',\n )\n const extend = db.prepare(\n `update a2_events set claim_expires_at = ?\n where session_id = ?\n and idx = ?\n and attempt_count = ?\n and processed_at is null\n and failed_at is null\n and claim_holder = ?\n and claim_expires_at > ?`,\n )\n for (const claim of claims) {\n const row = currentAttempt.get(sessionId, claim.index) as\n { attempt_count: number | bigint } | undefined\n if (!row) continue\n if (Number(row.attempt_count) > claim.attempt) {\n superseded.push(claim.index)\n continue\n }\n const updated = extend.run(\n expiresAt,\n sessionId,\n claim.index,\n claim.attempt,\n holder,\n now,\n )\n if (Number(updated.changes) === 1) renewed.push(claim.index)\n }\n return {\n renewed: renewed.toSorted((a, b) => a - b),\n superseded: superseded.toSorted((a, b) => a - b),\n }\n }),\n )\n },\n\n async completeAttempt({ sessionId, index, attempt, events }) {\n return wrap(() =>\n tx(() => {\n const parent = db\n .prepare('select * from a2_events where session_id = ? and idx = ?')\n .get(sessionId, index) as EventRow | undefined\n if (!parent) {\n throw new TypeError(\n `no event at index ${index} in session '${sessionId}'`,\n )\n }\n const ids = events.map((event) => event.id)\n if (new Set(ids).size !== ids.length) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'returned event batch contains the same event id more than once',\n )\n }\n\n if (parent.processed_at !== null) {\n if (Number(parent.processed_by_attempt) !== attempt) {\n return { outcome: 'superseded' }\n }\n const returnedIds =\n parent.returned_event_ids === null\n ? null\n : decodeReturnedEventIds(parent.returned_event_ids)\n if (\n returnedIds === null ||\n returnedIds.length !== ids.length ||\n returnedIds.some((id, offset) => id !== ids[offset])\n ) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'completed attempt does not match the returned event batch',\n )\n }\n const existing =\n ids.length === 0\n ? []\n : (selectByIds(ids.length).all(\n sessionId,\n ...ids,\n ) as unknown as ExistingEventRow[])\n const byId = new Map(existing.map((row) => [row.event_id, row]))\n const ordered = ids.map((id) => byId.get(id))\n if (\n ordered.some(\n (row) =>\n !row ||\n row.session_id !== sessionId ||\n row.cause === null ||\n toCause(row.cause)?.index !== index ||\n toCause(row.cause)?.attempt !== attempt,\n )\n ) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'returned event retry does not match the committed batch',\n )\n }\n return {\n outcome: 'completed',\n events: idempotentReplay(\n events,\n (ordered as ExistingEventRow[]).map(toStored),\n ),\n }\n }\n if (\n Number(parent.attempt_count) !== attempt ||\n parent.claim_holder === null ||\n parent.failed_at !== null\n ) {\n return { outcome: 'superseded' }\n }\n\n if (\n ids.length > 0 &&\n (selectByIds(ids.length).all(sessionId, ...ids) as unknown[])\n .length > 0\n ) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'returned event batch contains an already-appended event id',\n )\n }\n\n const status = appendStatus.get(\n sessionId,\n sessionId,\n ) as AppendStatusRow\n const now = clock.now().getTime()\n db.prepare(\n `update a2_events set\n processed_at = ?,\n processed_by_attempt = ?,\n returned_event_ids = ?,\n lane_ready = 0,\n claim_holder = null,\n claim_expires_at = null\n where session_id = ? and idx = ?`,\n ).run(now, attempt, JSON.stringify(ids), sessionId, index)\n if (parent.lane !== null) {\n promoteLaneHead.run(sessionId, sessionId, parent.lane)\n }\n const inserted = events.map((event, offset) => {\n const id = event.id\n const childIndex = Number(status.max) + offset + 1\n const cause = { index, attempt }\n insertEvent.run(\n sessionId,\n childIndex,\n event.type,\n JSON.stringify(event.payload) ?? 'null',\n id,\n now,\n JSON.stringify(cause),\n event.lane ?? null,\n event.lane ?? null,\n sessionId,\n event.lane ?? null,\n event.settled ? now : null,\n )\n return toStored(\n db\n .prepare(\n 'select * from a2_events where session_id = ? and idx = ?',\n )\n .get(sessionId, childIndex) as EventRow,\n )\n })\n return { outcome: 'completed', events: inserted }\n }),\n )\n },\n\n async failAttempt({ sessionId, index, attempt, error, maxFailures }) {\n return wrap(() =>\n tx(() => {\n const current = db\n .prepare('select * from a2_events where session_id = ? and idx = ?')\n .get(sessionId, index) as EventRow | undefined\n if (!current) {\n throw new TypeError(\n `no event at index ${index} in session '${sessionId}'`,\n )\n }\n const failureCount = Number(current.failure_count)\n if (\n current.processed_at !== null ||\n Number(current.attempt_count) !== attempt\n ) {\n return { outcome: 'superseded', failureCount }\n }\n if (current.failed_at !== null) {\n return { outcome: 'dead_lettered', failureCount }\n }\n if (\n current.claim_holder === null &&\n current.last_failed_attempt !== null &&\n Number(current.last_failed_attempt) === attempt\n ) {\n return { outcome: 'failed', failureCount }\n }\n if (current.claim_holder === null) {\n return { outcome: 'superseded', failureCount }\n }\n\n const now = clock.now().getTime()\n const nextFailureCount = failureCount + 1\n const deadLettered = nextFailureCount >= maxFailures\n db.prepare(\n `update a2_events set\n failure_count = ?,\n last_error = ?,\n last_failed_at = ?,\n last_failed_attempt = ?,\n failed_at = ?,\n claim_holder = null,\n claim_expires_at = null\n where session_id = ? and idx = ?`,\n ).run(\n nextFailureCount,\n error,\n now,\n attempt,\n deadLettered ? now : null,\n sessionId,\n index,\n )\n return {\n outcome: deadLettered ? 'dead_lettered' : 'failed',\n failureCount: nextFailureCount,\n }\n }),\n )\n },\n\n async readState(sessionId, reducerName) {\n return wrap(() => {\n const rows = db\n .prepare(\n `with snapshot as materialized (\n select up_to_index, state\n from a2_snapshots\n where session_id = ? and reducer_name = ?\n )\n select 0 as row_order, 'snapshot' as row_kind,\n snapshot.up_to_index as snapshot_index,\n snapshot.state as snapshot_state,\n null as session_id, null as idx, null as event_type,\n null as payload, null as event_id, null as created_at,\n null as cause, null as lane, null as lane_ready,\n null as processed_at,\n null as processed_by_attempt, null as returned_event_ids,\n null as first_claimed_at,\n null as last_claimed_at, null as attempt_count,\n null as failure_count, null as last_failed_at,\n null as last_failed_attempt, null as last_error,\n null as failed_at, null as claim_holder,\n null as claim_expires_at\n from snapshot\n union all\n select 1 as row_order, 'event' as row_kind,\n null as snapshot_index, null as snapshot_state,\n event.*\n from a2_events as event\n where event.session_id = ?\n and event.idx > coalesce((select up_to_index from snapshot), 0)\n order by row_order, idx`,\n )\n .all(sessionId, reducerName, sessionId) as unknown as StateReadRow[]\n const snapshot = rows.find((row) => row.row_kind === 'snapshot')\n return {\n snapshot: snapshot\n ? {\n index: Number(snapshot.snapshot_index),\n state: JSON.parse(snapshot.snapshot_state!),\n }\n : null,\n events: rows\n .filter(\n (row): row is StateReadRow & EventRow => row.row_kind === 'event',\n )\n .map(toEvent),\n }\n })\n },\n\n async putSnapshot(sessionId, reducerName, index, state) {\n wrap(() => {\n // Guarded upsert: a slower concurrent writer must never clobber\n // a further-along snapshot (spec §3).\n db.prepare(\n `insert into a2_snapshots (session_id, reducer_name, up_to_index, state, updated_at)\n values (?, ?, ?, ?, ?)\n on conflict (session_id, reducer_name) do update set\n up_to_index = excluded.up_to_index,\n state = excluded.state,\n updated_at = excluded.updated_at\n where excluded.up_to_index > a2_snapshots.up_to_index`,\n ).run(\n sessionId,\n reducerName,\n index,\n JSON.stringify(state) ?? 'null',\n clock.now().getTime(),\n )\n })\n },\n\n presence: {\n async set(ns, participant, values, meta) {\n const entries = Object.entries(values)\n if (entries.length === 0) return\n wrap(() =>\n tx(() => {\n const atMs = meta.at.getTime()\n // Expiry anchors on the storage clock — the sender's `at`\n // orders writes but never anchors their lifetime.\n const expiresAtMs = clock.now().getTime() + meta.ttlMs\n for (const [field, value] of entries) {\n // Field-wise LWW: a stored row with a strictly newer `at`\n // survives; ties go to the incoming write (memory parity).\n if (value === null) {\n deletePresence.run(ns, participant, field, atMs)\n } else {\n upsertPresence.run(\n ns,\n participant,\n field,\n JSON.stringify(value) ?? 'null',\n meta.seen,\n atMs,\n expiresAtMs,\n )\n }\n }\n }),\n )\n },\n\n async read(ns) {\n return wrap(() => {\n const now = clock.now().getTime()\n // The sweep is opportunistic cleanup; the select's filter\n // alone keeps expired rows out of this result.\n sweepPresence.run(ns, now)\n const rows = selectPresence.all(ns, now) as unknown as PresenceDbRow[]\n return rows.map((row) => ({\n participant: row.participant,\n field: row.field,\n value: JSON.parse(row.value) as unknown,\n seen: Number(row.seen),\n at: new Date(Number(row.at)),\n expiresAt: new Date(Number(row.expires_at)),\n }))\n })\n },\n },\n\n stream(sessionId, opts) {\n const readAfter = (afterIndex: number): StoredEvent[] =>\n wrap(() => {\n const rows = db\n .prepare(\n 'select * from a2_events where session_id = ? and idx > ? order by idx',\n )\n .all(sessionId, afterIndex) as unknown as EventRow[]\n return rows.map(toStored)\n })\n return pollingStream(\n readAfter,\n opts?.startAfter !== undefined ? { startAfter: opts.startAfter } : {},\n )\n },\n\n close() {\n db.close()\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AA8CA,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmEf,MAAM,qBAAqB,OAA2B;CACpD,KAAK,IAAI,UAAU,IAAK,WAAW,GACjC,IAAI;EACF,GAAG,KAAK,2BAA2B;EACnC;CACF,SAAS,KAAK;EAEZ,IAAI,EADU,IAA6B,YAAY,MAC1C,WAAW,KAAK,MAAM;EAEnC,QAAQ,KAAK,IAAI,WAAW,IAAI,kBAAkB,CAAC,CAAC,GAAG,GAAG,GAAG,CAAC;CAChE;AAEJ;AAEA,MAAM,QAAW,OAAmB;CAClC,IAAI;EACF,OAAO,GAAG;CACZ,SAAS,KAAK;EACZ,IAAI,eAAe,WAAW,eAAe,WAAW,MAAM;EAC9D,MAAM,IAAI,QAAQ,qBAAqB,iCAAiC,EACtE,OAAO,IACT,CAAC;CACH;AACF;AAEA,MAAM,UAAU,OACd,OAAO,OAAO,OAAO,IAAI,KAAK,OAAO,EAAE,CAAC;AAE1C,MAAM,WAAW,SAA2C;CAC1D,IAAI,SAAS,MAAM,OAAO;CAC1B,MAAM,QAAQ,KAAK,MAAM,IAAI;CAK7B,IACE,CAAC,OAAO,UAAU,MAAM,KAAK,KAC7B,OAAO,MAAM,KAAK,IAAI,KACtB,CAAC,OAAO,UAAU,MAAM,OAAO,KAC/B,OAAO,MAAM,OAAO,IAAI,GAExB,MAAM,IAAI,UAAU,mCAAmC;CAEzD,IACE,MAAM,cAAc,KAAA,MACnB,CAAC,OAAO,UAAU,MAAM,SAAS,KAAK,OAAO,MAAM,SAAS,IAAI,IAEjE,MAAM,IAAI,UAAU,mCAAmC;CAEzD,OAAO;EACL,OAAO,OAAO,MAAM,KAAK;EACzB,SAAS,OAAO,MAAM,OAAO;EAC7B,GAAI,MAAM,cAAc,KAAA,IACpB,CAAC,IACD,EAAE,WAAW,OAAO,MAAM,SAAS,EAAE;CAC3C;AACF;AAEA,MAAM,YAAY,SAAgC;CAChD,IAAI,IAAI;CACR,MAAM,IAAI;CACV,SAAS,KAAK,MAAM,IAAI,OAAO;CAC/B,OAAO,OAAO,IAAI,GAAG;CACrB,WAAW,IAAI;CACf,WAAW,IAAI,KAAK,OAAO,IAAI,UAAU,CAAC;CAC1C,OAAO,QAAQ,IAAI,KAAK;CACxB,MAAM,IAAI;CACV,aAAa,OAAO,IAAI,YAAY;CACpC,oBACE,IAAI,yBAAyB,OAAO,OAAO,OAAO,IAAI,oBAAoB;CAC5E,kBACE,IAAI,uBAAuB,OACvB,OACA,uBAAuB,IAAI,kBAAkB;CACnD,gBAAgB,OAAO,IAAI,gBAAgB;CAC3C,eAAe,OAAO,IAAI,eAAe;CACzC,cAAc,OAAO,IAAI,aAAa;CACtC,cAAc,OAAO,IAAI,aAAa;CACtC,cAAc,OAAO,IAAI,cAAc;CACvC,mBACE,IAAI,wBAAwB,OAAO,OAAO,OAAO,IAAI,mBAAmB;CAC1E,WAAW,IAAI;CACf,UAAU,OAAO,IAAI,SAAS;CAC9B,aAAa,IAAI;CACjB,gBAAgB,OAAO,IAAI,gBAAgB;AAC7C;AAEA,MAAM,WAAW,SAA0B;CACzC,IAAI,IAAI;CACR,MAAM,IAAI;CACV,SAAS,KAAK,MAAM,IAAI,OAAO;CAC/B,OAAO,OAAO,IAAI,GAAG;CACrB,WAAW,IAAI;CACf,WAAW,IAAI,KAAK,OAAO,IAAI,UAAU,CAAC;AAC5C;AAkDA,SAAgB,OAAO,UAA8B,CAAC,GAAgB;CACpE,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,aAAa,QAAQ,OAAO;CAElC,IAAI,SAAS,YAIX,UAAU,QAAQ;;EAAoC;CAAI,CAAC,GAAG,EAC5D,WAAW,KACb,CAAC;CAEH,MAAM,KAAK,IAAI,aAAa,IAAI;CAIhC,GAAG,KAAK,4BAA4B;CACpC,kBAAkB,EAAE;CACpB,GAAG,KAAK,6BAA6B;CACrC,GAAG,KAAK,MAAM;CAEd,MAAM,cAAc,GAAG,QACrB;;;;;;;;;;;;cAaF;CACA,MAAM,kBAAkB,GAAG,QACzB;;;;;;UAOF;CACA,MAAM,eAAe,GAAG,QACtB;;;;;;;6BAQF;CACA,MAAM,iBAAiB,GAAG,QACxB;;;;;;;yCAQF;CACA,MAAM,iBAAiB,GAAG,QACxB;iEAEF;CACA,MAAM,gBAAgB,GAAG,QACvB,0DACF;CACA,MAAM,iBAAiB,GAAG,QACxB;;;kCAIF;CACA,MAAM,eAAe,UACnB,GAAG,QACD;;;;;;;oCAO8B,MAAM,KAAK,EAAE,QAAQ,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE;4BAEpF;CAEF,MAAM,MAAS,OAAmB;EAChC,GAAG,KAAK,iBAAiB;EACzB,IAAI;GACF,MAAM,SAAS,GAAG;GAClB,GAAG,KAAK,QAAQ;GAChB,OAAO;EACT,SAAS,KAAK;GACZ,IAAI;IACF,GAAG,KAAK,UAAU;GACpB,QAAQ,CAGR;GACA,MAAM;EACR;CACF;CAEA,OAAO;EACL,MAAM,OAAO,WAAW,QAAQ;GAC9B,IAAI,OAAO,WAAW,GACpB,OAAO,WAAW;IAChB,MAAM,SAAS,aAAa,IAC1B,WACA,SACF;IACA,OAAO;KACL,QAAQ,CAAC;KACT,YAAY,OAAO,OAAO,WAAW,MAAM;IAC7C;GACF,CAAC;GAEH,OAAO,WACL,SAAS;IAEP,MAAM,cADW,OAAO,QAAQ,MAAM,EAAE,OAAO,KAAA,CACpB,CAAC,CAAC,KAAK,MAAM,EAAE,EAAY;IACtD,IAAI,IAAI,IAAI,WAAW,CAAC,CAAC,SAAS,YAAY,QAC5C,MAAM,IAAI,QACR,2BACA,iDACF;IAEF,IAAI,YAAY,SAAS,GAAG;KAC1B,MAAM,WAAW,YAAY,YAAY,MAAM,CAAC,CAAC,IAC/C,WACA,GAAG,WACL;KACA,IAAI,SAAS,SAAS,GAAG;MACvB,MAAM,UAAU,SAAS,MACtB,QAAQ,IAAI,eAAe,SAC9B;MACA,IAAI,SACF,MAAM,IAAI,QACR,2BACA,aAAa,QAAQ,SAAS,oCAChC;MAEF,IAAI,SAAS,WAAW,OAAO,QAC7B,OAAO;OACL,QAAQ,iBAAiB,QAAQ,SAAS,IAAI,QAAQ,CAAC;OACvD,YAAY,OAAO,SAAS,EAAE,CAAE,mBAAmB,MAAM;MAC3D;MAEF,MAAM,IAAI,QACR,2BACA,eAAe,SAAS,OAAO,wBAAwB,OAAO,SAAS,SAAS,OAAO,cACzF;KACF;IACF;IAMA,KAAK,MAAM,KAAK,QAAQ;KACtB,IAAI,CAAC,EAAE,OAAO;KACd,MAAM,SAAS,GACZ,QACC,iFACF,CAAC,CACA,IAAI,WAAW,EAAE,MAAM,KAAK;KAE/B,IAAI,CAAC,QACH,MAAM,IAAI,UACR,qBAAqB,EAAE,MAAM,MAAM,eAAe,UAAU,EAC9D;KAEF,IACE,OAAO,OAAO,aAAa,MAAM,EAAE,MAAM,WACzC,OAAO,cAAc,MAErB,MAAM,IAAI,QACR,sBACA,WAAW,EAAE,MAAM,QAAQ,wBAAwB,EAAE,MAAM,MAAM,eAAe,UAAU,EAC5F;IAEJ;IAEA,MAAM,SAAS,aAAa,IAC1B,WACA,SACF;IACA,MAAM,OAAO,OAAO,OAAO,GAAG;IAC9B,MAAM,MAAM,MAAM,IAAI,CAAC,CAAC,QAAQ;IA0ChC,OAAO;KACL,QA1C8B,OAAO,KAAK,GAAG,MAAM;MACnD,MAAM,KAAK,EAAE,MAAM,WAAW;MAC9B,MAAM,QAAQ,OAAO,IAAI;MACzB,YAAY,IACV,WACA,OACA,EAAE,MACF,KAAK,UAAU,EAAE,OAAO,KAAK,QAC7B,IACA,KACA,EAAE,QAAQ,KAAK,UAAU,EAAE,KAAK,IAAI,MACpC,EAAE,QAAQ,MACV,EAAE,QAAQ,MACV,WACA,EAAE,QAAQ,MACV,EAAE,UAAU,MAAM,IACpB;MACA,OAAO;OACL;OACA,MAAM,EAAE;OACR,SAAS,gBAAgB,EAAE,OAAO;OAClC;OACA;OACA,WAAW,IAAI,KAAK,GAAG;OACvB,OAAO,EAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,IAAI;OAClC,MAAM,EAAE,QAAQ;OAChB,aAAa,EAAE,UAAU,IAAI,KAAK,GAAG,IAAI;OACzC,oBAAoB;OACpB,kBAAkB;OAClB,gBAAgB;OAChB,eAAe;OACf,cAAc;OACd,cAAc;OACd,cAAc;OACd,mBAAmB;OACnB,WAAW;OACX,UAAU;OACV,aAAa;OACb,gBAAgB;MAClB;KACF,CAEiB;KACf,YACE,OAAO,OAAO,WAAW,MAAM,KAC/B,OAAO,MAAM,UAAU,MAAM,YAAY,IAAI;IACjD;GACF,CAAC,CACH;EACF;EAEA,MAAM,KAAK,WAAW,MAAM;GAC1B,OAAO,WAAW;IAChB,MAAM,aAAa,CAAC,gBAAgB;IACpC,MAAM,SAA8B,CAAC,SAAS;IAC9C,IAAI,MAAM,eAAe,KAAA,GAAW;KAClC,WAAW,KAAK,SAAS;KACzB,OAAO,KAAK,KAAK,UAAU;IAC7B;IACA,IAAI,MAAM,iBAAiB,KAAA,GAAW;KACpC,WAAW,KAAK,UAAU;KAC1B,OAAO,KAAK,KAAK,YAAY;IAC/B;IAMA,OALa,GACV,QACC,iCAAiC,WAAW,KAAK,OAAO,EAAE,cAC5D,CAAC,CACA,IAAI,GAAG,MACA,CAAC,CAAC,IAAI,QAAQ;GAC1B,CAAC;EACH;EAEA,MAAM,eAAe,EACnB,WACA,QACA,OACA,aACA,iBAAiB,CAAC,KACjB;GACD,OAAO,WACL,SAAS;IACP,MAAM,MAAM,MAAM,IAAI,CAAC,CAAC,QAAQ;IAChC,MAAM,YAAY,eAAe,MAAM;IACvC,MAAM,YACJ,eAAe,WAAW,IACtB,KACA,yBAAyB,eAAe,UAAU,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE;IACxE,MAAM,WAAW,GACd,QACC;;;;;;;oBAOM,UAAU;mCAElB,CAAC,CACA,IAAI,WAAW,KAAK,GAAG,cAAc;IAGxC,IAAI,SAAS,SAAS,GAAG;KACvB,MAAM,SAAS,GAAG,QAChB;;;;;;;2BAQF;KAYA,OAAO;MAAE,SAAS;MAAW,QAXd,SAAS,KAAK,QAAQ;OACnC,MAAM,UAAU,OAAO,IACrB,KACA,KACA,QACA,WACA,WACA,IAAI,GACN;OACA,OAAO,SAAS,OAAO;MACzB,CACkC;KAAE;IACtC;IAEA,MAAM,SAAS,GACZ,QACC;;;;;;2CAOF,CAAC,CACA,IAAI,WAAW,GAAG;IAGrB,OAAO,OAAO,aAAa,OACvB,EAAE,SAAS,UAAU,IACrB;KAAE,SAAS;KAAQ,SAAS,IAAI,KAAK,OAAO,OAAO,QAAQ,CAAC;IAAE;GACpE,CAAC,CACH;EACF;EAEA,MAAM,YAAY,EAAE,WAAW,QAAQ,QAAQ,OAAO,eAAe;GACnE,IAAI,OAAO,WAAW,GAAG,OAAO;IAAE,SAAS,CAAC;IAAG,YAAY,CAAC;GAAE;GAC9D,OAAO,WACL,SAAS;IACP,MAAM,MAAM,MAAM,IAAI,CAAC,CAAC,QAAQ;IAChC,MAAM,YAAY,eAAe,MAAM;IACvC,MAAM,UAAoB,CAAC;IAC3B,MAAM,aAAuB,CAAC;IAC9B,MAAM,iBAAiB,GAAG,QACxB,sEACF;IACA,MAAM,SAAS,GAAG,QAChB;;;;;;;yCAQF;IACA,KAAK,MAAM,SAAS,QAAQ;KAC1B,MAAM,MAAM,eAAe,IAAI,WAAW,MAAM,KAAK;KAErD,IAAI,CAAC,KAAK;KACV,IAAI,OAAO,IAAI,aAAa,IAAI,MAAM,SAAS;MAC7C,WAAW,KAAK,MAAM,KAAK;MAC3B;KACF;KACA,MAAM,UAAU,OAAO,IACrB,WACA,WACA,MAAM,OACN,MAAM,SACN,QACA,GACF;KACA,IAAI,OAAO,QAAQ,OAAO,MAAM,GAAG,QAAQ,KAAK,MAAM,KAAK;IAC7D;IACA,OAAO;KACL,SAAS,QAAQ,UAAU,GAAG,MAAM,IAAI,CAAC;KACzC,YAAY,WAAW,UAAU,GAAG,MAAM,IAAI,CAAC;IACjD;GACF,CAAC,CACH;EACF;EAEA,MAAM,gBAAgB,EAAE,WAAW,OAAO,SAAS,UAAU;GAC3D,OAAO,WACL,SAAS;IACP,MAAM,SAAS,GACZ,QAAQ,0DAA0D,CAAC,CACnE,IAAI,WAAW,KAAK;IACvB,IAAI,CAAC,QACH,MAAM,IAAI,UACR,qBAAqB,MAAM,eAAe,UAAU,EACtD;IAEF,MAAM,MAAM,OAAO,KAAK,UAAU,MAAM,EAAE;IAC1C,IAAI,IAAI,IAAI,GAAG,CAAC,CAAC,SAAS,IAAI,QAC5B,MAAM,IAAI,QACR,2BACA,gEACF;IAGF,IAAI,OAAO,iBAAiB,MAAM;KAChC,IAAI,OAAO,OAAO,oBAAoB,MAAM,SAC1C,OAAO,EAAE,SAAS,aAAa;KAEjC,MAAM,cACJ,OAAO,uBAAuB,OAC1B,OACA,uBAAuB,OAAO,kBAAkB;KACtD,IACE,gBAAgB,QAChB,YAAY,WAAW,IAAI,UAC3B,YAAY,MAAM,IAAI,WAAW,OAAO,IAAI,OAAO,GAEnD,MAAM,IAAI,QACR,2BACA,2DACF;KAEF,MAAM,WACJ,IAAI,WAAW,IACX,CAAC,IACA,YAAY,IAAI,MAAM,CAAC,CAAC,IACvB,WACA,GAAG,GACL;KACN,MAAM,OAAO,IAAI,IAAI,SAAS,KAAK,QAAQ,CAAC,IAAI,UAAU,GAAG,CAAC,CAAC;KAC/D,MAAM,UAAU,IAAI,KAAK,OAAO,KAAK,IAAI,EAAE,CAAC;KAC5C,IACE,QAAQ,MACL,QACC,CAAC,OACD,IAAI,eAAe,aACnB,IAAI,UAAU,QACd,QAAQ,IAAI,KAAK,CAAC,EAAE,UAAU,SAC9B,QAAQ,IAAI,KAAK,CAAC,EAAE,YAAY,OACpC,GAEA,MAAM,IAAI,QACR,2BACA,yDACF;KAEF,OAAO;MACL,SAAS;MACT,QAAQ,iBACN,QACC,QAA+B,IAAI,QAAQ,CAC9C;KACF;IACF;IACA,IACE,OAAO,OAAO,aAAa,MAAM,WACjC,OAAO,iBAAiB,QACxB,OAAO,cAAc,MAErB,OAAO,EAAE,SAAS,aAAa;IAGjC,IACE,IAAI,SAAS,KACZ,YAAY,IAAI,MAAM,CAAC,CAAC,IAAI,WAAW,GAAG,GAAG,CAAC,CAC5C,SAAS,GAEZ,MAAM,IAAI,QACR,2BACA,4DACF;IAGF,MAAM,SAAS,aAAa,IAC1B,WACA,SACF;IACA,MAAM,MAAM,MAAM,IAAI,CAAC,CAAC,QAAQ;IAChC,GAAG,QACD;;;;;;;8CAQF,CAAC,CAAC,IAAI,KAAK,SAAS,KAAK,UAAU,GAAG,GAAG,WAAW,KAAK;IACzD,IAAI,OAAO,SAAS,MAClB,gBAAgB,IAAI,WAAW,WAAW,OAAO,IAAI;IA4BvD,OAAO;KAAE,SAAS;KAAa,QA1Bd,OAAO,KAAK,OAAO,WAAW;MAC7C,MAAM,KAAK,MAAM;MACjB,MAAM,aAAa,OAAO,OAAO,GAAG,IAAI,SAAS;MACjD,MAAM,QAAQ;OAAE;OAAO;MAAQ;MAC/B,YAAY,IACV,WACA,YACA,MAAM,MACN,KAAK,UAAU,MAAM,OAAO,KAAK,QACjC,IACA,KACA,KAAK,UAAU,KAAK,GACpB,MAAM,QAAQ,MACd,MAAM,QAAQ,MACd,WACA,MAAM,QAAQ,MACd,MAAM,UAAU,MAAM,IACxB;MACA,OAAO,SACL,GACG,QACC,0DACF,CAAC,CACA,IAAI,WAAW,UAAU,CAC9B;KACF,CAC8C;IAAE;GAClD,CAAC,CACH;EACF;EAEA,MAAM,YAAY,EAAE,WAAW,OAAO,SAAS,OAAO,eAAe;GACnE,OAAO,WACL,SAAS;IACP,MAAM,UAAU,GACb,QAAQ,0DAA0D,CAAC,CACnE,IAAI,WAAW,KAAK;IACvB,IAAI,CAAC,SACH,MAAM,IAAI,UACR,qBAAqB,MAAM,eAAe,UAAU,EACtD;IAEF,MAAM,eAAe,OAAO,QAAQ,aAAa;IACjD,IACE,QAAQ,iBAAiB,QACzB,OAAO,QAAQ,aAAa,MAAM,SAElC,OAAO;KAAE,SAAS;KAAc;IAAa;IAE/C,IAAI,QAAQ,cAAc,MACxB,OAAO;KAAE,SAAS;KAAiB;IAAa;IAElD,IACE,QAAQ,iBAAiB,QACzB,QAAQ,wBAAwB,QAChC,OAAO,QAAQ,mBAAmB,MAAM,SAExC,OAAO;KAAE,SAAS;KAAU;IAAa;IAE3C,IAAI,QAAQ,iBAAiB,MAC3B,OAAO;KAAE,SAAS;KAAc;IAAa;IAG/C,MAAM,MAAM,MAAM,IAAI,CAAC,CAAC,QAAQ;IAChC,MAAM,mBAAmB,eAAe;IACxC,MAAM,eAAe,oBAAoB;IACzC,GAAG,QACD;;;;;;;;8CASF,CAAC,CAAC,IACA,kBACA,OACA,KACA,SACA,eAAe,MAAM,MACrB,WACA,KACF;IACA,OAAO;KACL,SAAS,eAAe,kBAAkB;KAC1C,cAAc;IAChB;GACF,CAAC,CACH;EACF;EAEA,MAAM,UAAU,WAAW,aAAa;GACtC,OAAO,WAAW;IAChB,MAAM,OAAO,GACV,QACC;;;;;;;;;;;;;;;;;;;;;;;;;;;qCA4BF,CAAC,CACA,IAAI,WAAW,aAAa,SAAS;IACxC,MAAM,WAAW,KAAK,MAAM,QAAQ,IAAI,aAAa,UAAU;IAC/D,OAAO;KACL,UAAU,WACN;MACE,OAAO,OAAO,SAAS,cAAc;MACrC,OAAO,KAAK,MAAM,SAAS,cAAe;KAC5C,IACA;KACJ,QAAQ,KACL,QACE,QAAwC,IAAI,aAAa,OAC5D,CAAC,CACA,IAAI,OAAO;IAChB;GACF,CAAC;EACH;EAEA,MAAM,YAAY,WAAW,aAAa,OAAO,OAAO;GACtD,WAAW;IAGT,GAAG,QACD;;;;;;iEAOF,CAAC,CAAC,IACA,WACA,aACA,OACA,KAAK,UAAU,KAAK,KAAK,QACzB,MAAM,IAAI,CAAC,CAAC,QAAQ,CACtB;GACF,CAAC;EACH;EAEA,UAAU;GACR,MAAM,IAAI,IAAI,aAAa,QAAQ,MAAM;IACvC,MAAM,UAAU,OAAO,QAAQ,MAAM;IACrC,IAAI,QAAQ,WAAW,GAAG;IAC1B,WACE,SAAS;KACP,MAAM,OAAO,KAAK,GAAG,QAAQ;KAG7B,MAAM,cAAc,MAAM,IAAI,CAAC,CAAC,QAAQ,IAAI,KAAK;KACjD,KAAK,MAAM,CAAC,OAAO,UAAU,SAG3B,IAAI,UAAU,MACZ,eAAe,IAAI,IAAI,aAAa,OAAO,IAAI;UAE/C,eAAe,IACb,IACA,aACA,OACA,KAAK,UAAU,KAAK,KAAK,QACzB,KAAK,MACL,MACA,WACF;IAGN,CAAC,CACH;GACF;GAEA,MAAM,KAAK,IAAI;IACb,OAAO,WAAW;KAChB,MAAM,MAAM,MAAM,IAAI,CAAC,CAAC,QAAQ;KAGhC,cAAc,IAAI,IAAI,GAAG;KAEzB,OADa,eAAe,IAAI,IAAI,GAC1B,CAAC,CAAC,KAAK,SAAS;MACxB,aAAa,IAAI;MACjB,OAAO,IAAI;MACX,OAAO,KAAK,MAAM,IAAI,KAAK;MAC3B,MAAM,OAAO,IAAI,IAAI;MACrB,IAAI,IAAI,KAAK,OAAO,IAAI,EAAE,CAAC;MAC3B,WAAW,IAAI,KAAK,OAAO,IAAI,UAAU,CAAC;KAC5C,EAAE;IACJ,CAAC;GACH;EACF;EAEA,OAAO,WAAW,MAAM;GACtB,MAAM,aAAa,eACjB,WAAW;IAMT,OALa,GACV,QACC,uEACF,CAAC,CACA,IAAI,WAAW,UACR,CAAC,CAAC,IAAI,QAAQ;GAC1B,CAAC;GACH,OAAO,cACL,WACA,MAAM,eAAe,KAAA,IAAY,EAAE,YAAY,KAAK,WAAW,IAAI,CAAC,CACtE;EACF;EAEA,QAAQ;GACN,GAAG,MAAM;EACX;CACF;AACF"}
1
+ {"version":3,"file":"store-sqlite.js","names":[],"sources":["../src/store-sqlite.ts"],"sourcesContent":["/**\n * experimental-a2/store-sqlite — sqlite store backend (the dev default, `.a2/dev.db`).\n *\n * Built on `node:sqlite` (Node ≥ 22.13) so it ships with zero\n * dependencies. Implements the A2Store interface; the conformance suite\n * in test/conformance is the executable contract.\n *\n * The spec's `index` column is stored as `idx` (a2-implementation.md\n * §11 — `index` collides with a reserved word) and mapped back at the\n * API boundary. Timestamps are epoch milliseconds written from the\n * injected clock — never SQL `now()` — so tests can time-travel against\n * real storage.\n */\n\nimport { mkdirSync } from 'node:fs'\nimport { dirname, resolve } from 'node:path'\nimport { DatabaseSync } from 'node:sqlite'\nimport { A2Error } from './errors.ts'\nimport { idempotentReplay } from './idempotent-replay.ts'\nimport { decodeReturnedEventIds } from './store-codec.ts'\nimport { pollingStream } from './store-polling.ts'\nimport {\n RANDOM_IDS,\n SYSTEM_CLOCK,\n type A2Store,\n type Clock,\n type Event,\n type EventCause,\n type IdSource,\n type StoredEvent,\n} from './store.ts'\n\nexport type SqliteStoreOptions = {\n /** Database file path. Defaults to `.a2/dev.db`; `:memory:` works. */\n path?: string\n /** Injectable clock — every stored timestamp comes from here. */\n clock?: Clock\n /** Injectable id source for generated event ids. */\n ids?: IdSource\n}\n\nexport type SqliteStore = A2Store & {\n /** Close the underlying database handle. */\n close(): void\n}\n\nconst SCHEMA = `\ncreate table if not exists a2_events (\n session_id text not null,\n idx integer not null,\n event_type text not null,\n payload text not null,\n event_id text not null,\n created_at integer not null,\n cause text,\n lane text,\n lane_ready integer not null default 0,\n processed_at integer,\n processed_by_attempt integer,\n returned_event_ids text,\n first_claimed_at integer,\n last_claimed_at integer,\n attempt_count integer not null default 0,\n failure_count integer not null default 0,\n last_failed_at integer,\n last_failed_attempt integer,\n last_error text,\n failed_at integer,\n claim_holder text,\n claim_expires_at integer,\n primary key (session_id, idx)\n) strict;\n\ncreate unique index if not exists a2_events_event_id on a2_events (event_id);\ncreate index if not exists a2_events_unprocessed\n on a2_events (session_id, idx) where processed_at is null;\ncreate index if not exists a2_events_lane_pending\n on a2_events (session_id, lane, idx) where processed_at is null;\ncreate index if not exists a2_events_dispatch_ready\n on a2_events (session_id, idx)\n where lane_ready = 1 and processed_at is null and failed_at is null;\ncreate index if not exists a2_events_claim_expiry\n on a2_events (session_id, claim_expires_at)\n where lane_ready = 1 and processed_at is null and failed_at is null;\n\ncreate table if not exists a2_snapshots (\n session_id text not null,\n reducer_name text not null,\n up_to_index integer not null,\n state text not null,\n updated_at integer not null,\n primary key (session_id, reducer_name)\n) strict;\n\ncreate table if not exists a2_snapshot_history (\n session_id text not null,\n reducer_name text not null,\n up_to_index integer not null,\n state text not null,\n updated_at integer not null,\n primary key (session_id, reducer_name, up_to_index)\n) strict;\n\ncreate table if not exists a2_snapshot_pins (\n session_id text not null,\n event_index integer not null,\n reducer_name text not null,\n up_to_index integer not null,\n primary key (session_id, event_index, reducer_name, up_to_index)\n) strict;\n\ncreate index if not exists a2_snapshot_pins_checkpoint\n on a2_snapshot_pins (session_id, reducer_name, up_to_index);\n\ncreate table if not exists a2_presence (\n ns text not null,\n participant text not null,\n field text not null,\n value text not null,\n seen integer not null,\n at integer not null,\n expires_at integer not null,\n primary key (ns, participant, field)\n) strict;\n`\n\n/**\n * Switching journal modes takes an exclusive lock, and — unlike normal\n * statements — the switch can return SQLITE_BUSY without consulting the\n * busy handler while several connections race it (instances cold-booting\n * against one file). Bounded synchronous retry; the window is boot-only\n * and tiny. Found by the multi-process torture test.\n */\nconst setWalJournalMode = (db: DatabaseSync): void => {\n for (let attempt = 0; ; attempt += 1) {\n try {\n db.exec('pragma journal_mode = wal')\n return\n } catch (err) {\n const busy = (err as { errcode?: number }).errcode === 5\n if (!busy || attempt >= 100) throw err\n // Synchronous 5ms sleep — sqlite() is a sync constructor.\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)\n }\n }\n}\n\nconst wrap = <T>(fn: () => T): T => {\n try {\n return fn()\n } catch (err) {\n if (err instanceof A2Error || err instanceof TypeError) throw err\n throw new A2Error('STORE_UNAVAILABLE', 'sqlite store operation failed', {\n cause: err,\n })\n }\n}\n\nconst toDate = (ms: number | bigint | null): Date | null =>\n ms === null ? null : new Date(Number(ms))\n\nconst toCause = (json: string | null): EventCause | null => {\n if (json === null) return null\n const value = JSON.parse(json) as {\n index?: unknown\n attempt?: unknown\n batchSize?: unknown\n }\n if (\n !Number.isInteger(value.index) ||\n Number(value.index) < 1 ||\n !Number.isInteger(value.attempt) ||\n Number(value.attempt) < 1\n ) {\n throw new TypeError('stored event has an invalid cause')\n }\n if (\n value.batchSize !== undefined &&\n (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)\n ) {\n throw new TypeError('stored event has an invalid cause')\n }\n return {\n index: Number(value.index),\n attempt: Number(value.attempt),\n ...(value.batchSize === undefined\n ? {}\n : { batchSize: Number(value.batchSize) }),\n }\n}\n\nconst toStored = (row: EventRow): StoredEvent => ({\n id: row.event_id,\n type: row.event_type,\n payload: JSON.parse(row.payload),\n index: Number(row.idx),\n sessionId: row.session_id,\n createdAt: new Date(Number(row.created_at)),\n cause: toCause(row.cause),\n lane: row.lane,\n processedAt: toDate(row.processed_at),\n processedByAttempt:\n row.processed_by_attempt === null ? null : Number(row.processed_by_attempt),\n returnedEventIds:\n row.returned_event_ids === null\n ? null\n : decodeReturnedEventIds(row.returned_event_ids),\n firstClaimedAt: toDate(row.first_claimed_at),\n lastClaimedAt: toDate(row.last_claimed_at),\n attemptCount: Number(row.attempt_count),\n failureCount: Number(row.failure_count),\n lastFailedAt: toDate(row.last_failed_at),\n lastFailedAttempt:\n row.last_failed_attempt === null ? null : Number(row.last_failed_attempt),\n lastError: row.last_error,\n failedAt: toDate(row.failed_at),\n claimHolder: row.claim_holder,\n claimExpiresAt: toDate(row.claim_expires_at),\n})\n\nconst toEvent = (row: EventRow): Event => ({\n id: row.event_id,\n type: row.event_type,\n payload: JSON.parse(row.payload),\n index: Number(row.idx),\n sessionId: row.session_id,\n createdAt: new Date(Number(row.created_at)),\n})\n\ntype EventRow = {\n session_id: string\n idx: number | bigint\n event_type: string\n payload: string\n event_id: string\n created_at: number | bigint\n cause: string | null\n lane: string | null\n processed_at: number | bigint | null\n processed_by_attempt: number | bigint | null\n returned_event_ids: string | null\n first_claimed_at: number | bigint | null\n last_claimed_at: number | bigint | null\n attempt_count: number | bigint\n failure_count: number | bigint\n last_failed_at: number | bigint | null\n last_failed_attempt: number | bigint | null\n last_error: string | null\n failed_at: number | bigint | null\n claim_holder: string | null\n claim_expires_at: number | bigint | null\n}\n\ntype PresenceDbRow = {\n participant: string\n field: string\n value: string\n seen: number | bigint\n at: number | bigint\n expires_at: number | bigint\n}\n\ntype StateReadRow = Partial<EventRow> & {\n row_kind: 'snapshot' | 'event'\n head_index: number | bigint | null\n snapshot_index: number | bigint | null\n snapshot_state: string | null\n}\n\ntype SnapshotHeadRow = {\n up_to_index: number | bigint\n state: string\n updated_at: number | bigint\n}\n\ntype AppendStatusRow = {\n max: number | bigint\n has_pending: number | bigint\n}\n\ntype ExistingEventRow = EventRow & {\n session_has_pending: number | bigint\n}\n\nexport function sqlite(options: SqliteStoreOptions = {}): SqliteStore {\n const path = options.path ?? '.a2/dev.db'\n const clock = options.clock ?? SYSTEM_CLOCK\n const generateId = options.ids ?? RANDOM_IDS\n\n if (path !== ':memory:') {\n // The dev-default backend only ever runs outside production, but\n // bundlers can't know that: without the ignore, Turbopack sees a\n // dynamic fs path and traces the entire project into the output.\n mkdirSync(dirname(resolve(/* turbopackIgnore: true */ path)), {\n recursive: true,\n })\n }\n const db = new DatabaseSync(path)\n // busy_timeout before anything that takes locks: concurrent\n // cold-opens (several instances booting at once) race the WAL switch\n // and schema creation.\n db.exec('pragma busy_timeout = 5000')\n setWalJournalMode(db)\n db.exec('pragma synchronous = normal')\n db.exec(SCHEMA)\n\n const insertEvent = db.prepare(\n `insert into a2_events\n (session_id, idx, event_type, payload, event_id, created_at,\n cause, lane, lane_ready, processed_at)\n select ?, ?, ?, ?, ?, ?, ?, ?,\n case\n when ? is null then 1\n when not exists (\n select 1 from a2_events\n where session_id = ? and lane = ? and processed_at is null\n ) then 1\n else 0\n end,\n ?`,\n )\n const promoteLaneHead = db.prepare(\n `update a2_events set lane_ready = 1\n where session_id = ?\n and idx = (\n select idx from a2_events\n where session_id = ? and lane = ? and processed_at is null\n order by idx limit 1\n )`,\n )\n const appendStatus = db.prepare(\n `select coalesce(\n (select max(idx) from a2_events where session_id = ?),\n 0\n ) as max,\n exists(\n select 1 from a2_events\n where session_id = ? and processed_at is null\n ) as has_pending`,\n )\n const upsertPresence = db.prepare(\n `insert into a2_presence (ns, participant, field, value, seen, at, expires_at)\n values (?, ?, ?, ?, ?, ?, ?)\n on conflict (ns, participant, field) do update set\n value = excluded.value,\n seen = excluded.seen,\n at = excluded.at,\n expires_at = excluded.expires_at\n where a2_presence.at <= excluded.at`,\n )\n const deletePresence = db.prepare(\n `delete from a2_presence\n where ns = ? and participant = ? and field = ? and at <= ?`,\n )\n const sweepPresence = db.prepare(\n 'delete from a2_presence where ns = ? and expires_at <= ?',\n )\n const selectPresence = db.prepare(\n `select participant, field, value, seen, at, expires_at\n from a2_presence\n where ns = ? and expires_at > ?\n order by participant, field`,\n )\n const selectByIds = (count: number) =>\n db.prepare(\n `select stored.*,\n exists(\n select 1 from a2_events pending\n where pending.session_id = ?\n and pending.processed_at is null\n ) as session_has_pending\n from a2_events stored\n where stored.event_id in (${Array.from({ length: count }, () => '?').join(', ')})\n order by stored.idx`,\n )\n\n const tx = <T>(fn: () => T): T => {\n db.exec('begin immediate')\n try {\n const result = fn()\n db.exec('commit')\n return result\n } catch (err) {\n try {\n db.exec('rollback')\n } catch {\n // Rollback can fail if the transaction never started; the\n // original error is what matters.\n }\n throw err\n }\n }\n\n return {\n async append(sessionId, events) {\n if (events.length === 0) {\n return wrap(() => {\n const status = appendStatus.get(\n sessionId,\n sessionId,\n ) as AppendStatusRow\n return {\n events: [],\n hasPending: Number(status.has_pending) === 1,\n }\n })\n }\n return wrap(() =>\n tx(() => {\n const supplied = events.filter((e) => e.id !== undefined)\n const suppliedIds = supplied.map((e) => e.id as string)\n if (new Set(suppliedIds).size !== suppliedIds.length) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'batch contains the same event id more than once',\n )\n }\n if (suppliedIds.length > 0) {\n const existing = selectByIds(suppliedIds.length).all(\n sessionId,\n ...suppliedIds,\n ) as unknown as ExistingEventRow[]\n if (existing.length > 0) {\n const foreign = existing.find(\n (row) => row.session_id !== sessionId,\n )\n if (foreign) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n `event id '${foreign.event_id}' already exists in another session`,\n )\n }\n if (existing.length === events.length) {\n return {\n events: idempotentReplay(events, existing.map(toStored)),\n hasPending: Number(existing[0]!.session_has_pending) === 1,\n }\n }\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,\n )\n }\n }\n\n // Attempt-currency fence: a fresh handler append commits only\n // while its causal attempt is still the parent's latest\n // (store.ts `append`). `begin immediate` serializes this read\n // against a concurrent claim.\n for (const e of events) {\n if (!e.cause) continue\n const parent = db\n .prepare(\n 'select attempt_count, failed_at from a2_events where session_id = ? and idx = ?',\n )\n .get(sessionId, e.cause.index) as\n { attempt_count: number | bigint; failed_at: unknown } | undefined\n if (!parent) {\n throw new TypeError(\n `no event at index ${e.cause.index} in session '${sessionId}'`,\n )\n }\n if (\n Number(parent.attempt_count) !== e.cause.attempt ||\n parent.failed_at !== null\n ) {\n throw new A2Error(\n 'SUPERSEDED_ATTEMPT',\n `attempt ${e.cause.attempt} no longer owns event ${e.cause.index} in session '${sessionId}'`,\n )\n }\n }\n\n const status = appendStatus.get(\n sessionId,\n sessionId,\n ) as AppendStatusRow\n const base = Number(status.max)\n const now = clock.now().getTime()\n const inserted: StoredEvent[] = events.map((e, i) => {\n const id = e.id ?? generateId()\n const index = base + 1 + i\n insertEvent.run(\n sessionId,\n index,\n e.type,\n JSON.stringify(e.payload) ?? 'null',\n id,\n now,\n e.cause ? JSON.stringify(e.cause) : null,\n e.lane ?? null,\n e.lane ?? null,\n sessionId,\n e.lane ?? null,\n e.settled ? now : null,\n )\n return {\n id,\n type: e.type,\n payload: structuredClone(e.payload),\n index,\n sessionId,\n createdAt: new Date(now),\n cause: e.cause ? { ...e.cause } : null,\n lane: e.lane ?? null,\n processedAt: e.settled ? new Date(now) : null,\n processedByAttempt: null,\n returnedEventIds: null,\n firstClaimedAt: null,\n lastClaimedAt: null,\n attemptCount: 0,\n failureCount: 0,\n lastFailedAt: null,\n lastFailedAttempt: null,\n lastError: null,\n failedAt: null,\n claimHolder: null,\n claimExpiresAt: null,\n }\n })\n return {\n events: inserted,\n hasPending:\n Number(status.has_pending) === 1 ||\n events.some((event) => event.settled !== true),\n }\n }),\n )\n },\n\n async read(sessionId, opts) {\n return wrap(() => {\n const conditions = ['session_id = ?']\n const params: (string | number)[] = [sessionId]\n if (opts?.afterIndex !== undefined) {\n conditions.push('idx > ?')\n params.push(opts.afterIndex)\n }\n if (opts?.throughIndex !== undefined) {\n conditions.push('idx <= ?')\n params.push(opts.throughIndex)\n }\n const rows = db\n .prepare(\n `select * from a2_events where ${conditions.join(' and ')} order by idx`,\n )\n .all(...params) as unknown as EventRow[]\n return rows.map(toStored)\n })\n },\n\n async claimAvailable({\n sessionId,\n holder,\n ttlMs,\n expiresAtMs,\n excludeIndexes = [],\n }) {\n return wrap(() =>\n tx(() => {\n const now = clock.now().getTime()\n const expiresAt = expiresAtMs ?? now + ttlMs\n const exclusion =\n excludeIndexes.length === 0\n ? ''\n : `and event.idx not in (${excludeIndexes.map(() => '?').join(', ')})`\n const eligible = db\n .prepare(\n `select event.idx\n from a2_events event\n where event.session_id = ?\n and event.processed_at is null\n and event.failed_at is null\n and event.lane_ready = 1\n and (event.claim_expires_at is null or event.claim_expires_at <= ?)\n ${exclusion}\n order by event.idx`,\n )\n .all(sessionId, now, ...excludeIndexes) as unknown as Array<{\n idx: number | bigint\n }>\n if (eligible.length > 0) {\n const update = db.prepare(\n `update a2_events set\n attempt_count = attempt_count + 1,\n first_claimed_at = coalesce(first_claimed_at, ?),\n last_claimed_at = ?,\n claim_holder = ?,\n claim_expires_at = ?\n where session_id = ? and idx = ?\n returning *`,\n )\n const events = eligible.map((row) => {\n const claimed = update.get(\n now,\n now,\n holder,\n expiresAt,\n sessionId,\n row.idx,\n ) as EventRow\n return toStored(claimed)\n })\n return { outcome: 'claimed', events }\n }\n\n const active = db\n .prepare(\n `select min(claim_expires_at) as retry_at\n from a2_events\n where session_id = ?\n and processed_at is null\n and failed_at is null\n and lane_ready = 1\n and claim_expires_at > ?`,\n )\n .get(sessionId, now) as {\n retry_at: number | bigint | null\n }\n return active.retry_at === null\n ? { outcome: 'settled' }\n : { outcome: 'busy', retryAt: new Date(Number(active.retry_at)) }\n }),\n )\n },\n\n async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {\n if (claims.length === 0) return { renewed: [], superseded: [] }\n return wrap(() =>\n tx(() => {\n const now = clock.now().getTime()\n const expiresAt = expiresAtMs ?? now + ttlMs\n const renewed: number[] = []\n const superseded: number[] = []\n const currentAttempt = db.prepare(\n 'select attempt_count from a2_events where session_id = ? and idx = ?',\n )\n const extend = db.prepare(\n `update a2_events set claim_expires_at = ?\n where session_id = ?\n and idx = ?\n and attempt_count = ?\n and processed_at is null\n and failed_at is null\n and claim_holder = ?\n and claim_expires_at > ?`,\n )\n for (const claim of claims) {\n const row = currentAttempt.get(sessionId, claim.index) as\n { attempt_count: number | bigint } | undefined\n if (!row) continue\n if (Number(row.attempt_count) > claim.attempt) {\n superseded.push(claim.index)\n continue\n }\n const updated = extend.run(\n expiresAt,\n sessionId,\n claim.index,\n claim.attempt,\n holder,\n now,\n )\n if (Number(updated.changes) === 1) renewed.push(claim.index)\n }\n return {\n renewed: renewed.toSorted((a, b) => a - b),\n superseded: superseded.toSorted((a, b) => a - b),\n }\n }),\n )\n },\n\n async completeAttempt({ sessionId, index, attempt, events }) {\n return wrap(() =>\n tx(() => {\n const parent = db\n .prepare('select * from a2_events where session_id = ? and idx = ?')\n .get(sessionId, index) as EventRow | undefined\n if (!parent) {\n throw new TypeError(\n `no event at index ${index} in session '${sessionId}'`,\n )\n }\n const ids = events.map((event) => event.id)\n if (new Set(ids).size !== ids.length) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'returned event batch contains the same event id more than once',\n )\n }\n\n if (parent.processed_at !== null) {\n if (Number(parent.processed_by_attempt) !== attempt) {\n return { outcome: 'superseded' }\n }\n const returnedIds =\n parent.returned_event_ids === null\n ? null\n : decodeReturnedEventIds(parent.returned_event_ids)\n if (\n returnedIds === null ||\n returnedIds.length !== ids.length ||\n returnedIds.some((id, offset) => id !== ids[offset])\n ) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'completed attempt does not match the returned event batch',\n )\n }\n const existing =\n ids.length === 0\n ? []\n : (selectByIds(ids.length).all(\n sessionId,\n ...ids,\n ) as unknown as ExistingEventRow[])\n const byId = new Map(existing.map((row) => [row.event_id, row]))\n const ordered = ids.map((id) => byId.get(id))\n if (\n ordered.some(\n (row) =>\n !row ||\n row.session_id !== sessionId ||\n row.cause === null ||\n toCause(row.cause)?.index !== index ||\n toCause(row.cause)?.attempt !== attempt,\n )\n ) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'returned event retry does not match the committed batch',\n )\n }\n return {\n outcome: 'completed',\n events: idempotentReplay(\n events,\n (ordered as ExistingEventRow[]).map(toStored),\n ),\n }\n }\n if (\n Number(parent.attempt_count) !== attempt ||\n parent.claim_holder === null ||\n parent.failed_at !== null\n ) {\n return { outcome: 'superseded' }\n }\n\n if (\n ids.length > 0 &&\n (selectByIds(ids.length).all(sessionId, ...ids) as unknown[])\n .length > 0\n ) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'returned event batch contains an already-appended event id',\n )\n }\n\n const status = appendStatus.get(\n sessionId,\n sessionId,\n ) as AppendStatusRow\n const now = clock.now().getTime()\n db.prepare(\n `update a2_events set\n processed_at = ?,\n processed_by_attempt = ?,\n returned_event_ids = ?,\n lane_ready = 0,\n claim_holder = null,\n claim_expires_at = null\n where session_id = ? and idx = ?`,\n ).run(now, attempt, JSON.stringify(ids), sessionId, index)\n db.prepare(\n 'delete from a2_snapshot_pins where session_id = ? and event_index = ?',\n ).run(sessionId, index)\n db.prepare(\n `delete from a2_snapshot_history\n where session_id = ?\n and not exists (\n select 1 from a2_snapshot_pins pin\n where pin.session_id = a2_snapshot_history.session_id\n and pin.reducer_name = a2_snapshot_history.reducer_name\n and pin.up_to_index = a2_snapshot_history.up_to_index\n )`,\n ).run(sessionId)\n if (parent.lane !== null) {\n promoteLaneHead.run(sessionId, sessionId, parent.lane)\n }\n const inserted = events.map((event, offset) => {\n const id = event.id\n const childIndex = Number(status.max) + offset + 1\n const cause = { index, attempt }\n insertEvent.run(\n sessionId,\n childIndex,\n event.type,\n JSON.stringify(event.payload) ?? 'null',\n id,\n now,\n JSON.stringify(cause),\n event.lane ?? null,\n event.lane ?? null,\n sessionId,\n event.lane ?? null,\n event.settled ? now : null,\n )\n return toStored(\n db\n .prepare(\n 'select * from a2_events where session_id = ? and idx = ?',\n )\n .get(sessionId, childIndex) as EventRow,\n )\n })\n return { outcome: 'completed', events: inserted }\n }),\n )\n },\n\n async failAttempt({ sessionId, index, attempt, error, maxFailures }) {\n return wrap(() =>\n tx(() => {\n const current = db\n .prepare('select * from a2_events where session_id = ? and idx = ?')\n .get(sessionId, index) as EventRow | undefined\n if (!current) {\n throw new TypeError(\n `no event at index ${index} in session '${sessionId}'`,\n )\n }\n const failureCount = Number(current.failure_count)\n if (\n current.processed_at !== null ||\n Number(current.attempt_count) !== attempt\n ) {\n return { outcome: 'superseded', failureCount }\n }\n if (current.failed_at !== null) {\n return { outcome: 'dead_lettered', failureCount }\n }\n if (\n current.claim_holder === null &&\n current.last_failed_attempt !== null &&\n Number(current.last_failed_attempt) === attempt\n ) {\n return { outcome: 'failed', failureCount }\n }\n if (current.claim_holder === null) {\n return { outcome: 'superseded', failureCount }\n }\n\n const now = clock.now().getTime()\n const nextFailureCount = failureCount + 1\n const deadLettered = nextFailureCount >= maxFailures\n db.prepare(\n `update a2_events set\n failure_count = ?,\n last_error = ?,\n last_failed_at = ?,\n last_failed_attempt = ?,\n failed_at = ?,\n claim_holder = null,\n claim_expires_at = null\n where session_id = ? and idx = ?`,\n ).run(\n nextFailureCount,\n error,\n now,\n attempt,\n deadLettered ? now : null,\n sessionId,\n index,\n )\n if (deadLettered) {\n db.prepare(\n 'delete from a2_snapshot_pins where session_id = ? and event_index = ?',\n ).run(sessionId, index)\n db.prepare(\n `delete from a2_snapshot_history\n where session_id = ?\n and not exists (\n select 1 from a2_snapshot_pins pin\n where pin.session_id = a2_snapshot_history.session_id\n and pin.reducer_name = a2_snapshot_history.reducer_name\n and pin.up_to_index = a2_snapshot_history.up_to_index\n )`,\n ).run(sessionId)\n }\n return {\n outcome: deadLettered ? 'dead_lettered' : 'failed',\n failureCount: nextFailureCount,\n }\n }),\n )\n },\n\n async readState(sessionId, reducerName, stateOptions) {\n return wrap(() => {\n const throughIndex = stateOptions?.throughIndex ?? null\n const snapshotThroughIndex =\n stateOptions?.snapshotThroughIndex ?? throughIndex\n const rows = db\n .prepare(\n `with head as materialized (\n select up_to_index, state, updated_at\n from a2_snapshots\n where session_id = ? and reducer_name = ?\n ), snapshot as materialized (\n select up_to_index, state from (\n select up_to_index, state from head\n where (? is null or up_to_index <= ?)\n union all\n select up_to_index, state\n from a2_snapshot_history\n where session_id = ? and reducer_name = ?\n and (? is null or up_to_index <= ?)\n ) order by up_to_index desc limit 1\n )\n select 0 as row_order, 'snapshot' as row_kind,\n (select up_to_index from head) as head_index,\n (select up_to_index from snapshot) as snapshot_index,\n (select state from snapshot) as snapshot_state,\n null as session_id, null as idx, null as event_type,\n null as payload, null as event_id, null as created_at,\n null as cause, null as lane, null as lane_ready,\n null as processed_at,\n null as processed_by_attempt, null as returned_event_ids,\n null as first_claimed_at,\n null as last_claimed_at, null as attempt_count,\n null as failure_count, null as last_failed_at,\n null as last_failed_attempt, null as last_error,\n null as failed_at, null as claim_holder,\n null as claim_expires_at\n union all\n select 1 as row_order, 'event' as row_kind,\n (select up_to_index from head) as head_index,\n null as snapshot_index, null as snapshot_state,\n event.*\n from a2_events as event\n where event.session_id = ?\n and event.idx > coalesce((select up_to_index from snapshot), 0)\n and (? is null or event.idx <= ?)\n order by row_order, idx`,\n )\n .all(\n sessionId,\n reducerName,\n snapshotThroughIndex,\n snapshotThroughIndex,\n sessionId,\n reducerName,\n snapshotThroughIndex,\n snapshotThroughIndex,\n sessionId,\n throughIndex,\n throughIndex,\n ) as unknown as StateReadRow[]\n const snapshot = rows.find((row) => row.row_kind === 'snapshot')\n return {\n headIndex:\n snapshot?.head_index === null || snapshot?.head_index === undefined\n ? null\n : Number(snapshot.head_index),\n snapshot:\n snapshot?.snapshot_index !== null &&\n snapshot?.snapshot_index !== undefined\n ? {\n index: Number(snapshot.snapshot_index),\n state: JSON.parse(snapshot.snapshot_state!),\n }\n : null,\n events: rows\n .filter(\n (row): row is StateReadRow & EventRow => row.row_kind === 'event',\n )\n .map(toEvent),\n }\n })\n },\n\n async putSnapshots(sessionId, reducerName, writes) {\n await wrap(() =>\n tx(() => {\n const selectHead = db.prepare(\n `select up_to_index, state, updated_at from a2_snapshots\n where session_id = ? and reducer_name = ?`,\n )\n const unfinished = db.prepare(\n `select 1 from a2_events\n where session_id = ? and idx = ?\n and processed_at is null and failed_at is null`,\n )\n const insertPin = db.prepare(\n `insert or ignore into a2_snapshot_pins\n (session_id, event_index, reducer_name, up_to_index)\n values (?, ?, ?, ?)`,\n )\n const hasPin = db.prepare(\n `select 1 from a2_snapshot_pins\n where session_id = ? and reducer_name = ? and up_to_index = ?\n limit 1`,\n )\n const putHistory = db.prepare(\n `insert into a2_snapshot_history\n (session_id, reducer_name, up_to_index, state, updated_at)\n values (?, ?, ?, ?, ?)\n on conflict (session_id, reducer_name, up_to_index) do update set\n state = excluded.state, updated_at = excluded.updated_at`,\n )\n const putHead = db.prepare(\n `insert into a2_snapshots\n (session_id, reducer_name, up_to_index, state, updated_at)\n values (?, ?, ?, ?, ?)\n on conflict (session_id, reducer_name) do update set\n up_to_index = excluded.up_to_index,\n state = excluded.state,\n updated_at = excluded.updated_at\n where excluded.up_to_index >= a2_snapshots.up_to_index`,\n )\n const deleteHistory = db.prepare(\n `delete from a2_snapshot_history\n where session_id = ? and reducer_name = ? and up_to_index = ?`,\n )\n for (const write of writes.toSorted((a, b) => a.index - b.index)) {\n for (const eventIndex of write.pinEventIndexes ?? []) {\n if (unfinished.get(sessionId, eventIndex)) {\n insertPin.run(sessionId, eventIndex, reducerName, write.index)\n }\n }\n const head = selectHead.get(sessionId, reducerName) as\n SnapshotHeadRow | undefined\n const now = clock.now().getTime()\n if (!head || write.index >= Number(head.up_to_index)) {\n if (\n head &&\n write.index > Number(head.up_to_index) &&\n hasPin.get(sessionId, reducerName, Number(head.up_to_index))\n ) {\n putHistory.run(\n sessionId,\n reducerName,\n Number(head.up_to_index),\n head.state,\n Number(head.updated_at),\n )\n }\n putHead.run(\n sessionId,\n reducerName,\n write.index,\n JSON.stringify(write.state) ?? 'null',\n now,\n )\n deleteHistory.run(sessionId, reducerName, write.index)\n } else if (\n write.index < Number(head.up_to_index) &&\n hasPin.get(sessionId, reducerName, write.index)\n ) {\n putHistory.run(\n sessionId,\n reducerName,\n write.index,\n JSON.stringify(write.state) ?? 'null',\n now,\n )\n }\n }\n }),\n )\n },\n\n presence: {\n async set(ns, participant, values, meta) {\n const entries = Object.entries(values)\n if (entries.length === 0) return\n wrap(() =>\n tx(() => {\n const atMs = meta.at.getTime()\n // Expiry anchors on the storage clock — the sender's `at`\n // orders writes but never anchors their lifetime.\n const expiresAtMs = clock.now().getTime() + meta.ttlMs\n for (const [field, value] of entries) {\n // Field-wise LWW: a stored row with a strictly newer `at`\n // survives; ties go to the incoming write (memory parity).\n if (value === null) {\n deletePresence.run(ns, participant, field, atMs)\n } else {\n upsertPresence.run(\n ns,\n participant,\n field,\n JSON.stringify(value) ?? 'null',\n meta.seen,\n atMs,\n expiresAtMs,\n )\n }\n }\n }),\n )\n },\n\n async read(ns) {\n return wrap(() => {\n const now = clock.now().getTime()\n // The sweep is opportunistic cleanup; the select's filter\n // alone keeps expired rows out of this result.\n sweepPresence.run(ns, now)\n const rows = selectPresence.all(ns, now) as unknown as PresenceDbRow[]\n return rows.map((row) => ({\n participant: row.participant,\n field: row.field,\n value: JSON.parse(row.value) as unknown,\n seen: Number(row.seen),\n at: new Date(Number(row.at)),\n expiresAt: new Date(Number(row.expires_at)),\n }))\n })\n },\n },\n\n stream(sessionId, opts) {\n const readAfter = (afterIndex: number): StoredEvent[] =>\n wrap(() => {\n const rows = db\n .prepare(\n 'select * from a2_events where session_id = ? and idx > ? order by idx',\n )\n .all(sessionId, afterIndex) as unknown as EventRow[]\n return rows.map(toStored)\n })\n return pollingStream(\n readAfter,\n opts?.startAfter !== undefined ? { startAfter: opts.startAfter } : {},\n )\n },\n\n close() {\n db.close()\n },\n }\n}\n"],"mappings":"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"}
@@ -66,6 +66,10 @@ when a process dies between the claim and handler entry.
66
66
  `ctx.session.id` is the same value as `ctx.event.sessionId`. Use
67
67
  `ctx.session.history()` when a handler needs raw facts. Use
68
68
  [`state()`](/concepts/state) for a cached computed view of a long session.
69
+ The handler-scoped state read folds through `ctx.event.index` by default, so
70
+ later concurrent appends do not change what that event sees. Use
71
+ `ctx.session.state(reducer, { through: 'latest' })` when the handler
72
+ intentionally joins against the current committed frontier.
69
73
 
70
74
  ## Return what happens after success
71
75
 
@@ -96,17 +96,57 @@ arguments or annotations are needed, and literal unions survive the fold.
96
96
  fold includes every event through that index and none after it. The browser
97
97
  resumes its live stream from that boundary. See [Live UI](/guides/react).
98
98
 
99
+ A root session reads the latest committed frontier by default. A handler's
100
+ session is causally scoped, so the same call stops at its triggering event:
101
+
102
+ ```ts causal-server.ts
103
+ import { createServer } from 'experimental-a2/server'
104
+ import { orders } from './contracts'
105
+ import { ordersReducer } from './reducer'
106
+
107
+ export const causalOrdersServer = createServer({
108
+ contract: orders,
109
+ handlers: {
110
+ created: async ({ event, session }) => {
111
+ const atTrigger = await session.state(ordersReducer)
112
+ // atTrigger.index === event.index
113
+
114
+ const current = await session.state(ordersReducer, {
115
+ through: 'latest',
116
+ })
117
+ void current
118
+ },
119
+ },
120
+ })
121
+ ```
122
+
123
+ The trigger boundary is stable across concurrent appends and retries. Use the
124
+ explicit latest read for joins or other logic that intentionally observes later
125
+ events. A numeric `{ through: index }` reads any inclusive log boundary;
126
+ `through: 0` returns `initialState`.
127
+
128
+ A bounded handler read also keeps that exact folded checkpoint available while
129
+ the triggering event is unfinished. This is automatic. When concurrent work
130
+ advances the reducer's latest snapshot, A2 retains the older checkpoint until
131
+ every handler event using it completes or dead-letters. There is no TTL, history
132
+ limit, or retention setting to tune.
133
+
134
+ Root reads and handler reads with `{ through: 'latest' }` do not retain
135
+ historical checkpoints. A root numeric read can use an older checkpoint that an
136
+ active handler already retained, but querying an old boundary does not keep it
137
+ around or write a stale snapshot behind the current head. If no suitable
138
+ checkpoint exists, A2 folds the bounded log.
139
+
99
140
  The snapshot and its remaining event tail come back in one consistent store
100
141
  operation. A missing, invalid, or unreadable snapshot rebuilds from the full log.
101
142
  `state()` is observational: it never runs handlers or waits for pending work.
102
- Its index marks committed history, not handler completion. Inside a handler it
103
- includes the trigger and may include later events committed before the read.
143
+ Its index marks committed history, not handler completion.
104
144
 
105
- The read and a following `ctx.session.append(name, ...events)` are separate
145
+ A latest read and a following `ctx.session.append(name, ...events)` are separate
106
146
  operations. Concurrent appends and retries can move the frontier between them.
107
- For joins, use a monotone readiness check (once ready, always ready) and give
108
- the output event a stable explicit `id`. Repeated attempts then converge on the
109
- same append.
147
+ For joins, read with `{ through: 'latest' }`, use a monotone readiness check
148
+ (once ready, always ready), and give the output event a stable explicit `id`.
149
+ Repeated attempts then converge on the same append.
110
150
 
111
151
  `stateSchema` declares the state's shape once. Without it, the state type
112
152
  is inferred from `initialState`, fine while every fold arm returns the
@@ -130,6 +170,12 @@ Folding a long session on every read would get slow, so the store backend
130
170
  caches folded state as a snapshot. You never interact with it, except for
131
171
  one string.
132
172
 
173
+ Each session and reducer has one latest snapshot. Older snapshots exist only
174
+ while unfinished handler events need their exact state boundary. Handler
175
+ completion and dead-lettering release those references in the same store
176
+ operation and remove any historical snapshot with no remaining reader. Several
177
+ handlers can share one checkpoint; it stays until the last reference leaves.
178
+
133
179
  Snapshots are keyed by the reducer's `name`, which makes the name do two
134
180
  jobs. It's the identity: two different reducers over the same session
135
181
  never fight over a cache entry, because they have different names. And
@@ -138,9 +184,11 @@ it's the invalidation knob. Changed the fold's logic? Change the name (a
138
184
  ignored; the next read refolds from raw events and caches under the new
139
185
  name. That's the entire cache invalidation story: one string.
140
186
 
141
- Snapshot write-back runs in platform `waitUntil` after the state is ready. It
142
- never delays the read, and a failed or interrupted cache write changes no
143
- application behavior. The next read folds the missing tail again.
187
+ Snapshot write-back runs in platform `waitUntil` after the state is ready. One
188
+ same-tick group of reads for a reducer and session reads the widest required log
189
+ range once, folds it once, and writes the needed prefixes together. A failed or
190
+ interrupted cache write changes no application behavior. The next read folds
191
+ the missing tail again.
144
192
 
145
193
  Deleting every snapshot is always safe. The log rebuilds them.
146
194
 
@@ -627,7 +627,8 @@ authorization are ready. There is no fixed tool concurrency limit. A private
627
627
  coordinator reducer tracks generation closure, cancellation, calls, approvals,
628
628
  and terminal results for the active response. Its retained state is bounded;
629
629
  completed responses do not accumulate in the coordinator. Each join reads a
630
- durable reducer snapshot plus the log tail through `ctx.session.state()`. The snapshot
630
+ durable reducer snapshot plus the log tail through
631
+ `ctx.session.state(coordinator, { through: 'latest' })`. The snapshot
631
632
  is only a cache. Recovery can rebuild the same coordinator state from the event
632
633
  log after process death.
633
634
 
@@ -158,12 +158,11 @@ The context every handler receives:
158
158
  when a process dies before handler entry.
159
159
 
160
160
  `ctx.session.id` equals `ctx.event.sessionId`. Its `history`, `state`, and
161
- `stream` methods are the same session operations returned by
162
- `server.session(id)`. `ctx.session.state(reducer)` reads the cached snapshot
163
- plus immutable log tail and returns `{ state, index }`. The index is a
164
- consistent committed frontier captured when the call runs. It includes the
165
- triggering event and may include events committed later while another handler
166
- is active.
161
+ `stream` methods share the session surface returned by `server.session(id)`,
162
+ with one causal default: `ctx.session.state(reducer)` folds through the
163
+ triggering event's index. The same event sees the same state across concurrent
164
+ appends and retries. Ask for `{ through: 'latest' }` when a handler intentionally
165
+ needs the committed frontier captured when that read runs.
167
166
 
168
167
  The handler-local append is specialized:
169
168
 
@@ -184,10 +183,11 @@ in its stable task identity. A relative delay is anchored to that event's
184
183
  durable `createdAt`, so a handler retry keeps the same delivery time. The call
185
184
  returns `Promise<void>` after the configured scheduler accepts the task.
186
185
 
187
- A state read and following append are not atomic. Concurrent appends and
188
- retries may move the frontier between them. A generic join should use a
189
- monotone readiness predicate and a stable explicit output event `id`, so every
190
- eligible attempt converges on the same append.
186
+ A latest state read and following append are not atomic. Concurrent appends and
187
+ retries may move the frontier between them. A generic join should read with
188
+ `{ through: 'latest' }`, use a monotone readiness predicate, and give its output
189
+ a stable explicit event `id`, so every eligible attempt converges on the same
190
+ append.
191
191
 
192
192
  ### `server.fetch(request, options?)`
193
193
 
@@ -630,10 +630,13 @@ Raw events from the session, oldest first. `gte` and `lte` are inclusive event
630
630
  indexes; omit a bound to leave that end of the log open.
631
631
  Bounds are non-negative safe integers. `lte: 0` returns `[]`; `gte > lte` throws.
632
632
 
633
- ### `session.state(reducer)`
633
+ ### `session.state(reducer, options?)`
634
634
 
635
635
  ```ts
636
- session.state(reducer: Reducer<S>): Promise<{ state: S; index: number }>
636
+ session.state(
637
+ reducer: Reducer<S>,
638
+ options?: { through?: number | 'latest' },
639
+ ): Promise<{ state: S; index: number }>
637
640
  ```
638
641
 
639
642
  The log folded through one committed prefix. `index` is its exact boundary,
@@ -641,6 +644,24 @@ an append-order cursor rather than a handler-settlement marker. Hand it to the
641
644
  client to resume there. Snapshot write-back uses platform `waitUntil`; this
642
645
  read never dispatches handlers or waits for pending work.
643
646
 
647
+ On a root session from `server.session(id)`, omitting `through` reads the latest
648
+ committed frontier. On the handler-scoped `ctx.session`, omitting it reads
649
+ through `ctx.event.index`. That boundary is inclusive and stable across retries.
650
+ Use `{ through: 'latest' }` inside a handler only when later committed events
651
+ are intentionally part of the decision. A numeric `through` is an inclusive,
652
+ non-negative safe integer; `through: 0` returns the reducer's initial state.
653
+
654
+ The default handler read and numeric handler reads automatically retain their
655
+ exact folded checkpoint while the triggering event is unfinished. Completion
656
+ or dead-lettering releases the reference and collects the checkpoint when no
657
+ other unfinished handler uses it. There are no retention options. Handler
658
+ `{ through: 'latest' }` reads and all root reads do not retain history.
659
+
660
+ If the latest snapshot is ahead of a numeric boundary, A2 can use the greatest
661
+ eligible retained checkpoint. A root historical read never creates retention
662
+ or writes a stale checkpoint behind the latest snapshot; it folds from the log
663
+ when no eligible checkpoint exists.
664
+
644
665
  ### `session.stream(options?)`
645
666
 
646
667
  ```ts
@@ -1120,10 +1141,12 @@ retried or interrupted.
1120
1141
  Independent authorized tool handlers run concurrently without a fixed
1121
1142
  concurrency limit. A private coordinator reducer tracks generation closure,
1122
1143
  cancellation, calls, approvals, and terminal results for the active response.
1123
- Completed responses do not accumulate in its state. Its `ctx.session.state()` reads the
1124
- durable snapshot plus log tail. Process memory is not authoritative. Concurrent
1125
- join checks return the same deterministic continuation event, so they use
1126
- `ctx.session.append()` and storage deduplicates the race.
1144
+ Completed responses do not accumulate in its state. Its
1145
+ `ctx.session.state(coordinator, { through: 'latest' })` reads the durable
1146
+ snapshot plus log tail because these joins intentionally observe concurrent
1147
+ tool results. Process memory is not authoritative. Concurrent join checks return
1148
+ the same deterministic continuation event, so they use `ctx.session.append()`
1149
+ and storage deduplicates the race.
1127
1150
 
1128
1151
  Tool results and generation completion have no fixed relative order. The
1129
1152
  continuation predicate needs both the closed model step and every required
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Durable sync and reactions for things with a lifecycle: one event log, derived state, and live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ai-server.ts CHANGED
@@ -842,13 +842,16 @@ export function createHandlers<
842
842
  const scheduleNext = async (
843
843
  ctx: Pick<HandlerContext<D>, 'session'>,
844
844
  ): Promise<AppendInput<D> | void> =>
845
- requestForNextMessage((await ctx.session.state(coordinator)).state)
845
+ requestForNextMessage(
846
+ (await ctx.session.state(coordinator, { through: 'latest' })).state,
847
+ )
846
848
 
847
849
  const continueIfReady = async (
848
850
  ctx: Pick<HandlerContext<D>, 'session'>,
849
851
  generationId: string,
850
852
  ): Promise<void> => {
851
- const state = (await ctx.session.state(coordinator)).state
853
+ const state = (await ctx.session.state(coordinator, { through: 'latest' }))
854
+ .state
852
855
  const response = state.response
853
856
  if (response?.generation?.generationId !== generationId) {
854
857
  return
@@ -1092,6 +1095,7 @@ export function createHandlers<
1092
1095
  call,
1093
1096
  ctx.session.history,
1094
1097
  )
1098
+ if (ctx.signal.aborted) return
1095
1099
  const scope: AmbientToolScope = {
1096
1100
  contract: options.agent.contract,
1097
1101
  context: ctx,
@@ -1106,7 +1110,8 @@ export function createHandlers<
1106
1110
  const handleToolCall = async (
1107
1111
  ctx: HandlerContext<D, 'ai.tool.called'>,
1108
1112
  ): Promise<AppendInput<D> | void> => {
1109
- const state = (await ctx.session.state(coordinator)).state
1113
+ const state = (await ctx.session.state(coordinator, { through: 'latest' }))
1114
+ .state
1110
1115
  const response = state.response
1111
1116
  const current = response?.calls.find(
1112
1117
  (candidate) => candidate.index === ctx.event.index,
@@ -1130,7 +1135,8 @@ export function createHandlers<
1130
1135
  const handleApproval = async (
1131
1136
  ctx: HandlerContext<D, 'ai.approval.responded'>,
1132
1137
  ): Promise<AppendInput<D> | void> => {
1133
- const state = (await ctx.session.state(coordinator)).state
1138
+ const state = (await ctx.session.state(coordinator, { through: 'latest' }))
1139
+ .state
1134
1140
  const response = state.response
1135
1141
  const current = response?.calls.find(
1136
1142
  (candidate) =>
@@ -1163,7 +1169,9 @@ export function createHandlers<
1163
1169
  const history = await ctx.session.history()
1164
1170
  const requestId = ctx.event.id
1165
1171
  const request = ctx.event.payload
1166
- const coordinatorState = (await ctx.session.state(coordinator)).state
1172
+ const coordinatorState = (
1173
+ await ctx.session.state(coordinator, { through: 'latest' })
1174
+ ).state
1167
1175
  if (
1168
1176
  coordinatorState.closed ||
1169
1177
  coordinatorState.response?.activeRequestId !== requestId
@@ -1317,7 +1325,9 @@ export function createHandlers<
1317
1325
  } as AppendInput<D>)
1318
1326
  await ctx.session.append('generation-start', ...startEvents)
1319
1327
 
1320
- const startedCoordinatorState = (await ctx.session.state(coordinator)).state
1328
+ const startedCoordinatorState = (
1329
+ await ctx.session.state(coordinator, { through: 'latest' })
1330
+ ).state
1321
1331
  if (
1322
1332
  startedCoordinatorState.response?.activeRequestId !== requestId ||
1323
1333
  startedCoordinatorState.response.generation?.generationId !== generationId
@@ -1364,8 +1374,12 @@ export function createHandlers<
1364
1374
  }
1365
1375
  }
1366
1376
 
1367
- const currentState = (await ctx.session.state(options.agent.reducer)).state
1368
- const currentCoordinatorState = (await ctx.session.state(coordinator)).state
1377
+ const currentState = (
1378
+ await ctx.session.state(options.agent.reducer, { through: 'latest' })
1379
+ ).state
1380
+ const currentCoordinatorState = (
1381
+ await ctx.session.state(coordinator, { through: 'latest' })
1382
+ ).state
1369
1383
  const generationMessages = activeContextMessages(
1370
1384
  currentState,
1371
1385
  currentCoordinatorState,
@@ -1381,6 +1395,7 @@ export function createHandlers<
1381
1395
  options.instructions === undefined
1382
1396
  ? undefined
1383
1397
  : await resolve(options.instructions, resolverContext)
1398
+ if (ctx.signal.aborted) return
1384
1399
  const generateContext: AgentGenerateContext<M, D, T> = {
1385
1400
  request: ctx.event,
1386
1401
  requestId,
@@ -1541,7 +1556,8 @@ export function createHandlers<
1541
1556
  const handleRetry = async (
1542
1557
  ctx: HandlerContext<D, 'ai.retry.requested'>,
1543
1558
  ): Promise<AppendInput<D> | void> => {
1544
- const state = (await ctx.session.state(coordinator)).state
1559
+ const state = (await ctx.session.state(coordinator, { through: 'latest' }))
1560
+ .state
1545
1561
  const response = state.response
1546
1562
  if (
1547
1563
  response?.status !== 'failed' ||
@@ -1564,7 +1580,8 @@ export function createHandlers<
1564
1580
  const handleInputResponse = async (
1565
1581
  ctx: HandlerContext<D, 'ai.input.responded'>,
1566
1582
  ): Promise<void> => {
1567
- const state = (await ctx.session.state(coordinator)).state
1583
+ const state = (await ctx.session.state(coordinator, { through: 'latest' }))
1584
+ .state
1568
1585
  const response = state.response
1569
1586
  if (
1570
1587
  response?.responseMessageId !== ctx.event.payload.messageId ||