experimental-a2 0.0.0 → 0.2.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 (55) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/ai-server.browser.js +2 -2
  3. package/dist/ai-server.d.ts +19 -7
  4. package/dist/ai-server.js +730 -96
  5. package/dist/ai.d.ts +32 -11
  6. package/dist/ai.js +253 -75
  7. package/dist/client.d.ts +1 -1
  8. package/dist/client.js +4 -4
  9. package/dist/{contract-B0kAXoaL.js → contract-CG_adnu_.js} +2 -1
  10. package/dist/{contract-DL8btVd9.d.ts → contract-C_3dIIEU.d.ts} +4 -1
  11. package/dist/devtools-server.browser.js +2 -2
  12. package/dist/devtools-server.js +1 -1
  13. package/dist/http.d.ts +1 -1
  14. package/dist/http.js +4 -3
  15. package/dist/idempotent-replay-BMyHrP0L.js +19 -0
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +1 -1
  18. package/dist/{internal-Dm8Ejnud.js → internal-D6wNxTck.js} +3 -3
  19. package/dist/{log-Dg1I8NRr.d.ts → log-ldf5g8Cx.d.ts} +74 -56
  20. package/dist/log-memory.d.ts +1 -1
  21. package/dist/log-memory.js +173 -96
  22. package/dist/{log-polling-RO7kclzR.js → log-polling-6COoN60V.js} +1 -1
  23. package/dist/log-postgres.d.ts +1 -1
  24. package/dist/log-postgres.js +235 -192
  25. package/dist/log-redis.d.ts +1 -1
  26. package/dist/log-redis.js +453 -263
  27. package/dist/log-sqlite.d.ts +1 -1
  28. package/dist/log-sqlite.js +216 -127
  29. package/dist/otel.d.ts +1 -1
  30. package/dist/otel.js +1 -1
  31. package/dist/react.d.ts +1 -1
  32. package/dist/react.js +1 -1
  33. package/dist/recovery-vercel.d.ts +2 -2
  34. package/dist/recovery-vercel.js +9 -10
  35. package/dist/server-DJgD2YWP.js +877 -0
  36. package/dist/server.browser.js +4 -4
  37. package/dist/server.d.ts +46 -27
  38. package/dist/server.js +1 -1
  39. package/dist/{telemetry-C78al20p.d.ts → telemetry-Cso0qyHQ.d.ts} +1 -1
  40. package/dist/{wire-2QpU1EtJ.js → wire-BVsgR8o9.js} +1 -1
  41. package/docs/01-quickstart.mdx +7 -7
  42. package/docs/concepts/01-contracts.mdx +22 -22
  43. package/docs/concepts/02-handlers.mdx +223 -89
  44. package/docs/concepts/03-durability.mdx +199 -112
  45. package/docs/concepts/04-state.mdx +27 -1
  46. package/docs/guides/01-timers.mdx +4 -4
  47. package/docs/guides/02-cancellation.mdx +32 -4
  48. package/docs/guides/05-production.mdx +61 -27
  49. package/docs/guides/06-ai-agents.mdx +151 -70
  50. package/docs/guides/07-devtools.mdx +6 -3
  51. package/docs/guides/08-application-data.mdx +5 -6
  52. package/docs/index.mdx +30 -14
  53. package/docs/reference/01-api.mdx +305 -70
  54. package/package.json +31 -31
  55. package/dist/server-DYsnKTTy.js +0 -780
@@ -1,4 +1,4 @@
1
- import { c as IdSource, i as Clock, t as A2Log } from "./log-Dg1I8NRr.js";
1
+ import { c as IdSource, i as Clock, t as A2Log } from "./log-ldf5g8Cx.js";
2
2
  //#region src/log-sqlite.d.ts
3
3
  type SqliteLogOptions = {
4
4
  /** Database file path. Defaults to `.a2/dev.db`; `:memory:` works. */
@@ -1,12 +1,13 @@
1
1
  import { t as A2Error } from "./errors-BJRMd-h6.js";
2
+ import { t as idempotentReplay } from "./idempotent-replay-BMyHrP0L.js";
2
3
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
3
- import { t as pollingStream } from "./log-polling-RO7kclzR.js";
4
+ import { t as pollingStream } from "./log-polling-6COoN60V.js";
4
5
  import { mkdirSync } from "node:fs";
5
6
  import { dirname, resolve } from "node:path";
6
7
  import { DatabaseSync } from "node:sqlite";
7
8
  //#region src/log-sqlite.ts
8
9
  /**
9
- * a2/log-sqlite — sqlite log backend (the dev default, `.a2/dev.db`).
10
+ * experimental-a2/log-sqlite — sqlite log backend (the dev default, `.a2/dev.db`).
10
11
  *
11
12
  * Built on `node:sqlite` (Node ≥ 22.13) so it ships with zero
12
13
  * dependencies. Implements the A2Log interface; the conformance suite
@@ -27,8 +28,11 @@ create table if not exists a2_events (
27
28
  event_id text not null,
28
29
  created_at integer not null,
29
30
  cause text,
31
+ lane text,
32
+ lane_ready integer not null default 0,
30
33
  processed_at integer,
31
34
  processed_by_attempt integer,
35
+ returned_event_ids text,
32
36
  first_claimed_at integer,
33
37
  last_claimed_at integer,
34
38
  attempt_count integer not null default 0,
@@ -37,18 +41,22 @@ create table if not exists a2_events (
37
41
  last_failed_attempt integer,
38
42
  last_error text,
39
43
  failed_at integer,
44
+ claim_holder text,
45
+ claim_expires_at integer,
40
46
  primary key (session_id, idx)
41
47
  ) strict;
42
48
 
43
49
  create unique index if not exists a2_events_event_id on a2_events (event_id);
44
50
  create index if not exists a2_events_unprocessed
45
51
  on a2_events (session_id, idx) where processed_at is null;
46
-
47
- create table if not exists a2_leases (
48
- session_id text primary key,
49
- holder text not null,
50
- expires_at integer not null
51
- ) strict;
52
+ create index if not exists a2_events_lane_pending
53
+ on a2_events (session_id, lane, idx) where processed_at is null;
54
+ create index if not exists a2_events_dispatch_ready
55
+ on a2_events (session_id, idx)
56
+ where lane_ready = 1 and processed_at is null and failed_at is null;
57
+ create index if not exists a2_events_claim_expiry
58
+ on a2_events (session_id, claim_expires_at)
59
+ where lane_ready = 1 and processed_at is null and failed_at is null;
52
60
 
53
61
  create table if not exists a2_snapshots (
54
62
  session_id text not null,
@@ -88,9 +96,11 @@ const toCause = (json) => {
88
96
  if (json === null) return null;
89
97
  const value = JSON.parse(json);
90
98
  if (!Number.isInteger(value.index) || Number(value.index) < 1 || !Number.isInteger(value.attempt) || Number(value.attempt) < 1) throw new TypeError("stored event has an invalid cause");
99
+ if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
91
100
  return {
92
101
  index: Number(value.index),
93
- attempt: Number(value.attempt)
102
+ attempt: Number(value.attempt),
103
+ ...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
94
104
  };
95
105
  };
96
106
  const toStored = (row) => ({
@@ -101,8 +111,10 @@ const toStored = (row) => ({
101
111
  sessionId: row.session_id,
102
112
  createdAt: new Date(Number(row.created_at)),
103
113
  cause: toCause(row.cause),
114
+ lane: row.lane,
104
115
  processedAt: toDate(row.processed_at),
105
116
  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),
106
118
  firstClaimedAt: toDate(row.first_claimed_at),
107
119
  lastClaimedAt: toDate(row.last_claimed_at),
108
120
  attemptCount: Number(row.attempt_count),
@@ -110,7 +122,9 @@ const toStored = (row) => ({
110
122
  lastFailedAt: toDate(row.last_failed_at),
111
123
  lastFailedAttempt: row.last_failed_attempt === null ? null : Number(row.last_failed_attempt),
112
124
  lastError: row.last_error,
113
- failedAt: toDate(row.failed_at)
125
+ failedAt: toDate(row.failed_at),
126
+ claimHolder: row.claim_holder,
127
+ claimExpiresAt: toDate(row.claim_expires_at)
114
128
  });
115
129
  const toEvent = (row) => ({
116
130
  id: row.event_id,
@@ -120,9 +134,6 @@ const toEvent = (row) => ({
120
134
  sessionId: row.session_id,
121
135
  createdAt: new Date(Number(row.created_at))
122
136
  });
123
- const requireChange = (changes, what) => {
124
- if (Number(changes) === 0) throw new TypeError(what);
125
- };
126
137
  function sqlite(options = {}) {
127
138
  const path = options.path ?? ".a2/dev.db";
128
139
  const clock = options.clock ?? SYSTEM_CLOCK;
@@ -138,11 +149,41 @@ function sqlite(options = {}) {
138
149
  db.exec(SCHEMA);
139
150
  const insertEvent = db.prepare(`insert into a2_events
140
151
  (session_id, idx, event_type, payload, event_id, created_at,
141
- cause)
142
- values (?, ?, ?, ?, ?, ?, ?)`);
143
- const maxIdx = db.prepare("select coalesce(max(idx), 0) as max from a2_events where session_id = ?");
144
- const selectByIds = (count) => db.prepare(`select * from a2_events where event_id in (${Array.from({ length: count }, () => "?").join(", ")})
145
- order by idx`);
152
+ cause, lane, lane_ready, processed_at)
153
+ select ?, ?, ?, ?, ?, ?, ?, ?,
154
+ case
155
+ when ? is null then 1
156
+ when not exists (
157
+ select 1 from a2_events
158
+ where session_id = ? and lane = ? and processed_at is null
159
+ ) then 1
160
+ else 0
161
+ end,
162
+ ?`);
163
+ const promoteLaneHead = db.prepare(`update a2_events set lane_ready = 1
164
+ where session_id = ?
165
+ and idx = (
166
+ select idx from a2_events
167
+ where session_id = ? and lane = ? and processed_at is null
168
+ order by idx limit 1
169
+ )`);
170
+ const appendStatus = db.prepare(`select coalesce(
171
+ (select max(idx) from a2_events where session_id = ?),
172
+ 0
173
+ ) as max,
174
+ exists(
175
+ select 1 from a2_events
176
+ where session_id = ? and processed_at is null
177
+ ) as has_pending`);
178
+ const selectByIds = (count) => db.prepare(`select stored.*,
179
+ exists(
180
+ select 1 from a2_events pending
181
+ where pending.session_id = ?
182
+ and pending.processed_at is null
183
+ ) as session_has_pending
184
+ from a2_events stored
185
+ where stored.event_id in (${Array.from({ length: count }, () => "?").join(", ")})
186
+ order by stored.idx`);
146
187
  const tx = (fn) => {
147
188
  db.exec("begin immediate");
148
189
  try {
@@ -158,45 +199,62 @@ function sqlite(options = {}) {
158
199
  };
159
200
  return {
160
201
  async append(sessionId, events) {
161
- if (events.length === 0) return [];
202
+ if (events.length === 0) return wrap(() => {
203
+ const status = appendStatus.get(sessionId, sessionId);
204
+ return {
205
+ events: [],
206
+ hasPending: Number(status.has_pending) === 1
207
+ };
208
+ });
162
209
  return wrap(() => tx(() => {
163
210
  const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
164
211
  if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
165
212
  if (suppliedIds.length > 0) {
166
- const existing = selectByIds(suppliedIds.length).all(...suppliedIds);
213
+ const existing = selectByIds(suppliedIds.length).all(sessionId, ...suppliedIds);
167
214
  if (existing.length > 0) {
168
215
  const foreign = existing.find((row) => row.session_id !== sessionId);
169
216
  if (foreign) throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${foreign.event_id}' already exists in another session`);
170
- if (existing.length === events.length) return existing.map(toStored);
217
+ if (existing.length === events.length) return {
218
+ events: idempotentReplay(events, existing.map(toStored)),
219
+ hasPending: Number(existing[0].session_has_pending) === 1
220
+ };
171
221
  throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
172
222
  }
173
223
  }
174
- const base = Number(maxIdx.get(sessionId).max);
224
+ const status = appendStatus.get(sessionId, sessionId);
225
+ const base = Number(status.max);
175
226
  const now = clock.now().getTime();
176
- return events.map((e, i) => {
177
- const id = e.id ?? generateId();
178
- const index = base + 1 + i;
179
- insertEvent.run(sessionId, index, e.type, JSON.stringify(e.payload) ?? "null", id, now, e.cause ? JSON.stringify(e.cause) : null);
180
- return {
181
- id,
182
- type: e.type,
183
- payload: structuredClone(e.payload),
184
- index,
185
- sessionId,
186
- createdAt: new Date(now),
187
- cause: e.cause ? { ...e.cause } : null,
188
- processedAt: null,
189
- processedByAttempt: null,
190
- firstClaimedAt: null,
191
- lastClaimedAt: null,
192
- attemptCount: 0,
193
- failureCount: 0,
194
- lastFailedAt: null,
195
- lastFailedAttempt: null,
196
- lastError: null,
197
- failedAt: null
198
- };
199
- });
227
+ return {
228
+ events: events.map((e, i) => {
229
+ const id = e.id ?? generateId();
230
+ const index = base + 1 + i;
231
+ insertEvent.run(sessionId, index, e.type, JSON.stringify(e.payload) ?? "null", id, now, e.cause ? JSON.stringify(e.cause) : null, e.lane ?? null, e.lane ?? null, sessionId, e.lane ?? null, e.settled ? now : null);
232
+ return {
233
+ id,
234
+ type: e.type,
235
+ payload: structuredClone(e.payload),
236
+ index,
237
+ sessionId,
238
+ createdAt: new Date(now),
239
+ cause: e.cause ? { ...e.cause } : null,
240
+ lane: e.lane ?? null,
241
+ processedAt: e.settled ? new Date(now) : null,
242
+ processedByAttempt: null,
243
+ returnedEventIds: null,
244
+ firstClaimedAt: null,
245
+ lastClaimedAt: null,
246
+ attemptCount: 0,
247
+ failureCount: 0,
248
+ lastFailedAt: null,
249
+ lastFailedAttempt: null,
250
+ lastError: null,
251
+ failedAt: null,
252
+ claimHolder: null,
253
+ claimExpiresAt: null
254
+ };
255
+ }),
256
+ hasPending: Number(status.has_pending) === 1 || events.some((event) => event.settled !== true)
257
+ };
200
258
  }));
201
259
  },
202
260
  async read(sessionId, opts) {
@@ -207,74 +265,116 @@ function sqlite(options = {}) {
207
265
  conditions.push("idx > ?");
208
266
  params.push(opts.afterIndex);
209
267
  }
210
- if (opts?.unprocessedOnly) conditions.push("processed_at is null");
211
268
  return db.prepare(`select * from a2_events where ${conditions.join(" and ")} order by idx`).all(...params).map(toStored);
212
269
  });
213
270
  },
214
- async claimNext({ sessionId, holder, ttlMs, expiresAtMs, maxIndex }) {
271
+ async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes = [] }) {
215
272
  return wrap(() => tx(() => {
216
- const next = db.prepare(`select * from a2_events
217
- where session_id = ? and processed_at is null
218
- order by idx limit 1`).get(sessionId);
219
- if (!next || next.failed_at !== null || maxIndex !== void 0 && Number(next.idx) > maxIndex) return { outcome: "settled" };
220
273
  const now = clock.now().getTime();
221
- const current = db.prepare("select holder, expires_at from a2_leases where session_id = ?").get(sessionId);
222
- if (current && Number(current.expires_at) > now && current.holder !== holder) return { outcome: "busy" };
223
- db.prepare(`insert into a2_leases (session_id, holder, expires_at) values (?, ?, ?)
224
- on conflict (session_id) do update set
225
- holder = excluded.holder,
226
- expires_at = excluded.expires_at`).run(sessionId, holder, expiresAtMs ?? now + ttlMs);
227
- const claimed = db.prepare(`update a2_events
228
- set attempt_count = attempt_count + 1,
229
- first_claimed_at = coalesce(first_claimed_at, ?),
230
- last_claimed_at = ?
231
- where session_id = ? and idx = ? and processed_at is null
232
- returning *`).get(now, now, sessionId, next.idx);
233
- if (!claimed) throw new TypeError(`no pending event at index ${String(next.idx)} in session '${sessionId}'`);
234
- return {
235
- outcome: "claimed",
236
- event: toStored(claimed)
274
+ const expiresAt = expiresAtMs ?? now + ttlMs;
275
+ const exclusion = excludeIndexes.length === 0 ? "" : `and event.idx not in (${excludeIndexes.map(() => "?").join(", ")})`;
276
+ const eligible = db.prepare(`select event.idx
277
+ from a2_events event
278
+ where event.session_id = ?
279
+ and event.processed_at is null
280
+ and event.failed_at is null
281
+ and event.lane_ready = 1
282
+ and (event.claim_expires_at is null or event.claim_expires_at <= ?)
283
+ ${exclusion}
284
+ order by event.idx`).all(sessionId, now, ...excludeIndexes);
285
+ if (eligible.length > 0) {
286
+ const update = db.prepare(`update a2_events set
287
+ attempt_count = attempt_count + 1,
288
+ first_claimed_at = coalesce(first_claimed_at, ?),
289
+ last_claimed_at = ?,
290
+ claim_holder = ?,
291
+ claim_expires_at = ?
292
+ where session_id = ? and idx = ?
293
+ returning *`);
294
+ return {
295
+ outcome: "claimed",
296
+ events: eligible.map((row) => {
297
+ const claimed = update.get(now, now, holder, expiresAt, sessionId, row.idx);
298
+ return toStored(claimed);
299
+ })
300
+ };
301
+ }
302
+ const active = db.prepare(`select min(claim_expires_at) as retry_at
303
+ from a2_events
304
+ where session_id = ?
305
+ and processed_at is null
306
+ and failed_at is null
307
+ and lane_ready = 1
308
+ and claim_expires_at > ?`).get(sessionId, now);
309
+ return active.retry_at === null ? { outcome: "settled" } : {
310
+ outcome: "busy",
311
+ retryAt: new Date(Number(active.retry_at))
237
312
  };
238
313
  }));
239
314
  },
240
- async completeAndClaimNext({ sessionId, holder, completedIndex, attempt, maxIndex }) {
315
+ async renewClaims({ sessionId, holder, indexes, ttlMs, expiresAtMs }) {
316
+ if (indexes.length === 0) return [];
241
317
  return wrap(() => tx(() => {
242
318
  const now = clock.now().getTime();
243
- if (!db.prepare(`update a2_events
244
- set processed_at = ?, processed_by_attempt = ?
245
- where session_id = ? and idx = ?
246
- and processed_at is null and attempt_count = ?
247
- returning idx`).get(now, attempt, sessionId, completedIndex, attempt)) {
248
- if (!db.prepare("select 1 from a2_events where session_id = ? and idx = ?").get(sessionId, completedIndex)) throw new TypeError(`no event at index ${completedIndex} in session '${sessionId}'`);
249
- return { outcome: "superseded" };
319
+ 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);
329
+ }));
330
+ },
331
+ async completeAttempt({ sessionId, index, attempt, events }) {
332
+ return wrap(() => tx(() => {
333
+ const parent = db.prepare("select * from a2_events where session_id = ? and idx = ?").get(sessionId, index);
334
+ if (!parent) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
335
+ const ids = events.map((event) => event.id);
336
+ 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
+ if (parent.processed_at !== null) {
338
+ 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);
340
+ 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
+ const existing = ids.length === 0 ? [] : selectByIds(ids.length).all(sessionId, ...ids);
342
+ const byId = new Map(existing.map((row) => [row.event_id, row]));
343
+ const ordered = ids.map((id) => byId.get(id));
344
+ if (ordered.some((row) => !row || row.session_id !== sessionId || row.cause === null || toCause(row.cause)?.index !== index || toCause(row.cause)?.attempt !== attempt)) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
345
+ return {
346
+ outcome: "completed",
347
+ events: idempotentReplay(events, ordered.map(toStored))
348
+ };
250
349
  }
251
- const next = db.prepare(`select * from a2_events
252
- where session_id = ? and processed_at is null
253
- order by idx limit 1`).get(sessionId);
254
- if (!next || next.failed_at !== null || maxIndex !== void 0 && Number(next.idx) > maxIndex) return { outcome: "settled" };
255
- const lease = db.prepare("select holder, expires_at from a2_leases where session_id = ?").get(sessionId);
256
- if (!lease || lease.holder !== holder || Number(lease.expires_at) <= now) return { outcome: "busy" };
257
- const claimed = db.prepare(`update a2_events
258
- set attempt_count = attempt_count + 1,
259
- first_claimed_at = coalesce(first_claimed_at, ?),
260
- last_claimed_at = ?
261
- where session_id = ? and idx = ? and processed_at is null
262
- returning *`).get(now, now, sessionId, next.idx);
263
- if (!claimed) throw new TypeError(`no pending event at index ${String(next.idx)} in session '${sessionId}'`);
350
+ if (Number(parent.attempt_count) !== attempt || parent.claim_holder === null || parent.failed_at !== null) return { outcome: "superseded" };
351
+ if (ids.length > 0 && selectByIds(ids.length).all(sessionId, ...ids).length > 0) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains an already-appended event id");
352
+ const status = appendStatus.get(sessionId, sessionId);
353
+ const now = clock.now().getTime();
354
+ db.prepare(`update a2_events set
355
+ processed_at = ?,
356
+ processed_by_attempt = ?,
357
+ returned_event_ids = ?,
358
+ lane_ready = 0,
359
+ claim_holder = null,
360
+ claim_expires_at = null
361
+ where session_id = ? and idx = ?`).run(now, attempt, JSON.stringify(ids), sessionId, index);
362
+ if (parent.lane !== null) promoteLaneHead.run(sessionId, sessionId, parent.lane);
264
363
  return {
265
- outcome: "claimed",
266
- event: toStored(claimed)
364
+ outcome: "completed",
365
+ events: events.map((event, offset) => {
366
+ const id = event.id;
367
+ const childIndex = Number(status.max) + offset + 1;
368
+ const cause = {
369
+ index,
370
+ attempt
371
+ };
372
+ insertEvent.run(sessionId, childIndex, event.type, JSON.stringify(event.payload) ?? "null", id, now, JSON.stringify(cause), event.lane ?? null, event.lane ?? null, sessionId, event.lane ?? null, event.settled ? now : null);
373
+ return toStored(db.prepare("select * from a2_events where session_id = ? and idx = ?").get(sessionId, childIndex));
374
+ })
267
375
  };
268
376
  }));
269
377
  },
270
- async markProcessed(sessionId, index) {
271
- wrap(() => {
272
- const { changes } = db.prepare(`update a2_events
273
- set processed_at = coalesce(processed_at, ?)
274
- where session_id = ? and idx = ?`).run(clock.now().getTime(), sessionId, index);
275
- requireChange(changes, `no event at index ${index} in session '${sessionId}'`);
276
- });
277
- },
278
378
  async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
279
379
  return wrap(() => tx(() => {
280
380
  const current = db.prepare("select * from a2_events where session_id = ? and idx = ?").get(sessionId, index);
@@ -288,6 +388,14 @@ function sqlite(options = {}) {
288
388
  outcome: "dead_lettered",
289
389
  failureCount
290
390
  };
391
+ if (current.claim_holder === null && current.last_failed_attempt !== null && Number(current.last_failed_attempt) === attempt) return {
392
+ outcome: "failed",
393
+ failureCount
394
+ };
395
+ if (current.claim_holder === null) return {
396
+ outcome: "superseded",
397
+ failureCount
398
+ };
291
399
  const now = clock.now().getTime();
292
400
  const nextFailureCount = failureCount + 1;
293
401
  const deadLettered = nextFailureCount >= maxFailures;
@@ -296,7 +404,9 @@ function sqlite(options = {}) {
296
404
  last_error = ?,
297
405
  last_failed_at = ?,
298
406
  last_failed_attempt = ?,
299
- failed_at = ?
407
+ failed_at = ?,
408
+ claim_holder = null,
409
+ claim_expires_at = null
300
410
  where session_id = ? and idx = ?`).run(nextFailureCount, error, now, attempt, deadLettered ? now : null, sessionId, index);
301
411
  return {
302
412
  outcome: deadLettered ? "dead_lettered" : "failed",
@@ -304,12 +414,6 @@ function sqlite(options = {}) {
304
414
  };
305
415
  }));
306
416
  },
307
- async markFailed(sessionId, index) {
308
- wrap(() => {
309
- const { changes } = db.prepare("update a2_events set failed_at = ? where session_id = ? and idx = ?").run(clock.now().getTime(), sessionId, index);
310
- requireChange(changes, `no event at index ${index} in session '${sessionId}'`);
311
- });
312
- },
313
417
  async readState(sessionId, reducerName) {
314
418
  return wrap(() => {
315
419
  const rows = db.prepare(`with snapshot as materialized (
@@ -322,12 +426,15 @@ function sqlite(options = {}) {
322
426
  snapshot.state as snapshot_state,
323
427
  null as session_id, null as idx, null as event_type,
324
428
  null as payload, null as event_id, null as created_at,
325
- null as cause, null as processed_at,
326
- null as processed_by_attempt, null as first_claimed_at,
429
+ null as cause, null as lane, null as lane_ready,
430
+ null as processed_at,
431
+ null as processed_by_attempt, null as returned_event_ids,
432
+ null as first_claimed_at,
327
433
  null as last_claimed_at, null as attempt_count,
328
434
  null as failure_count, null as last_failed_at,
329
435
  null as last_failed_attempt, null as last_error,
330
- null as failed_at
436
+ null as failed_at, null as claim_holder,
437
+ null as claim_expires_at
331
438
  from snapshot
332
439
  union all
333
440
  select 1 as row_order, 'event' as row_kind,
@@ -423,24 +530,6 @@ function sqlite(options = {}) {
423
530
  });
424
531
  return pollingStream(readAfter, opts?.startAt !== void 0 ? { startAt: opts.startAt } : {});
425
532
  },
426
- lease: {
427
- async acquire({ sessionId, holder, ttlMs, expiresAtMs }) {
428
- return wrap(() => tx(() => {
429
- const now = clock.now().getTime();
430
- const expiresAt = expiresAtMs ?? now + ttlMs;
431
- const current = db.prepare("select * from a2_leases where session_id = ?").get(sessionId);
432
- if (current && Number(current.expires_at) > now && current.holder !== holder) return false;
433
- db.prepare(`insert into a2_leases (session_id, holder, expires_at) values (?, ?, ?)
434
- on conflict (session_id) do update set holder = excluded.holder, expires_at = excluded.expires_at`).run(sessionId, holder, expiresAt);
435
- return true;
436
- }));
437
- },
438
- async release({ sessionId, holder }) {
439
- wrap(() => {
440
- db.prepare("delete from a2_leases where session_id = ? and holder = ?").run(sessionId, holder);
441
- });
442
- }
443
- },
444
533
  close() {
445
534
  db.close();
446
535
  }
package/dist/otel.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as A2Telemetry } from "./telemetry-C78al20p.js";
1
+ import { i as A2Telemetry } from "./telemetry-Cso0qyHQ.js";
2
2
  import { Tracer } from "@opentelemetry/api";
3
3
  //#region src/otel.d.ts
4
4
  type OtelOptions = {
package/dist/otel.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { SpanStatusCode, trace } from "@opentelemetry/api";
2
2
  //#region src/otel.ts
3
3
  /**
4
- * a2/otel — OpenTelemetry adapter for the A2Telemetry interface.
4
+ * experimental-a2/otel — OpenTelemetry adapter for the A2Telemetry interface.
5
5
  *
6
6
  * Peer-depends on `@opentelemetry/api` (optional). Spans nest through
7
7
  * the active context, so with a context manager registered (any real
package/dist/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as EventDefs, r as ContractEvent, s as Reducer, t as AppendInput } from "./contract-DL8btVd9.js";
1
+ import { a as EventDefs, c as Reducer, r as ContractEvent, t as AppendInput } from "./contract-C_3dIIEU.js";
2
2
  import { A2Client, Connection, PushResult } from "./client.js";
3
3
  import { ReactElement, ReactNode } from "react";
4
4
  //#region src/react.d.ts
package/dist/react.js CHANGED
@@ -3,7 +3,7 @@ import { createClient } from "./client.js";
3
3
  import { createContext, createElement, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
4
4
  //#region src/react.ts
5
5
  /**
6
- * a2/react — React bindings over a2/client.
6
+ * experimental-a2/react — React bindings over experimental-a2/client.
7
7
  *
8
8
  * `createReact({ client })` is a factory (like createContext): call it
9
9
  * once in a `'use client'` module with a shared A2 client and export the
@@ -1,4 +1,4 @@
1
- import { i as Clock } from "./log-Dg1I8NRr.js";
1
+ import { i as Clock } from "./log-ldf5g8Cx.js";
2
2
  import { A2Recovery } from "./server.js";
3
3
  //#region src/recovery-vercel.d.ts
4
4
  /**
@@ -47,7 +47,7 @@ type VercelQueuesOptions = {
47
47
  topic?: string;
48
48
  /**
49
49
  * Fallback delay when an arm does not supply an absolute due time.
50
- * Core A2 arms lease windows explicitly. Default 5.
50
+ * Core A2 arms claim windows explicitly. Default 5.
51
51
  */
52
52
  delaySeconds?: number;
53
53
  /** Injectable transport (tests). Default: `@vercel/queue`. */
@@ -1,15 +1,15 @@
1
- import { i as serverInternals, t as DRAIN_TIMINGS } from "./internal-Dm8Ejnud.js";
1
+ import { i as serverInternals, t as DRAIN_TIMINGS } from "./internal-D6wNxTck.js";
2
2
  import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
3
3
  import { n as SYSTEM_CLOCK } from "./log-yJbXUf72.js";
4
4
  //#region src/recovery-vercel.ts
5
5
  /**
6
- * a2/recovery-vercel — queue-backed recovery over Vercel Queues.
6
+ * experimental-a2/recovery-vercel — queue-backed recovery over Vercel Queues.
7
7
  *
8
8
  * `vercelQueues()` returns an `A2Recovery` (a2-implementation.md §9):
9
9
  *
10
10
  * - `arm` rounds the requested watchdog time up to a one-second slot.
11
11
  * One message per `(contract, session, dueAt)` coalesces appends,
12
- * lease renewals, and racing callbacks. `DuplicateMessageError` is
12
+ * claim renewals and racing callbacks. `DuplicateMessageError` is
13
13
  * success because that due-time slot is already durable.
14
14
  * - `handler(...servers)` is the delivery route: look up the server for
15
15
  * the contract named in the message and drain it. Settled drains ack;
@@ -36,7 +36,7 @@ const MIN_VISIBILITY_SECONDS = 30;
36
36
  const unsettledBackoffSeconds = (delaySeconds, deliveryCount) => Math.min(Math.max(delaySeconds * 2 ** Math.max(deliveryCount - 1, 0), MIN_VISIBILITY_SECONDS), 60);
37
37
  const vercelQueueTransport = async (delaySeconds) => {
38
38
  const queue = await import("@vercel/queue").catch(() => {
39
- throw new Error("a2/recovery-vercel needs the '@vercel/queue' package (optional peer dependency) — install it, or inject a transport");
39
+ throw new Error("experimental-a2/recovery-vercel needs the '@vercel/queue' package (optional peer dependency) — install it, or inject a transport");
40
40
  });
41
41
  return {
42
42
  send: (topic, message, options) => queue.send(topic, message, options),
@@ -73,10 +73,10 @@ function vercelQueues(options = {}) {
73
73
  }
74
74
  };
75
75
  const successorDueAt = (message) => {
76
- const minimumDueAt = clock.now().getTime() + DRAIN_TIMINGS.leaseTtlMs + DRAIN_TIMINGS.recoveryGraceMs;
76
+ const minimumDueAt = clock.now().getTime() + DRAIN_TIMINGS.claimTtlMs + DRAIN_TIMINGS.recoveryGraceMs;
77
77
  if (message.dueAt === void 0) return minimumDueAt;
78
- const beats = Math.max(1, Math.ceil((minimumDueAt - message.dueAt) / DRAIN_TIMINGS.leaseHeartbeatMs));
79
- return message.dueAt + beats * DRAIN_TIMINGS.leaseHeartbeatMs;
78
+ const beats = Math.max(1, Math.ceil((minimumDueAt - message.dueAt) / DRAIN_TIMINGS.claimHeartbeatMs));
79
+ return message.dueAt + beats * DRAIN_TIMINGS.claimHeartbeatMs;
80
80
  };
81
81
  return {
82
82
  async arm({ contract, sessionId, dueAt }) {
@@ -97,11 +97,10 @@ function vercelQueues(options = {}) {
97
97
  const nextDueAt = successorDueAt(message);
98
98
  const result = internals ? await internals.recoveryDrain(message.sessionId, { recoveryDueAt: nextDueAt }) : {
99
99
  ...await server.drain(message.sessionId),
100
- outcome: "unknown",
101
- processed: 0
100
+ outcome: "unknown"
102
101
  };
103
102
  if (result.settled) return;
104
- if (result.outcome === "busy" || result.outcome === "handed_off") {
103
+ if (result.outcome === "busy") {
105
104
  if (result.recoveryArm) await result.recoveryArm;
106
105
  else await sendWatchdog({
107
106
  contract: message.contract,