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
@@ -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
  }
@@ -53,7 +53,12 @@
53
53
  */
54
54
 
55
55
  import type { PresencePatch } from './contract.ts'
56
- import { A2Error, supersededAttemptError } from './errors.ts'
56
+ import {
57
+ A2Error,
58
+ duplicateBatchError,
59
+ lastIndexMismatchError,
60
+ supersededAttemptError,
61
+ } from './errors.ts'
57
62
  import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
58
63
  import { idempotentReplay } from './idempotent-replay.ts'
59
64
  import { NOTIFY_TIMINGS, nullProtoRecord } from './internal.ts'
@@ -705,11 +710,12 @@ end
705
710
 
706
711
  /**
707
712
  * KEYS: counter.
708
- * ARGV: sessionId, nowMs, n, served consumers, origin index or '', then per
709
- * event: id, type, payloadJson, causeJson or '', targets.
713
+ * ARGV: sessionId, nowMs, n, served consumers, origin index or '',
714
+ * ifLastIndex or '', then per event: id, type, payloadJson, causeJson or '',
715
+ * targets.
710
716
  * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
711
- * ['dup', hasPending, ...indexes] | ['partial', count] |
712
- * ['superseded', position] | ['cleared', originIndex] |
717
+ * ['dup', hasPending, head, ...indexes] | ['partial', count, head] |
718
+ * ['superseded', position] | ['cleared', originIndex] | ['moved', head] |
713
719
  * ['index', type, after].
714
720
  */
715
721
  const APPEND_LUA = `${BASE_LUA}${CURSOR_LUA}
@@ -717,7 +723,7 @@ local now, n = ARGV[2], tonumber(ARGV[3])
717
723
  use_served(ARGV[4])
718
724
  if n == 0 then return { 'empty', has_pending() } end
719
725
  local ids = {}
720
- for i = 1, n do ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
726
+ for i = 1, n do ids[i] = ARGV[6 + (i - 1) * 5 + 1] end
721
727
  local owners = owners_of(read_bounds(ids))
722
728
  local base = total
723
729
  local dups = {}
@@ -725,18 +731,18 @@ for i = 1, n do
725
731
  if owners[i] then dups[#dups + 1] = owners[i] end
726
732
  end
727
733
  if #dups == n then
728
- local res = { 'dup', has_pending() }
734
+ local res = { 'dup', has_pending(), tostring(total) }
729
735
  for i = 1, #dups do res[#res + 1] = dups[i] end
730
736
  return res
731
737
  end
732
- if #dups > 0 then return { 'partial', tostring(#dups) } end
738
+ if #dups > 0 then return { 'partial', tostring(#dups), tostring(total) } end
733
739
  local origin = ARGV[5]
734
740
  if origin ~= '' and tonumber(origin) <= boundary then return { 'cleared', origin } end
735
741
  -- The attempt-currency fence: a cause's head row must still be its cursor's
736
742
  -- head, at that attempt, not dead-lettered.
737
743
  local refs, fenced = {}, {}
738
744
  for i = 1, n do
739
- local raw = ARGV[5 + (i - 1) * 5 + 4]
745
+ local raw = ARGV[6 + (i - 1) * 5 + 4]
740
746
  if raw ~= '' then
741
747
  local cause = cjson.decode(raw)
742
748
  local index = tonumber(cause.index)
@@ -756,16 +762,18 @@ for k, cause in ipairs(fenced) do
756
762
  end
757
763
  if served_has(cause.consumer) then pending_fast = true end
758
764
  end
765
+ local expected = ARGV[6]
766
+ if expected ~= '' and tonumber(expected) ~= total then return { 'moved', tostring(total) } end
759
767
  local batch = {}
760
768
  for i = 1, n do
761
- local o = 5 + (i - 1) * 5
769
+ local o = 6 + (i - 1) * 5
762
770
  batch[i] = { idx = base + i, type = ARGV[o + 2], targets = targets_of(ARGV[o + 5]) }
763
771
  end
764
772
  local planned, missing, after = plan(batch, base)
765
773
  if not planned then return { 'index', missing, tostring(after) } end
766
774
  local marks = {}
767
775
  for i = 1, n do
768
- local o = 5 + (i - 1) * 5
776
+ local o = 6 + (i - 1) * 5
769
777
  local field = tostring(base + i)
770
778
  redis.call('XADD', LOG, field .. '-0',
771
779
  'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
@@ -2408,6 +2416,11 @@ export function createRedisStoreCore(
2408
2416
  )
2409
2417
  }
2410
2418
  const now = clock.now().getTime()
2419
+ const ifLastIndex = appendOptions?.ifLastIndex
2420
+ const conditionalAt = (head: string | undefined) =>
2421
+ ifLastIndex === undefined
2422
+ ? undefined
2423
+ : { sessionId, ifLastIndex, head: Number(head) }
2411
2424
  const withIds = events.map((e) => ({
2412
2425
  id: e.id ?? generateId(),
2413
2426
  type: e.type,
@@ -2427,6 +2440,7 @@ export function createRedisStoreCore(
2427
2440
  withIds.length,
2428
2441
  servedArg(appendOptions?.consumers),
2429
2442
  appendOptions?.originIndex ?? '',
2443
+ ifLastIndex ?? '',
2430
2444
  ...withIds.flatMap((e, i) => [
2431
2445
  e.id,
2432
2446
  e.type,
@@ -2453,15 +2467,22 @@ export function createRedisStoreCore(
2453
2467
  withIds[Number(reply[1]) - 1]!.cause!,
2454
2468
  )
2455
2469
  }
2470
+ if (verdict === 'moved') {
2471
+ throw lastIndexMismatchError(
2472
+ sessionId,
2473
+ ifLastIndex!,
2474
+ Number(reply[1]),
2475
+ )
2476
+ }
2456
2477
  if (verdict === 'partial') {
2457
- throw new A2Error(
2458
- 'PARTIAL_DUPLICATE_BATCH',
2478
+ throw duplicateBatchError(
2459
2479
  `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`,
2480
+ conditionalAt(reply[2]),
2460
2481
  )
2461
2482
  }
2462
2483
  if (verdict === 'dup') {
2463
2484
  const hasPending = reply[1] === '1'
2464
- const firstIndex = Math.min(...reply.slice(2).map(Number))
2485
+ const firstIndex = Math.min(...reply.slice(3).map(Number))
2465
2486
  // A valid retry occupies exactly events.length consecutive indexes.
2466
2487
  const { rows: existing, clearIndex } = await retryAfterGap(() =>
2467
2488
  readBoundedRange(
@@ -2477,7 +2498,11 @@ export function createRedisStoreCore(
2477
2498
  `session '${sessionId}' was cleared while an append retry replayed; retry it`,
2478
2499
  )
2479
2500
  }
2480
- const replayed = idempotentReplay(events, existing)
2501
+ const replayed = idempotentReplay(
2502
+ events,
2503
+ existing,
2504
+ conditionalAt(reply[2]),
2505
+ )
2481
2506
  // The first attempt may have committed without publishing.
2482
2507
  await publish(
2483
2508
  notifyChannel(sessionId),
@@ -217,11 +217,17 @@ export function createRedisNotifyStore(options: {
217
217
  */
218
218
  let subscriberGate: Promise<RedisConnection> | null = null
219
219
  const gatedSubscriber = (): Promise<RedisConnection> => {
220
- subscriberGate ??= getSubscriber().then(async (conn) => {
220
+ if (subscriberGate) return subscriberGate
221
+ const gate = getSubscriber().then(async (conn) => {
221
222
  await conn.call('echo', 'ready')
222
223
  return conn
223
224
  })
224
- return subscriberGate
225
+ subscriberGate = gate
226
+ // A failed check is retried by the next channel command.
227
+ void gate.catch(() => {
228
+ if (subscriberGate === gate) subscriberGate = null
229
+ })
230
+ return gate
225
231
  }
226
232
 
227
233
  const acquireChannel = async (channel: string): Promise<void> => {
@@ -15,7 +15,12 @@
15
15
  import { mkdirSync } from 'node:fs'
16
16
  import { dirname, resolve } from 'node:path'
17
17
  import { DatabaseSync } from 'node:sqlite'
18
- import { A2Error, supersededAttemptError } from './errors.ts'
18
+ import {
19
+ A2Error,
20
+ duplicateBatchError,
21
+ lastIndexMismatchError,
22
+ supersededAttemptError,
23
+ } from './errors.ts'
19
24
  import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
20
25
  import { idempotentReplay } from './idempotent-replay.ts'
21
26
  import {
@@ -850,18 +855,27 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
850
855
  'batch contains the same event id more than once',
851
856
  )
852
857
  }
858
+ const ifLastIndex = appendOptions?.ifLastIndex
859
+ const conditional =
860
+ ifLastIndex === undefined
861
+ ? undefined
862
+ : { sessionId, ifLastIndex, head: headOf(sessionId) }
853
863
  if (suppliedIds.length > 0) {
854
864
  const existing = liveOwnersOf(sessionId, clearIndex, suppliedIds)
855
865
  if (existing.length > 0) {
856
866
  if (existing.length === events.length) {
857
867
  return {
858
- events: idempotentReplay(events, existing.map(toStored)),
868
+ events: idempotentReplay(
869
+ events,
870
+ existing.map(toStored),
871
+ conditional,
872
+ ),
859
873
  hasPending: hasPending(sessionId, clearIndex, consumers),
860
874
  }
861
875
  }
862
- throw new A2Error(
863
- 'PARTIAL_DUPLICATE_BATCH',
876
+ throw duplicateBatchError(
864
877
  `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,
878
+ conditional,
865
879
  )
866
880
  }
867
881
  }
@@ -880,6 +894,13 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
880
894
  if (e.cause)
881
895
  assertCurrentCause({ sessionId, cause: e.cause, clearIndex })
882
896
  }
897
+ if (conditional && conditional.head !== conditional.ifLastIndex) {
898
+ throw lastIndexMismatchError(
899
+ sessionId,
900
+ conditional.ifLastIndex,
901
+ conditional.head,
902
+ )
903
+ }
883
904
 
884
905
  const inserted = insertBatch({
885
906
  sessionId,