iterate 0.2.6 → 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.
Files changed (218) hide show
  1. package/README.md +86 -76
  2. package/THIRD_PARTY_NOTICES.md +55 -0
  3. package/bin/iterate.js +18 -3
  4. package/dist/api-url-B6404M82.mjs +17 -0
  5. package/dist/api-url-B6404M82.mjs.map +1 -0
  6. package/dist/app-ref-BipL0feU.mjs +35 -0
  7. package/dist/app-ref-BipL0feU.mjs.map +1 -0
  8. package/dist/app-ref-C1CrgXqX.mjs +7 -0
  9. package/dist/app-ref-C1CrgXqX.mjs.map +1 -0
  10. package/dist/app-ref-DYai_om1.mjs +7 -0
  11. package/dist/app-ref-DYai_om1.mjs.map +1 -0
  12. package/dist/cli-D0c-pDL_.mjs +1010 -0
  13. package/dist/cli-D0c-pDL_.mjs.map +1 -0
  14. package/dist/client.d.ts +3 -0
  15. package/dist/client.mjs +4 -0
  16. package/dist/cloudflare-BTm90gQ4.mjs +951 -0
  17. package/dist/cloudflare-BTm90gQ4.mjs.map +1 -0
  18. package/dist/contract-s4FW4eES.mjs +309 -0
  19. package/dist/contract-s4FW4eES.mjs.map +1 -0
  20. package/dist/document-review/index.d.ts +5 -0
  21. package/dist/document-review/types.d.ts +107 -0
  22. package/dist/document-review.mjs +7015 -0
  23. package/dist/document-review.mjs.map +1 -0
  24. package/dist/durable-object-processor-durability-CNsTjAJS.mjs +205 -0
  25. package/dist/durable-object-processor-durability-CNsTjAJS.mjs.map +1 -0
  26. package/dist/idempotency-DleloJNt.mjs +28 -0
  27. package/dist/idempotency-DleloJNt.mjs.map +1 -0
  28. package/dist/index.mjs +1 -1
  29. package/dist/itx/api-url.d.ts +6 -0
  30. package/dist/itx/itx-node-client.d.ts +65 -0
  31. package/dist/itx/itx-session.d.ts +215 -0
  32. package/dist/itx/owned-rpc-session.d.ts +14 -0
  33. package/dist/itx/query-client.d.ts +10 -0
  34. package/dist/itx-api.generated.d.ts +6195 -0
  35. package/dist/itx-session-sjud8GiT.mjs +534 -0
  36. package/dist/itx-session-sjud8GiT.mjs.map +1 -0
  37. package/dist/live-state-BJNqOwFw.mjs +299 -0
  38. package/dist/live-state-BJNqOwFw.mjs.map +1 -0
  39. package/dist/next/api.d.ts +479 -0
  40. package/dist/next/api.mjs +0 -0
  41. package/dist/next/app-server.d.ts +44 -0
  42. package/dist/next/app-server.mjs +479 -0
  43. package/dist/next/app-server.mjs.map +1 -0
  44. package/dist/next/app-session.d.ts +49 -0
  45. package/dist/next/app-session.mjs +238 -0
  46. package/dist/next/app-session.mjs.map +1 -0
  47. package/dist/next/app.d.ts +29 -0
  48. package/dist/next/app.mjs +141 -0
  49. package/dist/next/app.mjs.map +1 -0
  50. package/dist/next/client/live-state.d.ts +63 -0
  51. package/dist/next/client/oauth.d.ts +12 -0
  52. package/dist/next/client/react.d.ts +109 -0
  53. package/dist/next/client/socket.d.ts +6 -0
  54. package/dist/next/client.mjs +156 -0
  55. package/dist/next/client.mjs.map +1 -0
  56. package/dist/next/expression.d.ts +146 -0
  57. package/dist/next/expression.mjs +399 -0
  58. package/dist/next/expression.mjs.map +1 -0
  59. package/dist/next/lib.d.ts +56 -0
  60. package/dist/next/lib.mjs +199 -0
  61. package/dist/next/lib.mjs.map +1 -0
  62. package/dist/next/oauth-scopes.d.ts +32 -0
  63. package/dist/next/oauth-scopes.mjs +40 -0
  64. package/dist/next/oauth-scopes.mjs.map +1 -0
  65. package/dist/next/oauth.mjs +29 -0
  66. package/dist/next/oauth.mjs.map +1 -0
  67. package/dist/next/principal.d.ts +64 -0
  68. package/dist/next/principal.mjs +98 -0
  69. package/dist/next/principal.mjs.map +1 -0
  70. package/dist/next/project-ingress.d.ts +37 -0
  71. package/dist/next/project-ingress.mjs +75 -0
  72. package/dist/next/project-ingress.mjs.map +1 -0
  73. package/dist/next/react.mjs +285 -0
  74. package/dist/next/react.mjs.map +1 -0
  75. package/dist/next/sdk/auth.d.ts +5 -0
  76. package/dist/next/sdk/index.d.ts +112 -0
  77. package/dist/next/sdk.mjs +139 -0
  78. package/dist/next/sdk.mjs.map +1 -0
  79. package/dist/next/stream/processor.d.ts +378 -0
  80. package/dist/next/stream/processor.mjs +582 -0
  81. package/dist/next/stream/processor.mjs.map +1 -0
  82. package/dist/next/stream/run.d.ts +58 -0
  83. package/dist/next/stream/run.mjs +40 -0
  84. package/dist/next/stream/run.mjs.map +1 -0
  85. package/dist/next-node.d.ts +15 -0
  86. package/dist/next-node.mjs +51 -0
  87. package/dist/next-node.mjs.map +1 -0
  88. package/dist/node.d.ts +3 -0
  89. package/dist/node.mjs +185 -0
  90. package/dist/node.mjs.map +1 -0
  91. package/dist/processor-host-capabilities-BMFH3KTM.mjs +56 -0
  92. package/dist/processor-host-capabilities-BMFH3KTM.mjs.map +1 -0
  93. package/dist/processors/cloudflare.d.ts +3 -0
  94. package/dist/processors/durable-object-processor-durability.d.ts +79 -0
  95. package/dist/processors/event-consumption-metrics.d.ts +82 -0
  96. package/dist/processors/idempotency.d.ts +13 -0
  97. package/dist/processors/index.d.ts +12 -0
  98. package/dist/processors/processor-contracts.d.ts +342 -0
  99. package/dist/processors/processor-facet.d.ts +186 -0
  100. package/dist/processors/processor-host-capabilities.d.ts +60 -0
  101. package/dist/processors/prompt-sections.d.ts +17 -0
  102. package/dist/processors/rpc-types.d.ts +515 -0
  103. package/dist/processors/schemas.d.ts +102 -0
  104. package/dist/processors/stream-handle.d.ts +45 -0
  105. package/dist/processors/stream-processor-keepalive.d.ts +95 -0
  106. package/dist/processors/stream-processor-registry.d.ts +233 -0
  107. package/dist/processors/stream-processor-runner.d.ts +289 -0
  108. package/dist/processors/stream-processor.d.ts +339 -0
  109. package/dist/processors/stream-runtime-metrics.d.ts +107 -0
  110. package/dist/processors/testing.d.ts +302 -0
  111. package/dist/processors-BoNyeBfQ.mjs +10 -0
  112. package/dist/processors-BoNyeBfQ.mjs.map +1 -0
  113. package/dist/processors-cloudflare.mjs +3 -0
  114. package/dist/processors-testing.mjs +435 -0
  115. package/dist/processors-testing.mjs.map +1 -0
  116. package/dist/processors.mjs +52 -0
  117. package/dist/processors.mjs.map +1 -0
  118. package/dist/protocol-DnK_f2m6.mjs +251 -0
  119. package/dist/protocol-DnK_f2m6.mjs.map +1 -0
  120. package/dist/sdk/capnweb/index.d.ts +2 -0
  121. package/dist/sdk/capnweb/live-state/compact.d.ts +5 -0
  122. package/dist/sdk/capnweb/live-state/diff.d.ts +41 -0
  123. package/dist/sdk/capnweb/live-state/engine.d.ts +44 -0
  124. package/dist/sdk/capnweb/live-state/index.d.ts +41 -0
  125. package/dist/sdk/capnweb/live-state/protocol.d.ts +87 -0
  126. package/dist/sdk/capnweb/live-state/retain.d.ts +23 -0
  127. package/dist/sdk/capnweb/live-state/store.d.ts +20 -0
  128. package/dist/sdk/capnweb/live-state/types.d.ts +11 -0
  129. package/dist/sdk/capnweb/react.d.ts +45 -0
  130. package/dist/sdk/capnweb/react.mjs +316 -0
  131. package/dist/sdk/capnweb/react.mjs.map +1 -0
  132. package/dist/sdk/capnweb.mjs +4 -0
  133. package/dist/sdk/itx/react.d.ts +191 -0
  134. package/dist/sdk/itx/react.mjs +383 -0
  135. package/dist/sdk/itx/react.mjs.map +1 -0
  136. package/dist/sdk-DMB-IM11.mjs +933 -0
  137. package/dist/sdk-DMB-IM11.mjs.map +1 -0
  138. package/dist/sdk.d.ts +339 -0
  139. package/dist/sdk.mjs +2 -0
  140. package/dist/serve-itx.d.ts +46 -0
  141. package/dist/starter-apps/flake-dashboard/app-ref.d.ts +31 -0
  142. package/dist/starter-apps/flake-dashboard/configured-worker.mjs +1055 -0
  143. package/dist/starter-apps/flake-dashboard/configured-worker.mjs.map +1 -0
  144. package/dist/starter-apps/flake-dashboard/contract.d.ts +4839 -0
  145. package/dist/starter-apps/flake-dashboard/contract.mjs +2 -0
  146. package/dist/starter-apps/flake-dashboard/index.d.ts +17 -0
  147. package/dist/starter-apps/flake-dashboard/index.mjs +56 -0
  148. package/dist/starter-apps/flake-dashboard/index.mjs.map +1 -0
  149. package/dist/starter-apps/flake-dashboard/worker.d.ts +4607 -0
  150. package/dist/starter-apps/github-ai-linter/ai-linter.d.ts +8914 -0
  151. package/dist/starter-apps/github-ai-linter/configured-worker.mjs +17987 -0
  152. package/dist/starter-apps/github-ai-linter/configured-worker.mjs.map +1 -0
  153. package/dist/starter-apps/github-ai-linter/contract.d.ts +9193 -0
  154. package/dist/starter-apps/github-ai-linter/index.d.ts +10 -0
  155. package/dist/starter-apps/github-ai-linter/index.mjs +36 -0
  156. package/dist/starter-apps/github-ai-linter/index.mjs.map +1 -0
  157. package/dist/starter-apps/github-ai-linter/prompt.d.ts +13 -0
  158. package/dist/starter-apps/github-ai-linter/review-bot.d.ts +808 -0
  159. package/dist/starter-apps/github-ai-linter/rules.d.ts +34 -0
  160. package/dist/starter-apps/github-ai-linter/worker-ref.d.ts +19 -0
  161. package/dist/starter-apps/github-ai-linter/worker.d.ts +19 -0
  162. package/dist/starter-apps/github-ai-linter/worker.mjs +947 -0
  163. package/dist/starter-apps/github-ai-linter/worker.mjs.map +1 -0
  164. package/dist/starter-apps/guestbook/app-ref.d.ts +27 -0
  165. package/dist/starter-apps/guestbook/client.d.ts +7 -0
  166. package/dist/starter-apps/guestbook/client.mjs +59 -0
  167. package/dist/starter-apps/guestbook/configured-worker.mjs +205 -0
  168. package/dist/starter-apps/guestbook/configured-worker.mjs.map +1 -0
  169. package/dist/starter-apps/guestbook/index.d.ts +9 -0
  170. package/dist/starter-apps/guestbook/index.mjs +31 -0
  171. package/dist/starter-apps/guestbook/index.mjs.map +1 -0
  172. package/dist/starter-apps/guestbook/processor.d.ts +2267 -0
  173. package/dist/starter-apps/guestbook/worker.d.ts +26 -0
  174. package/dist/starter-apps/guestbook/worker.mjs +191 -0
  175. package/dist/starter-apps/guestbook/worker.mjs.map +1 -0
  176. package/dist/starter-apps/media/configured-worker.mjs +577 -0
  177. package/dist/starter-apps/media/configured-worker.mjs.map +1 -0
  178. package/dist/starter-apps/media/index.mjs +36 -0
  179. package/dist/starter-apps/media/index.mjs.map +1 -0
  180. package/dist/starter-apps/media/ref.mjs +20 -0
  181. package/dist/starter-apps/media/ref.mjs.map +1 -0
  182. package/dist/starter-apps/media/worker.mjs +579 -0
  183. package/dist/starter-apps/media/worker.mjs.map +1 -0
  184. package/dist/starter-apps/notes/configured-worker.mjs +6134 -0
  185. package/dist/starter-apps/notes/configured-worker.mjs.map +1 -0
  186. package/dist/starter-apps/notes/index.mjs +23 -0
  187. package/dist/starter-apps/notes/index.mjs.map +1 -0
  188. package/dist/starter-apps/notes/ref.mjs +21 -0
  189. package/dist/starter-apps/notes/ref.mjs.map +1 -0
  190. package/dist/starter-apps/notes/worker.mjs +427 -0
  191. package/dist/starter-apps/notes/worker.mjs.map +1 -0
  192. package/dist/starter-apps/todo/client.mjs +59 -0
  193. package/dist/starter-apps/todo/configured-worker.mjs +2864 -0
  194. package/dist/starter-apps/todo/configured-worker.mjs.map +1 -0
  195. package/dist/starter-apps/todo/index.d.ts +8 -0
  196. package/dist/starter-apps/todo/index.mjs +29 -0
  197. package/dist/starter-apps/todo/index.mjs.map +1 -0
  198. package/dist/stream-processor-keepalive-DAQTP6m3.mjs +2082 -0
  199. package/dist/stream-processor-keepalive-DAQTP6m3.mjs.map +1 -0
  200. package/dist/usingCtx-inzbY1Qz.mjs +57 -0
  201. package/dist/usingCtx-mZx5nsAW.mjs +11800 -0
  202. package/dist/usingCtx-mZx5nsAW.mjs.map +1 -0
  203. package/dist/worker-ref-DZxPDmb_.mjs +390 -0
  204. package/dist/worker-ref-DZxPDmb_.mjs.map +1 -0
  205. package/menubar/Iterate.entitlements +12 -0
  206. package/menubar/Iterate.swift +914 -0
  207. package/menubar/IterateIcon.swift +145 -0
  208. package/menubar/README.md +28 -0
  209. package/menubar/build-menubar-app.sh +59 -0
  210. package/package.json +235 -18
  211. package/dist/cli-DMS4kJph.mjs +0 -868
  212. package/dist/cli-DMS4kJph.mjs.map +0 -1
  213. package/dist/config-DtnR7Lv7.mjs +0 -170
  214. package/dist/config-DtnR7Lv7.mjs.map +0 -1
  215. package/dist/index.d.mts.map +0 -1
  216. package/dist/stream-tui/agent-chat-terminal.d.mts +0 -1
  217. package/dist/stream-tui/agent-chat-terminal.mjs +0 -933
  218. package/dist/stream-tui/agent-chat-terminal.mjs.map +0 -1
@@ -0,0 +1,2082 @@
1
+ import { z } from "zod";
2
+ import { RpcTarget } from "@iterate-com/capnweb";
3
+ //#region src/processors/rpc-types.ts
4
+ /** Maximum serialized durable-event bytes one Stream read may return. */
5
+ const MAX_STREAM_EVENT_READ_BYTE_LIMIT = 8 * 1024 * 1024;
6
+ /**
7
+ * A durable receiver's declaration that it cannot accept ANY batch right now —
8
+ * part of the delivery contract, not an implementation detail. The subscription's cursor row
9
+ * treats a rejection carrying this name as "the receiver is down/not ready"
10
+ * and backs off or halts even under `onFailingEvent: "skip"`,
11
+ * because failing-event confirmation is a verdict about ONE event and an unavailable
12
+ * receiver fails every event: confirming skips during an outage window steps
13
+ * over healthy events forever (the bootstrap incarnation: the project-worker
14
+ * feed called its receiver before the config repo seeded, and permanently skipped the
15
+ * events that raced the seed).
16
+ *
17
+ * Matched by NAME, not instanceof: the rejection crosses Workers RPC hops
18
+ * (loopback itx roots, DO bindings), which preserve `error.name` but not
19
+ * class identity.
20
+ */
21
+ var StreamReceiverUnavailableError = class StreamReceiverUnavailableError extends Error {
22
+ static NAME = "StreamReceiverUnavailableError";
23
+ name = StreamReceiverUnavailableError.NAME;
24
+ };
25
+ /** A compare-and-append assertion lost to another committed stream event. */
26
+ var StreamOffsetConflictError = class StreamOffsetConflictError extends Error {
27
+ static NAME = "StreamOffsetConflictError";
28
+ name = StreamOffsetConflictError.NAME;
29
+ };
30
+ /** An operation was bound to a stream lifetime that this path no longer names. */
31
+ var StreamIdMismatchError = class StreamIdMismatchError extends Error {
32
+ static NAME = "StreamIdMismatchError";
33
+ name = StreamIdMismatchError.NAME;
34
+ };
35
+ /** Canonical guarded-append rejection text, including across RPC hops that
36
+ * normalize the custom error name to `Error`. */
37
+ function streamIdMismatchMessage(expectedStreamId, actualStreamId) {
38
+ return `stream ID changed (${expectedStreamId} -> ${String(actualStreamId)}); append rejected`;
39
+ }
40
+ const STREAM_ID_MISMATCH_MESSAGE = /^stream ID changed \(.+ -> .+\); append rejected$/;
41
+ /**
42
+ * Match a guarded append rejected because its source stream was recreated.
43
+ * Durable Object RPC preserves the custom name; CapnWeb can reduce it to a
44
+ * plain Error, so the exact canonical message remains a narrow fallback.
45
+ */
46
+ function isStreamIdMismatchError(error) {
47
+ const candidate = error;
48
+ return candidate?.name === StreamIdMismatchError.NAME || candidate?.name === "Error" && typeof candidate.message === "string" && STREAM_ID_MISMATCH_MESSAGE.test(candidate.message);
49
+ }
50
+ /** Canonical compare-and-append conflict text, including across RPC hops that
51
+ * normalize the custom error name to `Error`. */
52
+ function streamOffsetConflictMessage(expectedOffset, actualOffset) {
53
+ return `expected next offset ${expectedOffset}, found ${actualOffset}`;
54
+ }
55
+ const STREAM_OFFSET_CONFLICT_MESSAGE = /^expected next offset \d+, found \d+$/;
56
+ /**
57
+ * Match by name because Durable Object RPC preserves names, not prototypes.
58
+ * CapnWeb's public itx boundary currently normalizes custom error names to
59
+ * `Error`, so retain an exact message fallback for that hop. Keep this
60
+ * deliberately narrow: callers use the result to retry a compare-and-append.
61
+ */
62
+ function isStreamOffsetConflictError(error) {
63
+ const candidate = error;
64
+ return candidate?.name === StreamOffsetConflictError.NAME || candidate?.name === "Error" && typeof candidate.message === "string" && STREAM_OFFSET_CONFLICT_MESSAGE.test(candidate.message);
65
+ }
66
+ function isStreamReceiverUnavailableError(error) {
67
+ return error?.name === StreamReceiverUnavailableError.NAME;
68
+ }
69
+ //#endregion
70
+ //#region src/processors/stream-handle.ts
71
+ /**
72
+ * Resolve the path accepted by `stream.at(path)`. Absolute paths start at the
73
+ * stream root; relative paths start at `basePath`; `..` may climb only as far
74
+ * as the root. Keeping this next to {@link ProcessorStream} lets processor
75
+ * appends decide whether a resolved destination is their own stream without
76
+ * relying on RPC-target object identity.
77
+ */
78
+ function resolveStreamPath(basePath, streamPath) {
79
+ const segments = streamPath.startsWith("/") ? [] : basePath.split("/").filter(Boolean);
80
+ for (const segment of streamPath.split("/")) {
81
+ if (segment === "" || segment === ".") continue;
82
+ if (segment === "..") {
83
+ if (segments.length === 0) throw new Error(`stream path "${streamPath}" escapes the stream root (resolved from "${basePath}")`);
84
+ segments.pop();
85
+ continue;
86
+ }
87
+ segments.push(segment);
88
+ }
89
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
90
+ }
91
+ //#endregion
92
+ //#region src/processors/stream-runtime-metrics.ts
93
+ /**
94
+ * Fixed-capacity ring of latency samples. `stats()` is `null` until the first
95
+ * sample — surfaces render "—" instead of a made-up number.
96
+ */
97
+ var LatencyRing = class {
98
+ #capacity;
99
+ #samples = [];
100
+ #next = 0;
101
+ #last = 0;
102
+ #lastAt = 0;
103
+ constructor(capacity = 32) {
104
+ if (!Number.isInteger(capacity) || capacity <= 0) throw new Error("LatencyRing capacity must be a positive integer");
105
+ this.#capacity = capacity;
106
+ }
107
+ record(ms, atMs) {
108
+ if (!Number.isFinite(ms)) return;
109
+ const sample = Math.max(0, Math.round(ms));
110
+ if (this.#samples.length < this.#capacity) this.#samples.push(sample);
111
+ else this.#samples[this.#next] = sample;
112
+ this.#next = (this.#next + 1) % this.#capacity;
113
+ this.#last = sample;
114
+ this.#lastAt = atMs;
115
+ }
116
+ stats() {
117
+ if (this.#samples.length === 0) return null;
118
+ const sorted = [...this.#samples].sort((a, b) => a - b);
119
+ const rank = (q) => sorted[Math.max(0, Math.ceil(q * sorted.length) - 1)];
120
+ return {
121
+ last: this.#last,
122
+ p50: rank(.5),
123
+ p95: rank(.95),
124
+ samples: sorted.length,
125
+ lastAt: this.#lastAt
126
+ };
127
+ }
128
+ };
129
+ /**
130
+ * 60 one-second buckets of {count, bytes}. Stale slots (lapped by the ring)
131
+ * are ignored at read time, so a burst followed by silence decays to zero
132
+ * without a sweeper.
133
+ */
134
+ var MinuteBuckets = class {
135
+ #seconds = new Array(60).fill(-1);
136
+ #counts = new Array(60).fill(0);
137
+ #bytes = new Array(60).fill(0);
138
+ bump(atMs, count, bytes) {
139
+ const second = Math.floor(atMs / 1e3);
140
+ const slot = (second % 60 + 60) % 60;
141
+ if (this.#seconds[slot] !== second) {
142
+ this.#seconds[slot] = second;
143
+ this.#counts[slot] = 0;
144
+ this.#bytes[slot] = 0;
145
+ }
146
+ this.#counts[slot] += count;
147
+ this.#bytes[slot] += bytes;
148
+ }
149
+ lastMinute(nowMs) {
150
+ const { count, bytes } = this.window(nowMs, 60);
151
+ return {
152
+ count,
153
+ bytes,
154
+ perSecond: count / 60
155
+ };
156
+ }
157
+ /** Totals over the trailing `seconds` (≤60) — short windows make responsive rates. */
158
+ window(nowMs, seconds) {
159
+ const nowSecond = Math.floor(nowMs / 1e3);
160
+ let count = 0;
161
+ let bytes = 0;
162
+ for (let slot = 0; slot < 60; slot += 1) {
163
+ const second = this.#seconds[slot];
164
+ if (second < 0 || second > nowSecond || second <= nowSecond - seconds) continue;
165
+ count += this.#counts[slot];
166
+ bytes += this.#bytes[slot];
167
+ }
168
+ return {
169
+ count,
170
+ bytes
171
+ };
172
+ }
173
+ /**
174
+ * The raw per-second buckets, oldest→newest, always exactly 60 entries
175
+ * (silent seconds are zero) — what a UI graphs directly, so the graph is
176
+ * the measurement rather than a client-side reconstruction of it.
177
+ */
178
+ series(nowMs) {
179
+ const nowSecond = Math.floor(nowMs / 1e3);
180
+ const counts = new Array(60).fill(0);
181
+ const bytes = new Array(60).fill(0);
182
+ for (let slot = 0; slot < 60; slot += 1) {
183
+ const second = this.#seconds[slot];
184
+ if (second < 0 || second > nowSecond || second <= nowSecond - 60) continue;
185
+ const index = 59 - (nowSecond - second);
186
+ counts[index] = this.#counts[slot];
187
+ bytes[index] = this.#bytes[slot];
188
+ }
189
+ return {
190
+ counts,
191
+ bytes
192
+ };
193
+ }
194
+ };
195
+ /** The stream Durable Object's in-memory throughput accounting. */
196
+ var StreamRuntimeMetrics = class {
197
+ #measuredSinceMs;
198
+ ingress = new MinuteBuckets();
199
+ egress = new MinuteBuckets();
200
+ constructor(nowMs) {
201
+ this.#measuredSinceMs = nowMs;
202
+ }
203
+ report(nowMs) {
204
+ const direction = (buckets) => {
205
+ const trailing5s = buckets.window(nowMs, 5);
206
+ return {
207
+ perSecond5s: trailing5s.count / 5,
208
+ bytesPerSecond5s: trailing5s.bytes / 5,
209
+ lastMinute: buckets.lastMinute(nowMs),
210
+ series: buckets.series(nowMs)
211
+ };
212
+ };
213
+ return {
214
+ measuredSince: new Date(this.#measuredSinceMs).toISOString(),
215
+ reportedAt: new Date(nowMs).toISOString(),
216
+ ingress: direction(this.ingress),
217
+ egress: direction(this.egress)
218
+ };
219
+ }
220
+ };
221
+ /**
222
+ * Age an event-driven throughput snapshot against the local wall clock. This
223
+ * keeps trailing windows truthful during silence without polling the stream.
224
+ */
225
+ function ageStreamThroughputMetrics(metrics, nowMs) {
226
+ const reportedAtMs = Date.parse(metrics.reportedAt);
227
+ if (!Number.isFinite(reportedAtMs)) return metrics;
228
+ const elapsedSeconds = Math.max(0, Math.floor(nowMs / 1e3) - Math.floor(reportedAtMs / 1e3));
229
+ if (elapsedSeconds === 0) return metrics;
230
+ const age = (report) => {
231
+ const shift = (values) => {
232
+ const seconds = Math.min(values.length, elapsedSeconds);
233
+ return [...values.slice(seconds), ...new Array(seconds).fill(0)];
234
+ };
235
+ const counts = shift(report.series.counts);
236
+ const bytes = shift(report.series.bytes);
237
+ const sum = (values) => values.reduce((total, value) => total + value, 0);
238
+ const count = sum(counts);
239
+ const byteCount = sum(bytes);
240
+ return {
241
+ perSecond5s: sum(counts.slice(-5)) / 5,
242
+ bytesPerSecond5s: sum(bytes.slice(-5)) / 5,
243
+ lastMinute: {
244
+ count,
245
+ bytes: byteCount,
246
+ perSecond: count / 60
247
+ },
248
+ series: {
249
+ counts,
250
+ bytes
251
+ }
252
+ };
253
+ };
254
+ return {
255
+ ...metrics,
256
+ ingress: age(metrics.ingress),
257
+ egress: age(metrics.egress)
258
+ };
259
+ }
260
+ /**
261
+ * The mutual ping's NTP-style math, shared by both requesters (a stream
262
+ * pinging a callback owner; anything pinging the stream). The requester stamps
263
+ * `t0` and observes `t3`; the responder reports receive/reply-send times on
264
+ * ITS clock. RTT excludes responder processing time; `clockOffsetMs`
265
+ * estimates `responderClock - requesterClock`.
266
+ */
267
+ function pingRoundTrip(reply, t3) {
268
+ return {
269
+ rttMs: Math.max(0, t3 - reply.t0 - (reply.t2 - reply.t1)),
270
+ clockOffsetMs: (reply.t1 - reply.t0 + (reply.t2 - t3)) / 2
271
+ };
272
+ }
273
+ //#endregion
274
+ //#region src/processors/event-consumption-metrics.ts
275
+ /** In-flight own-append correlation entries beyond this are dropped oldest-first. */
276
+ const MAX_PENDING_OWN_APPENDS = 16;
277
+ var EventConsumptionMetrics = class {
278
+ #measuredSinceMs;
279
+ #consumeOwnAppend = new LatencyRing();
280
+ #appendRoundTrip = new LatencyRing();
281
+ #deliveryAge = new LatencyRing();
282
+ #ingest = new LatencyRing();
283
+ #batchesIngested = 0;
284
+ #eventsIngested = 0;
285
+ #clockOffsetMs = null;
286
+ /** Highest offset this host has ingested through (see noteBatchIngested). */
287
+ #ingestedThroughOffset = 0;
288
+ /** Own appends awaiting their loop-back delivery: committed offset + call-start time. */
289
+ #pendingOwnAppends = [];
290
+ constructor(nowMs) {
291
+ this.#measuredSinceMs = nowMs;
292
+ }
293
+ /**
294
+ * An `append()` this host issued resolved: `t0` is when the host called it,
295
+ * `atMs` is when the commit came back, and `maxCommittedOffset` is the
296
+ * highest committed offset that CAN come back to this host — the caller's
297
+ * judgement, because only the caller knows what it consumes. `null` means
298
+ * the append carried nothing this host will ever be delivered, and is timed
299
+ * for its round trip alone.
300
+ */
301
+ noteAppendCommitted(args) {
302
+ this.#appendRoundTrip.record(args.atMs - args.t0, args.atMs);
303
+ if (args.maxCommittedOffset === null) return;
304
+ if (args.maxCommittedOffset <= this.#ingestedThroughOffset) {
305
+ this.#consumeOwnAppend.record(args.atMs - args.t0, args.atMs);
306
+ return;
307
+ }
308
+ this.#pendingOwnAppends.push({
309
+ offset: args.maxCommittedOffset,
310
+ t0: args.t0
311
+ });
312
+ if (this.#pendingOwnAppends.length > MAX_PENDING_OWN_APPENDS) this.#pendingOwnAppends.splice(0, this.#pendingOwnAppends.length - MAX_PENDING_OWN_APPENDS);
313
+ }
314
+ /** One delivered batch fully ingested (fold applied / SQLite write done). */
315
+ noteBatchIngested(args) {
316
+ this.#batchesIngested += 1;
317
+ this.#eventsIngested += args.ingestedOffsets.length;
318
+ this.#ingestedThroughOffset = Math.max(this.#ingestedThroughOffset, args.ingestedThroughOffset);
319
+ this.#ingest.record(args.atMs - args.ingestStartedAtMs, args.atMs);
320
+ if (args.newestEventCreatedAtMs !== void 0 && Number.isFinite(args.newestEventCreatedAtMs)) {
321
+ const hostNowOnStreamClock = args.atMs - (this.#clockOffsetMs ?? 0);
322
+ this.#deliveryAge.record(hostNowOnStreamClock - args.newestEventCreatedAtMs, args.atMs);
323
+ }
324
+ if (this.#pendingOwnAppends.length > 0) {
325
+ const stillPending = [];
326
+ for (const pending of this.#pendingOwnAppends) {
327
+ if (pending.offset > args.ingestedThroughOffset) {
328
+ stillPending.push(pending);
329
+ continue;
330
+ }
331
+ if (args.ingestedOffsets.includes(pending.offset)) this.#consumeOwnAppend.record(args.atMs - pending.t0, args.atMs);
332
+ }
333
+ this.#pendingOwnAppends = stillPending;
334
+ }
335
+ }
336
+ /**
337
+ * The stream pinged this host: `t0` is the stream's send time (stream
338
+ * clock), `t1` the host's receive time (host clock). With a one-way-delay
339
+ * estimate (half the host's measured transport RTT, when it has one) this
340
+ * yields the host−stream clock offset used to correct delivery ages.
341
+ */
342
+ notePingObserved(args) {
343
+ this.#clockOffsetMs = args.t1 - args.t0 - (args.oneWayEstimateMs ?? 0);
344
+ }
345
+ /** The host's event connection reopened: in-flight own-append correlations are void. */
346
+ clearPendingAppends() {
347
+ this.#pendingOwnAppends = [];
348
+ }
349
+ report() {
350
+ return {
351
+ measuredSince: new Date(this.#measuredSinceMs).toISOString(),
352
+ consumeOwnAppendMs: this.#consumeOwnAppend.stats(),
353
+ appendRoundTripMs: this.#appendRoundTrip.stats(),
354
+ deliveryAgeMs: this.#deliveryAge.stats(),
355
+ ingestMs: this.#ingest.stats(),
356
+ batchesIngested: this.#batchesIngested,
357
+ eventsIngested: this.#eventsIngested,
358
+ clockOffsetMs: this.#clockOffsetMs
359
+ };
360
+ }
361
+ };
362
+ //#endregion
363
+ //#region src/processors/schemas.ts
364
+ /**
365
+ * Maximum number of stream-to-stream copies retained in one event's provenance.
366
+ * Cycles normally stop a chain earlier; this bounds acyclic graphs and the
367
+ * serialized event growth they can produce.
368
+ */
369
+ const MAX_COPIED_FROM_HOPS = 32;
370
+ /** Append input before the stream assigns offset and timestamp. */
371
+ const StreamEventInput = z.strictObject({
372
+ type: z.string(),
373
+ payload: z.record(z.string(), z.unknown()).optional(),
374
+ metadata: z.record(z.string(), z.unknown()).optional(),
375
+ source: z.strictObject({
376
+ processor: z.strictObject({
377
+ slug: z.string(),
378
+ version: z.string(),
379
+ stream: z.strictObject({
380
+ path: z.string().trim().min(1),
381
+ projectId: z.string().trim().min(1).nullable(),
382
+ /** Exact lifetime of the processor's home stream. */
383
+ streamId: z.uuid()
384
+ }),
385
+ whileProcessing: z.strictObject({
386
+ offset: z.number().int().nonnegative(),
387
+ type: z.string().trim().min(1)
388
+ }).optional()
389
+ }).optional(),
390
+ copiedFrom: z.array(z.strictObject({
391
+ /** Name of the source stream's subscription that copied this event. */
392
+ name: z.string().trim().min(1),
393
+ /** Random identity assigned when that source stream's storage was created. */
394
+ streamId: z.uuid(),
395
+ /** Creation time of that source stream, used to order destructive recreations. */
396
+ streamCreatedAt: z.string().trim().min(1),
397
+ /** Configure or cursor-set event that started this delivered copy. */
398
+ cursorChangedAtSourceOffset: z.number().int().positive(),
399
+ createdAt: z.string(),
400
+ offset: z.number().int().nonnegative(),
401
+ path: z.string().trim().min(1),
402
+ projectId: z.string().trim().min(1).nullable(),
403
+ type: z.string().trim().min(1)
404
+ })).min(1).max(32).optional()
405
+ }).optional(),
406
+ idempotencyKey: z.string().trim().min(1).optional(),
407
+ /**
408
+ * Ephemeral events receive real stream offsets but their bodies are NEVER
409
+ * written to the Stream Durable Object's SQLite. The current Durable Object
410
+ * incarnation keeps up to 10 MiB of serialized ephemeral events in memory,
411
+ * evicting the oldest first. A restart forgets all of them.
412
+ *
413
+ * Range reads exclude ephemeral events unless `includeEphemeral: true`;
414
+ * point reads by offset return one only while it remains in memory. Session
415
+ * connections replay currently buffered ephemeral events after their replay
416
+ * cursor and receive new ones live. Durable subscriptions never deliver
417
+ * them. An ephemeral event cannot have an idempotency key.
418
+ *
419
+ * Nothing durable may depend on an ephemeral event. Use it for streaming
420
+ * signals whose durable truth lands separately — for example, LLM response
421
+ * chunks followed by a durable assistant context item.
422
+ * `z.literal(true)`, not boolean: absent = durable, so committed rows stay
423
+ * self-describing and `ephemeral: false` is a loud input error, not a
424
+ * silent synonym for omitting the flag.
425
+ */
426
+ ephemeral: z.literal(true).optional()
427
+ }).superRefine((event, context) => {
428
+ if (event.ephemeral === true && event.idempotencyKey !== void 0) context.addIssue({
429
+ code: "custom",
430
+ message: "ephemeral events cannot have an idempotencyKey",
431
+ path: ["idempotencyKey"]
432
+ });
433
+ });
434
+ /** Offset-assigned stream event after commit. */
435
+ const StreamEvent = StreamEventInput.safeExtend({
436
+ offset: z.number().int().nonnegative(),
437
+ createdAt: z.string(),
438
+ path: z.string().trim().min(1)
439
+ });
440
+ /**
441
+ * One known stream in a project's reduced state — what the project processor
442
+ * records per agent/repo/secret/stream and what the collection `list()`
443
+ * methods return.
444
+ */
445
+ const StreamListItem = z.object({
446
+ createdAt: z.string(),
447
+ path: z.string()
448
+ });
449
+ //#endregion
450
+ //#region src/processors/processor-contracts.ts
451
+ /**
452
+ * Merge one processor configuration patch into its current configuration.
453
+ *
454
+ * Configuration patches recurse only through plain JSON objects. Arrays,
455
+ * scalars, and `null` replace the previous value wholesale; omitted keys are
456
+ * retained. Processors validate the merged result with their own complete
457
+ * configuration schema before storing it in reduced state.
458
+ */
459
+ function mergeProcessorConfig(base, patch) {
460
+ if (!isPlainObject(base) || !isPlainObject(patch)) return patch;
461
+ const merged = { ...base };
462
+ for (const [key, patchValue] of Object.entries(patch)) {
463
+ const baseValue = merged[key];
464
+ merged[key] = isPlainObject(baseValue) && isPlainObject(patchValue) ? mergeProcessorConfig(baseValue, patchValue) : patchValue;
465
+ }
466
+ return merged;
467
+ }
468
+ function isPlainObject(value) {
469
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
470
+ const prototype = Object.getPrototypeOf(value);
471
+ return prototype === Object.prototype || prototype === null;
472
+ }
473
+ /**
474
+ * Rebuild the concrete Zod envelope for one committed event from its catalog
475
+ * key plus `payloadSchema`. Contracts author event definitions as plain
476
+ * `{ description, payloadSchema }` values keyed by the event type string, so
477
+ * replay and live delivery share one validation path.
478
+ */
479
+ function getEventSchema(args) {
480
+ return z.looseObject({
481
+ type: z.literal(args.type),
482
+ payload: args.payloadSchema,
483
+ metadata: StreamEvent.shape.metadata,
484
+ source: StreamEvent.shape.source,
485
+ idempotencyKey: StreamEvent.shape.idempotencyKey,
486
+ ephemeral: StreamEvent.shape.ephemeral,
487
+ offset: StreamEvent.shape.offset,
488
+ createdAt: StreamEvent.shape.createdAt,
489
+ path: StreamEvent.shape.path
490
+ }).superRefine(rejectEphemeralIdempotency);
491
+ }
492
+ /** The envelope `ephemeral` slot: for a definition marked `ephemeral: true`,
493
+ * absent defaults to `true` and an explicit `false` FAILS the parse — the
494
+ * contract, not the append site, decides that the event never becomes a
495
+ * durable stream fact. */
496
+ function ephemeralEnvelopeSchema(forced, standard) {
497
+ return forced === true ? z.literal(true).default(true) : standard;
498
+ }
499
+ function rejectEphemeralIdempotency(event, context) {
500
+ if (event.ephemeral === true && event.idempotencyKey !== void 0) context.addIssue({
501
+ code: "custom",
502
+ message: "ephemeral events cannot have an idempotencyKey",
503
+ path: ["idempotencyKey"]
504
+ });
505
+ }
506
+ /**
507
+ * `getEventSchema` without offset/createdAt (and strict, so an accidental
508
+ * `offset` key on an append input fails loudly). Gives pre-append policy code
509
+ * the same payload validation as reducers, without fabricating a committed
510
+ * event just to get at the typed payload.
511
+ */
512
+ function getEventInputSchema(args) {
513
+ return z.strictObject({
514
+ type: z.literal(args.type),
515
+ payload: args.payloadSchema,
516
+ metadata: StreamEventInput.shape.metadata,
517
+ source: StreamEventInput.shape.source,
518
+ idempotencyKey: StreamEventInput.shape.idempotencyKey,
519
+ ephemeral: ephemeralEnvelopeSchema(args.ephemeral, StreamEventInput.shape.ephemeral)
520
+ }).superRefine(rejectEphemeralIdempotency);
521
+ }
522
+ /**
523
+ * Memoized twins of {@link getEventSchema} / {@link getEventInputSchema} for
524
+ * hot paths. Constructing the zod wrapper per call costs ~20µs (~50x the
525
+ * parse itself), and the reduce/append paths run once per event. Keyed by
526
+ * payload-schema identity, then event type: catalog entries are module
527
+ * constants, so the WeakMap never grows past the contract surface.
528
+ */
529
+ const eventSchemaCache = /* @__PURE__ */ new WeakMap();
530
+ function cachedSchema(cache, build, args) {
531
+ let byType = cache.get(args.payloadSchema);
532
+ if (byType === void 0) {
533
+ byType = /* @__PURE__ */ new Map();
534
+ cache.set(args.payloadSchema, byType);
535
+ }
536
+ let schema = byType.get(args.type);
537
+ if (schema === void 0) {
538
+ schema = build(args);
539
+ byType.set(args.type, schema);
540
+ }
541
+ return schema;
542
+ }
543
+ /** Memoized {@link getEventSchema} (see {@link eventSchemaCache}). */
544
+ function cachedEventSchema(args) {
545
+ return cachedSchema(eventSchemaCache, getEventSchema, args);
546
+ }
547
+ /**
548
+ * Validate an append input with the payload schema resolved from a processor
549
+ * contract. Prefer the contract-bound `contract.buildEvent(event)` API, which
550
+ * carries the same types without repeating the contract in the argument.
551
+ *
552
+ * @deprecated Use `contract.buildEvent(event)`.
553
+ */
554
+ function buildEvent(args) {
555
+ return parseResolvedEventInput(args.contract, args.event);
556
+ }
557
+ function parseResolvedEventInput(contract, event) {
558
+ const eventDefinition = getResolvedEventDefinition({
559
+ contract,
560
+ eventType: event.type
561
+ });
562
+ if (eventDefinition === void 0) {
563
+ const owner = contract.slug == null ? "contract" : `processor "${contract.slug}"`;
564
+ throw new Error(`${owner} cannot build unresolved event "${event.type}".`);
565
+ }
566
+ return getEventInputSchema({
567
+ type: event.type,
568
+ payloadSchema: eventDefinition.payloadSchema,
569
+ ephemeral: eventDefinition.ephemeral
570
+ }).parse(event);
571
+ }
572
+ function defineProcessorContract(contract) {
573
+ assertNoLocalProcessorDepEventConflicts(contract);
574
+ assertDefaultStateSchema(contract);
575
+ if (typeof contract !== "object" || contract === null) throw new Error("Processor contract must be an object.");
576
+ for (const method of [
577
+ "buildEvent",
578
+ "parseEvent",
579
+ "parseEventInput",
580
+ "parseConsumedInput"
581
+ ]) if (method in contract) throw new Error(`Processor "${getProcessorSlug(contract)}" must not define ${method}.`);
582
+ const typedContract = contract;
583
+ return Object.assign(typedContract, {
584
+ buildEvent(event) {
585
+ return parseResolvedEventInput(typedContract, event);
586
+ },
587
+ parseEvent: makeContractEventParser(typedContract, getEventSchema),
588
+ parseEventInput: makeContractEventParser(typedContract, getEventInputSchema),
589
+ parseConsumedInput: makeContractConsumedInputParser(typedContract)
590
+ });
591
+ }
592
+ /**
593
+ * Runtime twin of {@link ConsumedInput}. Unlike `parseEventInput`, this parser
594
+ * rejects a resolved event that the processor does not actually consume. A
595
+ * domain object's typed `append` door uses both so its remote runtime boundary
596
+ * cannot drift from the processor contract after TypeScript has been erased.
597
+ */
598
+ function makeContractConsumedInputParser(contract) {
599
+ const parserCache = /* @__PURE__ */ new Map();
600
+ return (event) => {
601
+ if (event.ephemeral === true) throw new Error(`Processor "${getProcessorSlug(contract)}" cannot consume ephemeral event "${event.type}".`);
602
+ const eventDefinition = getConsumedEventDefinition({
603
+ contract,
604
+ eventType: event.type
605
+ });
606
+ if (eventDefinition === void 0) throw new Error(`Processor "${getProcessorSlug(contract)}" does not consume event "${event.type}".`);
607
+ let schema = parserCache.get(event.type);
608
+ if (schema === void 0) {
609
+ schema = getEventInputSchema({
610
+ type: event.type,
611
+ payloadSchema: eventDefinition.payloadSchema,
612
+ ephemeral: eventDefinition.ephemeral
613
+ });
614
+ parserCache.set(event.type, schema);
615
+ }
616
+ return schema.parse(event);
617
+ };
618
+ }
619
+ /**
620
+ * Shared runtime body of `contract.parseEvent(...)` and
621
+ * `contract.parseEventInput(...)`: both resolve the payload schema from the
622
+ * contract catalog, so an edit to a core event schema automatically affects
623
+ * committed-event reduction and pre-commit validation together.
624
+ */
625
+ function makeContractEventParser(contract, schemaFor) {
626
+ const parserCache = /* @__PURE__ */ new Map();
627
+ return (event) => {
628
+ const eventType = event.type;
629
+ const eventDefinition = getResolvedEventDefinition({
630
+ contract,
631
+ eventType
632
+ });
633
+ if (eventDefinition == null) throw new Error(`Processor "${getProcessorSlug(contract)}" cannot parse unresolved event "${eventType}".`);
634
+ let schema = parserCache.get(eventType);
635
+ if (schema === void 0) {
636
+ schema = schemaFor({
637
+ type: eventType,
638
+ payloadSchema: eventDefinition.payloadSchema,
639
+ ephemeral: eventDefinition.ephemeral
640
+ });
641
+ parserCache.set(eventType, schema);
642
+ }
643
+ return schema.parse(event);
644
+ };
645
+ }
646
+ /**
647
+ * Enforces the invariant that reduced processor state is object-shaped (so
648
+ * state slices can evolve safely and hooks never branch on primitive state).
649
+ */
650
+ function assertObjectProcessorState(args) {
651
+ if (typeof args.value === "object" && args.value !== null && !Array.isArray(args.value)) return;
652
+ throw new Error(`Processor "${args.processorSlug}" state must be an object.`);
653
+ }
654
+ function assertDefaultStateSchema(contract) {
655
+ if (typeof contract !== "object" || contract === null) throw new Error("Processor contract must be an object.");
656
+ const processorSlug = getProcessorSlug(contract);
657
+ if (!("stateSchema" in contract) || !isZodSchema(contract.stateSchema)) throw new Error(`Processor "${processorSlug}" must define stateSchema.`);
658
+ let defaultState;
659
+ try {
660
+ defaultState = contract.stateSchema.parse({});
661
+ } catch (error) {
662
+ throw new Error(`Processor "${processorSlug}" stateSchema must parse {}.`, { cause: error });
663
+ }
664
+ assertObjectProcessorState({
665
+ processorSlug,
666
+ value: defaultState
667
+ });
668
+ }
669
+ /**
670
+ * Resolve the payload schema a processor should use for an incoming event:
671
+ * the named definition when the type is listed in `consumes`, a permissive
672
+ * `z.unknown()` definition when the contract consumes `"*"`, and `undefined`
673
+ * when the event is not consumed at all. Runtime counterpart of
674
+ * `ConsumedEvent<Contract>`.
675
+ *
676
+ * `"*"` NEVER MATCHES AN EPHEMERAL EVENT, and that one rule is what lets
677
+ * ephemeral types live in `consumes` beside durable ones instead of in a
678
+ * parallel list. Naming a type explicitly is the opt-in: you cannot be handed
679
+ * a microphone firehose by a wildcard you wrote for durable facts, and a
680
+ * processor that wants live events says so by type. Ephemeral bodies live
681
+ * only in the Stream DO's bounded buffer, so a processor receiving one must
682
+ * have decided it can cope with never seeing it again — a decision nobody
683
+ * makes by writing `"*"`.
684
+ */
685
+ function getConsumedEventDefinition(args) {
686
+ if (!args.contract.consumes.includes(args.eventType)) {
687
+ if (args.ephemeral === true) return void 0;
688
+ if (args.contract.consumes.includes("*")) return { payloadSchema: z.unknown() };
689
+ return;
690
+ }
691
+ const eventDefinition = getResolvedEventDefinition(args);
692
+ if (eventDefinition == null) throw new Error(`Unresolved stream processor consumes event type "${args.eventType}".`);
693
+ if (args.ephemeral === true && eventDefinition.ephemeral !== true) return void 0;
694
+ return eventDefinition;
695
+ }
696
+ function getResolvedEventDefinition(args) {
697
+ const localEventDefinition = args.contract.events[args.eventType];
698
+ if (localEventDefinition != null) return localEventDefinition;
699
+ for (const dependency of args.contract.processorDeps ?? []) {
700
+ const dependencyEventDefinition = getDependencyEvents(dependency)?.[args.eventType];
701
+ if (dependencyEventDefinition != null) return dependencyEventDefinition;
702
+ }
703
+ }
704
+ function getDependencyEvents(dependency) {
705
+ if (isEventCatalog(dependency)) return dependency;
706
+ if (typeof dependency === "object" && dependency !== null && "events" in dependency && isEventCatalog(dependency.events)) return dependency.events;
707
+ }
708
+ function assertNoLocalProcessorDepEventConflicts(contract) {
709
+ if (typeof contract !== "object" || contract === null || !("events" in contract)) return;
710
+ if (!isEventCatalog(contract.events)) return;
711
+ const processorDeps = "processorDeps" in contract && Array.isArray(contract.processorDeps) ? contract.processorDeps : [];
712
+ for (const dependency of processorDeps) {
713
+ const dependencyEvents = getDependencyEvents(dependency);
714
+ if (dependencyEvents === void 0) continue;
715
+ for (const type of Object.keys(contract.events)) {
716
+ if (!Object.prototype.hasOwnProperty.call(dependencyEvents, type)) continue;
717
+ throw new Error(`Processor "${getProcessorSlug(contract)}" defines event "${type}" that is already owned by processor dependency "${getProcessorSlug(dependency)}".`);
718
+ }
719
+ }
720
+ }
721
+ function isEventCatalog(value) {
722
+ if (typeof value !== "object" || value === null) return false;
723
+ return Object.values(value).every(isEventDefinition);
724
+ }
725
+ function isEventDefinition(value) {
726
+ return typeof value === "object" && value !== null && "payloadSchema" in value && typeof value.payloadSchema === "object" && value.payloadSchema !== null;
727
+ }
728
+ function isZodSchema(value) {
729
+ return typeof value === "object" && value !== null && "parse" in value && typeof value.parse === "function";
730
+ }
731
+ function getProcessorSlug(contract) {
732
+ if (typeof contract === "object" && contract !== null && "slug" in contract && typeof contract.slug === "string") return contract.slug;
733
+ return "unknown";
734
+ }
735
+ /**
736
+ * The ONE platform revival fact for every recovery-wired stream processor.
737
+ * Appended by the platform keepalive (`durableObjectRecovery` in
738
+ * durable-object-processor-durability.ts) when a processor is revived after
739
+ * its incarnation died owing background work — never emitted by a processor.
740
+ * Per-processor identity rides the payload's `processorSlug` and the
741
+ * `processor-revived:<slug>@...` idempotency key, not the type string.
742
+ * Consuming it is OPTIONAL: a processor should do so only when it reacts to
743
+ * the fact itself. Its append still wakes delivery when it is unconsumed, and
744
+ * a head-reaching frame receives the runner's eventless
745
+ * `processEvent(event: null, caughtUp: true)` pass so open obligations are not
746
+ * stranded. The event DEFINITION (payload schema) lives with the platform's
747
+ * core stream contract; this constant is here so contracts and the recovery
748
+ * adapter agree on the type string without importing that contract.
749
+ */
750
+ const STREAM_PROCESSOR_REVIVED_EVENT_TYPE = "events.iterate.com/stream/processor-revived";
751
+ /**
752
+ * A processor contract announcement carried on `connection-opened` when the
753
+ * callback owner is a hosted stream processor. UIs and tooling read it from
754
+ * that event and from `runtime.connections[..].openedBy`.
755
+ */
756
+ const ProcessorContractAnnouncement = z.object({
757
+ slug: z.string().trim().min(1),
758
+ version: z.string().trim().min(1),
759
+ description: z.string(),
760
+ consumes: z.array(z.string()),
761
+ emits: z.array(z.string()),
762
+ ownedEvents: z.array(z.object({
763
+ type: z.string().trim().min(1),
764
+ description: z.string().optional()
765
+ }))
766
+ });
767
+ /**
768
+ * Platform stream events a processor contract may CONSUME without owning —
769
+ * pass as a `processorDeps` entry. Currently just the keepalive revival fact.
770
+ * Consumption is optional and belongs only in processors that react to the
771
+ * fact itself; an unconsumed revival tail receives the runner's eventless
772
+ * at-head turn. The event's authoritative definition lives with the
773
+ * platform's core stream contract, and this catalog is deliberately
774
+ * payload-loose.
775
+ */
776
+ const PLATFORM_STREAM_EVENTS = { [STREAM_PROCESSOR_REVIVED_EVENT_TYPE]: {
777
+ description: "Platform keepalive revival fact: appended when a processor's incarnation died owing background work; consumed when the processor reacts to the fact itself, otherwise followed by an eventless at-head processEvent turn.",
778
+ payloadSchema: z.looseObject({})
779
+ } };
780
+ //#endregion
781
+ //#region src/processors/stream-processor.ts
782
+ async function awaitKeepAliveBacked(keepAliveWhile, work) {
783
+ if (keepAliveWhile === void 0) return await work();
784
+ return await new Promise((resolve, reject) => {
785
+ keepAliveWhile(async () => {
786
+ try {
787
+ const result = await work();
788
+ resolve(result);
789
+ return result;
790
+ } catch (error) {
791
+ reject(error);
792
+ throw error;
793
+ }
794
+ });
795
+ });
796
+ }
797
+ /**
798
+ * Class-based stream processor.
799
+ *
800
+ * The model in one sentence: the StreamProcessorRunner
801
+ * (stream-processor-runner.ts) delivers ordered events, folds each consumed
802
+ * event into state through `reduce`, hands each reduction to the side-effect
803
+ * hooks, and owns cursors, checkpoints, retry, and recovery — the processor
804
+ * itself is only the hooks.
805
+ *
806
+ * Subclasses override up to two hooks:
807
+ *
808
+ * - `reduce` — pure projection of one consumed event into the next state
809
+ * - `processEvent` — synchronous per-event side effects; what most processors
810
+ * implement. Side effects derived from the whole fold (rather than the
811
+ * delivered event) belong here too, guarded by `args.delivery.caughtUp`.
812
+ * `args.event` is `null` only when a caught-up scan contained no
813
+ * consumed event; authors skip their per-event switch but can still act on
814
+ * the fold.
815
+ *
816
+ * Every hook runs inside the runner's serialized delivery chain: a later
817
+ * batch never starts until the previous one has completed or failed, and the
818
+ * cursor is only committed after the hooks (plus any `blockProcessorWhile`
819
+ * work) succeed.
820
+ */
821
+ var StreamProcessor = class extends RpcTarget {
822
+ stream;
823
+ /** Path of the home stream — the one `this.stream` points at. */
824
+ path;
825
+ /** Owning project, or null on a global (deployment-root) stream. */
826
+ projectId;
827
+ deps;
828
+ /**
829
+ * Self-measured consumption metrics (see event-consumption-metrics.ts): every
830
+ * home-stream append and every committed event batch feeds it (the
831
+ * latter through the driver's `noteBatchIngested`), closing the
832
+ * consume-your-own-appends loop on the processor's own clock. HOSTS merge
833
+ * `eventConsumptionMetrics.report()` into the `getRuntimeState` answer they give
834
+ * the stream (`runtime.metrics`) — merged host-side so a subclass
835
+ * overriding `getRuntimeState` with its own `runtime` bag cannot
836
+ * accidentally drop it. In-memory; resets with the isolate.
837
+ */
838
+ eventConsumptionMetrics = new EventConsumptionMetrics(Date.now());
839
+ #keepAliveWhile;
840
+ constructor(args) {
841
+ super();
842
+ const { stream, path, projectId, keepAliveWhile, ...deps } = args;
843
+ this.stream = stream;
844
+ this.path = path;
845
+ this.projectId = projectId;
846
+ this.deps = deps;
847
+ this.#keepAliveWhile = keepAliveWhile;
848
+ }
849
+ /**
850
+ * @internal Hands the StreamProcessorRunner its {@link StreamProcessorRunnerHooks}.
851
+ * A STATIC accessor on purpose: statics may reach protected/private members
852
+ * of instances of their own class, so the runner gets the hooks without any
853
+ * new public instance member (nothing for subclasses to see, shadow, or
854
+ * call). Authors never touch this; the runner is its only caller.
855
+ */
856
+ static runnerHooks(processor) {
857
+ return {
858
+ contract: processor.contract,
859
+ initialState: () => processor.contract.stateSchema.parse({}),
860
+ parseState: (value) => {
861
+ const parsed = processor.contract.stateSchema.safeParse(value);
862
+ return parsed.success ? {
863
+ success: true,
864
+ state: parsed.data
865
+ } : {
866
+ success: false,
867
+ error: parsed.error
868
+ };
869
+ },
870
+ reduceRawEvent: (args) => processor.#reduceRawEvent(args),
871
+ isDeliverable: (event) => processor.#isDeliverable(event),
872
+ processEvent: (args) => processor.processEvent(args),
873
+ noteBatchIngested: (args) => processor.eventConsumptionMetrics.noteBatchIngested(args),
874
+ idempotencyKey: (key, whileProcessing) => processor.idempotencyKey(key, whileProcessing),
875
+ processorStamp: (streamId, whileProcessing) => processor.#processorStamp(streamId, whileProcessing),
876
+ append: (opts, input) => processor.#appendStamped({
877
+ target: processor.stream,
878
+ targetPath: processor.path,
879
+ sourceStreamId: opts.streamId,
880
+ whileProcessing: opts.whileProcessing
881
+ }, input),
882
+ appendTo: (path, opts, input) => processor.#appendStamped({
883
+ ...processor.#appendTarget(path),
884
+ sourceStreamId: opts.streamId,
885
+ whileProcessing: opts.whileProcessing
886
+ }, input)
887
+ };
888
+ }
889
+ /**
890
+ * The processor-contributed slice of the published runtime state: the
891
+ * operational `runtime` bag only (see {@link ProcessorRuntimeContribution}).
892
+ * Subclasses override to expose debug data; the base contributes nothing.
893
+ * The snapshot half comes from the runner, and event-consumption metrics are
894
+ * merged in host-side — never read cursor state here.
895
+ */
896
+ async getRuntimeState() {
897
+ return {};
898
+ }
899
+ /** Build and validate an append input for an event listed in `contract.emits`. */
900
+ #buildEmittedEvent(event) {
901
+ if (!this.contract.emits.includes(event.type)) throw new Error(`Processor "${this.contract.slug}" cannot build emitted event "${event.type}".`);
902
+ const eventDefinition = getResolvedEventDefinition({
903
+ contract: this.contract,
904
+ eventType: event.type
905
+ });
906
+ if (eventDefinition === void 0) throw new Error(`Unresolved stream processor emits event type "${event.type}".`);
907
+ return getEventInputSchema({
908
+ type: event.type,
909
+ payloadSchema: eventDefinition.payloadSchema,
910
+ ephemeral: eventDefinition.ephemeral
911
+ }).parse(event);
912
+ }
913
+ /**
914
+ * Pure projection of one consumed event into the next state. Defaults to
915
+ * identity; returning `null`/`undefined` also keeps the current state.
916
+ */
917
+ reduce(args) {
918
+ return args.state;
919
+ }
920
+ /**
921
+ * Synchronous side-effect hook, called by the runner once per consumed event
922
+ * and, when necessary, once more with `event: null` for a caught-up scan
923
+ * that consumed nothing. It is ALSO the caught-up processing: when
924
+ * `args.delivery.caughtUp` is true (`args.state` is the whole observed fold),
925
+ * an obligation processor
926
+ * drives its undriven obligations and settles dead ones — scheduling that
927
+ * async work via `args.blockProcessorWhile`, keyed by STABLE obligation keys
928
+ * (`this.idempotencyKey(<obligation>)` with the deciding state folded into
929
+ * the key and NO event bound, so a redelivery/revival does not rotate the
930
+ * key and re-run the effect).
931
+ * The runner never sets `caughtUp` below its highest observed offset — no override
932
+ * needs its own mid-catch-up gate. Simple processors ignore the flag.
933
+ */
934
+ processEvent(_args) {}
935
+ /** Parse a raw event against the contract: `undefined` (type not consumed),
936
+ * a Zod error (consumed type, bad shape), or the typed consumed event.
937
+ * Stateless — shared by {@link #reduceRawEvent} and {@link #isDeliverable}. */
938
+ #parseConsumedEvent(event) {
939
+ const eventDefinition = getConsumedEventDefinition({
940
+ contract: this.contract,
941
+ eventType: event.type,
942
+ ephemeral: event.ephemeral
943
+ });
944
+ if (eventDefinition === void 0) return { ok: false };
945
+ const parsed = cachedEventSchema({
946
+ type: event.type,
947
+ payloadSchema: eventDefinition.payloadSchema,
948
+ ephemeral: eventDefinition.ephemeral
949
+ }).safeParse(event);
950
+ if (!parsed.success) return {
951
+ ok: false,
952
+ error: parsed.error
953
+ };
954
+ return {
955
+ ok: true,
956
+ event: parsed.data
957
+ };
958
+ }
959
+ /** True when this event will reach `processEvent`: a consumed type that
960
+ * parses. A malformed consumed event is deliberately NOT deliverable. */
961
+ #isDeliverable(event) {
962
+ return this.#parseConsumedEvent(event).ok;
963
+ }
964
+ /**
965
+ * Reduce one raw stream event against explicit state, without touching any
966
+ * processor-internal state. Returns `undefined` for events this processor
967
+ * does not consume, and a {@link ConsumedEventParseFailure} for events of a
968
+ * consumed TYPE whose shape fails the contract parse — streams accept raw
969
+ * appends by design, so a malformed event is a fact of the log, not an
970
+ * exception: throwing here would wedge the cursor on it forever.
971
+ */
972
+ #reduceRawEvent(args) {
973
+ const parsed = this.#parseConsumedEvent(args.event);
974
+ if (!parsed.ok) return parsed.error === void 0 ? void 0 : { parseError: parsed.error };
975
+ const event = parsed.event;
976
+ const state = this.reduce({
977
+ event,
978
+ state: args.state
979
+ }) ?? args.state;
980
+ assertObjectProcessorState({
981
+ processorSlug: this.contract.slug,
982
+ value: state
983
+ });
984
+ return {
985
+ event,
986
+ previousState: args.state,
987
+ state
988
+ };
989
+ }
990
+ /**
991
+ * Fire-and-forget async work backed by the injected keep-alive, with
992
+ * failures logged. For work launched OUTSIDE a delivery hook (DO verbs,
993
+ * alarm handlers); inside `processEvent`, use the `runInBackground` helper
994
+ * from the hook args — that one rides the runner's recovery keepalive.
995
+ */
996
+ runInBackground(work) {
997
+ awaitKeepAliveBacked(this.#keepAliveWhile, work).catch((error) => {
998
+ console.error("stream processor background work failed", error);
999
+ });
1000
+ }
1001
+ /**
1002
+ * Append events listed in `contract.emits` to this processor's own stream,
1003
+ * stamped with `source.processor` provenance (no `whileProcessing`: this
1004
+ * overload is for appends outside any event batch — alarm handlers, DO methods —
1005
+ * and for decisions derived from the whole fold). Inside `processEvent`,
1006
+ * prefer the event-bound `args.append`.
1007
+ */
1008
+ append(...input) {
1009
+ return this.#appendStamped({
1010
+ target: this.stream,
1011
+ targetPath: this.path
1012
+ }, input);
1013
+ }
1014
+ /** Like {@link append}, onto a sibling stream (resolved via `stream.at(path)`). */
1015
+ appendTo(path, ...input) {
1016
+ return this.#appendStamped(this.#appendTarget(path), input);
1017
+ }
1018
+ #appendTarget(path) {
1019
+ const targetPath = resolveStreamPath(this.path, path);
1020
+ return {
1021
+ target: targetPath === this.path ? this.stream : this.stream.at(path),
1022
+ targetPath
1023
+ };
1024
+ }
1025
+ /**
1026
+ * Processor-scoped idempotency key: `<slug>/<key>`, plus `@<path>:<offset>`
1027
+ * when the append is a deterministic consequence of processing one event —
1028
+ * a resent event batch then dedupes instead of double-appending. The path
1029
+ * makes fan-in safe: two same-slug processors on different streams
1030
+ * forwarding into one target can never collide. Omit `whileProcessing` for
1031
+ * state-derived appends and fold the deciding state into `key` instead
1032
+ * (e.g. a generation counter).
1033
+ */
1034
+ idempotencyKey(key, whileProcessing) {
1035
+ const base = `${this.contract.slug}/${key}`;
1036
+ if (whileProcessing === void 0) return base;
1037
+ return `${base}@${whileProcessing.path}:${whileProcessing.offset}`;
1038
+ }
1039
+ /**
1040
+ * The provenance stamp for one append. Always overwrites any
1041
+ * caller-supplied `source.processor`: the stamp describes THIS append, and
1042
+ * ancestry stays walkable through `whileProcessing` (and `copiedFrom`
1043
+ * for subscription copies, which preserve the original stamp).
1044
+ */
1045
+ #processorStamp(streamId, whileProcessing) {
1046
+ return {
1047
+ slug: this.contract.slug,
1048
+ version: this.contract.version,
1049
+ stream: {
1050
+ path: this.path,
1051
+ projectId: this.projectId,
1052
+ streamId
1053
+ },
1054
+ ...whileProcessing === void 0 ? {} : { whileProcessing: {
1055
+ offset: whileProcessing.offset,
1056
+ type: whileProcessing.type
1057
+ } }
1058
+ };
1059
+ }
1060
+ #appendStamped(args, input) {
1061
+ const builtEvents = input.map((event) => this.#buildEmittedEvent(event));
1062
+ return this.#appendBuiltEvents(args, builtEvents);
1063
+ }
1064
+ async #appendBuiltEvents(args, builtEvents) {
1065
+ const sourceStreamId = args.sourceStreamId ?? (await this.stream.getEventPage({
1066
+ afterOffset: Number.MAX_SAFE_INTEGER,
1067
+ limit: 1
1068
+ })).streamId;
1069
+ const processor = this.#processorStamp(sourceStreamId, args.whileProcessing);
1070
+ let events = builtEvents.map((built) => ({
1071
+ ...built,
1072
+ source: {
1073
+ ...built.source,
1074
+ processor
1075
+ }
1076
+ }));
1077
+ if (args.targetPath !== this.path) {
1078
+ events = events.map((event) => event.idempotencyKey === void 0 ? event : {
1079
+ ...event,
1080
+ idempotencyKey: `${event.idempotencyKey}@source-stream:${sourceStreamId}`
1081
+ });
1082
+ return args.target.append(...events);
1083
+ }
1084
+ const t0 = Date.now();
1085
+ return this.stream.appendIfStreamId({
1086
+ streamId: sourceStreamId,
1087
+ events
1088
+ }).then((committed) => {
1089
+ if (committed.length === 0) return committed;
1090
+ let maxCommittedOffset = null;
1091
+ for (const event of committed) {
1092
+ if (!this.#isDeliverable(event)) continue;
1093
+ maxCommittedOffset = Math.max(maxCommittedOffset ?? 0, event.offset);
1094
+ }
1095
+ this.eventConsumptionMetrics.noteAppendCommitted({
1096
+ maxCommittedOffset,
1097
+ t0,
1098
+ atMs: Date.now()
1099
+ });
1100
+ return committed;
1101
+ });
1102
+ }
1103
+ };
1104
+ //#endregion
1105
+ //#region src/processors/stream-processor-runner.ts
1106
+ /**
1107
+ * Processes event batches for one processor on one stream. Runtime-neutral:
1108
+ * the browser, the Durable Object registry, and the in-memory
1109
+ * test harness all instantiate exactly this class and differ only in the
1110
+ * `durability` / `keepAlive` adapters they pass. One runner per processor —
1111
+ * the "host" of old survives only as a thin registry that builds adapters and
1112
+ * routes wakes/alarms to the right runner.
1113
+ *
1114
+ * Serialization: batches and self-pulls share ONE in-memory chain, so a
1115
+ * catch-up never interleaves with a half-processed batch. Cross-incarnation
1116
+ * races (a stale runner outliving progress made elsewhere) are fenced durably
1117
+ * instead, by the progress store's `cursorRevision` CAS + monotonic fence.
1118
+ */
1119
+ var StreamProcessorRunner = class {
1120
+ processor;
1121
+ hooks;
1122
+ stream;
1123
+ durability;
1124
+ keepAlive;
1125
+ now;
1126
+ readPageSize;
1127
+ /** Memoized load for one stream lifetime; cleared on failure or recreation. */
1128
+ #loaded;
1129
+ #loadingStreamId;
1130
+ /** True once progress reflects a real load (fresh default over an empty
1131
+ * store counts; a pending/failed load does not) — the gate that keeps
1132
+ * default or partially-refolded state from ever escaping (the legacy
1133
+ * `isLoaded` invariant). `snapshot()` additionally
1134
+ * awaits the load, so partial state cannot escape through it either. */
1135
+ #hasLoaded = false;
1136
+ /** The COMMITTED, loaded progress — what snapshots and direct callbacks publish.
1137
+ * A hosted wake may publish only the separately-read processing cursor before this
1138
+ * reduction cache loads. Batch folds accumulate in locals and land here only after
1139
+ * the durable commit. */
1140
+ #progress;
1141
+ /** Highest stream offset observed across all batches this incarnation. */
1142
+ #highestObservedOffset = 0;
1143
+ /** Serializes batches + self-pulls; failures are contained per entry. */
1144
+ #chain = Promise.resolve();
1145
+ #disposed = false;
1146
+ #eventWaiters = /* @__PURE__ */ new Set();
1147
+ #stateChangeObservers = /* @__PURE__ */ new Set();
1148
+ /** Memoized schema default, for pre-load `currentState` reads. */
1149
+ #defaultState;
1150
+ constructor(args) {
1151
+ this.processor = args.processor;
1152
+ this.hooks = StreamProcessor.runnerHooks(args.processor);
1153
+ this.stream = args.stream;
1154
+ this.durability = args.durability;
1155
+ this.keepAlive = args.keepAlive;
1156
+ this.now = args.now ?? (() => Date.now());
1157
+ this.readPageSize = args.readPageSize ?? 500;
1158
+ }
1159
+ /**
1160
+ * Opens the processor's event-batch callback and returns its committed
1161
+ * processing offset. A hosted processor wake returns this pair to a source
1162
+ * stream; the browser database writer calls the same method directly.
1163
+ *
1164
+ * `checkpointOffset` is the PROCESSING cursor (`acknowledgedThroughOffset`),
1165
+ * never the reduction offset: the caller resumes after this value, and
1166
+ * resuming from a reduction-pinned snapshot
1167
+ * offset could skip events whose effects were never acknowledged.
1168
+ *
1169
+ * `processEventBatch` is the only place transport batching enters the
1170
+ * runner; inside it the runner reduces and processes one event at a time. A
1171
+ * hosting transport may adapt how the promise is observed, but must not
1172
+ * duplicate these semantics.
1173
+ */
1174
+ async openEventBatchCallback(expectedStreamId) {
1175
+ this.#assertNotDisposed();
1176
+ const opened = await this.#enqueue(async () => {
1177
+ const streamId = await this.#readCurrentStreamId(expectedStreamId);
1178
+ await this.#load(streamId);
1179
+ return {
1180
+ streamId,
1181
+ checkpointOffset: this.#requireProgress().processing.acknowledgedThroughOffset
1182
+ };
1183
+ });
1184
+ return this.#eventBatchCallback({
1185
+ ...opened,
1186
+ deferredLoad: false,
1187
+ sourceScansAllEvents: false
1188
+ });
1189
+ }
1190
+ /**
1191
+ * Open the callback used by a trusted hosted source Stream.
1192
+ *
1193
+ * The request already carries that source's authoritative stream ID. Reading
1194
+ * it back before returning would deadlock a colocated Processor Facet: the
1195
+ * source alarm owns the wake RPC while the facet's identity/refold read waits
1196
+ * for that same source turn. Return the durable processing cursor without a
1197
+ * source read, then finish any reduction-cache load when the source invokes
1198
+ * the independent one-way batch callback.
1199
+ */
1200
+ async openHostedEventBatchCallback(streamId) {
1201
+ this.#assertNotDisposed();
1202
+ const checkpointOffset = await this.#enqueue(() => this.#prepareHostedCheckpoint(streamId));
1203
+ return this.#eventBatchCallback({
1204
+ streamId,
1205
+ checkpointOffset,
1206
+ deferredLoad: true,
1207
+ sourceScansAllEvents: true
1208
+ });
1209
+ }
1210
+ #eventBatchCallback(args) {
1211
+ return {
1212
+ checkpointOffset: args.checkpointOffset,
1213
+ processEventBatch: (batch) => {
1214
+ const attempt = this.#enqueue(async () => {
1215
+ if (args.deferredLoad) await this.#loadPreparedStream(args.streamId);
1216
+ await this.#processBatch(batch);
1217
+ });
1218
+ this.durability?.recovery?.keepAliveWhile(() => attempt);
1219
+ if (!args.sourceScansAllEvents) this.#runInBackground(() => attempt.then(() => this.#enqueue(async () => {
1220
+ const { processing } = this.#requireProgress();
1221
+ if (processing.acknowledgedThroughOffset < batch.streamMaxOffset) await this.#selfCatchUp();
1222
+ }), () => void 0));
1223
+ return attempt;
1224
+ }
1225
+ };
1226
+ }
1227
+ /** Handle a durable recovery alarm routed here by the hosting registry. */
1228
+ async handleAlarm(info) {
1229
+ const recovery = this.durability?.recovery;
1230
+ if (recovery === void 0) return;
1231
+ await recovery.handleAlarm(info);
1232
+ }
1233
+ /** One consistent read of the fold, pinned to `reducedThroughOffset`. */
1234
+ async snapshot() {
1235
+ return this.#enqueue(async () => {
1236
+ const streamId = await this.#readCurrentStreamId();
1237
+ await this.#load(streamId);
1238
+ const progress = this.#requireProgress();
1239
+ return {
1240
+ offset: progress.reduction.reducedThroughOffset,
1241
+ state: progress.reduction.state
1242
+ };
1243
+ });
1244
+ }
1245
+ /**
1246
+ * Whether published state IS a real fold rather than the schema default —
1247
+ * the legacy `isLoaded` gate. With the runner, the load itself performs any
1248
+ * pending refold, so this is true whenever a load has completed and false
1249
+ * only before the first successful load.
1250
+ */
1251
+ get isLoaded() {
1252
+ return this.#hasLoaded;
1253
+ }
1254
+ /** Highest offset whose processing and blocking consequences have committed. */
1255
+ get currentAcknowledgedThroughOffset() {
1256
+ return this.#progress?.processing.acknowledgedThroughOffset ?? 0;
1257
+ }
1258
+ /** Source lifetime paired atomically with the current committed state and cursor. */
1259
+ get currentStreamId() {
1260
+ return this.#progress?.streamId;
1261
+ }
1262
+ /**
1263
+ * The current committed fold, synchronously (the schema default until the
1264
+ * first load) — the legacy `StreamProcessor.currentState`,
1265
+ * kept so a hosting registry can assemble its
1266
+ * live state without an async hop. Gate on {@link isLoaded} first: a cold
1267
+ * runner reports the default, and publishing that anywhere live would wipe
1268
+ * real facts for state observers.
1269
+ */
1270
+ get currentState() {
1271
+ if (this.#progress !== void 0) return this.#progress.reduction.state;
1272
+ this.#defaultState ??= this.hooks.initialState();
1273
+ return this.#defaultState;
1274
+ }
1275
+ /**
1276
+ * Observe committed reduced-state changes IN-PROCESS: the observer is a
1277
+ * local function (the hosting registry wires it to reassemble its
1278
+ * live-state engine), never a retained RPC stub. It fires after a batch
1279
+ * commit lands durably AND the committed state changed identity — the
1280
+ * runner's home for the legacy `StreamProcessor.observeStateChanges` +
1281
+ * post-persist notify. Returns a function that stops observing.
1282
+ */
1283
+ observeStateChanges(observer) {
1284
+ this.#stateChangeObservers.add(observer);
1285
+ return () => void this.#stateChangeObservers.delete(observer);
1286
+ }
1287
+ /**
1288
+ * Read journal pages after the acknowledged cursor and process them until
1289
+ * caught up — the public method for read-your-writes and a
1290
+ * hosting registry's cold-load healing (the legacy host's `catchUpInternal`
1291
+ * shape). One page of lookahead, so every non-final batch carries a
1292
+ * `streamMaxOffset` past its own last event and only the genuinely final page is
1293
+ * marked caught up. Serialized with delivered batches on the runner's chain; failures
1294
+ * RETHROW — the caller owns any swallow-and-log policy.
1295
+ */
1296
+ catchUp() {
1297
+ return this.#enqueue(async () => {
1298
+ const streamId = await this.#readCurrentStreamId();
1299
+ await this.#load(streamId);
1300
+ await this.#selfCatchUp();
1301
+ });
1302
+ }
1303
+ async waitUntilEvent(args) {
1304
+ if (args.signal?.aborted === true) throw abortReason(args.signal);
1305
+ if ("offset" in args) {
1306
+ if (!Number.isSafeInteger(args.offset) || args.offset < 0) throw new Error("waitUntilEvent offset must be a non-negative safe integer");
1307
+ const streamId = await this.#readCurrentStreamId();
1308
+ await this.#load(streamId);
1309
+ if (this.#requireProgress().processing.acknowledgedThroughOffset >= args.offset) return;
1310
+ const { offset, signal, timeoutMs } = args;
1311
+ const reached = this.#registerEventWaiter({
1312
+ kind: "offset",
1313
+ offset
1314
+ }, {
1315
+ signal,
1316
+ timeoutMs
1317
+ });
1318
+ this.catchUp().catch((error) => {
1319
+ reached.reject(error);
1320
+ });
1321
+ return await reached.promise;
1322
+ }
1323
+ const { predicate, signal, timeoutMs } = args;
1324
+ await this.#registerEventWaiter({
1325
+ kind: "predicate",
1326
+ predicate
1327
+ }, {
1328
+ signal,
1329
+ timeoutMs
1330
+ }).promise;
1331
+ }
1332
+ /** Release processor resources. Idempotent; a disposed runner rejects new work. */
1333
+ dispose() {
1334
+ this.#disposed = true;
1335
+ for (const waiter of this.#eventWaiters) this.#settleEventWaiter(waiter, { error: /* @__PURE__ */ new Error("StreamProcessorRunner disposed") });
1336
+ this.#stateChangeObservers.clear();
1337
+ }
1338
+ async #processBatch(batch) {
1339
+ const ingestStartedAtMs = this.now();
1340
+ assertProcessorEventBatch(batch);
1341
+ const committed = this.#requireProgress();
1342
+ if (batch.streamId !== committed.streamId) throw new Error(`stream processor "${this.hooks.contract.slug}" received batch for stream ID ${batch.streamId}; current progress belongs to ${committed.streamId}`);
1343
+ const committedThroughOffset = committed.processing.acknowledgedThroughOffset;
1344
+ if (batch.scannedAfterOffset > committedThroughOffset) throw new Error(`delivery batch starts after the committed scan cursor: ${batch.scannedAfterOffset} > ${committedThroughOffset}`);
1345
+ const batchScannedThroughOffset = Math.max(committedThroughOffset, batch.scannedThroughOffset);
1346
+ const pending = [];
1347
+ let scan = committedThroughOffset;
1348
+ for (const event of batch.events) {
1349
+ if (event.offset <= scan) continue;
1350
+ scan = event.offset;
1351
+ pending.push(event);
1352
+ }
1353
+ this.#highestObservedOffset = Math.max(this.#highestObservedOffset, batch.streamMaxOffset, batchScannedThroughOffset);
1354
+ if (pending.length === 0 && batchScannedThroughOffset === committedThroughOffset) return;
1355
+ const batchCaughtUp = batchScannedThroughOffset >= this.#highestObservedOffset;
1356
+ let lastDeliveredOffset = null;
1357
+ for (const event of pending) if (this.hooks.isDeliverable(event)) lastDeliveredOffset = event.offset;
1358
+ let firedCaughtUp = false;
1359
+ const ctx = {
1360
+ revision: committed.processing.cursorRevision,
1361
+ ingestStartedAtMs,
1362
+ state: committed.reduction.state,
1363
+ reducedThroughOffset: committed.reduction.reducedThroughOffset,
1364
+ completedThroughOffset: committed.processing.acknowledgedThroughOffset,
1365
+ eventsSinceCommit: 0,
1366
+ uncommittedEvents: [],
1367
+ uncommittedParseFailures: []
1368
+ };
1369
+ /** Every blocker started anywhere in this batch, for failure settlement. */
1370
+ const startedBlockers = [];
1371
+ try {
1372
+ for (const event of pending) {
1373
+ const reduction = this.hooks.reduceRawEvent({
1374
+ event,
1375
+ state: ctx.state
1376
+ });
1377
+ if (reduction !== void 0 && "parseError" in reduction) ctx.uncommittedParseFailures.push({
1378
+ event,
1379
+ error: reduction.parseError
1380
+ });
1381
+ else if (reduction !== void 0) {
1382
+ const caughtUp = batchCaughtUp && event.offset === lastDeliveredOffset;
1383
+ if (caughtUp) firedCaughtUp = true;
1384
+ const delivery = {
1385
+ caughtUp,
1386
+ streamId: batch.streamId
1387
+ };
1388
+ let eventChain = Promise.resolve();
1389
+ const whileProcessing = reduction.event;
1390
+ this.hooks.processEvent({
1391
+ event: reduction.event,
1392
+ previousState: reduction.previousState,
1393
+ state: reduction.state,
1394
+ delivery,
1395
+ blockProcessorWhile: (work) => {
1396
+ const attempt = eventChain.then(() => this.#keepAliveBackedWork(work).catch((error) => {
1397
+ console.error(`stream processor blocked work failed (${this.hooks.contract.slug})`, error);
1398
+ throw error;
1399
+ }));
1400
+ eventChain = attempt;
1401
+ startedBlockers.push(attempt);
1402
+ },
1403
+ runInBackground: (work) => this.#runInBackground(work),
1404
+ append: (...input) => this.hooks.append({
1405
+ streamId: batch.streamId,
1406
+ whileProcessing
1407
+ }, input),
1408
+ appendTo: (path, ...input) => this.hooks.appendTo(path, {
1409
+ streamId: batch.streamId,
1410
+ whileProcessing
1411
+ }, input)
1412
+ });
1413
+ await eventChain;
1414
+ ctx.state = reduction.state;
1415
+ }
1416
+ ctx.reducedThroughOffset = event.offset;
1417
+ ctx.completedThroughOffset = event.offset;
1418
+ ctx.eventsSinceCommit += 1;
1419
+ ctx.uncommittedEvents.push(event);
1420
+ }
1421
+ if (batchCaughtUp && !firedCaughtUp) {
1422
+ const delivery = {
1423
+ caughtUp: true,
1424
+ streamId: batch.streamId
1425
+ };
1426
+ let caughtUpChain = Promise.resolve();
1427
+ this.hooks.processEvent({
1428
+ event: null,
1429
+ previousState: ctx.state,
1430
+ state: ctx.state,
1431
+ delivery,
1432
+ blockProcessorWhile: (work) => {
1433
+ const attempt = caughtUpChain.then(() => this.#keepAliveBackedWork(work).catch((error) => {
1434
+ console.error(`stream processor blocked work failed (${this.hooks.contract.slug})`, error);
1435
+ throw error;
1436
+ }));
1437
+ caughtUpChain = attempt;
1438
+ startedBlockers.push(attempt);
1439
+ },
1440
+ runInBackground: (work) => this.#runInBackground(work),
1441
+ append: (...input) => this.hooks.append({ streamId: batch.streamId }, input),
1442
+ appendTo: (path, ...input) => this.hooks.appendTo(path, { streamId: batch.streamId }, input)
1443
+ });
1444
+ await caughtUpChain;
1445
+ }
1446
+ ctx.reducedThroughOffset = batchScannedThroughOffset;
1447
+ ctx.completedThroughOffset = batchScannedThroughOffset;
1448
+ } catch (error) {
1449
+ await Promise.allSettled(startedBlockers);
1450
+ throw error;
1451
+ }
1452
+ if (ctx.eventsSinceCommit > 0 || batchScannedThroughOffset > committedThroughOffset) await this.#commitBatchContext(ctx);
1453
+ }
1454
+ /**
1455
+ * Persist the batch context, THEN advance the published cursor, resolve
1456
+ * waiters, and flush parse-failure diagnostics for the covered events.
1457
+ *
1458
+ * Persist-before-advance is load-bearing (the legacy #ingest's ordering):
1459
+ * if the durable write fails, the batch must stay retryable — the redelivered
1460
+ * batch re-reduces from the OLD published state and retries the write.
1461
+ * Advancing in-memory first would make the retry a silent no-op (every
1462
+ * event filtered out, nothing re-saved), losing the batch durably.
1463
+ */
1464
+ async #commitBatchContext(ctx) {
1465
+ const streamId = this.#requireProgress().streamId;
1466
+ const next = {
1467
+ streamId,
1468
+ reduction: {
1469
+ reducerVersion: this.hooks.contract.version,
1470
+ reducedThroughOffset: ctx.reducedThroughOffset,
1471
+ state: ctx.state
1472
+ },
1473
+ processing: {
1474
+ acknowledgedThroughOffset: ctx.completedThroughOffset,
1475
+ cursorRevision: ctx.revision
1476
+ }
1477
+ };
1478
+ const previousCommittedState = this.#progress?.reduction.state;
1479
+ await this.#commit(next, {
1480
+ expectedCursorRevision: ctx.revision,
1481
+ expectedStreamId: streamId
1482
+ });
1483
+ this.#progress = next;
1484
+ ctx.eventsSinceCommit = 0;
1485
+ const committedEvents = ctx.uncommittedEvents.splice(0);
1486
+ const committedFailures = ctx.uncommittedParseFailures.splice(0);
1487
+ if (committedEvents.length > 0) {
1488
+ const newestEventCreatedAtMs = Date.parse(committedEvents.at(-1).createdAt);
1489
+ this.hooks.noteBatchIngested({
1490
+ ingestedThroughOffset: next.processing.acknowledgedThroughOffset,
1491
+ ingestedOffsets: committedEvents.map((event) => event.offset),
1492
+ ...Number.isFinite(newestEventCreatedAtMs) && { newestEventCreatedAtMs },
1493
+ ingestStartedAtMs: ctx.ingestStartedAtMs,
1494
+ atMs: this.now()
1495
+ });
1496
+ }
1497
+ if (!Object.is(previousCommittedState, next.reduction.state)) this.#notifyStateChange({
1498
+ offset: next.reduction.reducedThroughOffset,
1499
+ state: next.reduction.state
1500
+ });
1501
+ this.#resolveEventWaiters(committedEvents, next.processing.acknowledgedThroughOffset);
1502
+ for (const { event, error } of committedFailures) {
1503
+ const message = `stream processor "${this.hooks.contract.slug}" skipped event at offset ${event.offset} ("${event.type}"): it fails the contract's schema`;
1504
+ console.error(message, error);
1505
+ this.#runInBackground(() => this.stream.appendIfStreamId({
1506
+ streamId,
1507
+ events: [{
1508
+ type: "events.iterate.com/stream/error-occurred",
1509
+ idempotencyKey: this.hooks.idempotencyKey("event-parse-failed", event),
1510
+ source: { processor: this.hooks.processorStamp(streamId, event) },
1511
+ payload: {
1512
+ message,
1513
+ error: {
1514
+ name: error.name,
1515
+ message: error.message
1516
+ }
1517
+ }
1518
+ }]
1519
+ }));
1520
+ }
1521
+ }
1522
+ /**
1523
+ * Return the hosted source's authoritative effect cursor without reading
1524
+ * that source. Fresh/recreated lifetimes still land their durable fence
1525
+ * before the checkpoint escapes; an existing lifetime leaves its disposable
1526
+ * reduction cache unloaded until the one-way delivery callback.
1527
+ */
1528
+ async #prepareHostedCheckpoint(streamId) {
1529
+ if (this.#hasLoaded && this.#progress?.streamId === streamId) return this.#progress.processing.acknowledgedThroughOffset;
1530
+ if (this.#hasLoaded) {
1531
+ this.#hasLoaded = false;
1532
+ this.#loaded = void 0;
1533
+ this.#loadingStreamId = void 0;
1534
+ }
1535
+ const persisted = await this.durability?.progress.read();
1536
+ if (persisted === void 0) {
1537
+ const fresh = this.#freshProgress(streamId, 0);
1538
+ await this.#commit(fresh, {
1539
+ expectedCursorRevision: 0,
1540
+ expectedStreamId: void 0
1541
+ });
1542
+ this.#progress = fresh;
1543
+ this.#hasLoaded = true;
1544
+ return 0;
1545
+ }
1546
+ if (persisted.streamId === streamId) return persisted.processing.acknowledgedThroughOffset;
1547
+ const replaceForStream = this.durability?.progress.replaceForStream;
1548
+ if (replaceForStream === void 0) throw new Error(`stream processor "${this.hooks.contract.slug}" progress belongs to stream ID ${persisted.streamId}, but the current stream ID is ${streamId}; this durability backend must reset its related projections before reopening`);
1549
+ const fresh = this.#freshProgress(streamId, persisted.processing.cursorRevision + 1);
1550
+ await replaceForStream(fresh, {
1551
+ expectedCursorRevision: persisted.processing.cursorRevision,
1552
+ expectedStreamId: persisted.streamId
1553
+ });
1554
+ this.#progress = fresh;
1555
+ this.#hasLoaded = true;
1556
+ this.#notifyStateChange({
1557
+ offset: 0,
1558
+ state: fresh.reduction.state
1559
+ });
1560
+ return 0;
1561
+ }
1562
+ #load(streamId) {
1563
+ return this.#loadWithStreamReplacement(streamId, true);
1564
+ }
1565
+ #loadPreparedStream(streamId) {
1566
+ if (this.#hasLoaded) return Promise.resolve();
1567
+ return this.#loadWithStreamReplacement(streamId, false);
1568
+ }
1569
+ #loadWithStreamReplacement(streamId, replaceMismatchedStream) {
1570
+ if (this.#hasLoaded && this.#progress?.streamId === streamId) return Promise.resolve();
1571
+ if (this.#hasLoaded) {
1572
+ this.#hasLoaded = false;
1573
+ this.#loaded = void 0;
1574
+ this.#loadingStreamId = void 0;
1575
+ }
1576
+ if (this.#loaded !== void 0) {
1577
+ if (this.#loadingStreamId === streamId) return this.#loaded;
1578
+ return this.#loaded.then(() => this.#loadWithStreamReplacement(streamId, replaceMismatchedStream));
1579
+ }
1580
+ this.#loadingStreamId = streamId;
1581
+ this.#loaded = this.#loadOnce(streamId, replaceMismatchedStream).catch((error) => {
1582
+ this.#loaded = void 0;
1583
+ this.#loadingStreamId = void 0;
1584
+ throw error;
1585
+ });
1586
+ return this.#loaded;
1587
+ }
1588
+ #freshProgress(streamId, cursorRevision) {
1589
+ return {
1590
+ streamId,
1591
+ reduction: {
1592
+ reducerVersion: this.hooks.contract.version,
1593
+ reducedThroughOffset: 0,
1594
+ state: this.hooks.initialState()
1595
+ },
1596
+ processing: {
1597
+ acknowledgedThroughOffset: 0,
1598
+ cursorRevision
1599
+ }
1600
+ };
1601
+ }
1602
+ async #loadOnce(streamId, replaceMismatchedStream) {
1603
+ const persisted = await this.durability?.progress.read();
1604
+ if (persisted === void 0) {
1605
+ const fresh = this.#freshProgress(streamId, 0);
1606
+ await this.#commit(fresh, {
1607
+ expectedCursorRevision: 0,
1608
+ expectedStreamId: void 0
1609
+ });
1610
+ this.#progress = fresh;
1611
+ this.#hasLoaded = true;
1612
+ return;
1613
+ }
1614
+ if (persisted.streamId !== streamId) {
1615
+ if (!replaceMismatchedStream) throw new Error(`hosted callback for stream ID ${streamId} is stale; processor progress belongs to ${persisted.streamId}`);
1616
+ const replaceForStream = this.durability?.progress.replaceForStream;
1617
+ if (replaceForStream === void 0) throw new Error(`stream processor "${this.hooks.contract.slug}" progress belongs to stream ID ${persisted.streamId}, but the current stream ID is ${streamId}; this durability backend must reset its related projections before reopening`);
1618
+ const fresh = this.#freshProgress(streamId, persisted.processing.cursorRevision + 1);
1619
+ await replaceForStream(fresh, {
1620
+ expectedCursorRevision: persisted.processing.cursorRevision,
1621
+ expectedStreamId: persisted.streamId
1622
+ });
1623
+ this.#progress = fresh;
1624
+ this.#hasLoaded = true;
1625
+ this.#notifyStateChange({
1626
+ offset: 0,
1627
+ state: fresh.reduction.state
1628
+ });
1629
+ return;
1630
+ }
1631
+ const acknowledged = persisted.processing.acknowledgedThroughOffset;
1632
+ const parsed = this.hooks.parseState(persisted.reduction.state);
1633
+ const reducedAheadOfAck = persisted.reduction.reducedThroughOffset > acknowledged;
1634
+ if (persisted.reduction.reducerVersion === this.hooks.contract.version && parsed.success && !reducedAheadOfAck) {
1635
+ let reduction = {
1636
+ ...persisted.reduction,
1637
+ state: parsed.state
1638
+ };
1639
+ if (reduction.reducedThroughOffset < acknowledged) {
1640
+ reduction = await this.#rebuildReduction(streamId, acknowledged, {
1641
+ state: reduction.state,
1642
+ reducedThroughOffset: reduction.reducedThroughOffset
1643
+ });
1644
+ const progress = {
1645
+ streamId,
1646
+ reduction,
1647
+ processing: persisted.processing
1648
+ };
1649
+ await this.#commit(progress, {
1650
+ expectedCursorRevision: persisted.processing.cursorRevision,
1651
+ expectedStreamId: streamId
1652
+ });
1653
+ this.#progress = progress;
1654
+ this.#hasLoaded = true;
1655
+ return;
1656
+ }
1657
+ this.#progress = {
1658
+ streamId,
1659
+ reduction,
1660
+ processing: persisted.processing
1661
+ };
1662
+ this.#hasLoaded = true;
1663
+ return;
1664
+ }
1665
+ console.warn(reducedAheadOfAck ? `stream processor "${this.hooks.contract.slug}" persisted reduction cursor (${persisted.reduction.reducedThroughOffset}) is AHEAD of the acknowledged cursor (${acknowledged}) — an invalid record; discarding the fold and refolding reduce-only through the acknowledgement` : `stream processor "${this.hooks.contract.slug}" reduction cache is stale (persisted reducerVersion "${persisted.reduction.reducerVersion}", current "${this.hooks.contract.version}", state ${parsed.success ? "valid" : "invalid"}); refolding reduce-only through acknowledged offset ${acknowledged}`);
1666
+ const progress = {
1667
+ streamId,
1668
+ reduction: await this.#rebuildReduction(streamId, acknowledged),
1669
+ processing: persisted.processing
1670
+ };
1671
+ await this.#commit(progress, {
1672
+ expectedCursorRevision: persisted.processing.cursorRevision,
1673
+ expectedStreamId: streamId
1674
+ });
1675
+ this.#progress = progress;
1676
+ this.#hasLoaded = true;
1677
+ }
1678
+ /** Rebuild the fold through `throughOffset`, reduce ONLY, paged — from
1679
+ * offset 0 by default, or extending `from` (a valid persisted fold that
1680
+ * LAGS the target, so only the gap's events are read). */
1681
+ async #rebuildReduction(streamId, throughOffset, from) {
1682
+ let state = from?.state ?? this.hooks.initialState();
1683
+ let afterOffset = from?.reducedThroughOffset ?? 0;
1684
+ if (throughOffset > afterOffset) for (;;) {
1685
+ const page = await this.stream.getEventPage({
1686
+ afterOffset,
1687
+ beforeOffset: throughOffset + 1,
1688
+ byteLimit: MAX_STREAM_EVENT_READ_BYTE_LIMIT,
1689
+ limit: this.readPageSize
1690
+ });
1691
+ this.#assertReadStreamId(page.streamId, streamId);
1692
+ if (page.events.length === 0) break;
1693
+ for (const event of page.events) {
1694
+ if (event.offset > throughOffset) continue;
1695
+ const reduction = this.hooks.reduceRawEvent({
1696
+ event,
1697
+ state
1698
+ });
1699
+ if (reduction !== void 0 && !("parseError" in reduction)) state = reduction.state;
1700
+ }
1701
+ afterOffset = page.events.at(-1).offset;
1702
+ }
1703
+ return {
1704
+ reducerVersion: this.hooks.contract.version,
1705
+ reducedThroughOffset: throughOffset,
1706
+ state
1707
+ };
1708
+ }
1709
+ async #commit(progress, opts) {
1710
+ if (this.durability === void 0) return;
1711
+ await this.durability.progress.commit(progress, opts);
1712
+ }
1713
+ /**
1714
+ * Re-run `reduce` + `processEvent` from the acknowledged cursor by
1715
+ * reading the journal itself — catch-up cannot rely on a callback whose
1716
+ * starting offset was fixed when it opened. One page of lookahead means
1717
+ * every non-final batch carries a streamMaxOffset past its own last event (the
1718
+ * `caughtUp` flag appears only on the genuinely final page), matching the
1719
+ * host's catch-up.
1720
+ */
1721
+ async #selfCatchUp() {
1722
+ const streamId = this.#requireProgress().streamId;
1723
+ let scannedAfterOffset = this.#requireProgress().processing.acknowledgedThroughOffset;
1724
+ let targetOffset;
1725
+ for (;;) {
1726
+ const page = await this.stream.getEventPage({
1727
+ afterOffset: scannedAfterOffset,
1728
+ ...targetOffset === void 0 ? {} : { beforeOffset: targetOffset + 1 },
1729
+ byteLimit: MAX_STREAM_EVENT_READ_BYTE_LIMIT,
1730
+ limit: this.readPageSize
1731
+ });
1732
+ this.#assertReadStreamId(page.streamId, streamId);
1733
+ targetOffset ??= page.streamMaxOffset;
1734
+ const lastEventOffset = page.events.at(-1)?.offset;
1735
+ const isFinalPage = lastEventOffset === void 0 || lastEventOffset >= targetOffset;
1736
+ const scannedThroughOffset = isFinalPage ? targetOffset : lastEventOffset;
1737
+ if (scannedThroughOffset <= scannedAfterOffset) return;
1738
+ await this.#processBatch({
1739
+ streamId,
1740
+ events: page.events,
1741
+ scannedAfterOffset,
1742
+ scannedThroughOffset,
1743
+ streamMaxOffset: targetOffset
1744
+ });
1745
+ scannedAfterOffset = scannedThroughOffset;
1746
+ if (isFinalPage) return;
1747
+ }
1748
+ }
1749
+ async #readCurrentStreamId(expectedStreamId) {
1750
+ const page = await this.stream.getEventPage({
1751
+ afterOffset: Number.MAX_SAFE_INTEGER,
1752
+ limit: 1
1753
+ });
1754
+ if (expectedStreamId !== void 0 && page.streamId !== expectedStreamId) throw new Error(`stream processor "${this.hooks.contract.slug}" was opened for stream ID ${expectedStreamId}, but the stream at this path is ${page.streamId}`);
1755
+ return page.streamId;
1756
+ }
1757
+ #assertReadStreamId(actualStreamId, expectedStreamId) {
1758
+ if (actualStreamId === expectedStreamId) return;
1759
+ throw new Error(`stream processor "${this.hooks.contract.slug}" stream ID changed during a read (${expectedStreamId} -> ${actualStreamId})`);
1760
+ }
1761
+ /** Fire-and-forget async work backed by the keepalive, with failures logged. */
1762
+ #runInBackground(work) {
1763
+ this.#keepAliveBackedWork(work).catch((error) => {
1764
+ console.error("stream processor runner background work failed", error);
1765
+ });
1766
+ }
1767
+ /**
1768
+ * Route registered work through the recovery adapter's keepalive when
1769
+ * present (both `blockProcessorWhile` and `runInBackground` ride it — "the
1770
+ * DO died owing work" must equal "the alarm was armed"), else through the
1771
+ * plain `keepAlive` hook, else run directly.
1772
+ */
1773
+ async #keepAliveBackedWork(work) {
1774
+ return await awaitKeepAliveBacked(this.durability?.recovery?.keepAliveWhile ?? this.keepAlive, work);
1775
+ }
1776
+ /** Serialize batches + self-pulls; the chain swallows each entry's
1777
+ * failure so one failed batch never wedges the entries behind it. */
1778
+ #enqueue(work) {
1779
+ const next = this.#chain.then(() => {
1780
+ this.#assertNotDisposed();
1781
+ return work();
1782
+ });
1783
+ this.#chain = next.then(() => void 0, () => void 0);
1784
+ return next;
1785
+ }
1786
+ #assertNotDisposed() {
1787
+ if (this.#disposed) throw new Error(`StreamProcessorRunner for "${this.hooks.contract.slug}" is disposed; it accepts no new work`);
1788
+ }
1789
+ #requireProgress() {
1790
+ if (this.#progress === void 0) throw new Error("StreamProcessorRunner progress read before load — this is a runner bug");
1791
+ return this.#progress;
1792
+ }
1793
+ #notifyStateChange(snapshot) {
1794
+ for (const observer of [...this.#stateChangeObservers]) try {
1795
+ observer(snapshot);
1796
+ } catch (error) {
1797
+ console.error("stream processor runner state-change observer failed", error);
1798
+ }
1799
+ }
1800
+ #registerEventWaiter(match, opts) {
1801
+ let waiter;
1802
+ return {
1803
+ promise: new Promise((resolve, reject) => {
1804
+ waiter = {
1805
+ ...match,
1806
+ reject,
1807
+ resolve,
1808
+ signal: opts.signal
1809
+ };
1810
+ this.#eventWaiters.add(waiter);
1811
+ if (opts.timeoutMs !== void 0) waiter.timer = setTimeout(() => {
1812
+ this.#settleEventWaiter(waiter, { error: /* @__PURE__ */ new Error(`waitUntilEvent timed out after ${opts.timeoutMs}ms`) });
1813
+ }, opts.timeoutMs);
1814
+ if (opts.signal !== void 0) {
1815
+ waiter.abortListener = () => {
1816
+ this.#settleEventWaiter(waiter, { error: abortReason(opts.signal) });
1817
+ };
1818
+ opts.signal.addEventListener("abort", waiter.abortListener, { once: true });
1819
+ if (opts.signal.aborted) waiter.abortListener();
1820
+ }
1821
+ }),
1822
+ reject: (error) => this.#settleEventWaiter(waiter, { error })
1823
+ };
1824
+ }
1825
+ #settleEventWaiter(waiter, outcome) {
1826
+ if (!this.#eventWaiters.delete(waiter)) return;
1827
+ if (waiter.timer !== void 0) clearTimeout(waiter.timer);
1828
+ if (waiter.signal !== void 0 && waiter.abortListener !== void 0) waiter.signal.removeEventListener("abort", waiter.abortListener);
1829
+ if ("error" in outcome) waiter.reject(outcome.error);
1830
+ else waiter.resolve();
1831
+ }
1832
+ #resolveEventWaiters(events, acknowledgedThroughOffset) {
1833
+ for (const waiter of this.#eventWaiters) {
1834
+ let matched = false;
1835
+ try {
1836
+ matched = waiter.kind === "offset" ? acknowledgedThroughOffset >= waiter.offset : events.some(waiter.predicate);
1837
+ } catch (error) {
1838
+ this.#settleEventWaiter(waiter, { error });
1839
+ continue;
1840
+ }
1841
+ if (matched) this.#settleEventWaiter(waiter, { value: void 0 });
1842
+ }
1843
+ }
1844
+ };
1845
+ function assertProcessorEventBatch(batch) {
1846
+ const coordinates = [
1847
+ ["scannedAfterOffset", batch.scannedAfterOffset],
1848
+ ["scannedThroughOffset", batch.scannedThroughOffset],
1849
+ ["streamMaxOffset", batch.streamMaxOffset]
1850
+ ];
1851
+ for (const [name, value] of coordinates) if (!Number.isSafeInteger(value) || value < 0) throw new Error(`stream processor event batch ${name} must be a non-negative safe integer`);
1852
+ if (batch.scannedThroughOffset < batch.scannedAfterOffset) throw new Error(`stream processor event batch scan regressed: ${batch.scannedAfterOffset} -> ${batch.scannedThroughOffset}`);
1853
+ if (batch.streamMaxOffset < batch.scannedThroughOffset) throw new Error(`stream processor event batch scan ${batch.scannedThroughOffset} is ahead of stream maximum offset ${batch.streamMaxOffset}`);
1854
+ let previousOffset = batch.scannedAfterOffset;
1855
+ for (const event of batch.events) {
1856
+ if (!Number.isSafeInteger(event.offset) || event.offset <= previousOffset) throw new Error(`stream processor event batch events must increase strictly after scan cursor ${previousOffset}; found ${event.offset}`);
1857
+ if (event.offset > batch.scannedThroughOffset) throw new Error(`stream processor event batch event ${event.offset} is beyond scanned-through offset ${batch.scannedThroughOffset}`);
1858
+ previousOffset = event.offset;
1859
+ }
1860
+ }
1861
+ function abortReason(signal) {
1862
+ return signal.reason ?? /* @__PURE__ */ new Error("waitUntilEvent aborted");
1863
+ }
1864
+ //#endregion
1865
+ //#region src/processors/stream-processor-keepalive.ts
1866
+ /** How far ahead of in-flight work the alarm is scheduled. Bounds post-eviction
1867
+ * revival latency; a deploy mid-agent-turn recovers within roughly this. */
1868
+ const KEEPALIVE_ALARM_LEAD_MS = 1e4;
1869
+ /**
1870
+ * Floor between redundant re-assertions of an already-sufficient alarm.
1871
+ * See #ensureArmedForWork: the re-assert exists to heal a lost platform
1872
+ * write, and healing within a fraction of the lead is as good as instantly.
1873
+ */
1874
+ const KEEPALIVE_REASSERT_MIN_INTERVAL_MS = 2500;
1875
+ /** Revival backoff by attempt number (1-based); past the table, the plateau. */
1876
+ const REVIVAL_BACKOFF_MS = [
1877
+ 1e4,
1878
+ 6e4,
1879
+ 5 * 6e4,
1880
+ 30 * 6e4
1881
+ ];
1882
+ const REVIVAL_BACKOFF_PLATEAU_MS = 360 * 6e4;
1883
+ /** Attempts before the crash-loop evidence fact is appended (once per version). */
1884
+ const CRASH_LOOP_EVIDENCE_THRESHOLD = 3;
1885
+ /**
1886
+ * Consecutive busy fires with NO settlement in between before the window is
1887
+ * treated as wedged (a hung promise nothing will ever settle — e.g. a socket
1888
+ * with no deadline). 90 fires ≈ 15 minutes at the lead, comfortably past the
1889
+ * longest legitimate tracked work (the providers' 10-minute deadlines), so
1890
+ * legit work never trips it while a wedge decays into the revival backoff
1891
+ * instead of re-arming every lead interval forever.
1892
+ */
1893
+ const MAX_CONSECUTIVE_BUSY_REFIRES = 90;
1894
+ function revivalBackoffMs(revivals) {
1895
+ return REVIVAL_BACKOFF_MS[revivals - 1] ?? 216e5;
1896
+ }
1897
+ const FRESH_RECORD = {
1898
+ revivals: 0,
1899
+ lastRevivalAt: 0,
1900
+ armedAtMs: null
1901
+ };
1902
+ var ProcessorKeepalive = class {
1903
+ #hooks;
1904
+ #inFlight = 0;
1905
+ /** Any tracked work settled successfully since the alarm was armed. A
1906
+ * successful revival pass also sets this — the pass IS settled work. */
1907
+ #sawCleanSettle = false;
1908
+ /** Any tracked work failed since the alarm was armed. Failures mean an
1909
+ * obligation may be unsettled (a debounce append that lost its stream), so
1910
+ * the next fire revives instead of disarming. */
1911
+ #sawFailure = false;
1912
+ /** Suppresses arm-earlier while the revival pass runs (see module doc). */
1913
+ #reviving = false;
1914
+ /** Consecutive busy fires without any settlement (wedged-work detector). */
1915
+ #busyRefires = 0;
1916
+ constructor(hooks) {
1917
+ this.#hooks = hooks;
1918
+ }
1919
+ /**
1920
+ * The keepalive's current alarm desire, for the host's slice merge. Read
1921
+ * straight from the durable record (synchronous DO KV) — a separate
1922
+ * in-memory copy would be one more thing to drift after an eviction, and
1923
+ * stale copied state is exactly the failure class this module hunts.
1924
+ */
1925
+ /** When an already-armed desire was last re-asserted; in-memory on purpose
1926
+ * (a fresh incarnation should re-assert on its first tracked work). */
1927
+ #lastReassertAtMs = 0;
1928
+ get armedAtMs() {
1929
+ return this.#hooks.readRecord()?.armedAtMs ?? null;
1930
+ }
1931
+ /**
1932
+ * Register one unit of in-flight work. Every registered work closure —
1933
+ * blocking and background alike — rides through here, so "the DO died owing
1934
+ * work" is exactly "the DO died with the alarm armed".
1935
+ */
1936
+ track(work) {
1937
+ this.#inFlight += 1;
1938
+ this.#ensureArmedForWork();
1939
+ this.#hooks.keepAlive(work.then(() => {
1940
+ this.#inFlight -= 1;
1941
+ this.#sawCleanSettle = true;
1942
+ this.#busyRefires = 0;
1943
+ }, () => {
1944
+ this.#inFlight -= 1;
1945
+ this.#sawFailure = true;
1946
+ this.#busyRefires = 0;
1947
+ }));
1948
+ }
1949
+ /**
1950
+ * The DO alarm handler body. The shared alarm may fire for another
1951
+ * subsystem's slice (the scheduler's), so this self-gates on the persisted
1952
+ * armed time and does nothing when the fire is not the keepalive's.
1953
+ */
1954
+ async onAlarm() {
1955
+ const now = this.#hooks.now();
1956
+ const armedAt = this.armedAtMs;
1957
+ if (armedAt === null || now < armedAt) return "not_due";
1958
+ if (this.#inFlight > 0 || this.#reviving) {
1959
+ this.#busyRefires += 1;
1960
+ if (this.#busyRefires < 90) {
1961
+ this.#arm(now + KEEPALIVE_ALARM_LEAD_MS);
1962
+ return "busy_rearmed";
1963
+ }
1964
+ if (this.#reviving) {
1965
+ this.#arm(now + REVIVAL_BACKOFF_PLATEAU_MS);
1966
+ return "revival_hung_backoff";
1967
+ }
1968
+ }
1969
+ if (this.#inFlight === 0 && this.#sawCleanSettle && !this.#sawFailure) {
1970
+ this.#sawCleanSettle = false;
1971
+ this.#disarmAndReset();
1972
+ return "clean_disarmed";
1973
+ }
1974
+ return await this.#revive(now);
1975
+ }
1976
+ async #revive(now) {
1977
+ const previous = this.#hooks.readRecord();
1978
+ const priorRevivals = previous === void 0 || previous.version !== this.#hooks.version ? 0 : previous.revivals;
1979
+ const record = {
1980
+ revivals: priorRevivals + 1,
1981
+ lastRevivalAt: now,
1982
+ version: this.#hooks.version,
1983
+ armedAtMs: now + revivalBackoffMs(priorRevivals + 1)
1984
+ };
1985
+ this.#hooks.writeRecord(record);
1986
+ this.#hooks.armAlarm(record.armedAtMs);
1987
+ if (record.revivals === CRASH_LOOP_EVIDENCE_THRESHOLD) this.#hooks.appendFact({
1988
+ type: "events.iterate.com/stream/error-occurred",
1989
+ idempotencyKey: `processor-host-crash-loop:${record.version}`,
1990
+ payload: { message: `processor host revival has failed ${record.revivals} consecutive times on version ${record.version}; backing off (plateau ${REVIVAL_BACKOFF_PLATEAU_MS / 6e4}m). A deploy resets the budget.` }
1991
+ });
1992
+ this.#sawFailure = false;
1993
+ this.#sawCleanSettle = false;
1994
+ this.#reviving = true;
1995
+ try {
1996
+ await this.#hooks.revive(record);
1997
+ this.#sawCleanSettle = true;
1998
+ if (!(this.#inFlight > 0 && this.#busyRefires >= 90)) this.#arm(this.#hooks.now() + KEEPALIVE_ALARM_LEAD_MS);
1999
+ return "revived";
2000
+ } catch (error) {
2001
+ if (this.#hooks.discardFailedRevival?.(error, record) === true) {
2002
+ this.#sawFailure = false;
2003
+ this.#sawCleanSettle = false;
2004
+ return "revival_discarded";
2005
+ }
2006
+ console.error("stream processor host revival failed; backing off", {
2007
+ revivals: record.revivals,
2008
+ nextAttemptAt: record.armedAtMs,
2009
+ error
2010
+ });
2011
+ this.#sawFailure = true;
2012
+ return "revival_failed";
2013
+ } finally {
2014
+ this.#reviving = false;
2015
+ }
2016
+ }
2017
+ /**
2018
+ * The operator's no-deploy antidote: clear the crash-loop budget and, when
2019
+ * a retry is owed (the record is armed), pull it in to the confirmation
2020
+ * lead so the next fire revives promptly on the fresh budget. Without this
2021
+ * the mark resets only on a quiet-clean confirmation or a version change —
2022
+ * a 3-strikes plateau otherwise mutes a wedged processor for six hours at
2023
+ * a time with a deploy as the only cure (the 2026-08-11 prod incident).
2024
+ */
2025
+ resetBackoff() {
2026
+ const record = this.#hooks.readRecord();
2027
+ if (record === void 0) return;
2028
+ if (record.armedAtMs === null) {
2029
+ this.#hooks.writeRecord({
2030
+ ...FRESH_RECORD,
2031
+ version: this.#hooks.version
2032
+ });
2033
+ return;
2034
+ }
2035
+ const atMs = this.#hooks.now() + KEEPALIVE_ALARM_LEAD_MS;
2036
+ this.#hooks.writeRecord({
2037
+ ...FRESH_RECORD,
2038
+ version: this.#hooks.version,
2039
+ armedAtMs: atMs
2040
+ });
2041
+ this.#hooks.armAlarm(atMs);
2042
+ }
2043
+ /** Arm for in-flight work: move the alarm earlier, never later, and never
2044
+ * during a revival pass (its backoff safety net must govern). */
2045
+ #ensureArmedForWork() {
2046
+ if (this.#reviving) return;
2047
+ const nowMs = this.#hooks.now();
2048
+ const atMs = nowMs + KEEPALIVE_ALARM_LEAD_MS;
2049
+ const armedAt = this.armedAtMs;
2050
+ if (armedAt !== null && armedAt <= atMs) {
2051
+ if (nowMs - this.#lastReassertAtMs < KEEPALIVE_REASSERT_MIN_INTERVAL_MS) return;
2052
+ this.#lastReassertAtMs = nowMs;
2053
+ this.#hooks.armAlarm(armedAt);
2054
+ return;
2055
+ }
2056
+ this.#lastReassertAtMs = nowMs;
2057
+ this.#arm(atMs);
2058
+ }
2059
+ #arm(atMs) {
2060
+ const record = this.#hooks.readRecord();
2061
+ this.#hooks.writeRecord({
2062
+ ...record?.version === this.#hooks.version ? record : {
2063
+ ...FRESH_RECORD,
2064
+ version: this.#hooks.version
2065
+ },
2066
+ armedAtMs: atMs
2067
+ });
2068
+ this.#hooks.armAlarm(atMs);
2069
+ }
2070
+ #disarmAndReset() {
2071
+ const record = this.#hooks.readRecord();
2072
+ if (record !== void 0 && (record.revivals !== 0 || record.armedAtMs !== null)) this.#hooks.writeRecord({
2073
+ ...FRESH_RECORD,
2074
+ version: this.#hooks.version
2075
+ });
2076
+ this.#hooks.armAlarm(null);
2077
+ }
2078
+ };
2079
+ //#endregion
2080
+ export { resolveStreamPath as A, StreamListItem as C, StreamRuntimeMetrics as D, MinuteBuckets as E, isStreamIdMismatchError as F, isStreamOffsetConflictError as I, isStreamReceiverUnavailableError as L, StreamIdMismatchError as M, StreamOffsetConflictError as N, ageStreamThroughputMetrics as O, StreamReceiverUnavailableError as P, streamIdMismatchMessage as R, StreamEventInput as S, LatencyRing as T, getEventInputSchema as _, revivalBackoffMs as a, MAX_COPIED_FROM_HOPS as b, awaitKeepAliveBacked as c, STREAM_PROCESSOR_REVIVED_EVENT_TYPE as d, assertObjectProcessorState as f, getConsumedEventDefinition as g, defineProcessorContract as h, REVIVAL_BACKOFF_PLATEAU_MS as i, MAX_STREAM_EVENT_READ_BYTE_LIMIT as j, pingRoundTrip as k, PLATFORM_STREAM_EVENTS as l, cachedEventSchema as m, MAX_CONSECUTIVE_BUSY_REFIRES as n, StreamProcessorRunner as o, buildEvent as p, ProcessorKeepalive as r, StreamProcessor as s, KEEPALIVE_ALARM_LEAD_MS as t, ProcessorContractAnnouncement as u, getResolvedEventDefinition as v, EventConsumptionMetrics as w, StreamEvent as x, mergeProcessorConfig as y, streamOffsetConflictMessage as z };
2081
+
2082
+ //# sourceMappingURL=stream-processor-keepalive-DAQTP6m3.mjs.map