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,10 +1,11 @@
1
1
  import { t as A2Error } from "./errors-BJRMd-h6.js";
2
2
  import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
3
+ import { t as idempotentReplay } from "./idempotent-replay-BMyHrP0L.js";
3
4
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
4
- import { t as pollingStream } from "./log-polling-RO7kclzR.js";
5
+ import { t as pollingStream } from "./log-polling-6COoN60V.js";
5
6
  //#region src/log-postgres.ts
6
7
  /**
7
- * a2/log-postgres — postgres log backend (the production path).
8
+ * experimental-a2/log-postgres — postgres log backend (the production path).
8
9
  *
9
10
  * Implements the A2Log interface over any client that speaks the tiny
10
11
  * `PostgresClient` shape below — a `pg` Pool matches it structurally
@@ -30,8 +31,11 @@ create table if not exists a2_events (
30
31
  event_id text not null,
31
32
  created_at bigint not null,
32
33
  cause jsonb,
34
+ lane text,
35
+ lane_ready boolean not null default false,
33
36
  processed_at bigint,
34
37
  processed_by_attempt integer,
38
+ returned_event_ids jsonb,
35
39
  first_claimed_at bigint,
36
40
  last_claimed_at bigint,
37
41
  attempt_count integer not null default 0,
@@ -40,32 +44,22 @@ create table if not exists a2_events (
40
44
  last_failed_attempt integer,
41
45
  last_error text,
42
46
  failed_at bigint,
47
+ claim_holder text,
48
+ claim_expires_at bigint,
43
49
  primary key (session_id, idx)
44
50
  );
45
51
 
46
- -- CREATE TABLE IF NOT EXISTS leaves an older A2 table untouched. Keep
47
- -- additive lifecycle changes self-healing: an old warm deployment can recreate
48
- -- its embedded schema after an operator drops the tables during a rollout.
49
- alter table a2_events add column if not exists cause jsonb;
50
- alter table a2_events add column if not exists processed_by_attempt integer;
51
- alter table a2_events add column if not exists first_claimed_at bigint;
52
- alter table a2_events add column if not exists last_claimed_at bigint;
53
- alter table a2_events
54
- add column if not exists attempt_count integer not null default 0;
55
- alter table a2_events
56
- add column if not exists failure_count integer not null default 0;
57
- alter table a2_events add column if not exists last_failed_at bigint;
58
- alter table a2_events add column if not exists last_failed_attempt integer;
59
-
60
52
  create unique index if not exists a2_events_event_id on a2_events (event_id);
61
53
  create index if not exists a2_events_unprocessed
62
54
  on a2_events (session_id, idx) where processed_at is null;
63
-
64
- create table if not exists a2_leases (
65
- session_id text primary key,
66
- holder text not null,
67
- expires_at bigint not null
68
- );
55
+ create index if not exists a2_events_lane_pending
56
+ on a2_events (session_id, lane, idx) where processed_at is null;
57
+ create index if not exists a2_events_dispatch_ready
58
+ on a2_events (session_id, idx)
59
+ where lane_ready and processed_at is null and failed_at is null;
60
+ create index if not exists a2_events_claim_expiry
61
+ on a2_events (session_id, claim_expires_at)
62
+ where lane_ready and processed_at is null and failed_at is null;
69
63
 
70
64
  create table if not exists a2_snapshots (
71
65
  session_id text not null,
@@ -82,9 +76,11 @@ const toCause = (raw) => {
82
76
  if (raw === null || raw === void 0) return null;
83
77
  const value = typeof raw === "string" ? JSON.parse(raw) : raw;
84
78
  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");
79
+ if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
85
80
  return {
86
81
  index: Number(value.index),
87
- attempt: Number(value.attempt)
82
+ attempt: Number(value.attempt),
83
+ ...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
88
84
  };
89
85
  };
90
86
  const toStored = (row) => ({
@@ -95,8 +91,10 @@ const toStored = (row) => ({
95
91
  sessionId: row["session_id"],
96
92
  createdAt: new Date(asNumber(row["created_at"])),
97
93
  cause: toCause(row["cause"]),
94
+ lane: row["lane"] ?? null,
98
95
  processedAt: toDate(row["processed_at"]),
99
96
  processedByAttempt: row["processed_by_attempt"] === null || row["processed_by_attempt"] === void 0 ? null : asNumber(row["processed_by_attempt"]),
97
+ returnedEventIds: row["returned_event_ids"] === null || row["returned_event_ids"] === void 0 ? null : typeof row["returned_event_ids"] === "string" ? JSON.parse(row["returned_event_ids"]) : row["returned_event_ids"],
100
98
  firstClaimedAt: toDate(row["first_claimed_at"]),
101
99
  lastClaimedAt: toDate(row["last_claimed_at"]),
102
100
  attemptCount: asNumber(row["attempt_count"]),
@@ -104,7 +102,9 @@ const toStored = (row) => ({
104
102
  lastFailedAt: toDate(row["last_failed_at"]),
105
103
  lastFailedAttempt: row["last_failed_attempt"] === null || row["last_failed_attempt"] === void 0 ? null : asNumber(row["last_failed_attempt"]),
106
104
  lastError: row["last_error"] ?? null,
107
- failedAt: toDate(row["failed_at"])
105
+ failedAt: toDate(row["failed_at"]),
106
+ claimHolder: row["claim_holder"] ?? null,
107
+ claimExpiresAt: toDate(row["claim_expires_at"])
108
108
  });
109
109
  const toEvent = (row) => ({
110
110
  id: row["event_id"],
@@ -151,7 +151,7 @@ function postgres(options = {}) {
151
151
  let c = options.client;
152
152
  if (!c) {
153
153
  c = new (await (import("pg").catch(() => {
154
- throw new A2Error("LOG_NOT_CONFIGURED", "a2/log-postgres with a connectionString needs the 'pg' package (optional peer dependency) — install it, or inject a client");
154
+ throw new A2Error("LOG_NOT_CONFIGURED", "experimental-a2/log-postgres with a connectionString needs the 'pg' package (optional peer dependency) — install it, or inject a client");
155
155
  }))).default.Pool({ connectionString: options.connectionString });
156
156
  owned = c;
157
157
  }
@@ -196,21 +196,48 @@ function postgres(options = {}) {
196
196
  };
197
197
  return {
198
198
  async append(sessionId, events) {
199
- if (events.length === 0) return [];
199
+ if (events.length === 0) return wrap(async () => {
200
+ const { rows } = await (await client()).query(`select exists(
201
+ select 1 from a2_events
202
+ where session_id = $1 and processed_at is null
203
+ ) as has_pending`, [sessionId]);
204
+ return {
205
+ events: [],
206
+ hasPending: rows[0]?.["has_pending"] === true
207
+ };
208
+ });
200
209
  return wrap(() => withTx(async (query) => {
201
210
  await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
202
211
  const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
203
212
  if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
204
213
  if (suppliedIds.length > 0) {
205
- const { rows: existing } = await query("select * from a2_events where event_id = any($1) order by idx", [suppliedIds]);
214
+ const { rows: existing } = await query(`select stored.*,
215
+ exists(
216
+ select 1 from a2_events pending
217
+ where pending.session_id = $2
218
+ and pending.processed_at is null
219
+ ) as session_has_pending
220
+ from a2_events stored
221
+ where stored.event_id = any($1)
222
+ order by stored.idx`, [suppliedIds, sessionId]);
206
223
  if (existing.length > 0) {
207
224
  const foreign = existing.find((row) => row["session_id"] !== sessionId);
208
225
  if (foreign) throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${String(foreign["event_id"])}' already exists in another session`);
209
- if (existing.length === events.length) return existing.map(toStored);
226
+ if (existing.length === events.length) return {
227
+ events: idempotentReplay(events, existing.map(toStored)),
228
+ hasPending: existing[0]?.["session_has_pending"] === true
229
+ };
210
230
  throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
211
231
  }
212
232
  }
213
- const { rows: maxRows } = await query("select coalesce(max(idx), 0) as max from a2_events where session_id = $1", [sessionId]);
233
+ const { rows: maxRows } = await query(`select coalesce(max(idx), 0) as max,
234
+ exists(
235
+ select 1 from a2_events pending
236
+ where pending.session_id = $1
237
+ and pending.processed_at is null
238
+ ) as session_has_pending
239
+ from a2_events
240
+ where session_id = $1`, [sessionId]);
214
241
  const base = asNumber(maxRows[0]?.["max"]);
215
242
  const now = clock.now().getTime();
216
243
  const inserted = [];
@@ -220,15 +247,24 @@ function postgres(options = {}) {
220
247
  const index = base + 1 + i;
221
248
  await query(`insert into a2_events
222
249
  (session_id, idx, event_type, payload, event_id, created_at,
223
- cause)
224
- values ($1, $2, $3, $4::jsonb, $5, $6, $7::jsonb)`, [
250
+ cause, lane, lane_ready, processed_at)
251
+ values (
252
+ $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
253
+ $8::text is null or not exists (
254
+ select 1 from a2_events
255
+ where session_id = $1 and lane = $8 and processed_at is null
256
+ ),
257
+ $9
258
+ )`, [
225
259
  sessionId,
226
260
  index,
227
261
  e.type,
228
262
  jsonb(e.payload),
229
263
  id,
230
264
  now,
231
- e.cause ? jsonb(e.cause) : null
265
+ e.cause ? jsonb(e.cause) : null,
266
+ e.lane ?? null,
267
+ e.settled ? now : null
232
268
  ]);
233
269
  inserted.push({
234
270
  id,
@@ -238,8 +274,10 @@ function postgres(options = {}) {
238
274
  sessionId,
239
275
  createdAt: new Date(now),
240
276
  cause: e.cause ? { ...e.cause } : null,
241
- processedAt: null,
277
+ lane: e.lane ?? null,
278
+ processedAt: e.settled ? new Date(now) : null,
242
279
  processedByAttempt: null,
280
+ returnedEventIds: null,
243
281
  firstClaimedAt: null,
244
282
  lastClaimedAt: null,
245
283
  attemptCount: 0,
@@ -247,10 +285,15 @@ function postgres(options = {}) {
247
285
  lastFailedAt: null,
248
286
  lastFailedAttempt: null,
249
287
  lastError: null,
250
- failedAt: null
288
+ failedAt: null,
289
+ claimHolder: null,
290
+ claimExpiresAt: null
251
291
  });
252
292
  }
253
- return inserted;
293
+ return {
294
+ events: inserted,
295
+ hasPending: maxRows[0]?.["session_has_pending"] === true || events.some((event) => event.settled !== true)
296
+ };
254
297
  }));
255
298
  },
256
299
  async read(sessionId, opts) {
@@ -262,146 +305,176 @@ function postgres(options = {}) {
262
305
  params.push(opts.afterIndex);
263
306
  conditions.push(`idx > $${params.length}`);
264
307
  }
265
- if (opts?.unprocessedOnly) conditions.push("processed_at is null");
266
308
  const { rows } = await c.query(`select * from a2_events where ${conditions.join(" and ")} order by idx`, params);
267
309
  return rows.map(toStored);
268
310
  });
269
311
  },
270
- async claimNext({ sessionId, holder, ttlMs, expiresAtMs, maxIndex }) {
271
- return wrap(async () => {
272
- const c = await client();
312
+ async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes = [] }) {
313
+ return wrap(() => withTx(async (query) => {
273
314
  const now = clock.now().getTime();
274
- const expiresAt = expiresAtMs ?? now + ttlMs;
275
- const { rows } = await c.query(`with head as materialized (
276
- select e.* from a2_events e
277
- where e.session_id = $1 and e.processed_at is null
278
- order by e.idx limit 1 for update of e
279
- ), eligible as materialized (
280
- select * from head
281
- where failed_at is null
282
- and ($5::bigint is null or idx <= $5)
283
- ), won as (
284
- insert into a2_leases (session_id, holder, expires_at)
285
- select $1, $2, $3 from eligible
286
- on conflict (session_id) do update set
287
- holder = excluded.holder,
288
- expires_at = excluded.expires_at
289
- where a2_leases.expires_at <= $4 or a2_leases.holder = $2
290
- returning holder
291
- ), claimed as (
292
- update a2_events e set
293
- attempt_count = e.attempt_count + 1,
294
- first_claimed_at = coalesce(e.first_claimed_at, $4),
295
- last_claimed_at = $4
296
- from eligible, won
297
- where e.session_id = $1
298
- and e.idx = eligible.idx
299
- and e.processed_at is null
300
- returning e.*
301
- )
302
- select case
303
- when exists (select 1 from claimed) then 'claimed'
304
- when exists (select 1 from eligible) then 'busy'
305
- else 'settled'
306
- end as outcome, claimed.*
307
- from (values (1)) as singleton(n)
308
- left join claimed on true`, [
315
+ const { rows } = await query(`with eligible as materialized (
316
+ select event.idx
317
+ from a2_events event
318
+ where event.session_id = $1
319
+ and event.processed_at is null
320
+ and event.failed_at is null
321
+ and event.lane_ready
322
+ and (event.claim_expires_at is null or event.claim_expires_at <= $2)
323
+ and not (event.idx = any($5::bigint[]))
324
+ for update of event
325
+ )
326
+ update a2_events claimed set
327
+ attempt_count = claimed.attempt_count + 1,
328
+ first_claimed_at = coalesce(claimed.first_claimed_at, $2),
329
+ last_claimed_at = $2,
330
+ claim_holder = $3,
331
+ claim_expires_at = $4
332
+ from eligible
333
+ where claimed.session_id = $1 and claimed.idx = eligible.idx
334
+ returning claimed.*`, [
309
335
  sessionId,
310
- holder,
311
- expiresAt,
312
336
  now,
313
- maxIndex ?? null
337
+ holder,
338
+ expiresAtMs ?? now + ttlMs,
339
+ [...excludeIndexes]
314
340
  ]);
315
- const row = rows[0];
316
- if (row["outcome"] === "claimed") return {
341
+ if (rows.length > 0) return {
317
342
  outcome: "claimed",
318
- event: toStored(row)
343
+ events: rows.map(toStored).toSorted((a, b) => a.index - b.index)
319
344
  };
320
- return { outcome: row["outcome"] };
321
- });
345
+ const { rows: activeRows } = await query(`select min(claim_expires_at) as retry_at
346
+ from a2_events
347
+ where session_id = $1
348
+ and processed_at is null
349
+ and failed_at is null
350
+ and lane_ready
351
+ and claim_expires_at > $2`, [sessionId, now]);
352
+ const retryAt = activeRows[0]?.["retry_at"];
353
+ return retryAt === null || retryAt === void 0 ? { outcome: "settled" } : {
354
+ outcome: "busy",
355
+ retryAt: new Date(asNumber(retryAt))
356
+ };
357
+ }));
322
358
  },
323
- async completeAndClaimNext({ sessionId, holder, completedIndex, attempt, maxIndex }) {
359
+ async renewClaims({ sessionId, holder, indexes, ttlMs, expiresAtMs }) {
360
+ if (indexes.length === 0) return [];
324
361
  return wrap(async () => {
325
362
  const c = await client();
326
363
  const now = clock.now().getTime();
327
- const { rows } = await c.query(`with current as materialized (
328
- select e.* from a2_events e
329
- where e.session_id = $1 and e.idx = $3
330
- for update of e
331
- ), completed as (
332
- update a2_events e set
333
- processed_at = $5,
334
- processed_by_attempt = $4
335
- from current
336
- where e.session_id = $1
337
- and e.idx = $3
338
- and current.processed_at is null
339
- and current.attempt_count = $4
340
- returning e.idx
341
- ), head as materialized (
342
- select e.* from a2_events e, completed
343
- where e.session_id = $1
344
- and e.idx > $3
345
- and e.processed_at is null
346
- order by e.idx limit 1 for update of e
347
- ), eligible as materialized (
348
- select * from head
349
- where failed_at is null
350
- and ($6::bigint is null or idx <= $6)
351
- ), owned as materialized (
352
- select l.session_id from a2_leases l, eligible
353
- where l.session_id = $1
354
- and l.holder = $2
355
- and l.expires_at > $5
356
- for update of l
357
- ), claimed as (
358
- update a2_events e set
359
- attempt_count = e.attempt_count + 1,
360
- first_claimed_at = coalesce(e.first_claimed_at, $5),
361
- last_claimed_at = $5
362
- from eligible, owned
363
- where e.session_id = $1
364
- and e.idx = eligible.idx
365
- and e.processed_at is null
366
- returning e.*
367
- )
368
- select case
369
- when not exists (select 1 from current) then 'missing'
370
- when not exists (select 1 from completed) then 'superseded'
371
- when exists (select 1 from claimed) then 'claimed'
372
- when not exists (select 1 from eligible) then 'settled'
373
- else 'busy'
374
- end as outcome, claimed.*
375
- from (values (1)) as singleton(n)
376
- left join claimed on true`, [
364
+ const expiresAt = expiresAtMs ?? now + ttlMs;
365
+ const { rows } = await c.query(`update a2_events set claim_expires_at = $1
366
+ where session_id = $2
367
+ and idx = any($3::bigint[])
368
+ and processed_at is null
369
+ and failed_at is null
370
+ and claim_holder = $4
371
+ and claim_expires_at > $5
372
+ returning idx`, [
373
+ expiresAt,
377
374
  sessionId,
375
+ indexes,
378
376
  holder,
379
- completedIndex,
380
- attempt,
381
- now,
382
- maxIndex ?? null
377
+ now
383
378
  ]);
384
- const row = rows[0];
385
- if (row["outcome"] === "missing") throw new TypeError(`no event at index ${completedIndex} in session '${sessionId}'`);
386
- if (row["outcome"] === "claimed") return {
387
- outcome: "claimed",
388
- event: toStored(row)
389
- };
390
- return { outcome: row["outcome"] };
379
+ return rows.map((row) => asNumber(row["idx"])).toSorted((a, b) => a - b);
391
380
  });
392
381
  },
393
- async markProcessed(sessionId, index) {
394
- await wrap(async () => {
395
- const { rows } = await (await client()).query(`update a2_events
396
- set processed_at = coalesce(processed_at, $1)
397
- where session_id = $2 and idx = $3
398
- returning idx`, [
399
- clock.now().getTime(),
382
+ async completeAttempt({ sessionId, index, attempt, events }) {
383
+ return wrap(() => withTx(async (query) => {
384
+ const { rows: parentRows } = await query(`select * from a2_events
385
+ where session_id = $1 and idx = $2
386
+ for update`, [sessionId, index]);
387
+ const parent = parentRows[0];
388
+ if (!parent) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
389
+ const ids = events.map((event) => event.id);
390
+ if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
391
+ if (parent["processed_at"] !== null) {
392
+ if (asNumber(parent["processed_by_attempt"]) !== attempt) return { outcome: "superseded" };
393
+ const returnedIds = parent["returned_event_ids"] === null || parent["returned_event_ids"] === void 0 ? null : typeof parent["returned_event_ids"] === "string" ? JSON.parse(parent["returned_event_ids"]) : parent["returned_event_ids"];
394
+ 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");
395
+ if (events.length === 0) return {
396
+ outcome: "completed",
397
+ events: []
398
+ };
399
+ const { rows: existing } = await query(`select * from a2_events
400
+ where event_id = any($1::text[])`, [ids]);
401
+ const byId = new Map(existing.map((row) => [row["event_id"], row]));
402
+ const ordered = ids.map((id) => byId.get(id));
403
+ if (ordered.some((row) => {
404
+ const cause = row ? toCause(row["cause"]) : null;
405
+ return !row || row["session_id"] !== sessionId || cause?.index !== index || cause.attempt !== attempt;
406
+ })) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
407
+ return {
408
+ outcome: "completed",
409
+ events: idempotentReplay(events, ordered.map(toStored))
410
+ };
411
+ }
412
+ if (asNumber(parent["attempt_count"]) !== attempt || parent["claim_holder"] === null || parent["claim_holder"] === void 0 || parent["failed_at"] !== null) return { outcome: "superseded" };
413
+ await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
414
+ if (ids.length > 0) {
415
+ const { rows: existing } = await query("select event_id from a2_events where event_id = any($1::text[])", [ids]);
416
+ if (existing.length > 0) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains an already-appended event id");
417
+ }
418
+ const { rows: maxRows } = await query("select coalesce(max(idx), 0) as max from a2_events where session_id = $1", [sessionId]);
419
+ const base = asNumber(maxRows[0]?.["max"]);
420
+ const now = clock.now().getTime();
421
+ const inserted = [];
422
+ for (let offset = 0; offset < events.length; offset += 1) {
423
+ const event = events[offset];
424
+ const { rows } = await query(`insert into a2_events
425
+ (session_id, idx, event_type, payload, event_id, created_at,
426
+ cause, lane, lane_ready, processed_at)
427
+ values (
428
+ $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
429
+ $8::text is null or not exists (
430
+ select 1 from a2_events
431
+ where session_id = $1 and lane = $8 and processed_at is null
432
+ ),
433
+ $9
434
+ )
435
+ returning *`, [
436
+ sessionId,
437
+ base + offset + 1,
438
+ event.type,
439
+ jsonb(event.payload),
440
+ event.id,
441
+ now,
442
+ jsonb({
443
+ index,
444
+ attempt
445
+ }),
446
+ event.lane ?? null,
447
+ event.settled ? now : null
448
+ ]);
449
+ inserted.push(toStored(rows[0]));
450
+ }
451
+ await query(`update a2_events set
452
+ processed_at = $1,
453
+ processed_by_attempt = $2,
454
+ returned_event_ids = $5::jsonb,
455
+ lane_ready = false,
456
+ claim_holder = null,
457
+ claim_expires_at = null
458
+ where session_id = $3 and idx = $4`, [
459
+ now,
460
+ attempt,
400
461
  sessionId,
401
- index
462
+ index,
463
+ jsonb(ids)
402
464
  ]);
403
- if (rows.length === 0) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
404
- });
465
+ const parentLane = parent["lane"] ?? null;
466
+ if (parentLane !== null) await query(`update a2_events set lane_ready = true
467
+ where session_id = $1
468
+ and idx = (
469
+ select idx from a2_events
470
+ where session_id = $1 and lane = $2 and processed_at is null
471
+ order by idx limit 1
472
+ )`, [sessionId, parentLane]);
473
+ return {
474
+ outcome: "completed",
475
+ events: inserted
476
+ };
477
+ }));
405
478
  },
406
479
  async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
407
480
  return wrap(async () => {
@@ -418,13 +491,16 @@ function postgres(options = {}) {
418
491
  failed_at = case
419
492
  when e.failure_count + 1 >= $5 then $6
420
493
  else e.failed_at
421
- end
494
+ end,
495
+ claim_holder = null,
496
+ claim_expires_at = null
422
497
  from current
423
498
  where e.session_id = $1
424
499
  and e.idx = $2
425
500
  and current.processed_at is null
426
501
  and current.failed_at is null
427
502
  and current.attempt_count = $3
503
+ and current.claim_holder is not null
428
504
  returning e.failure_count, e.failed_at
429
505
  )
430
506
  select case
@@ -440,6 +516,10 @@ function postgres(options = {}) {
440
516
  when exists (
441
517
  select 1 from current where failed_at is not null
442
518
  ) then 'dead_lettered'
519
+ when exists (
520
+ select 1 from current
521
+ where claim_holder is null and last_failed_attempt = $3
522
+ ) then 'failed'
443
523
  else 'superseded'
444
524
  end as outcome,
445
525
  coalesce(
@@ -461,16 +541,6 @@ function postgres(options = {}) {
461
541
  };
462
542
  });
463
543
  },
464
- async markFailed(sessionId, index) {
465
- await wrap(async () => {
466
- const { rows } = await (await client()).query("update a2_events set failed_at = $1 where session_id = $2 and idx = $3 returning idx", [
467
- clock.now().getTime(),
468
- sessionId,
469
- index
470
- ]);
471
- if (rows.length === 0) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
472
- });
473
- },
474
544
  async readState(sessionId, reducerName) {
475
545
  return wrap(async () => {
476
546
  const { rows } = await (await client()).query(`with snapshot as materialized (
@@ -590,33 +660,6 @@ function postgres(options = {}) {
590
660
  });
591
661
  return pollingStream(readAfter, opts?.startAt !== void 0 ? { startAt: opts.startAt } : {});
592
662
  },
593
- lease: {
594
- async acquire({ sessionId, holder, ttlMs, expiresAtMs }) {
595
- return wrap(async () => {
596
- const c = await client();
597
- const now = clock.now().getTime();
598
- const expiresAt = expiresAtMs ?? now + ttlMs;
599
- const { rows } = await c.query(`insert into a2_leases (session_id, holder, expires_at)
600
- values ($1, $2, $3)
601
- on conflict (session_id) do update set
602
- holder = excluded.holder,
603
- expires_at = excluded.expires_at
604
- where a2_leases.expires_at <= $4 or a2_leases.holder = $2
605
- returning holder`, [
606
- sessionId,
607
- holder,
608
- expiresAt,
609
- now
610
- ]);
611
- return rows.length === 1;
612
- });
613
- },
614
- async release({ sessionId, holder }) {
615
- await wrap(async () => {
616
- await (await client()).query("delete from a2_leases where session_id = $1 and holder = $2", [sessionId, holder]);
617
- });
618
- }
619
- },
620
663
  async close() {
621
664
  const current = connection.peek();
622
665
  if (!current) return;
@@ -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-redis.d.ts
3
3
  /**
4
4
  * The minimal client this backend needs — `ioredis` matches it