experimental-a2 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/ai-server.d.ts +4 -5
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +8 -7
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +334 -2
  7. package/dist/ai.d.ts.map +1 -0
  8. package/dist/ai.js +1 -1
  9. package/dist/client.d.ts +202 -2
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +1025 -1
  12. package/dist/client.js.map +1 -0
  13. package/dist/errors-BQuJpe82.js.map +1 -1
  14. package/dist/index.d.ts +22 -3
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
  17. package/dist/internal-DRXJ56EI.js.map +1 -0
  18. package/dist/react.d.ts +1 -1
  19. package/dist/react.js +1 -1
  20. package/dist/scheduler-qstash.d.ts +3 -3
  21. package/dist/scheduler-qstash.js +4 -5
  22. package/dist/scheduler-qstash.js.map +1 -1
  23. package/dist/scheduler-vercel.d.ts +2 -2
  24. package/dist/scheduler-vercel.js +4 -4
  25. package/dist/scheduler-vercel.js.map +1 -1
  26. package/dist/{server-Duw6MVlB.js → server-286j79Mt.js} +708 -79
  27. package/dist/server-286j79Mt.js.map +1 -0
  28. package/dist/{server-DpvjhdoE.d.ts → server-DgXmORIq.d.ts} +67 -49
  29. package/dist/server-DgXmORIq.d.ts.map +1 -0
  30. package/dist/server.d.ts +3 -3
  31. package/dist/server.js +1 -1
  32. package/dist/store-N8PXxDAS.js.map +1 -1
  33. package/dist/{store-DysUkTH3.d.ts → store-flRz1OWh.d.ts} +2 -57
  34. package/dist/store-flRz1OWh.d.ts.map +1 -0
  35. package/dist/store-memory.d.ts +1 -1
  36. package/dist/store-memory.d.ts.map +1 -1
  37. package/dist/store-memory.js +1 -59
  38. package/dist/store-memory.js.map +1 -1
  39. package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
  40. package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
  41. package/dist/store-postgres.d.ts +1 -1
  42. package/dist/store-postgres.js +1 -81
  43. package/dist/store-postgres.js.map +1 -1
  44. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DEYO8Ryv.js} +48 -134
  45. package/dist/store-redis-core-DEYO8Ryv.js.map +1 -0
  46. package/dist/store-redis-http.d.ts +1 -1
  47. package/dist/store-redis-http.js +2 -3
  48. package/dist/store-redis-http.js.map +1 -1
  49. package/dist/store-redis.d.ts +1 -1
  50. package/dist/store-redis.js +3 -4
  51. package/dist/store-redis.js.map +1 -1
  52. package/dist/store-sqlite.d.ts +1 -1
  53. package/dist/store-sqlite.d.ts.map +1 -1
  54. package/dist/store-sqlite.js +1 -72
  55. package/dist/store-sqlite.js.map +1 -1
  56. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  57. package/dist/wire-B6te_wns.js.map +1 -0
  58. package/docs/guides/03-react.mdx +20 -28
  59. package/docs/guides/05-production.mdx +9 -11
  60. package/docs/guides/06-ai-agents.mdx +8 -14
  61. package/docs/guides/09-presence.mdx +14 -19
  62. package/docs/guides/10-transports.mdx +104 -86
  63. package/docs/reference/01-api.mdx +143 -277
  64. package/docs/reference/02-errors.mdx +5 -7
  65. package/package.json +1 -14
  66. package/src/ai-server.ts +9 -5
  67. package/src/client.ts +2 -2
  68. package/src/errors.ts +1 -0
  69. package/src/internal.ts +3 -62
  70. package/src/push-envelope.ts +24 -21
  71. package/src/scheduler-qstash.ts +3 -3
  72. package/src/scheduler-vercel.ts +2 -2
  73. package/src/server-fetch.ts +344 -0
  74. package/src/server.ts +73 -225
  75. package/src/session-socket.ts +36 -20
  76. package/src/sse.ts +2 -2
  77. package/src/store-memory.ts +0 -81
  78. package/src/store-postgres.ts +0 -100
  79. package/src/store-redis-core.ts +47 -211
  80. package/src/store-redis-http.ts +0 -1
  81. package/src/store-redis.ts +0 -1
  82. package/src/store-sqlite.ts +0 -119
  83. package/src/store.ts +0 -60
  84. package/src/wire.ts +2 -1
  85. package/dist/ai-D_PGS-JR.d.ts +0 -334
  86. package/dist/ai-D_PGS-JR.d.ts.map +0 -1
  87. package/dist/cli-B3VuxoDe.js +0 -599
  88. package/dist/cli-B3VuxoDe.js.map +0 -1
  89. package/dist/cli-bin.d.ts +0 -1
  90. package/dist/cli-bin.js +0 -7
  91. package/dist/cli-bin.js.map +0 -1
  92. package/dist/cli.d.ts +0 -20
  93. package/dist/cli.d.ts.map +0 -1
  94. package/dist/cli.js +0 -2
  95. package/dist/client-CdMqi7mC.d.ts +0 -202
  96. package/dist/client-CdMqi7mC.d.ts.map +0 -1
  97. package/dist/client-Dj5d3SP_.js +0 -1026
  98. package/dist/client-Dj5d3SP_.js.map +0 -1
  99. package/dist/devtools-J_jZ2vQf.d.ts +0 -152
  100. package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
  101. package/dist/devtools-kJJaORn-.js +0 -340
  102. package/dist/devtools-kJJaORn-.js.map +0 -1
  103. package/dist/devtools-server.browser.d.ts +0 -1
  104. package/dist/devtools-server.browser.js +0 -6
  105. package/dist/devtools-server.browser.js.map +0 -1
  106. package/dist/devtools-server.d.ts +0 -23
  107. package/dist/devtools-server.d.ts.map +0 -1
  108. package/dist/devtools-server.js +0 -1270
  109. package/dist/devtools-server.js.map +0 -1
  110. package/dist/devtools.d.ts +0 -2
  111. package/dist/devtools.js +0 -2
  112. package/dist/errors-W6nwJ-fm.d.ts +0 -21
  113. package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
  114. package/dist/http.d.ts +0 -151
  115. package/dist/http.d.ts.map +0 -1
  116. package/dist/http.js +0 -706
  117. package/dist/http.js.map +0 -1
  118. package/dist/inspection-DaxB5jM2.js +0 -13
  119. package/dist/inspection-DaxB5jM2.js.map +0 -1
  120. package/dist/internal-DstsI6Re.js.map +0 -1
  121. package/dist/platform-B4TnJtWu.js +0 -34
  122. package/dist/platform-B4TnJtWu.js.map +0 -1
  123. package/dist/server-DpvjhdoE.d.ts.map +0 -1
  124. package/dist/server-Duw6MVlB.js.map +0 -1
  125. package/dist/store-DysUkTH3.d.ts.map +0 -1
  126. package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
  127. package/dist/testing.browser.d.ts +0 -1
  128. package/dist/testing.browser.js +0 -6
  129. package/dist/testing.browser.js.map +0 -1
  130. package/dist/testing.d.ts +0 -32
  131. package/dist/testing.d.ts.map +0 -1
  132. package/dist/testing.js +0 -103
  133. package/dist/testing.js.map +0 -1
  134. package/dist/wire-BFQmSJ-9.js.map +0 -1
  135. package/docs/guides/07-devtools.mdx +0 -229
  136. package/src/cli-bin.ts +0 -5
  137. package/src/cli.ts +0 -1046
  138. package/src/devtools-app.ts +0 -989
  139. package/src/devtools-server.browser.ts +0 -5
  140. package/src/devtools-server.ts +0 -604
  141. package/src/devtools.ts +0 -716
  142. package/src/http.ts +0 -394
  143. package/src/inspection.ts +0 -39
  144. package/src/testing.browser.ts +0 -5
  145. package/src/testing.ts +0 -185
package/src/server.ts CHANGED
@@ -28,11 +28,9 @@ import {
28
28
  markSchedulerSendFailure,
29
29
  nullProtoRecord,
30
30
  serverInternals,
31
- serverSchedulerBindings,
32
31
  type DrainOutcome,
33
32
  type DrainResult,
34
33
  } from './internal.ts'
35
- import { InspectionUnsupportedError, serverInspection } from './inspection.ts'
36
34
  import { defaultSleep } from './store-polling.ts'
37
35
  import type {
38
36
  A2Store,
@@ -46,6 +44,14 @@ import type {
46
44
  StoredEvent,
47
45
  } from './store.ts'
48
46
  import type { Reducer } from './reducer.ts'
47
+ import {
48
+ createServerFetch,
49
+ type A2Operation,
50
+ type A2PushEvent,
51
+ type A2PushPresence,
52
+ type ServerFetchOptions,
53
+ type UpgradeWebSocket,
54
+ } from './server-fetch.ts'
49
55
  import { validateSync } from './validate.ts'
50
56
  import {
51
57
  deterministicEventId,
@@ -66,45 +72,6 @@ import {
66
72
  type A2Telemetry,
67
73
  } from './telemetry.ts'
68
74
 
69
- /**
70
- * Events that arrived over the wire through `parsePushBody` — already
71
- * envelope-validated, headed for schema validation inside `append`.
72
- * The brand lets the documented push route hand them straight to
73
- * `session.append` without weakening typed appends for app code: a
74
- * hand-written `{ type: string }` literal still fails to compile.
75
- */
76
- export type PushedEvent = {
77
- type: string
78
- payload: unknown
79
- id?: string
80
- readonly '~a2.pushed': true
81
- }
82
-
83
- /**
84
- * A presence patch that arrived over the wire through `parsePushBody`
85
- * — same provenance brand as `PushedEvent`, so the documented route
86
- * hands it whole to `session.setPresence` while a hand-written
87
- * untyped patch still fails to compile. Field validation happens
88
- * inside `setPresence`.
89
- */
90
- export type PushedPresence = {
91
- participant: string
92
- values: Record<string, unknown>
93
- seen?: number
94
- /** The sender's LWW stamp in epoch ms; receipt time when absent. */
95
- at?: number
96
- readonly '~a2.pushed': true
97
- }
98
-
99
- export type PushValidationContext = {
100
- sessionId: string
101
- events: readonly PushedEvent[]
102
- /** The whole pushed patch — present exactly on the presence-plane
103
- * invocation, so the callback authorizes the participant id and can
104
- * apply size or cardinality policy. */
105
- presence?: PushedPresence
106
- }
107
-
108
75
  /** What every handler receives. */
109
76
  export type HandlerContext<
110
77
  D extends EventDefs,
@@ -142,11 +109,9 @@ export type Lane<
142
109
  K extends keyof D & string = keyof D & string,
143
110
  > = string | ((context: LaneContext<D, K>) => string)
144
111
 
145
- export type SessionDispatch<D extends EventDefs> = {
146
- (...events: AppendInput<D>[]): Promise<ContractEvent<D>[]>
147
- /** The push-route path: events from `parsePushBody`. */
148
- (...events: PushedEvent[]): Promise<ContractEvent<D>[]>
149
- }
112
+ export type SessionDispatch<D extends EventDefs> = (
113
+ ...events: AppendInput<D>[]
114
+ ) => Promise<ContractEvent<D>[]>
150
115
 
151
116
  export type SessionAppend<D extends EventDefs> = SessionDispatch<D> & {
152
117
  /** Commit, then hand pending work directly to configured scheduler. */
@@ -192,16 +157,12 @@ export type SessionPresence<D extends EventDefs, P extends PresenceDefs> = {
192
157
  * is the sender's LWW stamp in epoch ms; omitted, receipt time
193
158
  * stands in (single writer, so receipt order is sender order).
194
159
  */
195
- setPresence(
196
- patch:
197
- | {
198
- participant: string
199
- values: PresencePatch<P>['values']
200
- seen?: number
201
- at?: number
202
- }
203
- | PushedPresence,
204
- ): Promise<void>
160
+ setPresence(patch: {
161
+ participant: string
162
+ values: PresencePatch<P>['values']
163
+ seen?: number
164
+ at?: number
165
+ }): Promise<void>
205
166
  /** The current map, expired values pruned — a point-in-time read. */
206
167
  presence(): Promise<PresenceMap<P>>
207
168
  }
@@ -219,8 +180,7 @@ export type Session<
219
180
  state<S>(reducer: Reducer<D, S>): Promise<{ state: S; index: number }>
220
181
  /**
221
182
  * A live feed of this session's events, starting after `startAfter`
222
- * (exclusive). Server-side only `handle` from experimental-a2/http
223
- * exposes it over SSE as the route's stream lane.
183
+ * (exclusive). Server-side only. `server.fetch` exposes it over SSE.
224
184
  */
225
185
  stream(opts?: { startAfter?: number }): AsyncIterable<ContractEvent<D>>
226
186
  }
@@ -255,6 +215,10 @@ export type A2Server<
255
215
  > = {
256
216
  /** The contract this server implements. */
257
217
  readonly contract: Contract<D, P>
218
+ readonly fetch: {
219
+ (request: Request): Promise<Response>
220
+ (request: Request, options: ServerFetchOptions<D, P>): Promise<Response>
221
+ }
258
222
  session(id: string): Session<D, SessionAppend<D>, P>
259
223
  /**
260
224
  * Process every currently eligible event. `settled` means nothing
@@ -263,6 +227,15 @@ export type A2Server<
263
227
  drain(sessionId: string): Promise<{ settled: boolean }>
264
228
  }
265
229
 
230
+ export type {
231
+ A2Operation,
232
+ A2PushEvent,
233
+ A2PushPresence,
234
+ ServerFetchOptions,
235
+ UpgradeWebSocket,
236
+ }
237
+ export type { A2Socket } from './session-socket.ts'
238
+
266
239
  /**
267
240
  * Deliver one authenticated scheduler append through an A2 server's ordinary
268
241
  * top-level append path. Custom scheduler adapters call this after validating
@@ -336,8 +309,6 @@ export type ServerOptions<
336
309
  scheduler?: A2Scheduler
337
310
  /** Optional instrumentation — e.g. `otel()` from `experimental-a2/otel`. */
338
311
  telemetry?: A2Telemetry
339
- /** Validate events that came through `parsePushBody` before writing them. */
340
- validatePush?: (context: PushValidationContext) => void | PromiseLike<void>
341
312
  /**
342
313
  * Presence-plane policy — valid only when the contract declares
343
314
  * presence fields (TypeError at construction otherwise). `ttlMs` is
@@ -1295,22 +1266,6 @@ export function createServer<
1295
1266
  },
1296
1267
  fallbackSeen: number,
1297
1268
  ): Promise<void> => {
1298
- // The push-route provenance seam, mirroring appendExternal: a
1299
- // branded patch came from the wire, so `validatePush` runs before
1300
- // field validation and the broadcast — with `events: []`, the
1301
- // documented presence-only shape.
1302
- if (Reflect.get(patch, '~a2.pushed') === true) {
1303
- // Frozen before the callback: this is the authorization seam,
1304
- // and an authorization hook must not be able to rewrite
1305
- // authorship or values on its way through (assignment throws
1306
- // under strict mode, failing the push loudly).
1307
- Object.freeze(patch.values)
1308
- await options.validatePush?.({
1309
- sessionId,
1310
- events: [],
1311
- presence: Object.freeze(patch as PushedPresence),
1312
- })
1313
- }
1314
1269
  if (
1315
1270
  typeof patch.participant !== 'string' ||
1316
1271
  patch.participant.length === 0
@@ -2240,16 +2195,38 @@ export function createServer<
2240
2195
  shouldClaim = false
2241
2196
  }
2242
2197
  }
2198
+ if (
2199
+ claim?.outcome === 'claimed' &&
2200
+ [...active.values()].some((entry) => entry.lapsed)
2201
+ ) {
2202
+ shouldClaim = true
2203
+ continue
2204
+ }
2243
2205
  if (active.size > 0) {
2244
2206
  const wake = signal?.wait(signalVersion)
2245
- await Promise.race([
2246
- ...[...active.values()].map((entry) => entry.execution),
2247
- ...(wake ? [wake.promise] : []),
2207
+ const hasLapsed = [...active.values()].some(
2208
+ (entry) => entry.lapsed,
2209
+ )
2210
+ const retry =
2211
+ claim?.outcome === 'busy' && hasLapsed
2212
+ ? defaultSleep(
2213
+ Math.max(0, claim.retryAt.getTime() - Date.now()),
2214
+ )
2215
+ : undefined
2216
+ const retryDue = await Promise.race([
2217
+ ...[...active.values()].map((entry) =>
2218
+ entry.execution.then(() => false),
2219
+ ),
2220
+ ...(wake ? [wake.promise.then(() => false)] : []),
2221
+ ...(retry ? [retry.promise.then(() => true)] : []),
2248
2222
  ])
2249
2223
  wake?.cancel()
2224
+ retry?.cancel()
2250
2225
  shouldClaim =
2226
+ retryDue ||
2251
2227
  claimAgain ||
2252
2228
  active.size === 0 ||
2229
+ hasLapsed ||
2253
2230
  (signal !== undefined && signal.version !== signalVersion)
2254
2231
  claimAgain = false
2255
2232
  continue
@@ -2298,19 +2275,12 @@ export function createServer<
2298
2275
  const appendExternal = async (
2299
2276
  sessionId: string,
2300
2277
  mode: 'inline' | 'dispatch',
2301
- events: Array<AppendInput<D> | PushedEvent>,
2278
+ events: AppendInput<D>[],
2302
2279
  requireDurableInitialArm = false,
2303
2280
  ): Promise<ContractEvent<D>[]> => {
2304
- const pushed = events.filter(
2305
- (event): event is PushedEvent =>
2306
- Reflect.get(event, '~a2.pushed') === true,
2307
- )
2308
- if (pushed.length > 0) {
2309
- await options.validatePush?.({ sessionId, events: pushed })
2310
- }
2311
2281
  return appendCore(
2312
2282
  sessionId,
2313
- events as AppendInput<D>[],
2283
+ events,
2314
2284
  'external',
2315
2285
  mode,
2316
2286
  (_rows, watchdogDueAt) => scheduleDrain(sessionId, watchdogDueAt),
@@ -2320,21 +2290,23 @@ export function createServer<
2320
2290
  )
2321
2291
  }
2322
2292
 
2293
+ const session = (id: string): Session<D, SessionAppend<D>, P> => {
2294
+ assertSessionId(id)
2295
+ const appendInline = async (...events: AppendInput<D>[]) =>
2296
+ appendExternal(id, 'inline', events)
2297
+ const appendDispatch = async (...events: AppendInput<D>[]) =>
2298
+ appendExternal(id, 'dispatch', events)
2299
+ const append = Object.assign(appendInline, {
2300
+ dispatch: appendDispatch,
2301
+ }) as SessionAppend<D>
2302
+ return makeSession(id, append, null)
2303
+ }
2304
+
2305
+ const fetch = createServerFetch({ contract: serverContract, session })
2323
2306
  const self: A2Server<D, P> = {
2324
2307
  contract: serverContract,
2325
- session(id) {
2326
- assertSessionId(id)
2327
- const appendInline = async (
2328
- ...events: Array<AppendInput<D> | PushedEvent>
2329
- ) => appendExternal(id, 'inline', events)
2330
- const appendDispatch = async (
2331
- ...events: Array<AppendInput<D> | PushedEvent>
2332
- ) => appendExternal(id, 'dispatch', events)
2333
- const append = Object.assign(appendInline, {
2334
- dispatch: appendDispatch,
2335
- }) as SessionAppend<D>
2336
- return makeSession(id, append, null)
2337
- },
2308
+ fetch,
2309
+ session,
2338
2310
 
2339
2311
  async drain(sessionId) {
2340
2312
  assertSessionId(sessionId)
@@ -2363,126 +2335,6 @@ export function createServer<
2363
2335
  await appendExternal(sessionId, 'inline', [...events], true)
2364
2336
  },
2365
2337
  })
2366
- serverSchedulerBindings.set(self, { scheduler })
2367
-
2368
- serverInspection.set(self, {
2369
- async listSessions(inspectionOptions) {
2370
- const store = await resolveStore()
2371
- if (!store.inspect) {
2372
- throw new InspectionUnsupportedError(
2373
- 'this store backend does not support inspection',
2374
- )
2375
- }
2376
- const page = await store.inspect.listSessions({
2377
- prefix: `${name}${NS}`,
2378
- limit: inspectionOptions.limit,
2379
- ...(inspectionOptions.cursor !== undefined
2380
- ? { cursor: inspectionOptions.cursor }
2381
- : {}),
2382
- })
2383
- return {
2384
- cursor: page.cursor,
2385
- // oxlint-disable-next-line oxc/no-map-spread -- inspection results remain adapter-owned
2386
- sessions: page.sessions.map((session) => {
2387
- const publicSession = { ...session }
2388
- publicSession.sessionId = stripNs(session.sessionId)
2389
- return publicSession
2390
- }),
2391
- }
2392
- },
2393
- async readSessionPage(sessionId, inspectionOptions) {
2394
- assertSessionId(sessionId)
2395
- if (
2396
- !Number.isSafeInteger(inspectionOptions.afterIndex) ||
2397
- inspectionOptions.afterIndex < 0 ||
2398
- !Number.isSafeInteger(inspectionOptions.limit) ||
2399
- inspectionOptions.limit < 1 ||
2400
- (inspectionOptions.throughIndex !== undefined &&
2401
- (!Number.isSafeInteger(inspectionOptions.throughIndex) ||
2402
- inspectionOptions.throughIndex < 0))
2403
- ) {
2404
- throw new TypeError('invalid inspection event page bounds')
2405
- }
2406
- const store = await resolveStore()
2407
- if (!store.inspect) {
2408
- throw new InspectionUnsupportedError(
2409
- 'this store backend does not support inspection',
2410
- )
2411
- }
2412
- const ns = nsId(sessionId)
2413
- const snapshots =
2414
- inspectionOptions.afterIndex === 0
2415
- ? await store.inspect.listSnapshots(ns)
2416
- : []
2417
- const page = store.inspect.readEvents
2418
- ? await store.inspect.readEvents(ns, inspectionOptions)
2419
- : await (async () => {
2420
- const all = await store.read(ns)
2421
- const currentFrontier = all.at(-1)?.index ?? 0
2422
- const throughIndex = Math.min(
2423
- inspectionOptions.throughIndex ?? currentFrontier,
2424
- currentFrontier,
2425
- )
2426
- return {
2427
- events: all
2428
- .filter(
2429
- (event) =>
2430
- event.index > inspectionOptions.afterIndex &&
2431
- event.index <= throughIndex,
2432
- )
2433
- .slice(0, inspectionOptions.limit),
2434
- throughIndex,
2435
- }
2436
- })()
2437
- const { events, throughIndex } = page
2438
- if (
2439
- !Number.isSafeInteger(throughIndex) ||
2440
- throughIndex < 0 ||
2441
- events.length > inspectionOptions.limit ||
2442
- (inspectionOptions.throughIndex !== undefined &&
2443
- inspectionOptions.afterIndex > 0 &&
2444
- throughIndex !== inspectionOptions.throughIndex)
2445
- ) {
2446
- throw new A2Error(
2447
- 'STORE_UNAVAILABLE',
2448
- `inspection event page for session '${sessionId}' has invalid bounds`,
2449
- )
2450
- }
2451
- for (let position = 0; position < events.length; position += 1) {
2452
- if (
2453
- events[position]!.index !==
2454
- inspectionOptions.afterIndex + position + 1 ||
2455
- events[position]!.index > throughIndex
2456
- ) {
2457
- throw new A2Error(
2458
- 'STORE_UNAVAILABLE',
2459
- `inspection event page for session '${sessionId}' is not contiguous`,
2460
- )
2461
- }
2462
- }
2463
- if (inspectionOptions.afterIndex < throughIndex && events.length === 0) {
2464
- throw new A2Error(
2465
- 'STORE_UNAVAILABLE',
2466
- `inspection event page for session '${sessionId}' ended before index ${throughIndex}`,
2467
- )
2468
- }
2469
- const lastIndex = events.at(-1)?.index ?? inspectionOptions.afterIndex
2470
- return {
2471
- // oxlint-disable-next-line oxc/no-map-spread -- inspection results remain adapter-owned
2472
- events: events.map((event) => {
2473
- const publicEvent = { ...event }
2474
- publicEvent.sessionId = sessionId
2475
- return publicEvent
2476
- }),
2477
- snapshots: snapshots.filter(
2478
- (snapshot) => snapshot.index <= throughIndex,
2479
- ),
2480
- throughIndex,
2481
- nextIndex: lastIndex < throughIndex ? lastIndex : null,
2482
- }
2483
- },
2484
- })
2485
-
2486
2338
  return self
2487
2339
  }
2488
2340
 
@@ -2666,7 +2518,6 @@ function cloneInitial<S>(initial: S): S {
2666
2518
  // transports — re-exported so experimental-a2/server is self-sufficient.
2667
2519
  export type {
2668
2520
  A2Store,
2669
- A2StoreInspection,
2670
2521
  AppendEvent,
2671
2522
  Clock,
2672
2523
  Event,
@@ -2678,9 +2529,6 @@ export type {
2678
2529
  StoreStateRead,
2679
2530
  PresenceRow,
2680
2531
  StoredEvent,
2681
- StoredSessionPage,
2682
- StoredSessionSummary,
2683
- StoredSnapshot,
2684
2532
  } from './store.ts'
2685
2533
  export type {
2686
2534
  ScheduledEvent,
@@ -11,7 +11,8 @@ import { A2Error } from './errors.ts'
11
11
  import { SOCKET_TIMINGS, STREAM_TIMINGS } from './internal.ts'
12
12
  import type { Event } from './store.ts'
13
13
  import type { EventDefs, PresencePatch, PresenceSnapshot } from './contract.ts'
14
- import type { PushedEvent, PushedPresence, Session } from './server.ts'
14
+ import type { ParsedPushEvent, ParsedPushPresence } from './push-envelope.ts'
15
+ import type { Session } from './server.ts'
15
16
  import { parsePresenceSibling, parsePushEvents } from './push-envelope.ts'
16
17
  import {
17
18
  SOCKET_PING_FRAME,
@@ -71,16 +72,15 @@ const MAX_MALFORMED_FRAMES = 5
71
72
  * The pump's runtime view of a session. The presence members are
72
73
  * conditional on the public surface (`WithPresence`) and therefore
73
74
  * invisible on a generic `Session<D>`, so capability is checked at
74
- * runtime (`setPresence` present) the same resolution as the docs'
75
- * route sample. The casts live in `sessionsSocket` and `handle`.
75
+ * runtime (`setPresence` present). The cast stays inside the transport.
76
76
  */
77
77
  export type SocketSession = {
78
78
  stream(opts: {
79
79
  startAfter: number
80
80
  presence?: boolean
81
81
  }): AsyncIterable<Event | PresencePatch | PresenceSnapshot>
82
- append(...events: PushedEvent[]): Promise<Event[]>
83
- setPresence?(patch: PushedPresence): Promise<void>
82
+ append(...events: ParsedPushEvent[]): Promise<Event[]>
83
+ setPresence?(patch: ParsedPushPresence): Promise<void>
84
84
  }
85
85
 
86
86
  /**
@@ -238,7 +238,8 @@ export type SessionsSocketResolve<D extends EventDefs> = (
238
238
  sessionId: string,
239
239
  /** The entry's resume frontier — `stream({ startAfter })`. */
240
240
  startAfter: number,
241
- ) => Session<D> | null | Promise<Session<D> | null>
241
+ ) =>
242
+ Pick<Session<D>, 'stream'> | null | Promise<Pick<Session<D>, 'stream'> | null>
242
243
 
243
244
  export type SessionsSocketOptions = {
244
245
  /** The `stream()` presence opt-in for every session on the socket —
@@ -247,17 +248,11 @@ export type SessionsSocketOptions = {
247
248
  /** Epoch ms: close cleanly (code 1000) at this time, ahead of a known
248
249
  * platform deadline, so clients reconnect on our schedule. */
249
250
  deadline?: number
250
- /**
251
- * Per-frame push gate — policy's seam on the up lane, the socket
252
- * counterpart of a POST route's checks (`handle`'s `before` rides
253
- * it). Runs after the frame parses and before `append`: return an
254
- * `A2Error` to answer the push with that error ack (nothing
255
- * appended), `null` to allow. Absent, every push on an accepted
256
- * subscription is allowed — the prior behavior, unchanged.
257
- */
251
+ /** Runs after frame parsing and before append or presence I/O. */
258
252
  gatePush?(
259
253
  sessionId: string,
260
- events: PushedEvent[],
254
+ events: ParsedPushEvent[],
255
+ presence?: ParsedPushPresence,
261
256
  ): A2Error | null | Promise<A2Error | null>
262
257
  }
263
258
 
@@ -359,11 +354,14 @@ export function sessionsSocket<D extends EventDefs>(
359
354
  id: string,
360
355
  index: number,
361
356
  ): Promise<void> => {
362
- let session: Session<D> | null
357
+ let session: Pick<Session<D>, 'stream'> | null
363
358
  try {
364
359
  session = await resolve(id, index)
365
360
  } catch {
366
- session = null
361
+ if (!shell.torn()) {
362
+ shell.send(socketUnsubscribedFor(id, 'stream failed'))
363
+ }
364
+ return
367
365
  }
368
366
  if (shell.torn()) return
369
367
  if (session === null) {
@@ -419,7 +417,8 @@ export function sessionsSocket<D extends EventDefs>(
419
417
  }
420
418
  try {
421
419
  const events = parsePushEvents(frame['events'])
422
- const denial = (await options?.gatePush?.(sessionId, events)) ?? null
420
+ const denial =
421
+ (await options?.gatePush?.(sessionId, events, undefined)) ?? null
423
422
  if (denial !== null) {
424
423
  shell.send(socketErrorAckFor(req, denial, sessionId))
425
424
  return
@@ -442,11 +441,28 @@ export function sessionsSocket<D extends EventDefs>(
442
441
  shell.malformedFrame('presence frame on a presence-less session')
443
442
  return
444
443
  }
444
+ let patch: ParsedPushPresence | undefined
445
445
  try {
446
- const patch = parsePresenceSibling(frame)
447
- if (patch) await subscription.target.setPresence(patch)
446
+ patch = parsePresenceSibling(frame)
448
447
  } catch {
449
448
  shell.malformedFrame('invalid presence frame')
449
+ return
450
+ }
451
+ if (!patch) return
452
+ try {
453
+ const denial = (await options?.gatePush?.(sessionId, [], patch)) ?? null
454
+ if (denial !== null) return
455
+ await subscription.target.setPresence(patch)
456
+ } catch (error) {
457
+ const failure = asA2Error(error)
458
+ if (
459
+ failure.code === 'INVALID_PAYLOAD' ||
460
+ failure.code === 'UNKNOWN_PRESENCE_FIELD'
461
+ ) {
462
+ shell.malformedFrame('invalid presence frame')
463
+ return
464
+ }
465
+ shell.shutdown(1011, 'internal error')
450
466
  }
451
467
  }
452
468
 
package/src/sse.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The SSE response protocol — framing, heartbeat, deadline, and
3
- * teardown for the live stream lane. Internal module: `handle`'s
4
- * stream branch is the one sanctioned caller; the framing rules stay
3
+ * teardown for the live stream lane. Internal module: `server.fetch` is
4
+ * the sanctioned caller; the framing rules stay
5
5
  * documented on `sseResponse` because the client's parser (and the
6
6
  * stall watchdog) depend on them.
7
7
  */
@@ -21,7 +21,6 @@ import {
21
21
  type IdSource,
22
22
  type PresenceRow,
23
23
  type StoredEvent,
24
- type StoredSessionSummary,
25
24
  type StoreStateRead,
26
25
  } from './store.ts'
27
26
 
@@ -648,86 +647,6 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
648
647
  },
649
648
  },
650
649
 
651
- inspect: {
652
- async listSessions(inspectionOptions) {
653
- const ids = [...sessions.keys()]
654
- .filter(
655
- (sessionId) =>
656
- sessionId.startsWith(inspectionOptions.prefix) &&
657
- (inspectionOptions.cursor === undefined ||
658
- sessionId > inspectionOptions.cursor),
659
- )
660
- .toSorted()
661
- const visible = ids.slice(0, inspectionOptions.limit)
662
- const summaries = visible.map((sessionId): StoredSessionSummary => {
663
- const rows = sessions.get(sessionId)!
664
- const first = rows[0]!
665
- let updatedAt = first.createdAt
666
- let pendingCount = 0
667
- let failedCount = 0
668
- let attemptCount = 0
669
- let failureCount = 0
670
- for (const row of rows) {
671
- attemptCount += row.attemptCount
672
- failureCount += row.failureCount
673
- if (row.processedAt === null && row.failedAt === null) {
674
- pendingCount += 1
675
- }
676
- if (row.failedAt !== null) failedCount += 1
677
- for (const timestamp of [
678
- row.createdAt,
679
- row.firstClaimedAt,
680
- row.lastClaimedAt,
681
- row.lastFailedAt,
682
- row.processedAt,
683
- row.failedAt,
684
- ]) {
685
- if (timestamp && timestamp > updatedAt) updatedAt = timestamp
686
- }
687
- }
688
- return {
689
- sessionId,
690
- eventCount: rows.length,
691
- pendingCount,
692
- failedCount,
693
- attemptCount,
694
- failureCount,
695
- firstEventAt: new Date(first.createdAt),
696
- updatedAt: new Date(updatedAt),
697
- }
698
- })
699
- return {
700
- sessions: summaries,
701
- cursor: ids.length > visible.length ? (visible.at(-1) ?? null) : null,
702
- }
703
- },
704
- async readEvents(sessionId, pageOptions) {
705
- const rows = sessions.get(sessionId) ?? []
706
- const throughIndex = Math.min(
707
- pageOptions.throughIndex ?? rows.length,
708
- rows.length,
709
- )
710
- return {
711
- events: rows
712
- .slice(pageOptions.afterIndex, throughIndex)
713
- .slice(0, pageOptions.limit)
714
- .map(toStored),
715
- throughIndex,
716
- }
717
- },
718
- async listSnapshots(sessionId) {
719
- const prefix = `${sessionId}\u0000`
720
- return [...snapshots.entries()]
721
- .filter(([key]) => key.startsWith(prefix))
722
- .map(([key, snapshot]) => ({
723
- reducerName: key.slice(prefix.length),
724
- index: snapshot.index,
725
- updatedAt: new Date(snapshot.updatedAt),
726
- }))
727
- .toSorted((a, b) => a.reducerName.localeCompare(b.reducerName))
728
- },
729
- },
730
-
731
650
  stream(sessionId, opts) {
732
651
  const startAfter = opts?.startAfter ?? 0
733
652
  return {