experimental-a2 0.22.0 → 0.24.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 (131) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/{actor-CqibLgjQ.d.ts → actor-Bqyb7gwf.d.ts} +3 -3
  3. package/dist/{actor-CqibLgjQ.d.ts.map → actor-Bqyb7gwf.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-client.js +2 -2
  6. package/dist/actor-react.d.ts +2 -2
  7. package/dist/actor-react.js +5 -5
  8. package/dist/{actor-shared-DFET5q16.js → actor-shared-B4OB6Qo7.js} +3 -3
  9. package/dist/{actor-shared-DFET5q16.js.map → actor-shared-B4OB6Qo7.js.map} +1 -1
  10. package/dist/actor.d.ts +1 -1
  11. package/dist/actor.js +4 -4
  12. package/dist/{ai-DAu77CDr.d.ts → ai-BZUu5-b7.d.ts} +2 -2
  13. package/dist/{ai-DAu77CDr.d.ts.map → ai-BZUu5-b7.d.ts.map} +1 -1
  14. package/dist/{ai-EEfKyc-z.js → ai-CCCNxd3L.js} +3 -3
  15. package/dist/{ai-EEfKyc-z.js.map → ai-CCCNxd3L.js.map} +1 -1
  16. package/dist/ai-client.d.ts +2 -2
  17. package/dist/ai-client.js +2 -2
  18. package/dist/ai-fx.d.ts +1 -1
  19. package/dist/ai-fx.js +25 -3
  20. package/dist/ai-fx.js.map +1 -1
  21. package/dist/{ai-harness-DTjkAr2T.d.ts → ai-harness-C8aV869s.d.ts} +2 -2
  22. package/dist/{ai-harness-DTjkAr2T.d.ts.map → ai-harness-C8aV869s.d.ts.map} +1 -1
  23. package/dist/ai-pi.d.ts +1 -1
  24. package/dist/ai-react.d.ts +4 -4
  25. package/dist/ai-react.js +2 -2
  26. package/dist/ai-server.d.ts +3 -3
  27. package/dist/ai-server.js +4 -4
  28. package/dist/ai.d.ts +1 -1
  29. package/dist/ai.js +1 -1
  30. package/dist/{client-CUMbS_38.d.ts → client-CCJASjUM.d.ts} +2 -2
  31. package/dist/{client-CUMbS_38.d.ts.map → client-CCJASjUM.d.ts.map} +1 -1
  32. package/dist/{client-BQS4i_V9.js → client-sq-djxhD.js} +4 -4
  33. package/dist/{client-BQS4i_V9.js.map → client-sq-djxhD.js.map} +1 -1
  34. package/dist/client.d.ts +2 -2
  35. package/dist/client.js +1 -1
  36. package/dist/{contract-CWukt01d.js → contract-DqfBSIqJ.js} +2 -2
  37. package/dist/{contract-CWukt01d.js.map → contract-DqfBSIqJ.js.map} +1 -1
  38. package/dist/index.d.ts +3 -3
  39. package/dist/index.js +3 -3
  40. package/dist/{initial-snapshot-D3Lve43o.d.ts → initial-snapshot-BA6xwv10.d.ts} +2 -2
  41. package/dist/initial-snapshot-BA6xwv10.d.ts.map +1 -0
  42. package/dist/{react-BoHvC3v9.d.ts → react-KC4eW7yG.d.ts} +3 -3
  43. package/dist/{react-BoHvC3v9.d.ts.map → react-KC4eW7yG.d.ts.map} +1 -1
  44. package/dist/react.d.ts +1 -1
  45. package/dist/react.js +1 -1
  46. package/dist/{reducer-CjmvZSAb.js → reducer-Bswr11ar.js} +2 -2
  47. package/dist/{reducer-CjmvZSAb.js.map → reducer-Bswr11ar.js.map} +1 -1
  48. package/dist/scheduler-qstash.d.ts +2 -2
  49. package/dist/scheduler-qstash.js +2 -2
  50. package/dist/scheduler-vercel.d.ts +2 -2
  51. package/dist/scheduler-vercel.js +1 -1
  52. package/dist/{server-qgez0Xny.d.ts → server-B9gu0qi1.d.ts} +9 -3
  53. package/dist/server-B9gu0qi1.d.ts.map +1 -0
  54. package/dist/{server-DtFrWxi3.js → server-DX7mM6nm.js} +22 -14
  55. package/dist/server-DX7mM6nm.js.map +1 -0
  56. package/dist/server.d.ts +2 -2
  57. package/dist/server.js +1 -1
  58. package/dist/{session-clear-wHd5GCpZ.js → session-clear-XJYpmUpg.js} +14 -2
  59. package/dist/session-clear-XJYpmUpg.js.map +1 -0
  60. package/dist/{session-clear-D1PFe7xl.d.ts → session-clear-vfonou9S.d.ts} +2 -2
  61. package/dist/session-clear-vfonou9S.d.ts.map +1 -0
  62. package/dist/{store-D0MhBvg6.d.ts → store-DEzcHZOA.d.ts} +11 -1
  63. package/dist/store-DEzcHZOA.d.ts.map +1 -0
  64. package/dist/store-N8PXxDAS.js.map +1 -1
  65. package/dist/{store-codec-DwrWlgQx.js → store-codec-pAG2oPgQ.js} +4 -4
  66. package/dist/store-codec-pAG2oPgQ.js.map +1 -0
  67. package/dist/store-memory.d.ts +1 -1
  68. package/dist/store-memory.d.ts.map +1 -1
  69. package/dist/store-memory.js +11 -4
  70. package/dist/store-memory.js.map +1 -1
  71. package/dist/store-postgres.d.ts +1 -1
  72. package/dist/store-postgres.d.ts.map +1 -1
  73. package/dist/store-postgres.js +26 -7
  74. package/dist/store-postgres.js.map +1 -1
  75. package/dist/store-redis-http.d.ts +1 -1
  76. package/dist/store-redis-http.js +2 -2
  77. package/dist/{store-redis-notify-CsJw_hfD.js → store-redis-notify-ys_-VrSP.js} +34 -18
  78. package/dist/store-redis-notify-ys_-VrSP.js.map +1 -0
  79. package/dist/store-redis.d.ts +1 -1
  80. package/dist/store-redis.js +2 -2
  81. package/dist/store-sqlite.d.ts +1 -1
  82. package/dist/store-sqlite.d.ts.map +1 -1
  83. package/dist/store-sqlite.js +11 -4
  84. package/dist/store-sqlite.js.map +1 -1
  85. package/dist/store-valkey.d.ts +33 -0
  86. package/dist/store-valkey.d.ts.map +1 -0
  87. package/dist/store-valkey.js +218 -0
  88. package/dist/store-valkey.js.map +1 -0
  89. package/dist/{wire-CV-Z7wz-.js → wire-BFE9sIQf.js} +3 -2
  90. package/dist/wire-BFE9sIQf.js.map +1 -0
  91. package/docs/client/03-presence.mdx +1 -0
  92. package/docs/contract/03-sessions.mdx +51 -0
  93. package/docs/reference/01-core.mdx +1 -0
  94. package/docs/reference/02-server.mdx +44 -4
  95. package/docs/reference/06-errors.mdx +1 -0
  96. package/docs/stores/01-overview.mdx +1 -0
  97. package/docs/stores/02-redis.mdx +3 -1
  98. package/docs/stores/07-valkey.mdx +97 -0
  99. package/examples/playground/app/a2slack/consumers/search.ts +1 -1
  100. package/examples/playground/app/a2slack/db/migrations/0005_a2slack_feed_v2.sql +7 -0
  101. package/examples/playground/app/a2slack/db/migrations/meta/0005_snapshot.json +1165 -0
  102. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
  103. package/examples/playground/app/a2slack/db/queries.ts +1 -1
  104. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +17 -14
  105. package/examples/playground/app/a2slack/search-index.ts +1 -1
  106. package/examples/playground/app/a2slack/server.ts +2 -2
  107. package/examples/playground/lib/store.ts +2 -0
  108. package/examples/playground/package.json +1 -1
  109. package/package.json +8 -2
  110. package/src/ai-fx.ts +46 -3
  111. package/src/errors.ts +39 -0
  112. package/src/idempotent-replay.ts +4 -3
  113. package/src/libfx.d.ts +2 -0
  114. package/src/server.ts +32 -9
  115. package/src/store-memory.ts +26 -4
  116. package/src/store-postgres.ts +36 -6
  117. package/src/store-redis-core.ts +40 -15
  118. package/src/store-redis-notify.ts +8 -2
  119. package/src/store-sqlite.ts +25 -4
  120. package/src/store-valkey.ts +376 -0
  121. package/src/store.ts +10 -0
  122. package/src/wire.ts +1 -0
  123. package/dist/initial-snapshot-D3Lve43o.d.ts.map +0 -1
  124. package/dist/server-DtFrWxi3.js.map +0 -1
  125. package/dist/server-qgez0Xny.d.ts.map +0 -1
  126. package/dist/session-clear-D1PFe7xl.d.ts.map +0 -1
  127. package/dist/session-clear-wHd5GCpZ.js.map +0 -1
  128. package/dist/store-D0MhBvg6.d.ts.map +0 -1
  129. package/dist/store-codec-DwrWlgQx.js.map +0 -1
  130. package/dist/store-redis-notify-CsJw_hfD.js.map +0 -1
  131. package/dist/wire-CV-Z7wz-.js.map +0 -1
@@ -36,6 +36,13 @@
36
36
  "when": 1792693132589,
37
37
  "tag": "0004_a2_session_event_ids",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1792761727680,
44
+ "tag": "0005_a2slack_feed_v2",
45
+ "breakpoints": true
39
46
  }
40
47
  ]
41
- }
48
+ }
@@ -38,7 +38,7 @@ import {
38
38
  } from './schema'
39
39
 
40
40
  /** The feed consumer's name, which its position is recorded under. */
41
- export const FEED = 'a2slack-feed'
41
+ export const FEED = 'a2slack-feed-v2'
42
42
 
43
43
  export const PAGE_SIZE = 50
44
44
 
@@ -340,7 +340,7 @@ async function loadRun(store: A2Store, label: string) {
340
340
  payload: { id: channel, by: pick(USERS) },
341
341
  })
342
342
  await until(
343
- () => idle(store, ['welcome', 'a2slack-feed', 'a2slack-notify']),
343
+ () => idle(store, ['welcome', a2slack.queries.FEED, 'a2slack-notify']),
344
344
  {
345
345
  timeoutMs: 60_000,
346
346
  },
@@ -350,7 +350,7 @@ async function loadRun(store: A2Store, label: string) {
350
350
 
351
351
  const posted = await post(to, MESSAGES, CONCURRENCY)
352
352
  const catchUpMs = await until(
353
- () => idle(store, ['welcome', 'a2slack-feed', 'a2slack-notify']),
353
+ () => idle(store, ['welcome', a2slack.queries.FEED, 'a2slack-notify']),
354
354
  { timeoutMs: 10 * 60_000, everyMs: 100 },
355
355
  )
356
356
  const log = await logCounts(to[0]!)
@@ -361,7 +361,7 @@ async function loadRun(store: A2Store, label: string) {
361
361
  await followers.close()
362
362
  const tables = await tableCounts()
363
363
  const db = await a2slack.database()
364
- const feedPosition = await a2slack.queries.position(db, 'a2slack-feed')
364
+ const feedPosition = await a2slack.queries.position(db, a2slack.queries.FEED)
365
365
 
366
366
  const [root] = await rows<{ id: string }>(
367
367
  "select id from a2slack_messages where thread_id is null and channel = 'room-1' order by log_index desc limit 1",
@@ -449,23 +449,23 @@ it('rebuild: a renamed feed fills empty tables from the whole log', async () =>
449
449
  const db = await a2slack.database()
450
450
  await db.execute(
451
451
  sql.raw(
452
- "truncate a2slack_channels, a2slack_messages, a2slack_notifications, a2slack_reactions, a2slack_reads; delete from a2slack_positions where consumer = 'a2slack-feed'",
452
+ `truncate a2slack_channels, a2slack_messages, a2slack_notifications, a2slack_reactions, a2slack_reads; delete from a2slack_positions where consumer = '${a2slack.queries.FEED}'`,
453
453
  ),
454
454
  )
455
455
  const { telemetry, runs } = recorder()
456
- const feedV2 = a2slack
456
+ const feedRebuild = a2slack
457
457
  .consumer({
458
458
  contract: a2slack.model.workspace,
459
- name: 'a2slack-feed-v2',
460
- lane: 'feed-v2',
459
+ name: 'a2slack-feed-rebuild',
460
+ lane: 'feed-rebuild',
461
461
  backfill: true,
462
462
  })
463
463
  .run(async ({ event }) => {
464
464
  await a2slack.feedEvent(await a2slack.database(), event)
465
465
  })
466
- const [server] = servers(redisStore, [feedV2], telemetry, 1)
466
+ const [server] = servers(redisStore, [feedRebuild], telemetry, 1)
467
467
  const t0 = performance.now()
468
- const consumed = await session(server!).consume(feedV2)
468
+ const consumed = await session(server!).consume(feedRebuild)
469
469
  const ms = performance.now() - t0
470
470
  const after = await tableCounts()
471
471
  const result = {
@@ -524,9 +524,12 @@ it('poison: one bad event dead-letters its consumer alone, and redrive resumes i
524
524
  await post(to, 50, 5)
525
525
 
526
526
  const t0 = performance.now()
527
- await until(() => idle(redisStore, ['a2slack-feed', 'a2slack-notify']), {
528
- timeoutMs: 60_000,
529
- })
527
+ await until(
528
+ () => idle(redisStore, [a2slack.queries.FEED, 'a2slack-notify']),
529
+ {
530
+ timeoutMs: 60_000,
531
+ },
532
+ )
530
533
  const othersCaughtUpMs = performance.now() - t0
531
534
  const stuck = (await redisStore.cursors('a2slack\u001fworkspace')).find(
532
535
  (cursor) => cursor.consumer === 'drill-seen',
@@ -625,14 +628,14 @@ it('outage: Postgres goes away mid-load, and every event still lands once', asyn
625
628
  })
626
629
  await outage
627
630
  await until(
628
- () => idle(redisStore, ['welcome', 'a2slack-feed', 'a2slack-notify']),
631
+ () => idle(redisStore, ['welcome', a2slack.queries.FEED, 'a2slack-notify']),
629
632
  { timeoutMs: 5 * 60_000, everyMs: 200 },
630
633
  )
631
634
  const idleAt = performance.now()
632
635
  const log = await logCounts(to[0]!)
633
636
  const tables = await tableCounts()
634
637
  const db = await a2slack.database()
635
- const feedPosition = await a2slack.queries.position(db, 'a2slack-feed')
638
+ const feedPosition = await a2slack.queries.position(db, a2slack.queries.FEED)
636
639
  const deadLetters = await session(to[0]!).deadLetters()
637
640
  const result = {
638
641
  appended: 3_000,
@@ -12,7 +12,7 @@ import { Redis, s } from '@upstash/redis'
12
12
  import { searchFeed } from './db/queries'
13
13
  import type { SearchResult } from './model'
14
14
 
15
- const NAME = 'a2slack-messages-v1'
15
+ const NAME = 'a2slack-messages-v2'
16
16
  const PREFIX = `${NAME}:message:`
17
17
 
18
18
  const schema = s.object({
@@ -4,8 +4,8 @@
4
4
  * either writes somewhere else or returns events into the log:
5
5
  *
6
6
  * - `welcome` posts a new channel's opening message.
7
- * - `a2slack-feed` keeps the tables every page reads (db/schema.ts).
8
- * - `a2slack-search-v1` keeps the search index in Upstash Redis Search,
7
+ * - `a2slack-feed-v2` keeps the tables every page reads (db/schema.ts).
8
+ * - `a2slack-search-v2` keeps the search index in Upstash Redis Search,
9
9
  * where it's configured (search-index.ts).
10
10
  * - `a2slack-notify` works out who should hear about each message, and
11
11
  * returns a notification addressed to each of them.
@@ -8,6 +8,8 @@ const postgresUrl = process.env.DATABASE_URL?.replace(
8
8
  'sslmode=verify-full',
9
9
  )
10
10
 
11
+ // A new keyPrefix starts every log over: a2slack's feed (consumers/feed.ts)
12
+ // has to start over with it, under a new name and with its tables cleared.
11
13
  export const store: RedisStore | PostgresStore | undefined = redisUrl
12
14
  ? redis({ url: redisUrl, keyPrefix: 'a2-playground-tagged' })
13
15
  : postgresUrl
@@ -39,7 +39,7 @@
39
39
  "ai": "^7.0.58",
40
40
  "codemirror": "^6.0.2",
41
41
  "drizzle-orm": "^0.45.3",
42
- "experimental-a2": "0.22.0",
42
+ "experimental-a2": "0.24.0",
43
43
  "geist": "^1.7.2",
44
44
  "ioredis": "^5.9.0",
45
45
  "libfx": "0.0.12",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Syncs your immutable event log, from server to browser: one contract, handlers, derived state, and a live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -61,6 +61,7 @@
61
61
  "./store-postgres": "./dist/store-postgres.js",
62
62
  "./store-redis": "./dist/store-redis.js",
63
63
  "./store-redis-http": "./dist/store-redis-http.js",
64
+ "./store-valkey": "./dist/store-valkey.js",
64
65
  "./scheduler-qstash": "./dist/scheduler-qstash.js",
65
66
  "./scheduler-vercel": "./dist/scheduler-vercel.js",
66
67
  "./cache-indexeddb": "./dist/cache-indexeddb.js",
@@ -77,7 +78,8 @@
77
78
  "ioredis": "^5.4.0",
78
79
  "libfx": "^0.0.12",
79
80
  "pg": "^8.11.0",
80
- "react": "^19.0.0"
81
+ "react": "^19.0.0",
82
+ "redis": "^5.8.0 || ^6.0.0"
81
83
  },
82
84
  "peerDependenciesMeta": {
83
85
  "@opentelemetry/api": {
@@ -109,6 +111,9 @@
109
111
  },
110
112
  "react": {
111
113
  "optional": true
114
+ },
115
+ "redis": {
116
+ "optional": true
112
117
  }
113
118
  },
114
119
  "devDependencies": {
@@ -119,6 +124,7 @@
119
124
  "@upstash/qstash": "^2.11.3",
120
125
  "ai": "^7.0.58",
121
126
  "ioredis": "^5.9.0",
127
+ "redis": "^6.2.1",
122
128
  "@opentelemetry/api": "^1.9.0",
123
129
  "@types/pg": "^8.15.0",
124
130
  "@types/ws": "^8.18.1",
package/src/ai-fx.ts CHANGED
@@ -70,6 +70,8 @@ const SNAPSHOT_VERSION = 1
70
70
  const CATALOG_TTL_MS = 60 * 60 * 1000
71
71
  /** The Gateway model catalog libfx reads for each new kernel. */
72
72
  const CATALOG_PATH = '/coding-agent/v1/models'
73
+ /** Times a step rebuilds its kernel for a stopped turn FX failed on replay. */
74
+ const REPLAY_REBUILDS = 3
73
75
 
74
76
  type StreamPart = { type: string } & Record<string, unknown>
75
77
  /** A recorded tool call, with the input the kernel reads from its text. */
@@ -144,7 +146,13 @@ type KernelHooks = {
144
146
  tool: (toolName: string, input: unknown) => unknown
145
147
  }
146
148
 
147
- type Kernel = { agent: FxAgent; hooks: KernelHooks; waiting: Waiting[] }
149
+ type Kernel = {
150
+ agent: FxAgent
151
+ hooks: KernelHooks
152
+ waiting: Waiting[]
153
+ /** Whether FX answered the kernel's latest prompt with an error. */
154
+ failed: boolean
155
+ }
148
156
 
149
157
  /**
150
158
  * A kernel a step left waiting on its tool calls. The session's next step in
@@ -249,8 +257,12 @@ async function runStep(
249
257
  await resume(controller, turns.at(-1)!, adopted)
250
258
  return
251
259
  }
252
- for (const plan of turns) {
260
+ let rebuilds = 0
261
+ let next = 0
262
+ while (next < turns.length) {
253
263
  if (stopped || step.signal.aborted) return
264
+ const plan = turns[next]!
265
+ next += 1
254
266
  // The step's abort cancels the turn; the turn outlives it when parked.
255
267
  const fxTurn = kernel!.agent.prompt(plan.prompt)
256
268
  const state: TurnState = {
@@ -282,6 +294,18 @@ async function runStep(
282
294
  throw diverged(
283
295
  `a recorded turn ended with ${result.stopReason}, not ${expected}`,
284
296
  )
297
+ if (kernel!.failed) {
298
+ // libfx's cancel aborts the request FX waits on as it sends the
299
+ // stop, and FX fails the turn when it reads the abort first. That
300
+ // kernel holds no interruption where the log has one, so the turns
301
+ // replay on a new kernel.
302
+ if (rebuilds === REPLAY_REBUILDS)
303
+ throw diverged('FX failed a stopped turn instead of cancelling it')
304
+ rebuilds += 1
305
+ await kernel!.agent.close().catch(() => {})
306
+ kernel = await create(read.saved)
307
+ next = 0
308
+ }
285
309
  continue
286
310
  }
287
311
  await finishLive(controller, state, drained)
@@ -577,7 +601,8 @@ async function runStep(
577
601
  const create = async (
578
602
  checkpoint: Uint8Array | undefined,
579
603
  ): Promise<Kernel> => {
580
- const created = { hooks, waiting: [] as Waiting[] }
604
+ const created = { hooks, waiting: [] as Waiting[], failed: false }
605
+ let prompt: unknown
581
606
  const agent = await createFxAgent({
582
607
  apiKey: credential.token,
583
608
  model: kernelModel(step),
@@ -591,6 +616,24 @@ async function runStep(
591
616
  })),
592
617
  ...(checkpoint === undefined ? {} : { checkpoint }),
593
618
  fetch: (input, init) => created.hooks.fetch(input, init),
619
+ // libfx reports a turn FX failed after its cancel as cancelled; only
620
+ // the ACP response to the turn's prompt tells the two apart.
621
+ onEvent: ({ type, message }) => {
622
+ const rpc = (message ?? {}) as {
623
+ id?: unknown
624
+ method?: unknown
625
+ error?: unknown
626
+ }
627
+ if (type === 'acp.send' && rpc.method === 'session/prompt') {
628
+ prompt = rpc.id
629
+ created.failed = false
630
+ } else if (
631
+ type === 'acp.receive' &&
632
+ rpc.method === undefined &&
633
+ rpc.id === prompt
634
+ )
635
+ created.failed = rpc.error !== undefined
636
+ },
594
637
  })
595
638
  return Object.assign(created, { agent })
596
639
  }
package/src/errors.ts CHANGED
@@ -5,6 +5,7 @@ export type A2ServerErrorCode =
5
5
  | 'PARTIAL_DUPLICATE_BATCH' // batch mixed already-appended and fresh events
6
6
  | 'SUPERSEDED_ATTEMPT' // consumer append from an attempt a recovery claim or a clear replaced
7
7
  | 'SESSION_CLEARED' // a read or call reached across the session's latest clear
8
+ | 'LAST_INDEX_MISMATCH' // a conditional append's ifLastIndex was not the head — nothing was written
8
9
  | 'CLAIM_EXPIRED' // ctx.signal abort reason: the attempt's lease lapsed without renewal
9
10
  | 'STORE_UNAVAILABLE' // store backend failure — retryable, nothing was written
10
11
  | 'STORE_NOT_CONFIGURED' // no `store` in production, a missing peer dependency, or Postgres lacks A2's tables
@@ -48,6 +49,44 @@ export function supersededAttemptError(
48
49
  )
49
50
  }
50
51
 
52
+ /** A conditional append whose `ifLastIndex` is not the session's head. */
53
+ export function lastIndexMismatchError(
54
+ sessionId: string,
55
+ ifLastIndex: number,
56
+ head: number,
57
+ ): A2Error {
58
+ return new A2Error(
59
+ 'LAST_INDEX_MISMATCH',
60
+ `session '${sessionId}' is at index ${head}, not ${ifLastIndex}`,
61
+ { details: { index: head } },
62
+ )
63
+ }
64
+
65
+ /** A conditional append's condition and the head its store found. */
66
+ export type ConditionalHead = {
67
+ sessionId: string
68
+ ifLastIndex: number
69
+ head: number
70
+ }
71
+
72
+ /**
73
+ * A batch whose ids conflict with committed events. In a conditional append
74
+ * whose head moved, the condition failed first, as when another writer's
75
+ * batch took those ids, so the conflict reports the head instead.
76
+ */
77
+ export function duplicateBatchError(
78
+ message: string,
79
+ conditional: ConditionalHead | undefined,
80
+ ): A2Error {
81
+ return conditional && conditional.head !== conditional.ifLastIndex
82
+ ? lastIndexMismatchError(
83
+ conditional.sessionId,
84
+ conditional.ifLastIndex,
85
+ conditional.head,
86
+ )
87
+ : new A2Error('PARTIAL_DUPLICATE_BATCH', message)
88
+ }
89
+
51
90
  /**
52
91
  * Wrap an arbitrary thrown value as STORE_UNAVAILABLE, preserving the
53
92
  * original as `cause`. A2Errors pass through untouched — they already
@@ -1,4 +1,4 @@
1
- import { A2Error } from './errors.ts'
1
+ import { duplicateBatchError, type ConditionalHead } from './errors.ts'
2
2
  import type { AppendEvent, StoredEvent } from './store.ts'
3
3
 
4
4
  const canonicalJson = (value: unknown): string =>
@@ -20,6 +20,7 @@ const canonicalJson = (value: unknown): string =>
20
20
  export const idempotentReplay = (
21
21
  events: readonly AppendEvent[],
22
22
  existing: readonly StoredEvent[],
23
+ conditional?: ConditionalHead,
23
24
  ): StoredEvent[] => {
24
25
  const byId = new Map(existing.map((event) => [event.id, event]))
25
26
  const ordered = events.map((event) =>
@@ -42,9 +43,9 @@ export const idempotentReplay = (
42
43
  )
43
44
  })
44
45
  ) {
45
- throw new A2Error(
46
- 'PARTIAL_DUPLICATE_BATCH',
46
+ throw duplicateBatchError(
47
47
  'already-appended events do not match the retried batch',
48
+ conditional,
48
49
  )
49
50
  }
50
51
 
package/src/libfx.d.ts CHANGED
@@ -37,5 +37,7 @@ declare module 'libfx' {
37
37
  input: string | URL | Request,
38
38
  init?: RequestInit,
39
39
  ) => Promise<Response>
40
+ /** Diagnostics; `acp.send` and `acp.receive` carry each ACP message. */
41
+ onEvent?: (event: { type: string; message?: unknown }) => void
40
42
  }): Promise<FxAgent>
41
43
  }
package/src/server.ts CHANGED
@@ -155,6 +155,15 @@ export type SessionDispatch<D extends EventDefs> = (
155
155
  export type SessionAppend<D extends EventDefs> = SessionDispatch<D> & {
156
156
  /** Commit, then hand pending work directly to configured scheduler. */
157
157
  dispatch: SessionDispatch<D>
158
+ /**
159
+ * Append only while `index` is the session's head, checked in the same
160
+ * atomic step as the write. Otherwise nothing is written and it throws
161
+ * `LAST_INDEX_MISMATCH`, with the head as `details.index`.
162
+ */
163
+ ifLastIndex(
164
+ index: number,
165
+ ...events: AppendInput<D>[]
166
+ ): Promise<ContractEvent<D>[]>
158
167
  }
159
168
 
160
169
  export type ConsumerAppend<D extends EventDefs> = (
@@ -1395,6 +1404,9 @@ export function createServer<
1395
1404
  })
1396
1405
  }
1397
1406
 
1407
+ /** What the store checks before it writes, inside the append's atomic step. */
1408
+ type AppendCondition = { originIndex?: number; ifLastIndex?: number }
1409
+
1398
1410
  /**
1399
1411
  * Validate + write. `onAppended` runs inside the telemetry span, so
1400
1412
  * the drain it schedules is created in the append's active context —
@@ -1409,7 +1421,7 @@ export function createServer<
1409
1421
  cause?: EventCause,
1410
1422
  generatedIds?: readonly boolean[],
1411
1423
  requireDurableInitialArm = false,
1412
- originIndex?: number,
1424
+ condition: AppendCondition = {},
1413
1425
  ): Promise<ContractEvent<D>[]> =>
1414
1426
  telemetry.span(
1415
1427
  'a2.append',
@@ -1442,7 +1454,7 @@ export function createServer<
1442
1454
  try {
1443
1455
  const result = await store.append(nsId(sessionId), validated, {
1444
1456
  consumers: storeConsumers,
1445
- ...(originIndex === undefined ? {} : { originIndex }),
1457
+ ...condition,
1446
1458
  })
1447
1459
  rows = result.events
1448
1460
  // A consumer appending events no consumer takes adds no work, and
@@ -2243,7 +2255,7 @@ export function createServer<
2243
2255
  throw new TypeError('schedule.clear requires an ifLastIndex')
2244
2256
  }
2245
2257
  const { ifLastIndex, ...timing } = clearOptions
2246
- assertClearIndex(ifLastIndex)
2258
+ assertIfLastIndex(ifLastIndex)
2247
2259
  const dueAt = scheduleDueAt(
2248
2260
  timing as ScheduleTiming,
2249
2261
  triggerCreatedAt ?? Date.now(),
@@ -2848,7 +2860,7 @@ export function createServer<
2848
2860
  attempt: ClearingAttempt | undefined,
2849
2861
  ): Promise<SessionClearResult> => {
2850
2862
  const ifLastIndex = clearOptions?.ifLastIndex
2851
- if (ifLastIndex !== undefined) assertClearIndex(ifLastIndex)
2863
+ if (ifLastIndex !== undefined) assertIfLastIndex(ifLastIndex)
2852
2864
  const store = await resolveStore()
2853
2865
  if (!store.clear || !store.reclaim) {
2854
2866
  throw new TypeError(
@@ -4077,7 +4089,7 @@ export function createServer<
4077
4089
  mode: 'inline' | 'dispatch',
4078
4090
  events: AppendInput<D>[],
4079
4091
  requireDurableInitialArm = false,
4080
- originIndex?: number,
4092
+ condition: AppendCondition = {},
4081
4093
  ): Promise<ContractEvent<D>[]> => {
4082
4094
  return appendCore(
4083
4095
  sessionId,
@@ -4088,7 +4100,7 @@ export function createServer<
4088
4100
  undefined,
4089
4101
  undefined,
4090
4102
  requireDurableInitialArm,
4091
- originIndex,
4103
+ condition,
4092
4104
  )
4093
4105
  }
4094
4106
 
@@ -4100,8 +4112,19 @@ export function createServer<
4100
4112
  appendExternal(id, 'inline', events)
4101
4113
  const appendDispatch = async (...events: AppendInput<D>[]) =>
4102
4114
  appendExternal(id, 'dispatch', events)
4115
+ const appendIfLastIndex = async (
4116
+ index: number,
4117
+ ...events: AppendInput<D>[]
4118
+ ) => {
4119
+ assertIfLastIndex(index)
4120
+ if (events.length === 0) {
4121
+ throw new TypeError('append.ifLastIndex requires at least one event')
4122
+ }
4123
+ return appendExternal(id, 'inline', events, false, { ifLastIndex: index })
4124
+ }
4103
4125
  const append = Object.assign(appendInline, {
4104
4126
  dispatch: appendDispatch,
4127
+ ifLastIndex: appendIfLastIndex,
4105
4128
  }) as SessionAppend<D>
4106
4129
  return Object.assign(makeSession(id, append, null), {
4107
4130
  deadLetters: () => listDeadLetters(id),
@@ -4169,7 +4192,7 @@ export function createServer<
4169
4192
  'inline',
4170
4193
  [...events],
4171
4194
  true,
4172
- originIndex,
4195
+ originIndex === undefined ? {} : { originIndex },
4173
4196
  )
4174
4197
  } catch (error) {
4175
4198
  // The scheduling consumer's lifetime was cleared: an acknowledged no-op.
@@ -4186,7 +4209,7 @@ export function createServer<
4186
4209
  (await readHistory(sessionId, { gte })).map(toPublic),
4187
4210
  schedulerClear: async (sessionId, ifLastIndex) => {
4188
4211
  assertSessionId(sessionId)
4189
- assertClearIndex(ifLastIndex)
4212
+ assertIfLastIndex(ifLastIndex)
4190
4213
  await clearSession(sessionId, { ifLastIndex }, undefined)
4191
4214
  },
4192
4215
  })
@@ -4232,7 +4255,7 @@ function assertRedriveIndex(value: number): void {
4232
4255
  }
4233
4256
  }
4234
4257
 
4235
- function assertClearIndex(value: number): void {
4258
+ function assertIfLastIndex(value: number): void {
4236
4259
  if (!Number.isSafeInteger(value) || value < 0) {
4237
4260
  throw new TypeError('ifLastIndex must be a non-negative safe integer')
4238
4261
  }
@@ -7,7 +7,12 @@
7
7
  */
8
8
 
9
9
  import type { PresencePatch } from './contract.ts'
10
- import { A2Error, supersededAttemptError } from './errors.ts'
10
+ import {
11
+ A2Error,
12
+ duplicateBatchError,
13
+ lastIndexMismatchError,
14
+ supersededAttemptError,
15
+ } from './errors.ts'
11
16
  import { idempotentReplay } from './idempotent-replay.ts'
12
17
  import { nullProtoRecord } from './internal.ts'
13
18
  import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
@@ -664,6 +669,12 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
664
669
  return { events: [], hasPending: hasPending(sessionId, consumers) }
665
670
  }
666
671
 
672
+ const ifLastIndex = appendOptions?.ifLastIndex
673
+ const conditional =
674
+ ifLastIndex === undefined
675
+ ? undefined
676
+ : { sessionId, ifLastIndex, head: headOf(sessions.get(sessionId)) }
677
+
667
678
  // Batch-scoped idempotency (a2-implementation.md §3): all ids
668
679
  // already present → lost-ack retry, return the original rows;
669
680
  // some present → the caller mixed sent and fresh events.
@@ -671,13 +682,17 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
671
682
  if (existing.length > 0) {
672
683
  if (existing.length === events.length) {
673
684
  return {
674
- events: idempotentReplay(events, existing.map(toStored)),
685
+ events: idempotentReplay(
686
+ events,
687
+ existing.map(toStored),
688
+ conditional,
689
+ ),
675
690
  hasPending: hasPending(sessionId, consumers),
676
691
  }
677
692
  }
678
- throw new A2Error(
679
- 'PARTIAL_DUPLICATE_BATCH',
693
+ throw duplicateBatchError(
680
694
  `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,
695
+ conditional,
681
696
  )
682
697
  }
683
698
 
@@ -692,6 +707,13 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
692
707
  for (const event of events) {
693
708
  if (event.cause) assertCurrentCause(sessionId, event.cause)
694
709
  }
710
+ if (conditional && conditional.head !== conditional.ifLastIndex) {
711
+ throw lastIndexMismatchError(
712
+ sessionId,
713
+ conditional.ifLastIndex,
714
+ conditional.head,
715
+ )
716
+ }
695
717
 
696
718
  const inserted = insert(sessionId, events, clock.now(), consumers)
697
719
  notifyStreams(sessionId, inserted)