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
@@ -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),
@@ -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,
package/src/store.ts CHANGED
@@ -326,12 +326,22 @@ export interface A2Store {
326
326
  * a `cause` at or below the latest clear marker throws
327
327
  * `SUPERSEDED_ATTEMPT`, an `originIndex` there throws `SESSION_CLEARED`,
328
328
  * and ids whose rows lie below the marker are free to reuse.
329
+ *
330
+ * `ifLastIndex` conditions a non-empty batch on the session's head, the
331
+ * last index including any clear marker: when the head is anything else,
332
+ * throw `lastIndexMismatchError` carrying the head and write nothing. It is
333
+ * checked last, after the replay path and both fences, inside the same
334
+ * atomic operation, never as a read before it. A conditional batch whose
335
+ * ids conflict (mixed, or not matching their rows) reports that head
336
+ * instead of `PARTIAL_DUPLICATE_BATCH` when it isn't `ifLastIndex`
337
+ * (`duplicateBatchError`).
329
338
  */
330
339
  append(
331
340
  sessionId: string,
332
341
  events: AppendEvent[],
333
342
  options?: {
334
343
  originIndex?: number
344
+ ifLastIndex?: number
335
345
  consumers?: readonly StoreConsumer[]
336
346
  },
337
347
  ): Promise<StoreAppendResult>
package/src/wire.ts CHANGED
@@ -309,6 +309,7 @@ const ERROR_STATUS: Record<A2ServerErrorCode, number> = {
309
309
  PARTIAL_DUPLICATE_BATCH: 400,
310
310
  SUPERSEDED_ATTEMPT: 409,
311
311
  SESSION_CLEARED: 409,
312
+ LAST_INDEX_MISMATCH: 409,
312
313
  CLAIM_EXPIRED: 409,
313
314
  STORE_UNAVAILABLE: 503,
314
315
  STORE_NOT_CONFIGURED: 500,
@@ -1 +0,0 @@
1
- {"version":3,"file":"initial-snapshot-D3Lve43o.d.ts","names":[],"sources":["../src/errors.ts","../src/initial-snapshot.ts"],"mappings":";;KAAY;KAcA,cACV;cAEW,gBAAgB;WAClB,MAAM;;WAEN;EAET,YACE,MAAM,aACN,iBACA;IAAY;IAAmB;;;;;;;;;;;cA6CtB,0BAA0B;EACrC,YAAY,iBAAiB;IAAY;;;;;KCrE/B,gBAAgB,KACxB,cAAc,wBAGd,QAAQ,cAAc"}