experimental-a2 0.3.0 → 0.4.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 (91) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/dist/ai-B4YhEnfw.d.ts +333 -0
  3. package/dist/ai-server.d.ts +48 -9
  4. package/dist/ai-server.js +121 -49
  5. package/dist/ai.d.ts +2 -303
  6. package/dist/ai.js +231 -86
  7. package/dist/cli-B3VuxoDe.js +597 -0
  8. package/dist/cli-bin.d.ts +1 -0
  9. package/dist/cli-bin.js +5 -0
  10. package/dist/cli.d.ts +19 -0
  11. package/dist/cli.js +2 -0
  12. package/dist/client-BrfDXQ8A.d.ts +155 -0
  13. package/dist/client-Bt4tAKi9.js +798 -0
  14. package/dist/client.d.ts +2 -90
  15. package/dist/client.js +1 -409
  16. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
  17. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
  18. package/dist/devtools-J_jZ2vQf.d.ts +151 -0
  19. package/dist/devtools-kJJaORn-.js +338 -0
  20. package/dist/devtools-server.browser.js +1 -1
  21. package/dist/devtools-server.d.ts +2 -2
  22. package/dist/devtools-server.js +224 -43
  23. package/dist/devtools.d.ts +2 -0
  24. package/dist/devtools.js +2 -0
  25. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
  26. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
  27. package/dist/http.d.ts +71 -13
  28. package/dist/http.js +302 -41
  29. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +2 -2
  32. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
  33. package/dist/internal-aEotMzu_.js +209 -0
  34. package/dist/otel.d.ts +1 -1
  35. package/dist/platform-B4TnJtWu.js +32 -0
  36. package/dist/react.d.ts +37 -14
  37. package/dist/react.js +26 -15
  38. package/dist/scheduler-qstash.d.ts +78 -0
  39. package/dist/scheduler-qstash.js +499 -0
  40. package/dist/scheduler-task-BpzhPnRS.js +54 -0
  41. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
  42. package/dist/scheduler-vercel.js +226 -0
  43. package/dist/server-CcNnFnoW.js +1405 -0
  44. package/dist/server-YtPq7hjw.d.ts +260 -0
  45. package/dist/server.d.ts +4 -155
  46. package/dist/server.js +2 -2
  47. package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
  48. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
  49. package/dist/store-codec-DTG0Ftek.js +8 -0
  50. package/dist/store-memory.d.ts +11 -0
  51. package/dist/{log-memory.js → store-memory.js} +127 -24
  52. package/dist/{log-polling-DZ1MiKLg.js → store-polling-DgrrAE3d.js} +5 -5
  53. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
  54. package/dist/{log-postgres.js → store-postgres.js} +158 -24
  55. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-DWqx3F47.js} +269 -66
  56. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +7 -7
  57. package/dist/{log-redis-http.js → store-redis-http.js} +20 -12
  58. package/dist/{log-redis.d.ts → store-redis.d.ts} +6 -6
  59. package/dist/{log-redis.js → store-redis.js} +99 -39
  60. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
  61. package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
  62. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
  63. package/dist/testing.browser.d.ts +1 -0
  64. package/dist/testing.browser.js +4 -0
  65. package/dist/testing.d.ts +31 -0
  66. package/dist/testing.js +101 -0
  67. package/dist/wire-DCUZBUlT.js +222 -0
  68. package/docs/01-quickstart.mdx +3 -3
  69. package/docs/concepts/01-contracts.mdx +18 -13
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +21 -21
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -49
  74. package/docs/guides/02-cancellation.mdx +30 -4
  75. package/docs/guides/03-react.mdx +20 -21
  76. package/docs/guides/04-local-first.mdx +1 -1
  77. package/docs/guides/05-production.mdx +310 -61
  78. package/docs/guides/06-ai-agents.mdx +249 -49
  79. package/docs/guides/07-devtools.mdx +137 -12
  80. package/docs/guides/08-application-data.mdx +1 -1
  81. package/docs/guides/09-presence.mdx +284 -0
  82. package/docs/guides/10-transports.mdx +131 -0
  83. package/docs/index.mdx +18 -14
  84. package/docs/reference/01-api.mdx +751 -103
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -7
  87. package/dist/internal-gCd5qMry.js +0 -44
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/recovery-vercel.js +0 -119
  90. package/dist/server-BcLa4RFL.js +0 -877
  91. package/dist/wire-BVsgR8o9.js +0 -62
@@ -1,32 +1,10 @@
1
- import { t as A2Error } from "./errors-BJRMd-h6.js";
2
- import { n as NOTIFY_TIMINGS } from "./internal-gCd5qMry.js";
1
+ import { t as A2Error } from "./errors-BQuJpe82.js";
2
+ import { r as NOTIFY_TIMINGS } from "./internal-aEotMzu_.js";
3
+ import { t as defaultSleep } from "./store-polling-DgrrAE3d.js";
3
4
  import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
4
- import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
5
- import { t as defaultSleep } from "./log-polling-DZ1MiKLg.js";
6
- import { t as createRedisLogCore } from "./log-redis-core-CyJ5L8yR.js";
7
- //#region src/log-redis.ts
8
- /**
9
- * experimental-a2/log-redis — the Redis-protocol log backend, on Redis
10
- * Streams. The storage semantics live in log-redis-core.ts (shared
11
- * with experimental-a2/log-redis-http); this module owns the connection and
12
- * the live feed.
13
- *
14
- * `stream()` is notify-driven: writes to watched sessions fire a
15
- * disposable PUBLISH wake-up (feeds hold a TTL'd presence marker the
16
- * write script checks, so unwatched sessions cost no wake-up), one
17
- * shared subscriber connection per backend serves every local feed,
18
- * and each wake triggers an `XRANGE` catch-up read. The notification
19
- * only decides when to read, never what — a lost one is healed by a
20
- * safety re-read (NOTIFY_TIMINGS), so delivery never depends on
21
- * pub/sub. Connections scale with processes (one command client plus
22
- * one subscriber), not with concurrent viewers.
23
- *
24
- * Works with any Redis-protocol server on a single instance or a
25
- * non-cluster provider (Upstash — durable by default — Redis, Valkey).
26
- * Cluster mode is out: the atomic scripts span keys. `ioredis` is an
27
- * optional peer dependency; pass `url`, or inject any client exposing
28
- * `call`/`duplicate`/`on`/`disconnect`.
29
- */
5
+ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
6
+ import { t as createRedisStoreCore } from "./store-redis-core-DWqx3F47.js";
7
+ //#region src/store-redis.ts
30
8
  function redis(options = {}) {
31
9
  const clock = options.clock ?? SYSTEM_CLOCK;
32
10
  const ids = options.ids ?? RANDOM_IDS;
@@ -35,12 +13,13 @@ function redis(options = {}) {
35
13
  const connection = retryableLazy(async () => {
36
14
  if (options.client) return options.client;
37
15
  return new (await (import("ioredis").catch(() => {
38
- throw new A2Error("LOG_NOT_CONFIGURED", "experimental-a2/log-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client");
16
+ throw new A2Error("STORE_NOT_CONFIGURED", "experimental-a2/store-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client");
39
17
  }))).default(options.url);
40
18
  });
41
19
  const client = connection.get;
42
20
  const notifyChannel = (sessionId) => `${prefix}:${sessionId}:notify`;
43
- const core = createRedisLogCore({
21
+ const presenceChannel = (ns) => `${prefix}:${ns}:presence`;
22
+ const core = createRedisStoreCore({
44
23
  call: async (command, ...args) => {
45
24
  return (await client()).call(command, ...args);
46
25
  },
@@ -66,18 +45,63 @@ function redis(options = {}) {
66
45
  * clock's every-stored-timestamp pledge is untouched.
67
46
  */
68
47
  const watchedRefreshedAt = /* @__PURE__ */ new Map();
48
+ /**
49
+ * `:presence` channel → live listeners. Same-instance patches are
50
+ * delivered here synchronously by `presence.set`, so subscribe/set
51
+ * ordering never depends on the SUBSCRIBE command settling; the
52
+ * channel carries only other instances' patches, with own echoes
53
+ * skipped by `src`.
54
+ */
55
+ const presenceListeners = /* @__PURE__ */ new Map();
56
+ const instanceId = crypto.randomUUID();
57
+ const deliverRemotePresence = (listeners, message) => {
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(message);
61
+ } catch {
62
+ return;
63
+ }
64
+ if (parsed.src === instanceId) return;
65
+ const patch = {
66
+ participant: parsed.participant,
67
+ values: parsed.values,
68
+ seen: parsed.seen,
69
+ at: new Date(parsed.at)
70
+ };
71
+ for (const listener of listeners) listener(patch);
72
+ };
69
73
  const getSubscriber = () => {
70
74
  subscriber ??= (async () => {
71
75
  const conn = (await client()).duplicate();
72
- conn.on("message", (channel) => {
76
+ conn.on("message", (channel, message) => {
73
77
  const set = wakers.get(channel);
74
- if (!set) return;
75
- for (const wake of set) wake();
78
+ if (set) for (const wake of set) wake();
79
+ const listeners = presenceListeners.get(channel);
80
+ if (listeners) deliverRemotePresence(listeners, message);
76
81
  });
77
82
  return conn;
78
83
  })();
79
84
  return subscriber;
80
85
  };
86
+ /**
87
+ * The subscriber, gated on connection readiness — for channel
88
+ * commands only (`close` uses the ungated promise, so it can never
89
+ * hang on an unreachable server). SUBSCRIBE carries redis's
90
+ * ok-loading flag, so ioredis writes it mid-handshake, ahead of the
91
+ * ready check's INFO — which then finds the connection already in
92
+ * subscriber mode and fails the check (a spurious error event plus
93
+ * a reconnect). ECHO has no such flag: it resolves only once the
94
+ * connection is ready, so every channel command chained behind it
95
+ * lands after the check.
96
+ */
97
+ let subscriberGate = null;
98
+ const gatedSubscriber = () => {
99
+ subscriberGate ??= getSubscriber().then(async (conn) => {
100
+ await conn.call("echo", "ready");
101
+ return conn;
102
+ });
103
+ return subscriberGate;
104
+ };
81
105
  const acquireChannel = async (channel) => {
82
106
  const existing = subscriptions.get(channel);
83
107
  if (existing) {
@@ -92,7 +116,7 @@ function redis(options = {}) {
92
116
  }
93
117
  const lease = {
94
118
  refs: 1,
95
- ready: getSubscriber().then((conn) => conn.call("subscribe", channel))
119
+ ready: gatedSubscriber().then((conn) => conn.call("subscribe", channel))
96
120
  };
97
121
  subscriptions.set(channel, lease);
98
122
  try {
@@ -109,7 +133,7 @@ function redis(options = {}) {
109
133
  if (lease.refs > 0) return;
110
134
  subscriptions.delete(channel);
111
135
  watchedRefreshedAt.delete(channel);
112
- getSubscriber().then((conn) => conn.call("unsubscribe", channel)).catch(() => {});
136
+ gatedSubscriber().then((conn) => conn.call("unsubscribe", channel)).catch(() => {});
113
137
  };
114
138
  const armWaker = (channel) => {
115
139
  let wake;
@@ -141,11 +165,47 @@ function redis(options = {}) {
141
165
  readState: core.readState,
142
166
  putSnapshot: core.putSnapshot,
143
167
  inspect: core.inspect,
168
+ presence: {
169
+ async set(ns, participant, values, meta) {
170
+ const patch = await core.presence.set(ns, participant, values, meta);
171
+ if (!patch) return;
172
+ const channel = presenceChannel(ns);
173
+ const listeners = presenceListeners.get(channel);
174
+ if (listeners) for (const listener of listeners) listener(patch);
175
+ const message = JSON.stringify({
176
+ src: instanceId,
177
+ participant: patch.participant,
178
+ values: patch.values,
179
+ seen: patch.seen,
180
+ at: patch.at.getTime()
181
+ });
182
+ await client().then((c) => c.call("publish", channel, message)).catch(() => {});
183
+ },
184
+ read: core.presence.read,
185
+ subscribe(ns, onPatch) {
186
+ const channel = presenceChannel(ns);
187
+ let listeners = presenceListeners.get(channel);
188
+ if (!listeners) {
189
+ listeners = /* @__PURE__ */ new Set();
190
+ presenceListeners.set(channel, listeners);
191
+ }
192
+ listeners.add(onPatch);
193
+ acquireChannel(channel).catch(() => {});
194
+ let stopped = false;
195
+ return () => {
196
+ if (stopped) return;
197
+ stopped = true;
198
+ listeners.delete(onPatch);
199
+ if (listeners.size === 0) presenceListeners.delete(channel);
200
+ releaseChannel(channel);
201
+ };
202
+ }
203
+ },
144
204
  stream(sessionId, opts) {
145
- const startAt = opts?.startAt ?? 0;
205
+ const startAfter = opts?.startAfter ?? 0;
146
206
  const channel = notifyChannel(sessionId);
147
207
  return { [Symbol.asyncIterator]() {
148
- let last = startAt;
208
+ let last = startAfter;
149
209
  let buffer = [];
150
210
  let closed = false;
151
211
  let subscribed = false;
@@ -176,7 +236,7 @@ function redis(options = {}) {
176
236
  }
177
237
  if (severed) {
178
238
  release();
179
- throw new A2Error("LOG_UNAVAILABLE", "the log was closed");
239
+ throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
180
240
  }
181
241
  const waker = armWaker(channel);
182
242
  interrupt = waker.wake;
@@ -204,7 +264,7 @@ function redis(options = {}) {
204
264
  done: true
205
265
  };
206
266
  if (err instanceof A2Error) throw err;
207
- throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
267
+ throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
208
268
  } finally {
209
269
  waker.disarm();
210
270
  interrupt = null;
@@ -1,6 +1,6 @@
1
- import { c as IdSource, i as Clock, t as A2Log } from "./log-ldf5g8Cx.js";
2
- //#region src/log-sqlite.d.ts
3
- type SqliteLogOptions = {
1
+ import { c as IdSource, i as Clock, t as A2Store } from "./store-C3sNAaBT.js";
2
+ //#region src/store-sqlite.d.ts
3
+ type SqliteStoreOptions = {
4
4
  /** Database file path. Defaults to `.a2/dev.db`; `:memory:` works. */
5
5
  path?: string;
6
6
  /** Injectable clock — every stored timestamp comes from here. */
@@ -8,10 +8,10 @@ type SqliteLogOptions = {
8
8
  /** Injectable id source for generated event ids. */
9
9
  ids?: IdSource;
10
10
  };
11
- type SqliteLog = A2Log & {
11
+ type SqliteStore = A2Store & {
12
12
  /** Close the underlying database handle. */
13
13
  close(): void;
14
14
  };
15
- declare function sqlite(options?: SqliteLogOptions): SqliteLog;
15
+ declare function sqlite(options?: SqliteStoreOptions): SqliteStore;
16
16
  //#endregion
17
- export { SqliteLog, SqliteLogOptions, sqlite };
17
+ export { SqliteStore, SqliteStoreOptions, sqlite };
@@ -1,16 +1,17 @@
1
- import { t as A2Error } from "./errors-BJRMd-h6.js";
2
- import { t as idempotentReplay } from "./idempotent-replay-BMyHrP0L.js";
3
- import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
4
- import { n as pollingStream } from "./log-polling-DZ1MiKLg.js";
1
+ import { t as A2Error } from "./errors-BQuJpe82.js";
2
+ import { n as pollingStream } from "./store-polling-DgrrAE3d.js";
3
+ import { t as idempotentReplay } from "./idempotent-replay-DuqEkYA7.js";
4
+ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
5
+ import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
5
6
  import { mkdirSync } from "node:fs";
6
7
  import { dirname, resolve } from "node:path";
7
8
  import { DatabaseSync } from "node:sqlite";
8
- //#region src/log-sqlite.ts
9
+ //#region src/store-sqlite.ts
9
10
  /**
10
- * experimental-a2/log-sqlite — sqlite log backend (the dev default, `.a2/dev.db`).
11
+ * experimental-a2/store-sqlite — sqlite store backend (the dev default, `.a2/dev.db`).
11
12
  *
12
13
  * Built on `node:sqlite` (Node ≥ 22.13) so it ships with zero
13
- * dependencies. Implements the A2Log interface; the conformance suite
14
+ * dependencies. Implements the A2Store interface; the conformance suite
14
15
  * in test/conformance is the executable contract.
15
16
  *
16
17
  * The spec's `index` column is stored as `idx` (a2-implementation.md
@@ -66,6 +67,17 @@ create table if not exists a2_snapshots (
66
67
  updated_at integer not null,
67
68
  primary key (session_id, reducer_name)
68
69
  ) strict;
70
+
71
+ create table if not exists a2_presence (
72
+ ns text not null,
73
+ participant text not null,
74
+ field text not null,
75
+ value text not null,
76
+ seen integer not null,
77
+ at integer not null,
78
+ expires_at integer not null,
79
+ primary key (ns, participant, field)
80
+ ) strict;
69
81
  `;
70
82
  /**
71
83
  * Switching journal modes takes an exclusive lock, and — unlike normal
@@ -88,7 +100,7 @@ const wrap = (fn) => {
88
100
  return fn();
89
101
  } catch (err) {
90
102
  if (err instanceof A2Error || err instanceof TypeError) throw err;
91
- throw new A2Error("LOG_UNAVAILABLE", "sqlite log operation failed", { cause: err });
103
+ throw new A2Error("STORE_UNAVAILABLE", "sqlite store operation failed", { cause: err });
92
104
  }
93
105
  };
94
106
  const toDate = (ms) => ms === null ? null : new Date(Number(ms));
@@ -114,7 +126,7 @@ const toStored = (row) => ({
114
126
  lane: row.lane,
115
127
  processedAt: toDate(row.processed_at),
116
128
  processedByAttempt: row.processed_by_attempt === null ? null : Number(row.processed_by_attempt),
117
- returnedEventIds: row.returned_event_ids === null ? null : JSON.parse(row.returned_event_ids),
129
+ returnedEventIds: row.returned_event_ids === null ? null : decodeReturnedEventIds(row.returned_event_ids),
118
130
  firstClaimedAt: toDate(row.first_claimed_at),
119
131
  lastClaimedAt: toDate(row.last_claimed_at),
120
132
  attemptCount: Number(row.attempt_count),
@@ -175,6 +187,21 @@ function sqlite(options = {}) {
175
187
  select 1 from a2_events
176
188
  where session_id = ? and processed_at is null
177
189
  ) as has_pending`);
190
+ const upsertPresence = db.prepare(`insert into a2_presence (ns, participant, field, value, seen, at, expires_at)
191
+ values (?, ?, ?, ?, ?, ?, ?)
192
+ on conflict (ns, participant, field) do update set
193
+ value = excluded.value,
194
+ seen = excluded.seen,
195
+ at = excluded.at,
196
+ expires_at = excluded.expires_at
197
+ where a2_presence.at <= excluded.at`);
198
+ const deletePresence = db.prepare(`delete from a2_presence
199
+ where ns = ? and participant = ? and field = ? and at <= ?`);
200
+ const sweepPresence = db.prepare("delete from a2_presence where ns = ? and expires_at <= ?");
201
+ const selectPresence = db.prepare(`select participant, field, value, seen, at, expires_at
202
+ from a2_presence
203
+ where ns = ? and expires_at > ?
204
+ order by participant, field`);
178
205
  const selectByIds = (count) => db.prepare(`select stored.*,
179
206
  exists(
180
207
  select 1 from a2_events pending
@@ -221,6 +248,12 @@ function sqlite(options = {}) {
221
248
  throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
222
249
  }
223
250
  }
251
+ for (const e of events) {
252
+ if (!e.cause) continue;
253
+ const parent = db.prepare("select attempt_count, failed_at from a2_events where session_id = ? and idx = ?").get(sessionId, e.cause.index);
254
+ if (!parent) throw new TypeError(`no event at index ${e.cause.index} in session '${sessionId}'`);
255
+ if (Number(parent.attempt_count) !== e.cause.attempt || parent.failed_at !== null) throw new A2Error("SUPERSEDED_ATTEMPT", `attempt ${e.cause.attempt} no longer owns event ${e.cause.index} in session '${sessionId}'`);
256
+ }
224
257
  const status = appendStatus.get(sessionId, sessionId);
225
258
  const base = Number(status.max);
226
259
  const now = clock.now().getTime();
@@ -265,6 +298,10 @@ function sqlite(options = {}) {
265
298
  conditions.push("idx > ?");
266
299
  params.push(opts.afterIndex);
267
300
  }
301
+ if (opts?.throughIndex !== void 0) {
302
+ conditions.push("idx <= ?");
303
+ params.push(opts.throughIndex);
304
+ }
268
305
  return db.prepare(`select * from a2_events where ${conditions.join(" and ")} order by idx`).all(...params).map(toStored);
269
306
  });
270
307
  },
@@ -312,20 +349,39 @@ function sqlite(options = {}) {
312
349
  };
313
350
  }));
314
351
  },
315
- async renewClaims({ sessionId, holder, indexes, ttlMs, expiresAtMs }) {
316
- if (indexes.length === 0) return [];
352
+ async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
353
+ if (claims.length === 0) return {
354
+ renewed: [],
355
+ superseded: []
356
+ };
317
357
  return wrap(() => tx(() => {
318
358
  const now = clock.now().getTime();
319
359
  const expiresAt = expiresAtMs ?? now + ttlMs;
320
- const placeholders = indexes.map(() => "?").join(", ");
321
- return db.prepare(`update a2_events set claim_expires_at = ?
322
- where session_id = ?
323
- and idx in (${placeholders})
324
- and processed_at is null
325
- and failed_at is null
326
- and claim_holder = ?
327
- and claim_expires_at > ?
328
- returning idx`).all(expiresAt, sessionId, ...indexes, holder, now).map((row) => Number(row.idx)).toSorted((a, b) => a - b);
360
+ const renewed = [];
361
+ const superseded = [];
362
+ const currentAttempt = db.prepare("select attempt_count from a2_events where session_id = ? and idx = ?");
363
+ const extend = db.prepare(`update a2_events set claim_expires_at = ?
364
+ where session_id = ?
365
+ and idx = ?
366
+ and attempt_count = ?
367
+ and processed_at is null
368
+ and failed_at is null
369
+ and claim_holder = ?
370
+ and claim_expires_at > ?`);
371
+ for (const claim of claims) {
372
+ const row = currentAttempt.get(sessionId, claim.index);
373
+ if (!row) continue;
374
+ if (Number(row.attempt_count) > claim.attempt) {
375
+ superseded.push(claim.index);
376
+ continue;
377
+ }
378
+ const updated = extend.run(expiresAt, sessionId, claim.index, claim.attempt, holder, now);
379
+ if (Number(updated.changes) === 1) renewed.push(claim.index);
380
+ }
381
+ return {
382
+ renewed: renewed.toSorted((a, b) => a - b),
383
+ superseded: superseded.toSorted((a, b) => a - b)
384
+ };
329
385
  }));
330
386
  },
331
387
  async completeAttempt({ sessionId, index, attempt, events }) {
@@ -336,7 +392,7 @@ function sqlite(options = {}) {
336
392
  if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
337
393
  if (parent.processed_at !== null) {
338
394
  if (Number(parent.processed_by_attempt) !== attempt) return { outcome: "superseded" };
339
- const returnedIds = parent.returned_event_ids === null ? null : JSON.parse(parent.returned_event_ids);
395
+ const returnedIds = parent.returned_event_ids === null ? null : decodeReturnedEventIds(parent.returned_event_ids);
340
396
  if (returnedIds === null || returnedIds.length !== ids.length || returnedIds.some((id, offset) => id !== ids[offset])) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "completed attempt does not match the returned event batch");
341
397
  const existing = ids.length === 0 ? [] : selectByIds(ids.length).all(sessionId, ...ids);
342
398
  const byId = new Map(existing.map((row) => [row.event_id, row]));
@@ -465,6 +521,32 @@ function sqlite(options = {}) {
465
521
  where excluded.up_to_index > a2_snapshots.up_to_index`).run(sessionId, reducerName, index, JSON.stringify(state) ?? "null", clock.now().getTime());
466
522
  });
467
523
  },
524
+ presence: {
525
+ async set(ns, participant, values, meta) {
526
+ const entries = Object.entries(values);
527
+ if (entries.length === 0) return;
528
+ wrap(() => tx(() => {
529
+ const atMs = meta.at.getTime();
530
+ const expiresAtMs = clock.now().getTime() + meta.ttlMs;
531
+ for (const [field, value] of entries) if (value === null) deletePresence.run(ns, participant, field, atMs);
532
+ else upsertPresence.run(ns, participant, field, JSON.stringify(value) ?? "null", meta.seen, atMs, expiresAtMs);
533
+ }));
534
+ },
535
+ async read(ns) {
536
+ return wrap(() => {
537
+ const now = clock.now().getTime();
538
+ sweepPresence.run(ns, now);
539
+ return selectPresence.all(ns, now).map((row) => ({
540
+ participant: row.participant,
541
+ field: row.field,
542
+ value: JSON.parse(row.value),
543
+ seen: Number(row.seen),
544
+ at: new Date(Number(row.at)),
545
+ expiresAt: new Date(Number(row.expires_at))
546
+ }));
547
+ });
548
+ }
549
+ },
468
550
  inspect: {
469
551
  async listSessions(inspectionOptions) {
470
552
  return wrap(() => {
@@ -513,6 +595,18 @@ function sqlite(options = {}) {
513
595
  };
514
596
  });
515
597
  },
598
+ async readEvents(sessionId, pageOptions) {
599
+ return wrap(() => {
600
+ const currentFrontier = Number(db.prepare("select coalesce(max(idx), 0) as frontier from a2_events where session_id = ?").get(sessionId).frontier);
601
+ const throughIndex = Math.min(pageOptions.throughIndex ?? currentFrontier, currentFrontier);
602
+ return {
603
+ events: db.prepare(`select * from a2_events
604
+ where session_id = ? and idx > ? and idx <= ?
605
+ order by idx limit ?`).all(sessionId, pageOptions.afterIndex, throughIndex, pageOptions.limit).map(toStored),
606
+ throughIndex
607
+ };
608
+ });
609
+ },
516
610
  async listSnapshots(sessionId) {
517
611
  return wrap(() => {
518
612
  return db.prepare(`select reducer_name, up_to_index, updated_at
@@ -528,7 +622,7 @@ function sqlite(options = {}) {
528
622
  const readAfter = (afterIndex) => wrap(() => {
529
623
  return db.prepare("select * from a2_events where session_id = ? and idx > ? order by idx").all(sessionId, afterIndex).map(toStored);
530
624
  });
531
- return pollingStream(readAfter, opts?.startAt !== void 0 ? { startAt: opts.startAt } : {});
625
+ return pollingStream(readAfter, opts?.startAfter !== void 0 ? { startAfter: opts.startAfter } : {});
532
626
  },
533
627
  close() {
534
628
  db.close();
@@ -1,7 +1,7 @@
1
1
  //#region src/telemetry.d.ts
2
2
  /**
3
3
  * The A2Telemetry interface — the instrumentation seam. Same philosophy
4
- * as log backends: the interface lives in core, implementations ship as
4
+ * as store backends: the interface lives in core, implementations ship as
5
5
  * entry points (`experimental-a2/otel` adapts it to OpenTelemetry). Without one,
6
6
  * every operation runs through a no-op wrapper.
7
7
  */
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,4 @@
1
+ //#region src/testing.browser.ts
2
+ /** experimental-a2/testing loads local capture files and is Node-only. */
3
+ throw new Error("experimental-a2/testing is Node-only. Load captures in a local test or script.");
4
+ //#endregion
@@ -0,0 +1,31 @@
1
+ import { a as EventDefs, n as Contract, p as Reducer, r as ContractEvent } from "./contract-jIfaR085.js";
2
+ import { c as DevtoolsCapture, l as DevtoolsCaptureEvent } from "./devtools-J_jZ2vQf.js";
3
+ //#region src/testing.d.ts
4
+ type CaptureSource = string | URL;
5
+ type LoadedCapture<D extends EventDefs> = {
6
+ /** The verified capture, including its footer and operational records. */
7
+ readonly capture: DevtoolsCapture;
8
+ /** The contract used to validate and type the captured event history. */
9
+ readonly contract: Contract<D>;
10
+ /** Contract-typed public events with `createdAt` revived to `Date`. */
11
+ readonly events: readonly ContractEvent<D>[];
12
+ /** Unmodified wire events, including dispatch and failure metadata. */
13
+ readonly rawEvents: readonly DevtoolsCaptureEvent[];
14
+ };
15
+ type ReplayCaptureOptions = {
16
+ /** Fold events through this log index, inclusive. Zero means no events. */
17
+ throughIndex?: number;
18
+ };
19
+ type CaptureReplay<S> = {
20
+ readonly state: S;
21
+ /** The last event index actually folded, or zero for the initial state. */
22
+ readonly index: number;
23
+ };
24
+ /** Validate an already-parsed capture against an application's contract. */
25
+ declare function prepareCapture<D extends EventDefs>(capture: DevtoolsCapture, contract: Contract<D>): Promise<LoadedCapture<D>>;
26
+ /** Load, integrity-check, and contract-type a `.a2log` file or URL. */
27
+ declare function loadCapture<D extends EventDefs>(source: CaptureSource, contract: Contract<D>): Promise<LoadedCapture<D>>;
28
+ /** Purely fold captured events through a reducer, optionally to a cutpoint. */
29
+ declare function replayCapture<D extends EventDefs, S>(loaded: LoadedCapture<D>, reducer: Reducer<D, S>, options?: ReplayCaptureOptions): CaptureReplay<S>;
30
+ //#endregion
31
+ export { CaptureReplay, CaptureSource, LoadedCapture, ReplayCaptureOptions, loadCapture, prepareCapture, replayCapture };
@@ -0,0 +1,101 @@
1
+ import { n as validateSync } from "./validate-XKT4FSNn.js";
2
+ import { l as parseDevtoolsCapture, u as verifyDevtoolsCapture } from "./devtools-kJJaORn-.js";
3
+ import { readFile } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ //#region src/testing.ts
6
+ /**
7
+ * Offline helpers for loading a captured A2 session and replaying its events
8
+ * through the application's current reducers. This module never constructs a
9
+ * server or dispatches handlers.
10
+ */
11
+ const captureError = (message) => /* @__PURE__ */ new TypeError(`invalid A2 capture: ${message}`);
12
+ const typedEvents = (capture, contract) => {
13
+ const events = [];
14
+ for (const event of capture.events) {
15
+ const schema = Object.hasOwn(contract.events, event.type) ? contract.events[event.type] : void 0;
16
+ if (schema === void 0) throw captureError(`contract '${contract.name}' has no event type '${event.type}' at index ${event.index}`);
17
+ const result = validateSync(schema, structuredClone(event.payload), `captured event '${event.type}'`);
18
+ if (result.issues !== void 0) throw captureError(`event #${event.index} '${event.type}' has an invalid payload: ${result.issues.map((issue) => issue.message).join("; ")}`);
19
+ events.push({
20
+ id: event.id,
21
+ type: event.type,
22
+ payload: result.value,
23
+ index: event.index,
24
+ sessionId: event.sessionId,
25
+ createdAt: new Date(event.createdAt)
26
+ });
27
+ }
28
+ return events;
29
+ };
30
+ /** Validate an already-parsed capture against an application's contract. */
31
+ async function prepareCapture(capture, contract) {
32
+ await verifyDevtoolsCapture(capture);
33
+ if (capture.contract !== contract.name) throw captureError(`expected contract '${contract.name}', received '${capture.contract}'`);
34
+ return {
35
+ capture,
36
+ contract,
37
+ events: typedEvents(capture, contract),
38
+ rawEvents: capture.events
39
+ };
40
+ }
41
+ const displayUrl = (url) => {
42
+ if (url.protocol === "data:") return "data: URL";
43
+ const safe = new URL(url);
44
+ safe.username = "";
45
+ safe.password = "";
46
+ safe.search = "";
47
+ safe.hash = "";
48
+ return safe.href;
49
+ };
50
+ const fetchCapture = async (url) => {
51
+ const location = displayUrl(url);
52
+ let response;
53
+ try {
54
+ response = await fetch(url);
55
+ } catch {
56
+ throw new Error(`failed to load A2 capture from '${location}'`);
57
+ }
58
+ if (!response.ok) throw new Error(`failed to load A2 capture from '${location}': ${response.status} ${response.statusText}`);
59
+ return new Uint8Array(await response.arrayBuffer());
60
+ };
61
+ const readCapture = async (source) => {
62
+ if (source instanceof URL) return source.protocol === "file:" ? readFile(fileURLToPath(source)) : fetchCapture(source);
63
+ if (/^[a-z]:[\\/]/i.test(source)) return readFile(source);
64
+ let url;
65
+ try {
66
+ url = new URL(source);
67
+ } catch {
68
+ return readFile(source);
69
+ }
70
+ return readCapture(url);
71
+ };
72
+ /** Load, integrity-check, and contract-type a `.a2log` file or URL. */
73
+ async function loadCapture(source, contract) {
74
+ const encoded = await readCapture(source);
75
+ return prepareCapture(parseDevtoolsCapture(encoded), contract);
76
+ }
77
+ const cloneInitial = (initialState) => {
78
+ try {
79
+ return structuredClone(initialState);
80
+ } catch {
81
+ return initialState;
82
+ }
83
+ };
84
+ /** Purely fold captured events through a reducer, optionally to a cutpoint. */
85
+ function replayCapture(loaded, reducer, options = {}) {
86
+ const { throughIndex } = options;
87
+ if (throughIndex !== void 0 && (!Number.isInteger(throughIndex) || throughIndex < 0)) throw new TypeError("throughIndex must be a non-negative integer");
88
+ let state = cloneInitial(reducer.initialState);
89
+ let index = 0;
90
+ for (const event of loaded.events) {
91
+ if (throughIndex !== void 0 && event.index > throughIndex) break;
92
+ state = reducer.fold(state, event);
93
+ index = event.index;
94
+ }
95
+ return {
96
+ state,
97
+ index
98
+ };
99
+ }
100
+ //#endregion
101
+ export { loadCapture, prepareCapture, replayCapture };