@ewhauser/world-celld 0.1.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 ADDED
@@ -0,0 +1,949 @@
1
+ import { a as stringify, i as parse, n as rpcParse, r as rpcStringify, t as compact } from "./util-BvWlkqgV.js";
2
+ import { EventSchema, HookSchema, MessageId, SPEC_VERSION_CURRENT, StepSchema, WorkflowRunSchema, parseQueueName } from "@workflow/world";
3
+ import { EntityConflictError, HookNotFoundError, RunExpiredError, RunNotSupportedError, TooEarlyError, WorkflowRunNotFoundError, WorkflowWorldError } from "@workflow/errors";
4
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
5
+ import { monotonicFactory } from "ulid";
6
+ //#region src/config.ts
7
+ function resolveConfig(config) {
8
+ return {
9
+ fleetUrl: config?.fleetUrl ?? process.env.CELLD_FLEET_URL,
10
+ secret: config?.secret ?? process.env.CELLD_WORLD_SECRET,
11
+ env: config?.env,
12
+ deploymentId: config?.deploymentId ?? process.env.CELLD_DEPLOYMENT_ID ?? "celld-default",
13
+ baseUrl: config?.baseUrl,
14
+ queueShards: config?.queueShards ?? 1,
15
+ readPollMs: config?.readPollMs ?? 250,
16
+ rpcTimeoutMs: config?.rpcTimeoutMs ?? 3e4
17
+ };
18
+ }
19
+ //#endregion
20
+ //#region src/remote/errors.ts
21
+ /**
22
+ * Reconstruct a typed error from a router error payload. `@workflow/errors`
23
+ * matches with name-based `.is()` checks, so a WorkflowWorldError with the
24
+ * original name (and status) restored behaves identically to the source
25
+ * error for every consumer in the workflow runtime.
26
+ */
27
+ function reconstructError(wire, httpStatus) {
28
+ const message = wire?.message ?? `world-celld rpc failed with HTTP ${httpStatus}`;
29
+ const error = new WorkflowWorldError(message, { status: wire?.status ?? httpStatus });
30
+ if (wire?.name) error.name = wire.name;
31
+ return error;
32
+ }
33
+ /** Transport-level failure (fleet unreachable, malformed response). */
34
+ var FleetTransportError = class extends Error {
35
+ cause;
36
+ constructor(message, cause) {
37
+ super(message);
38
+ this.cause = cause;
39
+ this.name = "FleetTransportError";
40
+ }
41
+ };
42
+ //#endregion
43
+ //#region src/remote/rpc-client.ts
44
+ /** Statuses that mean "the fleet couldn't take the request" (safe to retry reads). */
45
+ const RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
46
+ 502,
47
+ 503,
48
+ 504
49
+ ]);
50
+ const DEFAULT_TIMEOUT_MS = 3e4;
51
+ function delayMs(ms) {
52
+ return new Promise((resolve) => setTimeout(resolve, ms));
53
+ }
54
+ /**
55
+ * Invoke one DO method through the worker router.
56
+ *
57
+ * Mutating calls run exactly once from the client's perspective: a blind
58
+ * retry of e.g. applyEvent would turn commit-then-network-fail into a
59
+ * spurious ENTITY_CONFLICT. The workflow runtime already gets at-least-once
60
+ * via queue redelivery. Read-only calls retry on transport failures.
61
+ */
62
+ async function callDO(transport, binding, name, method, args, opts) {
63
+ const doFetch = transport.fetchImpl ?? fetch;
64
+ const url = `${transport.fleetUrl.replace(/\/$/, "")}/v1/rpc/${binding}/${encodeURIComponent(name)}/${method}`;
65
+ const attempts = opts?.idempotent ? 3 : 1;
66
+ let lastError;
67
+ for (let attempt = 1; attempt <= attempts; attempt++) {
68
+ if (attempt > 1) await delayMs(100 * attempt + Math.random() * 200);
69
+ let response;
70
+ try {
71
+ response = await doFetch(url, {
72
+ method: "POST",
73
+ headers: {
74
+ "content-type": "application/json",
75
+ authorization: `Bearer ${transport.secret}`
76
+ },
77
+ body: rpcStringify(args),
78
+ signal: AbortSignal.timeout(Math.max(1, transport.timeoutMs ?? DEFAULT_TIMEOUT_MS))
79
+ });
80
+ } catch (error) {
81
+ lastError = new FleetTransportError(`world-celld: fleet unreachable at ${url}`, error);
82
+ continue;
83
+ }
84
+ if (response.ok) try {
85
+ return rpcParse(await response.text());
86
+ } catch (error) {
87
+ lastError = new FleetTransportError(`world-celld: malformed successful response from ${url}`, error);
88
+ if (attempt < attempts) continue;
89
+ throw lastError;
90
+ }
91
+ const text = await response.text();
92
+ let wire;
93
+ try {
94
+ wire = JSON.parse(text).error;
95
+ } catch {
96
+ wire = { message: text || `HTTP ${response.status}` };
97
+ }
98
+ if (RETRYABLE_STATUSES.has(response.status) && attempt < attempts) {
99
+ lastError = reconstructError(wire, response.status);
100
+ continue;
101
+ }
102
+ throw reconstructError(wire, response.status);
103
+ }
104
+ throw lastError instanceof Error ? lastError : new FleetTransportError("world-celld: rpc failed", lastError);
105
+ }
106
+ //#endregion
107
+ //#region src/remote/namespaces.ts
108
+ const RUNS = {
109
+ methods: [
110
+ "applyEvent",
111
+ "getRun",
112
+ "getStep",
113
+ "getEvent",
114
+ "listEvents",
115
+ "listSteps",
116
+ "listHooks"
117
+ ],
118
+ mutating: /* @__PURE__ */ new Set(["applyEvent"])
119
+ };
120
+ const STREAMS = {
121
+ methods: [
122
+ "writeChunk",
123
+ "closeStream",
124
+ "getChunks",
125
+ "getInfo",
126
+ "registerStream",
127
+ "listStreams"
128
+ ],
129
+ mutating: /* @__PURE__ */ new Set([
130
+ "writeChunk",
131
+ "closeStream",
132
+ "registerStream"
133
+ ])
134
+ };
135
+ const QUEUE = {
136
+ methods: [
137
+ "enqueue",
138
+ "stats",
139
+ "listDeadLetters",
140
+ "redriveDeadLetter",
141
+ "purgeDeadLetters",
142
+ "rearmAlarm"
143
+ ],
144
+ mutating: /* @__PURE__ */ new Set([
145
+ "enqueue",
146
+ "redriveDeadLetter",
147
+ "purgeDeadLetters",
148
+ "rearmAlarm"
149
+ ])
150
+ };
151
+ function makeStub(transport, binding, name, spec) {
152
+ const stub = {};
153
+ for (const method of spec.methods) stub[method] = (...args) => callDO(transport, binding, name, method, args, { idempotent: !spec.mutating.has(method) });
154
+ return stub;
155
+ }
156
+ function makeNamespace(transport, binding, spec) {
157
+ return {
158
+ idFromName(name) {
159
+ return { toString: () => name };
160
+ },
161
+ get(id) {
162
+ return makeStub(transport, binding, id.toString(), spec);
163
+ }
164
+ };
165
+ }
166
+ /** The IndexDO is a single well-known cell. */
167
+ const INDEX_CELL_NAME = "index";
168
+ function makeIndexNamespace(transport) {
169
+ const call = (method, args, idempotent) => callDO(transport, "index", INDEX_CELL_NAME, method, args, { idempotent });
170
+ return {
171
+ get: (key) => call("get", [key], true),
172
+ put: (key, value) => call("put", [key, value], false),
173
+ delete: (key) => call("delete", [key], false),
174
+ list: (options) => call("list", [options], true),
175
+ reserveHookToken: (token, owner) => call("reserveHookToken", [token, owner], false),
176
+ finalizeHookIndexes: (token, hookId, serializedHook, owner) => call("finalizeHookIndexes", [
177
+ token,
178
+ hookId,
179
+ serializedHook,
180
+ owner
181
+ ], false),
182
+ releaseHookToken: (token, owner) => call("releaseHookToken", [token, owner], false),
183
+ deleteHookIndexes: (token, hookId, owner) => call("deleteHookIndexes", [
184
+ token,
185
+ hookId,
186
+ owner
187
+ ], false)
188
+ };
189
+ }
190
+ function createRemoteEnv(transport) {
191
+ return {
192
+ WORKFLOW_DB: makeNamespace(transport, "runs", RUNS),
193
+ WORKFLOW_STREAMS: makeNamespace(transport, "streams", STREAMS),
194
+ WORKFLOW_INDEX: makeIndexNamespace(transport),
195
+ WORKFLOW_QUEUE: makeNamespace(transport, "queue", QUEUE)
196
+ };
197
+ }
198
+ //#endregion
199
+ //#region src/vendor/shared/debug.ts
200
+ /**
201
+ * Creates a namespaced debug logger.
202
+ * Logs to stderr to avoid corrupting CLI JSON output on stdout.
203
+ *
204
+ * Control via WORKFLOW_DEBUG environment variable:
205
+ * WORKFLOW_DEBUG=1 // Enable all
206
+ * WORKFLOW_DEBUG=mysql-world // Specific namespace
207
+ * WORKFLOW_DEBUG=mysql-world,redis-world // Multiple namespaces
208
+ *
209
+ * Vendored from vinnymac/worlds packages/shared, modified: guarded for
210
+ * non-Node runtimes (celld/workerd), where `process` may be absent or
211
+ * stubbed — falls back to console.error.
212
+ */
213
+ function createDebugLogger(namespace) {
214
+ return (...args) => {
215
+ const debug = typeof process === "undefined" ? void 0 : process.env?.WORKFLOW_DEBUG;
216
+ if (!debug) return;
217
+ if (!(debug === "1" || debug === "true" || debug === "*" || debug.split(",").some((ns) => ns.trim() === namespace))) return;
218
+ const prefix = `[${(/* @__PURE__ */ new Date()).toISOString()}] [${namespace}]`;
219
+ const message = args.map((arg) => typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg)).join(" ");
220
+ if (typeof process.stderr?.write === "function") process.stderr.write(`${prefix} ${message}\n`);
221
+ else console.error(`${prefix} ${message}`);
222
+ };
223
+ }
224
+ //#endregion
225
+ //#region src/util.ts
226
+ const debug = createDebugLogger("celld-world");
227
+ //#endregion
228
+ //#region src/queue.ts
229
+ /**
230
+ * Queue implementation for the celld world.
231
+ *
232
+ * Vendored from vinnymac/worlds packages/world-cloudflare/src/queue.ts
233
+ * (Apache-2.0, see NOTICE), modified for celld:
234
+ *
235
+ * - The Cloudflare Queues producer is replaced by an `enqueue` RPC to a
236
+ * QueueDO cell (`q:<shard>`), which owns the message lifecycle: scheduling
237
+ * via durable alarms, delivery via outbound fetch, retries with capped
238
+ * backoff, and a dead-letter table.
239
+ * - The consumer-side claim-DO idempotency mechanism is gone: a queue cell is
240
+ * a single serialized writer, so idempotencyKey dedup happens inside the
241
+ * cell (at enqueue and across the delivery state machine).
242
+ * - `createQueueHandler` has ONE dialect — the x-vqs wire format the test
243
+ * pump has always used — extended so permanent errors surface as their
244
+ * HTTP statuses. QueueDO deliveries and the in-process test pump hit the
245
+ * exact same handler path.
246
+ */
247
+ /**
248
+ * HTTP status codes that indicate permanent (non-retryable) failures.
249
+ * Deliveries with these statuses are dropped immediately instead of retried.
250
+ *
251
+ * - 404: Resource not found (e.g., run was deleted)
252
+ * - 409: Conflict (e.g., duplicate event that can't be replayed)
253
+ * - 410: Gone (e.g., run was already terminal)
254
+ * - 422: Unprocessable entity (e.g., invalid payload structure)
255
+ */
256
+ const PERMANENT_ERROR_STATUSES = /* @__PURE__ */ new Set([
257
+ 404,
258
+ 409,
259
+ 410,
260
+ 422
261
+ ]);
262
+ function isPermanentError(err) {
263
+ if (err instanceof WorkflowWorldError && err.status !== void 0) return PERMANENT_ERROR_STATUSES.has(err.status);
264
+ return false;
265
+ }
266
+ /** Caps at 60 seconds. */
267
+ function computeBackoff(attempt) {
268
+ return Math.min(60, 2 ** attempt);
269
+ }
270
+ const QUEUE_PATHNAMES = {
271
+ workflow: "flow",
272
+ step: "step"
273
+ };
274
+ function isTestMode() {
275
+ if (process.env.CELLD_QUEUE_MODE === "cells") return false;
276
+ return process.env.VITEST === "true" || process.env.NODE_ENV === "test";
277
+ }
278
+ function resolveBaseUrl(config) {
279
+ if (config.baseUrl) return config.baseUrl;
280
+ if (process.env.WORKFLOW_BASE_URL) return process.env.WORKFLOW_BASE_URL;
281
+ return `http://localhost:${process.env.PORT ?? "3000"}`;
282
+ }
283
+ /**
284
+ * FNV-1a hash for shard selection. All messages sharing an idempotencyKey
285
+ * land on the same cell so dedup stays strictly consistent.
286
+ */
287
+ function shardFor(key, shards) {
288
+ if (shards <= 1) return 0;
289
+ let hash = 2166136261;
290
+ for (let i = 0; i < key.length; i++) {
291
+ hash ^= key.charCodeAt(i);
292
+ hash = Math.imul(hash, 16777619);
293
+ }
294
+ return (hash >>> 0) % shards;
295
+ }
296
+ /**
297
+ * In-process test pump. Holds two in-memory FIFOs and HTTP-dispatches
298
+ * envelopes to the user's server at
299
+ * `${baseUrl}/.well-known/workflow/v1/{flow|step}`.
300
+ *
301
+ * Mirrors world-local's idempotency semantics: messages are deduplicated on
302
+ * `idempotencyKey` while a message with the same key is in flight, and the
303
+ * key is released only when the message is fully handled (success or drop).
304
+ *
305
+ * Modified vs upstream: permanent-error statuses (404/409/410/422) from the
306
+ * handler drop the message immediately instead of burning retry budget —
307
+ * matching QueueDO's production semantics.
308
+ */
309
+ function createTestPump(config) {
310
+ const httpTimeoutMs = config.httpTimeoutMs ?? 3e5;
311
+ const maxAttempts = config.maxAttempts ?? 5;
312
+ const baseBackoffMs = config.backoffDelayMs ?? 1e3;
313
+ const queues = {
314
+ flow: [],
315
+ step: []
316
+ };
317
+ const wakers = {
318
+ flow: [],
319
+ step: []
320
+ };
321
+ /** Inflight messageIds by idempotencyKey (world-local queue.js semantics). */
322
+ const inflightMessages = /* @__PURE__ */ new Map();
323
+ let running = false;
324
+ function release(envelope) {
325
+ if (envelope.idempotencyKey) inflightMessages.delete(envelope.idempotencyKey);
326
+ }
327
+ function enqueue(pathname, envelope) {
328
+ queues[pathname].push(envelope);
329
+ wakers[pathname].shift()?.();
330
+ }
331
+ async function take(pathname) {
332
+ const existing = queues[pathname].shift();
333
+ if (existing) return existing;
334
+ return new Promise((resolve) => {
335
+ wakers[pathname].push(() => resolve(queues[pathname].shift() ?? null));
336
+ });
337
+ }
338
+ async function dispatch(envelope, pathname) {
339
+ const url = `${resolveBaseUrl(config)}/.well-known/workflow/v1/${pathname}`;
340
+ const response = await fetch(url, {
341
+ method: "POST",
342
+ headers: {
343
+ "content-type": "application/json",
344
+ "x-vqs-queue-name": envelope.queueName,
345
+ "x-vqs-message-id": envelope.messageId,
346
+ "x-vqs-message-attempt": String(envelope.attempt)
347
+ },
348
+ body: stringify(envelope.message),
349
+ signal: AbortSignal.timeout(httpTimeoutMs)
350
+ });
351
+ if (response.ok) {
352
+ release(envelope);
353
+ return;
354
+ }
355
+ const text = await response.text();
356
+ if (response.status === 503) {
357
+ let parsed;
358
+ try {
359
+ parsed = JSON.parse(text);
360
+ } catch {
361
+ parsed = null;
362
+ }
363
+ const timeoutSeconds = parsed?.timeoutSeconds;
364
+ if (typeof timeoutSeconds === "number") {
365
+ setTimeout$1(timeoutSeconds * 1e3).then(() => enqueue(pathname, envelope));
366
+ return;
367
+ }
368
+ }
369
+ if (PERMANENT_ERROR_STATUSES.has(response.status)) {
370
+ release(envelope);
371
+ debug(`[world-celld test pump] dropping ${envelope.messageId}: permanent HTTP ${response.status}: ${text}`);
372
+ return;
373
+ }
374
+ if (envelope.attempt < maxAttempts) {
375
+ const next = {
376
+ ...envelope,
377
+ attempt: envelope.attempt + 1
378
+ };
379
+ const backoff = baseBackoffMs * 2 ** (next.attempt - 1);
380
+ setTimeout$1(backoff).then(() => enqueue(pathname, next));
381
+ } else {
382
+ release(envelope);
383
+ console.error(`[world-celld test pump] dropping ${envelope.messageId} after ${envelope.attempt} attempts: HTTP ${response.status}: ${text}`);
384
+ }
385
+ }
386
+ async function loop(pathname) {
387
+ while (true) {
388
+ if (!running) return;
389
+ const envelope = await take(pathname);
390
+ if (!envelope) continue;
391
+ try {
392
+ await dispatch(envelope, pathname);
393
+ } catch (err) {
394
+ release(envelope);
395
+ console.error(`[world-celld test pump] dispatch error on ${pathname}:`, err);
396
+ }
397
+ }
398
+ }
399
+ return {
400
+ /** Returns the inflight messageId when the key is already claimed. */
401
+ inflight(idempotencyKey) {
402
+ return idempotencyKey ? inflightMessages.get(idempotencyKey) : void 0;
403
+ },
404
+ push(pathname, envelope, delaySeconds) {
405
+ if (envelope.idempotencyKey) inflightMessages.set(envelope.idempotencyKey, MessageId.parse(envelope.messageId));
406
+ if (delaySeconds && delaySeconds > 0) setTimeout$1(delaySeconds * 1e3).then(() => enqueue(pathname, envelope));
407
+ else enqueue(pathname, envelope);
408
+ },
409
+ async start() {
410
+ if (running) return;
411
+ running = true;
412
+ loop("flow");
413
+ loop("step");
414
+ },
415
+ stop() {
416
+ running = false;
417
+ for (const list of Object.values(wakers)) for (const w of list) w();
418
+ }
419
+ };
420
+ }
421
+ function createQueue(config) {
422
+ const { env, deploymentId } = config;
423
+ const queueShards = config.queueShards ?? 1;
424
+ const generateMessageId = monotonicFactory();
425
+ const testPump = createTestPump(config);
426
+ const getQueueStub = (shard) => {
427
+ const id = env.WORKFLOW_QUEUE.idFromName(`q:${shard}`);
428
+ return env.WORKFLOW_QUEUE.get(id);
429
+ };
430
+ return {
431
+ async queue(queueName, message, opts) {
432
+ const { kind } = parseQueueName(queueName);
433
+ if (isTestMode()) {
434
+ const existing = testPump.inflight(opts?.idempotencyKey);
435
+ if (existing) return { messageId: existing };
436
+ const messageId = MessageId.parse(`msg_${generateMessageId()}`);
437
+ testPump.push(QUEUE_PATHNAMES[kind], {
438
+ messageId,
439
+ queueName,
440
+ attempt: 1,
441
+ message,
442
+ idempotencyKey: opts?.idempotencyKey
443
+ }, opts?.delaySeconds);
444
+ return { messageId };
445
+ }
446
+ const messageId = MessageId.parse(`msg_${generateMessageId()}`);
447
+ const shard = shardFor(opts?.idempotencyKey ?? messageId, queueShards);
448
+ const outcome = await getQueueStub(shard).enqueue({
449
+ messageId,
450
+ queueName,
451
+ pathname: QUEUE_PATHNAMES[kind],
452
+ body: stringify(message),
453
+ idempotencyKey: opts?.idempotencyKey,
454
+ delaySeconds: opts?.delaySeconds,
455
+ config: {
456
+ targetBaseUrl: resolveBaseUrl(config),
457
+ queueShards
458
+ }
459
+ });
460
+ if (!outcome.ok) throw new WorkflowWorldError(outcome.message, { status: 409 });
461
+ return { messageId: MessageId.parse(outcome.messageId) };
462
+ },
463
+ createQueueHandler(queueNamePrefix, handler) {
464
+ return async (req) => {
465
+ const reqQueueName = req.headers.get("x-vqs-queue-name");
466
+ const reqMessageId = req.headers.get("x-vqs-message-id");
467
+ const attemptStr = req.headers.get("x-vqs-message-attempt");
468
+ if (!reqQueueName || !reqMessageId || !attemptStr || !req.body) return Response.json({ error: "Missing required headers or body" }, { status: 400 });
469
+ if (!reqQueueName.startsWith(queueNamePrefix)) return Response.json({ error: "Unhandled queue" }, { status: 400 });
470
+ const attempt = Number.parseInt(attemptStr, 10);
471
+ try {
472
+ const result = await handler(parse(await req.text()), {
473
+ attempt,
474
+ queueName: reqQueueName,
475
+ messageId: reqMessageId
476
+ });
477
+ if (result && typeof result.timeoutSeconds === "number") return Response.json({ timeoutSeconds: result.timeoutSeconds }, {
478
+ status: 503,
479
+ headers: { "Retry-After": String(result.timeoutSeconds) }
480
+ });
481
+ return Response.json({ ok: true });
482
+ } catch (error) {
483
+ if (isPermanentError(error)) {
484
+ debug("[world-celld queue handler] permanent error:", {
485
+ status: error.status,
486
+ error: String(error)
487
+ });
488
+ return Response.json({
489
+ error: String(error),
490
+ permanent: true
491
+ }, { status: error.status });
492
+ }
493
+ const backoffSeconds = computeBackoff(attempt);
494
+ debug("[world-celld queue handler] transient error, will retry:", {
495
+ error: String(error),
496
+ backoffSeconds
497
+ });
498
+ return Response.json({
499
+ error: String(error),
500
+ retryAfter: backoffSeconds
501
+ }, {
502
+ status: 500,
503
+ headers: { "Retry-After": String(backoffSeconds) }
504
+ });
505
+ }
506
+ };
507
+ },
508
+ async getDeploymentId() {
509
+ return deploymentId;
510
+ },
511
+ async start() {
512
+ if (isTestMode()) await testPump.start();
513
+ }
514
+ };
515
+ }
516
+ //#endregion
517
+ //#region src/storage.ts
518
+ function sortableTimestamp(date) {
519
+ return date.getTime().toString().padStart(13, "0");
520
+ }
521
+ function workflowRunIndexKey(run) {
522
+ return `run:${run.workflowName}:${sortableTimestamp(run.createdAt)}:${run.runId}`;
523
+ }
524
+ function globalRunIndexKey(run) {
525
+ return `runall:${sortableTimestamp(run.createdAt)}:${run.runId}`;
526
+ }
527
+ function hookOwner(hook) {
528
+ return {
529
+ runId: hook.runId,
530
+ hookId: hook.hookId
531
+ };
532
+ }
533
+ /**
534
+ * Filter data based on ResolveData parameter.
535
+ * When resolveData is 'none', strips specified keys to reduce data transfer.
536
+ */
537
+ function filterData(data, resolveData, keysToStrip) {
538
+ if (resolveData === "none") {
539
+ const newData = { ...data };
540
+ for (const key of keysToStrip) if (key in newData) delete newData[key];
541
+ return newData;
542
+ }
543
+ return data;
544
+ }
545
+ /**
546
+ * Filter hook data based on resolveData parameter
547
+ */
548
+ function filterHookData(hook, resolveData) {
549
+ if (resolveData === "none" && "metadata" in hook) {
550
+ const { metadata: _, ...rest } = hook;
551
+ return {
552
+ metadata: void 0,
553
+ ...rest
554
+ };
555
+ }
556
+ return hook;
557
+ }
558
+ /**
559
+ * Convert a structured applyEvent failure back into the typed error the
560
+ * runtime matches on (via error-name based `.is()` checks). Typed errors
561
+ * cannot cross the DO RPC boundary intact, which is why the DO returns
562
+ * outcome objects instead of throwing.
563
+ */
564
+ function throwOutcomeError(outcome, runId, data) {
565
+ switch (outcome.code) {
566
+ case "RUN_NOT_FOUND": throw new WorkflowRunNotFoundError(runId);
567
+ case "STEP_NOT_FOUND": throw new WorkflowWorldError(outcome.message, { status: 404 });
568
+ case "HOOK_NOT_FOUND": throw new HookNotFoundError(data.correlationId ?? runId);
569
+ case "ENTITY_CONFLICT": throw new EntityConflictError(outcome.message);
570
+ case "RUN_EXPIRED": throw new RunExpiredError(outcome.message);
571
+ case "TOO_EARLY": throw new TooEarlyError(outcome.message, { retryAfter: outcome.retryAfterSeconds });
572
+ case "RUN_NOT_SUPPORTED": throw new RunNotSupportedError(outcome.runSpecVersion ?? 0, SPEC_VERSION_CURRENT);
573
+ case "LEGACY_RUN_NOT_SUPPORTED": throw new WorkflowWorldError(outcome.message, { status: 422 });
574
+ }
575
+ }
576
+ const parseRun = (run) => WorkflowRunSchema.parse(compact(run));
577
+ const parseStep = (step) => StepSchema.parse(compact(step));
578
+ const parseHook = (hook) => HookSchema.parse(compact(hook));
579
+ const parseEvent = (event) => EventSchema.parse(compact(event));
580
+ function createStorage(config) {
581
+ const { env } = config;
582
+ const ulid = monotonicFactory();
583
+ const getRunDO = (runId) => {
584
+ const id = env.WORKFLOW_DB.idFromName(runId);
585
+ return env.WORKFLOW_DB.get(id);
586
+ };
587
+ const runsGet = async (runId, params) => {
588
+ const run = await getRunDO(runId).getRun();
589
+ if (!run) throw new WorkflowRunNotFoundError(runId);
590
+ return filterData(parseRun(run), params?.resolveData, ["input", "output"]);
591
+ };
592
+ return {
593
+ runs: {
594
+ get: runsGet,
595
+ async list(params) {
596
+ const limit = params?.pagination?.limit ?? 20;
597
+ const prefix = params?.workflowName ? `run:${params.workflowName}:` : "runall:";
598
+ const reverse = params?.pagination?.sortOrder === "desc";
599
+ const matches = [];
600
+ let scanCursor = params?.pagination?.cursor;
601
+ let exhausted = false;
602
+ while (matches.length <= limit && !exhausted) {
603
+ const kvList = await env.WORKFLOW_INDEX.list({
604
+ prefix,
605
+ limit: Math.min(1e3, Math.max(50, limit)),
606
+ cursor: scanCursor,
607
+ reverse
608
+ });
609
+ if (kvList.keys.length === 0) {
610
+ exhausted = true;
611
+ break;
612
+ }
613
+ for (const key of kvList.keys) {
614
+ const meta = await env.WORKFLOW_INDEX.get(key.name);
615
+ if (!meta) continue;
616
+ const { runId } = JSON.parse(meta);
617
+ try {
618
+ const run = await runsGet(runId, { resolveData: params?.resolveData });
619
+ if (!params?.status || run.status === params.status) {
620
+ matches.push({
621
+ key: key.name,
622
+ run
623
+ });
624
+ if (matches.length > limit) break;
625
+ }
626
+ } catch (error) {
627
+ if (!WorkflowRunNotFoundError.is(error)) throw error;
628
+ }
629
+ }
630
+ exhausted = kvList.list_complete;
631
+ scanCursor = kvList.cursor ?? kvList.keys.at(-1)?.name;
632
+ }
633
+ const hasMore = matches.length > limit;
634
+ const page = matches.slice(0, limit);
635
+ return {
636
+ data: page.map(({ run }) => run),
637
+ cursor: hasMore ? page.at(-1)?.key ?? null : null,
638
+ hasMore
639
+ };
640
+ }
641
+ },
642
+ events: {
643
+ async create(runId, data, _params) {
644
+ let effectiveRunId;
645
+ if (data.eventType === "run_created" && (!runId || runId === "")) effectiveRunId = `wrun_${ulid()}`;
646
+ else if (!runId) throw new WorkflowWorldError("runId is required for non-run_created events", { status: 400 });
647
+ else effectiveRunId = runId;
648
+ const stub = getRunDO(effectiveRunId);
649
+ let tokenHolder;
650
+ let hookReservation;
651
+ if (data.eventType === "hook_created") {
652
+ hookReservation = {
653
+ runId: effectiveRunId,
654
+ hookId: data.correlationId
655
+ };
656
+ const reservation = await env.WORKFLOW_INDEX.reserveHookToken(data.eventData.token, hookReservation);
657
+ tokenHolder = reservation.claimed ? null : reservation.holder;
658
+ }
659
+ let outcome;
660
+ try {
661
+ outcome = await stub.applyEvent({
662
+ runId: effectiveRunId,
663
+ data,
664
+ tokenHolder
665
+ });
666
+ } catch (error) {
667
+ if (hookReservation && data.eventType === "hook_created") await env.WORKFLOW_INDEX.releaseHookToken(data.eventData.token, hookReservation);
668
+ throw error;
669
+ }
670
+ if (!outcome.ok) {
671
+ if (hookReservation && data.eventType === "hook_created") await env.WORKFLOW_INDEX.releaseHookToken(data.eventData.token, hookReservation);
672
+ throwOutcomeError(outcome, effectiveRunId, data);
673
+ }
674
+ if (outcome.run) {
675
+ const meta = JSON.stringify({
676
+ runId: effectiveRunId,
677
+ createdAt: outcome.run.createdAt.toISOString(),
678
+ status: outcome.run.status
679
+ });
680
+ await env.WORKFLOW_INDEX.put(workflowRunIndexKey(outcome.run), meta);
681
+ await env.WORKFLOW_INDEX.put(globalRunIndexKey(outcome.run), meta);
682
+ }
683
+ if (outcome.hookToIndex) {
684
+ const serialized = stringify(outcome.hookToIndex);
685
+ await env.WORKFLOW_INDEX.finalizeHookIndexes(outcome.hookToIndex.token, outcome.hookToIndex.hookId, serialized, hookOwner(outcome.hookToIndex));
686
+ } else if (hookReservation && data.eventType === "hook_created") await env.WORKFLOW_INDEX.releaseHookToken(data.eventData.token, hookReservation);
687
+ for (const released of outcome.releasedHooks) await env.WORKFLOW_INDEX.deleteHookIndexes(released.token, released.hookId, {
688
+ runId: effectiveRunId,
689
+ hookId: released.hookId
690
+ });
691
+ if (outcome.event?.correlationId) {
692
+ const correlationKey = `correlation:${encodeURIComponent(outcome.event.correlationId)}:${sortableTimestamp(outcome.event.createdAt)}:${outcome.event.eventId}:${effectiveRunId}`;
693
+ await env.WORKFLOW_INDEX.put(correlationKey, JSON.stringify({
694
+ runId: effectiveRunId,
695
+ eventId: outcome.event.eventId
696
+ }));
697
+ }
698
+ return {
699
+ event: outcome.event,
700
+ run: outcome.run,
701
+ step: outcome.step,
702
+ hook: outcome.hook,
703
+ events: outcome.events
704
+ };
705
+ },
706
+ async get(runId, eventId, _params) {
707
+ const event = await getRunDO(runId).getEvent(eventId);
708
+ if (!event) throw new WorkflowWorldError(`Event not found: ${eventId}`, { status: 404 });
709
+ return parseEvent(event);
710
+ },
711
+ async list(params) {
712
+ const { runId } = params;
713
+ const limit = params?.pagination?.limit ?? 100;
714
+ const result = await getRunDO(runId).listEvents({
715
+ limit,
716
+ cursor: params?.pagination?.cursor || void 0,
717
+ sortOrder: params.pagination?.sortOrder || "asc"
718
+ });
719
+ return {
720
+ data: result.data.map(parseEvent),
721
+ cursor: result.cursor,
722
+ hasMore: result.hasMore
723
+ };
724
+ },
725
+ async listByCorrelationId(params) {
726
+ const limit = params.pagination?.limit ?? 100;
727
+ const prefix = `correlation:${encodeURIComponent(params.correlationId)}:`;
728
+ const listed = await env.WORKFLOW_INDEX.list({
729
+ prefix,
730
+ limit,
731
+ cursor: params.pagination?.cursor,
732
+ reverse: params.pagination?.sortOrder === "desc"
733
+ });
734
+ return {
735
+ data: (await Promise.all(listed.keys.map(async ({ name }) => {
736
+ const raw = await env.WORKFLOW_INDEX.get(name);
737
+ if (!raw) return null;
738
+ const { runId, eventId } = JSON.parse(raw);
739
+ return getRunDO(runId).getEvent(eventId);
740
+ }))).filter((event) => event !== null).map(parseEvent),
741
+ cursor: listed.list_complete ? null : listed.cursor ?? null,
742
+ hasMore: !listed.list_complete
743
+ };
744
+ }
745
+ },
746
+ steps: {
747
+ async get(runId, stepId, params) {
748
+ if (!runId) throw new WorkflowWorldError("runId is required for Cloudflare step lookup", { status: 400 });
749
+ const step = await getRunDO(runId).getStep(stepId);
750
+ if (!step) throw new WorkflowWorldError(`Step not found: ${stepId}`, { status: 404 });
751
+ return filterData(parseStep(step), params?.resolveData, ["input", "output"]);
752
+ },
753
+ async list(params) {
754
+ const { runId } = params;
755
+ const limit = params?.pagination?.limit ?? 20;
756
+ const result = await getRunDO(runId).listSteps({
757
+ limit,
758
+ cursor: params?.pagination?.cursor || void 0,
759
+ sortOrder: params?.pagination?.sortOrder ?? "asc"
760
+ });
761
+ return {
762
+ data: result.data.map((s) => filterData(parseStep(s), params?.resolveData, ["input", "output"])),
763
+ cursor: result.cursor,
764
+ hasMore: result.hasMore
765
+ };
766
+ }
767
+ },
768
+ hooks: {
769
+ async get(hookId, params) {
770
+ const raw = await env.WORKFLOW_INDEX.get(`hookid:${hookId}`);
771
+ if (!raw) throw new HookNotFoundError(hookId);
772
+ return filterHookData(parseHook(parse(raw)), params?.resolveData ?? "all");
773
+ },
774
+ async getByToken(token, params) {
775
+ const raw = await env.WORKFLOW_INDEX.get(`hook:${token}`);
776
+ if (!raw) throw new HookNotFoundError(token);
777
+ return filterHookData(parseHook(parse(raw)), params?.resolveData ?? "all");
778
+ },
779
+ async list(params) {
780
+ if (!params.runId) throw new WorkflowWorldError("runId is required for listing hooks", { status: 400 });
781
+ const runId = params.runId;
782
+ const limit = params?.pagination?.limit ?? 100;
783
+ const result = await getRunDO(runId).listHooks({
784
+ limit,
785
+ cursor: params?.pagination?.cursor || void 0,
786
+ sortOrder: params?.pagination?.sortOrder ?? "asc"
787
+ });
788
+ return {
789
+ data: result.data.map((h) => filterHookData(parseHook(h), params?.resolveData ?? "all")),
790
+ cursor: result.cursor,
791
+ hasMore: result.hasMore
792
+ };
793
+ }
794
+ }
795
+ };
796
+ }
797
+ //#endregion
798
+ //#region src/streamer.ts
799
+ /** Default poll interval while waiting for new chunks on a live stream. */
800
+ const READ_POLL_MS = 100;
801
+ /** Chunks fetched per DO round-trip while reading. */
802
+ const READ_BATCH_SIZE = 32;
803
+ /** Default / maximum page sizes for getStreamChunks. */
804
+ const DEFAULT_CHUNK_PAGE_SIZE = 32;
805
+ const MAX_CHUNK_PAGE_SIZE = 32;
806
+ function toBytes(chunk) {
807
+ return typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
808
+ }
809
+ function createStreamer(config) {
810
+ const { env } = config;
811
+ const readPollMs = config.readPollMs ?? READ_POLL_MS;
812
+ const getStreamDO = (streamName) => {
813
+ const id = env.WORKFLOW_STREAMS.idFromName(`stream:${streamName}`);
814
+ return env.WORKFLOW_STREAMS.get(id);
815
+ };
816
+ const getRunRegistryDO = (runId) => {
817
+ const id = env.WORKFLOW_STREAMS.idFromName(`run-streams:${runId}`);
818
+ return env.WORKFLOW_STREAMS.get(id);
819
+ };
820
+ /** Per-isolate cache so each (runId, stream) pair registers only once. */
821
+ const registeredStreams = /* @__PURE__ */ new Set();
822
+ async function registerStreamForRun(name, runId) {
823
+ const cacheKey = `${runId}\0${name}`;
824
+ if (registeredStreams.has(cacheKey)) return;
825
+ await getRunRegistryDO(runId).registerStream(name);
826
+ registeredStreams.add(cacheKey);
827
+ }
828
+ return {
829
+ async writeToStream(name, runId, chunk) {
830
+ await registerStreamForRun(name, await runId);
831
+ await getStreamDO(name).writeChunk(toBytes(chunk));
832
+ },
833
+ async closeStream(name, runId) {
834
+ await registerStreamForRun(name, await runId);
835
+ await getStreamDO(name).closeStream();
836
+ },
837
+ async readFromStream(name, startIndex = 0) {
838
+ const stub = getStreamDO(name);
839
+ let nextIndex;
840
+ if (startIndex < 0) {
841
+ const info = await stub.getInfo();
842
+ nextIndex = Math.max(0, info.tailIndex + 1 + startIndex);
843
+ } else nextIndex = startIndex;
844
+ let cancelled = false;
845
+ return new ReadableStream({
846
+ async pull(controller) {
847
+ while (true) {
848
+ if (cancelled) return;
849
+ const { chunks, done } = await stub.getChunks({
850
+ startIndex: nextIndex,
851
+ limit: READ_BATCH_SIZE
852
+ });
853
+ if (chunks.length > 0) {
854
+ for (const chunk of chunks) controller.enqueue(chunk);
855
+ nextIndex += chunks.length;
856
+ return;
857
+ }
858
+ if (done) {
859
+ controller.close();
860
+ return;
861
+ }
862
+ await new Promise((resolve) => setTimeout(resolve, readPollMs));
863
+ }
864
+ },
865
+ cancel() {
866
+ cancelled = true;
867
+ }
868
+ });
869
+ },
870
+ async listStreamsByRunId(runId) {
871
+ return getRunRegistryDO(runId).listStreams();
872
+ },
873
+ async getStreamChunks(name, _runId, options) {
874
+ const limit = Math.min(options?.limit ?? DEFAULT_CHUNK_PAGE_SIZE, MAX_CHUNK_PAGE_SIZE);
875
+ const startIndex = options?.cursor ? Number.parseInt(options.cursor, 10) : 0;
876
+ if (Number.isNaN(startIndex) || startIndex < 0) throw new Error(`Invalid stream cursor: ${options?.cursor}`);
877
+ const result = await getStreamDO(name).getChunks({
878
+ startIndex,
879
+ limit: limit + 1
880
+ });
881
+ const hasMore = result.chunks.length > limit;
882
+ return {
883
+ data: result.chunks.slice(0, limit).map((chunk, offset) => ({
884
+ index: startIndex + offset,
885
+ data: chunk
886
+ })),
887
+ cursor: hasMore ? String(startIndex + limit) : null,
888
+ hasMore,
889
+ done: result.done
890
+ };
891
+ },
892
+ async getStreamInfo(name, _runId) {
893
+ return getStreamDO(name).getInfo();
894
+ }
895
+ };
896
+ }
897
+ //#endregion
898
+ //#region src/index.ts
899
+ /**
900
+ * @ewhauser/world-celld — celld World implementation for the Vercel Workflow
901
+ * DevKit. Modeled on vinnymac/worlds packages/world-cloudflare/src/index.ts
902
+ * (Apache-2.0, see NOTICE).
903
+ */
904
+ function createCelldWorld(config) {
905
+ const resolved = resolveConfig(config);
906
+ let env = resolved.env;
907
+ if (!env) {
908
+ const globalEnv = globalThis.CELLD_ENV;
909
+ if (globalEnv) env = globalEnv;
910
+ }
911
+ if (!env) {
912
+ if (resolved.fleetUrl) {
913
+ if (!resolved.secret) throw new Error("world-celld: config.secret (or CELLD_WORLD_SECRET) is required with fleetUrl");
914
+ env = createRemoteEnv({
915
+ fleetUrl: resolved.fleetUrl,
916
+ secret: resolved.secret,
917
+ timeoutMs: resolved.rpcTimeoutMs
918
+ });
919
+ }
920
+ }
921
+ 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");
922
+ const storage = createStorage({
923
+ env: {
924
+ WORKFLOW_DB: env.WORKFLOW_DB,
925
+ WORKFLOW_INDEX: env.WORKFLOW_INDEX
926
+ },
927
+ deploymentId: resolved.deploymentId
928
+ });
929
+ const queue = createQueue({
930
+ env: { WORKFLOW_QUEUE: env.WORKFLOW_QUEUE },
931
+ deploymentId: resolved.deploymentId,
932
+ baseUrl: resolved.baseUrl,
933
+ queueShards: resolved.queueShards
934
+ });
935
+ const streamer = createStreamer({
936
+ env: { WORKFLOW_STREAMS: env.WORKFLOW_STREAMS },
937
+ readPollMs: resolved.readPollMs
938
+ });
939
+ return {
940
+ ...storage,
941
+ ...queue,
942
+ ...streamer,
943
+ specVersion: SPEC_VERSION_CURRENT
944
+ };
945
+ }
946
+ //#endregion
947
+ export { createCelldWorld, createCelldWorld as createWorld };
948
+
949
+ //# sourceMappingURL=index.js.map