experimental-a2 0.4.0 → 0.5.1
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 +66 -0
- package/dist/{ai-B4YhEnfw.d.ts → ai-D_PGS-JR.d.ts} +3 -2
- package/dist/ai-D_PGS-JR.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +4 -3
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +4 -2
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +3 -1
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +2 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.js +2 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -0
- package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/contract-48bUMgcL.js +2 -0
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/contract-jIfaR085.d.ts +2 -1
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +2 -1
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +2 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +2 -0
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +2 -1
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +2 -0
- package/dist/devtools-server.js.map +1 -0
- package/dist/errors-BQuJpe82.js +2 -0
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/errors-W6nwJ-fm.d.ts +2 -1
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +121 -72
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +503 -178
- package/dist/http.js.map +1 -0
- package/dist/idempotent-replay-DuqEkYA7.js +2 -0
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/inspection-DaxB5jM2.js +2 -0
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +3 -2
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +2 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +12 -3
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +5 -1
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +4 -3
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +4 -2
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +2 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -3
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +4 -2
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/{server-YtPq7hjw.d.ts → server-DpvjhdoE.d.ts} +6 -5
- package/dist/server-DpvjhdoE.d.ts.map +1 -0
- package/dist/{server-CcNnFnoW.js → server-Duw6MVlB.js} +112 -54
- package/dist/server-Duw6MVlB.js.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js +2 -0
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +2 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +3 -2
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/store-memory.js +19 -11
- package/dist/store-memory.js.map +1 -0
- package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/store-postgres.d.ts +3 -2
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/store-postgres.js +57 -1
- package/dist/store-postgres.js.map +1 -0
- package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/store-redis-http.d.ts +3 -2
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/store-redis-http.js +4 -2
- package/dist/store-redis-http.js.map +1 -0
- package/dist/store-redis.d.ts +3 -2
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/store-redis.js +5 -3
- package/dist/store-redis.js.map +1 -0
- package/dist/store-sqlite.d.ts +3 -2
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/store-sqlite.js +3 -1
- package/dist/store-sqlite.js.map +1 -0
- package/dist/{telemetry-BjYHTfh2.d.ts → telemetry-CpeclqB2.d.ts} +4 -3
- package/dist/telemetry-CpeclqB2.d.ts.map +1 -0
- package/dist/testing.browser.js +2 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +2 -1
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +2 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/guides/03-react.mdx +59 -39
- package/docs/guides/06-ai-agents.mdx +5 -27
- package/docs/guides/09-presence.mdx +19 -40
- package/docs/guides/10-transports.mdx +49 -40
- package/docs/reference/01-api.mdx +107 -26
- package/docs/reference/02-errors.mdx +4 -2
- package/package.json +2 -1
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +54 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The A2Store interface — the storage contract every store backend
|
|
3
|
+
* implements. See specs/a2-implementation.md §2–3.
|
|
4
|
+
*
|
|
5
|
+
* This is the whole storage contract: append, read, dispatch claims,
|
|
6
|
+
* failure markers, snapshots, and the live stream. The scheduler
|
|
7
|
+
* needs nothing extra — the armed queue message is its own state, and
|
|
8
|
+
* the store is the only thing it consults.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PresencePatch } from './contract.ts'
|
|
12
|
+
|
|
13
|
+
/** A stored event, as the public API exposes it. */
|
|
14
|
+
export type Event = {
|
|
15
|
+
id: string
|
|
16
|
+
type: string
|
|
17
|
+
payload: unknown
|
|
18
|
+
/** Position in the session's event log, from 1. */
|
|
19
|
+
index: number
|
|
20
|
+
sessionId: string
|
|
21
|
+
createdAt: Date
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The handler dispatch whose append first persisted a child event. */
|
|
25
|
+
export type EventCause = {
|
|
26
|
+
index: number
|
|
27
|
+
attempt: number
|
|
28
|
+
/** Size of one named handler append, used to reject truncated retries. */
|
|
29
|
+
batchSize?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What the store persists: immutable event history, including its causal edge,
|
|
34
|
+
* plus derived dispatch and failure bookkeeping. The bookkeeping is disposable;
|
|
35
|
+
* the event and its cause are not.
|
|
36
|
+
*/
|
|
37
|
+
export type StoredEvent = Event & {
|
|
38
|
+
/** Same-session handler dispatch that appended this event; null means root. */
|
|
39
|
+
cause: EventCause | null
|
|
40
|
+
/** Session-scoped serial execution key resolved when the event is appended. */
|
|
41
|
+
lane: string | null
|
|
42
|
+
/** Adapter clock time recorded by append settlement, completion, or manual skip; null while pending. */
|
|
43
|
+
processedAt: Date | null
|
|
44
|
+
/** Dispatch attempt that completed this event; null without dispatch or while pending. */
|
|
45
|
+
processedByAttempt: number | null
|
|
46
|
+
/** Exact ordered child ids atomically returned by the completing attempt. */
|
|
47
|
+
returnedEventIds: string[] | null
|
|
48
|
+
/** Adapter clock time recorded by the first durable dispatch claim. */
|
|
49
|
+
firstClaimedAt: Date | null
|
|
50
|
+
/** Adapter clock time recorded by the most recent durable dispatch claim. */
|
|
51
|
+
lastClaimedAt: Date | null
|
|
52
|
+
/** Durable dispatch claims, including claims abandoned by hard kills. */
|
|
53
|
+
attemptCount: number
|
|
54
|
+
/** Current dispatch holder; null when the event is not claimed. */
|
|
55
|
+
claimHolder: string | null
|
|
56
|
+
/** Adapter clock expiry for the current dispatch claim. */
|
|
57
|
+
claimExpiresAt: Date | null
|
|
58
|
+
/** Caught handler failures. This alone drives dead-lettering. */
|
|
59
|
+
failureCount: number
|
|
60
|
+
/** Adapter clock time recorded by the most recent caught handler failure. */
|
|
61
|
+
lastFailedAt: Date | null
|
|
62
|
+
/** Dispatch attempt that produced the most recent caught handler failure. */
|
|
63
|
+
lastFailedAttempt: number | null
|
|
64
|
+
/** The last handler failure, stringified. */
|
|
65
|
+
lastError: string | null
|
|
66
|
+
/** Adapter clock time recorded when dead-lettered; null otherwise. */
|
|
67
|
+
failedAt: Date | null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Durable, read-only session metadata for administrative inspection. */
|
|
71
|
+
export type StoredSessionSummary = {
|
|
72
|
+
sessionId: string
|
|
73
|
+
eventCount: number
|
|
74
|
+
pendingCount: number
|
|
75
|
+
failedCount: number
|
|
76
|
+
/** Durable dispatch claims across every event in the session. */
|
|
77
|
+
attemptCount: number
|
|
78
|
+
/** Caught handler failures across every event in the session. */
|
|
79
|
+
failureCount: number
|
|
80
|
+
firstEventAt: Date
|
|
81
|
+
updatedAt: Date
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One live presence value, as `A2Store.presence.read` returns it. The
|
|
86
|
+
* row is the LWW unit — one participant's one field. `at` is the
|
|
87
|
+
* sender's stamp, the LWW comparator. `expiresAt` is the storage's
|
|
88
|
+
* own clock at the applied set plus its `ttlMs` — never derived from
|
|
89
|
+
* the sender stamp; a row is live strictly before it.
|
|
90
|
+
*/
|
|
91
|
+
export type PresenceRow = {
|
|
92
|
+
participant: string
|
|
93
|
+
field: string
|
|
94
|
+
value: unknown
|
|
95
|
+
seen: number
|
|
96
|
+
at: Date
|
|
97
|
+
expiresAt: Date
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Metadata for the latest cached fold of one reducer. */
|
|
101
|
+
export type StoredSnapshot = {
|
|
102
|
+
reducerName: string
|
|
103
|
+
index: number
|
|
104
|
+
updatedAt: Date
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One consistent cache-plus-tail read for a reducer fold. */
|
|
108
|
+
export type StoreStateRead = {
|
|
109
|
+
/** The latest cached fold for this reducer, if one exists. */
|
|
110
|
+
snapshot: { index: number; state: unknown } | null
|
|
111
|
+
/** Immutable events strictly after `snapshot.index`, or the full log on a miss. */
|
|
112
|
+
events: Event[]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type StoredSessionPage = {
|
|
116
|
+
sessions: StoredSessionSummary[]
|
|
117
|
+
/** Opaque backend cursor; null means there is no next page. */
|
|
118
|
+
cursor: string | null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type StoredEventPage = {
|
|
122
|
+
events: StoredEvent[]
|
|
123
|
+
/** Fixed inclusive log frontier shared by every page in one inspection. */
|
|
124
|
+
throughIndex: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Optional read-only administration implemented by A2's shipped stores.
|
|
129
|
+
* It is separate from the correctness contract so custom stores do not
|
|
130
|
+
* need to expose storage-wide discovery merely to process events.
|
|
131
|
+
*/
|
|
132
|
+
export type A2StoreInspection = {
|
|
133
|
+
listSessions(options: {
|
|
134
|
+
/** Storage namespace prefix, including the contract separator. */
|
|
135
|
+
prefix: string
|
|
136
|
+
cursor?: string
|
|
137
|
+
limit: number
|
|
138
|
+
}): Promise<StoredSessionPage>
|
|
139
|
+
/**
|
|
140
|
+
* A bounded event page for read-only administration. The first page captures
|
|
141
|
+
* `throughIndex`; callers pass it back so concurrent appends cannot extend a
|
|
142
|
+
* walk indefinitely. Optional for custom stores; A2 falls back to `read()`.
|
|
143
|
+
*/
|
|
144
|
+
readEvents?(
|
|
145
|
+
sessionId: string,
|
|
146
|
+
options: { afterIndex: number; throughIndex?: number; limit: number },
|
|
147
|
+
): Promise<StoredEventPage>
|
|
148
|
+
listSnapshots(sessionId: string): Promise<StoredSnapshot[]>
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The result of atomically claiming every currently eligible event. */
|
|
152
|
+
export type StoreClaimAvailableResult =
|
|
153
|
+
| { outcome: 'claimed'; events: StoredEvent[] }
|
|
154
|
+
| { outcome: 'busy'; retryAt: Date }
|
|
155
|
+
| { outcome: 'settled' }
|
|
156
|
+
|
|
157
|
+
/** A completion may lose to a newer claim or an earlier completion. */
|
|
158
|
+
export type CompleteAttemptResult =
|
|
159
|
+
{ outcome: 'completed'; events: StoredEvent[] } | { outcome: 'superseded' }
|
|
160
|
+
|
|
161
|
+
/** The result of one claim-renewal heartbeat, both lists in append order. */
|
|
162
|
+
export type RenewClaimsResult = {
|
|
163
|
+
/** Listed claims that remain owned by the holder. */
|
|
164
|
+
renewed: number[]
|
|
165
|
+
/** Listed claims whose event a newer attempt has durably taken. */
|
|
166
|
+
superseded: number[]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The result of atomically recording a caught handler failure. */
|
|
170
|
+
export type FailAttemptResult = {
|
|
171
|
+
outcome: 'failed' | 'dead_lettered' | 'superseded'
|
|
172
|
+
failureCount: number
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Input to `A2Store.append` — already validated by the machine. */
|
|
176
|
+
export type AppendEvent = {
|
|
177
|
+
type: string
|
|
178
|
+
payload: unknown
|
|
179
|
+
/** Caller-supplied idempotency key; generated when absent. */
|
|
180
|
+
id?: string
|
|
181
|
+
/** Internal causal edge supplied atomically by handler `session.append`. */
|
|
182
|
+
cause?: EventCause
|
|
183
|
+
/** Session-scoped serial execution key resolved before persistence. */
|
|
184
|
+
lane?: string
|
|
185
|
+
/** Internal hint: settle this event in the append transaction; no handler is registered. */
|
|
186
|
+
settled?: true
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A handler-returned event with the deterministic id retries require. */
|
|
190
|
+
export type ReturnedEvent = AppendEvent & { id: string }
|
|
191
|
+
|
|
192
|
+
/** The rows written by an append and the session's pending state. */
|
|
193
|
+
export type StoreAppendResult = {
|
|
194
|
+
events: StoredEvent[]
|
|
195
|
+
/** Whether the session contains an event without a completion marker. */
|
|
196
|
+
hasPending: boolean
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* An injectable clock. Adapters take one so tests can drive claim
|
|
201
|
+
* expiry, failure timestamps, and (later) stuck-session detection
|
|
202
|
+
* deterministically — against real storage, no mocking.
|
|
203
|
+
*/
|
|
204
|
+
export type Clock = {
|
|
205
|
+
now(): Date
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** An injectable id source for generated event ids. */
|
|
209
|
+
export type IdSource = () => string
|
|
210
|
+
|
|
211
|
+
export const SYSTEM_CLOCK: Clock = {
|
|
212
|
+
now: () => new Date(),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const RANDOM_IDS: IdSource = () => crypto.randomUUID()
|
|
216
|
+
|
|
217
|
+
export interface A2Store {
|
|
218
|
+
/**
|
|
219
|
+
* Accepts a batch; the batch is atomic — one transaction, consecutive
|
|
220
|
+
* `index`es, all-or-nothing. The idempotency key covers the whole
|
|
221
|
+
* operation, not each item: if *every* event's `id` already exists in
|
|
222
|
+
* this session, this is a retry of a committed batch whose ack was
|
|
223
|
+
* lost — return the existing rows as success. If only *some* ids
|
|
224
|
+
* exist, the caller mixed an already-sent batch with fresh events —
|
|
225
|
+
* always a caller bug — so throw `A2Error('PARTIAL_DUPLICATE_BATCH')`.
|
|
226
|
+
* Events carrying `settled: true` get `processedAt` in this same atomic
|
|
227
|
+
* operation, with no dispatch claim or `processedByAttempt`. The result's
|
|
228
|
+
* `hasPending` reflects the whole session in the same atomic operation,
|
|
229
|
+
* including older events and idempotent retries.
|
|
230
|
+
*
|
|
231
|
+
* An event carrying `cause` is a handler append, fenced by attempt
|
|
232
|
+
* currency: accept it only while `cause.attempt` is still the parent
|
|
233
|
+
* event's latest attempt and the parent is not dead-lettered; otherwise
|
|
234
|
+
* throw `A2Error('SUPERSEDED_ATTEMPT')` and write nothing. The check is
|
|
235
|
+
* part of this atomic operation and must serialize against a concurrent
|
|
236
|
+
* `claimAvailable` — an unlocked read of the parent admits write skew.
|
|
237
|
+
* The idempotent-replay path runs first, so a batch whose ids all exist
|
|
238
|
+
* replays regardless of the current attempt.
|
|
239
|
+
*/
|
|
240
|
+
append(sessionId: string, events: AppendEvent[]): Promise<StoreAppendResult>
|
|
241
|
+
|
|
242
|
+
/** Events for one session, oldest first. Bounds form `(afterIndex, throughIndex]`. */
|
|
243
|
+
read(
|
|
244
|
+
sessionId: string,
|
|
245
|
+
opts?: { afterIndex?: number; throughIndex?: number },
|
|
246
|
+
): Promise<StoredEvent[]>
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Atomically claims every eligible pending event. Unlaned events are all
|
|
250
|
+
* independently eligible. Within a lane, only the lowest-index unfinished
|
|
251
|
+
* event is eligible. A live claim produces `busy` only when it is the sole
|
|
252
|
+
* remaining obstacle to actionable work. Claimed events are returned in event-log
|
|
253
|
+
* order. Excluded rows remain lane barriers.
|
|
254
|
+
*/
|
|
255
|
+
claimAvailable(options: {
|
|
256
|
+
sessionId: string
|
|
257
|
+
holder: string
|
|
258
|
+
ttlMs: number
|
|
259
|
+
expiresAtMs?: number
|
|
260
|
+
excludeIndexes?: readonly number[]
|
|
261
|
+
}): Promise<StoreClaimAvailableResult>
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Renews the listed live claims still owned by `holder`. Each listed claim
|
|
265
|
+
* carries the attempt ordinal the holder owns. `renewed` lists the claims
|
|
266
|
+
* that remain owned after the operation; `superseded` lists the claims
|
|
267
|
+
* whose event's `attemptCount` has durably passed the listed attempt. An
|
|
268
|
+
* expired claim no successor has taken appears in neither list — its
|
|
269
|
+
* attempt may still complete (`completeAttempt` is the fence), so it is
|
|
270
|
+
* not reported as lost. Renewal never revives an expired claim.
|
|
271
|
+
*/
|
|
272
|
+
renewClaims(options: {
|
|
273
|
+
sessionId: string
|
|
274
|
+
holder: string
|
|
275
|
+
claims: readonly { index: number; attempt: number }[]
|
|
276
|
+
ttlMs: number
|
|
277
|
+
expiresAtMs?: number
|
|
278
|
+
}): Promise<RenewClaimsResult>
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Atomically completes one current attempt and appends its returned events.
|
|
282
|
+
* Retrying a committed completion with the same attempt and deterministic
|
|
283
|
+
* child ids returns the existing children. A stale attempt never appends.
|
|
284
|
+
*/
|
|
285
|
+
completeAttempt(options: {
|
|
286
|
+
sessionId: string
|
|
287
|
+
index: number
|
|
288
|
+
attempt: number
|
|
289
|
+
events: ReturnedEvent[]
|
|
290
|
+
}): Promise<CompleteAttemptResult>
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Atomically records a caught failure for one claimed attempt. A stale
|
|
294
|
+
* attempt cannot poison a processed event or a newer dispatch. Accepted
|
|
295
|
+
* failures record the operation's clock time.
|
|
296
|
+
*/
|
|
297
|
+
failAttempt(options: {
|
|
298
|
+
sessionId: string
|
|
299
|
+
index: number
|
|
300
|
+
attempt: number
|
|
301
|
+
error: string
|
|
302
|
+
maxFailures: number
|
|
303
|
+
}): Promise<FailAttemptResult>
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Reads a reducer snapshot and its event tail as one consistent adapter
|
|
307
|
+
* operation. On a cache miss, `snapshot` is null and `events` is the full
|
|
308
|
+
* log. The snapshot is untrusted; core may reject it and issue a full
|
|
309
|
+
* `read()` when its state schema no longer accepts the cached value.
|
|
310
|
+
*/
|
|
311
|
+
readState(sessionId: string, reducerName: string): Promise<StoreStateRead>
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Optional batched `readState` — one consistent snapshot-plus-tail read
|
|
315
|
+
* per session id, aligned positionally with the input (duplicates
|
|
316
|
+
* allowed). Each element has its own frontier; the batch makes no
|
|
317
|
+
* cross-session consistency claim. Core falls back to parallel
|
|
318
|
+
* `readState` calls when absent.
|
|
319
|
+
*/
|
|
320
|
+
readStates?(
|
|
321
|
+
sessionIds: string[],
|
|
322
|
+
reducerName: string,
|
|
323
|
+
): Promise<StoreStateRead[]>
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Writes a disposable reducer cache. Guard this operation so a slower
|
|
327
|
+
* concurrent writer can never clobber a further-along snapshot
|
|
328
|
+
* (`where up_to_index < excluded.up_to_index`).
|
|
329
|
+
*/
|
|
330
|
+
putSnapshot(
|
|
331
|
+
sessionId: string,
|
|
332
|
+
reducerName: string,
|
|
333
|
+
index: number,
|
|
334
|
+
state: unknown,
|
|
335
|
+
): Promise<void>
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Optional ephemeral-plane capability (specs/a2-implementation.md
|
|
339
|
+
* §15.1) — optional like snapshots are. Values arrive already
|
|
340
|
+
* validated by core; adapters store them opaquely. Presence never
|
|
341
|
+
* touches the event log: no index, no history row, no recovery arm.
|
|
342
|
+
*/
|
|
343
|
+
presence?: {
|
|
344
|
+
/**
|
|
345
|
+
* Field-wise last-writer-wins merge of one participant's values:
|
|
346
|
+
* a field whose existing row has a strictly newer `at` (the
|
|
347
|
+
* sender's stamp) is left untouched; `null` deletes the row. Every
|
|
348
|
+
* applied write refreshes that field's expiry to the storage's own
|
|
349
|
+
* clock plus `ttlMs` — the sender stamp orders writes but never
|
|
350
|
+
* anchors their lifetime.
|
|
351
|
+
*/
|
|
352
|
+
set(
|
|
353
|
+
ns: string,
|
|
354
|
+
participant: string,
|
|
355
|
+
values: Record<string, unknown | null>,
|
|
356
|
+
meta: { seen: number; at: Date; ttlMs: number },
|
|
357
|
+
): Promise<void>
|
|
358
|
+
|
|
359
|
+
/** The current map, pruned of rows at or past their `expiresAt`. */
|
|
360
|
+
read(ns: string): Promise<PresenceRow[]>
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Push-tier patch delivery; present only on backends with a real
|
|
364
|
+
* broadcast primitive. Without it the backend is the degraded
|
|
365
|
+
* tier: live feeds surface presence by re-reading on their
|
|
366
|
+
* existing poll cadence.
|
|
367
|
+
*/
|
|
368
|
+
subscribe?(ns: string, onPatch: (patch: PresencePatch) => void): () => void
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Read-only operational data used by `experimental-a2/devtools/server`. */
|
|
372
|
+
inspect?: A2StoreInspection
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* A live feed of one session's events, starting after `startAfter`
|
|
376
|
+
* (exclusive). Transport is the backend's choice — in-process pub/sub,
|
|
377
|
+
* polling, LISTEN/NOTIFY — callers never branch on which. The iterable
|
|
378
|
+
* ends when the consumer calls `return()` (e.g. a disconnecting SSE
|
|
379
|
+
* client) and must deliver events appended after subscription.
|
|
380
|
+
*/
|
|
381
|
+
stream(
|
|
382
|
+
sessionId: string,
|
|
383
|
+
opts?: { startAfter?: number },
|
|
384
|
+
): AsyncIterable<Event>
|
|
385
|
+
}
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The A2Telemetry interface — the instrumentation seam. Same philosophy
|
|
3
|
+
* as store backends: the interface lives in core, implementations ship as
|
|
4
|
+
* entry points (`experimental-a2/otel` adapts it to OpenTelemetry). Without
|
|
5
|
+
* one, spans are free and swallowed errors print to the console.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Attribute values a2 emits. */
|
|
9
|
+
export type A2AttributeValue = string | number | boolean
|
|
10
|
+
|
|
11
|
+
/** The spans a2 emits today. The catalogue grows with the surface. */
|
|
12
|
+
export type A2SpanName = 'a2.append' | 'a2.drain' | 'a2.event' | 'a2.state'
|
|
13
|
+
|
|
14
|
+
export type A2SpanHandle = {
|
|
15
|
+
/** Attach or update an attribute mid-span (e.g. the outcome). */
|
|
16
|
+
setAttribute(key: string, value: A2AttributeValue): void
|
|
17
|
+
/**
|
|
18
|
+
* Mark the span failed without a2's control flow throwing — used for
|
|
19
|
+
* handler failures, which a2 swallows by design (they go into the
|
|
20
|
+
* retry machinery, not up the stack) but telemetry must still see.
|
|
21
|
+
*/
|
|
22
|
+
recordError(error: unknown): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type A2Telemetry = {
|
|
26
|
+
/**
|
|
27
|
+
* Wrap one unit of a2 work. Implementations should time it, record a
|
|
28
|
+
* thrown error as a failure, propagate context so nested spans tree
|
|
29
|
+
* up, and always return `fn`'s result (or rethrow its error) —
|
|
30
|
+
* telemetry observes, it never alters behavior.
|
|
31
|
+
*/
|
|
32
|
+
span<T>(
|
|
33
|
+
name: A2SpanName,
|
|
34
|
+
attributes: Record<string, A2AttributeValue>,
|
|
35
|
+
fn: (span: A2SpanHandle) => Promise<T>,
|
|
36
|
+
): Promise<T>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The default when no telemetry is configured. Spans cost nothing, but
|
|
41
|
+
* `recordError` prints: it only ever receives errors a2 swallows by
|
|
42
|
+
* design, so without a configured sink the console is the one place
|
|
43
|
+
* they can surface. Passing `telemetry` replaces this sink wholesale.
|
|
44
|
+
*/
|
|
45
|
+
export const CONSOLE_TELEMETRY: A2Telemetry = {
|
|
46
|
+
span: (name, attributes, fn) =>
|
|
47
|
+
fn({
|
|
48
|
+
setAttribute: () => {},
|
|
49
|
+
recordError: (error) => {
|
|
50
|
+
// oxlint-disable-next-line no-console -- the whole point: swallowed errors must surface without configuration
|
|
51
|
+
console.error(`[a2] error in ${name}`, attributes, error)
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline helpers for loading a captured A2 session and replaying its events
|
|
3
|
+
* through the application's current reducers. This module never constructs a
|
|
4
|
+
* server or dispatches handlers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile } from 'node:fs/promises'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import type { Contract, ContractEvent, EventDefs } from './contract.ts'
|
|
10
|
+
import {
|
|
11
|
+
parseDevtoolsCapture,
|
|
12
|
+
verifyDevtoolsCapture,
|
|
13
|
+
type DevtoolsCapture,
|
|
14
|
+
type DevtoolsCaptureEvent,
|
|
15
|
+
} from './devtools.ts'
|
|
16
|
+
import type { Reducer } from './reducer.ts'
|
|
17
|
+
import { validateSync } from './validate.ts'
|
|
18
|
+
|
|
19
|
+
export type CaptureSource = string | URL
|
|
20
|
+
|
|
21
|
+
export type LoadedCapture<D extends EventDefs> = {
|
|
22
|
+
/** The verified capture, including its footer and operational records. */
|
|
23
|
+
readonly capture: DevtoolsCapture
|
|
24
|
+
/** The contract used to validate and type the captured event history. */
|
|
25
|
+
readonly contract: Contract<D>
|
|
26
|
+
/** Contract-typed public events with `createdAt` revived to `Date`. */
|
|
27
|
+
readonly events: readonly ContractEvent<D>[]
|
|
28
|
+
/** Unmodified wire events, including dispatch and failure metadata. */
|
|
29
|
+
readonly rawEvents: readonly DevtoolsCaptureEvent[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ReplayCaptureOptions = {
|
|
33
|
+
/** Fold events through this log index, inclusive. Zero means no events. */
|
|
34
|
+
throughIndex?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type CaptureReplay<S> = {
|
|
38
|
+
readonly state: S
|
|
39
|
+
/** The last event index actually folded, or zero for the initial state. */
|
|
40
|
+
readonly index: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const captureError = (message: string): TypeError =>
|
|
44
|
+
new TypeError(`invalid A2 capture: ${message}`)
|
|
45
|
+
|
|
46
|
+
const typedEvents = <D extends EventDefs>(
|
|
47
|
+
capture: DevtoolsCapture,
|
|
48
|
+
contract: Contract<D>,
|
|
49
|
+
): ContractEvent<D>[] => {
|
|
50
|
+
const events: ContractEvent<D>[] = []
|
|
51
|
+
for (const event of capture.events) {
|
|
52
|
+
const schema = Object.hasOwn(contract.events, event.type)
|
|
53
|
+
? contract.events[event.type]
|
|
54
|
+
: undefined
|
|
55
|
+
if (schema === undefined) {
|
|
56
|
+
throw captureError(
|
|
57
|
+
`contract '${contract.name}' has no event type '${event.type}' at index ${event.index}`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
const result = validateSync(
|
|
61
|
+
schema,
|
|
62
|
+
structuredClone(event.payload),
|
|
63
|
+
`captured event '${event.type}'`,
|
|
64
|
+
)
|
|
65
|
+
if (result.issues !== undefined) {
|
|
66
|
+
throw captureError(
|
|
67
|
+
`event #${event.index} '${event.type}' has an invalid payload: ${result.issues
|
|
68
|
+
.map((issue) => issue.message)
|
|
69
|
+
.join('; ')}`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
events.push({
|
|
73
|
+
id: event.id,
|
|
74
|
+
type: event.type,
|
|
75
|
+
payload: result.value,
|
|
76
|
+
index: event.index,
|
|
77
|
+
sessionId: event.sessionId,
|
|
78
|
+
createdAt: new Date(event.createdAt),
|
|
79
|
+
} as ContractEvent<D>)
|
|
80
|
+
}
|
|
81
|
+
return events
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Validate an already-parsed capture against an application's contract. */
|
|
85
|
+
export async function prepareCapture<D extends EventDefs>(
|
|
86
|
+
capture: DevtoolsCapture,
|
|
87
|
+
contract: Contract<D>,
|
|
88
|
+
): Promise<LoadedCapture<D>> {
|
|
89
|
+
await verifyDevtoolsCapture(capture)
|
|
90
|
+
if (capture.contract !== contract.name) {
|
|
91
|
+
throw captureError(
|
|
92
|
+
`expected contract '${contract.name}', received '${capture.contract}'`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
capture,
|
|
97
|
+
contract,
|
|
98
|
+
events: typedEvents(capture, contract),
|
|
99
|
+
rawEvents: capture.events,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const displayUrl = (url: URL): string => {
|
|
104
|
+
if (url.protocol === 'data:') return 'data: URL'
|
|
105
|
+
const safe = new URL(url)
|
|
106
|
+
safe.username = ''
|
|
107
|
+
safe.password = ''
|
|
108
|
+
safe.search = ''
|
|
109
|
+
safe.hash = ''
|
|
110
|
+
return safe.href
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const fetchCapture = async (url: URL): Promise<Uint8Array> => {
|
|
114
|
+
const location = displayUrl(url)
|
|
115
|
+
let response: Response
|
|
116
|
+
try {
|
|
117
|
+
response = await fetch(url)
|
|
118
|
+
} catch {
|
|
119
|
+
throw new Error(`failed to load A2 capture from '${location}'`)
|
|
120
|
+
}
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`failed to load A2 capture from '${location}': ${response.status} ${response.statusText}`,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const readCapture = async (source: CaptureSource): Promise<Uint8Array> => {
|
|
130
|
+
if (source instanceof URL) {
|
|
131
|
+
return source.protocol === 'file:'
|
|
132
|
+
? readFile(fileURLToPath(source))
|
|
133
|
+
: fetchCapture(source)
|
|
134
|
+
}
|
|
135
|
+
if (/^[a-z]:[\\/]/i.test(source)) return readFile(source)
|
|
136
|
+
let url: URL | undefined
|
|
137
|
+
try {
|
|
138
|
+
url = new URL(source)
|
|
139
|
+
} catch {
|
|
140
|
+
return readFile(source)
|
|
141
|
+
}
|
|
142
|
+
return readCapture(url)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Load, integrity-check, and contract-type a `.a2log` file or URL. */
|
|
146
|
+
export async function loadCapture<D extends EventDefs>(
|
|
147
|
+
source: CaptureSource,
|
|
148
|
+
contract: Contract<D>,
|
|
149
|
+
): Promise<LoadedCapture<D>> {
|
|
150
|
+
const encoded = await readCapture(source)
|
|
151
|
+
const capture = parseDevtoolsCapture(encoded)
|
|
152
|
+
return prepareCapture(capture, contract)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const cloneInitial = <S>(initialState: S): S => {
|
|
156
|
+
try {
|
|
157
|
+
return structuredClone(initialState)
|
|
158
|
+
} catch {
|
|
159
|
+
return initialState
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Purely fold captured events through a reducer, optionally to a cutpoint. */
|
|
164
|
+
export function replayCapture<D extends EventDefs, S>(
|
|
165
|
+
loaded: LoadedCapture<D>,
|
|
166
|
+
reducer: Reducer<D, S>,
|
|
167
|
+
options: ReplayCaptureOptions = {},
|
|
168
|
+
): CaptureReplay<S> {
|
|
169
|
+
const { throughIndex } = options
|
|
170
|
+
if (
|
|
171
|
+
throughIndex !== undefined &&
|
|
172
|
+
(!Number.isInteger(throughIndex) || throughIndex < 0)
|
|
173
|
+
) {
|
|
174
|
+
throw new TypeError('throughIndex must be a non-negative integer')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let state = cloneInitial(reducer.initialState)
|
|
178
|
+
let index = 0
|
|
179
|
+
for (const event of loaded.events) {
|
|
180
|
+
if (throughIndex !== undefined && event.index > throughIndex) break
|
|
181
|
+
state = reducer.fold(state, event)
|
|
182
|
+
index = event.index
|
|
183
|
+
}
|
|
184
|
+
return { state, index }
|
|
185
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from './standard-schema.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run a Standard Schema validator, requiring a synchronous result.
|
|
5
|
+
* Validators must be synchronous (specs/a2-api.md §1) — an async one is
|
|
6
|
+
* a definition bug, rejected at definition time where possible and here
|
|
7
|
+
* as a backstop. `what` names the schema in error messages, e.g.
|
|
8
|
+
* "event 'created'" or "the stateSchema".
|
|
9
|
+
*/
|
|
10
|
+
export function validateSync<Input, Output>(
|
|
11
|
+
schema: StandardSchemaV1<Input, Output>,
|
|
12
|
+
value: unknown,
|
|
13
|
+
what: string,
|
|
14
|
+
): StandardSchemaV1.Result<Output> {
|
|
15
|
+
const result = schema['~standard'].validate(value)
|
|
16
|
+
if (result instanceof Promise) {
|
|
17
|
+
throw new TypeError(
|
|
18
|
+
`the schema for ${what} is asynchronous — a2 schemas must validate synchronously`,
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
return result
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Probe a schema to reject always-async validators early. The probe
|
|
26
|
+
* value is deliberately garbage — we only care whether the result is a
|
|
27
|
+
* Promise, not whether it passes.
|
|
28
|
+
*/
|
|
29
|
+
export function assertSyncSchema(schema: StandardSchemaV1, what: string): void {
|
|
30
|
+
const probe = Symbol('a2.schema-probe')
|
|
31
|
+
const result = schema['~standard'].validate(probe)
|
|
32
|
+
if (result instanceof Promise) {
|
|
33
|
+
// Swallow the eventual rejection — the probe input was garbage.
|
|
34
|
+
result.catch(() => {})
|
|
35
|
+
throw new TypeError(
|
|
36
|
+
`the schema for ${what} is asynchronous — a2 schemas must validate synchronously`,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
}
|