experimental-a2 0.4.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.
Files changed (182) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +4 -3
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +4 -2
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +1 -1
  11. package/dist/ai.js +3 -1
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +2 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.js +2 -0
  16. package/dist/cli-bin.js.map +1 -0
  17. package/dist/cli.d.ts +2 -1
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
  20. package/dist/client-BKlyLiOU.js.map +1 -0
  21. package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
  22. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -2
  24. package/dist/client.js +1 -1
  25. package/dist/contract-48bUMgcL.js +2 -0
  26. package/dist/contract-48bUMgcL.js.map +1 -0
  27. package/dist/contract-jIfaR085.d.ts +2 -1
  28. package/dist/contract-jIfaR085.d.ts.map +1 -0
  29. package/dist/devtools-J_jZ2vQf.d.ts +2 -1
  30. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  31. package/dist/devtools-kJJaORn-.js +2 -0
  32. package/dist/devtools-kJJaORn-.js.map +1 -0
  33. package/dist/devtools-server.browser.js +2 -0
  34. package/dist/devtools-server.browser.js.map +1 -0
  35. package/dist/devtools-server.d.ts +2 -1
  36. package/dist/devtools-server.d.ts.map +1 -0
  37. package/dist/devtools-server.js +2 -0
  38. package/dist/devtools-server.js.map +1 -0
  39. package/dist/errors-BQuJpe82.js +2 -0
  40. package/dist/errors-BQuJpe82.js.map +1 -0
  41. package/dist/errors-W6nwJ-fm.d.ts +2 -1
  42. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  43. package/dist/http.d.ts +121 -72
  44. package/dist/http.d.ts.map +1 -0
  45. package/dist/http.js +503 -178
  46. package/dist/http.js.map +1 -0
  47. package/dist/idempotent-replay-DuqEkYA7.js +2 -0
  48. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  49. package/dist/index.d.ts +1 -1
  50. package/dist/inspection-DaxB5jM2.js +2 -0
  51. package/dist/inspection-DaxB5jM2.js.map +1 -0
  52. package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
  53. package/dist/internal-DstsI6Re.js.map +1 -0
  54. package/dist/otel.d.ts +2 -1
  55. package/dist/otel.d.ts.map +1 -0
  56. package/dist/otel.js +2 -0
  57. package/dist/otel.js.map +1 -0
  58. package/dist/platform-B4TnJtWu.js +2 -0
  59. package/dist/platform-B4TnJtWu.js.map +1 -0
  60. package/dist/react.d.ts +12 -3
  61. package/dist/react.d.ts.map +1 -0
  62. package/dist/react.js +5 -1
  63. package/dist/react.js.map +1 -0
  64. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  65. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +4 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -0
  68. package/dist/scheduler-qstash.js +4 -2
  69. package/dist/scheduler-qstash.js.map +1 -0
  70. package/dist/scheduler-task-BpzhPnRS.js +2 -0
  71. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -3
  73. package/dist/scheduler-vercel.d.ts.map +1 -0
  74. package/dist/scheduler-vercel.js +4 -2
  75. package/dist/scheduler-vercel.js.map +1 -0
  76. package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
  77. package/dist/server-C72KOw51.js.map +1 -0
  78. package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
  79. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  80. package/dist/server.browser.js +2 -0
  81. package/dist/server.browser.js.map +1 -0
  82. package/dist/server.d.ts +2 -2
  83. package/dist/server.js +1 -1
  84. package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
  85. package/dist/store-DysUkTH3.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js +2 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -0
  88. package/dist/store-codec-DTG0Ftek.js +2 -0
  89. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  90. package/dist/store-memory.d.ts +3 -2
  91. package/dist/store-memory.d.ts.map +1 -0
  92. package/dist/store-memory.js +19 -11
  93. package/dist/store-memory.js.map +1 -0
  94. package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
  95. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  96. package/dist/store-postgres.d.ts +3 -2
  97. package/dist/store-postgres.d.ts.map +1 -0
  98. package/dist/store-postgres.js +57 -1
  99. package/dist/store-postgres.js.map +1 -0
  100. package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
  101. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  102. package/dist/store-redis-http.d.ts +3 -2
  103. package/dist/store-redis-http.d.ts.map +1 -0
  104. package/dist/store-redis-http.js +4 -2
  105. package/dist/store-redis-http.js.map +1 -0
  106. package/dist/store-redis.d.ts +3 -2
  107. package/dist/store-redis.d.ts.map +1 -0
  108. package/dist/store-redis.js +5 -3
  109. package/dist/store-redis.js.map +1 -0
  110. package/dist/store-sqlite.d.ts +3 -2
  111. package/dist/store-sqlite.d.ts.map +1 -0
  112. package/dist/store-sqlite.js +3 -1
  113. package/dist/store-sqlite.js.map +1 -0
  114. package/dist/telemetry-BjYHTfh2.d.ts +2 -1
  115. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  116. package/dist/testing.browser.js +2 -0
  117. package/dist/testing.browser.js.map +1 -0
  118. package/dist/testing.d.ts +2 -1
  119. package/dist/testing.d.ts.map +1 -0
  120. package/dist/testing.js +2 -0
  121. package/dist/testing.js.map +1 -0
  122. package/dist/validate-XKT4FSNn.js +2 -0
  123. package/dist/validate-XKT4FSNn.js.map +1 -0
  124. package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
  125. package/dist/wire-BFQmSJ-9.js.map +1 -0
  126. package/docs/guides/03-react.mdx +59 -39
  127. package/docs/guides/06-ai-agents.mdx +5 -27
  128. package/docs/guides/09-presence.mdx +19 -40
  129. package/docs/guides/10-transports.mdx +49 -40
  130. package/docs/reference/01-api.mdx +107 -26
  131. package/docs/reference/02-errors.mdx +4 -2
  132. package/package.json +2 -1
  133. package/src/ai-coordinator.ts +358 -0
  134. package/src/ai-projector.ts +524 -0
  135. package/src/ai-sdk-step.ts +261 -0
  136. package/src/ai-server.browser.ts +5 -0
  137. package/src/ai-server.ts +1719 -0
  138. package/src/ai.ts +2155 -0
  139. package/src/cache-indexeddb.ts +10 -0
  140. package/src/cli-bin.ts +5 -0
  141. package/src/cli.ts +1046 -0
  142. package/src/client.ts +1826 -0
  143. package/src/contract.ts +206 -0
  144. package/src/deterministic-id.ts +72 -0
  145. package/src/devtools-app.ts +989 -0
  146. package/src/devtools-server.browser.ts +5 -0
  147. package/src/devtools-server.ts +604 -0
  148. package/src/devtools.ts +716 -0
  149. package/src/errors.ts +50 -0
  150. package/src/http.ts +394 -0
  151. package/src/idempotent-replay.ts +53 -0
  152. package/src/index.ts +37 -0
  153. package/src/inspection.ts +39 -0
  154. package/src/internal.ts +426 -0
  155. package/src/otel.ts +59 -0
  156. package/src/platform.ts +60 -0
  157. package/src/push-envelope.ts +137 -0
  158. package/src/react.ts +284 -0
  159. package/src/reducer.ts +108 -0
  160. package/src/retryable-lazy.ts +27 -0
  161. package/src/scheduler-qstash.ts +915 -0
  162. package/src/scheduler-task.ts +106 -0
  163. package/src/scheduler-vercel.ts +437 -0
  164. package/src/server.browser.ts +12 -0
  165. package/src/server.ts +2700 -0
  166. package/src/session-socket.ts +548 -0
  167. package/src/sse.ts +141 -0
  168. package/src/standard-schema.ts +77 -0
  169. package/src/store-codec.ts +10 -0
  170. package/src/store-memory.ts +788 -0
  171. package/src/store-polling.ts +102 -0
  172. package/src/store-postgres.ts +1212 -0
  173. package/src/store-redis-core.ts +1494 -0
  174. package/src/store-redis-http.ts +116 -0
  175. package/src/store-redis.ts +458 -0
  176. package/src/store-sqlite.ts +1108 -0
  177. package/src/store.ts +385 -0
  178. package/src/telemetry.ts +47 -0
  179. package/src/testing.browser.ts +5 -0
  180. package/src/testing.ts +185 -0
  181. package/src/validate.ts +39 -0
  182. package/src/wire.ts +454 -0
@@ -1,5 +1,5 @@
1
1
  import { t as A2Error } from "./errors-BQuJpe82.js";
2
- import { g as nullProtoRecord } from "./internal-aEotMzu_.js";
2
+ import { g as nullProtoRecord } from "./internal-DstsI6Re.js";
3
3
  //#region src/wire.ts
4
4
  /**
5
5
  * The wire format shared by experimental-a2/http (server side) and experimental-a2/client
@@ -109,6 +109,13 @@ function errorToWire(error) {
109
109
  if (error.details !== void 0) body.error.details = error.details;
110
110
  return body;
111
111
  }
112
+ /** Wrap an arbitrary thrown value for the wire: A2Errors pass through,
113
+ * anything else becomes STORE_UNAVAILABLE — from the client's
114
+ * perspective an unknown server failure is retryable-once, not a
115
+ * protocol contract. */
116
+ function asA2Error(error) {
117
+ return error instanceof A2Error ? error : new A2Error("STORE_UNAVAILABLE", "internal error", { cause: error });
118
+ }
112
119
  /** Rebuild an A2Error from a wire body; null if the body isn't one. */
113
120
  function errorFromWire(body) {
114
121
  if (body === null || typeof body !== "object") return null;
@@ -120,33 +127,55 @@ function errorFromWire(body) {
120
127
  }
121
128
  const SOCKET_PING_FRAME = JSON.stringify({ kind: "ping" });
122
129
  /** One stream item as a socket frame — `sseResponse`'s framing over
123
- * the same codecs, with `kind` instead of an SSE event name. */
124
- function socketFrameFor(item) {
130
+ * the same codecs, with `kind` instead of an SSE event name. On a
131
+ * multiplexed socket presence frames carry the `sessionId` tag; events
132
+ * already carry theirs in the wire event. */
133
+ function socketFrameFor(item, sessionId) {
134
+ const tag = sessionId === void 0 ? {} : { sessionId };
125
135
  if ("snapshot" in item) return JSON.stringify({
126
136
  kind: "presence-snapshot",
127
- ...presenceSnapshotToWire(item)
137
+ ...presenceSnapshotToWire(item),
138
+ ...tag
128
139
  });
129
140
  if ("participant" in item) return JSON.stringify({
130
141
  kind: "presence",
131
- ...presencePatchToWire(item)
142
+ ...presencePatchToWire(item),
143
+ ...tag
132
144
  });
133
145
  return JSON.stringify({
134
146
  kind: "event",
135
147
  ...eventToWire(item)
136
148
  });
137
149
  }
138
- function socketAckFor(req, events) {
150
+ function socketAckFor(req, events, sessionId) {
139
151
  return JSON.stringify({
140
152
  kind: "ack",
141
153
  req,
142
- events: events.map(eventToWire)
154
+ events: events.map(eventToWire),
155
+ ...sessionId === void 0 ? {} : { sessionId }
143
156
  });
144
157
  }
145
- function socketErrorAckFor(req, error) {
158
+ function socketErrorAckFor(req, error, sessionId) {
146
159
  return JSON.stringify({
147
160
  kind: "ack",
148
161
  req,
149
- ...errorToWire(error)
162
+ ...errorToWire(error),
163
+ ...sessionId === void 0 ? {} : { sessionId }
164
+ });
165
+ }
166
+ function socketSubscribedFor(sessionId) {
167
+ return JSON.stringify({
168
+ kind: "subscribed",
169
+ sessionId
170
+ });
171
+ }
172
+ /** The subscription-over notice: with a `reason` the server rejected
173
+ * or lost the session's stream, without one it ended cleanly. */
174
+ function socketUnsubscribedFor(sessionId, reason) {
175
+ return JSON.stringify({
176
+ kind: "unsubscribed",
177
+ sessionId,
178
+ ...reason === void 0 ? {} : { reason }
150
179
  });
151
180
  }
152
181
  function parseSocketFrame(data) {
@@ -185,38 +214,71 @@ function parseSocketFrame(data) {
185
214
  values,
186
215
  seen,
187
216
  at
188
- }
217
+ },
218
+ ...sessionTag(frame)
189
219
  };
190
220
  }
191
221
  case "presence-snapshot":
192
222
  if (!isWirePresenceSnapshot(parsed)) return null;
193
223
  return {
194
224
  kind: "presence-snapshot",
195
- snapshot: { snapshot: parsed.snapshot }
225
+ snapshot: { snapshot: parsed.snapshot },
226
+ ...sessionTag(frame)
227
+ };
228
+ case "subscribed": {
229
+ const sessionId = frame["sessionId"];
230
+ if (typeof sessionId !== "string") return null;
231
+ return {
232
+ kind: "subscribed",
233
+ sessionId
196
234
  };
235
+ }
236
+ case "unsubscribed": {
237
+ const sessionId = frame["sessionId"];
238
+ if (typeof sessionId !== "string") return null;
239
+ const reason = frame["reason"];
240
+ return typeof reason === "string" ? {
241
+ kind: "unsubscribed",
242
+ sessionId,
243
+ reason
244
+ } : {
245
+ kind: "unsubscribed",
246
+ sessionId
247
+ };
248
+ }
197
249
  case "ack": {
198
250
  const req = frame["req"];
199
251
  if (typeof req !== "number") return null;
252
+ const tag = sessionTag(frame);
200
253
  const events = frame["events"];
201
254
  if (Array.isArray(events) && events.every(isWireEvent)) return {
202
255
  kind: "ack",
203
256
  req,
204
- events
257
+ events,
258
+ ...tag
205
259
  };
206
260
  const error = errorFromWire(parsed);
207
261
  if (error) return {
208
262
  kind: "ack",
209
263
  req,
210
- error
264
+ error,
265
+ ...tag
211
266
  };
212
267
  return {
213
268
  kind: "ack",
214
269
  req,
215
- error: new A2Error("STORE_UNAVAILABLE", "unintelligible ack")
270
+ error: new A2Error("STORE_UNAVAILABLE", "unintelligible ack"),
271
+ ...tag
216
272
  };
217
273
  }
218
274
  default: return null;
219
275
  }
220
276
  }
277
+ const sessionTag = (frame) => {
278
+ const sessionId = frame["sessionId"];
279
+ return typeof sessionId === "string" ? { sessionId } : {};
280
+ };
221
281
  //#endregion
222
- export { socketFrameFor as _, eventFromWire as a, isWirePresencePatch as c, presencePatchFromWire as d, presencePatchToWire as f, socketErrorAckFor as g, socketAckFor as h, errorToWire as i, isWirePresenceSnapshot as l, presenceSnapshotToWire as m, errorFromWire as n, eventToWire as o, presenceSnapshotFromWire as p, errorStatus as r, isWireEvent as s, SOCKET_PING_FRAME as t, parseSocketFrame as u };
282
+ export { socketErrorAckFor as _, errorToWire as a, socketUnsubscribedFor as b, isWireEvent as c, parseSocketFrame as d, presencePatchFromWire as f, socketAckFor as g, presenceSnapshotToWire as h, errorStatus as i, isWirePresencePatch as l, presenceSnapshotFromWire as m, asA2Error as n, eventFromWire as o, presencePatchToWire as p, errorFromWire as r, eventToWire as s, SOCKET_PING_FRAME as t, isWirePresenceSnapshot as u, socketFrameFor as v, socketSubscribedFor as y };
283
+
284
+ //# sourceMappingURL=wire-BFQmSJ-9.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wire-BFQmSJ-9.js","names":[],"sources":["../src/wire.ts"],"sourcesContent":["/**\n * The wire format shared by experimental-a2/http (server side) and experimental-a2/client\n * (browser side): events as JSON with ISO timestamps, and the A2Error\n * envelope. Internal module — both entry points re-export what's\n * public.\n */\n\nimport { A2Error, type A2ErrorCode } from './errors.ts'\nimport { nullProtoRecord } from './internal.ts'\nimport type { Event } from './store.ts'\nimport type {\n PresenceMap,\n PresencePatch,\n PresenceSnapshot,\n} from './contract.ts'\n\nexport type WireEvent = {\n id: string\n type: string\n payload: unknown\n index: number\n sessionId: string\n /** ISO 8601 — revived to a Date on the client so reducers see the\n * same shape on both sides of the wire. */\n createdAt: string\n}\n\nexport function eventToWire(event: Event): WireEvent {\n return {\n id: event.id,\n type: event.type,\n payload: event.payload,\n index: event.index,\n sessionId: event.sessionId,\n createdAt: event.createdAt.toISOString(),\n }\n}\n\nexport function eventFromWire(wire: WireEvent): Event {\n return {\n id: wire.id,\n type: wire.type,\n payload: wire.payload,\n index: wire.index,\n sessionId: wire.sessionId,\n createdAt: new Date(wire.createdAt),\n }\n}\n\nexport function isWireEvent(value: unknown): value is WireEvent {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n typeof v['id'] === 'string' &&\n typeof v['type'] === 'string' &&\n typeof v['index'] === 'number' &&\n typeof v['sessionId'] === 'string' &&\n typeof v['createdAt'] === 'string'\n )\n}\n\n// ── the presence wire pair ───────────────────────────────────────────\n\n/** The `event: presence` SSE frame body: one patch as JSON. */\nexport type WirePresencePatch = {\n participant: string\n values: Record<string, unknown>\n seen: number\n /** ISO 8601 — revived to a Date on the client. */\n at: string\n}\n\n/**\n * The `event: presence-snapshot` SSE frame body: the in-memory\n * `PresenceSnapshot` shape verbatim, with each per-field `at`\n * serialized to ISO 8601.\n */\nexport type WirePresenceSnapshot = {\n snapshot: {\n [participant: string]: {\n [field: string]: { value: unknown; seen: number; at: string }\n }\n }\n}\n\nexport function presencePatchToWire(patch: PresencePatch): WirePresencePatch {\n return {\n participant: patch.participant,\n values: patch.values,\n seen: patch.seen,\n at: patch.at.toISOString(),\n }\n}\n\nexport function presencePatchFromWire(wire: WirePresencePatch): PresencePatch {\n return {\n participant: wire.participant,\n values: wire.values,\n seen: wire.seen,\n at: new Date(wire.at),\n }\n}\n\n// Participant and field keys come off the wire, so every object keyed\n// by them is built null-prototype — see `nullProtoRecord`.\nexport function presenceSnapshotToWire(\n snapshot: PresenceSnapshot,\n): WirePresenceSnapshot {\n const wire: WirePresenceSnapshot['snapshot'] = nullProtoRecord()\n for (const [participant, fields] of Object.entries(snapshot.snapshot)) {\n const wireFields: WirePresenceSnapshot['snapshot'][string] =\n nullProtoRecord()\n for (const [field, entry] of Object.entries(fields)) {\n if (entry === undefined) continue\n wireFields[field] = {\n value: entry.value,\n seen: entry.seen,\n at: entry.at.toISOString(),\n }\n }\n wire[participant] = wireFields\n }\n return { snapshot: wire }\n}\n\nexport function presenceSnapshotFromWire(\n wire: WirePresenceSnapshot,\n): PresenceSnapshot {\n const map: PresenceMap = nullProtoRecord()\n for (const [participant, fields] of Object.entries(wire.snapshot)) {\n const revived: PresenceMap[string] = nullProtoRecord()\n for (const [field, entry] of Object.entries(fields)) {\n revived[field] = {\n value: entry.value,\n seen: entry.seen,\n at: new Date(entry.at),\n }\n }\n map[participant] = revived\n }\n return { snapshot: map }\n}\n\nexport function isWirePresencePatch(\n value: unknown,\n): value is WirePresencePatch {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n typeof v['participant'] === 'string' &&\n v['values'] !== null &&\n typeof v['values'] === 'object' &&\n !Array.isArray(v['values']) &&\n typeof v['seen'] === 'number' &&\n typeof v['at'] === 'string'\n )\n}\n\nexport function isWirePresenceSnapshot(\n value: unknown,\n): value is WirePresenceSnapshot {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n v['snapshot'] !== null &&\n typeof v['snapshot'] === 'object' &&\n !Array.isArray(v['snapshot'])\n )\n}\n\n// ── the A2Error envelope ─────────────────────────────────────────────\n\nexport type WireError = {\n error: { code: A2ErrorCode; message: string; details?: unknown }\n}\n\nconst ERROR_STATUS: Record<A2ErrorCode, number> = {\n INVALID_PAYLOAD: 400,\n UNKNOWN_EVENT_TYPE: 400,\n PARTIAL_DUPLICATE_BATCH: 400,\n SUPERSEDED_ATTEMPT: 409,\n CLAIM_EXPIRED: 409,\n STORE_UNAVAILABLE: 503,\n STORE_NOT_CONFIGURED: 500,\n UNKNOWN_PRESENCE_FIELD: 400,\n PRESENCE_NOT_SUPPORTED: 500,\n}\n\nexport function errorStatus(code: A2ErrorCode): number {\n return ERROR_STATUS[code]\n}\n\nexport function errorToWire(error: A2Error): WireError {\n const body: WireError = {\n error: { code: error.code, message: error.message },\n }\n if (error.details !== undefined) body.error.details = error.details\n return body\n}\n\n/** Wrap an arbitrary thrown value for the wire: A2Errors pass through,\n * anything else becomes STORE_UNAVAILABLE — from the client's\n * perspective an unknown server failure is retryable-once, not a\n * protocol contract. */\nexport function asA2Error(error: unknown): A2Error {\n return error instanceof A2Error\n ? error\n : new A2Error('STORE_UNAVAILABLE', 'internal error', { cause: error })\n}\n\n/** Rebuild an A2Error from a wire body; null if the body isn't one. */\nexport function errorFromWire(body: unknown): A2Error | null {\n if (body === null || typeof body !== 'object') return null\n const err = (body as { error?: unknown }).error\n if (err === null || typeof err !== 'object') return null\n const { code, message, details } = err as Record<string, unknown>\n if (typeof code !== 'string' || !Object.hasOwn(ERROR_STATUS, code))\n return null\n return new A2Error(code as A2ErrorCode, String(message ?? code), {\n details,\n })\n}\n\n// ── the ws frame layer ───────────────────────────────────────────────\n// The SSE lanes reframed for a socket (specs/a2-api.md §13): every\n// message is one JSON text frame, and a frame is its wire payload plus\n// a `kind` discriminant (plus `req` where a reply must correlate).\n// Unknown kinds are skipped by both sides — the same\n// forward-compatibility rule as named SSE frames.\n//\n// The multiplexed superset rides the same frames: `subscribe` /\n// `unsubscribe` up-frames open and close per-session lanes on one\n// socket, `sessionId` tags route everything else. Single-session\n// frames (no tags) keep parsing unchanged — a route opts into\n// multiplexing by choosing the multi-session server handler, never by\n// breaking the old protocol.\n\n/**\n * Server → client, parsed: a live-stream item, a push ack, a\n * subscription lifecycle notice, or the heartbeat. `parseSocketFrame`\n * yields these; frames of unknown kind (or ones failing their shape\n * guard) come back `null`. `sessionId` is present on frames from a\n * multiplexed socket and absent on a single-session one.\n */\nexport type SocketDownFrame =\n | { kind: 'event'; event: WireEvent }\n | { kind: 'presence'; patch: WirePresencePatch; sessionId?: string }\n | {\n kind: 'presence-snapshot'\n snapshot: WirePresenceSnapshot\n sessionId?: string\n }\n | { kind: 'ack'; req: number; events: WireEvent[]; sessionId?: string }\n | { kind: 'ack'; req: number; error: A2Error; sessionId?: string }\n | { kind: 'subscribed'; sessionId: string }\n | { kind: 'unsubscribed'; sessionId: string; reason?: string }\n | { kind: 'ping' }\n\n/**\n * Client → server: the push envelope's two planes. `sessionId` is\n * implied by the socket on a single-session connection and required by\n * the multiplexed handler. Plain JSON on the wire; the server parses\n * and validates them through the same seams as `parsePushBody`.\n */\nexport type SocketPushFrame = {\n kind: 'push'\n /** Client-local ack correlator — opaque to the server. */\n req: number\n events: Array<{ type: string; payload: unknown; id?: string }>\n sessionId?: string\n}\n\nexport type SocketPresenceFrame = {\n kind: 'presence'\n participant: string\n values: Record<string, unknown>\n seen?: number\n at?: number\n sessionId?: string\n}\n\n/** Open per-session lanes on a multiplexed socket. `index` is each\n * session's exclusive resume frontier — `stream({ startAfter })`. */\nexport type SocketSubscribeFrame = {\n kind: 'subscribe'\n sessions: Array<{ id: string; index: number }>\n}\n\nexport type SocketUnsubscribeFrame = {\n kind: 'unsubscribe'\n sessions: string[]\n}\n\nexport type SocketUpFrame =\n | SocketPushFrame\n | SocketPresenceFrame\n | SocketSubscribeFrame\n | SocketUnsubscribeFrame\n\nexport const SOCKET_PING_FRAME: string = JSON.stringify({ kind: 'ping' })\n\n/** One stream item as a socket frame — `sseResponse`'s framing over\n * the same codecs, with `kind` instead of an SSE event name. On a\n * multiplexed socket presence frames carry the `sessionId` tag; events\n * already carry theirs in the wire event. */\nexport function socketFrameFor(\n item: Event | PresencePatch | PresenceSnapshot,\n sessionId?: string,\n): string {\n const tag = sessionId === undefined ? {} : { sessionId }\n if ('snapshot' in item) {\n return JSON.stringify({\n kind: 'presence-snapshot',\n ...presenceSnapshotToWire(item),\n ...tag,\n })\n }\n if ('participant' in item) {\n return JSON.stringify({\n kind: 'presence',\n ...presencePatchToWire(item),\n ...tag,\n })\n }\n return JSON.stringify({ kind: 'event', ...eventToWire(item) })\n}\n\nexport function socketAckFor(\n req: number,\n events: Event[],\n sessionId?: string,\n): string {\n return JSON.stringify({\n kind: 'ack',\n req,\n events: events.map(eventToWire),\n ...(sessionId === undefined ? {} : { sessionId }),\n })\n}\n\nexport function socketErrorAckFor(\n req: number,\n error: A2Error,\n sessionId?: string,\n): string {\n return JSON.stringify({\n kind: 'ack',\n req,\n ...errorToWire(error),\n ...(sessionId === undefined ? {} : { sessionId }),\n })\n}\n\nexport function socketSubscribedFor(sessionId: string): string {\n return JSON.stringify({ kind: 'subscribed', sessionId })\n}\n\n/** The subscription-over notice: with a `reason` the server rejected\n * or lost the session's stream, without one it ended cleanly. */\nexport function socketUnsubscribedFor(\n sessionId: string,\n reason?: string,\n): string {\n return JSON.stringify({\n kind: 'unsubscribed',\n sessionId,\n ...(reason === undefined ? {} : { reason }),\n })\n}\n\nexport function parseSocketFrame(data: string): SocketDownFrame | null {\n let parsed: unknown\n try {\n parsed = JSON.parse(data)\n } catch {\n return null\n }\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n return null\n }\n const frame = parsed as Record<string, unknown>\n switch (frame['kind']) {\n case 'ping':\n return { kind: 'ping' }\n case 'event': {\n if (!isWireEvent(parsed)) return null\n const { id, type, payload, index, sessionId, createdAt } = parsed\n return {\n kind: 'event',\n event: { id, type, payload, index, sessionId, createdAt },\n }\n }\n case 'presence': {\n if (!isWirePresencePatch(parsed)) return null\n const { participant, values, seen, at } = parsed\n return {\n kind: 'presence',\n patch: { participant, values, seen, at },\n ...sessionTag(frame),\n }\n }\n case 'presence-snapshot': {\n if (!isWirePresenceSnapshot(parsed)) return null\n return {\n kind: 'presence-snapshot',\n snapshot: { snapshot: parsed.snapshot },\n ...sessionTag(frame),\n }\n }\n case 'subscribed': {\n const sessionId = frame['sessionId']\n if (typeof sessionId !== 'string') return null\n return { kind: 'subscribed', sessionId }\n }\n case 'unsubscribed': {\n const sessionId = frame['sessionId']\n if (typeof sessionId !== 'string') return null\n const reason = frame['reason']\n return typeof reason === 'string'\n ? { kind: 'unsubscribed', sessionId, reason }\n : { kind: 'unsubscribed', sessionId }\n }\n case 'ack': {\n const req = frame['req']\n if (typeof req !== 'number') return null\n const tag = sessionTag(frame)\n const events = frame['events']\n if (Array.isArray(events) && events.every(isWireEvent)) {\n return { kind: 'ack', req, events, ...tag }\n }\n const error = errorFromWire(parsed)\n if (error) return { kind: 'ack', req, error, ...tag }\n // A correlatable ack must never be dropped: its waiter would hang\n // forever behind a healthy socket (pings keep the watchdog fed).\n // An outcome this client cannot interpret — an error code from a\n // newer server, an event shape that fails a guard — degrades to\n // a lost ack: retryable, and the client-generated ids make the\n // retry idempotent even if the append actually committed.\n return {\n kind: 'ack',\n req,\n error: new A2Error('STORE_UNAVAILABLE', 'unintelligible ack'),\n ...tag,\n }\n }\n default:\n return null\n }\n}\n\nconst sessionTag = (frame: Record<string, unknown>): { sessionId?: string } => {\n const sessionId = frame['sessionId']\n return typeof sessionId === 'string' ? { sessionId } : {}\n}\n"],"mappings":"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"}
@@ -23,52 +23,57 @@ reducer, and the provider + hook it returns.
23
23
 
24
24
  ## The API route
25
25
 
26
- One file exposes a session over HTTP: `GET` streams events, `POST` appends.
27
- These are ordinary route handlers; put whatever checks you like in front.
26
+ One call exposes a session over HTTP: `GET` streams events (and serves
27
+ history slices), `POST` appends. `handle` parses each request into an
28
+ intent, runs your hooks, then acts.
28
29
 
29
30
  ```ts app/api/order-events/route.ts
30
- import { A2Error } from 'experimental-a2'
31
+ import { handle } from 'experimental-a2/http'
31
32
  import { ordersServer } from '@/server/orders'
32
- import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
33
33
 
34
- export async function GET(req: Request) {
35
- const { searchParams } = new URL(req.url)
36
- const sessionId = searchParams.get('sessionId')
37
- if (!sessionId) {
38
- return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
39
- }
40
- const startAfter = Number(searchParams.get('index')) || 0
41
-
42
- // here's where you'd do auth, or any other checks
43
-
44
- return sseResponse(ordersServer.session(sessionId).stream({ startAfter }))
45
- }
46
-
47
- export async function POST(req: Request) {
48
- try {
49
- const { sessionId, events } = await parsePushBody(req)
50
-
51
- // here's where you'd do auth, or any other checks
52
-
53
- const result = await ordersServer.session(sessionId).append(...events)
54
- return Response.json(result)
55
- } catch (err) {
56
- return errorResponse(err)
57
- }
58
- }
34
+ export const { GET, POST } = handle(ordersServer, {
35
+ before({ request, intent }) {
36
+ // here's where you'd do auth, or any other checks. Every lane
37
+ // arrives parsed: intent.type is 'stream', 'history', 'push', or
38
+ // 'ws-upgrade'. Return a Response to refuse, e.g.:
39
+ // if (!canRead(request, intent)) return new Response(null, { status: 403 })
40
+ },
41
+ })
59
42
  ```
60
43
 
61
- `GET` is the read path. `stream({ startAfter })` is a live `AsyncIterable` of
62
- one session's events starting after a given position, and `sseResponse`
63
- pipes it into a server-sent events response. Clients pass `index` to resume
64
- exactly where they left off, after a first paint or a dropped
65
- connection.
44
+ `GET` is the read path. A plain `GET` is the live stream: a server-sent
45
+ events response of one session's events, resumed after the `index` query
46
+ parameter, so clients pick up exactly where they left off after a first
47
+ paint or a dropped connection. A `GET` carrying `gte`/`lte` query
48
+ parameters is a history slice instead: the bounded log range as JSON,
49
+ the cold read [`loadHistory`](#the-client-component) rides.
50
+
51
+ `POST` is the write path. The push envelope is validated (garbage
52
+ answers `INVALID_PAYLOAD` before your hooks run), then `append` does the
53
+ rest. The response is the appended events: an ack, not a stream. Thrown
54
+ [`A2Error`s](/reference/errors#over-the-wire) serialize onto the wire so
55
+ the client can branch on the same codes.
56
+
57
+ Parsing is protocol, hooks are policy. `before` sees every parsed
58
+ intent and short-circuits by returning a Response. `after` runs when the
59
+ library produced an HTTP response and can decorate or replace it. That
60
+ is where caching policy lives, if you want any: `outcome.covered` on a
61
+ history read means the closed range came back fully covered, an
62
+ immutable slice of an append-only log.
63
+
64
+ ```ts
65
+ // app/api/order-events/route.ts, now with response decoration:
66
+ import { handle } from 'experimental-a2/http'
67
+ import { ordersServer } from '@/server/orders'
66
68
 
67
- `POST` is the write path. `parsePushBody` validates the envelope (and
68
- throws `INVALID_PAYLOAD` on garbage), then `append` does the rest. The
69
- response is the appended events: an ack, not a stream. `errorResponse`
70
- serializes any thrown [`A2Error`](/reference/errors#over-the-wire) so the
71
- client can branch on the same codes.
69
+ export const { GET, POST } = handle(ordersServer, {
70
+ after({ outcome, response }) {
71
+ if (outcome.type === 'history' && outcome.covered) {
72
+ response.headers.set('cache-control', 'private, max-age=31536000')
73
+ }
74
+ },
75
+ })
76
+ ```
72
77
 
73
78
  ## The session module
74
79
 
@@ -174,6 +179,21 @@ What the hook gives you:
174
179
  seeded with. With only the server snapshot, it begins after `initialIndex`.
175
180
  Use it for UI that wants the log itself: an activity feed, a debug panel.
176
181
  Earlier events are not needed to hydrate `state`.
182
+ - **`loadHistory`**: backscroll. `loadHistory({ before?, limit? })`
183
+ fetches a bounded slice of the log from below the frontier (the same
184
+ route, `gte`/`lte` query parameters) and merges it into `events`:
185
+ deduped, ordered, shared across every handle of the session. By
186
+ default each call walks backward 50 events at a time from the oldest
187
+ one loaded. After a hydrate jump (returning to a session whose
188
+ frontier advanced while away), default paging still continues from
189
+ the oldest loaded event; pass an explicit `before` to fill the gap
190
+ between the old feed and the new frontier. It never touches `state` or the optimistic overlay;
191
+ backscrolled events are display data. Calls serialize per session, so
192
+ a double-tap never fetches the same range twice. The `ws` api has no
193
+ history lane; `loadHistory` throws a `TypeError` there.
194
+ - **`history`**: backscroll progress, `{ loading, complete,
195
+ oldestLoaded }`. `complete` means the feed reaches index 1 (or the
196
+ log is empty): nothing older is left, hide the "load older" button.
177
197
  - **`index`**: the stream frontier, the last server-confirmed log
178
198
  position. This is the `lastSeenIndex` that makes
179
199
  [cancellation](/guides/cancellation) exact.
@@ -129,36 +129,14 @@ One HTTP route gives the browser a read and write path. `GET` streams events;
129
129
  `POST` accepts optimistic pushes:
130
130
 
131
131
  ```ts app/api/agent-events/route.ts
132
- import { A2Error } from 'experimental-a2'
133
- import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
132
+ import { handle } from 'experimental-a2/http'
134
133
  import { assistantServer } from '@/server/assistant'
135
134
 
136
- export async function GET(req: Request): Promise<Response> {
137
- const { searchParams } = new URL(req.url)
138
- const sessionId = searchParams.get('sessionId')
139
- const startAfter = Number(searchParams.get('index')) || 0
140
-
141
- if (!sessionId) {
142
- return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
143
- }
144
-
145
- // here's where you'd do auth, or any other checks
146
-
147
- return sseResponse(assistantServer.session(sessionId).stream({ startAfter }))
148
- }
149
-
150
- export async function POST(req: Request): Promise<Response> {
151
- try {
152
- const { sessionId, events } = await parsePushBody(req)
153
-
135
+ export const { GET, POST } = handle(assistantServer, {
136
+ before({ request, intent }) {
154
137
  // here's where you'd do auth, or any other checks
155
-
156
- const appended = await assistantServer.session(sessionId).append(...events)
157
- return Response.json(appended)
158
- } catch (error) {
159
- return errorResponse(error)
160
- }
161
- }
138
+ },
139
+ })
162
140
  ```
163
141
 
164
142
  The route never calls the model directly. The browser appends user facts such
@@ -63,55 +63,34 @@ explicit leave required).
63
63
 
64
64
  ## The route
65
65
 
66
- The same two handlers as [Live UI](/guides/react), with one option and
67
- one branch. `stream({ presence: true })` interleaves presence patches
68
- with events on the SSE response, starting with a snapshot of the current
69
- map. The push body grows an optional `presence` sibling to `events`.
66
+ The same `handle` route as [Live UI](/guides/react), with one option.
67
+ `presence: true` interleaves presence patches with events on the
68
+ stream, starting with a snapshot of the current map; the push body
69
+ grows an optional `presence` sibling to `events`, forwarded to
70
+ `setPresence`.
70
71
 
71
72
  ```ts app/api/canvas-events/route.ts
72
- import { A2Error } from 'experimental-a2'
73
- import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
73
+ import { handle } from 'experimental-a2/http'
74
74
  import { canvasServer } from '@/server/canvas'
75
75
 
76
- export async function GET(req: Request) {
77
- const { searchParams } = new URL(req.url)
78
- const sessionId = searchParams.get('sessionId')
79
- if (!sessionId) {
80
- return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
81
- }
82
- const startAfter = Number(searchParams.get('index')) || 0
83
-
84
- // here's where you'd do auth, or any other checks
85
-
86
- return sseResponse(
87
- canvasServer.session(sessionId).stream({ startAfter, presence: true }),
88
- )
89
- }
90
-
91
- export async function POST(req: Request) {
92
- try {
93
- const { sessionId, events, presence } = await parsePushBody(req)
94
- const session = canvasServer.session(sessionId)
95
-
96
- // here's where you'd do auth, or any other checks; the participant
97
- // id is caller-supplied, so authorize it like you authorize events.
98
- // createServer's validatePush is the same seam: on the presence
99
- // plane it receives { sessionId, events: [], presence }, the whole
100
- // patch, participant included
101
-
102
- if (presence) await session.setPresence(presence)
103
- if (events.length === 0) return Response.json([])
104
- return Response.json(await session.append(...events))
105
- } catch (err) {
106
- return errorResponse(err)
107
- }
108
- }
76
+ export const { GET, POST } = handle(canvasServer, {
77
+ presence: true,
78
+ before({ request, intent }) {
79
+ // here's where you'd do auth, or any other checks. On a push,
80
+ // intent.presence carries the whole patch; the participant id
81
+ // is caller-supplied, so authorize it like you authorize events.
82
+ // createServer's validatePush is the same seam and covers every
83
+ // transport (socket presence frames never become intents): on the
84
+ // presence plane it receives { sessionId, events: [], presence }.
85
+ },
86
+ })
109
87
  ```
110
88
 
111
89
  `setPresence` validates each field against the contract, then
112
90
  broadcasts. No append transaction, no dispatch, no scheduler arm, no log
113
91
  row. A bad field throws `INVALID_PAYLOAD`; an unknown field throws
114
- `UNKNOWN_PRESENCE_FIELD`; nothing is broadcast on either.
92
+ `UNKNOWN_PRESENCE_FIELD`; nothing is broadcast on either. A
93
+ presence-only push acks `[]`.
115
94
 
116
95
  ## The browser
117
96
 
@@ -24,58 +24,67 @@ api: { type: 'ws', url: '/api/order-events' }
24
24
  platform duration limits differ per verb: the stream is a long-lived
25
25
  read that wants a high `maxDuration`, the push is a short write that
26
26
  doesn't.
27
- - **`ws`** rides everything over one WebSocket: the stream comes down
28
- it, pushes and presence go up it. Use it when latency or per-message
29
- cost matters; at a presence cadence of fifteen sends a second, each
30
- send is a socket frame instead of a route invocation.
27
+ - **`ws`** rides everything over one WebSocket: streams come down it,
28
+ pushes and presence go up it. One socket carries every session of
29
+ the client; a page showing ten sessions holds one connection, not
30
+ ten. Use it when latency or per-message cost matters; at a presence
31
+ cadence of fifteen sends a second, each send is a socket frame
32
+ instead of a route invocation.
31
33
 
32
34
  A split socket is unrepresentable on purpose. The socket is one
33
35
  connection in both directions; there is nothing left to split.
34
36
 
37
+ One wire is missing from `ws` by design: the history lane.
38
+ [`loadHistory`](/guides/react#the-client-component) is a bounded cold
39
+ read and rides plain HTTP; on a `ws` api it throws a `TypeError`.
40
+
35
41
  ## The WebSocket route
36
42
 
37
- The same route can serve both transports by branching on the upgrade
38
- header. Auth runs before the upgrade, while the request is still a
39
- request.
43
+ The same `handle` route serves both transports. Pass `options.upgrade`
44
+ and a GET carrying an upgrade header becomes the socket; plain GETs
45
+ stay SSE, POST keeps working next to it. A `ws` client never calls
46
+ POST, an `http` client never upgrades; the transports are additive.
40
47
 
41
48
  ```ts app/api/order-events/route.ts
42
49
  import { experimental_upgradeWebSocket } from '@vercel/functions'
43
- import { A2Error } from 'experimental-a2'
44
- import { errorResponse, sessionSocket, sseResponse } from 'experimental-a2/http'
50
+ import { handle } from 'experimental-a2/http'
45
51
  import { ordersServer } from '@/server/orders'
46
52
 
47
- export async function GET(req: Request) {
48
- const { searchParams } = new URL(req.url)
49
- const sessionId = searchParams.get('sessionId')
50
- if (!sessionId) {
51
- return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
52
- }
53
- const startAfter = Number(searchParams.get('index')) || 0
54
-
55
- // here's where you'd do auth, or any other checks
56
-
57
- if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
58
- return experimental_upgradeWebSocket(
59
- (ws) => sessionSocket(ordersServer.session(sessionId), ws, { startAfter }),
53
+ export const { GET, POST } = handle(ordersServer, {
54
+ before({ request, intent }) {
55
+ // here's where you'd do auth, or any other checks: the upgrade
56
+ // itself, every subscribe, and every push arrive here as intents
57
+ },
58
+ upgrade: (attach) =>
59
+ experimental_upgradeWebSocket(attach, {
60
60
  // ws defaults to 100 MiB per frame; POST bodies cap at about
61
61
  // 4.5 MB on the platform. Keep the two ingress paths at parity.
62
- { maxPayload: 4 * 1024 * 1024 },
63
- )
64
- }
65
- return sseResponse(ordersServer.session(sessionId).stream({ startAfter }))
66
- }
62
+ maxPayload: 4 * 1024 * 1024,
63
+ }),
64
+ })
67
65
  ```
68
66
 
69
- `sessionSocket` speaks the whole protocol against any `ws`-shaped
70
- socket: it pumps the session's stream down as JSON frames, accepts push
71
- and presence frames up, validates them exactly as `parsePushBody` does
72
- (same provenance brand, same `validatePush` calls, once per plane), and
73
- answers each push with its ack. POST keeps working unchanged next to
74
- it; a `ws` client never calls it, an `http` client never upgrades. The
75
- transports are additive.
76
-
77
- Contracts that declare presence pass `{ presence: true }` in the
78
- options, the same opt-in as `stream()`.
67
+ The socket is multiplexed: `subscribe` frames open per-session lanes,
68
+ each resuming from its own frontier; every down frame carries the
69
+ `sessionId` it belongs to; pushes and presence route by it. One
70
+ heartbeat, one connection, all of the client's sessions.
71
+
72
+ Auth has two moments. `before` runs for the upgrade itself
73
+ (`intent.type === 'ws-upgrade'`), while the request is still a request;
74
+ return a Response to refuse and no socket ever opens. It then runs
75
+ again for every subscribe and push frame: each subscribe arrives as a
76
+ `stream` intent, each push as a `push` intent, with `request` always
77
+ the original upgrade Request. A Response cannot cross a socket, so a
78
+ denial answers in the wire's own vocabulary: a denied subscribe gets an
79
+ `unsubscribed` notice (every other session on the socket streams on), a
80
+ denied push a non-retryable error ack. Socket presence frames are
81
+ fire-and-forget and never become intents; the presence plane's policy
82
+ seam on every wire is `validatePush`.
83
+
84
+ Two more options ride along: `presence: true` interleaves presence with
85
+ events on every lane, the same opt-in as `stream()`, and `deadline`
86
+ (epoch milliseconds) closes the socket cleanly ahead of a known
87
+ platform deadline, so clients reconnect on your schedule.
79
88
 
80
89
  ## The client
81
90
 
@@ -105,9 +114,9 @@ are the same machinery above the wire seam.
105
114
  A socket closes when the platform ends the function invocation, or
106
115
  when the server closes it deliberately ahead of a known deadline. The
107
116
  client treats every close the same way it treats a dropped SSE stream:
108
- reconnect with backoff, resume from the current frontier, receive a
109
- fresh presence snapshot, re-send its own presence fields set since the
110
- disconnect. A push whose socket died before the ack rejects as
117
+ reconnect with backoff, re-subscribe every session at its own frontier,
118
+ receive fresh presence snapshots, re-send its own presence fields set
119
+ since the disconnect. A push whose socket died before the ack rejects as
111
120
  retryable: nothing was acknowledged, and if the append had already
112
121
  committed, the client-generated event ids make the retry an idempotent
113
122
  replay (you get the original events back). Push retries wait for the