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-postgres.js","names":[],"sources":["../src/store-postgres.ts"],"sourcesContent":["/**\n * experimental-a2/store-postgres — postgres store backend (the production path).\n *\n * Implements the A2Store interface over any client that speaks the tiny\n * `PostgresClient` shape below — a `pg` Pool matches it structurally\n * (and is what `connectionString` creates, lazily; `pg` is an optional\n * peer dependency). The conformance suite in test/conformance is the\n * executable contract, run against PGlite (real Postgres, in-process)\n * and against a real server when `TEST_DATABASE_URL` is set.\n *\n * Semantics mirror the sqlite backend: `index` is stored as `idx`,\n * timestamps are epoch milliseconds written from the injected clock —\n * never SQL `now()` — and `stream()` is a poll loop. (A LISTEN/NOTIFY\n * upgrade can land later without any API change; callers never branch\n * on the transport.) Appends serialize per session via\n * `pg_advisory_xact_lock`, so concurrent writers can't race the index\n * assignment.\n */\n\nimport { A2Error } from './errors.ts'\nimport { idempotentReplay } from './idempotent-replay.ts'\nimport { decodeReturnedEventIds } from './store-codec.ts'\nimport { pollingStream } from './store-polling.ts'\nimport { retryableLazy } from './retryable-lazy.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 type StoreStateRead,\n} from './store.ts'\n\n/** The result shape this backend reads: just rows. */\nexport type PostgresQueryResult = { rows: Record<string, unknown>[] }\n\n/** One checked-out connection — what transactions run on. */\nexport type PostgresConnection = {\n query(text: string, values?: unknown[]): Promise<PostgresQueryResult>\n release(): void\n}\n\n/**\n * The minimal client this backend needs. `pg.Pool` matches it\n * structurally; so does PGlite (which has no `connect` — transactions\n * then serialize on an internal mutex, fine for a single-connection\n * client).\n */\nexport type PostgresClient = {\n query(text: string, values?: unknown[]): Promise<PostgresQueryResult>\n connect?(): Promise<PostgresConnection>\n end?(): Promise<void>\n}\n\nexport type PostgresStoreOptions = {\n /** Creates a `pg` Pool lazily (requires the optional peer dep `pg`). */\n connectionString?: string | undefined\n /** Bring your own client — a `pg` Pool, PGlite, a pooler wrapper. */\n client?: PostgresClient\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 PostgresStore = A2Store & {\n /** End the underlying pool/client (when this backend created it, or\n * the injected client has `end`). */\n close(): Promise<void>\n}\n\nconst SCHEMA = `\ncreate table if not exists a2_events (\n session_id text not null,\n idx bigint not null,\n event_type text not null,\n payload jsonb not null,\n event_id text not null,\n created_at bigint not null,\n cause jsonb,\n lane text,\n lane_ready boolean not null default false,\n processed_at bigint,\n processed_by_attempt integer,\n returned_event_ids jsonb,\n first_claimed_at bigint,\n last_claimed_at bigint,\n attempt_count integer not null default 0,\n failure_count integer not null default 0,\n last_failed_at bigint,\n last_failed_attempt integer,\n last_error text,\n failed_at bigint,\n claim_holder text,\n claim_expires_at bigint,\n primary key (session_id, idx)\n);\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 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 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 bigint not null,\n state jsonb not null,\n updated_at bigint not null,\n primary key (session_id, reducer_name)\n);\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 jsonb not null,\n seen bigint not null,\n at bigint not null,\n expires_at bigint not null,\n primary key (ns, participant, field)\n);\n`\n\nconst asNumber = (value: unknown): number => Number(value) // bigint columns arrive as strings\n\nconst toDate = (ms: unknown): Date | null =>\n ms === null || ms === undefined ? null : new Date(asNumber(ms))\n\nconst toCause = (raw: unknown): EventCause | null => {\n if (raw === null || raw === undefined) return null\n const value = (typeof raw === 'string' ? JSON.parse(raw) : raw) 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: Record<string, unknown>): StoredEvent => ({\n id: row['event_id'] as string,\n type: row['event_type'] as string,\n payload: row['payload'],\n index: asNumber(row['idx']),\n sessionId: row['session_id'] as string,\n createdAt: new Date(asNumber(row['created_at'])),\n cause: toCause(row['cause']),\n lane: (row['lane'] as string | null) ?? null,\n processedAt: toDate(row['processed_at']),\n processedByAttempt:\n row['processed_by_attempt'] === null ||\n row['processed_by_attempt'] === undefined\n ? null\n : asNumber(row['processed_by_attempt']),\n returnedEventIds:\n row['returned_event_ids'] === null ||\n row['returned_event_ids'] === undefined\n ? null\n : decodeReturnedEventIds(row['returned_event_ids']),\n firstClaimedAt: toDate(row['first_claimed_at']),\n lastClaimedAt: toDate(row['last_claimed_at']),\n attemptCount: asNumber(row['attempt_count']),\n failureCount: asNumber(row['failure_count']),\n lastFailedAt: toDate(row['last_failed_at']),\n lastFailedAttempt:\n row['last_failed_attempt'] === null ||\n row['last_failed_attempt'] === undefined\n ? null\n : asNumber(row['last_failed_attempt']),\n lastError: (row['last_error'] as string | null) ?? null,\n failedAt: toDate(row['failed_at']),\n claimHolder: (row['claim_holder'] as string | null) ?? null,\n claimExpiresAt: toDate(row['claim_expires_at']),\n})\n\nconst toEvent = (row: Record<string, unknown>): Event => ({\n id: row['event_id'] as string,\n type: row['event_type'] as string,\n payload: row['payload'],\n index: asNumber(row['idx']),\n sessionId: row['session_id'] as string,\n createdAt: new Date(asNumber(row['created_at'])),\n})\n\nconst wrap = async <T>(fn: () => Promise<T>): Promise<T> => {\n try {\n return await fn()\n } catch (err) {\n if (err instanceof A2Error || err instanceof TypeError) throw err\n throw new A2Error('STORE_UNAVAILABLE', 'postgres store operation failed', {\n cause: err,\n })\n }\n}\n\nconst jsonb = (value: unknown): string => JSON.stringify(value) ?? 'null'\n\nconst initializeSchema = async (client: PostgresClient): Promise<void> => {\n if (!client.connect) {\n await client.query(SCHEMA)\n return\n }\n\n const conn = await client.connect()\n try {\n await conn.query('begin')\n // `if not exists` does not serialize concurrent catalog writes. Every\n // serverless isolate takes the same transaction-scoped lock before DDL.\n await conn.query(\n \"select pg_advisory_xact_lock(hashtext('a2:postgres:schema'))\",\n )\n await conn.query(SCHEMA)\n await conn.query('commit')\n } catch (err) {\n await conn.query('rollback').catch(() => {})\n throw err\n } finally {\n conn.release()\n }\n}\n\nexport function postgres(options: PostgresStoreOptions = {}): PostgresStore {\n const clock = options.clock ?? SYSTEM_CLOCK\n const generateId = options.ids ?? RANDOM_IDS\n\n if (!options.client && !options.connectionString) {\n throw new TypeError(\n 'postgres() needs a connectionString or an injected client',\n )\n }\n\n // Lazy init: the first operation creates the client (dynamic import\n // of the optional peer dep `pg`) and the schema. Constructing the\n // backend does no I/O — same contract as every other adapter.\n const connection = retryableLazy(async () => {\n let owned: PostgresClient | null = null\n try {\n let c = options.client\n if (!c) {\n const pg = await import('pg').catch(() => {\n throw new A2Error(\n 'STORE_NOT_CONFIGURED',\n \"experimental-a2/store-postgres with a connectionString needs the 'pg' package (optional peer dependency) — install it, or inject a client\",\n )\n })\n c = new pg.default.Pool({\n connectionString: options.connectionString,\n }) as unknown as PostgresClient\n owned = c\n }\n await initializeSchema(c)\n return c\n } catch (err) {\n if (owned?.end) await owned.end().catch(() => {})\n throw err\n }\n })\n const client = connection.get\n\n // Transactions run on one connection. Pools check one out; a\n // single-connection client (PGlite) serializes on this mutex instead.\n let mutex: Promise<unknown> = Promise.resolve()\n const withTx = async <T>(\n fn: (query: PostgresConnection['query']) => Promise<T>,\n ): Promise<T> => {\n const c = await client()\n if (c.connect) {\n const conn = await c.connect()\n try {\n await conn.query('begin')\n const result = await fn((text, values) => conn.query(text, values))\n await conn.query('commit')\n return result\n } catch (err) {\n await conn.query('rollback').catch(() => {})\n throw err\n } finally {\n conn.release()\n }\n }\n const run = mutex.then(async () => {\n await c.query('begin')\n try {\n const result = await fn((text, values) => c.query(text, values))\n await c.query('commit')\n return result\n } catch (err) {\n await c.query('rollback').catch(() => {})\n throw err\n }\n })\n mutex = run.catch(() => {})\n return run\n }\n\n return {\n async append(sessionId, events) {\n if (events.length === 0) {\n return wrap(async () => {\n const c = await client()\n const { rows } = await c.query(\n `select exists(\n select 1 from a2_events\n where session_id = $1 and processed_at is null\n ) as has_pending`,\n [sessionId],\n )\n return {\n events: [],\n hasPending: rows[0]?.['has_pending'] === true,\n }\n })\n }\n return wrap(() =>\n withTx(async (query) => {\n // Serialize appends per session — index assignment must not\n // race across writers. Transaction-scoped, self-releasing.\n await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])\n\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 { rows: existing } = await query(\n `select stored.*,\n exists(\n select 1 from a2_events pending\n where pending.session_id = $2\n and pending.processed_at is null\n ) as session_has_pending\n from a2_events stored\n where stored.event_id = any($1)\n order by stored.idx`,\n [suppliedIds, sessionId],\n )\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 '${String(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: existing[0]?.['session_has_pending'] === true,\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`). `for update` serializes this read against\n // a concurrent `claimAvailable` — an unlocked read admits write\n // skew.\n const causeIndexes = [\n ...new Set(\n events\n .filter((e) => e.cause !== undefined)\n .map((e) => e.cause!.index),\n ),\n ]\n if (causeIndexes.length > 0) {\n const { rows: parents } = await query(\n `select idx, attempt_count, failed_at from a2_events\n where session_id = $1 and idx = any($2::bigint[])\n for update`,\n [sessionId, causeIndexes],\n )\n const byIndex = new Map(\n parents.map((row) => [asNumber(row['idx']), row]),\n )\n for (const e of events) {\n if (!e.cause) continue\n const parent = byIndex.get(e.cause.index)\n if (!parent) {\n throw new TypeError(\n `no event at index ${e.cause.index} in session '${sessionId}'`,\n )\n }\n if (\n asNumber(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\n const { rows: maxRows } = await query(\n `select coalesce(max(idx), 0) as max,\n exists(\n select 1 from a2_events pending\n where pending.session_id = $1\n and pending.processed_at is null\n ) as session_has_pending\n from a2_events\n where session_id = $1`,\n [sessionId],\n )\n const base = asNumber(maxRows[0]?.['max'])\n const now = clock.now().getTime()\n const inserted: StoredEvent[] = []\n for (let i = 0; i < events.length; i += 1) {\n const e = events[i]!\n const id = e.id ?? generateId()\n const index = base + 1 + i\n // oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts\n await query(\n `insert into a2_events\n (session_id, idx, event_type, payload, event_id, created_at,\n cause, lane, lane_ready, processed_at)\n values (\n $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,\n $8::text is null or not exists (\n select 1 from a2_events\n where session_id = $1 and lane = $8 and processed_at is null\n ),\n $9\n )`,\n [\n sessionId,\n index,\n e.type,\n jsonb(e.payload),\n id,\n now,\n e.cause ? jsonb(e.cause) : null,\n e.lane ?? null,\n e.settled ? now : null,\n ],\n )\n inserted.push({\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 maxRows[0]?.['session_has_pending'] === true ||\n events.some((event) => event.settled !== true),\n }\n }),\n )\n },\n\n async read(sessionId, opts) {\n return wrap(async () => {\n const c = await client()\n const conditions = ['session_id = $1']\n const params: unknown[] = [sessionId]\n if (opts?.afterIndex !== undefined) {\n params.push(opts.afterIndex)\n conditions.push(`idx > $${params.length}`)\n }\n if (opts?.throughIndex !== undefined) {\n params.push(opts.throughIndex)\n conditions.push(`idx <= $${params.length}`)\n }\n const { rows } = await c.query(\n `select * from a2_events where ${conditions.join(' and ')} order by idx`,\n params,\n )\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 withTx(async (query) => {\n const now = clock.now().getTime()\n const expiresAt = expiresAtMs ?? now + ttlMs\n const { rows } = await query(\n `with eligible as materialized (\n select event.idx\n from a2_events event\n where event.session_id = $1\n and event.processed_at is null\n and event.failed_at is null\n and event.lane_ready\n and (event.claim_expires_at is null or event.claim_expires_at <= $2)\n and not (event.idx = any($5::bigint[]))\n for update of event\n )\n update a2_events claimed set\n attempt_count = claimed.attempt_count + 1,\n first_claimed_at = coalesce(claimed.first_claimed_at, $2),\n last_claimed_at = $2,\n claim_holder = $3,\n claim_expires_at = $4\n from eligible\n where claimed.session_id = $1 and claimed.idx = eligible.idx\n returning claimed.*`,\n [sessionId, now, holder, expiresAt, [...excludeIndexes]],\n )\n if (rows.length > 0) {\n const events = rows\n .map(toStored)\n .toSorted((a, b) => a.index - b.index)\n return { outcome: 'claimed', events }\n }\n const { rows: activeRows } = await query(\n `select min(claim_expires_at) as retry_at\n from a2_events\n where session_id = $1\n and processed_at is null\n and failed_at is null\n and lane_ready\n and claim_expires_at > $2`,\n [sessionId, now],\n )\n const retryAt = activeRows[0]?.['retry_at']\n return retryAt === null || retryAt === undefined\n ? { outcome: 'settled' }\n : { outcome: 'busy', retryAt: new Date(asNumber(retryAt)) }\n }),\n )\n },\n\n async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {\n if (claims.length === 0) return { renewed: [], superseded: [] }\n return wrap(async () => {\n const c = await client()\n const now = clock.now().getTime()\n const expiresAt = expiresAtMs ?? now + ttlMs\n const indexes = claims.map((claim) => claim.index)\n const attempts = new Map(\n claims.map((claim) => [claim.index, claim.attempt]),\n )\n const { rows } = await c.query(\n `with listed as (\n select idx, attempt\n from unnest($3::bigint[], $6::int[]) as listed(idx, attempt)\n ),\n renewed as (\n update a2_events event set claim_expires_at = $1\n from listed\n where event.session_id = $2\n and event.idx = listed.idx\n and event.attempt_count = listed.attempt\n and event.processed_at is null\n and event.failed_at is null\n and event.claim_holder = $4\n and event.claim_expires_at > $5\n returning event.idx\n )\n select event.idx, event.attempt_count,\n exists(select 1 from renewed where renewed.idx = event.idx)\n as renewed\n from a2_events event\n where event.session_id = $2 and event.idx = any($3::bigint[])`,\n [\n expiresAt,\n sessionId,\n indexes,\n holder,\n now,\n claims.map((claim) => claim.attempt),\n ],\n )\n const renewed: number[] = []\n const superseded: number[] = []\n for (const row of rows) {\n const index = asNumber(row['idx'])\n if (row['renewed'] === true) renewed.push(index)\n else if (\n asNumber(row['attempt_count']) > (attempts.get(index) ?? 0)\n ) {\n superseded.push(index)\n }\n }\n return {\n renewed: renewed.toSorted((a, b) => a - b),\n superseded: superseded.toSorted((a, b) => a - b),\n }\n })\n },\n\n async completeAttempt({ sessionId, index, attempt, events }) {\n return wrap(() =>\n withTx(async (query) => {\n const { rows: parentRows } = await query(\n `select * from a2_events\n where session_id = $1 and idx = $2\n for update`,\n [sessionId, index],\n )\n const parent = parentRows[0]\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 if (parent['processed_at'] !== null) {\n if (asNumber(parent['processed_by_attempt']) !== attempt) {\n return { outcome: 'superseded' }\n }\n const returnedIds =\n parent['returned_event_ids'] === null ||\n parent['returned_event_ids'] === undefined\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 if (events.length === 0) {\n return { outcome: 'completed', events: [] }\n }\n const { rows: existing } = await query(\n `select * from a2_events\n where event_id = any($1::text[])`,\n [ids],\n )\n const byId = new Map(\n existing.map((row) => [row['event_id'] as string, row]),\n )\n const ordered = ids.map((id) => byId.get(id))\n if (\n ordered.some((row) => {\n const cause = row ? toCause(row['cause']) : null\n return (\n !row ||\n row['session_id'] !== sessionId ||\n cause?.index !== index ||\n cause.attempt !== attempt\n )\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 Record<string, unknown>[]).map(toStored),\n ),\n }\n }\n if (\n asNumber(parent['attempt_count']) !== attempt ||\n parent['claim_holder'] === null ||\n parent['claim_holder'] === undefined ||\n parent['failed_at'] !== null\n ) {\n return { outcome: 'superseded' }\n }\n\n await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])\n if (ids.length > 0) {\n const { rows: existing } = await query(\n 'select event_id from a2_events where event_id = any($1::text[])',\n [ids],\n )\n if (existing.length > 0) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'returned event batch contains an already-appended event id',\n )\n }\n }\n\n const { rows: maxRows } = await query(\n 'select coalesce(max(idx), 0) as max from a2_events where session_id = $1',\n [sessionId],\n )\n const base = asNumber(maxRows[0]?.['max'])\n const now = clock.now().getTime()\n const inserted: StoredEvent[] = []\n for (let offset = 0; offset < events.length; offset += 1) {\n const event = events[offset]!\n const childIndex = base + offset + 1\n // oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts\n const { rows } = await query(\n `insert into a2_events\n (session_id, idx, event_type, payload, event_id, created_at,\n cause, lane, lane_ready, processed_at)\n values (\n $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,\n $8::text is null or not exists (\n select 1 from a2_events\n where session_id = $1 and lane = $8 and processed_at is null\n ),\n $9\n )\n returning *`,\n [\n sessionId,\n childIndex,\n event.type,\n jsonb(event.payload),\n event.id,\n now,\n jsonb({ index, attempt }),\n event.lane ?? null,\n event.settled ? now : null,\n ],\n )\n inserted.push(toStored(rows[0]!))\n }\n await query(\n `update a2_events set\n processed_at = $1,\n processed_by_attempt = $2,\n returned_event_ids = $5::jsonb,\n lane_ready = false,\n claim_holder = null,\n claim_expires_at = null\n where session_id = $3 and idx = $4`,\n [now, attempt, sessionId, index, jsonb(ids)],\n )\n const parentLane = (parent['lane'] as string | null) ?? null\n if (parentLane !== null) {\n await query(\n `update a2_events set lane_ready = true\n where session_id = $1\n and idx = (\n select idx from a2_events\n where session_id = $1 and lane = $2 and processed_at is null\n order by idx limit 1\n )`,\n [sessionId, parentLane],\n )\n }\n return { outcome: 'completed', events: inserted }\n }),\n )\n },\n\n async failAttempt({ sessionId, index, attempt, error, maxFailures }) {\n return wrap(async () => {\n const c = await client()\n const { rows } = await c.query(\n `with current as materialized (\n select e.* from a2_events e\n where e.session_id = $1 and e.idx = $2\n for update of e\n ), updated as (\n update a2_events e set\n failure_count = e.failure_count + 1,\n last_error = $4,\n last_failed_at = $6,\n last_failed_attempt = $3,\n failed_at = case\n when e.failure_count + 1 >= $5 then $6\n else e.failed_at\n end,\n claim_holder = null,\n claim_expires_at = null\n from current\n where e.session_id = $1\n and e.idx = $2\n and current.processed_at is null\n and current.failed_at is null\n and current.attempt_count = $3\n and current.claim_holder is not null\n returning e.failure_count, e.failed_at\n )\n select case\n when not exists (select 1 from current) then 'missing'\n when exists (\n select 1 from updated where failed_at is not null\n ) then 'dead_lettered'\n when exists (select 1 from updated) then 'failed'\n when exists (\n select 1 from current\n where processed_at is not null or attempt_count <> $3\n ) then 'superseded'\n when exists (\n select 1 from current where failed_at is not null\n ) then 'dead_lettered'\n when exists (\n select 1 from current\n where claim_holder is null and last_failed_attempt = $3\n ) then 'failed'\n else 'superseded'\n end as outcome,\n coalesce(\n (select failure_count from updated),\n (select failure_count from current)\n ) as failure_count`,\n [\n sessionId,\n index,\n attempt,\n error,\n maxFailures,\n clock.now().getTime(),\n ],\n )\n const row = rows[0]\n if (!row || row['outcome'] === 'missing') {\n throw new TypeError(\n `no event at index ${index} in session '${sessionId}'`,\n )\n }\n return {\n outcome: row['outcome'] as 'failed' | 'dead_lettered' | 'superseded',\n failureCount: asNumber(row['failure_count']),\n }\n })\n },\n\n async readState(sessionId, reducerName) {\n return wrap(async () => {\n const c = await client()\n const { rows } = await c.query(\n `with snapshot as materialized (\n select up_to_index, state\n from a2_snapshots\n where session_id = $1 and reducer_name = $2\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::text as session_id, null::bigint as idx,\n null::text as event_type, null::jsonb as payload,\n null::text as event_id, null::bigint as created_at\n from snapshot\n union all\n select 1 as row_order, 'event' as row_kind,\n null::bigint as snapshot_index,\n null::jsonb as snapshot_state,\n event.session_id, event.idx, event.event_type,\n event.payload, event.event_id, event.created_at\n from a2_events event\n where event.session_id = $1\n and event.idx > coalesce((select up_to_index from snapshot), 0)\n order by row_order, idx`,\n [sessionId, reducerName],\n )\n const snapshot = rows.find((row) => row['row_kind'] === 'snapshot')\n return {\n snapshot: snapshot\n ? {\n index: asNumber(snapshot['snapshot_index']),\n state: snapshot['snapshot_state'],\n }\n : null,\n events: rows\n .filter((row) => row['row_kind'] === 'event')\n .map(toEvent),\n }\n })\n },\n\n async readStates(sessionIds, reducerName) {\n if (sessionIds.length === 0) return []\n return wrap(async () => {\n const c = await client()\n // One statement, one MVCC snapshot — each session's tail is\n // bounded by the same snapshot row the read returns.\n const { rows } = await c.query(\n `with snapshot as materialized (\n select session_id, up_to_index, state\n from a2_snapshots\n where session_id = any($1::text[]) and reducer_name = $2\n )\n select 0 as row_order, 'snapshot' as row_kind,\n snapshot.session_id,\n snapshot.up_to_index as snapshot_index,\n snapshot.state as snapshot_state,\n null::bigint as idx, null::text as event_type,\n null::jsonb as payload, null::text as event_id,\n null::bigint as created_at\n from snapshot\n union all\n select 1 as row_order, 'event' as row_kind,\n event.session_id,\n null::bigint as snapshot_index,\n null::jsonb as snapshot_state,\n event.idx, event.event_type, event.payload,\n event.event_id, event.created_at\n from a2_events event\n left join snapshot on snapshot.session_id = event.session_id\n where event.session_id = any($1::text[])\n and event.idx > coalesce(snapshot.up_to_index, 0)\n order by row_order, session_id, idx`,\n [sessionIds, reducerName],\n )\n const bySession = new Map<string, StoreStateRead>()\n const readOf = (sessionId: string): StoreStateRead => {\n let read = bySession.get(sessionId)\n if (!read) {\n read = { snapshot: null, events: [] }\n bySession.set(sessionId, read)\n }\n return read\n }\n for (const row of rows) {\n const read = readOf(row['session_id'] as string)\n if (row['row_kind'] === 'snapshot') {\n read.snapshot = {\n index: asNumber(row['snapshot_index']),\n state: row['snapshot_state'],\n }\n } else {\n read.events.push(toEvent(row))\n }\n }\n return sessionIds.map(\n (sessionId) =>\n bySession.get(sessionId) ?? { snapshot: null, events: [] },\n )\n })\n },\n\n async putSnapshot(sessionId, reducerName, index, state) {\n await wrap(async () => {\n const c = await client()\n // Guarded upsert: a slower concurrent writer must never clobber\n // a further-along snapshot (spec §3).\n await c.query(\n `insert into a2_snapshots (session_id, reducer_name, up_to_index, state, updated_at)\n values ($1, $2, $3, $4::jsonb, $5)\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 [sessionId, reducerName, index, jsonb(state), 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 await wrap(async () => {\n const c = await client()\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 // oxlint-disable-next-line no-await-in-loop -- independent per-field registers\n await (value === null\n ? c.query(\n `delete from a2_presence\n where ns = $1 and participant = $2 and field = $3\n and at <= $4`,\n [ns, participant, field, atMs],\n )\n : c.query(\n `insert into a2_presence\n (ns, participant, field, value, seen, at, expires_at)\n values ($1, $2, $3, $4::jsonb, $5, $6, $7)\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 ns,\n participant,\n field,\n jsonb(value),\n meta.seen,\n atMs,\n expiresAtMs,\n ],\n ))\n }\n })\n },\n\n async read(ns) {\n return wrap(async () => {\n const c = await client()\n const now = clock.now().getTime()\n // Sweep and read share one statement (one snapshot): the\n // delete is opportunistic cleanup; the filter alone keeps\n // expired rows out of this result.\n const { rows } = await c.query(\n `with swept as (\n delete from a2_presence\n where ns = $1 and expires_at <= $2\n )\n select participant, field, value, seen, at, expires_at\n from a2_presence\n where ns = $1 and expires_at > $2\n order by participant, field`,\n [ns, now],\n )\n return rows.map((row) => ({\n participant: row['participant'] as string,\n field: row['field'] as string,\n value: row['value'],\n seen: asNumber(row['seen']),\n at: new Date(asNumber(row['at'])),\n expiresAt: new Date(asNumber(row['expires_at'])),\n }))\n })\n },\n },\n\n stream(sessionId, opts) {\n const readAfter = (afterIndex: number): Promise<StoredEvent[]> =>\n wrap(async () => {\n const c = await client()\n const { rows } = await c.query(\n 'select * from a2_events where session_id = $1 and idx > $2 order by idx',\n [sessionId, afterIndex],\n )\n return rows.map(toStored)\n })\n return pollingStream(\n readAfter,\n opts?.startAfter !== undefined ? { startAfter: opts.startAfter } : {},\n )\n },\n\n async close() {\n const current = connection.peek()\n if (!current) return\n const c = await current\n await c.end?.()\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"store-postgres.js","names":[],"sources":["../src/store-postgres.ts"],"sourcesContent":["/**\n * experimental-a2/store-postgres — postgres store backend (the production path).\n *\n * Implements the A2Store interface over any client that speaks the tiny\n * `PostgresClient` shape below — a `pg` Pool matches it structurally\n * (and is what `connectionString` creates, lazily; `pg` is an optional\n * peer dependency). The conformance suite in test/conformance is the\n * executable contract, run against PGlite (real Postgres, in-process)\n * and against a real server when `TEST_DATABASE_URL` is set.\n *\n * Semantics mirror the sqlite backend: `index` is stored as `idx`,\n * timestamps are epoch milliseconds written from the injected clock —\n * never SQL `now()` — and `stream()` is a poll loop. (A LISTEN/NOTIFY\n * upgrade can land later without any API change; callers never branch\n * on the transport.) Appends serialize per session via\n * `pg_advisory_xact_lock`, so concurrent writers can't race the index\n * assignment.\n */\n\nimport { A2Error } from './errors.ts'\nimport { idempotentReplay } from './idempotent-replay.ts'\nimport { decodeReturnedEventIds } from './store-codec.ts'\nimport { pollingStream } from './store-polling.ts'\nimport { retryableLazy } from './retryable-lazy.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 type StoreStateReadRequest,\n type StoreStateRead,\n} from './store.ts'\n\n/** The result shape this backend reads: just rows. */\nexport type PostgresQueryResult = { rows: Record<string, unknown>[] }\n\n/** One checked-out connection — what transactions run on. */\nexport type PostgresConnection = {\n query(text: string, values?: unknown[]): Promise<PostgresQueryResult>\n release(): void\n}\n\n/**\n * The minimal client this backend needs. `pg.Pool` matches it\n * structurally; so does PGlite (which has no `connect` — transactions\n * then serialize on an internal mutex, fine for a single-connection\n * client).\n */\nexport type PostgresClient = {\n query(text: string, values?: unknown[]): Promise<PostgresQueryResult>\n connect?(): Promise<PostgresConnection>\n end?(): Promise<void>\n}\n\nexport type PostgresStoreOptions = {\n /** Creates a `pg` Pool lazily (requires the optional peer dep `pg`). */\n connectionString?: string | undefined\n /** Bring your own client — a `pg` Pool, PGlite, a pooler wrapper. */\n client?: PostgresClient\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 PostgresStore = A2Store & {\n /** End the underlying pool/client (when this backend created it, or\n * the injected client has `end`). */\n close(): Promise<void>\n}\n\nconst SCHEMA = `\ncreate table if not exists a2_events (\n session_id text not null,\n idx bigint not null,\n event_type text not null,\n payload jsonb not null,\n event_id text not null,\n created_at bigint not null,\n cause jsonb,\n lane text,\n lane_ready boolean not null default false,\n processed_at bigint,\n processed_by_attempt integer,\n returned_event_ids jsonb,\n first_claimed_at bigint,\n last_claimed_at bigint,\n attempt_count integer not null default 0,\n failure_count integer not null default 0,\n last_failed_at bigint,\n last_failed_attempt integer,\n last_error text,\n failed_at bigint,\n claim_holder text,\n claim_expires_at bigint,\n primary key (session_id, idx)\n);\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 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 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 bigint not null,\n state jsonb not null,\n updated_at bigint not null,\n primary key (session_id, reducer_name)\n);\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 bigint not null,\n state jsonb not null,\n updated_at bigint not null,\n primary key (session_id, reducer_name, up_to_index)\n);\n\ncreate table if not exists a2_snapshot_pins (\n session_id text not null,\n event_index bigint not null,\n reducer_name text not null,\n up_to_index bigint not null,\n primary key (session_id, event_index, reducer_name, up_to_index)\n);\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 jsonb not null,\n seen bigint not null,\n at bigint not null,\n expires_at bigint not null,\n primary key (ns, participant, field)\n);\n`\n\nconst asNumber = (value: unknown): number => Number(value) // bigint columns arrive as strings\n\nconst toDate = (ms: unknown): Date | null =>\n ms === null || ms === undefined ? null : new Date(asNumber(ms))\n\nconst toCause = (raw: unknown): EventCause | null => {\n if (raw === null || raw === undefined) return null\n const value = (typeof raw === 'string' ? JSON.parse(raw) : raw) 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: Record<string, unknown>): StoredEvent => ({\n id: row['event_id'] as string,\n type: row['event_type'] as string,\n payload: row['payload'],\n index: asNumber(row['idx']),\n sessionId: row['session_id'] as string,\n createdAt: new Date(asNumber(row['created_at'])),\n cause: toCause(row['cause']),\n lane: (row['lane'] as string | null) ?? null,\n processedAt: toDate(row['processed_at']),\n processedByAttempt:\n row['processed_by_attempt'] === null ||\n row['processed_by_attempt'] === undefined\n ? null\n : asNumber(row['processed_by_attempt']),\n returnedEventIds:\n row['returned_event_ids'] === null ||\n row['returned_event_ids'] === undefined\n ? null\n : decodeReturnedEventIds(row['returned_event_ids']),\n firstClaimedAt: toDate(row['first_claimed_at']),\n lastClaimedAt: toDate(row['last_claimed_at']),\n attemptCount: asNumber(row['attempt_count']),\n failureCount: asNumber(row['failure_count']),\n lastFailedAt: toDate(row['last_failed_at']),\n lastFailedAttempt:\n row['last_failed_attempt'] === null ||\n row['last_failed_attempt'] === undefined\n ? null\n : asNumber(row['last_failed_attempt']),\n lastError: (row['last_error'] as string | null) ?? null,\n failedAt: toDate(row['failed_at']),\n claimHolder: (row['claim_holder'] as string | null) ?? null,\n claimExpiresAt: toDate(row['claim_expires_at']),\n})\n\nconst toEvent = (row: Record<string, unknown>): Event => ({\n id: row['event_id'] as string,\n type: row['event_type'] as string,\n payload: row['payload'],\n index: asNumber(row['idx']),\n sessionId: row['session_id'] as string,\n createdAt: new Date(asNumber(row['created_at'])),\n})\n\nconst wrap = async <T>(fn: () => Promise<T>): Promise<T> => {\n try {\n return await fn()\n } catch (err) {\n if (err instanceof A2Error || err instanceof TypeError) throw err\n throw new A2Error('STORE_UNAVAILABLE', 'postgres store operation failed', {\n cause: err,\n })\n }\n}\n\nconst jsonb = (value: unknown): string => JSON.stringify(value) ?? 'null'\n\nconst initializeSchema = async (client: PostgresClient): Promise<void> => {\n if (!client.connect) {\n await client.query(SCHEMA)\n return\n }\n\n const conn = await client.connect()\n try {\n await conn.query('begin')\n // `if not exists` does not serialize concurrent catalog writes. Every\n // serverless isolate takes the same transaction-scoped lock before DDL.\n await conn.query(\n \"select pg_advisory_xact_lock(hashtext('a2:postgres:schema'))\",\n )\n await conn.query(SCHEMA)\n await conn.query('commit')\n } catch (err) {\n await conn.query('rollback').catch(() => {})\n throw err\n } finally {\n conn.release()\n }\n}\n\nexport function postgres(options: PostgresStoreOptions = {}): PostgresStore {\n const clock = options.clock ?? SYSTEM_CLOCK\n const generateId = options.ids ?? RANDOM_IDS\n\n if (!options.client && !options.connectionString) {\n throw new TypeError(\n 'postgres() needs a connectionString or an injected client',\n )\n }\n\n // Lazy init: the first operation creates the client (dynamic import\n // of the optional peer dep `pg`) and the schema. Constructing the\n // backend does no I/O — same contract as every other adapter.\n const connection = retryableLazy(async () => {\n let owned: PostgresClient | null = null\n try {\n let c = options.client\n if (!c) {\n const pg = await import('pg').catch(() => {\n throw new A2Error(\n 'STORE_NOT_CONFIGURED',\n \"experimental-a2/store-postgres with a connectionString needs the 'pg' package (optional peer dependency) — install it, or inject a client\",\n )\n })\n c = new pg.default.Pool({\n connectionString: options.connectionString,\n }) as unknown as PostgresClient\n owned = c\n }\n await initializeSchema(c)\n return c\n } catch (err) {\n if (owned?.end) await owned.end().catch(() => {})\n throw err\n }\n })\n const client = connection.get\n\n const readStateBatch = async (\n requests: readonly StoreStateReadRequest[],\n reducerName: string,\n ): Promise<StoreStateRead[]> => {\n if (requests.length === 0) return []\n const c = await client()\n const input = requests.map((request, position) => ({\n position,\n sessionId: request.sessionId,\n throughIndex: request.throughIndex ?? null,\n snapshotThroughIndex:\n request.snapshotThroughIndex ?? request.throughIndex ?? null,\n }))\n const { rows } = await c.query(\n `with request as materialized (\n select * from jsonb_to_recordset($1::jsonb) as value(\n position integer,\n \"sessionId\" text,\n \"throughIndex\" bigint,\n \"snapshotThroughIndex\" bigint\n )\n ), selected as materialized (\n select request.position,\n request.\"sessionId\" as session_id,\n request.\"throughIndex\" as through_index,\n head.up_to_index as head_index,\n candidate.up_to_index as snapshot_index,\n candidate.state as snapshot_state\n from request\n left join a2_snapshots head\n on head.session_id = request.\"sessionId\"\n and head.reducer_name = $2\n left join lateral (\n select up_to_index, state from (\n select head.up_to_index, head.state\n where request.\"snapshotThroughIndex\" is null\n or head.up_to_index <= request.\"snapshotThroughIndex\"\n union all\n select history.up_to_index, history.state\n from a2_snapshot_history history\n where history.session_id = request.\"sessionId\"\n and history.reducer_name = $2\n and (\n request.\"snapshotThroughIndex\" is null\n or history.up_to_index <= request.\"snapshotThroughIndex\"\n )\n ) candidates\n order by up_to_index desc\n limit 1\n ) candidate on true\n )\n select selected.position, 0 as row_order, selected.session_id,\n selected.head_index, selected.snapshot_index,\n selected.snapshot_state,\n null::bigint as idx, null::text as event_type,\n null::jsonb as payload, null::text as event_id,\n null::bigint as created_at\n from selected\n union all\n select selected.position, 1 as row_order, event.session_id,\n selected.head_index, null::bigint as snapshot_index,\n null::jsonb as snapshot_state,\n event.idx, event.event_type, event.payload,\n event.event_id, event.created_at\n from selected\n join a2_events event on event.session_id = selected.session_id\n where event.idx > coalesce(selected.snapshot_index, 0)\n and (\n selected.through_index is null\n or event.idx <= selected.through_index\n )\n order by position, row_order, idx`,\n [jsonb(input), reducerName],\n )\n const reads = requests.map<StoreStateRead>(() => ({\n headIndex: null,\n snapshot: null,\n events: [],\n }))\n for (const row of rows) {\n const read = reads[asNumber(row['position'])]!\n if (row['row_order'] === 0 || row['row_order'] === '0') {\n read.headIndex =\n row['head_index'] === null ? null : asNumber(row['head_index'])\n if (row['snapshot_index'] !== null) {\n read.snapshot = {\n index: asNumber(row['snapshot_index']),\n state: row['snapshot_state'],\n }\n }\n } else {\n read.events.push(toEvent(row))\n }\n }\n return reads\n }\n\n // Transactions run on one connection. Pools check one out; a\n // single-connection client (PGlite) serializes on this mutex instead.\n let mutex: Promise<unknown> = Promise.resolve()\n const withTx = async <T>(\n fn: (query: PostgresConnection['query']) => Promise<T>,\n ): Promise<T> => {\n const c = await client()\n if (c.connect) {\n const conn = await c.connect()\n try {\n await conn.query('begin')\n const result = await fn((text, values) => conn.query(text, values))\n await conn.query('commit')\n return result\n } catch (err) {\n await conn.query('rollback').catch(() => {})\n throw err\n } finally {\n conn.release()\n }\n }\n const run = mutex.then(async () => {\n await c.query('begin')\n try {\n const result = await fn((text, values) => c.query(text, values))\n await c.query('commit')\n return result\n } catch (err) {\n await c.query('rollback').catch(() => {})\n throw err\n }\n })\n mutex = run.catch(() => {})\n return run\n }\n\n return {\n async append(sessionId, events) {\n if (events.length === 0) {\n return wrap(async () => {\n const c = await client()\n const { rows } = await c.query(\n `select exists(\n select 1 from a2_events\n where session_id = $1 and processed_at is null\n ) as has_pending`,\n [sessionId],\n )\n return {\n events: [],\n hasPending: rows[0]?.['has_pending'] === true,\n }\n })\n }\n return wrap(() =>\n withTx(async (query) => {\n // Serialize appends per session — index assignment must not\n // race across writers. Transaction-scoped, self-releasing.\n await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])\n\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 { rows: existing } = await query(\n `select stored.*,\n exists(\n select 1 from a2_events pending\n where pending.session_id = $2\n and pending.processed_at is null\n ) as session_has_pending\n from a2_events stored\n where stored.event_id = any($1)\n order by stored.idx`,\n [suppliedIds, sessionId],\n )\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 '${String(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: existing[0]?.['session_has_pending'] === true,\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`). `for update` serializes this read against\n // a concurrent `claimAvailable` — an unlocked read admits write\n // skew.\n const causeIndexes = [\n ...new Set(\n events\n .filter((e) => e.cause !== undefined)\n .map((e) => e.cause!.index),\n ),\n ]\n if (causeIndexes.length > 0) {\n const { rows: parents } = await query(\n `select idx, attempt_count, failed_at from a2_events\n where session_id = $1 and idx = any($2::bigint[])\n for update`,\n [sessionId, causeIndexes],\n )\n const byIndex = new Map(\n parents.map((row) => [asNumber(row['idx']), row]),\n )\n for (const e of events) {\n if (!e.cause) continue\n const parent = byIndex.get(e.cause.index)\n if (!parent) {\n throw new TypeError(\n `no event at index ${e.cause.index} in session '${sessionId}'`,\n )\n }\n if (\n asNumber(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\n const { rows: maxRows } = await query(\n `select coalesce(max(idx), 0) as max,\n exists(\n select 1 from a2_events pending\n where pending.session_id = $1\n and pending.processed_at is null\n ) as session_has_pending\n from a2_events\n where session_id = $1`,\n [sessionId],\n )\n const base = asNumber(maxRows[0]?.['max'])\n const now = clock.now().getTime()\n const inserted: StoredEvent[] = []\n for (let i = 0; i < events.length; i += 1) {\n const e = events[i]!\n const id = e.id ?? generateId()\n const index = base + 1 + i\n // oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts\n await query(\n `insert into a2_events\n (session_id, idx, event_type, payload, event_id, created_at,\n cause, lane, lane_ready, processed_at)\n values (\n $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,\n $8::text is null or not exists (\n select 1 from a2_events\n where session_id = $1 and lane = $8 and processed_at is null\n ),\n $9\n )`,\n [\n sessionId,\n index,\n e.type,\n jsonb(e.payload),\n id,\n now,\n e.cause ? jsonb(e.cause) : null,\n e.lane ?? null,\n e.settled ? now : null,\n ],\n )\n inserted.push({\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 maxRows[0]?.['session_has_pending'] === true ||\n events.some((event) => event.settled !== true),\n }\n }),\n )\n },\n\n async read(sessionId, opts) {\n return wrap(async () => {\n const c = await client()\n const conditions = ['session_id = $1']\n const params: unknown[] = [sessionId]\n if (opts?.afterIndex !== undefined) {\n params.push(opts.afterIndex)\n conditions.push(`idx > $${params.length}`)\n }\n if (opts?.throughIndex !== undefined) {\n params.push(opts.throughIndex)\n conditions.push(`idx <= $${params.length}`)\n }\n const { rows } = await c.query(\n `select * from a2_events where ${conditions.join(' and ')} order by idx`,\n params,\n )\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 withTx(async (query) => {\n const now = clock.now().getTime()\n const expiresAt = expiresAtMs ?? now + ttlMs\n const { rows } = await query(\n `with eligible as materialized (\n select event.idx\n from a2_events event\n where event.session_id = $1\n and event.processed_at is null\n and event.failed_at is null\n and event.lane_ready\n and (event.claim_expires_at is null or event.claim_expires_at <= $2)\n and not (event.idx = any($5::bigint[]))\n for update of event\n )\n update a2_events claimed set\n attempt_count = claimed.attempt_count + 1,\n first_claimed_at = coalesce(claimed.first_claimed_at, $2),\n last_claimed_at = $2,\n claim_holder = $3,\n claim_expires_at = $4\n from eligible\n where claimed.session_id = $1 and claimed.idx = eligible.idx\n returning claimed.*`,\n [sessionId, now, holder, expiresAt, [...excludeIndexes]],\n )\n if (rows.length > 0) {\n const events = rows\n .map(toStored)\n .toSorted((a, b) => a.index - b.index)\n return { outcome: 'claimed', events }\n }\n const { rows: activeRows } = await query(\n `select min(claim_expires_at) as retry_at\n from a2_events\n where session_id = $1\n and processed_at is null\n and failed_at is null\n and lane_ready\n and claim_expires_at > $2`,\n [sessionId, now],\n )\n const retryAt = activeRows[0]?.['retry_at']\n return retryAt === null || retryAt === undefined\n ? { outcome: 'settled' }\n : { outcome: 'busy', retryAt: new Date(asNumber(retryAt)) }\n }),\n )\n },\n\n async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {\n if (claims.length === 0) return { renewed: [], superseded: [] }\n return wrap(async () => {\n const c = await client()\n const now = clock.now().getTime()\n const expiresAt = expiresAtMs ?? now + ttlMs\n const indexes = claims.map((claim) => claim.index)\n const attempts = new Map(\n claims.map((claim) => [claim.index, claim.attempt]),\n )\n const { rows } = await c.query(\n `with listed as (\n select idx, attempt\n from unnest($3::bigint[], $6::int[]) as listed(idx, attempt)\n ),\n renewed as (\n update a2_events event set claim_expires_at = $1\n from listed\n where event.session_id = $2\n and event.idx = listed.idx\n and event.attempt_count = listed.attempt\n and event.processed_at is null\n and event.failed_at is null\n and event.claim_holder = $4\n and event.claim_expires_at > $5\n returning event.idx\n )\n select event.idx, event.attempt_count,\n exists(select 1 from renewed where renewed.idx = event.idx)\n as renewed\n from a2_events event\n where event.session_id = $2 and event.idx = any($3::bigint[])`,\n [\n expiresAt,\n sessionId,\n indexes,\n holder,\n now,\n claims.map((claim) => claim.attempt),\n ],\n )\n const renewed: number[] = []\n const superseded: number[] = []\n for (const row of rows) {\n const index = asNumber(row['idx'])\n if (row['renewed'] === true) renewed.push(index)\n else if (\n asNumber(row['attempt_count']) > (attempts.get(index) ?? 0)\n ) {\n superseded.push(index)\n }\n }\n return {\n renewed: renewed.toSorted((a, b) => a - b),\n superseded: superseded.toSorted((a, b) => a - b),\n }\n })\n },\n\n async completeAttempt({ sessionId, index, attempt, events }) {\n return wrap(() =>\n withTx(async (query) => {\n const { rows: parentRows } = await query(\n `select * from a2_events\n where session_id = $1 and idx = $2\n for update`,\n [sessionId, index],\n )\n const parent = parentRows[0]\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 if (parent['processed_at'] !== null) {\n if (asNumber(parent['processed_by_attempt']) !== attempt) {\n return { outcome: 'superseded' }\n }\n const returnedIds =\n parent['returned_event_ids'] === null ||\n parent['returned_event_ids'] === undefined\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 if (events.length === 0) {\n return { outcome: 'completed', events: [] }\n }\n const { rows: existing } = await query(\n `select * from a2_events\n where event_id = any($1::text[])`,\n [ids],\n )\n const byId = new Map(\n existing.map((row) => [row['event_id'] as string, row]),\n )\n const ordered = ids.map((id) => byId.get(id))\n if (\n ordered.some((row) => {\n const cause = row ? toCause(row['cause']) : null\n return (\n !row ||\n row['session_id'] !== sessionId ||\n cause?.index !== index ||\n cause.attempt !== attempt\n )\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 Record<string, unknown>[]).map(toStored),\n ),\n }\n }\n if (\n asNumber(parent['attempt_count']) !== attempt ||\n parent['claim_holder'] === null ||\n parent['claim_holder'] === undefined ||\n parent['failed_at'] !== null\n ) {\n return { outcome: 'superseded' }\n }\n\n await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])\n if (ids.length > 0) {\n const { rows: existing } = await query(\n 'select event_id from a2_events where event_id = any($1::text[])',\n [ids],\n )\n if (existing.length > 0) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'returned event batch contains an already-appended event id',\n )\n }\n }\n\n const { rows: maxRows } = await query(\n 'select coalesce(max(idx), 0) as max from a2_events where session_id = $1',\n [sessionId],\n )\n const base = asNumber(maxRows[0]?.['max'])\n const now = clock.now().getTime()\n const inserted: StoredEvent[] = []\n for (let offset = 0; offset < events.length; offset += 1) {\n const event = events[offset]!\n const childIndex = base + offset + 1\n // oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts\n const { rows } = await query(\n `insert into a2_events\n (session_id, idx, event_type, payload, event_id, created_at,\n cause, lane, lane_ready, processed_at)\n values (\n $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,\n $8::text is null or not exists (\n select 1 from a2_events\n where session_id = $1 and lane = $8 and processed_at is null\n ),\n $9\n )\n returning *`,\n [\n sessionId,\n childIndex,\n event.type,\n jsonb(event.payload),\n event.id,\n now,\n jsonb({ index, attempt }),\n event.lane ?? null,\n event.settled ? now : null,\n ],\n )\n inserted.push(toStored(rows[0]!))\n }\n await query(\n `update a2_events set\n processed_at = $1,\n processed_by_attempt = $2,\n returned_event_ids = $5::jsonb,\n lane_ready = false,\n claim_holder = null,\n claim_expires_at = null\n where session_id = $3 and idx = $4`,\n [now, attempt, sessionId, index, jsonb(ids)],\n )\n await query(\n `delete from a2_snapshot_pins\n where session_id = $1 and event_index = $2`,\n [sessionId, index],\n )\n await query(\n `delete from a2_snapshot_history history\n where history.session_id = $1\n and not exists (\n select 1 from a2_snapshot_pins pin\n where pin.session_id = history.session_id\n and pin.reducer_name = history.reducer_name\n and pin.up_to_index = history.up_to_index\n )`,\n [sessionId],\n )\n const parentLane = (parent['lane'] as string | null) ?? null\n if (parentLane !== null) {\n await query(\n `update a2_events set lane_ready = true\n where session_id = $1\n and idx = (\n select idx from a2_events\n where session_id = $1 and lane = $2 and processed_at is null\n order by idx limit 1\n )`,\n [sessionId, parentLane],\n )\n }\n return { outcome: 'completed', events: inserted }\n }),\n )\n },\n\n async failAttempt({ sessionId, index, attempt, error, maxFailures }) {\n return wrap(() =>\n withTx(async (query) => {\n const { rows } = await query(\n `with current as materialized (\n select e.* from a2_events e\n where e.session_id = $1 and e.idx = $2\n for update of e\n ), updated as (\n update a2_events e set\n failure_count = e.failure_count + 1,\n last_error = $4,\n last_failed_at = $6,\n last_failed_attempt = $3,\n failed_at = case\n when e.failure_count + 1 >= $5 then $6\n else e.failed_at\n end,\n claim_holder = null,\n claim_expires_at = null\n from current\n where e.session_id = $1\n and e.idx = $2\n and current.processed_at is null\n and current.failed_at is null\n and current.attempt_count = $3\n and current.claim_holder is not null\n returning e.failure_count, e.failed_at\n )\n select case\n when not exists (select 1 from current) then 'missing'\n when exists (\n select 1 from updated where failed_at is not null\n ) then 'dead_lettered'\n when exists (select 1 from updated) then 'failed'\n when exists (\n select 1 from current\n where processed_at is not null or attempt_count <> $3\n ) then 'superseded'\n when exists (\n select 1 from current where failed_at is not null\n ) then 'dead_lettered'\n when exists (\n select 1 from current\n where claim_holder is null and last_failed_attempt = $3\n ) then 'failed'\n else 'superseded'\n end as outcome,\n coalesce(\n (select failure_count from updated),\n (select failure_count from current)\n ) as failure_count`,\n [\n sessionId,\n index,\n attempt,\n error,\n maxFailures,\n clock.now().getTime(),\n ],\n )\n const row = rows[0]\n if (!row || row['outcome'] === 'missing') {\n throw new TypeError(\n `no event at index ${index} in session '${sessionId}'`,\n )\n }\n if (row['outcome'] === 'dead_lettered') {\n await query(\n `delete from a2_snapshot_pins\n where session_id = $1 and event_index = $2`,\n [sessionId, index],\n )\n await query(\n `delete from a2_snapshot_history history\n where history.session_id = $1\n and not exists (\n select 1 from a2_snapshot_pins pin\n where pin.session_id = history.session_id\n and pin.reducer_name = history.reducer_name\n and pin.up_to_index = history.up_to_index\n )`,\n [sessionId],\n )\n }\n return {\n outcome: row['outcome'] as\n 'failed' | 'dead_lettered' | 'superseded',\n failureCount: asNumber(row['failure_count']),\n }\n }),\n )\n },\n\n async readState(sessionId, reducerName, stateOptions) {\n return wrap(\n async () =>\n (\n await readStateBatch(\n [\n {\n sessionId,\n ...(stateOptions?.throughIndex === undefined\n ? {}\n : { throughIndex: stateOptions.throughIndex }),\n ...(stateOptions?.snapshotThroughIndex === undefined\n ? {}\n : {\n snapshotThroughIndex: stateOptions.snapshotThroughIndex,\n }),\n },\n ],\n reducerName,\n )\n )[0]!,\n )\n },\n\n async readStates(requests, reducerName) {\n return wrap(() => readStateBatch(requests, reducerName))\n },\n\n async putSnapshots(sessionId, reducerName, writes) {\n await wrap(() =>\n withTx(async (query) => {\n const normalized = [\n ...new Map(writes.map((write) => [write.index, write])).values(),\n ].toSorted((a, b) => a.index - b.index)\n if (normalized.length === 0) return\n\n await query(\n 'select pg_advisory_xact_lock(hashtext($1), hashtext($2))',\n [sessionId, reducerName],\n )\n\n const requestedPins = normalized.flatMap((write) =>\n (write.pinEventIndexes ?? []).map((eventIndex) => ({\n eventIndex,\n snapshotIndex: write.index,\n })),\n )\n if (requestedPins.length > 0) {\n const eventIndexes = [\n ...new Set(requestedPins.map((pin) => pin.eventIndex)),\n ]\n const { rows: events } = await query(\n `select idx, processed_at, failed_at from a2_events\n where session_id = $1 and idx = any($2::bigint[])\n for update`,\n [sessionId, eventIndexes],\n )\n const unfinished = new Set(\n events\n .filter(\n (row) =>\n row['processed_at'] === null && row['failed_at'] === null,\n )\n .map((row) => asNumber(row['idx'])),\n )\n const accepted = requestedPins.filter((pin) =>\n unfinished.has(pin.eventIndex),\n )\n if (accepted.length > 0) {\n await query(\n `insert into a2_snapshot_pins\n (session_id, event_index, reducer_name, up_to_index)\n select $1, pin.\"eventIndex\", $2, pin.\"snapshotIndex\"\n from jsonb_to_recordset($3::jsonb) as pin(\n \"eventIndex\" bigint,\n \"snapshotIndex\" bigint\n )\n on conflict do nothing`,\n [sessionId, reducerName, jsonb(accepted)],\n )\n }\n }\n\n const { rows: headRows } = await query(\n `select up_to_index, state, updated_at from a2_snapshots\n where session_id = $1 and reducer_name = $2\n for update`,\n [sessionId, reducerName],\n )\n const currentHead = headRows[0]\n const latestWrite = normalized.at(-1)!\n const writesHead =\n !currentHead ||\n latestWrite.index >= asNumber(currentHead['up_to_index'])\n const advancesHead =\n !currentHead ||\n latestWrite.index > asNumber(currentHead['up_to_index'])\n const finalHeadIndex = advancesHead\n ? latestWrite.index\n : asNumber(currentHead['up_to_index'])\n const relevantIndexes = [\n ...new Set([\n ...normalized.map((write) => write.index),\n ...(currentHead ? [asNumber(currentHead['up_to_index'])] : []),\n ]),\n ]\n const { rows: pinRows } = await query(\n `select distinct up_to_index from a2_snapshot_pins\n where session_id = $1 and reducer_name = $2\n and up_to_index = any($3::bigint[])`,\n [sessionId, reducerName, relevantIndexes],\n )\n const pinned = new Set(\n pinRows.map((row) => asNumber(row['up_to_index'])),\n )\n const historical = new Map<\n number,\n { index: number; state: unknown; updatedAt: number }\n >()\n if (\n advancesHead &&\n currentHead &&\n pinned.has(asNumber(currentHead['up_to_index']))\n ) {\n const index = asNumber(currentHead['up_to_index'])\n historical.set(index, {\n index,\n state: currentHead['state'],\n updatedAt: asNumber(currentHead['updated_at']),\n })\n }\n const updatedAt = clock.now().getTime()\n for (const write of normalized) {\n if (write.index < finalHeadIndex && pinned.has(write.index)) {\n historical.set(write.index, {\n index: write.index,\n state: write.state,\n updatedAt,\n })\n }\n }\n if (historical.size > 0) {\n await query(\n `insert into a2_snapshot_history\n (session_id, reducer_name, up_to_index, state, updated_at)\n select $1, $2, snapshot.index, snapshot.state, snapshot.\"updatedAt\"\n from jsonb_to_recordset($3::jsonb) as snapshot(\n index bigint,\n state jsonb,\n \"updatedAt\" bigint\n )\n on conflict (session_id, reducer_name, up_to_index) do update set\n state = excluded.state,\n updated_at = excluded.updated_at`,\n [sessionId, reducerName, jsonb([...historical.values()])],\n )\n }\n if (writesHead) {\n await query(\n `insert into a2_snapshots\n (session_id, reducer_name, up_to_index, state, updated_at)\n values ($1, $2, $3, $4::jsonb, $5)\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 sessionId,\n reducerName,\n latestWrite.index,\n jsonb(latestWrite.state),\n clock.now().getTime(),\n ],\n )\n await query(\n `delete from a2_snapshot_history\n where session_id = $1 and reducer_name = $2 and up_to_index = $3`,\n [sessionId, reducerName, latestWrite.index],\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 await wrap(async () => {\n const c = await client()\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 // oxlint-disable-next-line no-await-in-loop -- independent per-field registers\n await (value === null\n ? c.query(\n `delete from a2_presence\n where ns = $1 and participant = $2 and field = $3\n and at <= $4`,\n [ns, participant, field, atMs],\n )\n : c.query(\n `insert into a2_presence\n (ns, participant, field, value, seen, at, expires_at)\n values ($1, $2, $3, $4::jsonb, $5, $6, $7)\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 ns,\n participant,\n field,\n jsonb(value),\n meta.seen,\n atMs,\n expiresAtMs,\n ],\n ))\n }\n })\n },\n\n async read(ns) {\n return wrap(async () => {\n const c = await client()\n const now = clock.now().getTime()\n // Sweep and read share one statement (one snapshot): the\n // delete is opportunistic cleanup; the filter alone keeps\n // expired rows out of this result.\n const { rows } = await c.query(\n `with swept as (\n delete from a2_presence\n where ns = $1 and expires_at <= $2\n )\n select participant, field, value, seen, at, expires_at\n from a2_presence\n where ns = $1 and expires_at > $2\n order by participant, field`,\n [ns, now],\n )\n return rows.map((row) => ({\n participant: row['participant'] as string,\n field: row['field'] as string,\n value: row['value'],\n seen: asNumber(row['seen']),\n at: new Date(asNumber(row['at'])),\n expiresAt: new Date(asNumber(row['expires_at'])),\n }))\n })\n },\n },\n\n stream(sessionId, opts) {\n const readAfter = (afterIndex: number): Promise<StoredEvent[]> =>\n wrap(async () => {\n const c = await client()\n const { rows } = await c.query(\n 'select * from a2_events where session_id = $1 and idx > $2 order by idx',\n [sessionId, afterIndex],\n )\n return rows.map(toStored)\n })\n return pollingStream(\n readAfter,\n opts?.startAfter !== undefined ? { startAfter: opts.startAfter } : {},\n )\n },\n\n async close() {\n const current = connection.peek()\n if (!current) return\n const c = await current\n await c.end?.()\n },\n }\n}\n"],"mappings":"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"}
@@ -93,8 +93,24 @@ has_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'
93
93
  return { 'ok', tostring(base + 1), has_pending,
94
94
  tostring(redis.call('EXISTS', KEYS[7])) }
95
95
  `;
96
- /** KEYS: counter, meta, pending, ready. ARGV: idx, attempt, error, maxFailures, nowMs. */
96
+ /**
97
+ * KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts.
98
+ * ARGV: idx, attempt, error, maxFailures, nowMs.
99
+ */
97
100
  const FAIL_ATTEMPT_LUA = `
101
+ local function release_snapshot_pins()
102
+ local refs = redis.call('SMEMBERS', KEYS[5])
103
+ for _, ref in ipairs(refs) do
104
+ local count = redis.call('HINCRBY', KEYS[6], ref, -1)
105
+ if count <= 0 then
106
+ redis.call('HDEL', KEYS[6], ref)
107
+ local checkpoint = cjson.decode(ref)
108
+ redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
109
+ end
110
+ end
111
+ redis.call('DEL', KEYS[5])
112
+ if redis.call('HLEN', KEYS[6]) == 0 then redis.call('DEL', KEYS[6]) end
113
+ end
98
114
  local function lane_field(lane, suffix)
99
115
  return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
100
116
  end
@@ -148,6 +164,7 @@ redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
148
164
  if failures >= tonumber(ARGV[4]) then
149
165
  redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
150
166
  redis.call('ZREM', KEYS[4], field)
167
+ release_snapshot_pins()
151
168
  return { 'dead_lettered', tostring(failures) }
152
169
  end
153
170
  return { 'failed', tostring(failures) }
@@ -238,11 +255,25 @@ for i = 1, #superseded do result[#result + 1] = superseded[i] end
238
255
  return result
239
256
  `;
240
257
  /**
241
- * KEYS: store, counter, ids, meta, pending, ready, watched.
258
+ * KEYS: store, counter, ids, meta, pending, ready, watched,
259
+ * event snapshot pins, snapshot pin counts.
242
260
  * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
243
261
  * id, type, payloadJson, lane, settled.
244
262
  */
245
263
  const COMPLETE_ATTEMPT_LUA = `
264
+ local function release_snapshot_pins()
265
+ local refs = redis.call('SMEMBERS', KEYS[8])
266
+ for _, ref in ipairs(refs) do
267
+ local count = redis.call('HINCRBY', KEYS[9], ref, -1)
268
+ if count <= 0 then
269
+ redis.call('HDEL', KEYS[9], ref)
270
+ local checkpoint = cjson.decode(ref)
271
+ redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
272
+ end
273
+ end
274
+ redis.call('DEL', KEYS[8])
275
+ if redis.call('HLEN', KEYS[9]) == 0 then redis.call('DEL', KEYS[9]) end
276
+ end
246
277
  local function lane_field(lane, suffix)
247
278
  return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
248
279
  end
@@ -341,6 +372,7 @@ redis.call('HSET', KEYS[4],
341
372
  parent .. ':ri', returned_json)
342
373
  redis.call('HDEL', KEYS[4], parent .. ':ch', parent .. ':ce')
343
374
  settle(parent)
375
+ release_snapshot_pins()
344
376
  local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
345
377
  for i = 1, n do
346
378
  local id = ARGV[5 + (i - 1) * 5 + 1]
@@ -364,13 +396,61 @@ redis.call('SET', KEYS[2], tostring(total + n))
364
396
  return { 'ok', tostring(total + 1),
365
397
  tostring(redis.call('EXISTS', KEYS[7])) }
366
398
  `;
367
- /** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
368
- const PUT_SNAPSHOT_LUA = `
369
- local cur = redis.call('GET', KEYS[1])
370
- if cur and tonumber(cjson.decode(cur)['index']) >= tonumber(ARGV[1]) then
371
- return 0
399
+ /**
400
+ * KEYS: head, history, event counter, event meta, pin counts.
401
+ * ARGV: nowMs, event pin key prefix, write count, then per write:
402
+ * index, snapshot JSON, pin count, pin event indexes.
403
+ */
404
+ const PUT_SNAPSHOTS_LUA = `
405
+ local total = tonumber(redis.call('GET', KEYS[3]) or '0')
406
+ local writes = {}
407
+ local offset = 4
408
+ for i = 1, tonumber(ARGV[3]) do
409
+ local index = tonumber(ARGV[offset])
410
+ local snapshot = ARGV[offset + 1]
411
+ local pin_count = tonumber(ARGV[offset + 2])
412
+ local ref = cjson.encode({ KEYS[2], tostring(index) })
413
+ for p = 1, pin_count do
414
+ local event_index = tonumber(ARGV[offset + 2 + p])
415
+ if event_index >= 1 and event_index <= total and
416
+ redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':p') == 0 and
417
+ redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':f') == 0 then
418
+ local pin_key = ARGV[2] .. tostring(event_index)
419
+ if redis.call('SADD', pin_key, ref) == 1 then
420
+ redis.call('HINCRBY', KEYS[5], ref, 1)
421
+ end
422
+ end
423
+ end
424
+ writes[#writes + 1] = { index = index, snapshot = snapshot, ref = ref }
425
+ offset = offset + 3 + pin_count
426
+ end
427
+
428
+ local head = redis.call('GET', KEYS[1])
429
+ local head_index = nil
430
+ if head then head_index = tonumber(cjson.decode(head)['index']) end
431
+ local latest = writes[#writes]
432
+ local writes_head = not head_index or latest.index >= head_index
433
+ local advances = not head_index or latest.index > head_index
434
+ local final_head_index = advances and latest.index or head_index
435
+
436
+ if advances and head and tonumber(redis.call('HGET', KEYS[5], cjson.encode({ KEYS[2], tostring(head_index) })) or '0') > 0 then
437
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], head_index, head_index)
438
+ redis.call('ZADD', KEYS[2], head_index, head)
439
+ end
440
+
441
+ for _, write in ipairs(writes) do
442
+ if write.index < final_head_index then
443
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], write.index, write.index)
444
+ if tonumber(redis.call('HGET', KEYS[5], write.ref) or '0') > 0 then
445
+ redis.call('ZADD', KEYS[2], write.index, write.snapshot)
446
+ end
447
+ end
448
+ end
449
+
450
+ if writes_head then
451
+ redis.call('SET', KEYS[1], latest.snapshot)
452
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], latest.index, latest.index)
372
453
  end
373
- redis.call('SET', KEYS[1], ARGV[2])
374
454
  return 1
375
455
  `;
376
456
  /**
@@ -424,11 +504,26 @@ for i = 1, #rows, 2 do
424
504
  end
425
505
  return live
426
506
  `;
427
- /** KEYS: snapshot, event counter. Returns one atomic cache-plus-frontier read. */
507
+ /**
508
+ * KEYS: head, history, event counter. ARGV: snapshot frontier or ''.
509
+ * Returns one atomic multiversion-cache-plus-log-frontier read.
510
+ */
428
511
  const READ_STATE_LUA = `
429
- local snapshot = redis.call('GET', KEYS[1])
430
- local frontier = redis.call('GET', KEYS[2]) or '0'
431
- return { snapshot or '', frontier }
512
+ local head = redis.call('GET', KEYS[1])
513
+ local cap = ARGV[1]
514
+ local snapshot = nil
515
+ if head and (cap == '' or tonumber(cjson.decode(head)['index']) <= tonumber(cap)) then
516
+ snapshot = head
517
+ end
518
+ local upper = cap == '' and '+inf' or cap
519
+ local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, '-inf', 'LIMIT', 0, 1)
520
+ if #historical > 0 then
521
+ if not snapshot or tonumber(cjson.decode(historical[1])['index']) > tonumber(cjson.decode(snapshot)['index']) then
522
+ snapshot = historical[1]
523
+ end
524
+ end
525
+ local frontier = redis.call('GET', KEYS[3]) or '0'
526
+ return { head or '', snapshot or '', frontier }
432
527
  `;
433
528
  const fieldMap = (fields) => {
434
529
  const map = {};
@@ -532,6 +627,10 @@ function createRedisStoreCore(options) {
532
627
  const pendingKey = (s) => `${prefix}:${s}:pending`;
533
628
  const readyKey = (s) => `${prefix}:${s}:ready`;
534
629
  const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
630
+ const snapHistoryKey = (s, r) => `${prefix}:${s}:snap-history:${r}`;
631
+ const snapshotEventPinsKey = (s, index) => `${prefix}:${s}:snapshot-pins:${index}`;
632
+ const snapshotEventPinsPrefix = (s) => `${prefix}:${s}:snapshot-pins:`;
633
+ const snapshotPinCountsKey = (s) => `${prefix}:${s}:snapshot-pin-counts`;
535
634
  const watchedKey = (s) => `${prefix}:${s}:watched`;
536
635
  const presenceKey = (s) => `${prefix}:${s}:presence`;
537
636
  const idsKey = `${prefix}:ids`;
@@ -765,7 +864,9 @@ function createRedisStoreCore(options) {
765
864
  metaKey(sessionId),
766
865
  pendingKey(sessionId),
767
866
  readyKey(sessionId),
768
- watchedKey(sessionId)
867
+ watchedKey(sessionId),
868
+ snapshotEventPinsKey(sessionId, index),
869
+ snapshotPinCountsKey(sessionId)
769
870
  ], [
770
871
  sessionId,
771
872
  now,
@@ -842,7 +943,9 @@ function createRedisStoreCore(options) {
842
943
  countKey(sessionId),
843
944
  metaKey(sessionId),
844
945
  pendingKey(sessionId),
845
- readyKey(sessionId)
946
+ readyKey(sessionId),
947
+ snapshotEventPinsKey(sessionId, index),
948
+ snapshotPinCountsKey(sessionId)
846
949
  ], [
847
950
  index,
848
951
  attempt,
@@ -858,30 +961,65 @@ function createRedisStoreCore(options) {
858
961
  };
859
962
  });
860
963
  },
861
- async readState(sessionId, reducerName) {
964
+ async readState(sessionId, reducerName, stateOptions) {
862
965
  return wrap(async () => {
863
- const reply = await evalScript(READ_STATE_LUA, [snapKey(sessionId, reducerName), countKey(sessionId)], []);
864
- const raw = reply[0];
966
+ const reply = await evalScript(READ_STATE_LUA, [
967
+ snapKey(sessionId, reducerName),
968
+ snapHistoryKey(sessionId, reducerName),
969
+ countKey(sessionId)
970
+ ], [stateOptions?.snapshotThroughIndex ?? stateOptions?.throughIndex ?? ""]);
971
+ const rawHead = reply[0];
972
+ const raw = reply[1];
973
+ const head = rawHead === "" ? null : JSON.parse(rawHead);
865
974
  const parsed = raw === "" ? null : JSON.parse(raw);
866
- const frontier = Number(reply[1]);
867
- if (!Number.isSafeInteger(frontier) || frontier < 0 || parsed !== null && (!Number.isSafeInteger(parsed.index) || parsed.index < 0)) throw new A2Error("STORE_UNAVAILABLE", `redis state frontier for session '${sessionId}' is invalid`);
868
- const events = await readEventRange(sessionId, parsed?.index ?? 0, frontier);
975
+ const currentFrontier = Number(reply[2]);
976
+ if (!Number.isSafeInteger(currentFrontier) || currentFrontier < 0 || head !== null && (!Number.isSafeInteger(head.index) || head.index < 0) || parsed !== null && (!Number.isSafeInteger(parsed.index) || parsed.index < 0)) throw new A2Error("STORE_UNAVAILABLE", `redis state frontier for session '${sessionId}' is invalid`);
977
+ const frontier = Math.min(currentFrontier, stateOptions?.throughIndex ?? currentFrontier);
978
+ const snapshot = parsed;
979
+ const events = await readEventRange(sessionId, snapshot?.index ?? 0, frontier);
869
980
  return {
870
- snapshot: parsed ? {
871
- index: parsed.index,
872
- state: parsed.state
981
+ headIndex: head?.index ?? null,
982
+ snapshot: snapshot ? {
983
+ index: snapshot.index,
984
+ state: snapshot.state
873
985
  } : null,
874
986
  events
875
987
  };
876
988
  });
877
989
  },
878
- async putSnapshot(sessionId, reducerName, index, state) {
990
+ async putSnapshots(sessionId, reducerName, writes) {
991
+ const byIndex = /* @__PURE__ */ new Map();
992
+ for (const write of writes) {
993
+ const existing = byIndex.get(write.index);
994
+ if (existing) {
995
+ existing.state = write.state;
996
+ for (const eventIndex of write.pinEventIndexes ?? []) existing.pinEventIndexes.add(eventIndex);
997
+ } else byIndex.set(write.index, {
998
+ index: write.index,
999
+ state: write.state,
1000
+ pinEventIndexes: new Set(write.pinEventIndexes ?? [])
1001
+ });
1002
+ }
1003
+ const normalized = [...byIndex.values()].toSorted((a, b) => a.index - b.index);
1004
+ if (normalized.length === 0) return;
879
1005
  await wrap(async () => {
880
- await evalScript(PUT_SNAPSHOT_LUA, [snapKey(sessionId, reducerName)], [index, json({
881
- index,
882
- state,
1006
+ const args = [
1007
+ clock.now().getTime(),
1008
+ snapshotEventPinsPrefix(sessionId),
1009
+ normalized.length
1010
+ ];
1011
+ for (const write of normalized) args.push(write.index, json({
1012
+ index: write.index,
1013
+ state: write.state,
883
1014
  updatedAt: clock.now().getTime()
884
- })]);
1015
+ }), write.pinEventIndexes.size, ...write.pinEventIndexes);
1016
+ await evalScript(PUT_SNAPSHOTS_LUA, [
1017
+ snapKey(sessionId, reducerName),
1018
+ snapHistoryKey(sessionId, reducerName),
1019
+ countKey(sessionId),
1020
+ metaKey(sessionId),
1021
+ snapshotPinCountsKey(sessionId)
1022
+ ], args);
885
1023
  });
886
1024
  },
887
1025
  presence: {
@@ -952,4 +1090,4 @@ function createRedisStoreCore(options) {
952
1090
  //#endregion
953
1091
  export { createRedisStoreCore as t };
954
1092
 
955
- //# sourceMappingURL=store-redis-core-DEYO8Ryv.js.map
1093
+ //# sourceMappingURL=store-redis-core-DT01r4GZ.js.map