@ewhauser/world-celld 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,15 +1,10 @@
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";
1
+ import { A as MAX_STREAM_BATCH_BYTES, B as decodeStreamWriteResult, C as boundedIntegerOption, F as MAX_STREAM_READ_CHUNKS, H as encodeStreamWriteBatch, I as MAX_STREAM_WRITE_CHUNKS, L as STREAM_BATCH_CONTENT_TYPE, M as MAX_STREAM_ERROR_BYTES, N as MAX_STREAM_LONG_POLL_MS, O as queueDelayDeadline, P as MAX_STREAM_READ_BYTES, R as decodeStreamReadResult, W as normalizeStreamError, b as MAX_QUEUE_DELIVERY_TIMEOUT_MS, d as rpcParse, f as rpcStringify, h as createWorkflowIndex, j as MAX_STREAM_CHUNK_BYTES, k as strictIntegerSetting, m as stringify, p as parse, r as nativeQueueDelaySeconds, u as compact, w as checkedQueueTimestampAdd, x as MAX_QUEUE_DERIVED_DEADLINE_HEADROOM_MS, y as MAX_FLEET_RPC_TIMEOUT_MS } from "./queue-protocol-CmJNMH_5.js";
2
+ import { CreateEventSchema, EventSchema, HookSchema, MessageId, QueuePayloadSchema, SPEC_VERSION_CURRENT, StepSchema, WaitSchema, WorkflowRunSchema, isTerminalWorkflowRunStatus, parseQueueName } from "@workflow/world";
3
3
  import { EntityConflictError, HookNotFoundError, RunExpiredError, RunNotSupportedError, TooEarlyError, WorkflowRunNotFoundError, WorkflowWorldError } from "@workflow/errors";
4
- import { setTimeout as setTimeout$1 } from "node:timers/promises";
5
4
  import { monotonicFactory } from "ulid";
6
5
  //#region src/config.ts
7
6
  function resolveConfig(config) {
8
- const queueShards = config?.queueShards ?? 1;
9
- if (!Number.isSafeInteger(queueShards) || queueShards < 1) throw new Error("world-celld: queueShards must be a positive safe integer");
10
- const retentionRaw = config?.runRetentionMs ?? process.env.CELLD_RUN_RETENTION_MS ?? 0;
11
- const runRetentionMs = typeof retentionRaw === "number" ? retentionRaw : Number.parseInt(retentionRaw, 10);
12
- if (!Number.isSafeInteger(runRetentionMs) || runRetentionMs < 0) throw new Error("world-celld: runRetentionMs must be a non-negative safe integer");
7
+ const runRetentionMs = config?.runRetentionMs !== void 0 ? boundedIntegerOption("world-celld: runRetentionMs", config.runRetentionMs, 0, 0) : strictIntegerSetting("world-celld: runRetentionMs", process.env.CELLD_RUN_RETENTION_MS, 0, 0);
13
8
  const rpcTimeoutMs = config?.rpcTimeoutMs ?? 3e4;
14
9
  if (!Number.isSafeInteger(rpcTimeoutMs) || rpcTimeoutMs < 1 || rpcTimeoutMs > 3e5) throw new Error(`world-celld: rpcTimeoutMs must be between 1 and ${MAX_FLEET_RPC_TIMEOUT_MS}`);
15
10
  const streamLongPollMs = config?.streamLongPollMs ?? Math.min(2e4, Math.max(1, rpcTimeoutMs - 1e3));
@@ -23,7 +18,6 @@ function resolveConfig(config) {
23
18
  env: config?.env,
24
19
  deploymentId: config?.deploymentId ?? process.env.CELLD_DEPLOYMENT_ID ?? "celld-default",
25
20
  baseUrl: config?.baseUrl,
26
- queueShards,
27
21
  runRetentionMs,
28
22
  streamLongPollMs,
29
23
  streamFlushIntervalMs,
@@ -61,10 +55,15 @@ const RETRYABLE_STATUSES$1 = /* @__PURE__ */ new Set([
61
55
  503,
62
56
  504
63
57
  ]);
64
- const DEFAULT_TIMEOUT_MS$1 = 3e4;
58
+ const DEFAULT_TIMEOUT_MS = 3e4;
65
59
  function delayMs$1(ms) {
66
60
  return new Promise((resolve) => setTimeout(resolve, ms));
67
61
  }
62
+ function resolveFleetTimeoutMs(transport) {
63
+ const timeoutMs = transport.timeoutMs ?? DEFAULT_TIMEOUT_MS;
64
+ 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}`);
65
+ return timeoutMs;
66
+ }
68
67
  /**
69
68
  * Invoke one DO method through the worker router.
70
69
  *
@@ -81,8 +80,7 @@ async function callFleetRoute(transport, path, args, opts) {
81
80
  const doFetch = transport.fetchImpl ?? fetch;
82
81
  const url = `${transport.fleetUrl.replace(/\/$/, "")}${path}`;
83
82
  const attempts = opts?.idempotent ? 3 : 1;
84
- const timeoutMs = transport.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
85
- 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}`);
83
+ const timeoutMs = resolveFleetTimeoutMs(transport);
86
84
  let lastError;
87
85
  for (let attempt = 1; attempt <= attempts; attempt++) {
88
86
  if (attempt > 1) await delayMs$1(100 * attempt + Math.random() * 200);
@@ -131,15 +129,27 @@ const RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
131
129
  504
132
130
  ]);
133
131
  const READ_ATTEMPTS = 3;
134
- const DEFAULT_TIMEOUT_MS = 3e4;
135
132
  const MAX_WRITE_RESPONSE_BYTES = 64;
136
133
  const MAX_READ_RESPONSE_BYTES = MAX_STREAM_READ_BYTES + 65536;
137
134
  const MAX_ERROR_RESPONSE_BYTES = 65536;
138
- function delayMs(ms) {
139
- return new Promise((resolve) => setTimeout(resolve, ms));
135
+ function delayMs(ms, signal) {
136
+ if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
137
+ if (signal.aborted) return Promise.reject(aborted(signal));
138
+ return new Promise((resolve, reject) => {
139
+ const timer = setTimeout(() => {
140
+ signal.removeEventListener("abort", onAbort);
141
+ resolve();
142
+ }, ms);
143
+ const onAbort = () => {
144
+ clearTimeout(timer);
145
+ signal.removeEventListener("abort", onAbort);
146
+ reject(aborted(signal));
147
+ };
148
+ signal.addEventListener("abort", onAbort, { once: true });
149
+ });
140
150
  }
141
151
  function requestSignal(timeoutMs, signal) {
142
- const timeout = AbortSignal.timeout(Math.max(1, timeoutMs));
152
+ const timeout = AbortSignal.timeout(timeoutMs);
143
153
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
144
154
  }
145
155
  function aborted(signal) {
@@ -190,6 +200,7 @@ function streamUrl(transport, name) {
190
200
  return `${transport.fleetUrl.replace(/\/$/, "")}/v1/streams/${encodeURIComponent(name)}/chunks`;
191
201
  }
192
202
  async function writeStreamChunks(transport, name, runId, chunks) {
203
+ const timeoutMs = resolveFleetTimeoutMs(transport);
193
204
  const body = encodeStreamWriteBatch(chunks);
194
205
  if (body.byteLength > 8389632) throw new Error("world-celld: encoded stream batch exceeds configured limit");
195
206
  const url = new URL(streamUrl(transport, name));
@@ -204,7 +215,7 @@ async function writeStreamChunks(transport, name, runId, chunks) {
204
215
  authorization: `Bearer ${transport.secret}`
205
216
  },
206
217
  body,
207
- signal: requestSignal(transport.timeoutMs ?? DEFAULT_TIMEOUT_MS)
218
+ signal: requestSignal(timeoutMs)
208
219
  });
209
220
  } catch (error) {
210
221
  throw new FleetTransportError(`world-celld: fleet unreachable at ${url.href}`, error);
@@ -218,6 +229,7 @@ async function writeStreamChunks(transport, name, runId, chunks) {
218
229
  }
219
230
  }
220
231
  async function readStreamChunks(transport, name, request, signal) {
232
+ const timeoutMs = resolveFleetTimeoutMs(transport);
221
233
  const url = new URL(streamUrl(transport, name));
222
234
  url.searchParams.set("runId", request.runId);
223
235
  url.searchParams.set("startIndex", String(request.startIndex));
@@ -228,27 +240,38 @@ async function readStreamChunks(transport, name, request, signal) {
228
240
  let lastError;
229
241
  for (let attempt = 1; attempt <= READ_ATTEMPTS; attempt++) {
230
242
  if (signal?.aborted) throw aborted(signal);
231
- if (attempt > 1) await delayMs(100 * attempt + Math.random() * 200);
243
+ if (attempt > 1) await delayMs(100 * attempt + Math.random() * 200, signal);
232
244
  let response;
233
245
  try {
234
246
  response = await doFetch(url, {
235
247
  method: "GET",
236
248
  headers: { authorization: `Bearer ${transport.secret}` },
237
- signal: requestSignal(transport.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal)
249
+ signal: requestSignal(timeoutMs, signal)
238
250
  });
239
251
  } catch (error) {
240
252
  if (signal?.aborted) throw aborted(signal);
241
253
  lastError = new FleetTransportError(`world-celld: fleet unreachable at ${url.href}`, error);
242
254
  continue;
243
255
  }
256
+ if (signal?.aborted) {
257
+ await response.body?.cancel(aborted(signal)).catch(() => void 0);
258
+ throw aborted(signal);
259
+ }
244
260
  if (response.ok) try {
245
261
  return decodeStreamReadResult(await readBoundedResponse(response, MAX_READ_RESPONSE_BYTES));
246
262
  } catch (error) {
247
- lastError = new FleetTransportError(`world-celld: malformed stream response from ${url.href}`, error);
263
+ if (signal?.aborted) throw aborted(signal);
264
+ lastError = error instanceof FleetTransportError ? error : new FleetTransportError(`world-celld: malformed stream response from ${url.href}`, error);
248
265
  if (attempt < READ_ATTEMPTS) continue;
249
266
  throw lastError;
250
267
  }
251
- const error = await errorFromResponse(response);
268
+ let error;
269
+ try {
270
+ error = await errorFromResponse(response);
271
+ } catch (cause) {
272
+ if (signal?.aborted) throw aborted(signal);
273
+ error = cause instanceof FleetTransportError ? cause : new FleetTransportError(`world-celld: malformed stream error response from ${url.href}`, cause);
274
+ }
252
275
  if (RETRYABLE_STATUSES.has(response.status) && attempt < READ_ATTEMPTS) {
253
276
  lastError = error;
254
277
  continue;
@@ -279,7 +302,8 @@ const RUNS = {
279
302
  "applyEvent",
280
303
  "scheduleCleanup",
281
304
  "cleanupNow",
282
- "rearmCleanup"
305
+ "rearmCleanup",
306
+ "resolveHookTokenClaim"
283
307
  ])
284
308
  };
285
309
  const STREAMS = {
@@ -315,26 +339,6 @@ function makeStreamNamespace(transport) {
315
339
  }
316
340
  };
317
341
  }
318
- const QUEUE = {
319
- methods: [
320
- "enqueue",
321
- "stats",
322
- "listDeadLetters",
323
- "redriveDeadLetter",
324
- "purgeDeadLetters",
325
- "rearmAlarm",
326
- "expireRun",
327
- "acknowledgeExpireRun"
328
- ],
329
- mutating: /* @__PURE__ */ new Set([
330
- "enqueue",
331
- "redriveDeadLetter",
332
- "purgeDeadLetters",
333
- "rearmAlarm",
334
- "expireRun",
335
- "acknowledgeExpireRun"
336
- ])
337
- };
338
342
  function makeStub(transport, binding, name, spec) {
339
343
  const stub = {};
340
344
  for (const method of spec.methods) stub[method] = (...args) => callDO(transport, binding, name, method, args, { idempotent: !spec.mutating.has(method) });
@@ -414,7 +418,7 @@ function createRemoteEnv(transport) {
414
418
  ], { idempotent: true }),
415
419
  releaseHookIndexes: (request) => callFleetRoute(transport, "/v1/index/hooks/release", [request], { idempotent: true })
416
420
  },
417
- WORKFLOW_QUEUE: makeNamespace(transport, "queue", QUEUE)
421
+ WORKFLOW_QUEUE: { send: (envelope, options) => callFleetRoute(transport, "/v1/queue/send", [envelope, options], { idempotent: false }) }
418
422
  };
419
423
  }
420
424
  //#endregion
@@ -454,18 +458,30 @@ const debug = createDebugLogger("celld-world");
454
458
  * Vendored from vinnymac/worlds packages/world-cloudflare/src/queue.ts
455
459
  * (Apache-2.0, see NOTICE), modified for celld:
456
460
  *
457
- * - The Cloudflare Queues producer is replaced by an `enqueue` RPC to a
458
- * QueueDO cell (`q:<shard>`), which owns the message lifecycle: scheduling
459
- * via durable alarms, delivery via outbound fetch, retries with capped
460
- * backoff, and a dead-letter table.
461
- * - The consumer-side claim-DO idempotency mechanism is gone: a queue cell is
462
- * a single serialized writer, so idempotencyKey dedup happens inside the
463
- * cell (at enqueue and across the delivery state machine).
461
+ * - Production publishes a small pointer through celld v0.5.0 native Queues.
462
+ * The deployed Queue consumer forwards deliveries to the same HTTP handler
463
+ * used by the in-process test pump.
464
+ * - Run-bearing message bodies live in the fleet object store. This preserves
465
+ * the World's larger payload contract and lets run retention delete payload
466
+ * bytes independently of the broker's fixed retention window.
464
467
  * - `createQueueHandler` has ONE dialect — the x-vqs wire format the test
465
468
  * pump has always used — extended so permanent errors surface as their
466
- * HTTP statuses. QueueDO deliveries and the in-process test pump hit the
469
+ * HTTP statuses. Native Queue deliveries and the in-process test pump hit the
467
470
  * exact same handler path.
468
471
  */
472
+ const MAX_TIMER_DELAY_MS = 2147483647;
473
+ const MAX_TEST_PUMP_BACKOFF_MS = 6e4;
474
+ function delay(milliseconds) {
475
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
476
+ }
477
+ async function delayFor(milliseconds) {
478
+ let remaining = milliseconds;
479
+ while (remaining > MAX_TIMER_DELAY_MS) {
480
+ await delay(MAX_TIMER_DELAY_MS);
481
+ remaining -= MAX_TIMER_DELAY_MS;
482
+ }
483
+ await delay(remaining);
484
+ }
469
485
  /**
470
486
  * HTTP status codes that indicate permanent (non-retryable) failures.
471
487
  * Deliveries with these statuses are dropped immediately instead of retried.
@@ -491,7 +507,7 @@ function computeBackoff(attempt) {
491
507
  }
492
508
  const QUEUE_PATHNAME = "flow";
493
509
  function isTestMode() {
494
- if (process.env.CELLD_QUEUE_MODE === "cells") return false;
510
+ if (process.env.CELLD_QUEUE_MODE === "native") return false;
495
511
  return process.env.VITEST === "true" || process.env.NODE_ENV === "test";
496
512
  }
497
513
  function resolveBaseUrl(config) {
@@ -500,19 +516,6 @@ function resolveBaseUrl(config) {
500
516
  return `http://localhost:${process.env.PORT ?? "3000"}`;
501
517
  }
502
518
  /**
503
- * FNV-1a hash for shard selection. All messages sharing an idempotencyKey
504
- * land on the same cell so dedup stays strictly consistent.
505
- */
506
- function shardFor(key, shards) {
507
- if (shards <= 1) return 0;
508
- let hash = 2166136261;
509
- for (let i = 0; i < key.length; i++) {
510
- hash ^= key.charCodeAt(i);
511
- hash = Math.imul(hash, 16777619);
512
- }
513
- return (hash >>> 0) % shards;
514
- }
515
- /**
516
519
  * In-process test pump. Holds an in-memory FIFO and HTTP-dispatches envelopes
517
520
  * to the user's server at `${baseUrl}/.well-known/workflow/v1/flow`.
518
521
  *
@@ -522,12 +525,12 @@ function shardFor(key, shards) {
522
525
  *
523
526
  * Modified vs upstream: permanent-error statuses (404/409/410/422) from the
524
527
  * handler drop the message immediately instead of burning retry budget —
525
- * matching QueueDO's production semantics.
528
+ * matching the native Queue consumer's production semantics.
526
529
  */
527
530
  function createTestPump(config) {
528
- const httpTimeoutMs = config.httpTimeoutMs ?? 3e5;
529
- const maxAttempts = config.maxAttempts ?? 5;
530
- const baseBackoffMs = config.backoffDelayMs ?? 1e3;
531
+ const httpTimeoutMs = boundedIntegerOption("world-celld test pump httpTimeoutMs", config.httpTimeoutMs, 3e5, 1, MAX_QUEUE_DELIVERY_TIMEOUT_MS);
532
+ const maxAttempts = boundedIntegerOption("world-celld test pump maxAttempts", config.maxAttempts, 5, 1);
533
+ const baseBackoffMs = boundedIntegerOption("world-celld test pump backoffDelayMs", config.backoffDelayMs, 1e3, 0, MAX_TEST_PUMP_BACKOFF_MS);
531
534
  const queues = { flow: [] };
532
535
  const wakers = { flow: [] };
533
536
  /** Inflight messageIds by idempotencyKey (world-local queue.js semantics). */
@@ -576,8 +579,10 @@ function createTestPump(config) {
576
579
  parsed = null;
577
580
  }
578
581
  const timeoutSeconds = parsed?.timeoutSeconds;
579
- if (typeof timeoutSeconds === "number") {
580
- setTimeout$1(timeoutSeconds * 1e3).then(() => enqueue(pathname, envelope));
582
+ const now = Date.now();
583
+ const redeliveryAt = queueDelayDeadline(now, timeoutSeconds, 1, MAX_QUEUE_DERIVED_DEADLINE_HEADROOM_MS);
584
+ if (redeliveryAt !== null) {
585
+ delayFor(redeliveryAt - now).then(() => enqueue(pathname, envelope));
581
586
  return;
582
587
  }
583
588
  }
@@ -591,8 +596,13 @@ function createTestPump(config) {
591
596
  ...envelope,
592
597
  attempt: envelope.attempt + 1
593
598
  };
594
- const backoff = baseBackoffMs * 2 ** (next.attempt - 1);
595
- setTimeout$1(backoff).then(() => enqueue(pathname, next));
599
+ const backoff = Math.min(MAX_TEST_PUMP_BACKOFF_MS, baseBackoffMs * 2 ** Math.min(next.attempt - 1, 30));
600
+ if (checkedQueueTimestampAdd(Date.now(), backoff, 900001) === null) {
601
+ release(envelope);
602
+ console.error(`[world-celld test pump] dropping ${envelope.messageId}: retry deadline exceeds the queue timestamp horizon`);
603
+ return;
604
+ }
605
+ delayFor(backoff).then(() => enqueue(pathname, next));
596
606
  } else {
597
607
  release(envelope);
598
608
  console.error(`[world-celld test pump] dropping ${envelope.messageId} after ${envelope.attempt} attempts: HTTP ${response.status}: ${text}`);
@@ -617,8 +627,9 @@ function createTestPump(config) {
617
627
  return idempotencyKey ? inflightMessages.get(idempotencyKey) : void 0;
618
628
  },
619
629
  push(pathname, envelope, delaySeconds) {
630
+ if (queueDelayDeadline(Date.now(), delaySeconds ?? 0, 0, 900001) === null) throw new Error("world-celld queue delaySeconds lacks required delivery headroom");
620
631
  if (envelope.idempotencyKey) inflightMessages.set(envelope.idempotencyKey, MessageId.parse(envelope.messageId));
621
- if (delaySeconds && delaySeconds > 0) setTimeout$1(delaySeconds * 1e3).then(() => enqueue(pathname, envelope));
632
+ if (delaySeconds && delaySeconds > 0) delayFor(delaySeconds * 1e3).then(() => enqueue(pathname, envelope));
622
633
  else enqueue(pathname, envelope);
623
634
  },
624
635
  async start() {
@@ -634,17 +645,16 @@ function createTestPump(config) {
634
645
  }
635
646
  function createQueue(config) {
636
647
  const { env, deploymentId } = config;
637
- const queueShards = config.queueShards ?? 1;
648
+ if (!env?.WORKFLOW_QUEUE) throw new Error("world-celld queue missing WORKFLOW_QUEUE binding");
638
649
  const generateMessageId = monotonicFactory();
639
650
  const testPump = createTestPump(config);
640
- const getQueueStub = (shard) => {
641
- const id = env.WORKFLOW_QUEUE.idFromName(`q:${shard}`);
642
- return env.WORKFLOW_QUEUE.get(id);
643
- };
644
651
  return {
645
652
  async queue(queueName, message, opts) {
646
653
  parseQueueName(queueName);
647
- const runId = "runId" in message && typeof message.runId === "string" ? message.runId : void 0;
654
+ const parsedMessage = QueuePayloadSchema.safeParse(message);
655
+ if (!parsedMessage.success) throw new WorkflowWorldError("world-celld queue payload is invalid", { status: 422 });
656
+ if (queueDelayDeadline(Date.now(), opts?.delaySeconds ?? 0, 0, 900001) === null) throw new Error("world-celld queue delaySeconds lacks required delivery headroom");
657
+ const runId = "runId" in parsedMessage.data && typeof parsedMessage.data.runId === "string" ? parsedMessage.data.runId : void 0;
648
658
  if (isTestMode()) {
649
659
  const existing = testPump.inflight(opts?.idempotencyKey);
650
660
  if (existing) return { messageId: existing };
@@ -653,50 +663,63 @@ function createQueue(config) {
653
663
  messageId,
654
664
  queueName,
655
665
  attempt: 1,
656
- message,
666
+ message: parsedMessage.data,
657
667
  idempotencyKey: opts?.idempotencyKey
658
668
  }, opts?.delaySeconds);
659
669
  return { messageId };
660
670
  }
661
671
  const messageId = MessageId.parse(`msg_${generateMessageId()}`);
662
- const shard = shardFor(opts?.idempotencyKey ?? messageId, queueShards);
663
- const outcome = await getQueueStub(shard).enqueue({
672
+ const body = stringify(parsedMessage.data);
673
+ const notBefore = opts?.delaySeconds && opts.delaySeconds > 0 ? queueDelayDeadline(Date.now(), opts.delaySeconds, 0, 900001) ?? void 0 : void 0;
674
+ const envelope = {
675
+ version: 1,
664
676
  messageId,
665
677
  queueName,
666
- pathname: QUEUE_PATHNAME,
667
- body: stringify(message),
678
+ targetBaseUrl: resolveBaseUrl(config),
668
679
  runId,
669
680
  idempotencyKey: opts?.idempotencyKey,
670
- delaySeconds: opts?.delaySeconds,
671
- config: {
672
- targetBaseUrl: resolveBaseUrl(config),
673
- queueShards
674
- }
675
- });
676
- if (!outcome.ok) {
677
- if (outcome.code === "RUN_EXPIRED") throw new RunExpiredError(outcome.message);
678
- throw new WorkflowWorldError(outcome.message, { status: 409 });
679
- }
680
- return { messageId: MessageId.parse(outcome.messageId) };
681
+ body,
682
+ notBefore
683
+ };
684
+ const published = await env.WORKFLOW_QUEUE.send(envelope, { delaySeconds: nativeQueueDelaySeconds(notBefore, Date.now()) });
685
+ return { messageId: MessageId.parse(published.messageId) };
681
686
  },
682
687
  createQueueHandler(queueNamePrefix, handler) {
683
688
  return async (req) => {
689
+ if (req.method !== "POST") return Response.json({ error: "Expected POST" }, { status: 405 });
690
+ if (req.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") return Response.json({ error: "Expected application/json" }, { status: 415 });
684
691
  const reqQueueName = req.headers.get("x-vqs-queue-name");
685
692
  const reqMessageId = req.headers.get("x-vqs-message-id");
686
693
  const attemptStr = req.headers.get("x-vqs-message-attempt");
687
694
  if (!reqQueueName || !reqMessageId || !attemptStr || !req.body) return Response.json({ error: "Missing required headers or body" }, { status: 400 });
688
695
  if (!reqQueueName.startsWith(queueNamePrefix)) return Response.json({ error: "Unhandled queue" }, { status: 400 });
689
- const attempt = Number.parseInt(attemptStr, 10);
696
+ let attempt;
690
697
  try {
691
- const result = await handler(parse(await req.text()), {
698
+ attempt = strictIntegerSetting("x-vqs-message-attempt", attemptStr, 1, 1);
699
+ } catch {
700
+ return Response.json({ error: "Invalid x-vqs-message-attempt header" }, { status: 400 });
701
+ }
702
+ try {
703
+ let body;
704
+ try {
705
+ body = parse(await req.text());
706
+ } catch {
707
+ return Response.json({ error: "Malformed tagged JSON body" }, { status: 422 });
708
+ }
709
+ const parsedBody = QueuePayloadSchema.safeParse(body);
710
+ if (!parsedBody.success) return Response.json({ error: "Invalid queue payload" }, { status: 422 });
711
+ const result = await handler(parsedBody.data, {
692
712
  attempt,
693
713
  queueName: reqQueueName,
694
714
  messageId: reqMessageId
695
715
  });
696
- if (result && typeof result.timeoutSeconds === "number") return Response.json({ timeoutSeconds: result.timeoutSeconds }, {
697
- status: 503,
698
- headers: { "Retry-After": String(result.timeoutSeconds) }
699
- });
716
+ if (result && typeof result.timeoutSeconds === "number") {
717
+ if (queueDelayDeadline(Date.now(), result.timeoutSeconds, 1, 900001) === null) throw new Error("Queue handler timeoutSeconds is out of range");
718
+ return Response.json({ timeoutSeconds: result.timeoutSeconds }, {
719
+ status: 503,
720
+ headers: { "Retry-After": String(result.timeoutSeconds) }
721
+ });
722
+ }
700
723
  return new Response(null, { status: 204 });
701
724
  } catch (error) {
702
725
  if (isPermanentError(error)) {
@@ -740,6 +763,114 @@ function hookOwner(hook) {
740
763
  hookId: hook.hookId
741
764
  };
742
765
  }
766
+ const APPLY_EVENT_ERROR_CODES = /* @__PURE__ */ new Set([
767
+ "RUN_NOT_FOUND",
768
+ "STEP_NOT_FOUND",
769
+ "HOOK_NOT_FOUND",
770
+ "WAIT_NOT_FOUND",
771
+ "ENTITY_CONFLICT",
772
+ "HOOK_CLAIM_CANCELLED",
773
+ "RUN_EXPIRED",
774
+ "TOO_EARLY",
775
+ "RUN_NOT_SUPPORTED"
776
+ ]);
777
+ function malformedApplyEventOutcome(reason) {
778
+ throw new FleetTransportError(`world-celld: malformed applyEvent outcome: ${reason}`);
779
+ }
780
+ function isRecord(value) {
781
+ return value !== null && typeof value === "object" && !Array.isArray(value);
782
+ }
783
+ function validateOptionalNumber(outcome, key, integer) {
784
+ const value = outcome[key];
785
+ if (value !== void 0 && (typeof value !== "number" || !Number.isFinite(value) || integer && !Number.isSafeInteger(value))) malformedApplyEventOutcome(`${key} must be ${integer ? "a safe integer" : "a finite number"}`);
786
+ }
787
+ function validateOptionalRetryAfterMs(outcome) {
788
+ const value = outcome.retryAfterMs;
789
+ if (value !== void 0 && (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) malformedApplyEventOutcome("retryAfterMs must be a non-negative safe integer");
790
+ }
791
+ function parseSuccessEntity(outcome, key, schema) {
792
+ const value = outcome[key];
793
+ if (value === void 0) return void 0;
794
+ if (!isRecord(value)) malformedApplyEventOutcome(`${key} is invalid`);
795
+ const result = schema.safeParse(compact(value));
796
+ if (!result.success) malformedApplyEventOutcome(`${key} is invalid`);
797
+ return result.data;
798
+ }
799
+ /**
800
+ * Validate the untrusted value decoded from the remote applyEvent RPC.
801
+ * Unknown failure codes are intentionally valid and classified separately so
802
+ * an older client can reconstruct their wire metadata without treating them
803
+ * as success. All fields consumed by the success path are checked before any
804
+ * derivative index operation can run.
805
+ */
806
+ function parseApplyEventOutcome(value) {
807
+ if (!isRecord(value)) malformedApplyEventOutcome("response must be an object");
808
+ if (value.ok === false) {
809
+ if (typeof value.code !== "string" || value.code.length === 0) malformedApplyEventOutcome("failure code must be a non-empty string");
810
+ if (typeof value.message !== "string") malformedApplyEventOutcome("failure message must be a string");
811
+ validateOptionalNumber(value, "status", true);
812
+ validateOptionalNumber(value, "retryAfter", false);
813
+ validateOptionalRetryAfterMs(value);
814
+ validateOptionalNumber(value, "retryAfterSeconds", false);
815
+ validateOptionalNumber(value, "runSpecVersion", true);
816
+ validateOptionalNumber(value, "worldSpecVersion", true);
817
+ const outcome = value;
818
+ return APPLY_EVENT_ERROR_CODES.has(outcome.code) ? {
819
+ kind: "known-failure",
820
+ outcome
821
+ } : {
822
+ kind: "unknown-failure",
823
+ outcome
824
+ };
825
+ }
826
+ if (value.ok !== true) malformedApplyEventOutcome("ok must be true or false");
827
+ if (!Array.isArray(value.releasedHooks)) malformedApplyEventOutcome("releasedHooks must be an array");
828
+ for (const releasedHook of value.releasedHooks) if (!isRecord(releasedHook) || typeof releasedHook.hookId !== "string" || releasedHook.hookId.length === 0 || typeof releasedHook.token !== "string" || releasedHook.token.length === 0) malformedApplyEventOutcome("releasedHooks entries must contain hookId and token strings");
829
+ const event = parseSuccessEntity(value, "event", EventSchema);
830
+ const run = parseSuccessEntity(value, "run", WorkflowRunSchema);
831
+ const step = parseSuccessEntity(value, "step", StepSchema);
832
+ const hook = parseSuccessEntity(value, "hook", HookSchema);
833
+ const wait = parseSuccessEntity(value, "wait", WaitSchema);
834
+ const hookToIndex = parseSuccessEntity(value, "hookToIndex", HookSchema);
835
+ if (value.stepCreated !== void 0 && value.stepCreated !== true) malformedApplyEventOutcome("stepCreated must be true when present");
836
+ if (value.runCreated !== void 0) {
837
+ if (!isRecord(value.runCreated) || typeof value.runCreated.workflowName !== "string" || !(value.runCreated.createdAt instanceof Date) || Number.isNaN(value.runCreated.createdAt.getTime())) malformedApplyEventOutcome("runCreated is invalid");
838
+ }
839
+ let events;
840
+ if (value.events !== void 0) {
841
+ if (!Array.isArray(value.events)) malformedApplyEventOutcome("events must be an array");
842
+ events = value.events.map((candidate) => {
843
+ if (!isRecord(candidate)) malformedApplyEventOutcome("events contains an invalid event");
844
+ const result = EventSchema.safeParse(compact(candidate));
845
+ if (!result.success) malformedApplyEventOutcome("events contains an invalid event");
846
+ return result.data;
847
+ });
848
+ if (value.cursor !== null && typeof value.cursor !== "string") malformedApplyEventOutcome("cursor is required when events are present");
849
+ if (typeof value.hasMore !== "boolean") malformedApplyEventOutcome("hasMore is required when events are present");
850
+ }
851
+ if (value.cursor !== void 0 && value.cursor !== null && typeof value.cursor !== "string") malformedApplyEventOutcome("cursor must be a string or null");
852
+ if (value.hasMore !== void 0 && typeof value.hasMore !== "boolean") malformedApplyEventOutcome("hasMore must be a boolean");
853
+ if (value.maxEvents !== void 0 && (typeof value.maxEvents !== "number" || !Number.isSafeInteger(value.maxEvents) || value.maxEvents < 0)) malformedApplyEventOutcome("maxEvents must be a non-negative safe integer");
854
+ if (run !== void 0 && value.indexPublicationExpiresAt === void 0) malformedApplyEventOutcome("indexPublicationExpiresAt is required when run is present");
855
+ if (value.indexPublicationExpiresAt !== void 0 && (typeof value.indexPublicationExpiresAt !== "number" || !Number.isFinite(value.indexPublicationExpiresAt))) malformedApplyEventOutcome("indexPublicationExpiresAt must be a finite number");
856
+ return {
857
+ kind: "success",
858
+ outcome: {
859
+ ...value,
860
+ event,
861
+ run,
862
+ step,
863
+ hook,
864
+ wait,
865
+ hookToIndex,
866
+ events,
867
+ releasedHooks: value.releasedHooks.map((releasedHook) => ({
868
+ hookId: releasedHook.hookId,
869
+ token: releasedHook.token
870
+ }))
871
+ }
872
+ };
873
+ }
743
874
  /**
744
875
  * Filter data based on ResolveData parameter.
745
876
  * When resolveData is 'none', strips specified keys to reduce data transfer.
@@ -772,16 +903,44 @@ function filterHookData(hook, resolveData) {
772
903
  * outcome objects instead of throwing.
773
904
  */
774
905
  function throwOutcomeError(outcome, runId, data) {
906
+ const withCode = (error) => {
907
+ Object.assign(error, { code: outcome.code });
908
+ return error;
909
+ };
775
910
  switch (outcome.code) {
776
- case "RUN_NOT_FOUND": throw new WorkflowRunNotFoundError(runId);
777
- case "STEP_NOT_FOUND": throw new WorkflowWorldError(outcome.message, { status: 404 });
778
- case "HOOK_NOT_FOUND": throw new HookNotFoundError(data.correlationId ?? runId);
779
- case "WAIT_NOT_FOUND": throw new WorkflowWorldError(outcome.message, { status: 404 });
780
- case "ENTITY_CONFLICT": throw new EntityConflictError(outcome.message);
781
- case "HOOK_CLAIM_CANCELLED": throw new WorkflowWorldError(outcome.message, { status: 503 });
782
- case "RUN_EXPIRED": throw new RunExpiredError(outcome.message);
783
- case "TOO_EARLY": throw new TooEarlyError(outcome.message, { retryAfter: outcome.retryAfterSeconds });
784
- case "RUN_NOT_SUPPORTED": throw new RunNotSupportedError(outcome.runSpecVersion ?? 0, SPEC_VERSION_CURRENT);
911
+ case "RUN_NOT_FOUND": throw withCode(new WorkflowRunNotFoundError(runId));
912
+ case "STEP_NOT_FOUND": throw new WorkflowWorldError(outcome.message, {
913
+ status: 404,
914
+ code: outcome.code
915
+ });
916
+ case "HOOK_NOT_FOUND": throw withCode(new HookNotFoundError(data.correlationId ?? runId));
917
+ case "WAIT_NOT_FOUND": throw new WorkflowWorldError(outcome.message, {
918
+ status: 404,
919
+ code: outcome.code
920
+ });
921
+ case "ENTITY_CONFLICT": throw withCode(new EntityConflictError(outcome.message));
922
+ case "HOOK_CLAIM_CANCELLED": throw new WorkflowWorldError(outcome.message, {
923
+ status: 503,
924
+ code: outcome.code
925
+ });
926
+ case "RUN_EXPIRED": throw withCode(new RunExpiredError(outcome.message));
927
+ case "TOO_EARLY": throw withCode(new TooEarlyError(outcome.message, { retryAfter: outcome.retryAfterSeconds }));
928
+ case "RUN_NOT_SUPPORTED": throw withCode(new RunNotSupportedError(outcome.runSpecVersion ?? 0, SPEC_VERSION_CURRENT));
929
+ default: {
930
+ const error = new WorkflowWorldError(outcome.message, {
931
+ status: outcome.status,
932
+ code: outcome.code,
933
+ retryAfter: outcome.retryAfter
934
+ });
935
+ for (const key of [
936
+ "retryAfterMs",
937
+ "retryAfterSeconds",
938
+ "runSpecVersion",
939
+ "worldSpecVersion",
940
+ "details"
941
+ ]) if (Object.hasOwn(outcome, key)) Object.assign(error, { [key]: outcome[key] });
942
+ throw error;
943
+ }
785
944
  }
786
945
  }
787
946
  const parseRun = (run) => WorkflowRunSchema.parse(compact(run));
@@ -807,10 +966,7 @@ function indexStatusExcludes(indexed, requested) {
807
966
  }
808
967
  function createStorage(config) {
809
968
  const { env } = config;
810
- const cleanup = {
811
- retentionMs: config.runRetentionMs ?? 0,
812
- queueShards: config.queueShards ?? 1
813
- };
969
+ const cleanup = { retentionMs: config.runRetentionMs ?? 0 };
814
970
  const ulid = monotonicFactory();
815
971
  const getRunDO = (runId) => {
816
972
  const id = env.WORKFLOW_DB.idFromName(runId);
@@ -883,6 +1039,8 @@ function createStorage(config) {
883
1039
  },
884
1040
  events: {
885
1041
  async create(runId, data, params) {
1042
+ data = CreateEventSchema.parse(compact(data));
1043
+ if (runId !== null && typeof runId !== "string") throw new WorkflowWorldError("runId must be a string or null", { status: 400 });
886
1044
  let effectiveRunId;
887
1045
  if (data.eventType === "run_created" && (!runId || runId === "")) effectiveRunId = `wrun_${ulid()}`;
888
1046
  else if (!runId) throw new WorkflowWorldError("runId is required for non-run_created events", { status: 400 });
@@ -905,16 +1063,16 @@ function createStorage(config) {
905
1063
  };
906
1064
  } else tokenHolder = admission.holder;
907
1065
  }
908
- let outcome;
1066
+ let parsedOutcome;
909
1067
  try {
910
- outcome = await stub.applyEvent({
1068
+ parsedOutcome = parseApplyEventOutcome(await stub.applyEvent({
911
1069
  runId: effectiveRunId,
912
1070
  data,
913
1071
  params,
914
1072
  tokenHolder,
915
1073
  hookClaimId: hookAdmission?.reservation.claimId,
916
1074
  cleanup
917
- });
1075
+ }));
918
1076
  } catch (error) {
919
1077
  if (hookAdmission) {
920
1078
  let resolution;
@@ -931,10 +1089,11 @@ function createStorage(config) {
931
1089
  }
932
1090
  throw error;
933
1091
  }
934
- if (!outcome.ok) {
1092
+ if (parsedOutcome.kind !== "success") {
935
1093
  if (hookAdmission) await env.WORKFLOW_INDEX.releaseHookReservation(hookAdmission.token, hookAdmission.owner, hookAdmission.reservation);
936
- throwOutcomeError(outcome, effectiveRunId, data);
1094
+ throwOutcomeError(parsedOutcome.outcome, effectiveRunId, data);
937
1095
  }
1096
+ const outcome = parsedOutcome.outcome;
938
1097
  if (outcome.run) {
939
1098
  const meta = JSON.stringify({
940
1099
  runId: effectiveRunId,
@@ -1262,6 +1421,12 @@ function createStreamer(config) {
1262
1421
  * DevKit. Modeled on vinnymac/worlds packages/world-cloudflare/src/index.ts
1263
1422
  * (Apache-2.0, see NOTICE).
1264
1423
  */
1424
+ const REQUIRED_ENV_BINDINGS = [
1425
+ "WORKFLOW_DB",
1426
+ "WORKFLOW_INDEX",
1427
+ "WORKFLOW_QUEUE",
1428
+ "WORKFLOW_STREAMS"
1429
+ ];
1265
1430
  function createCelldWorld(config) {
1266
1431
  const resolved = resolveConfig(config);
1267
1432
  let env = resolved.env;
@@ -1280,20 +1445,19 @@ function createCelldWorld(config) {
1280
1445
  }
1281
1446
  }
1282
1447
  if (!env) throw new Error("celld environment not configured. Provide config.fleetUrl + config.secret (or CELLD_FLEET_URL / CELLD_WORLD_SECRET), or pass config.env with WORKFLOW_DB, WORKFLOW_INDEX, WORKFLOW_QUEUE, WORKFLOW_STREAMS");
1448
+ for (const binding of REQUIRED_ENV_BINDINGS) if (!env[binding]) throw new Error(`world-celld: config.env is missing required binding ${binding}`);
1283
1449
  const storage = createStorage({
1284
1450
  env: {
1285
1451
  WORKFLOW_DB: env.WORKFLOW_DB,
1286
1452
  WORKFLOW_INDEX: env.WORKFLOW_INDEX
1287
1453
  },
1288
1454
  deploymentId: resolved.deploymentId,
1289
- runRetentionMs: resolved.runRetentionMs,
1290
- queueShards: resolved.queueShards
1455
+ runRetentionMs: resolved.runRetentionMs
1291
1456
  });
1292
1457
  const queue = createQueue({
1293
1458
  env: { WORKFLOW_QUEUE: env.WORKFLOW_QUEUE },
1294
1459
  deploymentId: resolved.deploymentId,
1295
- baseUrl: resolved.baseUrl,
1296
- queueShards: resolved.queueShards
1460
+ baseUrl: resolved.baseUrl
1297
1461
  });
1298
1462
  const streamer = createStreamer({
1299
1463
  env: { WORKFLOW_STREAMS: env.WORKFLOW_STREAMS },
@@ -1305,15 +1469,9 @@ function createCelldWorld(config) {
1305
1469
  getStatus: (runId) => runStub(runId).getCleanupStatus(),
1306
1470
  schedule: (runId) => {
1307
1471
  if (resolved.runRetentionMs === 0) throw new Error("world-celld: runRetentionMs must be greater than zero to schedule cleanup");
1308
- return runStub(runId).scheduleCleanup({
1309
- retentionMs: resolved.runRetentionMs,
1310
- queueShards: resolved.queueShards
1311
- });
1472
+ return runStub(runId).scheduleCleanup({ retentionMs: resolved.runRetentionMs });
1312
1473
  },
1313
- cleanupNow: (runId) => runStub(runId).cleanupNow({
1314
- retentionMs: Math.max(1, resolved.runRetentionMs),
1315
- queueShards: resolved.queueShards
1316
- }),
1474
+ cleanupNow: (runId) => runStub(runId).cleanupNow({ retentionMs: Math.max(1, resolved.runRetentionMs) }),
1317
1475
  rearm: (runId) => runStub(runId).rearmCleanup()
1318
1476
  };
1319
1477
  return {