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.
- package/CHANGELOG.md +8 -0
- package/dist/{actor-CqibLgjQ.d.ts → actor-Bqyb7gwf.d.ts} +3 -3
- package/dist/{actor-CqibLgjQ.d.ts.map → actor-Bqyb7gwf.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +2 -2
- package/dist/actor-react.d.ts +2 -2
- package/dist/actor-react.js +5 -5
- package/dist/{actor-shared-DFET5q16.js → actor-shared-B4OB6Qo7.js} +3 -3
- package/dist/{actor-shared-DFET5q16.js.map → actor-shared-B4OB6Qo7.js.map} +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +4 -4
- package/dist/{ai-DAu77CDr.d.ts → ai-BZUu5-b7.d.ts} +2 -2
- package/dist/{ai-DAu77CDr.d.ts.map → ai-BZUu5-b7.d.ts.map} +1 -1
- package/dist/{ai-EEfKyc-z.js → ai-CCCNxd3L.js} +3 -3
- package/dist/{ai-EEfKyc-z.js.map → ai-CCCNxd3L.js.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +1 -1
- package/dist/{ai-harness-DTjkAr2T.d.ts → ai-harness-C8aV869s.d.ts} +2 -2
- package/dist/{ai-harness-DTjkAr2T.d.ts.map → ai-harness-C8aV869s.d.ts.map} +1 -1
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-react.d.ts +4 -4
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.js +4 -4
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/{client-CUMbS_38.d.ts → client-CCJASjUM.d.ts} +2 -2
- package/dist/{client-CUMbS_38.d.ts.map → client-CCJASjUM.d.ts.map} +1 -1
- package/dist/{client-BQS4i_V9.js → client-sq-djxhD.js} +4 -4
- package/dist/{client-BQS4i_V9.js.map → client-sq-djxhD.js.map} +1 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/{contract-CWukt01d.js → contract-DqfBSIqJ.js} +2 -2
- package/dist/{contract-CWukt01d.js.map → contract-DqfBSIqJ.js.map} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/{initial-snapshot-D3Lve43o.d.ts → initial-snapshot-BA6xwv10.d.ts} +2 -2
- package/dist/initial-snapshot-BA6xwv10.d.ts.map +1 -0
- package/dist/{react-BoHvC3v9.d.ts → react-KC4eW7yG.d.ts} +3 -3
- package/dist/{react-BoHvC3v9.d.ts.map → react-KC4eW7yG.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{reducer-CjmvZSAb.js → reducer-Bswr11ar.js} +2 -2
- package/dist/{reducer-CjmvZSAb.js.map → reducer-Bswr11ar.js.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-qgez0Xny.d.ts → server-B9gu0qi1.d.ts} +9 -3
- package/dist/server-B9gu0qi1.d.ts.map +1 -0
- package/dist/{server-DtFrWxi3.js → server-DX7mM6nm.js} +22 -14
- package/dist/server-DX7mM6nm.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-wHd5GCpZ.js → session-clear-XJYpmUpg.js} +14 -2
- package/dist/session-clear-XJYpmUpg.js.map +1 -0
- package/dist/{session-clear-D1PFe7xl.d.ts → session-clear-vfonou9S.d.ts} +2 -2
- package/dist/session-clear-vfonou9S.d.ts.map +1 -0
- package/dist/{store-D0MhBvg6.d.ts → store-DEzcHZOA.d.ts} +11 -1
- package/dist/store-DEzcHZOA.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/{store-codec-DwrWlgQx.js → store-codec-pAG2oPgQ.js} +4 -4
- package/dist/store-codec-pAG2oPgQ.js.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +11 -4
- package/dist/store-memory.js.map +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +26 -7
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/{store-redis-notify-CBDVJxA_.js → store-redis-notify-ys_-VrSP.js} +27 -16
- package/dist/store-redis-notify-ys_-VrSP.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +11 -4
- package/dist/store-sqlite.js.map +1 -1
- package/dist/store-valkey.d.ts +1 -1
- package/dist/store-valkey.js +2 -2
- package/dist/{wire-CV-Z7wz-.js → wire-BFE9sIQf.js} +3 -2
- package/dist/wire-BFE9sIQf.js.map +1 -0
- package/docs/contract/03-sessions.mdx +51 -0
- package/docs/reference/02-server.mdx +44 -4
- package/docs/reference/06-errors.mdx +1 -0
- package/examples/playground/app/a2slack/consumers/search.ts +1 -1
- package/examples/playground/app/a2slack/db/migrations/0005_a2slack_feed_v2.sql +7 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0005_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
- package/examples/playground/app/a2slack/db/queries.ts +1 -1
- package/examples/playground/app/a2slack/drills/a2slack.drill.ts +17 -14
- package/examples/playground/app/a2slack/search-index.ts +1 -1
- package/examples/playground/app/a2slack/server.ts +2 -2
- package/examples/playground/lib/store.ts +2 -0
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/errors.ts +39 -0
- package/src/idempotent-replay.ts +4 -3
- package/src/server.ts +32 -9
- package/src/store-memory.ts +26 -4
- package/src/store-postgres.ts +36 -6
- package/src/store-redis-core.ts +40 -15
- package/src/store-sqlite.ts +25 -4
- package/src/store.ts +10 -0
- package/src/wire.ts +1 -0
- package/dist/initial-snapshot-D3Lve43o.d.ts.map +0 -1
- package/dist/server-DtFrWxi3.js.map +0 -1
- package/dist/server-qgez0Xny.d.ts.map +0 -1
- package/dist/session-clear-D1PFe7xl.d.ts.map +0 -1
- package/dist/session-clear-wHd5GCpZ.js.map +0 -1
- package/dist/store-D0MhBvg6.d.ts.map +0 -1
- package/dist/store-codec-DwrWlgQx.js.map +0 -1
- package/dist/store-redis-notify-CBDVJxA_.js.map +0 -1
- 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?: {
|
|
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
|
|
815
|
-
|
|
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-
|
|
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";
|