experimental-a2 0.3.0 → 0.5.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 +207 -0
- package/dist/ai-CFNeCrRl.d.ts +334 -0
- package/dist/ai-CFNeCrRl.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 +49 -9
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +123 -49
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +233 -86
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +599 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +7 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2 -0
- package/dist/client-BKlyLiOU.js +1008 -0
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/client-D7mvIXrF.d.ts +191 -0
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +152 -0
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +340 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +3 -1
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +4 -3
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +226 -43
- package/dist/devtools-server.js.map +1 -0
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +135 -28
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +647 -61
- package/dist/http.js.map +1 -0
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/internal-DstsI6Re.js +211 -0
- 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 +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +47 -15
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +30 -15
- 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 +79 -0
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +501 -0
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +56 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +228 -0
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/server-C72KOw51.js +1457 -0
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/server-DUF9pjsx.d.ts +261 -0
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +10 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +12 -0
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/{log-memory.js → store-memory.js} +145 -34
- package/dist/store-memory.js.map +1 -0
- package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/{log-postgres.js → store-postgres.js} +214 -24
- package/dist/store-postgres.js.map +1 -0
- package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
- package/dist/store-redis-http.js.map +1 -0
- package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/{log-redis.js → store-redis.js} +101 -39
- package/dist/store-redis.js.map +1 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
- package/dist/store-sqlite.js.map +1 -0
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +6 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +32 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +103 -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-BFQmSJ-9.js +284 -0
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/01-quickstart.mdx +3 -3
- package/docs/concepts/01-contracts.mdx +18 -13
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +21 -21
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -49
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +76 -57
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +310 -61
- package/docs/guides/06-ai-agents.mdx +252 -74
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +263 -0
- package/docs/guides/10-transports.mdx +140 -0
- package/docs/index.mdx +18 -14
- package/docs/reference/01-api.mdx +839 -110
- package/docs/reference/02-errors.mdx +13 -6
- package/package.json +24 -7
- 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 +47 -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/dist/internal-gCd5qMry.js +0 -44
- package/dist/log-memory.d.ts +0 -11
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-BcLa4RFL.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
package/src/wire.ts
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire format shared by experimental-a2/http (server side) and experimental-a2/client
|
|
3
|
+
* (browser side): events as JSON with ISO timestamps, and the A2Error
|
|
4
|
+
* envelope. Internal module — both entry points re-export what's
|
|
5
|
+
* public.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { A2Error, type A2ErrorCode } from './errors.ts'
|
|
9
|
+
import { nullProtoRecord } from './internal.ts'
|
|
10
|
+
import type { Event } from './store.ts'
|
|
11
|
+
import type {
|
|
12
|
+
PresenceMap,
|
|
13
|
+
PresencePatch,
|
|
14
|
+
PresenceSnapshot,
|
|
15
|
+
} from './contract.ts'
|
|
16
|
+
|
|
17
|
+
export type WireEvent = {
|
|
18
|
+
id: string
|
|
19
|
+
type: string
|
|
20
|
+
payload: unknown
|
|
21
|
+
index: number
|
|
22
|
+
sessionId: string
|
|
23
|
+
/** ISO 8601 — revived to a Date on the client so reducers see the
|
|
24
|
+
* same shape on both sides of the wire. */
|
|
25
|
+
createdAt: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function eventToWire(event: Event): WireEvent {
|
|
29
|
+
return {
|
|
30
|
+
id: event.id,
|
|
31
|
+
type: event.type,
|
|
32
|
+
payload: event.payload,
|
|
33
|
+
index: event.index,
|
|
34
|
+
sessionId: event.sessionId,
|
|
35
|
+
createdAt: event.createdAt.toISOString(),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function eventFromWire(wire: WireEvent): Event {
|
|
40
|
+
return {
|
|
41
|
+
id: wire.id,
|
|
42
|
+
type: wire.type,
|
|
43
|
+
payload: wire.payload,
|
|
44
|
+
index: wire.index,
|
|
45
|
+
sessionId: wire.sessionId,
|
|
46
|
+
createdAt: new Date(wire.createdAt),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isWireEvent(value: unknown): value is WireEvent {
|
|
51
|
+
if (value === null || typeof value !== 'object') return false
|
|
52
|
+
const v = value as Record<string, unknown>
|
|
53
|
+
return (
|
|
54
|
+
typeof v['id'] === 'string' &&
|
|
55
|
+
typeof v['type'] === 'string' &&
|
|
56
|
+
typeof v['index'] === 'number' &&
|
|
57
|
+
typeof v['sessionId'] === 'string' &&
|
|
58
|
+
typeof v['createdAt'] === 'string'
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── the presence wire pair ───────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** The `event: presence` SSE frame body: one patch as JSON. */
|
|
65
|
+
export type WirePresencePatch = {
|
|
66
|
+
participant: string
|
|
67
|
+
values: Record<string, unknown>
|
|
68
|
+
seen: number
|
|
69
|
+
/** ISO 8601 — revived to a Date on the client. */
|
|
70
|
+
at: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The `event: presence-snapshot` SSE frame body: the in-memory
|
|
75
|
+
* `PresenceSnapshot` shape verbatim, with each per-field `at`
|
|
76
|
+
* serialized to ISO 8601.
|
|
77
|
+
*/
|
|
78
|
+
export type WirePresenceSnapshot = {
|
|
79
|
+
snapshot: {
|
|
80
|
+
[participant: string]: {
|
|
81
|
+
[field: string]: { value: unknown; seen: number; at: string }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function presencePatchToWire(patch: PresencePatch): WirePresencePatch {
|
|
87
|
+
return {
|
|
88
|
+
participant: patch.participant,
|
|
89
|
+
values: patch.values,
|
|
90
|
+
seen: patch.seen,
|
|
91
|
+
at: patch.at.toISOString(),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function presencePatchFromWire(wire: WirePresencePatch): PresencePatch {
|
|
96
|
+
return {
|
|
97
|
+
participant: wire.participant,
|
|
98
|
+
values: wire.values,
|
|
99
|
+
seen: wire.seen,
|
|
100
|
+
at: new Date(wire.at),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Participant and field keys come off the wire, so every object keyed
|
|
105
|
+
// by them is built null-prototype — see `nullProtoRecord`.
|
|
106
|
+
export function presenceSnapshotToWire(
|
|
107
|
+
snapshot: PresenceSnapshot,
|
|
108
|
+
): WirePresenceSnapshot {
|
|
109
|
+
const wire: WirePresenceSnapshot['snapshot'] = nullProtoRecord()
|
|
110
|
+
for (const [participant, fields] of Object.entries(snapshot.snapshot)) {
|
|
111
|
+
const wireFields: WirePresenceSnapshot['snapshot'][string] =
|
|
112
|
+
nullProtoRecord()
|
|
113
|
+
for (const [field, entry] of Object.entries(fields)) {
|
|
114
|
+
if (entry === undefined) continue
|
|
115
|
+
wireFields[field] = {
|
|
116
|
+
value: entry.value,
|
|
117
|
+
seen: entry.seen,
|
|
118
|
+
at: entry.at.toISOString(),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
wire[participant] = wireFields
|
|
122
|
+
}
|
|
123
|
+
return { snapshot: wire }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function presenceSnapshotFromWire(
|
|
127
|
+
wire: WirePresenceSnapshot,
|
|
128
|
+
): PresenceSnapshot {
|
|
129
|
+
const map: PresenceMap = nullProtoRecord()
|
|
130
|
+
for (const [participant, fields] of Object.entries(wire.snapshot)) {
|
|
131
|
+
const revived: PresenceMap[string] = nullProtoRecord()
|
|
132
|
+
for (const [field, entry] of Object.entries(fields)) {
|
|
133
|
+
revived[field] = {
|
|
134
|
+
value: entry.value,
|
|
135
|
+
seen: entry.seen,
|
|
136
|
+
at: new Date(entry.at),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
map[participant] = revived
|
|
140
|
+
}
|
|
141
|
+
return { snapshot: map }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function isWirePresencePatch(
|
|
145
|
+
value: unknown,
|
|
146
|
+
): value is WirePresencePatch {
|
|
147
|
+
if (value === null || typeof value !== 'object') return false
|
|
148
|
+
const v = value as Record<string, unknown>
|
|
149
|
+
return (
|
|
150
|
+
typeof v['participant'] === 'string' &&
|
|
151
|
+
v['values'] !== null &&
|
|
152
|
+
typeof v['values'] === 'object' &&
|
|
153
|
+
!Array.isArray(v['values']) &&
|
|
154
|
+
typeof v['seen'] === 'number' &&
|
|
155
|
+
typeof v['at'] === 'string'
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isWirePresenceSnapshot(
|
|
160
|
+
value: unknown,
|
|
161
|
+
): value is WirePresenceSnapshot {
|
|
162
|
+
if (value === null || typeof value !== 'object') return false
|
|
163
|
+
const v = value as Record<string, unknown>
|
|
164
|
+
return (
|
|
165
|
+
v['snapshot'] !== null &&
|
|
166
|
+
typeof v['snapshot'] === 'object' &&
|
|
167
|
+
!Array.isArray(v['snapshot'])
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── the A2Error envelope ─────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
export type WireError = {
|
|
174
|
+
error: { code: A2ErrorCode; message: string; details?: unknown }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const ERROR_STATUS: Record<A2ErrorCode, number> = {
|
|
178
|
+
INVALID_PAYLOAD: 400,
|
|
179
|
+
UNKNOWN_EVENT_TYPE: 400,
|
|
180
|
+
PARTIAL_DUPLICATE_BATCH: 400,
|
|
181
|
+
SUPERSEDED_ATTEMPT: 409,
|
|
182
|
+
CLAIM_EXPIRED: 409,
|
|
183
|
+
STORE_UNAVAILABLE: 503,
|
|
184
|
+
STORE_NOT_CONFIGURED: 500,
|
|
185
|
+
UNKNOWN_PRESENCE_FIELD: 400,
|
|
186
|
+
PRESENCE_NOT_SUPPORTED: 500,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function errorStatus(code: A2ErrorCode): number {
|
|
190
|
+
return ERROR_STATUS[code]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function errorToWire(error: A2Error): WireError {
|
|
194
|
+
const body: WireError = {
|
|
195
|
+
error: { code: error.code, message: error.message },
|
|
196
|
+
}
|
|
197
|
+
if (error.details !== undefined) body.error.details = error.details
|
|
198
|
+
return body
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Wrap an arbitrary thrown value for the wire: A2Errors pass through,
|
|
202
|
+
* anything else becomes STORE_UNAVAILABLE — from the client's
|
|
203
|
+
* perspective an unknown server failure is retryable-once, not a
|
|
204
|
+
* protocol contract. */
|
|
205
|
+
export function asA2Error(error: unknown): A2Error {
|
|
206
|
+
return error instanceof A2Error
|
|
207
|
+
? error
|
|
208
|
+
: new A2Error('STORE_UNAVAILABLE', 'internal error', { cause: error })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Rebuild an A2Error from a wire body; null if the body isn't one. */
|
|
212
|
+
export function errorFromWire(body: unknown): A2Error | null {
|
|
213
|
+
if (body === null || typeof body !== 'object') return null
|
|
214
|
+
const err = (body as { error?: unknown }).error
|
|
215
|
+
if (err === null || typeof err !== 'object') return null
|
|
216
|
+
const { code, message, details } = err as Record<string, unknown>
|
|
217
|
+
if (typeof code !== 'string' || !Object.hasOwn(ERROR_STATUS, code))
|
|
218
|
+
return null
|
|
219
|
+
return new A2Error(code as A2ErrorCode, String(message ?? code), {
|
|
220
|
+
details,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── the ws frame layer ───────────────────────────────────────────────
|
|
225
|
+
// The SSE lanes reframed for a socket (specs/a2-api.md §13): every
|
|
226
|
+
// message is one JSON text frame, and a frame is its wire payload plus
|
|
227
|
+
// a `kind` discriminant (plus `req` where a reply must correlate).
|
|
228
|
+
// Unknown kinds are skipped by both sides — the same
|
|
229
|
+
// forward-compatibility rule as named SSE frames.
|
|
230
|
+
//
|
|
231
|
+
// The multiplexed superset rides the same frames: `subscribe` /
|
|
232
|
+
// `unsubscribe` up-frames open and close per-session lanes on one
|
|
233
|
+
// socket, `sessionId` tags route everything else. Single-session
|
|
234
|
+
// frames (no tags) keep parsing unchanged — a route opts into
|
|
235
|
+
// multiplexing by choosing the multi-session server handler, never by
|
|
236
|
+
// breaking the old protocol.
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Server → client, parsed: a live-stream item, a push ack, a
|
|
240
|
+
* subscription lifecycle notice, or the heartbeat. `parseSocketFrame`
|
|
241
|
+
* yields these; frames of unknown kind (or ones failing their shape
|
|
242
|
+
* guard) come back `null`. `sessionId` is present on frames from a
|
|
243
|
+
* multiplexed socket and absent on a single-session one.
|
|
244
|
+
*/
|
|
245
|
+
export type SocketDownFrame =
|
|
246
|
+
| { kind: 'event'; event: WireEvent }
|
|
247
|
+
| { kind: 'presence'; patch: WirePresencePatch; sessionId?: string }
|
|
248
|
+
| {
|
|
249
|
+
kind: 'presence-snapshot'
|
|
250
|
+
snapshot: WirePresenceSnapshot
|
|
251
|
+
sessionId?: string
|
|
252
|
+
}
|
|
253
|
+
| { kind: 'ack'; req: number; events: WireEvent[]; sessionId?: string }
|
|
254
|
+
| { kind: 'ack'; req: number; error: A2Error; sessionId?: string }
|
|
255
|
+
| { kind: 'subscribed'; sessionId: string }
|
|
256
|
+
| { kind: 'unsubscribed'; sessionId: string; reason?: string }
|
|
257
|
+
| { kind: 'ping' }
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Client → server: the push envelope's two planes. `sessionId` is
|
|
261
|
+
* implied by the socket on a single-session connection and required by
|
|
262
|
+
* the multiplexed handler. Plain JSON on the wire; the server parses
|
|
263
|
+
* and validates them through the same seams as `parsePushBody`.
|
|
264
|
+
*/
|
|
265
|
+
export type SocketPushFrame = {
|
|
266
|
+
kind: 'push'
|
|
267
|
+
/** Client-local ack correlator — opaque to the server. */
|
|
268
|
+
req: number
|
|
269
|
+
events: Array<{ type: string; payload: unknown; id?: string }>
|
|
270
|
+
sessionId?: string
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export type SocketPresenceFrame = {
|
|
274
|
+
kind: 'presence'
|
|
275
|
+
participant: string
|
|
276
|
+
values: Record<string, unknown>
|
|
277
|
+
seen?: number
|
|
278
|
+
at?: number
|
|
279
|
+
sessionId?: string
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Open per-session lanes on a multiplexed socket. `index` is each
|
|
283
|
+
* session's exclusive resume frontier — `stream({ startAfter })`. */
|
|
284
|
+
export type SocketSubscribeFrame = {
|
|
285
|
+
kind: 'subscribe'
|
|
286
|
+
sessions: Array<{ id: string; index: number }>
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export type SocketUnsubscribeFrame = {
|
|
290
|
+
kind: 'unsubscribe'
|
|
291
|
+
sessions: string[]
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export type SocketUpFrame =
|
|
295
|
+
| SocketPushFrame
|
|
296
|
+
| SocketPresenceFrame
|
|
297
|
+
| SocketSubscribeFrame
|
|
298
|
+
| SocketUnsubscribeFrame
|
|
299
|
+
|
|
300
|
+
export const SOCKET_PING_FRAME: string = JSON.stringify({ kind: 'ping' })
|
|
301
|
+
|
|
302
|
+
/** One stream item as a socket frame — `sseResponse`'s framing over
|
|
303
|
+
* the same codecs, with `kind` instead of an SSE event name. On a
|
|
304
|
+
* multiplexed socket presence frames carry the `sessionId` tag; events
|
|
305
|
+
* already carry theirs in the wire event. */
|
|
306
|
+
export function socketFrameFor(
|
|
307
|
+
item: Event | PresencePatch | PresenceSnapshot,
|
|
308
|
+
sessionId?: string,
|
|
309
|
+
): string {
|
|
310
|
+
const tag = sessionId === undefined ? {} : { sessionId }
|
|
311
|
+
if ('snapshot' in item) {
|
|
312
|
+
return JSON.stringify({
|
|
313
|
+
kind: 'presence-snapshot',
|
|
314
|
+
...presenceSnapshotToWire(item),
|
|
315
|
+
...tag,
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
if ('participant' in item) {
|
|
319
|
+
return JSON.stringify({
|
|
320
|
+
kind: 'presence',
|
|
321
|
+
...presencePatchToWire(item),
|
|
322
|
+
...tag,
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
return JSON.stringify({ kind: 'event', ...eventToWire(item) })
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function socketAckFor(
|
|
329
|
+
req: number,
|
|
330
|
+
events: Event[],
|
|
331
|
+
sessionId?: string,
|
|
332
|
+
): string {
|
|
333
|
+
return JSON.stringify({
|
|
334
|
+
kind: 'ack',
|
|
335
|
+
req,
|
|
336
|
+
events: events.map(eventToWire),
|
|
337
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function socketErrorAckFor(
|
|
342
|
+
req: number,
|
|
343
|
+
error: A2Error,
|
|
344
|
+
sessionId?: string,
|
|
345
|
+
): string {
|
|
346
|
+
return JSON.stringify({
|
|
347
|
+
kind: 'ack',
|
|
348
|
+
req,
|
|
349
|
+
...errorToWire(error),
|
|
350
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function socketSubscribedFor(sessionId: string): string {
|
|
355
|
+
return JSON.stringify({ kind: 'subscribed', sessionId })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The subscription-over notice: with a `reason` the server rejected
|
|
359
|
+
* or lost the session's stream, without one it ended cleanly. */
|
|
360
|
+
export function socketUnsubscribedFor(
|
|
361
|
+
sessionId: string,
|
|
362
|
+
reason?: string,
|
|
363
|
+
): string {
|
|
364
|
+
return JSON.stringify({
|
|
365
|
+
kind: 'unsubscribed',
|
|
366
|
+
sessionId,
|
|
367
|
+
...(reason === undefined ? {} : { reason }),
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function parseSocketFrame(data: string): SocketDownFrame | null {
|
|
372
|
+
let parsed: unknown
|
|
373
|
+
try {
|
|
374
|
+
parsed = JSON.parse(data)
|
|
375
|
+
} catch {
|
|
376
|
+
return null
|
|
377
|
+
}
|
|
378
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
379
|
+
return null
|
|
380
|
+
}
|
|
381
|
+
const frame = parsed as Record<string, unknown>
|
|
382
|
+
switch (frame['kind']) {
|
|
383
|
+
case 'ping':
|
|
384
|
+
return { kind: 'ping' }
|
|
385
|
+
case 'event': {
|
|
386
|
+
if (!isWireEvent(parsed)) return null
|
|
387
|
+
const { id, type, payload, index, sessionId, createdAt } = parsed
|
|
388
|
+
return {
|
|
389
|
+
kind: 'event',
|
|
390
|
+
event: { id, type, payload, index, sessionId, createdAt },
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
case 'presence': {
|
|
394
|
+
if (!isWirePresencePatch(parsed)) return null
|
|
395
|
+
const { participant, values, seen, at } = parsed
|
|
396
|
+
return {
|
|
397
|
+
kind: 'presence',
|
|
398
|
+
patch: { participant, values, seen, at },
|
|
399
|
+
...sessionTag(frame),
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
case 'presence-snapshot': {
|
|
403
|
+
if (!isWirePresenceSnapshot(parsed)) return null
|
|
404
|
+
return {
|
|
405
|
+
kind: 'presence-snapshot',
|
|
406
|
+
snapshot: { snapshot: parsed.snapshot },
|
|
407
|
+
...sessionTag(frame),
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
case 'subscribed': {
|
|
411
|
+
const sessionId = frame['sessionId']
|
|
412
|
+
if (typeof sessionId !== 'string') return null
|
|
413
|
+
return { kind: 'subscribed', sessionId }
|
|
414
|
+
}
|
|
415
|
+
case 'unsubscribed': {
|
|
416
|
+
const sessionId = frame['sessionId']
|
|
417
|
+
if (typeof sessionId !== 'string') return null
|
|
418
|
+
const reason = frame['reason']
|
|
419
|
+
return typeof reason === 'string'
|
|
420
|
+
? { kind: 'unsubscribed', sessionId, reason }
|
|
421
|
+
: { kind: 'unsubscribed', sessionId }
|
|
422
|
+
}
|
|
423
|
+
case 'ack': {
|
|
424
|
+
const req = frame['req']
|
|
425
|
+
if (typeof req !== 'number') return null
|
|
426
|
+
const tag = sessionTag(frame)
|
|
427
|
+
const events = frame['events']
|
|
428
|
+
if (Array.isArray(events) && events.every(isWireEvent)) {
|
|
429
|
+
return { kind: 'ack', req, events, ...tag }
|
|
430
|
+
}
|
|
431
|
+
const error = errorFromWire(parsed)
|
|
432
|
+
if (error) return { kind: 'ack', req, error, ...tag }
|
|
433
|
+
// A correlatable ack must never be dropped: its waiter would hang
|
|
434
|
+
// forever behind a healthy socket (pings keep the watchdog fed).
|
|
435
|
+
// An outcome this client cannot interpret — an error code from a
|
|
436
|
+
// newer server, an event shape that fails a guard — degrades to
|
|
437
|
+
// a lost ack: retryable, and the client-generated ids make the
|
|
438
|
+
// retry idempotent even if the append actually committed.
|
|
439
|
+
return {
|
|
440
|
+
kind: 'ack',
|
|
441
|
+
req,
|
|
442
|
+
error: new A2Error('STORE_UNAVAILABLE', 'unintelligible ack'),
|
|
443
|
+
...tag,
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
default:
|
|
447
|
+
return null
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const sessionTag = (frame: Record<string, unknown>): { sessionId?: string } => {
|
|
452
|
+
const sessionId = frame['sessionId']
|
|
453
|
+
return typeof sessionId === 'string' ? { sessionId } : {}
|
|
454
|
+
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
//#region src/internal.ts
|
|
2
|
-
const serverInternals = /* @__PURE__ */ new WeakMap();
|
|
3
|
-
/**
|
|
4
|
-
* Drain timing knobs. Mutable only as a white-box test seam. Claim
|
|
5
|
-
* heartbeats run on real timers, so tests shrink these to keep suites
|
|
6
|
-
* fast. Production always uses the defaults.
|
|
7
|
-
*/
|
|
8
|
-
const DRAIN_TIMINGS = {
|
|
9
|
-
claimTtlMs: 5e3,
|
|
10
|
-
claimHeartbeatMs: 2e3,
|
|
11
|
-
recoveryGraceMs: 1e3,
|
|
12
|
-
recoveryArmTimeoutMs: 2e3
|
|
13
|
-
};
|
|
14
|
-
/**
|
|
15
|
-
* Stream liveness knobs — the server-side SSE heartbeat and the
|
|
16
|
-
* client-side stall watchdog. Paired by design: the client declares a
|
|
17
|
-
* connection dead after roughly two missed heartbeats. Mutable only as
|
|
18
|
-
* a white-box test seam; production always uses the defaults.
|
|
19
|
-
*/
|
|
20
|
-
/**
|
|
21
|
-
* Poll cadence for the poll-based log streams (sqlite, postgres) —
|
|
22
|
-
* owned, not configurable: adaptive polling removed the operator's
|
|
23
|
-
* reason to tune it (the floor keeps active streams smooth; the
|
|
24
|
-
* ceiling bounds idle cost). Mutable only as a white-box test seam so
|
|
25
|
-
* suites don't wait out real idle gaps.
|
|
26
|
-
*/
|
|
27
|
-
const POLL_TIMINGS = {
|
|
28
|
-
activeFloorMs: 25,
|
|
29
|
-
idleCeilingMs: 250
|
|
30
|
-
};
|
|
31
|
-
/**
|
|
32
|
-
* Safety-read cadence for the notify-driven redis live stream. A
|
|
33
|
-
* parked feed re-reads the log at this interval even without a
|
|
34
|
-
* pub/sub wake-up, so a lost notification (subscriber reconnect gap,
|
|
35
|
-
* dropped PUBLISH) costs latency, never an event. Mutable only as a
|
|
36
|
-
* white-box test seam.
|
|
37
|
-
*/
|
|
38
|
-
const NOTIFY_TIMINGS = { safetyReadMs: 1e4 };
|
|
39
|
-
const STREAM_TIMINGS = {
|
|
40
|
-
sseHeartbeatMs: 15e3,
|
|
41
|
-
stallTimeoutMs: 35e3
|
|
42
|
-
};
|
|
43
|
-
//#endregion
|
|
44
|
-
export { serverInternals as a, STREAM_TIMINGS as i, NOTIFY_TIMINGS as n, POLL_TIMINGS as r, DRAIN_TIMINGS as t };
|
package/dist/log-memory.d.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { c as IdSource, i as Clock, t as A2Log } from "./log-ldf5g8Cx.js";
|
|
2
|
-
//#region src/log-memory.d.ts
|
|
3
|
-
type MemoryLogOptions = {
|
|
4
|
-
/** Injectable clock — every stored timestamp comes from here. */
|
|
5
|
-
clock?: Clock;
|
|
6
|
-
/** Injectable id source for generated event ids. */
|
|
7
|
-
ids?: IdSource;
|
|
8
|
-
};
|
|
9
|
-
declare function memory(options?: MemoryLogOptions): A2Log;
|
|
10
|
-
//#endregion
|
|
11
|
-
export { MemoryLogOptions, memory };
|
package/dist/recovery-vercel.js
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { a as serverInternals, t as DRAIN_TIMINGS } from "./internal-gCd5qMry.js";
|
|
2
|
-
import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
|
|
3
|
-
import { n as SYSTEM_CLOCK } from "./log-yJbXUf72.js";
|
|
4
|
-
//#region src/recovery-vercel.ts
|
|
5
|
-
/**
|
|
6
|
-
* experimental-a2/recovery-vercel — queue-backed recovery over Vercel Queues.
|
|
7
|
-
*
|
|
8
|
-
* `vercelQueues()` returns an `A2Recovery` (a2-implementation.md §9):
|
|
9
|
-
*
|
|
10
|
-
* - `arm` rounds the requested watchdog time up to a one-second slot.
|
|
11
|
-
* One message per `(contract, session, dueAt)` coalesces appends,
|
|
12
|
-
* claim renewals and racing callbacks. `DuplicateMessageError` is
|
|
13
|
-
* success because that due-time slot is already durable.
|
|
14
|
-
* - `handler(...servers)` is the delivery route: look up the server for
|
|
15
|
-
* the contract named in the message and drain it. Settled drains ack;
|
|
16
|
-
* contention arms a later watchdog before acking; handler failure keeps
|
|
17
|
-
* the current message and redelivers with backoff.
|
|
18
|
-
*
|
|
19
|
-
* `@vercel/queue` is an optional peer dependency, imported lazily. The
|
|
20
|
-
* `transport` option is the injection seam — tests run a real little
|
|
21
|
-
* in-memory queue through the same interface.
|
|
22
|
-
*/
|
|
23
|
-
/**
|
|
24
|
-
* The "not settled yet" signal for a stalled handler — an expected state,
|
|
25
|
-
* not a transport failure. The default transport classifies it in
|
|
26
|
-
* `@vercel/queue`'s `retry` option and reschedules the delivery quietly
|
|
27
|
-
* (`{ afterSeconds }` → visibility change → 200 response, no error log)
|
|
28
|
-
* instead of letting it surface as a thrown 500. Busy deliveries use a
|
|
29
|
-
* fresh watchdog instead. Custom transports should treat this class the
|
|
30
|
-
* same way when their protocol has a polite reschedule.
|
|
31
|
-
*/
|
|
32
|
-
var RecoveryUnsettled = class extends Error {};
|
|
33
|
-
/** Vercel Queues rejects shorter visibility changes. */
|
|
34
|
-
const MIN_VISIBILITY_SECONDS = 30;
|
|
35
|
-
/** Redelivery backoff for retry states: base, 2×, 4×… bounded. */
|
|
36
|
-
const unsettledBackoffSeconds = (delaySeconds, deliveryCount) => Math.min(Math.max(delaySeconds * 2 ** Math.max(deliveryCount - 1, 0), MIN_VISIBILITY_SECONDS), 60);
|
|
37
|
-
const vercelQueueTransport = async (delaySeconds) => {
|
|
38
|
-
const queue = await import("@vercel/queue").catch(() => {
|
|
39
|
-
throw new Error("experimental-a2/recovery-vercel needs the '@vercel/queue' package (optional peer dependency) — install it, or inject a transport");
|
|
40
|
-
});
|
|
41
|
-
return {
|
|
42
|
-
send: (topic, message, options) => queue.send(topic, message, options),
|
|
43
|
-
handleCallback: (handler) => queue.handleCallback(handler, {
|
|
44
|
-
visibilityTimeoutSeconds: MIN_VISIBILITY_SECONDS,
|
|
45
|
-
retry: (error, metadata) => error instanceof RecoveryUnsettled ? { afterSeconds: unsettledBackoffSeconds(delaySeconds, metadata.deliveryCount) } : void 0
|
|
46
|
-
}),
|
|
47
|
-
isDuplicate: (error) => error instanceof queue.DuplicateMessageError
|
|
48
|
-
};
|
|
49
|
-
};
|
|
50
|
-
function vercelQueues(options = {}) {
|
|
51
|
-
const topic = options.topic ?? "a2";
|
|
52
|
-
const delaySeconds = options.delaySeconds ?? 5;
|
|
53
|
-
const clock = options.clock ?? SYSTEM_CLOCK;
|
|
54
|
-
const transport = retryableLazy(() => options.transport ? Promise.resolve(options.transport) : vercelQueueTransport(delaySeconds)).get;
|
|
55
|
-
const sendWatchdog = async (opts) => {
|
|
56
|
-
const t = await transport();
|
|
57
|
-
const nowMs = clock.now().getTime();
|
|
58
|
-
const requestedDueAt = opts.dueAt ?? nowMs + delaySeconds * 1e3;
|
|
59
|
-
const dueAt = Math.ceil(requestedDueAt / 1e3) * 1e3;
|
|
60
|
-
const recoveryId = `${opts.contract}:${opts.sessionId}:watchdog:${dueAt}`;
|
|
61
|
-
try {
|
|
62
|
-
await t.send(topic, {
|
|
63
|
-
contract: opts.contract,
|
|
64
|
-
sessionId: opts.sessionId,
|
|
65
|
-
dueAt
|
|
66
|
-
}, {
|
|
67
|
-
delaySeconds: Math.max(0, Math.ceil((dueAt - nowMs) / 1e3)),
|
|
68
|
-
idempotencyKey: recoveryId
|
|
69
|
-
});
|
|
70
|
-
} catch (err) {
|
|
71
|
-
if (t.isDuplicate(err)) return;
|
|
72
|
-
throw err;
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
const successorDueAt = (message) => {
|
|
76
|
-
const minimumDueAt = clock.now().getTime() + DRAIN_TIMINGS.claimTtlMs + DRAIN_TIMINGS.recoveryGraceMs;
|
|
77
|
-
if (message.dueAt === void 0) return minimumDueAt;
|
|
78
|
-
const beats = Math.max(1, Math.ceil((minimumDueAt - message.dueAt) / DRAIN_TIMINGS.claimHeartbeatMs));
|
|
79
|
-
return message.dueAt + beats * DRAIN_TIMINGS.claimHeartbeatMs;
|
|
80
|
-
};
|
|
81
|
-
return {
|
|
82
|
-
async arm({ contract, sessionId, dueAt }) {
|
|
83
|
-
await sendWatchdog({
|
|
84
|
-
contract,
|
|
85
|
-
sessionId,
|
|
86
|
-
...dueAt !== void 0 ? { dueAt } : {}
|
|
87
|
-
});
|
|
88
|
-
},
|
|
89
|
-
handler(...servers) {
|
|
90
|
-
const byContract = /* @__PURE__ */ new Map();
|
|
91
|
-
for (const server of servers) byContract.set(server.contract.name, server);
|
|
92
|
-
return async (req) => {
|
|
93
|
-
return (await transport()).handleCallback(async (message) => {
|
|
94
|
-
const server = byContract.get(message.contract);
|
|
95
|
-
if (!server) throw new Error(`a2 recovery: no server for contract '${message.contract}' — pass it to recovery.handler(...)`);
|
|
96
|
-
const internals = serverInternals.get(server);
|
|
97
|
-
const nextDueAt = successorDueAt(message);
|
|
98
|
-
const result = internals ? await internals.recoveryDrain(message.sessionId, { recoveryDueAt: nextDueAt }) : {
|
|
99
|
-
...await server.drain(message.sessionId),
|
|
100
|
-
outcome: "unknown"
|
|
101
|
-
};
|
|
102
|
-
if (result.settled) return;
|
|
103
|
-
if (result.outcome === "busy") {
|
|
104
|
-
if (result.recoveryArm) await result.recoveryArm;
|
|
105
|
-
else await sendWatchdog({
|
|
106
|
-
contract: message.contract,
|
|
107
|
-
sessionId: message.sessionId,
|
|
108
|
-
dueAt: nextDueAt
|
|
109
|
-
});
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
throw new RecoveryUnsettled(`a2 recovery: session '${message.sessionId}' of '${message.contract}' not settled yet (${result.outcome}) — rescheduled for redelivery`);
|
|
113
|
-
})(req);
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
//#endregion
|
|
119
|
-
export { RecoveryUnsettled, vercelQueues };
|