experimental-a2 0.23.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 (118) hide show
  1. package/CHANGELOG.md +8 -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-harness-DTjkAr2T.d.ts → ai-harness-C8aV869s.d.ts} +2 -2
  20. package/dist/{ai-harness-DTjkAr2T.d.ts.map → ai-harness-C8aV869s.d.ts.map} +1 -1
  21. package/dist/ai-pi.d.ts +1 -1
  22. package/dist/ai-react.d.ts +4 -4
  23. package/dist/ai-react.js +2 -2
  24. package/dist/ai-server.d.ts +3 -3
  25. package/dist/ai-server.js +4 -4
  26. package/dist/ai.d.ts +1 -1
  27. package/dist/ai.js +1 -1
  28. package/dist/{client-CUMbS_38.d.ts → client-CCJASjUM.d.ts} +2 -2
  29. package/dist/{client-CUMbS_38.d.ts.map → client-CCJASjUM.d.ts.map} +1 -1
  30. package/dist/{client-BQS4i_V9.js → client-sq-djxhD.js} +4 -4
  31. package/dist/{client-BQS4i_V9.js.map → client-sq-djxhD.js.map} +1 -1
  32. package/dist/client.d.ts +2 -2
  33. package/dist/client.js +1 -1
  34. package/dist/{contract-CWukt01d.js → contract-DqfBSIqJ.js} +2 -2
  35. package/dist/{contract-CWukt01d.js.map → contract-DqfBSIqJ.js.map} +1 -1
  36. package/dist/index.d.ts +3 -3
  37. package/dist/index.js +3 -3
  38. package/dist/{initial-snapshot-D3Lve43o.d.ts → initial-snapshot-BA6xwv10.d.ts} +2 -2
  39. package/dist/initial-snapshot-BA6xwv10.d.ts.map +1 -0
  40. package/dist/{react-BoHvC3v9.d.ts → react-KC4eW7yG.d.ts} +3 -3
  41. package/dist/{react-BoHvC3v9.d.ts.map → react-KC4eW7yG.d.ts.map} +1 -1
  42. package/dist/react.d.ts +1 -1
  43. package/dist/react.js +1 -1
  44. package/dist/{reducer-CjmvZSAb.js → reducer-Bswr11ar.js} +2 -2
  45. package/dist/{reducer-CjmvZSAb.js.map → reducer-Bswr11ar.js.map} +1 -1
  46. package/dist/scheduler-qstash.d.ts +2 -2
  47. package/dist/scheduler-qstash.js +2 -2
  48. package/dist/scheduler-vercel.d.ts +2 -2
  49. package/dist/scheduler-vercel.js +1 -1
  50. package/dist/{server-qgez0Xny.d.ts → server-B9gu0qi1.d.ts} +9 -3
  51. package/dist/server-B9gu0qi1.d.ts.map +1 -0
  52. package/dist/{server-DtFrWxi3.js → server-DX7mM6nm.js} +22 -14
  53. package/dist/server-DX7mM6nm.js.map +1 -0
  54. package/dist/server.d.ts +2 -2
  55. package/dist/server.js +1 -1
  56. package/dist/{session-clear-wHd5GCpZ.js → session-clear-XJYpmUpg.js} +14 -2
  57. package/dist/session-clear-XJYpmUpg.js.map +1 -0
  58. package/dist/{session-clear-D1PFe7xl.d.ts → session-clear-vfonou9S.d.ts} +2 -2
  59. package/dist/session-clear-vfonou9S.d.ts.map +1 -0
  60. package/dist/{store-D0MhBvg6.d.ts → store-DEzcHZOA.d.ts} +11 -1
  61. package/dist/store-DEzcHZOA.d.ts.map +1 -0
  62. package/dist/store-N8PXxDAS.js.map +1 -1
  63. package/dist/{store-codec-DwrWlgQx.js → store-codec-pAG2oPgQ.js} +4 -4
  64. package/dist/store-codec-pAG2oPgQ.js.map +1 -0
  65. package/dist/store-memory.d.ts +1 -1
  66. package/dist/store-memory.d.ts.map +1 -1
  67. package/dist/store-memory.js +11 -4
  68. package/dist/store-memory.js.map +1 -1
  69. package/dist/store-postgres.d.ts +1 -1
  70. package/dist/store-postgres.d.ts.map +1 -1
  71. package/dist/store-postgres.js +26 -7
  72. package/dist/store-postgres.js.map +1 -1
  73. package/dist/store-redis-http.d.ts +1 -1
  74. package/dist/store-redis-http.js +2 -2
  75. package/dist/{store-redis-notify-CBDVJxA_.js → store-redis-notify-ys_-VrSP.js} +27 -16
  76. package/dist/store-redis-notify-ys_-VrSP.js.map +1 -0
  77. package/dist/store-redis.d.ts +1 -1
  78. package/dist/store-redis.js +2 -2
  79. package/dist/store-sqlite.d.ts +1 -1
  80. package/dist/store-sqlite.d.ts.map +1 -1
  81. package/dist/store-sqlite.js +11 -4
  82. package/dist/store-sqlite.js.map +1 -1
  83. package/dist/store-valkey.d.ts +1 -1
  84. package/dist/store-valkey.js +2 -2
  85. package/dist/{wire-CV-Z7wz-.js → wire-BFE9sIQf.js} +3 -2
  86. package/dist/wire-BFE9sIQf.js.map +1 -0
  87. package/docs/contract/03-sessions.mdx +51 -0
  88. package/docs/reference/02-server.mdx +44 -4
  89. package/docs/reference/06-errors.mdx +1 -0
  90. package/examples/playground/app/a2slack/consumers/search.ts +1 -1
  91. package/examples/playground/app/a2slack/db/migrations/0005_a2slack_feed_v2.sql +7 -0
  92. package/examples/playground/app/a2slack/db/migrations/meta/0005_snapshot.json +1165 -0
  93. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
  94. package/examples/playground/app/a2slack/db/queries.ts +1 -1
  95. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +17 -14
  96. package/examples/playground/app/a2slack/search-index.ts +1 -1
  97. package/examples/playground/app/a2slack/server.ts +2 -2
  98. package/examples/playground/lib/store.ts +2 -0
  99. package/examples/playground/package.json +1 -1
  100. package/package.json +1 -1
  101. package/src/errors.ts +39 -0
  102. package/src/idempotent-replay.ts +4 -3
  103. package/src/server.ts +32 -9
  104. package/src/store-memory.ts +26 -4
  105. package/src/store-postgres.ts +36 -6
  106. package/src/store-redis-core.ts +40 -15
  107. package/src/store-sqlite.ts +25 -4
  108. package/src/store.ts +10 -0
  109. package/src/wire.ts +1 -0
  110. package/dist/initial-snapshot-D3Lve43o.d.ts.map +0 -1
  111. package/dist/server-DtFrWxi3.js.map +0 -1
  112. package/dist/server-qgez0Xny.d.ts.map +0 -1
  113. package/dist/session-clear-D1PFe7xl.d.ts.map +0 -1
  114. package/dist/session-clear-wHd5GCpZ.js.map +0 -1
  115. package/dist/store-D0MhBvg6.d.ts.map +0 -1
  116. package/dist/store-codec-DwrWlgQx.js.map +0 -1
  117. package/dist/store-redis-notify-CBDVJxA_.js.map +0 -1
  118. 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.23.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.23.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",
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/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)
@@ -29,7 +29,12 @@
29
29
  * for the session lock holds, or one a clear left behind.
30
30
  */
31
31
 
32
- import { A2Error, supersededAttemptError } from './errors.ts'
32
+ import {
33
+ A2Error,
34
+ duplicateBatchError,
35
+ lastIndexMismatchError,
36
+ supersededAttemptError,
37
+ } from './errors.ts'
33
38
  import { idempotentReplay } from './idempotent-replay.ts'
34
39
  import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
35
40
  import {
@@ -574,7 +579,8 @@ const INSERTED_AFTER = `(
574
579
  * every event's id belongs to this session already, the batch is a retry: it
575
580
  * stops with the committed rows. Some owned aborts, as does scheduled work
576
581
  * from at or below the clear marker (`origin`). Rows below the marker own no
577
- * id.
582
+ * id. Both id outcomes carry the head, which a conditional append's conflict
583
+ * reports when it moved.
578
584
  */
579
585
  const APPEND_CHECKS = `
580
586
  with boundary as materialized (
@@ -592,12 +598,12 @@ const APPEND_CHECKS = `
592
598
  )
593
599
  select case
594
600
  when (select count(*) from owned) = ${param.bigint('count')} then
595
- jsonb_build_object('stop', 'replay',
601
+ jsonb_build_object('stop', 'replay', 'head', boundary.base,
596
602
  'has_pending', boundary.has_pending,
597
603
  'events', (select jsonb_agg(to_jsonb(owned) order by owned.idx)
598
604
  from owned))
599
605
  when exists (select 1 from owned) then
600
- jsonb_build_object('abort', 'mixed', 'owned',
606
+ jsonb_build_object('abort', 'mixed', 'head', boundary.base, 'owned',
601
607
  (select count(*) from owned))
602
608
  when ${param.bigint('origin')} <= boundary.clear_index then
603
609
  jsonb_build_object('abort', 'cleared')
@@ -630,6 +636,16 @@ const FENCE = `
630
636
  from cause where cause.ord not in (select ord from held)
631
637
  order by cause.ord limit 1`
632
638
 
639
+ /**
640
+ * A conditional append's head check, under the session lock and after the
641
+ * fences: it aborts with the head unless the head is `ifLastIndex`.
642
+ */
643
+ const IF_LAST_INDEX = `
644
+ select jsonb_build_object('abort', 'moved', 'index', head.idx)
645
+ from (select coalesce(max(idx), 0) as idx from a2_events
646
+ where session_id = ${SESSION}) head
647
+ where head.idx <> ${param.bigint('ifLastIndex')}`
648
+
633
649
  /**
634
650
  * Frees the `ids` this session's rows below its clear marker hold, so the
635
651
  * batch can take them. It never waits on a row: a reclaim deleting one may
@@ -1811,6 +1827,11 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1811
1827
  ]
1812
1828
  const work = enqueue(events, consumers)
1813
1829
  const originIndex = appendOptions?.originIndex
1830
+ const ifLastIndex = appendOptions?.ifLastIndex
1831
+ const conditionalAt = (head: unknown) =>
1832
+ ifLastIndex === undefined
1833
+ ? undefined
1834
+ : { sessionId, ifLastIndex, head: asNumber(head) }
1814
1835
  // One round trip, holding the session lock only while the server
1815
1836
  // runs it: index assignment must not race across writers.
1816
1837
  const results = await run({
@@ -1819,6 +1840,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1819
1840
  LOCK_SESSION,
1820
1841
  APPEND_CHECKS,
1821
1842
  causes.length > 0 && FENCE,
1843
+ ifLastIndex !== undefined && IF_LAST_INDEX,
1822
1844
  suppliedIds.length > 0 && REUSE_IDS,
1823
1845
  INSERT_EVENTS,
1824
1846
  ...work.statements,
@@ -1829,6 +1851,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1829
1851
  ids: suppliedIds,
1830
1852
  count: events.length,
1831
1853
  origin: originIndex ?? null,
1854
+ ifLastIndex: ifLastIndex ?? null,
1832
1855
  // Attempt-currency fence: a fresh consumer append commits only
1833
1856
  // while its causal attempt still owns the cursor's head
1834
1857
  // (store.ts `append`).
@@ -1846,9 +1869,9 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1846
1869
  switch (reason['abort']) {
1847
1870
  case 'mixed': {
1848
1871
  const owned = asNumber(reason['owned'])
1849
- return new A2Error(
1850
- 'PARTIAL_DUPLICATE_BATCH',
1872
+ return duplicateBatchError(
1851
1873
  `batch mixes ${owned} already-appended and ${events.length - owned} fresh events`,
1874
+ conditionalAt(reason['head']),
1852
1875
  )
1853
1876
  }
1854
1877
  case 'cleared':
@@ -1860,6 +1883,12 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1860
1883
  sessionId,
1861
1884
  causes[asNumber(reason['ord']) - 1]!,
1862
1885
  )
1886
+ case 'moved':
1887
+ return lastIndexMismatchError(
1888
+ sessionId,
1889
+ ifLastIndex!,
1890
+ asNumber(reason['index']),
1891
+ )
1863
1892
  case 'held':
1864
1893
  return new A2Error(
1865
1894
  'STORE_UNAVAILABLE',
@@ -1881,6 +1910,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1881
1910
  events: idempotentReplay(
1882
1911
  events,
1883
1912
  (checked['events'] as Row[]).map(toStored),
1913
+ conditionalAt(checked['head']),
1884
1914
  ),
1885
1915
  hasPending: checked['has_pending'] === true,
1886
1916
  }