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
@@ -23,6 +23,57 @@ serve millions of order sessions through the same server and consumers.
23
23
  Collections, joins, search, and constraints across many records belong in
24
24
  [your database](#sessions-and-your-database).
25
25
 
26
+ ## Appending from what you read
27
+
28
+ ```ts server/expire-order.ts
29
+ import { A2Error } from 'experimental-a2'
30
+ import { ordersServer } from './orders'
31
+
32
+ export async function expireOrder({ orderId, seenIndex, requestId }: {
33
+ orderId: string
34
+ seenIndex: number
35
+ requestId: string // the client's id for this request, the same on each retry
36
+ }) {
37
+ // here's where you'd do auth, or any other checks
38
+ try {
39
+ return await ordersServer.session(orderId).append.ifLastIndex(seenIndex, {
40
+ id: requestId,
41
+ type: 'expired',
42
+ payload: {},
43
+ })
44
+ } catch (err) {
45
+ if (err instanceof A2Error && err.code === 'LAST_INDEX_MISMATCH') {
46
+ // the order changed after it was read: read it again and decide again
47
+ return null
48
+ }
49
+ throw err
50
+ }
51
+ }
52
+ ```
53
+
54
+ `session.append.ifLastIndex(index, ...events)` appends only while `index` is
55
+ still the session's head, the index of its last event. Pass the head your
56
+ decision was based on: `session.state()` returns it with the state, and an
57
+ append returns each event's index.
58
+
59
+ The store checks the head in the same atomic step as the write, so of two
60
+ callers that read the same head, at most one lands. The other gets an `A2Error`
61
+ with code `LAST_INDEX_MISMATCH` and nothing written; its `err.details` is
62
+ `{ index }`, the head the store found. Any event moves the head, not only the
63
+ ones your check is about, so a busy session refuses more often.
64
+
65
+ Give each event an id that is unique to this writer's decision and the same on
66
+ each of its retries, such as an id the client generates for the request. A
67
+ batch whose ids are all in the session already returns its rows whatever the
68
+ head is now, so a retry after a lost response gets the events it wrote. Ids
69
+ built from the index alone are shared by every writer that read it, so they
70
+ fit only when each of those writers would append the same events: otherwise a
71
+ writer whose events happen to match another's gets that writer's rows back as
72
+ its own.
73
+
74
+ A [clear](#clearing-a-session) moves the head to its marker, so an index read
75
+ before it never matches after it.
76
+
26
77
  ## Clearing a session
27
78
 
28
79
  ```ts server/clear-order.ts
@@ -300,7 +300,11 @@ interface A2Store {
300
300
  append(
301
301
  sessionId: string,
302
302
  events: AppendEvent[],
303
- options?: { originIndex?: number; consumers?: readonly StoreConsumer[] },
303
+ options?: {
304
+ originIndex?: number
305
+ ifLastIndex?: number
306
+ consumers?: readonly StoreConsumer[]
307
+ },
304
308
  ): Promise<StoreAppendResult>
305
309
 
306
310
  cursors(sessionId: string): Promise<StoredCursor[]>
@@ -386,7 +390,7 @@ other consumer is neither pending nor claimed. Omitted, every cursor counts.
386
390
 
387
391
  | Method | Atomic effect |
388
392
  | --- | --- |
389
- | `append` | Write a consecutive batch. Each event's `targets` start work: a lane target queues the event in that consumer's lane cursor, a null lane creates a cursor for that event alone. Each log consumer in `consumers` whose types the batch carries gets its cursor created (at the start of the log with `backfill`, otherwise just before the batch) or, when idle, a head. Report pending work for the served consumers as `hasPending`. An event with a `cause` commits only while that cursor's head is `cause.index`, `cause.attempt` is its latest attempt, and it isn't dead-lettered; otherwise throw `SUPERSEDED_ATTEMPT`. The check serializes against a concurrent claim, after the idempotent-replay path. |
393
+ | `append` | Write a consecutive batch. Each event's `targets` start work: a lane target queues the event in that consumer's lane cursor, a null lane creates a cursor for that event alone. Each log consumer in `consumers` whose types the batch carries gets its cursor created (at the start of the log with `backfill`, otherwise just before the batch) or, when idle, a head. Report pending work for the served consumers as `hasPending`. An event with a `cause` commits only while that cursor's head is `cause.index`, `cause.attempt` is its latest attempt, and it isn't dead-lettered; otherwise throw `SUPERSEDED_ATTEMPT`. The check serializes against a concurrent claim, after the idempotent-replay path. With `ifLastIndex`, write only while the session's head, its last index including a clear marker, is that index; otherwise throw `LAST_INDEX_MISMATCH` with `details: { index }`, the head. Check it last, after the replay path and both fences, in the same atomic step. Under `ifLastIndex`, ids that conflict (mixed, or not matching their rows) throw `LAST_INDEX_MISMATCH` instead of `PARTIAL_DUPLICATE_BATCH` when the head isn't `ifLastIndex`, read in the same step as the ids. |
390
394
  | `cursors` | List every log and lane cursor, and every concurrent cursor that still has a head, by consumer, then lane, with concurrent cursors last by head. Lane and concurrent cursors whose work lies at or below the latest clear marker are not listed. |
391
395
  | `claimAvailable` | Claim every eligible head of the served consumers, in event order, then by consumer. A claim records its holder, expiry, timestamps, and next `attemptCount`. Taking over a lapsed claim, one its holder never completed or failed, adds one to `failureCount`; when that reaches `maxFailures`, dead-letter the head instead of claiming it: advance `attemptCount` to fence the lapsed attempt, leave `lastFailedAttempt` unchanged, and return it with `failedAt` set. Excluded heads stay unclaimed and keep blocking their lane. A claim may report `head`, the session's head when the claim was made. |
392
396
  | `renewClaims` | Extend the listed live claims the holder still owns. `renewed` lists those; `superseded` lists claims whose head a newer attempt took, or whose cursor moved past it. An expired claim no successor took is in neither and may still complete; renewal never revives it. |
@@ -548,6 +552,40 @@ scheduler instead of starting an inline drain. See
548
552
  runs inside an active drain, where its immediate and returned children
549
553
  become eligible, so it has no `dispatch`.
550
554
 
555
+ ### `session.append.ifLastIndex(index, ...events)`
556
+
557
+ ```ts
558
+ session.append.ifLastIndex(
559
+ index: number,
560
+ ...events: Array<{ type: string; payload: unknown; id?: string }>
561
+ ): Promise<Event[]>
562
+ ```
563
+
564
+ Commits the same atomic batch as `session.append`, only while `index` is still
565
+ the session's head: the index of its last event. See
566
+ [Appending from what you read](/contract/sessions#appending-from-what-you-read).
567
+
568
+ - The store checks the head in the same atomic step as the write, so of two
569
+ writers that read the same head, at most one lands.
570
+ - When the head is anything else, nothing is written and nothing runs: it
571
+ throws an `A2Error` with code `LAST_INDEX_MISMATCH`, and `details` is
572
+ `{ index }`, the head the store found.
573
+ - A batch whose ids are all in the session already returns its rows, whatever
574
+ the head is now. Give each event an id unique to this writer's decision and
575
+ stable across its retries, such as a request id the client generates. Ids
576
+ built from `index` alone are shared by every writer that read it, so they
577
+ fit only when each of them would append the same events.
578
+ - Ids that conflict with committed events, in a batch that mixes them with
579
+ fresh ones or doesn't match the rows they name, throw `LAST_INDEX_MISMATCH`
580
+ once the head has moved past `index`, as when another writer's batch took
581
+ them first. While the head is still `index`, they throw
582
+ `PARTIAL_DUPLICATE_BATCH`.
583
+ - A clear's marker is the head after it, so an index read before a clear never
584
+ matches after it.
585
+ - `index` is a non-negative safe integer, and the batch has at least one
586
+ event; otherwise it throws a `TypeError` before writing.
587
+ - Exists only on a server session's top-level append, like `dispatch`.
588
+
551
589
  ### `session.schedule(name, timing, ...events)`
552
590
 
553
591
  ```ts
@@ -811,8 +849,10 @@ client to resume there. See [State](/contract/state).
811
849
  only when later committed events are intentionally part of the decision.
812
850
  - A numeric `through` is an inclusive, non-negative safe integer;
813
851
  `through: 0` returns the reducer's initial state.
814
- - A latest read and a following append are not atomic. See
815
- [State](/contract/state#reducers) for joins that converge.
852
+ - A read and a following append are not atomic. To append only if nothing has
853
+ landed since the read, pass its `index` to
854
+ [`session.append.ifLastIndex`](#sessionappendiflastindexindex-events). In a
855
+ consumer, see [State](/contract/state#reducers) for joins that converge.
816
856
  - Never runs consumers or waits for pending work. Snapshot write-back
817
857
  uses platform `waitUntil`, and its failures never fail the completed read:
818
858
  they go to `a2.snapshot` telemetry or the default console sink, without
@@ -42,6 +42,7 @@ export async function POST(req: Request) {
42
42
  | `PARTIAL_DUPLICATE_BATCH` | a batch mixes already-appended and fresh events, or contains the same id twice. Event ids are unique per session, so an id used in another session never conflicts. Always a caller bug; nothing was written | no |
43
43
  | `SUPERSEDED_ATTEMPT` | a consumer's append or clear comes from an attempt that a recovery claim or a session clear replaced; nothing was written. Also the abort reason on that attempt's `ctx.signal` | no |
44
44
  | `SESSION_CLEARED` | a state read reaches below the latest clear, a consumer reads state past a clear of its event, or an actor call is cleared before it receives its answer | no |
45
+ | `LAST_INDEX_MISMATCH` | [`session.append.ifLastIndex()`](/reference/server#sessionappendiflastindexindex-events) found another head, including when another batch took its ids after the index it read; nothing was written. `details` is `{ index }`, the head it found. Read again and decide again; an identical retry would fail the same way | no |
45
46
  | `CLAIM_EXPIRED` | never thrown; only the `ctx.signal` abort reason when the attempt's lease lapsed without renewal (assumed loss, decided locally, not proof) | no |
46
47
  | `CONSUMER_DEAD_LETTERED` | `session.consume()` reached an event that dead-lettered for that consumer. Resolve it with `session.redrive()`, then consume again | no |
47
48
  | `STORE_UNAVAILABLE` | the store backend failed; nothing was written. The original error is the `cause` | **yes** |
@@ -10,7 +10,7 @@ import { deleteDocument, putDocument, setDocumentText } from '../search-index'
10
10
 
11
11
  export const searchIndex = consumer({
12
12
  contract: workspace,
13
- name: 'a2slack-search-v1',
13
+ name: 'a2slack-search-v2',
14
14
  types: ['message.posted', 'message.edited', 'message.deleted'],
15
15
  lane: 'search',
16
16
  backfill: true,
@@ -0,0 +1,7 @@
1
+ -- a2slack-feed-v2 fills these from the start of the log it reads.
2
+ TRUNCATE "a2slack_channels", "a2slack_messages", "a2slack_reactions", "a2slack_notifications", "a2slack_reads";--> statement-breakpoint
3
+ -- a2slack-feed's position counts an earlier log. Pinned at the top, it claims
4
+ -- no more events, so a deployment still running it writes no rows the new
5
+ -- feed would then collide with.
6
+ INSERT INTO "a2slack_positions" ("consumer", "log_index") VALUES ('a2slack-feed', 9007199254740991)
7
+ ON CONFLICT ("consumer") DO UPDATE SET "log_index" = excluded."log_index";