iterate 0.2.7 → 0.3.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/README.md +86 -76
- package/THIRD_PARTY_NOTICES.md +55 -0
- package/bin/iterate.js +11 -3
- package/dist/api-url-B6404M82.mjs +17 -0
- package/dist/api-url-B6404M82.mjs.map +1 -0
- package/dist/app-ref-BipL0feU.mjs +35 -0
- package/dist/app-ref-BipL0feU.mjs.map +1 -0
- package/dist/app-ref-C1CrgXqX.mjs +7 -0
- package/dist/app-ref-C1CrgXqX.mjs.map +1 -0
- package/dist/app-ref-DYai_om1.mjs +7 -0
- package/dist/app-ref-DYai_om1.mjs.map +1 -0
- package/dist/cli-D0c-pDL_.mjs +1010 -0
- package/dist/cli-D0c-pDL_.mjs.map +1 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.mjs +4 -0
- package/dist/cloudflare-BTm90gQ4.mjs +951 -0
- package/dist/cloudflare-BTm90gQ4.mjs.map +1 -0
- package/dist/contract-s4FW4eES.mjs +309 -0
- package/dist/contract-s4FW4eES.mjs.map +1 -0
- package/dist/document-review/index.d.ts +5 -0
- package/dist/document-review/types.d.ts +107 -0
- package/dist/document-review.mjs +7015 -0
- package/dist/document-review.mjs.map +1 -0
- package/dist/durable-object-processor-durability-CNsTjAJS.mjs +205 -0
- package/dist/durable-object-processor-durability-CNsTjAJS.mjs.map +1 -0
- package/dist/idempotency-DleloJNt.mjs +28 -0
- package/dist/idempotency-DleloJNt.mjs.map +1 -0
- package/dist/index.mjs +1 -1
- package/dist/itx/api-url.d.ts +6 -0
- package/dist/itx/itx-node-client.d.ts +65 -0
- package/dist/itx/itx-session.d.ts +215 -0
- package/dist/itx/owned-rpc-session.d.ts +14 -0
- package/dist/itx/query-client.d.ts +10 -0
- package/dist/itx-api.generated.d.ts +6195 -0
- package/dist/itx-session-sjud8GiT.mjs +534 -0
- package/dist/itx-session-sjud8GiT.mjs.map +1 -0
- package/dist/live-state-BJNqOwFw.mjs +299 -0
- package/dist/live-state-BJNqOwFw.mjs.map +1 -0
- package/dist/next/api.d.ts +479 -0
- package/dist/next/api.mjs +0 -0
- package/dist/next/app-server.d.ts +44 -0
- package/dist/next/app-server.mjs +479 -0
- package/dist/next/app-server.mjs.map +1 -0
- package/dist/next/app-session.d.ts +49 -0
- package/dist/next/app-session.mjs +238 -0
- package/dist/next/app-session.mjs.map +1 -0
- package/dist/next/app.d.ts +29 -0
- package/dist/next/app.mjs +141 -0
- package/dist/next/app.mjs.map +1 -0
- package/dist/next/client/live-state.d.ts +63 -0
- package/dist/next/client/oauth.d.ts +12 -0
- package/dist/next/client/react.d.ts +109 -0
- package/dist/next/client/socket.d.ts +6 -0
- package/dist/next/client.mjs +156 -0
- package/dist/next/client.mjs.map +1 -0
- package/dist/next/expression.d.ts +146 -0
- package/dist/next/expression.mjs +399 -0
- package/dist/next/expression.mjs.map +1 -0
- package/dist/next/lib.d.ts +56 -0
- package/dist/next/lib.mjs +199 -0
- package/dist/next/lib.mjs.map +1 -0
- package/dist/next/oauth-scopes.d.ts +32 -0
- package/dist/next/oauth-scopes.mjs +40 -0
- package/dist/next/oauth-scopes.mjs.map +1 -0
- package/dist/next/oauth.mjs +29 -0
- package/dist/next/oauth.mjs.map +1 -0
- package/dist/next/principal.d.ts +64 -0
- package/dist/next/principal.mjs +98 -0
- package/dist/next/principal.mjs.map +1 -0
- package/dist/next/project-ingress.d.ts +37 -0
- package/dist/next/project-ingress.mjs +75 -0
- package/dist/next/project-ingress.mjs.map +1 -0
- package/dist/next/react.mjs +285 -0
- package/dist/next/react.mjs.map +1 -0
- package/dist/next/sdk/auth.d.ts +5 -0
- package/dist/next/sdk/index.d.ts +112 -0
- package/dist/next/sdk.mjs +139 -0
- package/dist/next/sdk.mjs.map +1 -0
- package/dist/next/stream/processor.d.ts +378 -0
- package/dist/next/stream/processor.mjs +582 -0
- package/dist/next/stream/processor.mjs.map +1 -0
- package/dist/next/stream/run.d.ts +58 -0
- package/dist/next/stream/run.mjs +40 -0
- package/dist/next/stream/run.mjs.map +1 -0
- package/dist/next-node.d.ts +15 -0
- package/dist/next-node.mjs +51 -0
- package/dist/next-node.mjs.map +1 -0
- package/dist/node.d.ts +3 -0
- package/dist/node.mjs +185 -0
- package/dist/node.mjs.map +1 -0
- package/dist/processor-host-capabilities-BMFH3KTM.mjs +56 -0
- package/dist/processor-host-capabilities-BMFH3KTM.mjs.map +1 -0
- package/dist/processors/cloudflare.d.ts +3 -0
- package/dist/processors/durable-object-processor-durability.d.ts +79 -0
- package/dist/processors/event-consumption-metrics.d.ts +82 -0
- package/dist/processors/idempotency.d.ts +13 -0
- package/dist/processors/index.d.ts +12 -0
- package/dist/processors/processor-contracts.d.ts +342 -0
- package/dist/processors/processor-facet.d.ts +186 -0
- package/dist/processors/processor-host-capabilities.d.ts +60 -0
- package/dist/processors/prompt-sections.d.ts +17 -0
- package/dist/processors/rpc-types.d.ts +515 -0
- package/dist/processors/schemas.d.ts +102 -0
- package/dist/processors/stream-handle.d.ts +45 -0
- package/dist/processors/stream-processor-keepalive.d.ts +95 -0
- package/dist/processors/stream-processor-registry.d.ts +233 -0
- package/dist/processors/stream-processor-runner.d.ts +289 -0
- package/dist/processors/stream-processor.d.ts +339 -0
- package/dist/processors/stream-runtime-metrics.d.ts +107 -0
- package/dist/processors/testing.d.ts +302 -0
- package/dist/processors-BoNyeBfQ.mjs +10 -0
- package/dist/processors-BoNyeBfQ.mjs.map +1 -0
- package/dist/processors-cloudflare.mjs +3 -0
- package/dist/processors-testing.mjs +435 -0
- package/dist/processors-testing.mjs.map +1 -0
- package/dist/processors.mjs +52 -0
- package/dist/processors.mjs.map +1 -0
- package/dist/protocol-DnK_f2m6.mjs +251 -0
- package/dist/protocol-DnK_f2m6.mjs.map +1 -0
- package/dist/sdk/capnweb/index.d.ts +2 -0
- package/dist/sdk/capnweb/live-state/compact.d.ts +5 -0
- package/dist/sdk/capnweb/live-state/diff.d.ts +41 -0
- package/dist/sdk/capnweb/live-state/engine.d.ts +44 -0
- package/dist/sdk/capnweb/live-state/index.d.ts +41 -0
- package/dist/sdk/capnweb/live-state/protocol.d.ts +87 -0
- package/dist/sdk/capnweb/live-state/retain.d.ts +23 -0
- package/dist/sdk/capnweb/live-state/store.d.ts +20 -0
- package/dist/sdk/capnweb/live-state/types.d.ts +11 -0
- package/dist/sdk/capnweb/react.d.ts +45 -0
- package/dist/sdk/capnweb/react.mjs +316 -0
- package/dist/sdk/capnweb/react.mjs.map +1 -0
- package/dist/sdk/capnweb.mjs +4 -0
- package/dist/sdk/itx/react.d.ts +191 -0
- package/dist/sdk/itx/react.mjs +383 -0
- package/dist/sdk/itx/react.mjs.map +1 -0
- package/dist/sdk-DMB-IM11.mjs +933 -0
- package/dist/sdk-DMB-IM11.mjs.map +1 -0
- package/dist/sdk.d.ts +339 -0
- package/dist/sdk.mjs +2 -0
- package/dist/serve-itx.d.ts +46 -0
- package/dist/starter-apps/flake-dashboard/app-ref.d.ts +31 -0
- package/dist/starter-apps/flake-dashboard/configured-worker.mjs +1055 -0
- package/dist/starter-apps/flake-dashboard/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/flake-dashboard/contract.d.ts +4839 -0
- package/dist/starter-apps/flake-dashboard/contract.mjs +2 -0
- package/dist/starter-apps/flake-dashboard/index.d.ts +17 -0
- package/dist/starter-apps/flake-dashboard/index.mjs +56 -0
- package/dist/starter-apps/flake-dashboard/index.mjs.map +1 -0
- package/dist/starter-apps/flake-dashboard/worker.d.ts +4607 -0
- package/dist/starter-apps/github-ai-linter/ai-linter.d.ts +8914 -0
- package/dist/starter-apps/github-ai-linter/configured-worker.mjs +17987 -0
- package/dist/starter-apps/github-ai-linter/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/github-ai-linter/contract.d.ts +9193 -0
- package/dist/starter-apps/github-ai-linter/index.d.ts +10 -0
- package/dist/starter-apps/github-ai-linter/index.mjs +36 -0
- package/dist/starter-apps/github-ai-linter/index.mjs.map +1 -0
- package/dist/starter-apps/github-ai-linter/prompt.d.ts +13 -0
- package/dist/starter-apps/github-ai-linter/review-bot.d.ts +808 -0
- package/dist/starter-apps/github-ai-linter/rules.d.ts +34 -0
- package/dist/starter-apps/github-ai-linter/worker-ref.d.ts +19 -0
- package/dist/starter-apps/github-ai-linter/worker.d.ts +19 -0
- package/dist/starter-apps/github-ai-linter/worker.mjs +947 -0
- package/dist/starter-apps/github-ai-linter/worker.mjs.map +1 -0
- package/dist/starter-apps/guestbook/app-ref.d.ts +27 -0
- package/dist/starter-apps/guestbook/client.d.ts +7 -0
- package/dist/starter-apps/guestbook/client.mjs +59 -0
- package/dist/starter-apps/guestbook/configured-worker.mjs +205 -0
- package/dist/starter-apps/guestbook/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/guestbook/index.d.ts +9 -0
- package/dist/starter-apps/guestbook/index.mjs +31 -0
- package/dist/starter-apps/guestbook/index.mjs.map +1 -0
- package/dist/starter-apps/guestbook/processor.d.ts +2267 -0
- package/dist/starter-apps/guestbook/worker.d.ts +26 -0
- package/dist/starter-apps/guestbook/worker.mjs +191 -0
- package/dist/starter-apps/guestbook/worker.mjs.map +1 -0
- package/dist/starter-apps/media/configured-worker.mjs +577 -0
- package/dist/starter-apps/media/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/media/index.mjs +36 -0
- package/dist/starter-apps/media/index.mjs.map +1 -0
- package/dist/starter-apps/media/ref.mjs +20 -0
- package/dist/starter-apps/media/ref.mjs.map +1 -0
- package/dist/starter-apps/media/worker.mjs +579 -0
- package/dist/starter-apps/media/worker.mjs.map +1 -0
- package/dist/starter-apps/notes/configured-worker.mjs +6134 -0
- package/dist/starter-apps/notes/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/notes/index.mjs +23 -0
- package/dist/starter-apps/notes/index.mjs.map +1 -0
- package/dist/starter-apps/notes/ref.mjs +21 -0
- package/dist/starter-apps/notes/ref.mjs.map +1 -0
- package/dist/starter-apps/notes/worker.mjs +427 -0
- package/dist/starter-apps/notes/worker.mjs.map +1 -0
- package/dist/starter-apps/todo/client.mjs +59 -0
- package/dist/starter-apps/todo/configured-worker.mjs +2864 -0
- package/dist/starter-apps/todo/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/todo/index.d.ts +8 -0
- package/dist/starter-apps/todo/index.mjs +29 -0
- package/dist/starter-apps/todo/index.mjs.map +1 -0
- package/dist/stream-processor-keepalive-DAQTP6m3.mjs +2082 -0
- package/dist/stream-processor-keepalive-DAQTP6m3.mjs.map +1 -0
- package/dist/usingCtx-inzbY1Qz.mjs +57 -0
- package/dist/usingCtx-mZx5nsAW.mjs +11800 -0
- package/dist/usingCtx-mZx5nsAW.mjs.map +1 -0
- package/dist/worker-ref-DZxPDmb_.mjs +390 -0
- package/dist/worker-ref-DZxPDmb_.mjs.map +1 -0
- package/menubar/Iterate.entitlements +12 -0
- package/menubar/Iterate.swift +914 -0
- package/menubar/IterateIcon.swift +145 -0
- package/menubar/README.md +28 -0
- package/menubar/build-menubar-app.sh +59 -0
- package/package.json +235 -18
- package/dist/cli-DMS4kJph.mjs +0 -868
- package/dist/cli-DMS4kJph.mjs.map +0 -1
- package/dist/config-DtnR7Lv7.mjs +0 -170
- package/dist/config-DtnR7Lv7.mjs.map +0 -1
- package/dist/index.d.mts.map +0 -1
- package/dist/stream-tui/agent-chat-terminal.d.mts +0 -1
- package/dist/stream-tui/agent-chat-terminal.mjs +0 -933
- package/dist/stream-tui/agent-chat-terminal.mjs.map +0 -1
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { codedError, diff, jsonEqual, reportIssue } from "../lib.mjs";
|
|
2
|
+
import "zod";
|
|
3
|
+
//#region src/next/stream/processor.ts
|
|
4
|
+
/** How long after an attempt starts a dead host is revived: the recovery bound. A revive that finds
|
|
5
|
+
* the attempt still in flight claims again with the delay doubled, up to `REVIVE_AFTER_MAX_MS`. */
|
|
6
|
+
const REVIVE_AFTER_MS = 2e4;
|
|
7
|
+
const REVIVE_AFTER_MAX_MS = 30 * 6e4;
|
|
8
|
+
/** THE ONE consumes rule — the processor engine, the subscription delivery loop, and the inline
|
|
9
|
+
* reduces all call this; there is no second copy to drift. `consumes` undefined = every durable event
|
|
10
|
+
* (a subscriber's default). "*" = every durable event. A NAMED type opts that type in, INCLUDING
|
|
11
|
+
* ephemerals ("*" NEVER sweeps ephemerals) — so a live-state watcher spells
|
|
12
|
+
* `consumes: ["events.iterate.com/live-state/changed"]` and filters `payload.key` itself. The wake
|
|
13
|
+
* record (`stream/woken`) is a durable event like any other: a "*" row receives every incarnation's. */
|
|
14
|
+
function consumesEvent(consumes, event) {
|
|
15
|
+
if (event.ephemeral) return consumes?.includes(event.type) ?? false;
|
|
16
|
+
return !consumes || consumes.includes("*") || consumes.includes(event.type);
|
|
17
|
+
}
|
|
18
|
+
/** What the ENGINE reduces: the contract's consumes, minus the one type no processor may ever reduce or
|
|
19
|
+
* react to — a live-state delta. Deltas are notifications ABOUT state; letting one feed a reduce is
|
|
20
|
+
* the feedback-loop class, made unspellable here rather than discouraged. */
|
|
21
|
+
const reducesEvent = (consumes, event) => event.type !== "events.iterate.com/live-state/changed" && consumesEvent(consumes, event);
|
|
22
|
+
/** THE AUTHOR CLASS: a contract, three hooks and one helper. Deps an effect needs arrive through
|
|
23
|
+
* the subclass's own constructor, as for any class. One instance lives as long as its host; a field
|
|
24
|
+
* on it is RUNTIME state (gone with the host), which `projectLiveState` may reduce into the live view. */
|
|
25
|
+
var StreamProcessor = class {
|
|
26
|
+
/** Pure reduce. Return the NEXT state (a new object) — or null/undefined to keep the current. The
|
|
27
|
+
* `Event` type param — a discriminated union of the events the contract consumes — narrows
|
|
28
|
+
* `event.payload` per `event.type` inside the body, so no cast is needed; it defaults to the
|
|
29
|
+
* untyped `StreamEvent` for processors that don't declare one. */
|
|
30
|
+
reduce(_args) {}
|
|
31
|
+
/** Side-effect hook. Synchronous by design: register async work via the two helpers on args.
|
|
32
|
+
* `append`/`appendTo` take what THIS class's `contract` emits (`EmittedEventInput<this["contract"]>`:
|
|
33
|
+
* a subclass whose `contract` is a defined one gets each emitted type's payload as its catalog
|
|
34
|
+
* spells it; the base, and a hand-built contract, take any input). */
|
|
35
|
+
processEvent(_args) {}
|
|
36
|
+
/** The live-state PROJECTION — the shape clients see and the diffs are computed over. DEFAULT: the
|
|
37
|
+
* reduced state verbatim, so every processor is live out of the box; that is deliberate — the
|
|
38
|
+
* delta is an EPHEMERAL event, so "always live" costs an offset and a cheap diff, nothing durable.
|
|
39
|
+
* Override to redact, or to REDUCE IN RUNTIME FIELDS (`return { ...state, lastSeenMs: this.lastSeenMs }`);
|
|
40
|
+
* the engine re-projects after EVERY batch, and a field changed outside a batch needs the host's
|
|
41
|
+
* `publishLiveState()`. */
|
|
42
|
+
projectLiveState(state) {
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
45
|
+
/** Stable idempotency key namespaced by slug; pass the event being processed for a per-event key. */
|
|
46
|
+
idempotencyKey(key, event) {
|
|
47
|
+
return event ? `${this.contract.slug}/${key}@${event.offset}` : `${this.contract.slug}/${key}`;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/** THE ENGINE: everything below the author's three hooks — the serial chain, the checkpoint, gap
|
|
51
|
+
* repair, the at-head pass, version re-reduces, live-state publishing. Constructed by the host
|
|
52
|
+
* (`StreamProcessorDurableObject`; a test with the stand-ins in test-support.ts). */
|
|
53
|
+
var ProcessorEngine = class {
|
|
54
|
+
processor;
|
|
55
|
+
#contract;
|
|
56
|
+
#stream;
|
|
57
|
+
#storage;
|
|
58
|
+
/** Rule 1: every batch runs on this chain, one after another. */
|
|
59
|
+
#serialBatchChain = Promise.resolve();
|
|
60
|
+
/** The reduced state and the durable offset it was reduced through — checkpointed on the batches
|
|
61
|
+
* that carried a durable. */
|
|
62
|
+
#reducedState;
|
|
63
|
+
#reducedThroughOffset;
|
|
64
|
+
/** A checkpoint found under ANOTHER contract version: the input to the one re-reduce the chain
|
|
65
|
+
* runs before anything else; cleared once it ran. */
|
|
66
|
+
#staleCheckpoint;
|
|
67
|
+
/** The highest `range.through` ever SHOWN to this processor (see processEventBatch). */
|
|
68
|
+
#pushedThroughOffset;
|
|
69
|
+
/** A refusal that can only repeat — the checkpoint over its cell (stamped `retryable: false`):
|
|
70
|
+
* LATCHED for this incarnation, so every later batch, catch-up and read verb rejects with it at
|
|
71
|
+
* once instead of re-reducing into the same wall on every push and wake. A fresh incarnation
|
|
72
|
+
* tries once more. */
|
|
73
|
+
#latchedRefusal;
|
|
74
|
+
/** waitUntilProcessed's waiting callers, resolved as the cursor advances. */
|
|
75
|
+
#waitUntilProcessedWaiters = [];
|
|
76
|
+
/** Born with the engine, so its epoch is minted once per incarnation. */
|
|
77
|
+
#liveState;
|
|
78
|
+
/** Rule 3's claim: attempts in flight, and how many revives found one still in flight (the
|
|
79
|
+
* backoff of the next claim; reset when the last attempt settles). The claim calls ride ONE
|
|
80
|
+
* chain, so a release never overtakes the claim of the attempt that followed it. */
|
|
81
|
+
#backgroundWorkInFlight = 0;
|
|
82
|
+
#revivesWhileBusy = 0;
|
|
83
|
+
#claimChain = Promise.resolve();
|
|
84
|
+
/** Whether the last batch this engine ran carried the at-head pass (rule 5) — what `revive()`
|
|
85
|
+
* reads to know if its catch-up already ran one. */
|
|
86
|
+
#lastBatchAtHead = false;
|
|
87
|
+
constructor(processor, deps) {
|
|
88
|
+
this.processor = processor;
|
|
89
|
+
this.#contract = processor.contract;
|
|
90
|
+
this.#stream = deps.stream;
|
|
91
|
+
this.#storage = deps.storage;
|
|
92
|
+
const { slug, version } = this.#contract;
|
|
93
|
+
const checkpoint = this.#storage.read(slug);
|
|
94
|
+
if (checkpoint?.reducerVersion === version) {
|
|
95
|
+
this.#reducedState = checkpoint.state ?? this.#contract.initialState();
|
|
96
|
+
this.#reducedThroughOffset = checkpoint.reducedThroughOffset;
|
|
97
|
+
} else {
|
|
98
|
+
this.#reducedState = this.#contract.initialState();
|
|
99
|
+
this.#reducedThroughOffset = 0;
|
|
100
|
+
if (checkpoint) this.#staleCheckpoint = {
|
|
101
|
+
reducedThroughOffset: checkpoint.reducedThroughOffset,
|
|
102
|
+
state: checkpoint.state ?? this.#reducedState
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
let seed;
|
|
106
|
+
try {
|
|
107
|
+
seed = processor.projectLiveState(this.#staleCheckpoint ? this.#staleCheckpoint.state : this.#reducedState);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
reportIssue("processor.live-state", error, { slug });
|
|
110
|
+
seed = void 0;
|
|
111
|
+
}
|
|
112
|
+
this.#liveState = new LiveState(this.#stream, slug, seed);
|
|
113
|
+
}
|
|
114
|
+
/** THE SEED DOOR for live-state clients (LiveState.snapshot), caught up first. */
|
|
115
|
+
async liveSnapshot() {
|
|
116
|
+
if (!this.#reducedThroughPushedHead()) await this.catchUpFromLog();
|
|
117
|
+
return this.#liveState.snapshot();
|
|
118
|
+
}
|
|
119
|
+
/** Emit a delta for the CURRENT projection (reduced + any runtime fields) if it changed. The engine
|
|
120
|
+
* calls this after every batch; the host calls it after a runtime field moved outside a batch. A
|
|
121
|
+
* throwing projection loses only its notification (the client re-seeds on the chain gap). */
|
|
122
|
+
publishLiveState() {
|
|
123
|
+
let projection;
|
|
124
|
+
try {
|
|
125
|
+
projection = this.processor.projectLiveState(this.#reducedState);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
reportIssue("processor.live-state", error, { slug: this.#contract.slug });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.#liveState.set(projection);
|
|
131
|
+
}
|
|
132
|
+
/** THE push door: contiguous → reduce it directly (no read); anything else → gap repair from the
|
|
133
|
+
* own cursor first. Fire-and-forget safe: enqueues on the serial chain. */
|
|
134
|
+
processEventBatch(events, range) {
|
|
135
|
+
this.#pushedThroughOffset = Math.max(this.#pushedThroughOffset ?? 0, range.through);
|
|
136
|
+
return this.#runOnSerialChain(async () => {
|
|
137
|
+
await this.#rereduceIfVersionChanged();
|
|
138
|
+
while (this.#reducedThroughOffset < range.after) {
|
|
139
|
+
const after = this.#reducedThroughOffset;
|
|
140
|
+
const page = await this.#stream.read(after, 500);
|
|
141
|
+
if (page.scannedThroughOffset <= after) break;
|
|
142
|
+
await this.#reduceAndCommitEventBatch(page.events.filter((event) => event.offset <= range.after), {
|
|
143
|
+
after,
|
|
144
|
+
through: Math.min(page.scannedThroughOffset, range.after)
|
|
145
|
+
}, false);
|
|
146
|
+
}
|
|
147
|
+
await this.#reduceAndCommitEventBatch(events, range, range.through >= this.#pushedThroughOffset);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** Catch up from the own checkpoint (a cold boot, the read verbs, the barrier), page by page — a
|
|
151
|
+
* failed batch, a missed push, or a fresh incarnation can never skip a durable event. */
|
|
152
|
+
catchUpFromLog() {
|
|
153
|
+
return this.#runOnSerialChain(async () => {
|
|
154
|
+
await this.#rereduceIfVersionChanged();
|
|
155
|
+
for (;;) {
|
|
156
|
+
const after = this.#reducedThroughOffset;
|
|
157
|
+
const page = await this.#stream.read(after, 500);
|
|
158
|
+
if (page.scannedThroughOffset <= after) return;
|
|
159
|
+
await this.#reduceAndCommitEventBatch(page.events, {
|
|
160
|
+
after,
|
|
161
|
+
through: page.scannedThroughOffset
|
|
162
|
+
}, page.atHead);
|
|
163
|
+
if (page.atHead) return;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/** Reduce-and-effects caught up through the log, then `{ offset, state }`. */
|
|
168
|
+
async snapshot() {
|
|
169
|
+
if (!this.#reducedThroughPushedHead()) await this.catchUpFromLog();
|
|
170
|
+
return {
|
|
171
|
+
offset: this.#reducedThroughOffset,
|
|
172
|
+
state: this.#reducedState
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** Provably reduced through the head SHOWN so far → the read verbs skip their catch-up read. */
|
|
176
|
+
#reducedThroughPushedHead() {
|
|
177
|
+
return this.#pushedThroughOffset !== void 0 && this.#reducedThroughOffset >= this.#pushedThroughOffset;
|
|
178
|
+
}
|
|
179
|
+
/** THE barrier verb (read-your-writes): resolves once processed AT LEAST through `offset`. An
|
|
180
|
+
* offset ABOVE the durable mark (an ephemeral's) is reached only if this processor was pushed it —
|
|
181
|
+
* the log cannot prove past the mark, so a wake alone never advances there. */
|
|
182
|
+
waitUntilProcessed(input) {
|
|
183
|
+
const { offset, timeoutMs = 1e4 } = input;
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
if (this.#reducedThroughOffset >= offset) return resolve();
|
|
186
|
+
const waiter = {
|
|
187
|
+
offset,
|
|
188
|
+
resolve: () => {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
resolve();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
const timer = setTimeout(() => {
|
|
194
|
+
this.#waitUntilProcessedWaiters.splice(this.#waitUntilProcessedWaiters.indexOf(waiter), 1);
|
|
195
|
+
reject(/* @__PURE__ */ new Error(`processor "${this.#contract.slug}" did not reach offset ${offset} in ${timeoutMs}ms`));
|
|
196
|
+
}, timeoutMs);
|
|
197
|
+
this.#waitUntilProcessedWaiters.push(waiter);
|
|
198
|
+
this.catchUpFromLog().catch((error) => {
|
|
199
|
+
const i = this.#waitUntilProcessedWaiters.indexOf(waiter);
|
|
200
|
+
if (i === -1) return;
|
|
201
|
+
this.#waitUntilProcessedWaiters.splice(i, 1);
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Serialize on the chain. THE RULE: never await your own chain from inside a batch — a
|
|
208
|
+
* processor that appends during its batch would deadlock, which is why every append→drive
|
|
209
|
+
* caller is fire-and-forget. */
|
|
210
|
+
#runOnSerialChain(work) {
|
|
211
|
+
const run = this.#serialBatchChain.then(() => {
|
|
212
|
+
if (this.#latchedRefusal) throw this.#latchedRefusal;
|
|
213
|
+
return work();
|
|
214
|
+
});
|
|
215
|
+
this.#serialBatchChain = run.catch(() => {});
|
|
216
|
+
return run;
|
|
217
|
+
}
|
|
218
|
+
/** The one-time cost of a contract version bump: re-reduce the durable log from offset 0 through
|
|
219
|
+
* the OLD cursor (`reduce` only — those effects already ran) and checkpoint under the new
|
|
220
|
+
* version. Never past the old cursor: re-reducing to the head instead would judge an
|
|
221
|
+
* already-queued in-flight push stale and swallow its effects. */
|
|
222
|
+
async #rereduceIfVersionChanged() {
|
|
223
|
+
if (!this.#staleCheckpoint) return;
|
|
224
|
+
const target = this.#staleCheckpoint.reducedThroughOffset;
|
|
225
|
+
let state = this.#contract.initialState();
|
|
226
|
+
let reducedThroughOffset = 0;
|
|
227
|
+
while (reducedThroughOffset < target) {
|
|
228
|
+
const page = await this.#stream.read(reducedThroughOffset, 500);
|
|
229
|
+
for (const event of page.events) if (event.offset <= target && reducesEvent(this.#contract.consumes, event)) state = this.#validateNormalizeAndReduce(event, state).state;
|
|
230
|
+
if (page.scannedThroughOffset <= reducedThroughOffset) break;
|
|
231
|
+
reducedThroughOffset = Math.min(page.scannedThroughOffset, target);
|
|
232
|
+
}
|
|
233
|
+
this.#writeCheckpointOrLatch(this.#contract.slug, {
|
|
234
|
+
reducerVersion: this.#contract.version,
|
|
235
|
+
reducedThroughOffset: target
|
|
236
|
+
}, state, true);
|
|
237
|
+
this.#reducedState = state;
|
|
238
|
+
this.#reducedThroughOffset = target;
|
|
239
|
+
this.#staleCheckpoint = void 0;
|
|
240
|
+
this.publishLiveState();
|
|
241
|
+
this.#resolveWaitUntilProcessedWaiters(target);
|
|
242
|
+
}
|
|
243
|
+
/** Rules 2–5 over one range (the caller has healed any durable prefix gap first). DURABLES reduce
|
|
244
|
+
* at-most-once (`offset > cursor`); EPHEMERALS ALWAYS deliver — each rides exactly one push and
|
|
245
|
+
* can never be a redelivery, so a durable-only wake that clamped the cursor PAST an ephemeral
|
|
246
|
+
* offset must not suppress it. The cursor is a DURABLE-reduce watermark and never regresses. */
|
|
247
|
+
async #reduceAndCommitEventBatch(events, range, atHead) {
|
|
248
|
+
const reducedThroughOffsetBefore = this.#reducedThroughOffset;
|
|
249
|
+
const stateBefore = this.#reducedState;
|
|
250
|
+
let state = stateBefore;
|
|
251
|
+
const consumableEvents = events.filter((event) => reducesEvent(this.#contract.consumes, event) && (event.ephemeral || event.offset > reducedThroughOffsetBefore));
|
|
252
|
+
let caughtUpDelivered = false;
|
|
253
|
+
for (let i = 0; i < consumableEvents.length; i++) {
|
|
254
|
+
const last = i === consumableEvents.length - 1;
|
|
255
|
+
const r = await this.#reduceAndProcessEvent(consumableEvents[i], state, atHead && last);
|
|
256
|
+
state = r.state;
|
|
257
|
+
if (atHead && last && r.processed) caughtUpDelivered = true;
|
|
258
|
+
}
|
|
259
|
+
if (atHead && !caughtUpDelivered) state = (await this.#reduceAndProcessEvent(null, state, true)).state;
|
|
260
|
+
const reducedThroughOffset = Math.max(reducedThroughOffsetBefore, range.through);
|
|
261
|
+
const advanced = reducedThroughOffset > reducedThroughOffsetBefore;
|
|
262
|
+
if (events.some((event) => !event.ephemeral) && advanced) this.#writeCheckpointOrLatch(this.#contract.slug, {
|
|
263
|
+
reducerVersion: this.#contract.version,
|
|
264
|
+
reducedThroughOffset
|
|
265
|
+
}, state, state !== stateBefore);
|
|
266
|
+
this.#reducedState = state;
|
|
267
|
+
this.#reducedThroughOffset = reducedThroughOffset;
|
|
268
|
+
this.#lastBatchAtHead = atHead;
|
|
269
|
+
this.#resolveWaitUntilProcessedWaiters(reducedThroughOffset);
|
|
270
|
+
this.publishLiveState();
|
|
271
|
+
}
|
|
272
|
+
/** Start an attempt: the first in flight claims the alarm (not awaited — a claim that has not
|
|
273
|
+
* landed when the host dies revives nothing either way, and the attempt must not wait on it);
|
|
274
|
+
* the last to settle releases the claim. */
|
|
275
|
+
#runInBackground(work) {
|
|
276
|
+
this.#backgroundWorkInFlight += 1;
|
|
277
|
+
if (this.#backgroundWorkInFlight === 1) this.#claim(REVIVE_AFTER_MS);
|
|
278
|
+
work().catch((error) => reportIssue("processor.background", error, { slug: this.#contract.slug })).finally(() => {
|
|
279
|
+
this.#backgroundWorkInFlight -= 1;
|
|
280
|
+
if (this.#backgroundWorkInFlight === 0) {
|
|
281
|
+
this.#revivesWhileBusy = 0;
|
|
282
|
+
this.#claim(null);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
#claim(afterMs) {
|
|
287
|
+
const at = afterMs === null ? null : Date.now() + afterMs;
|
|
288
|
+
this.#claimChain = this.#claimChain.then(() => this.#stream.claim(at)).catch((error) => reportIssue("processor.claim", error, { slug: this.#contract.slug }));
|
|
289
|
+
}
|
|
290
|
+
/** THE REVIVE — the context's alarm pass calls this for a due claim (spent by then): catch up
|
|
291
|
+
* from the log and run the at-head pass, so a processor restarts what state says is still owed
|
|
292
|
+
* (rule 3). A fresh incarnation finds nothing in flight and starts it; an attempt still in flight
|
|
293
|
+
* here claims again, later each time (20 s, 40 s, … `REVIVE_AFTER_MAX_MS`). */
|
|
294
|
+
async revive() {
|
|
295
|
+
this.#lastBatchAtHead = false;
|
|
296
|
+
await this.catchUpFromLog();
|
|
297
|
+
if (!this.#lastBatchAtHead) await this.#runOnSerialChain(async () => {
|
|
298
|
+
this.#reducedState = (await this.#reduceAndProcessEvent(null, this.#reducedState, true)).state;
|
|
299
|
+
this.publishLiveState();
|
|
300
|
+
});
|
|
301
|
+
if (this.#backgroundWorkInFlight === 0) return;
|
|
302
|
+
this.#revivesWhileBusy += 1;
|
|
303
|
+
this.#claim(Math.min(REVIVE_AFTER_MS * 2 ** this.#revivesWhileBusy, REVIVE_AFTER_MAX_MS));
|
|
304
|
+
await this.#claimChain;
|
|
305
|
+
}
|
|
306
|
+
/** THE GUARDED REDUCE, shared by the live flow and the version replay. A reducer that throws on an
|
|
307
|
+
* event (malformed, or one an OLDER version accepted) must never wedge the processor: on a version
|
|
308
|
+
* replay it would fail the catch-up before the new checkpoint is written, every incarnation. */
|
|
309
|
+
#reduceOrKeep(event, state) {
|
|
310
|
+
try {
|
|
311
|
+
return this.processor.reduce({
|
|
312
|
+
event,
|
|
313
|
+
state
|
|
314
|
+
}) ?? state;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
reportIssue("processor.reduce", error, {
|
|
317
|
+
slug: this.#contract.slug,
|
|
318
|
+
offset: event.offset
|
|
319
|
+
});
|
|
320
|
+
return state;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** Validate a consumed event's payload against the contract's declared schema, then reduce it — or,
|
|
324
|
+
* for a malformed payload, skip the fold and report (it must never corrupt reduced state, the
|
|
325
|
+
* exported view a live client parses). Returns the next state AND the event to carry onward,
|
|
326
|
+
* NORMALIZED to the schema's `z.output` (coercions/defaults applied) when it validated — so the
|
|
327
|
+
* reducer, the effect hook, and the version replay all see exactly what `ConsumedEvent<Contract>`
|
|
328
|
+
* promises. SHARED by the live flow and `#rereduceIfVersionChanged`, so the two can never diverge
|
|
329
|
+
* (a coercion applied live but not on replay would make a version bump rewrite state). A payload-less
|
|
330
|
+
* event validates as `{}` (the "empty defaults" convention the contract requires of its stateSchema);
|
|
331
|
+
* a contract with no `events` catalog (the kernel-generic processors) folds unvalidated, as before. */
|
|
332
|
+
#validateNormalizeAndReduce(event, state) {
|
|
333
|
+
const parsed = this.#contract.payloadSchemaFor?.(event.type)?.safeParse(event.payload ?? {});
|
|
334
|
+
if (parsed && !parsed.success) {
|
|
335
|
+
reportIssue("processor.reduce.payload", parsed.error, {
|
|
336
|
+
slug: this.#contract.slug,
|
|
337
|
+
offset: event.offset,
|
|
338
|
+
type: event.type
|
|
339
|
+
});
|
|
340
|
+
return {
|
|
341
|
+
state,
|
|
342
|
+
event,
|
|
343
|
+
valid: false
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
const normalized = parsed ? {
|
|
347
|
+
...event,
|
|
348
|
+
payload: parsed.data
|
|
349
|
+
} : event;
|
|
350
|
+
return {
|
|
351
|
+
state: this.#reduceOrKeep(normalized, state),
|
|
352
|
+
event: normalized,
|
|
353
|
+
valid: true
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/** THE per-event primitive (rules 2–3) — the batch loop and the eventless at-head pass both come
|
|
357
|
+
* here: a GUARDED reduce, then `processEvent` with a FIFO blocker chain drained to a FIXED POINT.
|
|
358
|
+
* Returns the next state and whether the effect ran — a malformed payload for a KNOWN event is
|
|
359
|
+
* SKIPPED for BOTH reduce and effect (the effect hook is typed against `ConsumedEvent`'s `z.output`,
|
|
360
|
+
* so handing it garbage would throw and wedge the batch — checkpoints never advance, catch-up
|
|
361
|
+
* refails the same row); `processed: false` lets the batch fall back to the eventless caught-up pass.
|
|
362
|
+
* Owns NO cursor / persist / waiter — the caller does. */
|
|
363
|
+
async #reduceAndProcessEvent(event, state, caughtUp) {
|
|
364
|
+
const { slug, version, emits } = this.#contract;
|
|
365
|
+
const previousState = state;
|
|
366
|
+
if (event) {
|
|
367
|
+
const reduced = this.#validateNormalizeAndReduce(event, state);
|
|
368
|
+
if (!reduced.valid) return {
|
|
369
|
+
state: reduced.state,
|
|
370
|
+
processed: false
|
|
371
|
+
};
|
|
372
|
+
state = reduced.state;
|
|
373
|
+
event = reduced.event;
|
|
374
|
+
}
|
|
375
|
+
let blockers = Promise.resolve();
|
|
376
|
+
const stamped = (emittedEvents) => {
|
|
377
|
+
for (const emitted of emittedEvents) {
|
|
378
|
+
if (!emits.includes(emitted.type)) throw new Error(`processor "${slug}" emits ${JSON.stringify(emitted.type)} without declaring it`);
|
|
379
|
+
emitted.source = { processor: {
|
|
380
|
+
slug,
|
|
381
|
+
version,
|
|
382
|
+
...event && { whileProcessing: {
|
|
383
|
+
offset: event.offset,
|
|
384
|
+
type: event.type
|
|
385
|
+
} }
|
|
386
|
+
} };
|
|
387
|
+
}
|
|
388
|
+
return emittedEvents;
|
|
389
|
+
};
|
|
390
|
+
this.processor.processEvent({
|
|
391
|
+
event,
|
|
392
|
+
state,
|
|
393
|
+
previousState,
|
|
394
|
+
append: async (...emittedEvents) => await this.#stream.append(...stamped(emittedEvents)),
|
|
395
|
+
appendTo: async (path, ...emittedEvents) => {
|
|
396
|
+
if (!this.#stream.appendTo) throw new Error(`processor "${slug}": this host reaches no other context (appendTo)`);
|
|
397
|
+
return await this.#stream.appendTo(path, ...stamped(emittedEvents));
|
|
398
|
+
},
|
|
399
|
+
blockProcessorWhile: (work) => {
|
|
400
|
+
blockers = blockers.then(() => work());
|
|
401
|
+
},
|
|
402
|
+
runInBackground: (work) => this.#runInBackground(work),
|
|
403
|
+
delivery: { caughtUp }
|
|
404
|
+
});
|
|
405
|
+
for (let awaited; awaited !== blockers;) {
|
|
406
|
+
awaited = blockers;
|
|
407
|
+
await awaited;
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
state,
|
|
411
|
+
processed: true
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
/** The checkpoint write, with the latch: a refusal stamped `retryable: false` can only repeat. */
|
|
415
|
+
#writeCheckpointOrLatch(slug, cursor, state, stateChanged) {
|
|
416
|
+
try {
|
|
417
|
+
this.#storage.write(slug, cursor, state, stateChanged);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (error?.retryable === false) this.#latchedRefusal = error instanceof Error ? error : new Error(String(error));
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/** Resolve the waiters a cursor advance satisfies; keep the rest. */
|
|
424
|
+
#resolveWaitUntilProcessedWaiters(reducedThroughOffset) {
|
|
425
|
+
for (const w of this.#waitUntilProcessedWaiters.splice(0)) if (reducedThroughOffset >= w.offset) w.resolve();
|
|
426
|
+
else this.#waitUntilProcessedWaiters.push(w);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
function idempotencyConflictMessage(idempotencyKey, existingOffset) {
|
|
430
|
+
return `idempotency key "${idempotencyKey}" already names a different event at offset ${existingOffset}`;
|
|
431
|
+
}
|
|
432
|
+
/** Structural equality of the parts an idempotent retry must not change. */
|
|
433
|
+
function sameIdempotentEvent(existingEvent, requestedEvent) {
|
|
434
|
+
return existingEvent.type === requestedEvent.type && jsonEqual(existingEvent.payload, requestedEvent.payload) && jsonEqual(existingEvent.metadata, requestedEvent.metadata);
|
|
435
|
+
}
|
|
436
|
+
/** Under the 2 MB cell, with room for the row's other columns. */
|
|
437
|
+
const REDUCE_CHECKPOINT_STATE_MAX_CHARS = 2 * 1024 * 1024 - 4096;
|
|
438
|
+
/** What BOTH hosts read and write their checkpoints through — the stream's storage and a facet's
|
|
439
|
+
* own (the unit lane drives it over node:sqlite, stream/test-support.ts). */
|
|
440
|
+
var ReduceCheckpointTable = class ReduceCheckpointTable {
|
|
441
|
+
#sql;
|
|
442
|
+
/** `createTable: false` when the caller knows the table exists (the stream's storage skips every
|
|
443
|
+
* CREATE on a re-wake); a facet host constructs one per incarnation and lets it create. */
|
|
444
|
+
constructor(sql, options = { createTable: true }) {
|
|
445
|
+
this.#sql = sql;
|
|
446
|
+
if (options.createTable) ReduceCheckpointTable.createTable(sql);
|
|
447
|
+
}
|
|
448
|
+
static createTable(sql) {
|
|
449
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS reduce_checkpoints (
|
|
450
|
+
slug TEXT PRIMARY KEY,
|
|
451
|
+
reducer_version TEXT NOT NULL,
|
|
452
|
+
reduced_through_offset INTEGER NOT NULL,
|
|
453
|
+
state TEXT
|
|
454
|
+
)`);
|
|
455
|
+
}
|
|
456
|
+
read(slug) {
|
|
457
|
+
const row = this.#sql.exec("SELECT reducer_version, reduced_through_offset, state FROM reduce_checkpoints WHERE slug = ?", slug).toArray()[0];
|
|
458
|
+
if (!row) return void 0;
|
|
459
|
+
return {
|
|
460
|
+
reducerVersion: String(row.reducer_version),
|
|
461
|
+
reducedThroughOffset: Number(row.reduced_through_offset),
|
|
462
|
+
state: row.state ? JSON.parse(String(row.state)) : void 0
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
/** ALWAYS the cursor; the state ONLY when `stateChanged` — one write either way. */
|
|
466
|
+
write(slug, cursor, state, stateChanged) {
|
|
467
|
+
const serializedState = stateChanged ? JSON.stringify(state) ?? null : null;
|
|
468
|
+
if (serializedState && serializedState.length > REDUCE_CHECKPOINT_STATE_MAX_CHARS) throw Object.assign(codedError("REDUCE_CHECKPOINT_TOO_LARGE", `checkpoint "${slug}": the reduced state serializes to ${serializedState.length} chars, over the ${REDUCE_CHECKPOINT_STATE_MAX_CHARS}-char ceiling of one storage cell (2 MB) — a reduce must keep a summary, not the events; nothing was written`, {
|
|
469
|
+
slug,
|
|
470
|
+
chars: serializedState.length,
|
|
471
|
+
maxChars: REDUCE_CHECKPOINT_STATE_MAX_CHARS
|
|
472
|
+
}), { retryable: false });
|
|
473
|
+
this.#sql.exec(`INSERT INTO reduce_checkpoints (slug, reducer_version, reduced_through_offset, state)
|
|
474
|
+
VALUES (?, ?, ?, ?)
|
|
475
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
476
|
+
reducer_version = excluded.reducer_version,
|
|
477
|
+
reduced_through_offset = excluded.reduced_through_offset,
|
|
478
|
+
state = COALESCE(excluded.state, reduce_checkpoints.state)`, slug, cursor.reducerVersion, cursor.reducedThroughOffset, serializedState);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
/** A delta whose patch is over this many chars is not sent: a whole-array replace of a large
|
|
482
|
+
* projection would cost every watcher the projection per set, and past the event ceiling the door
|
|
483
|
+
* would refuse it outright. The delta rides with `patch: null` instead — the rev moved, re-seed. */
|
|
484
|
+
const LIVE_STATE_PATCH_MAX_CHARS = 1024 * 1024;
|
|
485
|
+
var LiveState = class {
|
|
486
|
+
#liveStateSink;
|
|
487
|
+
#liveStateKey;
|
|
488
|
+
#state;
|
|
489
|
+
/** The DIFF BASE: the last value that serialized — what a client that applied every delta holds.
|
|
490
|
+
* Kept apart from `#state` so a value the wire cannot carry, adopted without an emit, never
|
|
491
|
+
* becomes the base every later diff would throw against. */
|
|
492
|
+
#lastSerializedState;
|
|
493
|
+
#liveStateRev;
|
|
494
|
+
/** THE DELTA APPEND CHAIN — at most one delta append in flight, so commit order = mint order for a
|
|
495
|
+
* CROSS-HOP sink: `env.ITX.get().append(e)` mints a FRESH capability per call, so two deltas
|
|
496
|
+
* issued in different turns race across the hop and the second can commit first — ~14% of rapid
|
|
497
|
+
* pairs on the deployed edge (never locally, the hop is sub-ms). Nothing is dropped by a reorder,
|
|
498
|
+
* but it costs every watcher the full door re-read the deltas exist to avoid. A lone delta (the
|
|
499
|
+
* chain idle) is issued synchronously; only when an append is already in flight does the next
|
|
500
|
+
* queue behind it. Nobody waits on this. */
|
|
501
|
+
#liveStateDeltaAppendChain = Promise.resolve();
|
|
502
|
+
constructor(sink, key, initial) {
|
|
503
|
+
this.#liveStateSink = sink;
|
|
504
|
+
this.#liveStateKey = key;
|
|
505
|
+
this.#state = initial;
|
|
506
|
+
this.#lastSerializedState = initial;
|
|
507
|
+
this.#liveStateRev = Date.now() * 4096 + Math.floor(Math.random() * 4096);
|
|
508
|
+
}
|
|
509
|
+
/** The current value (reflects every `set`). */
|
|
510
|
+
get() {
|
|
511
|
+
return this.#state;
|
|
512
|
+
}
|
|
513
|
+
/** THE seed door: `{rev, state}` read together (single-threaded ⇒ atomically), which is what lets
|
|
514
|
+
* a client chain patches exactly instead of guessing which changes its snapshot already contains. */
|
|
515
|
+
snapshot() {
|
|
516
|
+
return {
|
|
517
|
+
rev: this.#liveStateRev,
|
|
518
|
+
state: this.#state
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/** Replace the value: diff the last serialized base → next; on a real change bump the revision
|
|
522
|
+
* and append the delta. Build a NEW value (don't mutate `next` in place) — the diff is over JSON.
|
|
523
|
+
* A diff/append failure degrades to a LOST notification (the client re-seeds on the chain gap),
|
|
524
|
+
* never a throw the caller sees. */
|
|
525
|
+
set(next) {
|
|
526
|
+
if (next === this.#lastSerializedState) return;
|
|
527
|
+
let patch;
|
|
528
|
+
try {
|
|
529
|
+
patch = diff(this.#lastSerializedState, next);
|
|
530
|
+
} catch {
|
|
531
|
+
this.#state = next;
|
|
532
|
+
this.#liveStateRev += 1;
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
this.#state = next;
|
|
536
|
+
this.#lastSerializedState = next;
|
|
537
|
+
if (!patch) return;
|
|
538
|
+
const from = this.#liveStateRev;
|
|
539
|
+
const to = from + 1;
|
|
540
|
+
this.#liveStateRev = to;
|
|
541
|
+
const wirePatch = JSON.stringify(patch).length > LIVE_STATE_PATCH_MAX_CHARS ? null : patch;
|
|
542
|
+
const emitDelta = () => this.#liveStateSink.append({
|
|
543
|
+
type: "events.iterate.com/live-state/changed",
|
|
544
|
+
ephemeral: true,
|
|
545
|
+
payload: {
|
|
546
|
+
key: this.#liveStateKey,
|
|
547
|
+
from,
|
|
548
|
+
to,
|
|
549
|
+
patch: wirePatch
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
this.#liveStateDeltaAppendChain = this.#liveStateDeltaAppendChain.then(emitDelta).catch(() => {});
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
function defineProcessorContract(contract) {
|
|
556
|
+
if (!contract.stateSchema.safeParse({}).success) throw new Error(`contract "${contract.slug}": stateSchema must parse {} (default every field)`);
|
|
557
|
+
const events = contract.events ?? {};
|
|
558
|
+
const processorDeps = contract.processorDeps ?? [];
|
|
559
|
+
const depEventTypes = /* @__PURE__ */ new Set();
|
|
560
|
+
for (const dep of processorDeps) for (const type of Object.keys(dep.events)) {
|
|
561
|
+
if (type in events) throw new Error(`contract "${contract.slug}": event "${type}" is already owned by a dep`);
|
|
562
|
+
if (depEventTypes.has(type)) throw new Error(`contract "${contract.slug}": event "${type}" is declared by two deps`);
|
|
563
|
+
depEventTypes.add(type);
|
|
564
|
+
}
|
|
565
|
+
const resolve = (type) => events[type] ?? processorDeps.map((dep) => dep.events[type]).find(Boolean);
|
|
566
|
+
return {
|
|
567
|
+
slug: contract.slug,
|
|
568
|
+
version: contract.version,
|
|
569
|
+
description: contract.description,
|
|
570
|
+
consumes: contract.consumes,
|
|
571
|
+
emits: contract.emits,
|
|
572
|
+
stateSchema: contract.stateSchema,
|
|
573
|
+
events,
|
|
574
|
+
processorDeps,
|
|
575
|
+
initialState: () => contract.stateSchema.parse({}),
|
|
576
|
+
payloadSchemaFor: (type) => resolve(type)?.payloadSchema
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
//#endregion
|
|
580
|
+
export { LiveState, ProcessorEngine, REVIVE_AFTER_MAX_MS, REVIVE_AFTER_MS, ReduceCheckpointTable, StreamProcessor, consumesEvent, defineProcessorContract, idempotencyConflictMessage, jsonEqual, sameIdempotentEvent };
|
|
581
|
+
|
|
582
|
+
//# sourceMappingURL=processor.mjs.map
|