@ewhauser/world-celld 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { c as globalRunIndexKey, d as workflowRunIndexKey, f as compact, g as stringify, h as parse, m as rpcStringify, o as correlationIndexKey, p as rpcParse } from "./retention-bLY1vcOu.js";
2
- import { EventSchema, HookSchema, MessageId, SPEC_VERSION_CURRENT, StepSchema, WorkflowRunSchema, parseQueueName } from "@workflow/world";
1
+ import { S as normalizeStreamError, a as stringify, b as encodeStreamWriteBatch, c as MAX_STREAM_BATCH_BYTES, d as MAX_STREAM_LONG_POLL_MS, f as MAX_STREAM_READ_BYTES, g as decodeStreamReadResult, h as STREAM_BATCH_CONTENT_TYPE, i as parse, l as MAX_STREAM_CHUNK_BYTES, m as MAX_STREAM_WRITE_CHUNKS, n as rpcParse, o as createWorkflowIndex, p as MAX_STREAM_READ_CHUNKS, r as rpcStringify, t as compact, u as MAX_STREAM_ERROR_BYTES, v as decodeStreamWriteResult } from "./util-DmO0XkFw.js";
2
+ import { EventSchema, HookSchema, MessageId, SPEC_VERSION_CURRENT, StepSchema, WorkflowRunSchema, isTerminalWorkflowRunStatus, parseQueueName } from "@workflow/world";
3
3
  import { EntityConflictError, HookNotFoundError, RunExpiredError, RunNotSupportedError, TooEarlyError, WorkflowRunNotFoundError, WorkflowWorldError } from "@workflow/errors";
4
4
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
5
5
  import { monotonicFactory } from "ulid";
@@ -8,6 +8,13 @@ function resolveConfig(config) {
8
8
  const retentionRaw = config?.runRetentionMs ?? process.env.CELLD_RUN_RETENTION_MS ?? 0;
9
9
  const runRetentionMs = typeof retentionRaw === "number" ? retentionRaw : Number.parseInt(retentionRaw, 10);
10
10
  if (!Number.isSafeInteger(runRetentionMs) || runRetentionMs < 0) throw new Error("world-celld: runRetentionMs must be a non-negative safe integer");
11
+ const rpcTimeoutMs = config?.rpcTimeoutMs ?? 3e4;
12
+ if (!Number.isSafeInteger(rpcTimeoutMs) || rpcTimeoutMs < 1) throw new Error("world-celld: rpcTimeoutMs must be a positive safe integer");
13
+ const streamLongPollMs = config?.streamLongPollMs ?? Math.min(2e4, Math.max(1, rpcTimeoutMs - 1e3));
14
+ if (!Number.isSafeInteger(streamLongPollMs) || streamLongPollMs < 1 || streamLongPollMs > 2e4) throw new Error(`world-celld: streamLongPollMs must be between 1 and ${MAX_STREAM_LONG_POLL_MS}`);
15
+ if (streamLongPollMs >= rpcTimeoutMs) throw new Error("world-celld: streamLongPollMs must be less than rpcTimeoutMs");
16
+ const streamFlushIntervalMs = config?.streamFlushIntervalMs ?? 0;
17
+ if (!Number.isSafeInteger(streamFlushIntervalMs) || streamFlushIntervalMs < 0) throw new Error("world-celld: streamFlushIntervalMs must be a non-negative safe integer");
11
18
  return {
12
19
  fleetUrl: config?.fleetUrl ?? process.env.CELLD_FLEET_URL,
13
20
  secret: config?.secret ?? process.env.CELLD_WORLD_SECRET,
@@ -16,8 +23,9 @@ function resolveConfig(config) {
16
23
  baseUrl: config?.baseUrl,
17
24
  queueShards: config?.queueShards ?? 1,
18
25
  runRetentionMs,
19
- readPollMs: config?.readPollMs ?? 250,
20
- rpcTimeoutMs: config?.rpcTimeoutMs ?? 3e4
26
+ streamLongPollMs,
27
+ streamFlushIntervalMs,
28
+ rpcTimeoutMs
21
29
  };
22
30
  }
23
31
  //#endregion
@@ -46,13 +54,13 @@ var FleetTransportError = class extends Error {
46
54
  //#endregion
47
55
  //#region src/remote/rpc-client.ts
48
56
  /** Statuses that mean "the fleet couldn't take the request" (safe to retry reads). */
49
- const RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
57
+ const RETRYABLE_STATUSES$1 = /* @__PURE__ */ new Set([
50
58
  502,
51
59
  503,
52
60
  504
53
61
  ]);
54
- const DEFAULT_TIMEOUT_MS = 3e4;
55
- function delayMs(ms) {
62
+ const DEFAULT_TIMEOUT_MS$1 = 3e4;
63
+ function delayMs$1(ms) {
56
64
  return new Promise((resolve) => setTimeout(resolve, ms));
57
65
  }
58
66
  /**
@@ -64,12 +72,16 @@ function delayMs(ms) {
64
72
  * via queue redelivery. Read-only calls retry on transport failures.
65
73
  */
66
74
  async function callDO(transport, binding, name, method, args, opts) {
75
+ return await callFleetRoute(transport, `/v1/rpc/${binding}/${encodeURIComponent(name)}/${method}`, args, opts);
76
+ }
77
+ /** Invoke a fixed authenticated worker route using the generic RPC codec. */
78
+ async function callFleetRoute(transport, path, args, opts) {
67
79
  const doFetch = transport.fetchImpl ?? fetch;
68
- const url = `${transport.fleetUrl.replace(/\/$/, "")}/v1/rpc/${binding}/${encodeURIComponent(name)}/${method}`;
80
+ const url = `${transport.fleetUrl.replace(/\/$/, "")}${path}`;
69
81
  const attempts = opts?.idempotent ? 3 : 1;
70
82
  let lastError;
71
83
  for (let attempt = 1; attempt <= attempts; attempt++) {
72
- if (attempt > 1) await delayMs(100 * attempt + Math.random() * 200);
84
+ if (attempt > 1) await delayMs$1(100 * attempt + Math.random() * 200);
73
85
  let response;
74
86
  try {
75
87
  response = await doFetch(url, {
@@ -79,7 +91,7 @@ async function callDO(transport, binding, name, method, args, opts) {
79
91
  authorization: `Bearer ${transport.secret}`
80
92
  },
81
93
  body: rpcStringify(args),
82
- signal: AbortSignal.timeout(Math.max(1, transport.timeoutMs ?? DEFAULT_TIMEOUT_MS))
94
+ signal: AbortSignal.timeout(Math.max(1, transport.timeoutMs ?? DEFAULT_TIMEOUT_MS$1))
83
95
  });
84
96
  } catch (error) {
85
97
  lastError = new FleetTransportError(`world-celld: fleet unreachable at ${url}`, error);
@@ -99,7 +111,7 @@ async function callDO(transport, binding, name, method, args, opts) {
99
111
  } catch {
100
112
  wire = { message: text || `HTTP ${response.status}` };
101
113
  }
102
- if (RETRYABLE_STATUSES.has(response.status) && attempt < attempts) {
114
+ if (RETRYABLE_STATUSES$1.has(response.status) && attempt < attempts) {
103
115
  lastError = reconstructError(wire, response.status);
104
116
  continue;
105
117
  }
@@ -108,6 +120,140 @@ async function callDO(transport, binding, name, method, args, opts) {
108
120
  throw lastError instanceof Error ? lastError : new FleetTransportError("world-celld: rpc failed", lastError);
109
121
  }
110
122
  //#endregion
123
+ //#region src/remote/stream-client.ts
124
+ const RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
125
+ 502,
126
+ 503,
127
+ 504
128
+ ]);
129
+ const READ_ATTEMPTS = 3;
130
+ const DEFAULT_TIMEOUT_MS = 3e4;
131
+ const MAX_WRITE_RESPONSE_BYTES = 64;
132
+ const MAX_READ_RESPONSE_BYTES = MAX_STREAM_READ_BYTES + 65536;
133
+ const MAX_ERROR_RESPONSE_BYTES = 65536;
134
+ function delayMs(ms) {
135
+ return new Promise((resolve) => setTimeout(resolve, ms));
136
+ }
137
+ function requestSignal(timeoutMs, signal) {
138
+ const timeout = AbortSignal.timeout(Math.max(1, timeoutMs));
139
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
140
+ }
141
+ function aborted(signal) {
142
+ return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
143
+ }
144
+ async function readBoundedResponse(response, maxBytes) {
145
+ if (Number(response.headers.get("content-length") ?? "0") > maxBytes) {
146
+ await response.body?.cancel("stream response exceeds configured limit").catch(() => void 0);
147
+ throw new FleetTransportError(`world-celld: stream response exceeds ${maxBytes} bytes`);
148
+ }
149
+ if (!response.body) return /* @__PURE__ */ new Uint8Array();
150
+ const reader = response.body.getReader();
151
+ const chunks = [];
152
+ let total = 0;
153
+ try {
154
+ for (;;) {
155
+ const { done, value } = await reader.read();
156
+ if (done) break;
157
+ total += value.byteLength;
158
+ if (total > maxBytes) {
159
+ await reader.cancel("stream response exceeds configured limit").catch(() => void 0);
160
+ throw new FleetTransportError(`world-celld: stream response exceeds ${maxBytes} bytes`);
161
+ }
162
+ chunks.push(value);
163
+ }
164
+ } finally {
165
+ reader.releaseLock();
166
+ }
167
+ const body = new Uint8Array(total);
168
+ let offset = 0;
169
+ for (const chunk of chunks) {
170
+ body.set(chunk, offset);
171
+ offset += chunk.byteLength;
172
+ }
173
+ return body;
174
+ }
175
+ async function errorFromResponse(response) {
176
+ const text = new TextDecoder().decode(await readBoundedResponse(response, MAX_ERROR_RESPONSE_BYTES));
177
+ let wire;
178
+ try {
179
+ wire = JSON.parse(text).error;
180
+ } catch {
181
+ wire = { message: text || `HTTP ${response.status}` };
182
+ }
183
+ return reconstructError(wire, response.status);
184
+ }
185
+ function streamUrl(transport, name) {
186
+ return `${transport.fleetUrl.replace(/\/$/, "")}/v1/streams/${encodeURIComponent(name)}/chunks`;
187
+ }
188
+ async function writeStreamChunks(transport, name, runId, chunks) {
189
+ const body = encodeStreamWriteBatch(chunks);
190
+ if (body.byteLength > 8389632) throw new Error("world-celld: encoded stream batch exceeds configured limit");
191
+ const url = new URL(streamUrl(transport, name));
192
+ url.searchParams.set("runId", runId);
193
+ const doFetch = transport.fetchImpl ?? fetch;
194
+ let response;
195
+ try {
196
+ response = await doFetch(url, {
197
+ method: "POST",
198
+ headers: {
199
+ "content-type": STREAM_BATCH_CONTENT_TYPE,
200
+ authorization: `Bearer ${transport.secret}`
201
+ },
202
+ body,
203
+ signal: requestSignal(transport.timeoutMs ?? DEFAULT_TIMEOUT_MS)
204
+ });
205
+ } catch (error) {
206
+ throw new FleetTransportError(`world-celld: fleet unreachable at ${url.href}`, error);
207
+ }
208
+ if (!response.ok) throw await errorFromResponse(response);
209
+ try {
210
+ return decodeStreamWriteResult(await readBoundedResponse(response, MAX_WRITE_RESPONSE_BYTES));
211
+ } catch (error) {
212
+ if (error instanceof FleetTransportError) throw error;
213
+ throw new FleetTransportError(`world-celld: malformed stream response from ${url.href}`, error);
214
+ }
215
+ }
216
+ async function readStreamChunks(transport, name, request, signal) {
217
+ const url = new URL(streamUrl(transport, name));
218
+ url.searchParams.set("runId", request.runId);
219
+ url.searchParams.set("startIndex", String(request.startIndex));
220
+ url.searchParams.set("maxChunks", String(request.maxChunks));
221
+ url.searchParams.set("maxBytes", String(request.maxBytes));
222
+ url.searchParams.set("waitMs", String(request.waitMs));
223
+ const doFetch = transport.fetchImpl ?? fetch;
224
+ let lastError;
225
+ for (let attempt = 1; attempt <= READ_ATTEMPTS; attempt++) {
226
+ if (signal?.aborted) throw aborted(signal);
227
+ if (attempt > 1) await delayMs(100 * attempt + Math.random() * 200);
228
+ let response;
229
+ try {
230
+ response = await doFetch(url, {
231
+ method: "GET",
232
+ headers: { authorization: `Bearer ${transport.secret}` },
233
+ signal: requestSignal(transport.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal)
234
+ });
235
+ } catch (error) {
236
+ if (signal?.aborted) throw aborted(signal);
237
+ lastError = new FleetTransportError(`world-celld: fleet unreachable at ${url.href}`, error);
238
+ continue;
239
+ }
240
+ if (response.ok) try {
241
+ return decodeStreamReadResult(await readBoundedResponse(response, MAX_READ_RESPONSE_BYTES));
242
+ } catch (error) {
243
+ lastError = new FleetTransportError(`world-celld: malformed stream response from ${url.href}`, error);
244
+ if (attempt < READ_ATTEMPTS) continue;
245
+ throw lastError;
246
+ }
247
+ const error = await errorFromResponse(response);
248
+ if (RETRYABLE_STATUSES.has(response.status) && attempt < READ_ATTEMPTS) {
249
+ lastError = error;
250
+ continue;
251
+ }
252
+ throw error;
253
+ }
254
+ throw lastError instanceof Error ? lastError : new FleetTransportError("world-celld: stream read failed", lastError);
255
+ }
256
+ //#endregion
111
257
  //#region src/remote/namespaces.ts
112
258
  const RUNS = {
113
259
  methods: [
@@ -121,7 +267,8 @@ const RUNS = {
121
267
  "getCleanupStatus",
122
268
  "scheduleCleanup",
123
269
  "cleanupNow",
124
- "rearmCleanup"
270
+ "rearmCleanup",
271
+ "resolveHookTokenClaim"
125
272
  ],
126
273
  mutating: /* @__PURE__ */ new Set([
127
274
  "applyEvent",
@@ -132,10 +279,8 @@ const RUNS = {
132
279
  };
133
280
  const STREAMS = {
134
281
  methods: [
135
- "writeChunk",
136
282
  "closeStream",
137
- "getChunks",
138
- "getInfo",
283
+ "failStream",
139
284
  "registerStream",
140
285
  "listStreams",
141
286
  "expireRegistry",
@@ -143,14 +288,28 @@ const STREAMS = {
143
288
  "expireStream"
144
289
  ],
145
290
  mutating: /* @__PURE__ */ new Set([
146
- "writeChunk",
147
291
  "closeStream",
292
+ "failStream",
148
293
  "registerStream",
149
294
  "expireRegistry",
150
295
  "finalizeRegistry",
151
296
  "expireStream"
152
297
  ])
153
298
  };
299
+ function makeStreamNamespace(transport) {
300
+ return {
301
+ idFromName(name) {
302
+ return { toString: () => name };
303
+ },
304
+ get(id) {
305
+ const name = id.toString();
306
+ const control = makeStub(transport, "streams", name, STREAMS);
307
+ control.writeChunks = (runId, chunks) => writeStreamChunks(transport, name, runId, chunks);
308
+ control.readChunks = (request, signal) => readStreamChunks(transport, name, request, signal);
309
+ return control;
310
+ }
311
+ };
312
+ }
154
313
  const QUEUE = {
155
314
  methods: [
156
315
  "enqueue",
@@ -184,41 +343,76 @@ function makeNamespace(transport, binding, spec) {
184
343
  }
185
344
  };
186
345
  }
187
- /** The IndexDO is a single well-known cell. */
188
- const INDEX_CELL_NAME = "index";
189
- function makeIndexNamespace(transport) {
190
- const call = (method, args, idempotent) => callDO(transport, "index", INDEX_CELL_NAME, method, args, { idempotent });
191
- return {
192
- get: (key) => call("get", [key], true),
193
- put: (key, value) => call("put", [key, value], false),
194
- delete: (key) => call("delete", [key], false),
195
- putOwned: (runId, key, value) => call("putOwned", [
196
- runId,
197
- key,
198
- value
199
- ], false),
200
- expireRun: (request) => call("expireRun", [request], false),
201
- list: (options) => call("list", [options], true),
202
- reserveHookToken: (token, owner) => call("reserveHookToken", [token, owner], false),
203
- finalizeHookIndexes: (token, hookId, serializedHook, owner) => call("finalizeHookIndexes", [
204
- token,
205
- hookId,
206
- serializedHook,
207
- owner
208
- ], false),
209
- releaseHookToken: (token, owner) => call("releaseHookToken", [token, owner], false),
210
- deleteHookIndexes: (token, hookId, owner) => call("deleteHookIndexes", [
211
- token,
212
- hookId,
213
- owner
214
- ], false)
215
- };
216
- }
346
+ const RUN_CATALOG = {
347
+ methods: [
348
+ "upsertRun",
349
+ "list",
350
+ "expireRun"
351
+ ],
352
+ mutating: /* @__PURE__ */ new Set()
353
+ };
354
+ const RUN_FENCES = {
355
+ methods: [
356
+ "getStatus",
357
+ "fenceTerminal",
358
+ "fenceExpired"
359
+ ],
360
+ mutating: /* @__PURE__ */ new Set()
361
+ };
362
+ const HOOK_TOKENS = {
363
+ methods: [
364
+ "get",
365
+ "reserve",
366
+ "finalize",
367
+ "releaseClaim",
368
+ "releaseBatch"
369
+ ],
370
+ mutating: /* @__PURE__ */ new Set()
371
+ };
372
+ const HOOK_IDS = {
373
+ methods: [
374
+ "get",
375
+ "reserve",
376
+ "publish",
377
+ "releaseClaim",
378
+ "releaseBatch"
379
+ ],
380
+ mutating: /* @__PURE__ */ new Set()
381
+ };
217
382
  function createRemoteEnv(transport) {
383
+ const runCatalog = makeNamespace(transport, "run-catalog", RUN_CATALOG);
384
+ const runFences = makeNamespace(transport, "run-fences", RUN_FENCES);
385
+ const hookTokens = makeNamespace(transport, "hook-tokens", HOOK_TOKENS);
386
+ const hookIds = makeNamespace(transport, "hook-ids", HOOK_IDS);
387
+ const workflowIndex = createWorkflowIndex({
388
+ runCatalog,
389
+ runFences,
390
+ hookTokens,
391
+ hookIds
392
+ });
218
393
  return {
219
394
  WORKFLOW_DB: makeNamespace(transport, "runs", RUNS),
220
- WORKFLOW_STREAMS: makeNamespace(transport, "streams", STREAMS),
221
- WORKFLOW_INDEX: makeIndexNamespace(transport),
395
+ WORKFLOW_STREAMS: makeStreamNamespace(transport),
396
+ WORKFLOW_INDEX: {
397
+ ...workflowIndex,
398
+ commitRun: (run, serializedMetadata) => callFleetRoute(transport, "/v1/index/runs/commit", [run, serializedMetadata], { idempotent: true }),
399
+ listRuns: (options) => callFleetRoute(transport, "/v1/index/runs/list", [options], { idempotent: true }),
400
+ expireRun: (request) => callFleetRoute(transport, "/v1/index/runs/expire", [request], { idempotent: true }),
401
+ reserveHook: (token, owner) => callFleetRoute(transport, "/v1/index/hooks/reserve", [token, owner], { idempotent: true }),
402
+ finalizeHookIndexes: (token, hookId, serializedHook, owner, reservation) => callFleetRoute(transport, "/v1/index/hooks/finalize", [
403
+ token,
404
+ hookId,
405
+ serializedHook,
406
+ owner,
407
+ reservation
408
+ ], { idempotent: true }),
409
+ releaseHookReservation: (token, owner, reservation) => callFleetRoute(transport, "/v1/index/hooks/release-reservation", [
410
+ token,
411
+ owner,
412
+ reservation
413
+ ], { idempotent: true }),
414
+ releaseHookIndexes: (request) => callFleetRoute(transport, "/v1/index/hooks/release", [request], { idempotent: true })
415
+ },
222
416
  WORKFLOW_QUEUE: makeNamespace(transport, "queue", QUEUE)
223
417
  };
224
418
  }
@@ -366,6 +560,9 @@ function createTestPump(config) {
366
560
  signal: AbortSignal.timeout(httpTimeoutMs)
367
561
  });
368
562
  if (response.ok) {
563
+ try {
564
+ await response.body?.cancel();
565
+ } catch {}
369
566
  release(envelope);
370
567
  return;
371
568
  }
@@ -499,7 +696,7 @@ function createQueue(config) {
499
696
  status: 503,
500
697
  headers: { "Retry-After": String(result.timeoutSeconds) }
501
698
  });
502
- return Response.json({ ok: true });
699
+ return new Response(null, { status: 204 });
503
700
  } catch (error) {
504
701
  if (isPermanentError(error)) {
505
702
  debug("[world-celld queue handler] permanent error:", {
@@ -580,6 +777,7 @@ function throwOutcomeError(outcome, runId, data) {
580
777
  case "HOOK_NOT_FOUND": throw new HookNotFoundError(data.correlationId ?? runId);
581
778
  case "WAIT_NOT_FOUND": throw new WorkflowWorldError(outcome.message, { status: 404 });
582
779
  case "ENTITY_CONFLICT": throw new EntityConflictError(outcome.message);
780
+ case "HOOK_CLAIM_CANCELLED": throw new WorkflowWorldError(outcome.message, { status: 503 });
583
781
  case "RUN_EXPIRED": throw new RunExpiredError(outcome.message);
584
782
  case "TOO_EARLY": throw new TooEarlyError(outcome.message, { retryAfter: outcome.retryAfterSeconds });
585
783
  case "RUN_NOT_SUPPORTED": throw new RunNotSupportedError(outcome.runSpecVersion ?? 0, SPEC_VERSION_CURRENT);
@@ -593,6 +791,19 @@ function unwrapRead(outcome) {
593
791
  if (!outcome.ok) throw new RunExpiredError(outcome.message);
594
792
  return outcome.value;
595
793
  }
794
+ /** Bound cross-run fanout so large list pages cannot create an RPC burst. */
795
+ const RUN_LIST_CONCURRENCY = 8;
796
+ /**
797
+ * Run statuses only move pending -> running -> terminal, and terminal statuses
798
+ * are immutable. An older index value may therefore safely exclude a filter
799
+ * only when it is already terminal, or when the caller asks for pending and
800
+ * the index has advanced beyond pending. Earlier non-terminal metadata cannot
801
+ * exclude a later status because a post-commit index write may need replay.
802
+ */
803
+ function indexStatusExcludes(indexed, requested) {
804
+ if (requested === void 0 || indexed === requested) return false;
805
+ return isTerminalWorkflowRunStatus(indexed) || requested === "pending";
806
+ }
596
807
  function createStorage(config) {
597
808
  const { env } = config;
598
809
  const cleanup = {
@@ -620,7 +831,7 @@ function createStorage(config) {
620
831
  let scanCursor = params?.pagination?.cursor;
621
832
  let exhausted = false;
622
833
  while (matches.length <= limit && !exhausted) {
623
- const kvList = await env.WORKFLOW_INDEX.list({
834
+ const kvList = await env.WORKFLOW_INDEX.listRuns({
624
835
  prefix,
625
836
  limit: Math.min(1e3, Math.max(50, limit)),
626
837
  cursor: scanCursor,
@@ -630,22 +841,32 @@ function createStorage(config) {
630
841
  exhausted = true;
631
842
  break;
632
843
  }
844
+ const candidates = [];
633
845
  for (const key of kvList.keys) {
634
- const meta = await env.WORKFLOW_INDEX.get(key.name);
635
- if (!meta) continue;
636
- const { runId } = JSON.parse(meta);
637
- try {
638
- const run = await runsGet(runId, { resolveData: params?.resolveData });
639
- if (!params?.status || run.status === params.status) {
640
- matches.push({
641
- key: key.name,
846
+ const metadata = JSON.parse(key.value);
847
+ if (indexStatusExcludes(metadata.status, params?.status)) continue;
848
+ candidates.push({
849
+ key: key.name,
850
+ metadata
851
+ });
852
+ }
853
+ for (let offset = 0; offset < candidates.length && matches.length <= limit;) {
854
+ const needed = limit + 1 - matches.length;
855
+ const batch = candidates.slice(offset, offset + Math.min(RUN_LIST_CONCURRENCY, needed));
856
+ const resolved = await Promise.all(batch.map(async ({ key, metadata }) => {
857
+ try {
858
+ const run = await runsGet(metadata.runId, { resolveData: params?.resolveData });
859
+ return !params?.status || run.status === params.status ? {
860
+ key,
642
861
  run
643
- });
644
- if (matches.length > limit) break;
862
+ } : null;
863
+ } catch (error) {
864
+ if (!WorkflowRunNotFoundError.is(error) && !RunExpiredError.is(error)) throw error;
865
+ return null;
645
866
  }
646
- } catch (error) {
647
- if (!WorkflowRunNotFoundError.is(error) && !RunExpiredError.is(error)) throw error;
648
- }
867
+ }));
868
+ for (const match of resolved) if (match) matches.push(match);
869
+ offset += batch.length;
649
870
  }
650
871
  exhausted = kvList.list_complete;
651
872
  scanCursor = kvList.cursor ?? kvList.keys.at(-1)?.name;
@@ -667,14 +888,21 @@ function createStorage(config) {
667
888
  else effectiveRunId = runId;
668
889
  const stub = getRunDO(effectiveRunId);
669
890
  let tokenHolder;
670
- let hookReservation;
891
+ let hookAdmission;
671
892
  if (data.eventType === "hook_created") {
672
- hookReservation = {
893
+ const owner = {
673
894
  runId: effectiveRunId,
674
895
  hookId: data.correlationId
675
896
  };
676
- const reservation = await env.WORKFLOW_INDEX.reserveHookToken(data.eventData.token, hookReservation);
677
- tokenHolder = reservation.claimed ? null : reservation.holder;
897
+ const admission = await env.WORKFLOW_INDEX.reserveHook(data.eventData.token, owner);
898
+ if (admission.admitted) {
899
+ tokenHolder = null;
900
+ hookAdmission = {
901
+ owner,
902
+ reservation: admission.reservation,
903
+ token: data.eventData.token
904
+ };
905
+ } else tokenHolder = admission.holder;
678
906
  }
679
907
  let outcome;
680
908
  try {
@@ -683,14 +911,27 @@ function createStorage(config) {
683
911
  data,
684
912
  params,
685
913
  tokenHolder,
914
+ hookClaimId: hookAdmission?.reservation.claimId,
686
915
  cleanup
687
916
  });
688
917
  } catch (error) {
689
- if (hookReservation && data.eventType === "hook_created") await env.WORKFLOW_INDEX.releaseHookToken(data.eventData.token, hookReservation);
918
+ if (hookAdmission) {
919
+ let resolution;
920
+ try {
921
+ resolution = await stub.resolveHookTokenClaim({
922
+ hookId: hookAdmission.owner.hookId,
923
+ token: hookAdmission.token,
924
+ claimId: hookAdmission.reservation.claimId
925
+ });
926
+ } catch {
927
+ throw error;
928
+ }
929
+ if (!resolution.committed) await env.WORKFLOW_INDEX.releaseHookReservation(hookAdmission.token, hookAdmission.owner, hookAdmission.reservation);
930
+ }
690
931
  throw error;
691
932
  }
692
933
  if (!outcome.ok) {
693
- if (hookReservation && data.eventType === "hook_created") await env.WORKFLOW_INDEX.releaseHookToken(data.eventData.token, hookReservation);
934
+ if (hookAdmission) await env.WORKFLOW_INDEX.releaseHookReservation(hookAdmission.token, hookAdmission.owner, hookAdmission.reservation);
694
935
  throwOutcomeError(outcome, effectiveRunId, data);
695
936
  }
696
937
  if (outcome.run) {
@@ -699,24 +940,18 @@ function createStorage(config) {
699
940
  createdAt: outcome.run.createdAt.toISOString(),
700
941
  status: outcome.run.status
701
942
  });
702
- await env.WORKFLOW_INDEX.putOwned(effectiveRunId, workflowRunIndexKey(outcome.run), meta);
703
- await env.WORKFLOW_INDEX.putOwned(effectiveRunId, globalRunIndexKey(outcome.run), meta);
943
+ await env.WORKFLOW_INDEX.commitRun(outcome.run, meta);
704
944
  }
705
945
  if (outcome.hookToIndex) {
706
946
  const serialized = stringify(outcome.hookToIndex);
707
- await env.WORKFLOW_INDEX.finalizeHookIndexes(outcome.hookToIndex.token, outcome.hookToIndex.hookId, serialized, hookOwner(outcome.hookToIndex));
708
- } else if (hookReservation && data.eventType === "hook_created") await env.WORKFLOW_INDEX.releaseHookToken(data.eventData.token, hookReservation);
709
- for (const released of outcome.releasedHooks) await env.WORKFLOW_INDEX.deleteHookIndexes(released.token, released.hookId, {
947
+ await env.WORKFLOW_INDEX.finalizeHookIndexes(outcome.hookToIndex.token, outcome.hookToIndex.hookId, serialized, hookOwner(outcome.hookToIndex), hookAdmission?.reservation);
948
+ } else if (hookAdmission) await env.WORKFLOW_INDEX.releaseHookReservation(hookAdmission.token, hookAdmission.owner, hookAdmission.reservation);
949
+ const terminal = Boolean(outcome.run && isTerminalWorkflowRunStatus(outcome.run.status));
950
+ if (outcome.releasedHooks.length > 0) await env.WORKFLOW_INDEX.releaseHookIndexes({
710
951
  runId: effectiveRunId,
711
- hookId: released.hookId
952
+ hooks: outcome.releasedHooks,
953
+ terminal
712
954
  });
713
- if (outcome.event?.correlationId) {
714
- const correlationKey = correlationIndexKey(outcome.event.correlationId, outcome.event.createdAt, outcome.event.eventId, effectiveRunId);
715
- await env.WORKFLOW_INDEX.putOwned(effectiveRunId, correlationKey, JSON.stringify({
716
- runId: effectiveRunId,
717
- eventId: outcome.event.eventId
718
- }));
719
- }
720
955
  const eventPage = outcome.events === void 0 ? {} : {
721
956
  events: outcome.events,
722
957
  cursor: outcome.cursor ?? null,
@@ -805,12 +1040,12 @@ function createStorage(config) {
805
1040
  },
806
1041
  hooks: {
807
1042
  async get(hookId, params) {
808
- const raw = await env.WORKFLOW_INDEX.get(`hookid:${hookId}`);
1043
+ const raw = await env.WORKFLOW_INDEX.getHookById(hookId);
809
1044
  if (!raw) throw new HookNotFoundError(hookId);
810
1045
  return filterHookData(parseHook(parse(raw)), params?.resolveData ?? "all");
811
1046
  },
812
1047
  async getByToken(token, params) {
813
- const raw = await env.WORKFLOW_INDEX.get(`hook:${token}`);
1048
+ const raw = await env.WORKFLOW_INDEX.getHookByToken(token);
814
1049
  if (!raw) throw new HookNotFoundError(token);
815
1050
  return filterHookData(parseHook(parse(raw)), params?.resolveData ?? "all");
816
1051
  },
@@ -834,19 +1069,27 @@ function createStorage(config) {
834
1069
  }
835
1070
  //#endregion
836
1071
  //#region src/streamer.ts
837
- /** Default poll interval while waiting for new chunks on a live stream. */
838
- const READ_POLL_MS = 100;
839
- /** Chunks fetched per DO round-trip while reading. */
840
- const READ_BATCH_SIZE = 32;
841
- /** Default / maximum page sizes for getStreamChunks. */
1072
+ const DEFAULT_LONG_POLL_MS = MAX_STREAM_LONG_POLL_MS;
842
1073
  const DEFAULT_CHUNK_PAGE_SIZE = 32;
843
1074
  const MAX_CHUNK_PAGE_SIZE = 32;
1075
+ const REGISTERED_STREAM_CACHE_SIZE = 4096;
844
1076
  function toBytes(chunk) {
845
1077
  return typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
846
1078
  }
1079
+ function throwTerminal(result) {
1080
+ if (result.state === "expired") throw new Error("Stream has expired");
1081
+ if (result.state === "errored") {
1082
+ const error = new Error(result.error?.message ?? "Stream failed");
1083
+ error.name = result.error?.name ?? "Error";
1084
+ throw error;
1085
+ }
1086
+ }
847
1087
  function createStreamer(config) {
848
1088
  const { env } = config;
849
- const readPollMs = config.readPollMs ?? READ_POLL_MS;
1089
+ const streamLongPollMs = config.streamLongPollMs ?? DEFAULT_LONG_POLL_MS;
1090
+ if (!Number.isSafeInteger(streamLongPollMs) || streamLongPollMs < 1 || streamLongPollMs > 2e4) throw new Error(`streamLongPollMs must be between 1 and ${MAX_STREAM_LONG_POLL_MS}`);
1091
+ const streamFlushIntervalMs = config.streamFlushIntervalMs ?? 0;
1092
+ if (!Number.isSafeInteger(streamFlushIntervalMs) || streamFlushIntervalMs < 0) throw new Error("streamFlushIntervalMs must be a non-negative safe integer");
850
1093
  const getStreamDO = (streamName) => {
851
1094
  const id = env.WORKFLOW_STREAMS.idFromName(`stream:${streamName}`);
852
1095
  return env.WORKFLOW_STREAMS.get(id);
@@ -855,91 +1098,157 @@ function createStreamer(config) {
855
1098
  const id = env.WORKFLOW_STREAMS.idFromName(`run-streams:${runId}`);
856
1099
  return env.WORKFLOW_STREAMS.get(id);
857
1100
  };
858
- /** Per-isolate cache so each (runId, stream) pair registers only once. */
1101
+ /** Bounded per-isolate cache; eviction only repeats idempotent registration. */
859
1102
  const registeredStreams = /* @__PURE__ */ new Set();
860
1103
  async function registerStreamForRun(name, runId) {
861
1104
  const cacheKey = `${runId}\0${name}`;
862
1105
  if (registeredStreams.has(cacheKey)) return;
863
1106
  await getRunRegistryDO(runId).registerStream(runId, name);
1107
+ if (registeredStreams.size >= REGISTERED_STREAM_CACHE_SIZE) {
1108
+ const oldest = registeredStreams.values().next().value;
1109
+ if (oldest !== void 0) registeredStreams.delete(oldest);
1110
+ }
864
1111
  registeredStreams.add(cacheKey);
865
1112
  }
866
- const writeToStream = async (name, runId, chunk) => {
1113
+ const writeChunksToStream = async (name, runId, input) => {
1114
+ if (input.length === 0) return;
867
1115
  const resolvedRunId = await runId;
1116
+ const chunks = input.map(toBytes);
1117
+ for (const chunk of chunks) if (chunk.byteLength > 1048576) throw new Error(`Stream chunk exceeds ${MAX_STREAM_CHUNK_BYTES} bytes`);
868
1118
  await registerStreamForRun(name, resolvedRunId);
869
- await getStreamDO(name).writeChunk(resolvedRunId, toBytes(chunk));
1119
+ let batch = [];
1120
+ let batchBytes = 0;
1121
+ for (const chunk of chunks) {
1122
+ if (batch.length > 0 && (batch.length === 32 || batchBytes + chunk.byteLength > 8388608)) {
1123
+ await getStreamDO(name).writeChunks(resolvedRunId, batch);
1124
+ batch = [];
1125
+ batchBytes = 0;
1126
+ }
1127
+ batch.push(chunk);
1128
+ batchBytes += chunk.byteLength;
1129
+ }
1130
+ if (batch.length > 0) await getStreamDO(name).writeChunks(resolvedRunId, batch);
870
1131
  };
1132
+ const writeToStream = (name, runId, chunk) => writeChunksToStream(name, runId, [chunk]);
871
1133
  const closeStream = async (name, runId) => {
872
1134
  const resolvedRunId = await runId;
873
1135
  await registerStreamForRun(name, resolvedRunId);
874
1136
  await getStreamDO(name).closeStream(resolvedRunId);
875
1137
  };
876
- const readFromStream = async (name, startIndex = 0) => {
1138
+ const errorStream = async (name, runId, error) => {
1139
+ const resolvedRunId = await runId;
1140
+ await registerStreamForRun(name, resolvedRunId);
1141
+ await getStreamDO(name).failStream(resolvedRunId, normalizeStreamError(error));
1142
+ };
1143
+ const readFromStream = async (name, runId, startIndex = 0) => {
877
1144
  const stub = getStreamDO(name);
878
1145
  let nextIndex;
879
1146
  if (startIndex < 0) {
880
- const info = await stub.getInfo();
1147
+ const info = await stub.readChunks({
1148
+ runId,
1149
+ startIndex: 0,
1150
+ maxChunks: 0,
1151
+ maxBytes: MAX_STREAM_READ_BYTES,
1152
+ waitMs: 0
1153
+ });
1154
+ throwTerminal(info);
881
1155
  nextIndex = Math.max(0, info.tailIndex + 1 + startIndex);
882
1156
  } else nextIndex = startIndex;
883
1157
  let cancelled = false;
1158
+ const abortController = new AbortController();
884
1159
  return new ReadableStream({
885
1160
  async pull(controller) {
886
- while (true) {
1161
+ for (;;) {
887
1162
  if (cancelled) return;
888
- const { chunks, done } = await stub.getChunks({
889
- startIndex: nextIndex,
890
- limit: READ_BATCH_SIZE
891
- });
892
- if (chunks.length > 0) {
893
- for (const chunk of chunks) controller.enqueue(chunk);
894
- nextIndex += chunks.length;
1163
+ let result;
1164
+ try {
1165
+ result = await stub.readChunks({
1166
+ runId,
1167
+ startIndex: nextIndex,
1168
+ maxChunks: 32,
1169
+ maxBytes: MAX_STREAM_READ_BYTES,
1170
+ waitMs: streamLongPollMs
1171
+ }, abortController.signal);
1172
+ } catch (error) {
1173
+ if (cancelled || abortController.signal.aborted) return;
1174
+ throw error;
1175
+ }
1176
+ if (result.chunks.length > 0) {
1177
+ for (const chunk of result.chunks) controller.enqueue(chunk);
1178
+ nextIndex += result.chunks.length;
895
1179
  return;
896
1180
  }
897
- if (done) {
1181
+ throwTerminal(result);
1182
+ if (result.state === "closed") {
898
1183
  controller.close();
899
1184
  return;
900
1185
  }
901
- await new Promise((resolve) => setTimeout(resolve, readPollMs));
902
1186
  }
903
1187
  },
904
1188
  cancel() {
905
1189
  cancelled = true;
1190
+ abortController.abort(new DOMException("Stream reader cancelled", "AbortError"));
906
1191
  }
907
1192
  });
908
1193
  };
909
1194
  const listStreamsByRunId = (runId) => getRunRegistryDO(runId).listStreams();
910
- const getStreamChunks = async (name, _runId, options) => {
911
- const limit = Math.min(options?.limit ?? DEFAULT_CHUNK_PAGE_SIZE, MAX_CHUNK_PAGE_SIZE);
1195
+ const getStreamChunks = async (name, runId, options) => {
1196
+ const requestedLimit = options?.limit ?? DEFAULT_CHUNK_PAGE_SIZE;
1197
+ if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) throw new Error("Stream chunk limit must be a positive safe integer");
1198
+ const limit = Math.min(requestedLimit, MAX_CHUNK_PAGE_SIZE);
912
1199
  const startIndex = options?.cursor ? Number.parseInt(options.cursor, 10) : 0;
913
1200
  if (Number.isNaN(startIndex) || startIndex < 0) throw new Error(`Invalid stream cursor: ${options?.cursor}`);
914
- const result = await getStreamDO(name).getChunks({
1201
+ const result = await getStreamDO(name).readChunks({
1202
+ runId,
915
1203
  startIndex,
916
- limit: limit + 1
1204
+ maxChunks: limit,
1205
+ maxBytes: MAX_STREAM_READ_BYTES,
1206
+ waitMs: 0
917
1207
  });
918
- const hasMore = result.chunks.length > limit;
1208
+ throwTerminal(result);
1209
+ const data = result.chunks.map((chunk, offset) => ({
1210
+ index: startIndex + offset,
1211
+ data: chunk
1212
+ }));
1213
+ const nextIndex = startIndex + data.length;
1214
+ const hasMore = nextIndex <= result.tailIndex;
919
1215
  return {
920
- data: result.chunks.slice(0, limit).map((chunk, offset) => ({
921
- index: startIndex + offset,
922
- data: chunk
923
- })),
924
- cursor: hasMore ? String(startIndex + limit) : null,
1216
+ data,
1217
+ cursor: hasMore ? String(nextIndex) : null,
925
1218
  hasMore,
926
- done: result.done
1219
+ done: result.state === "closed"
1220
+ };
1221
+ };
1222
+ const getStreamInfo = async (name, runId) => {
1223
+ const result = await getStreamDO(name).readChunks({
1224
+ runId,
1225
+ startIndex: 0,
1226
+ maxChunks: 0,
1227
+ maxBytes: MAX_STREAM_READ_BYTES,
1228
+ waitMs: 0
1229
+ });
1230
+ throwTerminal(result);
1231
+ return {
1232
+ tailIndex: result.tailIndex,
1233
+ done: result.state === "closed"
927
1234
  };
928
1235
  };
929
- const getStreamInfo = (name, _runId) => getStreamDO(name).getInfo();
930
1236
  return {
1237
+ streamFlushIntervalMs,
931
1238
  streams: {
932
1239
  write: (runId, name, chunk) => writeToStream(name, runId, chunk),
1240
+ writeMulti: (runId, name, chunks) => writeChunksToStream(name, runId, chunks),
933
1241
  close: (runId, name) => closeStream(name, runId),
934
- get: (runId, name, startIndex) => {
935
- return readFromStream(name, startIndex);
936
- },
1242
+ fail: (runId, name, error) => errorStream(name, runId, error),
1243
+ get: (runId, name, startIndex) => readFromStream(name, runId, startIndex),
937
1244
  list: listStreamsByRunId,
938
1245
  getChunks: (runId, name, options) => getStreamChunks(name, runId, options),
939
1246
  getInfo: (runId, name) => getStreamInfo(name, runId)
940
1247
  },
941
1248
  writeToStream,
1249
+ writeChunksToStream,
942
1250
  closeStream,
1251
+ errorStream,
943
1252
  readFromStream,
944
1253
  listStreamsByRunId,
945
1254
  getStreamChunks,
@@ -988,7 +1297,8 @@ function createCelldWorld(config) {
988
1297
  });
989
1298
  const streamer = createStreamer({
990
1299
  env: { WORKFLOW_STREAMS: env.WORKFLOW_STREAMS },
991
- readPollMs: resolved.readPollMs
1300
+ streamLongPollMs: resolved.streamLongPollMs,
1301
+ streamFlushIntervalMs: resolved.streamFlushIntervalMs
992
1302
  });
993
1303
  const runStub = (runId) => env.WORKFLOW_DB.get(env.WORKFLOW_DB.idFromName(runId));
994
1304
  const retention = {
@@ -1015,6 +1325,6 @@ function createCelldWorld(config) {
1015
1325
  };
1016
1326
  }
1017
1327
  //#endregion
1018
- export { createCelldWorld, createCelldWorld as createWorld };
1328
+ export { MAX_STREAM_BATCH_BYTES, MAX_STREAM_CHUNK_BYTES, MAX_STREAM_ERROR_BYTES, MAX_STREAM_LONG_POLL_MS, MAX_STREAM_READ_BYTES, MAX_STREAM_READ_CHUNKS, MAX_STREAM_WRITE_CHUNKS, createCelldWorld, createCelldWorld as createWorld };
1019
1329
 
1020
1330
  //# sourceMappingURL=index.js.map