experimental-a2 0.6.0 → 0.8.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 (148) hide show
  1. package/CHANGELOG.md +41 -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 +20 -17
  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-B2XNevQA.js} +830 -131
  27. package/dist/server-B2XNevQA.js.map +1 -0
  28. package/dist/{server-DpvjhdoE.d.ts → server-DjPhHnbI.d.ts} +71 -50
  29. package/dist/server-DjPhHnbI.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-RJO35BMj.d.ts} +24 -62
  34. package/dist/store-RJO35BMj.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 +80 -78
  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.d.ts.map +1 -1
  43. package/dist/store-postgres.js +231 -182
  44. package/dist/store-postgres.js.map +1 -1
  45. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DT01r4GZ.js} +213 -161
  46. package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
  47. package/dist/store-redis-http.d.ts +1 -1
  48. package/dist/store-redis-http.js +3 -4
  49. package/dist/store-redis-http.js.map +1 -1
  50. package/dist/store-redis.d.ts +1 -1
  51. package/dist/store-redis.js +4 -5
  52. package/dist/store-redis.js.map +1 -1
  53. package/dist/store-sqlite.d.ts +1 -1
  54. package/dist/store-sqlite.d.ts.map +1 -1
  55. package/dist/store-sqlite.js +104 -91
  56. package/dist/store-sqlite.js.map +1 -1
  57. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  58. package/dist/wire-B6te_wns.js.map +1 -0
  59. package/docs/concepts/02-handlers.mdx +4 -0
  60. package/docs/concepts/04-state.mdx +57 -9
  61. package/docs/guides/03-react.mdx +20 -28
  62. package/docs/guides/05-production.mdx +9 -11
  63. package/docs/guides/06-ai-agents.mdx +10 -15
  64. package/docs/guides/09-presence.mdx +14 -19
  65. package/docs/guides/10-transports.mdx +104 -86
  66. package/docs/reference/01-api.mdx +182 -293
  67. package/docs/reference/02-errors.mdx +5 -7
  68. package/package.json +1 -14
  69. package/src/ai-server.ts +36 -15
  70. package/src/client.ts +2 -2
  71. package/src/errors.ts +1 -0
  72. package/src/internal.ts +3 -62
  73. package/src/push-envelope.ts +24 -21
  74. package/src/scheduler-qstash.ts +3 -3
  75. package/src/scheduler-vercel.ts +2 -2
  76. package/src/server-fetch.ts +344 -0
  77. package/src/server.ts +315 -312
  78. package/src/session-socket.ts +36 -20
  79. package/src/sse.ts +2 -2
  80. package/src/store-memory.ts +138 -101
  81. package/src/store-postgres.ts +355 -238
  82. package/src/store-redis-core.ts +247 -237
  83. package/src/store-redis-http.ts +1 -2
  84. package/src/store-redis.ts +1 -2
  85. package/src/store-sqlite.ts +191 -153
  86. package/src/store.ts +24 -66
  87. package/src/wire.ts +2 -1
  88. package/dist/ai-D_PGS-JR.d.ts +0 -334
  89. package/dist/ai-D_PGS-JR.d.ts.map +0 -1
  90. package/dist/cli-B3VuxoDe.js +0 -599
  91. package/dist/cli-B3VuxoDe.js.map +0 -1
  92. package/dist/cli-bin.d.ts +0 -1
  93. package/dist/cli-bin.js +0 -7
  94. package/dist/cli-bin.js.map +0 -1
  95. package/dist/cli.d.ts +0 -20
  96. package/dist/cli.d.ts.map +0 -1
  97. package/dist/cli.js +0 -2
  98. package/dist/client-CdMqi7mC.d.ts +0 -202
  99. package/dist/client-CdMqi7mC.d.ts.map +0 -1
  100. package/dist/client-Dj5d3SP_.js +0 -1026
  101. package/dist/client-Dj5d3SP_.js.map +0 -1
  102. package/dist/devtools-J_jZ2vQf.d.ts +0 -152
  103. package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
  104. package/dist/devtools-kJJaORn-.js +0 -340
  105. package/dist/devtools-kJJaORn-.js.map +0 -1
  106. package/dist/devtools-server.browser.d.ts +0 -1
  107. package/dist/devtools-server.browser.js +0 -6
  108. package/dist/devtools-server.browser.js.map +0 -1
  109. package/dist/devtools-server.d.ts +0 -23
  110. package/dist/devtools-server.d.ts.map +0 -1
  111. package/dist/devtools-server.js +0 -1270
  112. package/dist/devtools-server.js.map +0 -1
  113. package/dist/devtools.d.ts +0 -2
  114. package/dist/devtools.js +0 -2
  115. package/dist/errors-W6nwJ-fm.d.ts +0 -21
  116. package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
  117. package/dist/http.d.ts +0 -151
  118. package/dist/http.d.ts.map +0 -1
  119. package/dist/http.js +0 -706
  120. package/dist/http.js.map +0 -1
  121. package/dist/inspection-DaxB5jM2.js +0 -13
  122. package/dist/inspection-DaxB5jM2.js.map +0 -1
  123. package/dist/internal-DstsI6Re.js.map +0 -1
  124. package/dist/platform-B4TnJtWu.js +0 -34
  125. package/dist/platform-B4TnJtWu.js.map +0 -1
  126. package/dist/server-DpvjhdoE.d.ts.map +0 -1
  127. package/dist/server-Duw6MVlB.js.map +0 -1
  128. package/dist/store-DysUkTH3.d.ts.map +0 -1
  129. package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
  130. package/dist/testing.browser.d.ts +0 -1
  131. package/dist/testing.browser.js +0 -6
  132. package/dist/testing.browser.js.map +0 -1
  133. package/dist/testing.d.ts +0 -32
  134. package/dist/testing.d.ts.map +0 -1
  135. package/dist/testing.js +0 -103
  136. package/dist/testing.js.map +0 -1
  137. package/dist/wire-BFQmSJ-9.js.map +0 -1
  138. package/docs/guides/07-devtools.mdx +0 -229
  139. package/src/cli-bin.ts +0 -5
  140. package/src/cli.ts +0 -1046
  141. package/src/devtools-app.ts +0 -989
  142. package/src/devtools-server.browser.ts +0 -5
  143. package/src/devtools-server.ts +0 -604
  144. package/src/devtools.ts +0 -716
  145. package/src/http.ts +0 -394
  146. package/src/inspection.ts +0 -39
  147. package/src/testing.browser.ts +0 -5
  148. 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,
@@ -40,12 +38,21 @@ import type {
40
38
  Event,
41
39
  EventCause,
42
40
  StoreClaimAvailableResult,
41
+ StoreSnapshotWrite,
43
42
  StoreStateRead,
44
43
  PresenceRow,
45
44
  ReturnedEvent,
46
45
  StoredEvent,
47
46
  } from './store.ts'
48
47
  import type { Reducer } from './reducer.ts'
48
+ import {
49
+ createServerFetch,
50
+ type A2Operation,
51
+ type A2PushEvent,
52
+ type A2PushPresence,
53
+ type ServerFetchOptions,
54
+ type UpgradeWebSocket,
55
+ } from './server-fetch.ts'
49
56
  import { validateSync } from './validate.ts'
50
57
  import {
51
58
  deterministicEventId,
@@ -66,45 +73,6 @@ import {
66
73
  type A2Telemetry,
67
74
  } from './telemetry.ts'
68
75
 
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
76
  /** What every handler receives. */
109
77
  export type HandlerContext<
110
78
  D extends EventDefs,
@@ -142,11 +110,9 @@ export type Lane<
142
110
  K extends keyof D & string = keyof D & string,
143
111
  > = string | ((context: LaneContext<D, K>) => string)
144
112
 
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
- }
113
+ export type SessionDispatch<D extends EventDefs> = (
114
+ ...events: AppendInput<D>[]
115
+ ) => Promise<ContractEvent<D>[]>
150
116
 
151
117
  export type SessionAppend<D extends EventDefs> = SessionDispatch<D> & {
152
118
  /** Commit, then hand pending work directly to configured scheduler. */
@@ -169,6 +135,10 @@ export type SessionSchedule<D extends EventDefs> = (
169
135
  ...events: AppendInput<D>[]
170
136
  ) => Promise<void>
171
137
 
138
+ export type StateOptions = {
139
+ through?: number | 'latest'
140
+ }
141
+
172
142
  /**
173
143
  * The presence members of a session — intersected in via
174
144
  * `WithPresence`, so they exist exactly when the contract declares
@@ -192,16 +162,12 @@ export type SessionPresence<D extends EventDefs, P extends PresenceDefs> = {
192
162
  * is the sender's LWW stamp in epoch ms; omitted, receipt time
193
163
  * stands in (single writer, so receipt order is sender order).
194
164
  */
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>
165
+ setPresence(patch: {
166
+ participant: string
167
+ values: PresencePatch<P>['values']
168
+ seen?: number
169
+ at?: number
170
+ }): Promise<void>
205
171
  /** The current map, expired values pruned — a point-in-time read. */
206
172
  presence(): Promise<PresenceMap<P>>
207
173
  }
@@ -216,11 +182,13 @@ export type Session<
216
182
  append: Append
217
183
  schedule: SessionSchedule<D>
218
184
  history(options?: { gte?: number; lte?: number }): Promise<ContractEvent<D>[]>
219
- state<S>(reducer: Reducer<D, S>): Promise<{ state: S; index: number }>
185
+ state<S>(
186
+ reducer: Reducer<D, S>,
187
+ options?: StateOptions,
188
+ ): Promise<{ state: S; index: number }>
220
189
  /**
221
190
  * 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.
191
+ * (exclusive). Server-side only. `server.fetch` exposes it over SSE.
224
192
  */
225
193
  stream(opts?: { startAfter?: number }): AsyncIterable<ContractEvent<D>>
226
194
  }
@@ -255,6 +223,10 @@ export type A2Server<
255
223
  > = {
256
224
  /** The contract this server implements. */
257
225
  readonly contract: Contract<D, P>
226
+ readonly fetch: {
227
+ (request: Request): Promise<Response>
228
+ (request: Request, options: ServerFetchOptions<D, P>): Promise<Response>
229
+ }
258
230
  session(id: string): Session<D, SessionAppend<D>, P>
259
231
  /**
260
232
  * Process every currently eligible event. `settled` means nothing
@@ -263,6 +235,15 @@ export type A2Server<
263
235
  drain(sessionId: string): Promise<{ settled: boolean }>
264
236
  }
265
237
 
238
+ export type {
239
+ A2Operation,
240
+ A2PushEvent,
241
+ A2PushPresence,
242
+ ServerFetchOptions,
243
+ UpgradeWebSocket,
244
+ }
245
+ export type { A2Socket } from './session-socket.ts'
246
+
266
247
  /**
267
248
  * Deliver one authenticated scheduler append through an A2 server's ordinary
268
249
  * top-level append path. Custom scheduler adapters call this after validating
@@ -336,8 +317,6 @@ export type ServerOptions<
336
317
  scheduler?: A2Scheduler
337
318
  /** Optional instrumentation — e.g. `otel()` from `experimental-a2/otel`. */
338
319
  telemetry?: A2Telemetry
339
- /** Validate events that came through `parsePushBody` before writing them. */
340
- validatePush?: (context: PushValidationContext) => void | PromiseLike<void>
341
320
  /**
342
321
  * Presence-plane policy — valid only when the contract declares
343
322
  * presence fields (TypeError at construction otherwise). `ttlMs` is
@@ -994,26 +973,85 @@ export function createServer<
994
973
  store: A2Store,
995
974
  sessionId: string,
996
975
  reducerName: string,
976
+ throughIndex?: number,
977
+ snapshotThroughIndex?: number,
997
978
  ): Promise<StoreStateRead> => {
998
979
  try {
999
- return await store.readState(nsId(sessionId), reducerName)
980
+ return await store.readState(
981
+ nsId(sessionId),
982
+ reducerName,
983
+ throughIndex === undefined && snapshotThroughIndex === undefined
984
+ ? undefined
985
+ : {
986
+ ...(throughIndex === undefined ? {} : { throughIndex }),
987
+ ...(snapshotThroughIndex === undefined
988
+ ? {}
989
+ : { snapshotThroughIndex }),
990
+ },
991
+ )
1000
992
  } catch {
1001
993
  try {
1002
- return { snapshot: null, events: await store.read(nsId(sessionId)) }
994
+ return {
995
+ headIndex: null,
996
+ snapshot: null,
997
+ events: await store.read(
998
+ nsId(sessionId),
999
+ throughIndex === undefined ? undefined : { throughIndex },
1000
+ ),
1001
+ }
1003
1002
  } catch (err) {
1004
1003
  throw asStoreUnavailable(err)
1005
1004
  }
1006
1005
  }
1007
1006
  }
1008
1007
 
1008
+ type PendingStateRead = {
1009
+ sessionId: string
1010
+ throughIndex?: number
1011
+ pinEventIndex?: number
1012
+ span: A2SpanHandle
1013
+ resolve: (result: { state: unknown; index: number }) => void
1014
+ reject: (reason: unknown) => void
1015
+ }
1016
+
1017
+ type StateReadPlan = {
1018
+ sessionId: string
1019
+ entries: PendingStateRead[]
1020
+ throughIndex?: number
1021
+ snapshotThroughIndex?: number
1022
+ }
1023
+
1024
+ const planStateReads = (batch: PendingStateRead[]): StateReadPlan[] => {
1025
+ const bySession = new Map<string, PendingStateRead[]>()
1026
+ for (const entry of batch) {
1027
+ const entries = bySession.get(entry.sessionId)
1028
+ if (entries) entries.push(entry)
1029
+ else bySession.set(entry.sessionId, [entry])
1030
+ }
1031
+ const plans: StateReadPlan[] = []
1032
+ for (const [sessionId, entries] of bySession) {
1033
+ const boundaries = entries.flatMap((entry) =>
1034
+ entry.throughIndex === undefined ? [] : [entry.throughIndex],
1035
+ )
1036
+ const plan: StateReadPlan = { sessionId, entries }
1037
+ if (boundaries.length === entries.length) {
1038
+ plan.throughIndex = Math.max(...boundaries)
1039
+ }
1040
+ if (boundaries.length > 0) {
1041
+ plan.snapshotThroughIndex = Math.min(...boundaries)
1042
+ }
1043
+ plans.push(plan)
1044
+ }
1045
+ return plans
1046
+ }
1047
+
1009
1048
  // The cache is untrusted: schema rejection refolds from the raw store.
1010
- const foldStateRead = async <S>(
1049
+ const foldStatePlan = async (
1011
1050
  store: A2Store,
1012
- sessionId: string,
1013
- reducer: Reducer<D, S>,
1051
+ reducer: Reducer<D, unknown>,
1052
+ plan: StateReadPlan,
1014
1053
  stateRead: StoreStateRead,
1015
- span: A2SpanHandle,
1016
- ): Promise<{ state: S; index: number; folded: number }> => {
1054
+ ): Promise<void> => {
1017
1055
  let state = cloneInitial(reducer.initialState)
1018
1056
  let index = 0
1019
1057
  let snapshotOutcome = 'miss'
@@ -1029,44 +1067,101 @@ export function createServer<
1029
1067
  if (result.issues) {
1030
1068
  snapshotOutcome = 'rejected'
1031
1069
  } else {
1032
- state = result.value as S
1070
+ state = result.value
1033
1071
  index = snap.index
1034
1072
  snapshotOutcome = 'hit'
1035
1073
  }
1036
1074
  } else {
1037
- state = snap.state as S
1075
+ state = snap.state
1038
1076
  index = snap.index
1039
1077
  snapshotOutcome = 'hit'
1040
1078
  }
1041
1079
  }
1042
- span.setAttribute('a2.state.snapshot', snapshotOutcome)
1043
-
1044
1080
  if (snapshotOutcome === 'rejected') {
1045
1081
  try {
1046
- rows = await store.read(nsId(sessionId))
1082
+ rows = await store.read(
1083
+ nsId(plan.sessionId),
1084
+ plan.throughIndex === undefined
1085
+ ? undefined
1086
+ : { throughIndex: plan.throughIndex },
1087
+ )
1047
1088
  } catch (err) {
1048
1089
  throw asStoreUnavailable(err)
1049
1090
  }
1050
1091
  }
1051
- for (const row of rows) {
1052
- state = reducer.fold(state, toPublic(row) as never)
1053
- index = row.index
1092
+ const ordered = plan.entries.toSorted(
1093
+ (a, b) =>
1094
+ (a.throughIndex ?? Number.POSITIVE_INFINITY) -
1095
+ (b.throughIndex ?? Number.POSITIVE_INFINITY),
1096
+ )
1097
+ const writes = new Map<
1098
+ number,
1099
+ { state: unknown; pinEventIndexes: Set<number> }
1100
+ >()
1101
+ let rowPosition = 0
1102
+ for (const entry of ordered) {
1103
+ const target = entry.throughIndex ?? Number.POSITIVE_INFINITY
1104
+ while (rowPosition < rows.length && rows[rowPosition]!.index <= target) {
1105
+ const row = rows[rowPosition]!
1106
+ state = reducer.fold(state, toPublic(row) as never)
1107
+ index = row.index
1108
+ rowPosition += 1
1109
+ }
1110
+ const resultState = cloneInitial(state)
1111
+ const folded = rowPosition
1112
+ entry.span.setAttribute('a2.state.snapshot', snapshotOutcome)
1113
+ entry.span.setAttribute('a2.state.folded', folded)
1114
+ entry.span.setAttribute('a2.state.index', index)
1115
+ if (index > 0 && (folded > 0 || entry.pinEventIndex !== undefined)) {
1116
+ const advancesHead =
1117
+ stateRead.headIndex === null || index > stateRead.headIndex
1118
+ const repairsHead =
1119
+ snapshotOutcome === 'rejected' && index === stateRead.headIndex
1120
+ if (
1121
+ !advancesHead &&
1122
+ !repairsHead &&
1123
+ entry.pinEventIndex === undefined
1124
+ ) {
1125
+ entry.resolve({ state: resultState, index })
1126
+ continue
1127
+ }
1128
+ let write = writes.get(index)
1129
+ if (!write) {
1130
+ write = {
1131
+ state: cloneInitial(resultState),
1132
+ pinEventIndexes: new Set(),
1133
+ }
1134
+ writes.set(index, write)
1135
+ }
1136
+ if (entry.pinEventIndex !== undefined) {
1137
+ write.pinEventIndexes.add(entry.pinEventIndex)
1138
+ }
1139
+ }
1140
+ entry.resolve({ state: resultState, index })
1141
+ }
1142
+ if (writes.size > 0) {
1143
+ const snapshots: StoreSnapshotWrite[] = []
1144
+ for (const [snapshotIndex, write] of writes) {
1145
+ const snapshot: StoreSnapshotWrite = {
1146
+ index: snapshotIndex,
1147
+ state: write.state,
1148
+ }
1149
+ if (write.pinEventIndexes.size > 0) {
1150
+ snapshot.pinEventIndexes = [...write.pinEventIndexes]
1151
+ }
1152
+ snapshots.push(snapshot)
1153
+ }
1154
+ const persistence = Promise.resolve()
1155
+ .then(() =>
1156
+ store.putSnapshots(nsId(plan.sessionId), reducer.name, snapshots),
1157
+ )
1158
+ .catch(() => {})
1159
+ track(persistence)
1160
+ platformWaitUntil(persistence)
1054
1161
  }
1055
- span.setAttribute('a2.state.folded', rows.length)
1056
- span.setAttribute('a2.state.index', index)
1057
- return { state, index, folded: rows.length }
1058
1162
  }
1059
1163
 
1060
1164
  // ── same-tick state-read coalescing ──────────────────────────────
1061
- // Concurrent state() calls for one reducer name coalesce into a
1062
- // single store.readStates() round trip when the adapter has one.
1063
- // Unlike DataLoader there is deliberately NO result cache: a batch
1064
- // exists only between enqueue and flush, and nothing is shared after
1065
- // distribution — every state() call still observes a fresh frontier.
1066
- type PendingStateRead = {
1067
- sessionId: string
1068
- resolve: (read: StoreStateRead | Promise<StoreStateRead>) => void
1069
- }
1070
1165
  const pendingStateReads = new Map<string, PendingStateRead[]>()
1071
1166
 
1072
1167
  // The flush microtask voids this promise, so the function must be
@@ -1074,40 +1169,62 @@ export function createServer<
1074
1169
  // synchronously throwing adapter or a read structuredClone rejects.
1075
1170
  const flushStateReads = async (
1076
1171
  store: A2Store,
1077
- reducerName: string,
1172
+ reducer: Reducer<D, unknown>,
1078
1173
  batch: PendingStateRead[],
1079
1174
  ): Promise<void> => {
1175
+ const plans = planStateReads(batch)
1080
1176
  let reads: StoreStateRead[] | null
1081
1177
  try {
1082
1178
  reads = store.readStates
1083
1179
  ? await store.readStates(
1084
- batch.map((entry) => nsId(entry.sessionId)),
1085
- reducerName,
1180
+ plans.map((plan) => ({
1181
+ sessionId: nsId(plan.sessionId),
1182
+ ...(plan.throughIndex === undefined
1183
+ ? {}
1184
+ : { throughIndex: plan.throughIndex }),
1185
+ ...(plan.snapshotThroughIndex === undefined
1186
+ ? {}
1187
+ : { snapshotThroughIndex: plan.snapshotThroughIndex }),
1188
+ })),
1189
+ reducer.name,
1190
+ )
1191
+ : await Promise.all(
1192
+ plans.map((plan) =>
1193
+ readCachedState(
1194
+ store,
1195
+ plan.sessionId,
1196
+ reducer.name,
1197
+ plan.throughIndex,
1198
+ plan.snapshotThroughIndex,
1199
+ ),
1200
+ ),
1086
1201
  )
1087
- : null
1088
1202
  } catch {
1089
1203
  reads = null
1090
1204
  }
1091
- // A failed or misaligned batch is an ill-behaved cache path, never a
1092
- // batch-shaped error: each caller falls back to its own per-call read
1093
- // (promise adoption carries a real storage failure to that caller).
1094
- if (!reads || reads.length !== batch.length) {
1095
- for (const entry of batch) {
1096
- entry.resolve(readCachedState(store, entry.sessionId, reducerName))
1205
+ if (!reads || reads.length !== plans.length) {
1206
+ try {
1207
+ reads = await Promise.all(
1208
+ plans.map((plan) =>
1209
+ readCachedState(
1210
+ store,
1211
+ plan.sessionId,
1212
+ reducer.name,
1213
+ plan.throughIndex,
1214
+ plan.snapshotThroughIndex,
1215
+ ),
1216
+ ),
1217
+ )
1218
+ } catch (err) {
1219
+ for (const entry of batch) entry.reject(err)
1220
+ return
1097
1221
  }
1098
- return
1099
1222
  }
1100
- // Duplicate ids share one store read; their folds must not share
1101
- // payload objects, exactly as two separate state() calls would not.
1102
- const seen = new Set<string>()
1103
- for (const [position, entry] of batch.entries()) {
1104
- const shared = seen.has(entry.sessionId)
1105
- seen.add(entry.sessionId)
1223
+ for (const [position, plan] of plans.entries()) {
1106
1224
  try {
1107
- const read = reads[position]!
1108
- entry.resolve(shared ? structuredClone(read) : read)
1109
- } catch {
1110
- entry.resolve(readCachedState(store, entry.sessionId, reducerName))
1225
+ await foldStatePlan(store, reducer, plan, reads[position]!)
1226
+ } catch (err) {
1227
+ for (const entry of plan.entries) entry.reject(err)
1111
1228
  }
1112
1229
  }
1113
1230
  }
@@ -1115,9 +1232,13 @@ export function createServer<
1115
1232
  const enqueueStateRead = (
1116
1233
  store: A2Store,
1117
1234
  sessionId: string,
1118
- reducerName: string,
1119
- ): Promise<StoreStateRead> =>
1120
- new Promise((resolve) => {
1235
+ reducer: Reducer<D, unknown>,
1236
+ throughIndex?: number,
1237
+ pinEventIndex?: number,
1238
+ span?: A2SpanHandle,
1239
+ ): Promise<{ state: unknown; index: number }> =>
1240
+ new Promise((resolve, reject) => {
1241
+ const reducerName = reducer.name
1121
1242
  let batch = pendingStateReads.get(reducerName)
1122
1243
  if (!batch) {
1123
1244
  const opened: PendingStateRead[] = []
@@ -1133,16 +1254,25 @@ export function createServer<
1133
1254
  // the last. A deeper-staggered caller just opens the next batch.
1134
1255
  queueMicrotask(() => {
1135
1256
  pendingStateReads.delete(reducerName)
1136
- void flushStateReads(store, reducerName, opened)
1257
+ void flushStateReads(store, reducer, opened)
1137
1258
  })
1138
1259
  batch = opened
1139
1260
  }
1140
- batch.push({ sessionId, resolve })
1261
+ batch.push({
1262
+ sessionId,
1263
+ ...(throughIndex === undefined ? {} : { throughIndex }),
1264
+ ...(pinEventIndex === undefined ? {} : { pinEventIndex }),
1265
+ span: span!,
1266
+ resolve,
1267
+ reject,
1268
+ })
1141
1269
  })
1142
1270
 
1143
1271
  const readState = async <S>(
1144
1272
  sessionId: string,
1145
1273
  reducer: Reducer<D, S>,
1274
+ throughIndex?: number,
1275
+ pinEventIndex?: number,
1146
1276
  ): Promise<{ state: S; index: number }> => {
1147
1277
  const store = await resolveStore()
1148
1278
  return telemetry.span(
@@ -1152,36 +1282,15 @@ export function createServer<
1152
1282
  'a2.session_id': sessionId,
1153
1283
  'a2.state.reducer': reducer.name,
1154
1284
  },
1155
- async (span) => {
1156
- const stateRead = store.readStates
1157
- ? await enqueueStateRead(store, sessionId, reducer.name)
1158
- : await readCachedState(store, sessionId, reducer.name)
1159
- const { state, index, folded } = await foldStateRead(
1285
+ (span) =>
1286
+ enqueueStateRead(
1160
1287
  store,
1161
1288
  sessionId,
1162
- reducer,
1163
- stateRead,
1289
+ reducer as Reducer<D, unknown>,
1290
+ throughIndex,
1291
+ pinEventIndex,
1164
1292
  span,
1165
- )
1166
- // Write-back is a disposable cache. Copy before returning so caller
1167
- // mutations cannot race the background persistence.
1168
- if (folded > 0) {
1169
- const snapshotState = cloneInitial(state)
1170
- const write = Promise.resolve()
1171
- .then(() =>
1172
- store.putSnapshot(
1173
- nsId(sessionId),
1174
- reducer.name,
1175
- index,
1176
- snapshotState,
1177
- ),
1178
- )
1179
- .catch(() => {})
1180
- track(write)
1181
- platformWaitUntil(write)
1182
- }
1183
- return { state, index }
1184
- },
1293
+ ) as Promise<{ state: S; index: number }>,
1185
1294
  )
1186
1295
  }
1187
1296
 
@@ -1205,11 +1314,11 @@ export function createServer<
1205
1314
  const payloads = events.map((event) =>
1206
1315
  cloneScheduledPayload(event.payload),
1207
1316
  )
1208
- const snapshottedEvents = events.map((event, index) => ({
1209
- type: event.type,
1210
- payload: payloads[index],
1211
- ...(event.id === undefined ? {} : { id: event.id }),
1212
- })) as AppendInput<D>[]
1317
+ const snapshottedEvents = events.map((event, index) =>
1318
+ event.id === undefined
1319
+ ? { type: event.type, payload: payloads[index] }
1320
+ : { type: event.type, payload: payloads[index], id: event.id },
1321
+ ) as AppendInput<D>[]
1213
1322
  const validated = validateEvents(
1214
1323
  sessionId,
1215
1324
  structuredClone(snapshottedEvents),
@@ -1295,22 +1404,6 @@ export function createServer<
1295
1404
  },
1296
1405
  fallbackSeen: number,
1297
1406
  ): 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
1407
  if (
1315
1408
  typeof patch.participant !== 'string' ||
1316
1409
  patch.participant.length === 0
@@ -1636,7 +1729,18 @@ export function createServer<
1636
1729
  schedule: makeSchedule(id, trigger),
1637
1730
  history: async (bounds?: { gte?: number; lte?: number }) =>
1638
1731
  (await readHistory(id, bounds)).map(toPublic),
1639
- state: <S>(reducer: Reducer<D, S>) => readState(id, reducer),
1732
+ state: <S>(reducer: Reducer<D, S>, stateOptions?: StateOptions) => {
1733
+ const through = stateOptions?.through ?? trigger?.index
1734
+ if (through !== undefined && through !== 'latest') {
1735
+ assertStateIndex(through)
1736
+ }
1737
+ return readState(
1738
+ id,
1739
+ reducer,
1740
+ through === 'latest' ? undefined : through,
1741
+ trigger && through !== 'latest' ? trigger.index : undefined,
1742
+ )
1743
+ },
1640
1744
  stream: (opts?: { startAfter?: number; presence?: boolean }) => {
1641
1745
  const startAfter = opts?.startAfter ?? 0
1642
1746
  assertStreamIndex(startAfter)
@@ -2240,16 +2344,38 @@ export function createServer<
2240
2344
  shouldClaim = false
2241
2345
  }
2242
2346
  }
2347
+ if (
2348
+ claim?.outcome === 'claimed' &&
2349
+ [...active.values()].some((entry) => entry.lapsed)
2350
+ ) {
2351
+ shouldClaim = true
2352
+ continue
2353
+ }
2243
2354
  if (active.size > 0) {
2244
2355
  const wake = signal?.wait(signalVersion)
2245
- await Promise.race([
2246
- ...[...active.values()].map((entry) => entry.execution),
2247
- ...(wake ? [wake.promise] : []),
2356
+ const hasLapsed = [...active.values()].some(
2357
+ (entry) => entry.lapsed,
2358
+ )
2359
+ const retry =
2360
+ claim?.outcome === 'busy' && hasLapsed
2361
+ ? defaultSleep(
2362
+ Math.max(0, claim.retryAt.getTime() - Date.now()),
2363
+ )
2364
+ : undefined
2365
+ const retryDue = await Promise.race([
2366
+ ...[...active.values()].map((entry) =>
2367
+ entry.execution.then(() => false),
2368
+ ),
2369
+ ...(wake ? [wake.promise.then(() => false)] : []),
2370
+ ...(retry ? [retry.promise.then(() => true)] : []),
2248
2371
  ])
2249
2372
  wake?.cancel()
2373
+ retry?.cancel()
2250
2374
  shouldClaim =
2375
+ retryDue ||
2251
2376
  claimAgain ||
2252
2377
  active.size === 0 ||
2378
+ hasLapsed ||
2253
2379
  (signal !== undefined && signal.version !== signalVersion)
2254
2380
  claimAgain = false
2255
2381
  continue
@@ -2298,19 +2424,12 @@ export function createServer<
2298
2424
  const appendExternal = async (
2299
2425
  sessionId: string,
2300
2426
  mode: 'inline' | 'dispatch',
2301
- events: Array<AppendInput<D> | PushedEvent>,
2427
+ events: AppendInput<D>[],
2302
2428
  requireDurableInitialArm = false,
2303
2429
  ): 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
2430
  return appendCore(
2312
2431
  sessionId,
2313
- events as AppendInput<D>[],
2432
+ events,
2314
2433
  'external',
2315
2434
  mode,
2316
2435
  (_rows, watchdogDueAt) => scheduleDrain(sessionId, watchdogDueAt),
@@ -2320,21 +2439,23 @@ export function createServer<
2320
2439
  )
2321
2440
  }
2322
2441
 
2442
+ const session = (id: string): Session<D, SessionAppend<D>, P> => {
2443
+ assertSessionId(id)
2444
+ const appendInline = async (...events: AppendInput<D>[]) =>
2445
+ appendExternal(id, 'inline', events)
2446
+ const appendDispatch = async (...events: AppendInput<D>[]) =>
2447
+ appendExternal(id, 'dispatch', events)
2448
+ const append = Object.assign(appendInline, {
2449
+ dispatch: appendDispatch,
2450
+ }) as SessionAppend<D>
2451
+ return makeSession(id, append, null)
2452
+ }
2453
+
2454
+ const fetch = createServerFetch({ contract: serverContract, session })
2323
2455
  const self: A2Server<D, P> = {
2324
2456
  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
- },
2457
+ fetch,
2458
+ session,
2338
2459
 
2339
2460
  async drain(sessionId) {
2340
2461
  assertSessionId(sessionId)
@@ -2363,126 +2484,6 @@ export function createServer<
2363
2484
  await appendExternal(sessionId, 'inline', [...events], true)
2364
2485
  },
2365
2486
  })
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
2487
  return self
2487
2488
  }
2488
2489
 
@@ -2507,6 +2508,12 @@ function assertStreamIndex(value: number): void {
2507
2508
  }
2508
2509
  }
2509
2510
 
2511
+ function assertStateIndex(value: number): void {
2512
+ if (!Number.isSafeInteger(value) || value < 0) {
2513
+ throw new TypeError('state.through must be a non-negative safe integer')
2514
+ }
2515
+ }
2516
+
2510
2517
  function assertAppendName(name: string): void {
2511
2518
  if (typeof name !== 'string' || name.length === 0) {
2512
2519
  throw new TypeError('handler append name must be a non-empty string')
@@ -2666,7 +2673,6 @@ function cloneInitial<S>(initial: S): S {
2666
2673
  // transports — re-exported so experimental-a2/server is self-sufficient.
2667
2674
  export type {
2668
2675
  A2Store,
2669
- A2StoreInspection,
2670
2676
  AppendEvent,
2671
2677
  Clock,
2672
2678
  Event,
@@ -2678,9 +2684,6 @@ export type {
2678
2684
  StoreStateRead,
2679
2685
  PresenceRow,
2680
2686
  StoredEvent,
2681
- StoredSessionPage,
2682
- StoredSessionSummary,
2683
- StoredSnapshot,
2684
2687
  } from './store.ts'
2685
2688
  export type {
2686
2689
  ScheduledEvent,