experimental-a2 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +360 -0
- package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
- package/dist/actor-DBbe5xeE.d.ts.map +1 -0
- package/dist/actor-client.d.ts +2 -5
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +13 -4
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -3
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +20 -10
- package/dist/actor-react.js.map +1 -1
- package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
- package/dist/actor-shared-t7w8xIKP.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +42 -23
- package/dist/actor.js.map +1 -1
- package/dist/{ai-CrLYwNEx.js → ai-UrWfKeuv.js} +966 -541
- package/dist/ai-UrWfKeuv.js.map +1 -0
- package/dist/ai-client.d.ts +27 -0
- package/dist/ai-client.d.ts.map +1 -0
- package/dist/ai-client.js +29 -0
- package/dist/ai-client.js.map +1 -0
- package/dist/ai-react.d.ts +96 -0
- package/dist/ai-react.d.ts.map +1 -0
- package/dist/ai-react.js +142 -0
- package/dist/ai-react.js.map +1 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.browser.js.map +1 -1
- package/dist/ai-server.d.ts +80 -36
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +466 -265
- package/dist/ai-server.js.map +1 -1
- package/dist/{ai-HJ9fHfYI.d.ts → ai-vpGtcaCB.d.ts} +115 -101
- package/dist/ai-vpGtcaCB.d.ts.map +1 -0
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
- package/dist/client-COaiaDub.js.map +1 -0
- package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
- package/dist/client-qRnRUzBS.d.ts.map +1 -0
- package/dist/client.d.ts +4 -2
- package/dist/client.js +1 -1
- package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
- package/dist/contract-BYKASlCV.js.map +1 -0
- package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
- package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-BrLso5x_.d.ts} +7 -3
- package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
- package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
- package/dist/internal-CbVvEwad.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/react-BbJ-g6Uu.d.ts +86 -0
- package/dist/react-BbJ-g6Uu.d.ts.map +1 -0
- package/dist/react.d.ts +2 -122
- package/dist/react.js +107 -100
- package/dist/react.js.map +1 -1
- package/dist/reducer-BAXc8JyF.js +87 -0
- package/dist/reducer-BAXc8JyF.js.map +1 -0
- package/dist/{reducer-otzHjuJj.d.ts → reducer-BYu6DT7h.d.ts} +45 -29
- package/dist/reducer-BYu6DT7h.d.ts.map +1 -0
- package/dist/reducer-runtime-DQwz_J59.js +28 -0
- package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +3 -3
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +33 -10
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -9
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +25 -44
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
- package/dist/server-CGM8WzRG.d.ts.map +1 -0
- package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
- package/dist/server-Rj64fNvX.js.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
- package/dist/session-clear-DGI4jTQ5.js.map +1 -0
- package/dist/session-clear-Dshpuhff.d.ts +12 -0
- package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
- package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
- package/dist/store-Gpkq7WBo.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +182 -69
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
- package/dist/store-polling-4_y8lETm.js.map +1 -0
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +399 -108
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
- package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +293 -11
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis-notify-tqWNnMRu.js +2374 -0
- package/dist/store-redis-notify-tqWNnMRu.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +11 -11
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +166 -32
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-Cx7AAPGq.js +23 -0
- package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
- package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-cZH-cibj.d.ts} +2 -2
- package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
- package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
- package/dist/wire-DjfHOvdW.js.map +1 -0
- package/docs/01-quickstart.mdx +2 -2
- package/docs/actors/01-introduction.mdx +4 -0
- package/docs/actors/04-routes.mdx +14 -1
- package/docs/concepts/01-contracts.mdx +95 -0
- package/docs/concepts/04-state.mdx +3 -3
- package/docs/guides/01-timers.mdx +22 -14
- package/docs/guides/02-cancellation.mdx +1 -1
- package/docs/guides/03-react.mdx +268 -328
- package/docs/guides/04-local-first.mdx +32 -73
- package/docs/guides/05-production.mdx +40 -27
- package/docs/guides/06-ai-agents.mdx +680 -580
- package/docs/guides/09-presence.mdx +3 -7
- package/docs/guides/10-transports.mdx +5 -5
- package/docs/index.mdx +10 -18
- package/docs/reference/01-api.mdx +662 -195
- package/docs/reference/02-errors.mdx +2 -1
- package/examples/playground/DEPLOY.md +52 -6
- package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
- package/examples/playground/app/a0/[appId]/page.tsx +48 -0
- package/examples/playground/app/a0/a0.css +818 -0
- package/examples/playground/app/a0/app-list.tsx +211 -0
- package/examples/playground/app/a0/app-view.tsx +55 -0
- package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
- package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
- package/examples/playground/app/a0/apps.test.ts +116 -0
- package/examples/playground/app/a0/apps.ts +87 -0
- package/examples/playground/app/a0/chat.tsx +364 -0
- package/examples/playground/app/a0/columns.tsx +109 -0
- package/examples/playground/app/a0/context-menu.tsx +116 -0
- package/examples/playground/app/a0/cookies.ts +46 -0
- package/examples/playground/app/a0/crash-on-request.ts +72 -0
- package/examples/playground/app/a0/crashes/route.ts +3 -0
- package/examples/playground/app/a0/crashes.test.ts +33 -0
- package/examples/playground/app/a0/crashes.ts +28 -0
- package/examples/playground/app/a0/devtools.tsx +164 -0
- package/examples/playground/app/a0/events/route.ts +6 -0
- package/examples/playground/app/a0/flags.ts +30 -0
- package/examples/playground/app/a0/layout.tsx +38 -0
- package/examples/playground/app/a0/list/route.ts +10 -0
- package/examples/playground/app/a0/model.test.ts +56 -0
- package/examples/playground/app/a0/model.ts +58 -0
- package/examples/playground/app/a0/page.tsx +14 -0
- package/examples/playground/app/a0/preview-pane.tsx +145 -0
- package/examples/playground/app/a0/preview.test.ts +53 -0
- package/examples/playground/app/a0/preview.ts +26 -0
- package/examples/playground/app/a0/sandbox-log.ts +69 -0
- package/examples/playground/app/a0/sandbox.ts +191 -0
- package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
- package/examples/playground/app/a0/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/server.ts +158 -0
- package/examples/playground/app/a0/session.ts +47 -0
- package/examples/playground/app/a0/shell.tsx +150 -0
- package/examples/playground/app/a0/short/events/route.ts +9 -0
- package/examples/playground/app/a0/splitter.tsx +120 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
- package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
- package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
- package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
- package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
- package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
- package/examples/playground/app/agent/events/route.ts +5 -2
- package/examples/playground/app/agent/ingress.test.ts +98 -0
- package/examples/playground/app/agent/model-input.test.tsx +58 -0
- package/examples/playground/app/agent/model-input.tsx +115 -0
- package/examples/playground/app/agent/model.test.ts +73 -0
- package/examples/playground/app/agent/model.ts +58 -26
- package/examples/playground/app/agent/new-agent-session.tsx +37 -19
- package/examples/playground/app/agent/page.tsx +4 -4
- package/examples/playground/app/agent/server.ts +15 -14
- package/examples/playground/app/agent/session.ts +5 -6
- package/examples/playground/app/agent/telemetry.test.ts +168 -0
- package/examples/playground/app/agent/telemetry.ts +30 -0
- package/examples/playground/app/agent/trace.test.ts +23 -0
- package/examples/playground/app/agent/trace.ts +18 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +11 -11
- package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
- package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
- package/examples/playground/app/canvas/model.ts +53 -53
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
- package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
- package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
- package/examples/playground/app/chat/[chatId]/session.ts +2 -19
- package/examples/playground/app/chat/agent/route.ts +9 -0
- package/examples/playground/app/chat/model.test.ts +46 -119
- package/examples/playground/app/chat/model.ts +82 -128
- package/examples/playground/app/chat/scheduler/route.ts +2 -2
- package/examples/playground/app/chat/server.test.ts +108 -0
- package/examples/playground/app/chat/server.ts +69 -73
- package/examples/playground/app/components/activity-feed.tsx +30 -1
- package/examples/playground/app/components/activity-history.test.tsx +35 -0
- package/examples/playground/app/components/demo-icons.tsx +89 -0
- package/examples/playground/app/components/demos.tsx +33 -0
- package/examples/playground/app/components/fast-link.test.tsx +58 -0
- package/examples/playground/app/components/fast-link.tsx +55 -0
- package/examples/playground/app/components/site-header.tsx +96 -0
- package/examples/playground/app/counter/counter-client.tsx +3 -3
- package/examples/playground/app/counter/model.test.ts +3 -3
- package/examples/playground/app/counter/model.ts +11 -21
- package/examples/playground/app/counter/page.tsx +2 -15
- package/examples/playground/app/counter/session.ts +2 -2
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
- package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
- package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
- package/examples/playground/app/documents/[documentId]/session.ts +2 -2
- package/examples/playground/app/documents/model.ts +27 -27
- package/examples/playground/app/globals.css +420 -207
- package/examples/playground/app/initial-state.test.tsx +54 -0
- package/examples/playground/app/layout.tsx +7 -23
- package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
- package/examples/playground/app/orders/[orderId]/session.ts +2 -2
- package/examples/playground/app/orders/model.ts +49 -49
- package/examples/playground/app/page.tsx +13 -102
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
- package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
- package/examples/playground/app/recovery/model.ts +30 -30
- package/examples/playground/instrumentation.ts +5 -0
- package/examples/playground/package.json +10 -2
- package/examples/playground/scripts/a0-template.ts +77 -0
- package/examples/playground/vercel.json +18 -0
- package/package.json +3 -1
- package/src/actor-client.ts +16 -4
- package/src/actor-react.ts +25 -11
- package/src/actor-shared.ts +21 -6
- package/src/actor.ts +76 -31
- package/src/ai-agent-actions.ts +219 -0
- package/src/ai-agent.ts +2343 -0
- package/src/ai-app-state.ts +91 -0
- package/src/ai-client-state.ts +39 -2
- package/src/ai-client.ts +62 -0
- package/src/ai-context-schema.ts +25 -5
- package/src/ai-context.ts +90 -40
- package/src/ai-control-server.ts +94 -55
- package/src/ai-control.ts +2 -0
- package/src/ai-coordinator.ts +23 -22
- package/src/ai-message-base.ts +209 -0
- package/src/ai-message-projection.ts +382 -58
- package/src/ai-progress-batches.ts +26 -0
- package/src/ai-projector.ts +95 -14
- package/src/ai-react.ts +146 -0
- package/src/ai-server.browser.ts +2 -2
- package/src/ai-server.ts +20 -1951
- package/src/ai-stored-state.ts +48 -163
- package/src/ai.ts +343 -276
- package/src/bootstrap-stream.ts +72 -0
- package/src/bounded-cache.ts +55 -0
- package/src/client-render-session.ts +49 -0
- package/src/client.ts +688 -151
- package/src/contract.ts +92 -42
- package/src/deterministic-id.ts +37 -0
- package/src/equal-json.ts +32 -0
- package/src/errors.ts +13 -1
- package/src/index.ts +5 -1
- package/src/initial-snapshot.ts +39 -0
- package/src/internal.ts +45 -0
- package/src/postgres-notification-scope.ts +14 -3
- package/src/react.ts +171 -206
- package/src/redis-http-reply.ts +256 -0
- package/src/redis-http-stream.ts +73 -0
- package/src/redis-http-subscriptions.ts +13 -5
- package/src/reducer-runtime.ts +52 -0
- package/src/reducer.ts +92 -10
- package/src/scheduler-qstash.ts +49 -20
- package/src/scheduler-task.ts +34 -2
- package/src/scheduler-vercel.ts +32 -84
- package/src/server-fetch.ts +139 -35
- package/src/server.ts +957 -237
- package/src/session-clear.ts +23 -0
- package/src/session-socket.ts +154 -49
- package/src/sse.ts +71 -4
- package/src/store-memory.ts +220 -78
- package/src/store-polling.ts +18 -4
- package/src/store-postgres.ts +461 -114
- package/src/store-redis-core.ts +1260 -434
- package/src/store-redis-http.ts +18 -2
- package/src/store-redis-notify.ts +260 -85
- package/src/store-redis.ts +19 -9
- package/src/store-sqlite.ts +259 -45
- package/src/store.ts +46 -1
- package/src/stream-buffer.ts +43 -0
- package/src/telemetry.ts +13 -1
- package/src/wire.ts +241 -13
- package/dist/actor-ohPC-81x.d.ts.map +0 -1
- package/dist/actor-shared-B5tJfzt-.js.map +0 -1
- package/dist/ai-CrLYwNEx.js.map +0 -1
- package/dist/ai-HJ9fHfYI.d.ts.map +0 -1
- package/dist/client-BYzHjkwU.js.map +0 -1
- package/dist/client-CzyacQpJ.d.ts.map +0 -1
- package/dist/contract-CKRg_E4q.js.map +0 -1
- package/dist/errors-DCk6ch5n.js.map +0 -1
- package/dist/errors-DvhSXnxk.d.ts.map +0 -1
- package/dist/internal-Dq2qYxou.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/reducer-DEMjEY_O.js +0 -29
- package/dist/reducer-DEMjEY_O.js.map +0 -1
- package/dist/reducer-otzHjuJj.d.ts.map +0 -1
- package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
- package/dist/server-BD5ckxZb.js.map +0 -1
- package/dist/server-DbD7IJVK.d.ts.map +0 -1
- package/dist/store-DyZM6fS5.d.ts.map +0 -1
- package/dist/store-polling-CmxUbV93.js.map +0 -1
- package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
- package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
- package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
- package/dist/validate-XKT4FSNn.js +0 -30
- package/dist/validate-XKT4FSNn.js.map +0 -1
- package/dist/wire-BO5wWCb1.js.map +0 -1
- package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
- package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
- package/examples/playground/app/agent/compaction-settings.ts +0 -49
- package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
- package/examples/playground/app/agent/compaction-timeline.ts +0 -208
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/* oxlint-disable no-await-in-loop -- response bytes and entries must be consumed in order */
|
|
2
|
+
|
|
3
|
+
type Scan = { depth: number; quoted: boolean; escaped: boolean }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Index just past the array or string `scan` is inside, or -1 when `text`
|
|
7
|
+
* ends first; `scan` then carries the state into the next chunk. Strings
|
|
8
|
+
* are skipped with `indexOf`, so the per-character loop only walks
|
|
9
|
+
* structure. A quote is escaped when an odd run of backslashes precedes
|
|
10
|
+
* it; a run is counted back only to where the string scan resumed, and
|
|
11
|
+
* `escaped` carries an odd trailing run across the chunk boundary.
|
|
12
|
+
*/
|
|
13
|
+
function scanValue(text: string, from: number, scan: Scan): number {
|
|
14
|
+
let at = from
|
|
15
|
+
while (at < text.length) {
|
|
16
|
+
if (scan.quoted) {
|
|
17
|
+
if (scan.escaped) {
|
|
18
|
+
scan.escaped = false
|
|
19
|
+
at++
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
const quote = text.indexOf('"', at)
|
|
23
|
+
const end = quote < 0 ? text.length : quote
|
|
24
|
+
let run = 0
|
|
25
|
+
while (end - run - 1 >= at && text.charCodeAt(end - run - 1) === 92) run++
|
|
26
|
+
if (quote < 0) {
|
|
27
|
+
scan.escaped = run % 2 === 1
|
|
28
|
+
return -1
|
|
29
|
+
}
|
|
30
|
+
at = quote + 1
|
|
31
|
+
if (run % 2 === 1) continue
|
|
32
|
+
scan.quoted = false
|
|
33
|
+
if (scan.depth === 0) return at
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
const code = text.charCodeAt(at++)
|
|
37
|
+
if (code === 34) scan.quoted = true
|
|
38
|
+
else if (code === 91 || code === 123) scan.depth++
|
|
39
|
+
else if ((code === 93 || code === 125) && --scan.depth === 0) return at
|
|
40
|
+
}
|
|
41
|
+
return -1
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class JsonReply {
|
|
45
|
+
private readonly reader: ReadableStreamDefaultReader<Uint8Array>
|
|
46
|
+
private readonly decoder = new TextDecoder('utf-8', { fatal: true })
|
|
47
|
+
private readonly signal: AbortSignal
|
|
48
|
+
private text = ''
|
|
49
|
+
private position = 0
|
|
50
|
+
private done = false
|
|
51
|
+
private readonly abort: () => void
|
|
52
|
+
private array: 'first' | 'value' | 'entry' | 'separator' | 'closed' = 'first'
|
|
53
|
+
private scan: Scan = { depth: 0, quoted: false, escaped: false }
|
|
54
|
+
private fragments: string[] = []
|
|
55
|
+
private failure: unknown
|
|
56
|
+
|
|
57
|
+
constructor(options: {
|
|
58
|
+
body: ReadableStream<Uint8Array>
|
|
59
|
+
signal: AbortSignal
|
|
60
|
+
}) {
|
|
61
|
+
const { body, signal } = options
|
|
62
|
+
this.reader = body.getReader()
|
|
63
|
+
this.signal = signal
|
|
64
|
+
this.abort = () => {
|
|
65
|
+
void this.reader.cancel(signal.reason).catch(() => {})
|
|
66
|
+
}
|
|
67
|
+
signal.addEventListener('abort', this.abort, { once: true })
|
|
68
|
+
if (signal.aborted) this.abort()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async available(): Promise<boolean> {
|
|
72
|
+
while (this.position === this.text.length && !this.done) {
|
|
73
|
+
this.signal.throwIfAborted()
|
|
74
|
+
const chunk = await this.reader.read()
|
|
75
|
+
this.signal.throwIfAborted()
|
|
76
|
+
this.done = Boolean(chunk.done)
|
|
77
|
+
this.text = chunk.done
|
|
78
|
+
? this.decoder.decode()
|
|
79
|
+
: this.decoder.decode(chunk.value, { stream: true })
|
|
80
|
+
this.position = 0
|
|
81
|
+
}
|
|
82
|
+
return this.position < this.text.length
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async peek(): Promise<string | undefined> {
|
|
86
|
+
while (await this.available()) {
|
|
87
|
+
while (this.position < this.text.length) {
|
|
88
|
+
const value = this.text[this.position]!
|
|
89
|
+
if (value !== ' ' && value !== '\n' && value !== '\r' && value !== '\t')
|
|
90
|
+
return value
|
|
91
|
+
this.position++
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async expect(value: string): Promise<void> {
|
|
98
|
+
if ((await this.peek()) !== value)
|
|
99
|
+
throw new Error('Invalid or truncated Redis HTTP reply')
|
|
100
|
+
this.position++
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async value(): Promise<unknown> {
|
|
104
|
+
const first = await this.peek()
|
|
105
|
+
if (first !== '[' && first !== '"')
|
|
106
|
+
throw new Error('Invalid Redis HTTP reply value')
|
|
107
|
+
const scan: Scan = { depth: 0, quoted: false, escaped: false }
|
|
108
|
+
const fragments: string[] = []
|
|
109
|
+
while (await this.available()) {
|
|
110
|
+
const start = this.position
|
|
111
|
+
const end = scanValue(this.text, start, scan)
|
|
112
|
+
this.position = end < 0 ? this.text.length : end
|
|
113
|
+
fragments.push(this.text.slice(start, this.position))
|
|
114
|
+
if (end >= 0) return JSON.parse(fragments.join('')) as unknown
|
|
115
|
+
}
|
|
116
|
+
throw new Error('Truncated Redis HTTP reply value')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The next entries of an open array, parsed together: every entry that
|
|
121
|
+
* completes in the current transport chunk, reading further chunks only
|
|
122
|
+
* until one has completed. Entries before a malformed one are returned
|
|
123
|
+
* first; the malformed one fails the next call. `undefined` once the
|
|
124
|
+
* array has closed.
|
|
125
|
+
*/
|
|
126
|
+
async entries(): Promise<unknown[] | undefined> {
|
|
127
|
+
if (this.failure !== undefined) throw this.failure
|
|
128
|
+
const texts: string[] = []
|
|
129
|
+
const fail = (message: string): void => {
|
|
130
|
+
const error = new Error(message)
|
|
131
|
+
if (texts.length === 0) throw error
|
|
132
|
+
this.failure = error
|
|
133
|
+
}
|
|
134
|
+
while (this.array !== 'closed') {
|
|
135
|
+
if (this.position === this.text.length) {
|
|
136
|
+
if (texts.length > 0) break
|
|
137
|
+
if (!(await this.available()))
|
|
138
|
+
throw new Error(
|
|
139
|
+
this.array === 'entry'
|
|
140
|
+
? 'Truncated Redis HTTP reply value'
|
|
141
|
+
: this.array === 'separator'
|
|
142
|
+
? 'Invalid or truncated Redis HTTP reply'
|
|
143
|
+
: 'Invalid Redis HTTP reply value',
|
|
144
|
+
)
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (this.array === 'entry') {
|
|
148
|
+
const start = this.position
|
|
149
|
+
const end = scanValue(this.text, start, this.scan)
|
|
150
|
+
this.position = end < 0 ? this.text.length : end
|
|
151
|
+
this.fragments.push(this.text.slice(start, this.position))
|
|
152
|
+
if (end < 0) continue
|
|
153
|
+
texts.push(
|
|
154
|
+
this.fragments.length === 1
|
|
155
|
+
? this.fragments[0]!
|
|
156
|
+
: this.fragments.join(''),
|
|
157
|
+
)
|
|
158
|
+
this.fragments = []
|
|
159
|
+
this.array = 'separator'
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
const value = this.text[this.position]!
|
|
163
|
+
if (value === ' ' || value === '\n' || value === '\r' || value === '\t') {
|
|
164
|
+
this.position++
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
if (
|
|
168
|
+
this.array === 'separator' ||
|
|
169
|
+
(this.array === 'first' && value === ']')
|
|
170
|
+
) {
|
|
171
|
+
if (value === ']') this.array = 'closed'
|
|
172
|
+
else if (value === ',') this.array = 'value'
|
|
173
|
+
else {
|
|
174
|
+
fail('Invalid or truncated Redis HTTP reply')
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
this.position++
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
if (value !== '[' && value !== '"') {
|
|
181
|
+
fail('Invalid Redis HTTP reply value')
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
this.scan = { depth: 0, quoted: false, escaped: false }
|
|
185
|
+
this.array = 'entry'
|
|
186
|
+
}
|
|
187
|
+
return texts.length > 0 ? this.parse(texts) : undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private parse(texts: string[]): unknown[] {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(`[${texts.join(',')}]`) as unknown[]
|
|
193
|
+
} catch {
|
|
194
|
+
// The joined parse fails only if one entry does: keep those before it.
|
|
195
|
+
const parsed: unknown[] = []
|
|
196
|
+
for (const text of texts) {
|
|
197
|
+
try {
|
|
198
|
+
parsed.push(JSON.parse(text))
|
|
199
|
+
} catch (error) {
|
|
200
|
+
this.failure = error
|
|
201
|
+
break
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (parsed.length === 0) throw this.failure
|
|
205
|
+
return parsed
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async end(): Promise<void> {
|
|
210
|
+
await this.expect('}')
|
|
211
|
+
if ((await this.peek()) !== undefined)
|
|
212
|
+
throw new Error('Trailing data in Redis HTTP reply')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async close(): Promise<void> {
|
|
216
|
+
this.signal.removeEventListener('abort', this.abort)
|
|
217
|
+
try {
|
|
218
|
+
await this.reader.cancel()
|
|
219
|
+
} finally {
|
|
220
|
+
this.reader.releaseLock()
|
|
221
|
+
this.text = ''
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The `result` array of an Upstash REST reply, in batches of the entries
|
|
228
|
+
* that complete in each transport chunk.
|
|
229
|
+
*/
|
|
230
|
+
export async function* redisHttpReply(options: {
|
|
231
|
+
body: ReadableStream<Uint8Array>
|
|
232
|
+
signal: AbortSignal
|
|
233
|
+
}): AsyncGenerator<unknown[]> {
|
|
234
|
+
const reply = new JsonReply(options)
|
|
235
|
+
try {
|
|
236
|
+
await reply.expect('{')
|
|
237
|
+
const key = await reply.value()
|
|
238
|
+
await reply.expect(':')
|
|
239
|
+
if (key === 'error') {
|
|
240
|
+
const error = await reply.value()
|
|
241
|
+
if (typeof error !== 'string') throw new Error('Invalid Redis HTTP error')
|
|
242
|
+
await reply.end()
|
|
243
|
+
throw new Error(`Redis HTTP command failed: ${String(error)}`)
|
|
244
|
+
}
|
|
245
|
+
if (key !== 'result') throw new Error('Invalid Redis HTTP reply envelope')
|
|
246
|
+
await reply.expect('[')
|
|
247
|
+
for (;;) {
|
|
248
|
+
const entries = await reply.entries()
|
|
249
|
+
if (entries === undefined) break
|
|
250
|
+
yield entries
|
|
251
|
+
}
|
|
252
|
+
await reply.end()
|
|
253
|
+
} finally {
|
|
254
|
+
await reply.close()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { A2Error } from './errors.ts'
|
|
2
|
+
import { redisHttpReply } from './redis-http-reply.ts'
|
|
3
|
+
import type { StreamEntry } from './store-redis-core.ts'
|
|
4
|
+
|
|
5
|
+
export const REDIS_HTTP_STREAM_READ_COUNT = 1000
|
|
6
|
+
|
|
7
|
+
export async function* readRedisStreamPage({
|
|
8
|
+
key,
|
|
9
|
+
from,
|
|
10
|
+
signal,
|
|
11
|
+
url,
|
|
12
|
+
token,
|
|
13
|
+
count,
|
|
14
|
+
}: {
|
|
15
|
+
key: string
|
|
16
|
+
from: string
|
|
17
|
+
signal: AbortSignal
|
|
18
|
+
url: string
|
|
19
|
+
token: string
|
|
20
|
+
count: number
|
|
21
|
+
}): AsyncGenerator<StreamEntry[], void> {
|
|
22
|
+
signal.throwIfAborted()
|
|
23
|
+
const response = await fetch(url, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
authorization: `Bearer ${token}`,
|
|
27
|
+
'content-type': 'application/json',
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify(['XRANGE', key, from, '+', 'COUNT', String(count)]),
|
|
30
|
+
signal,
|
|
31
|
+
})
|
|
32
|
+
if (!response.ok || !response.body) {
|
|
33
|
+
const body = (await response.json().catch(() => null)) as {
|
|
34
|
+
error?: unknown
|
|
35
|
+
} | null
|
|
36
|
+
throw new A2Error(
|
|
37
|
+
'STORE_UNAVAILABLE',
|
|
38
|
+
`redis rest command failed: ${
|
|
39
|
+
typeof body?.error === 'string'
|
|
40
|
+
? body.error
|
|
41
|
+
: `status ${response.status}`
|
|
42
|
+
}`,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
let last = Number(from.slice(0, -2)) - 1
|
|
46
|
+
for await (const batch of redisHttpReply({ body: response.body, signal })) {
|
|
47
|
+
const entries: StreamEntry[] = []
|
|
48
|
+
try {
|
|
49
|
+
for (const entry of batch) {
|
|
50
|
+
if (
|
|
51
|
+
!Array.isArray(entry) ||
|
|
52
|
+
entry.length !== 2 ||
|
|
53
|
+
typeof entry[0] !== 'string' ||
|
|
54
|
+
!Array.isArray(entry[1]) ||
|
|
55
|
+
entry[1].length % 2 !== 0 ||
|
|
56
|
+
!entry[1].every((field) => typeof field === 'string')
|
|
57
|
+
)
|
|
58
|
+
throw new Error('Invalid Redis stream entry')
|
|
59
|
+
const index = /^\d+-0$/.test(entry[0])
|
|
60
|
+
? Number(entry[0].slice(0, -2))
|
|
61
|
+
: NaN
|
|
62
|
+
if (!Number.isSafeInteger(index) || index <= last)
|
|
63
|
+
throw new Error('Redis stream entry did not advance')
|
|
64
|
+
last = index
|
|
65
|
+
entries.push(entry as StreamEntry)
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (entries.length > 0) yield entries
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
yield entries
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -12,7 +12,11 @@ export function redisHttpSubscriptions({
|
|
|
12
12
|
url: string
|
|
13
13
|
token: string
|
|
14
14
|
onReconnect: (channel: string) => void
|
|
15
|
-
fallback: {
|
|
15
|
+
fallback: {
|
|
16
|
+
enabled(): boolean
|
|
17
|
+
checkCommands(): Promise<void>
|
|
18
|
+
enable(): void
|
|
19
|
+
}
|
|
16
20
|
}): RedisConnection {
|
|
17
21
|
const subscriptions = new Map<
|
|
18
22
|
string,
|
|
@@ -75,10 +79,14 @@ export function redisHttpSubscriptions({
|
|
|
75
79
|
// oxlint-disable-next-line no-await-in-loop -- finish capability detection before falling back
|
|
76
80
|
await response.body?.cancel()
|
|
77
81
|
// oxlint-disable-next-line no-await-in-loop -- confirm that ordinary commands work
|
|
78
|
-
await fallback.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
await fallback.checkCommands()
|
|
83
|
+
if (stopped) return
|
|
84
|
+
if (!supported) {
|
|
85
|
+
fallback.enable()
|
|
86
|
+
for (const active of subscriptions.values()) active.stop()
|
|
87
|
+
subscriptions.clear()
|
|
88
|
+
return
|
|
89
|
+
}
|
|
82
90
|
}
|
|
83
91
|
if (
|
|
84
92
|
!response.ok ||
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const runtimeKey: unique symbol = Symbol('a2.reducerRuntime')
|
|
2
|
+
|
|
3
|
+
type StateStorage<S> = {
|
|
4
|
+
encode(state: S): unknown
|
|
5
|
+
decode(stored: unknown): S
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type ReducerRuntime<S> = {
|
|
9
|
+
storage?: StateStorage<S>
|
|
10
|
+
project?: (state: S) => S
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Carrier = { [runtimeKey]?: ReducerRuntime<unknown> }
|
|
14
|
+
|
|
15
|
+
export function withReducerRuntime<R extends object, S>(options: {
|
|
16
|
+
reducer: R
|
|
17
|
+
storage?: StateStorage<S>
|
|
18
|
+
project?: (state: S) => S
|
|
19
|
+
}): R {
|
|
20
|
+
const { reducer, ...runtime } = options
|
|
21
|
+
return {
|
|
22
|
+
...reducer,
|
|
23
|
+
[runtimeKey]: {
|
|
24
|
+
...(reducer as Carrier)[runtimeKey],
|
|
25
|
+
...runtime,
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function encodeState(options: {
|
|
31
|
+
reducer: object
|
|
32
|
+
state: unknown
|
|
33
|
+
}): unknown {
|
|
34
|
+
const storage = (options.reducer as Carrier)[runtimeKey]?.storage
|
|
35
|
+
return storage ? storage.encode(options.state) : options.state
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function decodeState(options: {
|
|
39
|
+
reducer: object
|
|
40
|
+
state: unknown
|
|
41
|
+
}): unknown {
|
|
42
|
+
const storage = (options.reducer as Carrier)[runtimeKey]?.storage
|
|
43
|
+
return storage ? storage.decode(options.state) : options.state
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function projectClientState<S>(options: {
|
|
47
|
+
reducer: object
|
|
48
|
+
state: S
|
|
49
|
+
}): S {
|
|
50
|
+
const project = (options.reducer as Carrier)[runtimeKey]?.project
|
|
51
|
+
return project ? (project(options.state) as S) : options.state
|
|
52
|
+
}
|
package/src/reducer.ts
CHANGED
|
@@ -1,14 +1,62 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ContractEvent,
|
|
3
|
+
EventDefs,
|
|
4
|
+
PresenceDefs,
|
|
5
|
+
ReducerOptions,
|
|
6
|
+
} from './contract.ts'
|
|
7
|
+
import { isSessionCleared, type SessionCleared } from './session-clear.ts'
|
|
2
8
|
import type { StandardSchemaV1 } from './standard-schema.ts'
|
|
3
9
|
import { validateSync } from './validate.ts'
|
|
4
10
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
export type StateSnapshot<S> = {
|
|
12
|
+
state: S
|
|
13
|
+
index: number
|
|
14
|
+
/** The session's latest clear marker, once it has been cleared. */
|
|
15
|
+
clearIndex?: number | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A fresh copy of a reducer's seed, so a fold that mutates in place
|
|
20
|
+
* cannot corrupt the shared `initialState`. Non-cloneable seeds
|
|
21
|
+
* (functions, class instances, proxies) fall back to the original.
|
|
22
|
+
*/
|
|
23
|
+
export function cloneInitial<S>(initial: S): S {
|
|
24
|
+
try {
|
|
25
|
+
return structuredClone(initial)
|
|
26
|
+
} catch {
|
|
27
|
+
return initial
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const markerFolds = new WeakSet<object>()
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Mark a fold that receives clear markers itself instead of resetting to its
|
|
35
|
+
* `initialState`. The actor follower has no seed of its own; its stream carries
|
|
36
|
+
* the reset state in the marker.
|
|
37
|
+
*/
|
|
38
|
+
export function foldsClearMarkers<
|
|
39
|
+
F extends (state: never, event: never) => unknown,
|
|
40
|
+
>(fold: F): F {
|
|
41
|
+
markerFolds.add(fold)
|
|
42
|
+
return fold
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One fold step; a clear marker resets to the seed on server and client alike. */
|
|
46
|
+
export function foldEvent<D extends EventDefs, S>(
|
|
47
|
+
source: Pick<Reducer<D, S>, 'fold' | 'initialState'>,
|
|
48
|
+
state: S,
|
|
49
|
+
event: ContractEvent<D> | SessionCleared,
|
|
50
|
+
): S {
|
|
51
|
+
if (!isSessionCleared(event)) return source.fold(state, event)
|
|
52
|
+
return markerFolds.has(source.fold)
|
|
53
|
+
? source.fold(state, event as unknown as ContractEvent<D>)
|
|
54
|
+
: cloneInitial(source.initialState)
|
|
55
|
+
}
|
|
8
56
|
|
|
9
57
|
/**
|
|
10
58
|
* A pure fold over a session's events — `(state, event) => state` —
|
|
11
|
-
*
|
|
59
|
+
* published inline in a contract or built with `reducer(options).fold(fn)`.
|
|
12
60
|
*
|
|
13
61
|
* The name is the reducer's identity. Cached folds (server snapshots,
|
|
14
62
|
* the browser cache) are keyed by it, which makes it the invalidation
|
|
@@ -20,7 +68,6 @@ export type Reducer<
|
|
|
20
68
|
D extends EventDefs = EventDefs,
|
|
21
69
|
S = unknown,
|
|
22
70
|
P extends PresenceDefs = Record<never, never>,
|
|
23
|
-
V = S,
|
|
24
71
|
> = {
|
|
25
72
|
/** Identity and cache key. Rename when the fold's logic changes. */
|
|
26
73
|
readonly name: string
|
|
@@ -34,8 +81,6 @@ export type Reducer<
|
|
|
34
81
|
readonly presence: Readonly<P>
|
|
35
82
|
readonly fold: (state: S, event: ContractEvent<D>) => S
|
|
36
83
|
readonly initialState: S
|
|
37
|
-
/** Internal read-side projection. Never used as a fold input or persisted. */
|
|
38
|
-
readonly [clientStateProjection]?: (state: S) => V
|
|
39
84
|
/**
|
|
40
85
|
* When present: `initialState` was validated against it at
|
|
41
86
|
* definition, and any cached fold a2 rehydrates (server snapshots,
|
|
@@ -45,7 +90,7 @@ export type Reducer<
|
|
|
45
90
|
readonly stateSchema: StandardSchemaV1<unknown, S> | undefined
|
|
46
91
|
}
|
|
47
92
|
|
|
48
|
-
/** The second step of `
|
|
93
|
+
/** The second step of `reducer` — see the method's doc. */
|
|
49
94
|
export type ReducerBuilder<
|
|
50
95
|
D extends EventDefs,
|
|
51
96
|
S,
|
|
@@ -54,7 +99,7 @@ export type ReducerBuilder<
|
|
|
54
99
|
fold(fold: (state: S, event: ContractEvent<D>) => S): Reducer<D, S, P>
|
|
55
100
|
}
|
|
56
101
|
|
|
57
|
-
/** Internal —
|
|
102
|
+
/** Internal — shared validation for inline and standalone reducer definitions. */
|
|
58
103
|
export function makeReducerBuilder<
|
|
59
104
|
D extends EventDefs,
|
|
60
105
|
P extends PresenceDefs,
|
|
@@ -94,6 +139,19 @@ export function makeReducerBuilder<
|
|
|
94
139
|
)
|
|
95
140
|
}
|
|
96
141
|
initialState = result.value as S
|
|
142
|
+
const restored = validateSync(
|
|
143
|
+
options.stateSchema,
|
|
144
|
+
initialState,
|
|
145
|
+
'the stateSchema',
|
|
146
|
+
)
|
|
147
|
+
if (
|
|
148
|
+
restored.issues ||
|
|
149
|
+
JSON.stringify(restored.value) !== JSON.stringify(initialState)
|
|
150
|
+
) {
|
|
151
|
+
throw new TypeError(
|
|
152
|
+
'stateSchema must accept and preserve its own output so snapshots can be restored',
|
|
153
|
+
)
|
|
154
|
+
}
|
|
97
155
|
}
|
|
98
156
|
|
|
99
157
|
return {
|
|
@@ -113,3 +171,27 @@ export function makeReducerBuilder<
|
|
|
113
171
|
},
|
|
114
172
|
}
|
|
115
173
|
}
|
|
174
|
+
|
|
175
|
+
export type RegisteredReducer<
|
|
176
|
+
D extends EventDefs,
|
|
177
|
+
P extends PresenceDefs,
|
|
178
|
+
> = Omit<Reducer<D, unknown, P>, 'fold'> & {
|
|
179
|
+
readonly fold: (state: never, event: never) => unknown
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function reducer<
|
|
183
|
+
D extends EventDefs,
|
|
184
|
+
P extends PresenceDefs,
|
|
185
|
+
Seed,
|
|
186
|
+
S = Seed,
|
|
187
|
+
>(
|
|
188
|
+
options: ReducerOptions<Seed, S> & {
|
|
189
|
+
contract: { events: Readonly<D>; presence: Readonly<P> }
|
|
190
|
+
},
|
|
191
|
+
): ReducerBuilder<D, S, P> {
|
|
192
|
+
return makeReducerBuilder(
|
|
193
|
+
options.contract.events,
|
|
194
|
+
options.contract.presence,
|
|
195
|
+
options,
|
|
196
|
+
)
|
|
197
|
+
}
|
package/src/scheduler-qstash.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* QStash publishes one signed HTTP delivery per scheduler task. Drain tasks
|
|
5
5
|
* use an absolute, whole-second `notBefore` and a fixed-size deduplication id;
|
|
6
|
-
* append tasks hash their stable task id with the callback URL
|
|
6
|
+
* append and clear tasks hash their stable task id with the callback URL, each
|
|
7
|
+
* under its own version tag.
|
|
7
8
|
* Store-outage successors carry a signed private generation and back off to
|
|
8
9
|
* one day without deriving their deduplication identity from the local clock.
|
|
9
10
|
* `handler(...servers)` verifies the signature over the raw request body
|
|
@@ -28,6 +29,7 @@ import { retryableLazy } from './retryable-lazy.ts'
|
|
|
28
29
|
import { normalizeSchedulerTask, type SchedulerTask } from './scheduler-task.ts'
|
|
29
30
|
import {
|
|
30
31
|
deliverSchedulerAppend,
|
|
32
|
+
deliverSchedulerClear,
|
|
31
33
|
type A2Scheduler,
|
|
32
34
|
type DrainableServer,
|
|
33
35
|
} from './server.ts'
|
|
@@ -37,7 +39,7 @@ type QStashStoreOutage = {
|
|
|
37
39
|
version: 1
|
|
38
40
|
/** Zero is the first successor created for a store outage. */
|
|
39
41
|
generation: number
|
|
40
|
-
/** The first failing drain task's due time. Append identity lives on the task. */
|
|
42
|
+
/** The first failing drain task's due time. Append and clear identity lives on the task. */
|
|
41
43
|
rootDueAt?: number
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -658,6 +660,16 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
|
|
|
658
660
|
task: SchedulerTask,
|
|
659
661
|
outage: QStashStoreOutage,
|
|
660
662
|
): Promise<string> => {
|
|
663
|
+
if (task.kind === 'clear') {
|
|
664
|
+
return digestId([
|
|
665
|
+
'a2:scheduler-qstash:store-outage:clear:v1',
|
|
666
|
+
url,
|
|
667
|
+
task.contract,
|
|
668
|
+
task.sessionId,
|
|
669
|
+
task.id,
|
|
670
|
+
outage.generation,
|
|
671
|
+
])
|
|
672
|
+
}
|
|
661
673
|
if (task.kind === 'append') {
|
|
662
674
|
return digestId([
|
|
663
675
|
'a2:scheduler-qstash:store-outage:append:v1',
|
|
@@ -708,14 +720,27 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
|
|
|
708
720
|
sessionId: task.sessionId,
|
|
709
721
|
dueAt,
|
|
710
722
|
events: task.events,
|
|
723
|
+
...(task.originIndex === undefined
|
|
724
|
+
? {}
|
|
725
|
+
: { originIndex: task.originIndex }),
|
|
711
726
|
}
|
|
712
|
-
:
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
727
|
+
: task.kind === 'clear'
|
|
728
|
+
? {
|
|
729
|
+
version: 1,
|
|
730
|
+
kind: 'clear',
|
|
731
|
+
id: task.id,
|
|
732
|
+
contract: task.contract,
|
|
733
|
+
sessionId: task.sessionId,
|
|
734
|
+
dueAt,
|
|
735
|
+
ifLastIndex: task.ifLastIndex,
|
|
736
|
+
}
|
|
737
|
+
: {
|
|
738
|
+
version: 1,
|
|
739
|
+
kind: 'drain',
|
|
740
|
+
contract: task.contract,
|
|
741
|
+
sessionId: task.sessionId,
|
|
742
|
+
dueAt,
|
|
743
|
+
}
|
|
719
744
|
const message: QStashMessage = {
|
|
720
745
|
...normalizedTask,
|
|
721
746
|
...(outage === undefined ? {} : { _a2QStash: outage }),
|
|
@@ -725,7 +750,7 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
|
|
|
725
750
|
? {
|
|
726
751
|
...normalizedTask,
|
|
727
752
|
_a2QStash:
|
|
728
|
-
normalizedTask.kind
|
|
753
|
+
normalizedTask.kind !== 'drain'
|
|
729
754
|
? { version: 1, generation: Number.MAX_SAFE_INTEGER }
|
|
730
755
|
: {
|
|
731
756
|
version: 1,
|
|
@@ -753,13 +778,15 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
|
|
|
753
778
|
? await storeOutageDeduplicationId(task, outage)
|
|
754
779
|
: task.kind === 'append'
|
|
755
780
|
? await digestId(['a2:scheduler-qstash:append:v1', url, task.id])
|
|
756
|
-
:
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
781
|
+
: task.kind === 'clear'
|
|
782
|
+
? await digestId(['a2:scheduler-qstash:clear:v1', url, task.id])
|
|
783
|
+
: await digestId([
|
|
784
|
+
'a2:scheduler-qstash:v1',
|
|
785
|
+
url,
|
|
786
|
+
task.contract,
|
|
787
|
+
task.sessionId,
|
|
788
|
+
dueAt,
|
|
789
|
+
])
|
|
763
790
|
const trustedSource =
|
|
764
791
|
inferredVercelPreview === true
|
|
765
792
|
? previewTrustedSource(Math.max(nowMs, dueAt))
|
|
@@ -793,7 +820,7 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
|
|
|
793
820
|
const nowMs = clock.now().getTime()
|
|
794
821
|
const dueAt = Math.ceil((nowMs + delayMs) / 1_000) * 1_000
|
|
795
822
|
const nextOutage: QStashStoreOutage =
|
|
796
|
-
task.kind
|
|
823
|
+
task.kind !== 'drain'
|
|
797
824
|
? { version: 1, generation }
|
|
798
825
|
: { version: 1, generation, rootDueAt: outage?.rootDueAt ?? task.dueAt }
|
|
799
826
|
await sendTask({ ...task, dueAt }, nextOutage)
|
|
@@ -855,9 +882,11 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
|
|
|
855
882
|
)
|
|
856
883
|
}
|
|
857
884
|
const internals = serverInternals.get(server)
|
|
858
|
-
if (task.kind
|
|
885
|
+
if (task.kind !== 'drain') {
|
|
859
886
|
try {
|
|
860
|
-
|
|
887
|
+
if (task.kind === 'append')
|
|
888
|
+
await deliverSchedulerAppend(server, task)
|
|
889
|
+
else await deliverSchedulerClear(server, task)
|
|
861
890
|
} catch (error) {
|
|
862
891
|
if (!storeUnavailable(error)) throw error
|
|
863
892
|
await sendStoreOutageSuccessor(task, outage)
|