@ewhauser/world-celld 0.3.0 → 0.3.2

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