@ewhauser/world-celld 0.1.0 → 0.2.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/worker.js CHANGED
@@ -162,7 +162,11 @@ const BINDINGS = {
162
162
  "getEvent",
163
163
  "listEvents",
164
164
  "listSteps",
165
- "listHooks"
165
+ "listHooks",
166
+ "getCleanupStatus",
167
+ "scheduleCleanup",
168
+ "cleanupNow",
169
+ "rearmCleanup"
166
170
  ])
167
171
  },
168
172
  streams: {
@@ -173,7 +177,10 @@ const BINDINGS = {
173
177
  "getChunks",
174
178
  "getInfo",
175
179
  "registerStream",
176
- "listStreams"
180
+ "listStreams",
181
+ "expireRegistry",
182
+ "finalizeRegistry",
183
+ "expireStream"
177
184
  ])
178
185
  },
179
186
  index: {
@@ -182,6 +189,8 @@ const BINDINGS = {
182
189
  "get",
183
190
  "put",
184
191
  "delete",
192
+ "putOwned",
193
+ "expireRun",
185
194
  "list",
186
195
  "reserveHookToken",
187
196
  "finalizeHookIndexes",
@@ -197,7 +206,8 @@ const BINDINGS = {
197
206
  "listDeadLetters",
198
207
  "redriveDeadLetter",
199
208
  "purgeDeadLetters",
200
- "rearmAlarm"
209
+ "rearmAlarm",
210
+ "expireRun"
201
211
  ])
202
212
  }
203
213
  };
@@ -320,6 +330,14 @@ var IndexDO = class extends DurableObject {
320
330
  async put(key, value) {
321
331
  await this.ctx.storage.put(key, value);
322
332
  }
333
+ /** Publish a derived index only while its owning run is not expired. */
334
+ async putOwned(runId, key, value) {
335
+ return await this.ctx.storage.transaction(async (txn) => {
336
+ if (await txn.get(`expired:${runId}`) !== void 0) return { stored: false };
337
+ await txn.put(key, value);
338
+ return { stored: true };
339
+ });
340
+ }
323
341
  async delete(key) {
324
342
  await this.ctx.storage.delete(key);
325
343
  }
@@ -365,6 +383,11 @@ var IndexDO = class extends DurableObject {
365
383
  }
366
384
  async finalizeHookIndexes(token, hookId, serializedHook, owner) {
367
385
  await this.ctx.storage.transaction(async (txn) => {
386
+ if (await txn.get(`expired:${owner.runId}`) !== void 0) {
387
+ const claim = await txn.get(`hookclaim:${token}`);
388
+ if (claim !== void 0 && sameOwner(claim.owner, owner)) await txn.delete(`hookclaim:${token}`);
389
+ return;
390
+ }
368
391
  const indexedHook = await txn.get(`hook:${token}`);
369
392
  if (indexedHook !== void 0 && !sameOwner(ownerFromHook(indexedHook), owner)) throw new Error(`Hook token ${token} is owned by another hook`);
370
393
  const claim = await txn.get(`hookclaim:${token}`);
@@ -396,6 +419,42 @@ var IndexDO = class extends DurableObject {
396
419
  if (claim !== void 0 && sameOwner(claim.owner, owner)) await txn.delete(claimKey);
397
420
  });
398
421
  }
422
+ /**
423
+ * Fence a run and remove all of its known derived indexes atomically.
424
+ * Once the fence exists, putOwned() and hook finalization cannot resurrect
425
+ * entries from a delayed request.
426
+ */
427
+ async expireRun(request) {
428
+ return await this.ctx.storage.transaction(async (txn) => {
429
+ await txn.put(`expired:${request.runId}`, request.expiredAt);
430
+ let deleted = 0;
431
+ for (const key of new Set(request.keys)) if (await txn.delete(key)) deleted++;
432
+ for (const hook of request.hooks) {
433
+ const owner = {
434
+ runId: request.runId,
435
+ hookId: hook.hookId
436
+ };
437
+ const tokenKey = `hook:${hook.token}`;
438
+ const idKey = `hookid:${hook.hookId}`;
439
+ const claimKey = `hookclaim:${hook.token}`;
440
+ const [byToken, byId, claim] = await Promise.all([
441
+ txn.get(tokenKey),
442
+ txn.get(idKey),
443
+ txn.get(claimKey)
444
+ ]);
445
+ if (byToken !== void 0 && sameOwner(ownerFromHook(byToken), owner)) {
446
+ if (await txn.delete(tokenKey)) deleted++;
447
+ }
448
+ if (byId !== void 0 && sameOwner(ownerFromHook(byId), owner)) {
449
+ if (await txn.delete(idKey)) deleted++;
450
+ }
451
+ if (claim !== void 0 && sameOwner(claim.owner, owner)) {
452
+ if (await txn.delete(claimKey)) deleted++;
453
+ }
454
+ }
455
+ return { deleted };
456
+ });
457
+ }
399
458
  };
400
459
  //#endregion
401
460
  //#region src/worker/durable-objects/QueueDO.ts
@@ -418,6 +477,12 @@ function pad(ms) {
418
477
  function dueKey(atMs, messageId) {
419
478
  return `due:${pad(atMs)}:${messageId}`;
420
479
  }
480
+ function runReferenceKey(runId, messageId) {
481
+ return `run:${runId}:${messageId}`;
482
+ }
483
+ function expiredRunKey(runId) {
484
+ return `expired-run:${runId}`;
485
+ }
421
486
  function backoffSeconds(attempt) {
422
487
  return Math.min(60, 2 ** attempt);
423
488
  }
@@ -439,6 +504,11 @@ var QueueDO = class extends DurableObject {
439
504
  const storage = this.ctx.storage;
440
505
  const now = this.#now();
441
506
  return await storage.transaction(async (txn) => {
507
+ if (request.runId && await txn.get(expiredRunKey(request.runId)) !== void 0) return {
508
+ ok: false,
509
+ code: "RUN_EXPIRED",
510
+ message: `Workflow run "${request.runId}" has expired`
511
+ };
442
512
  const pinned = await txn.get("cfg");
443
513
  if (!pinned) await txn.put("cfg", {
444
514
  queueShards: request.config.queueShards,
@@ -469,14 +539,21 @@ var QueueDO = class extends DurableObject {
469
539
  queueName: request.queueName,
470
540
  pathname: request.pathname,
471
541
  body: request.body,
542
+ runId: request.runId,
472
543
  targetBaseUrl: request.config.targetBaseUrl,
473
544
  idempotencyKey: request.idempotencyKey,
474
545
  attempt: 0,
475
546
  enqueuedAt: now
476
547
  };
477
548
  const dueAt = now + Math.max(0, request.delaySeconds ?? 0) * 1e3;
549
+ const scheduleKey = dueKey(dueAt, row.messageId);
478
550
  await txn.put(`msg:${row.messageId}`, row);
479
- await txn.put(dueKey(dueAt, row.messageId), row.messageId);
551
+ await txn.put(scheduleKey, row.messageId);
552
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
553
+ messageId: row.messageId,
554
+ dueKey: scheduleKey,
555
+ idempotencyKey: row.idempotencyKey
556
+ });
480
557
  await this.#armAlarmAtMost(txn, dueAt, now);
481
558
  return {
482
559
  ok: true,
@@ -514,7 +591,16 @@ var QueueDO = class extends DurableObject {
514
591
  for (const [key, deadline] of inflight) if (deadline <= now) {
515
592
  const messageId = key.slice(9);
516
593
  await txn.delete(key);
517
- if (await txn.get(`msg:${messageId}`)) await txn.put(dueKey(now, messageId), messageId);
594
+ const row = await txn.get(`msg:${messageId}`);
595
+ if (row) {
596
+ const scheduleKey = dueKey(now, messageId);
597
+ await txn.put(scheduleKey, messageId);
598
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
599
+ messageId: row.messageId,
600
+ dueKey: scheduleKey,
601
+ idempotencyKey: row.idempotencyKey
602
+ });
603
+ }
518
604
  } else inflightCount++;
519
605
  const rows = [];
520
606
  if (inflightCount < maxInflight) {
@@ -569,8 +655,25 @@ var QueueDO = class extends DurableObject {
569
655
  timeoutSeconds = void 0;
570
656
  }
571
657
  if (typeof timeoutSeconds === "number") await storage.transaction(async (txn) => {
658
+ if (row.runId && await txn.get(expiredRunKey(row.runId)) !== void 0) {
659
+ await txn.delete(`msg:${row.messageId}`);
660
+ await txn.delete(`inflight:${row.messageId}`);
661
+ await txn.delete(runReferenceKey(row.runId, row.messageId));
662
+ if (row.idempotencyKey) {
663
+ const dedupKey = `key:${row.idempotencyKey}`;
664
+ if (await txn.get(dedupKey) === row.messageId) await txn.delete(dedupKey);
665
+ }
666
+ await this.#scheduleNextAlarm(txn, now);
667
+ return;
668
+ }
572
669
  await txn.delete(`inflight:${row.messageId}`);
573
- await txn.put(dueKey(now + timeoutSeconds * 1e3, row.messageId), row.messageId);
670
+ const scheduleKey = dueKey(now + timeoutSeconds * 1e3, row.messageId);
671
+ await txn.put(scheduleKey, row.messageId);
672
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
673
+ messageId: row.messageId,
674
+ dueKey: scheduleKey,
675
+ idempotencyKey: row.idempotencyKey
676
+ });
574
677
  await this.#scheduleNextAlarm(txn, now);
575
678
  });
576
679
  else await this.#retry(row, "HTTP 503");
@@ -587,6 +690,7 @@ var QueueDO = class extends DurableObject {
587
690
  if (row.idempotencyKey) {
588
691
  if (await txn.get(`key:${row.idempotencyKey}`) === row.messageId) await txn.delete(`key:${row.idempotencyKey}`);
589
692
  }
693
+ if (row.runId) await txn.delete(runReferenceKey(row.runId, row.messageId));
590
694
  await this.#scheduleNextAlarm(txn, this.#now());
591
695
  });
592
696
  }
@@ -596,6 +700,17 @@ var QueueDO = class extends DurableObject {
596
700
  const maxAttempts = this.#intVar("QUEUE_MAX_ATTEMPTS", DEFAULT_MAX_ATTEMPTS);
597
701
  const attempt = row.attempt + 1;
598
702
  await storage.transaction(async (txn) => {
703
+ if (row.runId && await txn.get(expiredRunKey(row.runId)) !== void 0) {
704
+ await txn.delete(`msg:${row.messageId}`);
705
+ await txn.delete(`inflight:${row.messageId}`);
706
+ await txn.delete(runReferenceKey(row.runId, row.messageId));
707
+ if (row.idempotencyKey) {
708
+ const dedupKey = `key:${row.idempotencyKey}`;
709
+ if (await txn.get(dedupKey) === row.messageId) await txn.delete(dedupKey);
710
+ }
711
+ await this.#scheduleNextAlarm(txn, now);
712
+ return;
713
+ }
599
714
  await txn.delete(`inflight:${row.messageId}`);
600
715
  if (attempt >= maxAttempts) {
601
716
  const dead = {
@@ -604,11 +719,17 @@ var QueueDO = class extends DurableObject {
604
719
  lastError: reason,
605
720
  failedAt: now
606
721
  };
607
- await txn.put(`dlq:${pad(now)}:${row.messageId}`, dead);
722
+ const deadKey = `dlq:${pad(now)}:${row.messageId}`;
723
+ await txn.put(deadKey, dead);
608
724
  await txn.delete(`msg:${row.messageId}`);
609
725
  if (row.idempotencyKey) {
610
726
  if (await txn.get(`key:${row.idempotencyKey}`) === row.messageId) await txn.delete(`key:${row.idempotencyKey}`);
611
727
  }
728
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
729
+ messageId: row.messageId,
730
+ dlqKey: deadKey,
731
+ idempotencyKey: row.idempotencyKey
732
+ });
612
733
  await this.#scheduleNextAlarm(txn, now);
613
734
  return;
614
735
  }
@@ -617,8 +738,14 @@ var QueueDO = class extends DurableObject {
617
738
  attempt,
618
739
  lastError: reason
619
740
  };
741
+ const scheduleKey = dueKey(now + backoffSeconds(attempt) * 1e3, row.messageId);
620
742
  await txn.put(`msg:${row.messageId}`, updated);
621
- await txn.put(dueKey(now + backoffSeconds(attempt) * 1e3, row.messageId), row.messageId);
743
+ await txn.put(scheduleKey, row.messageId);
744
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
745
+ messageId: row.messageId,
746
+ dueKey: scheduleKey,
747
+ idempotencyKey: row.idempotencyKey
748
+ });
622
749
  await this.#scheduleNextAlarm(txn, now);
623
750
  });
624
751
  }
@@ -702,8 +829,14 @@ var QueueDO = class extends DurableObject {
702
829
  await txn.put(dedupKey, row.messageId);
703
830
  }
704
831
  await txn.put(`msg:${row.messageId}`, row);
705
- await txn.put(dueKey(now, row.messageId), row.messageId);
832
+ const scheduleKey = dueKey(now, row.messageId);
833
+ await txn.put(scheduleKey, row.messageId);
706
834
  await txn.delete(key);
835
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
836
+ messageId: row.messageId,
837
+ dueKey: scheduleKey,
838
+ idempotencyKey: row.idempotencyKey
839
+ });
707
840
  await this.#armAlarmAtMost(txn, now, now);
708
841
  return { ok: true };
709
842
  }
@@ -713,7 +846,11 @@ var QueueDO = class extends DurableObject {
713
846
  async purgeDeadLetters() {
714
847
  return await this.ctx.storage.transaction(async (txn) => {
715
848
  const dlq = await txn.list({ prefix: "dlq:" });
716
- for (const key of dlq.keys()) await txn.delete(key);
849
+ for (const [key, value] of dlq) {
850
+ await txn.delete(key);
851
+ const dead = value;
852
+ if (dead.runId) await txn.delete(runReferenceKey(dead.runId, dead.messageId));
853
+ }
717
854
  return { purged: dlq.size };
718
855
  });
719
856
  }
@@ -725,6 +862,32 @@ var QueueDO = class extends DurableObject {
725
862
  await this.#rearm();
726
863
  return { alarmAt: await this.ctx.storage.getAlarm() };
727
864
  }
865
+ /** Fence a run and purge every live, inflight, and dead-letter message it owns. */
866
+ async expireRun(runId, expiredAt) {
867
+ return { deleted: await this.ctx.storage.transaction(async (txn) => {
868
+ const existing = await txn.get(expiredRunKey(runId));
869
+ const references = await txn.list({ prefix: `run:${runId}:` });
870
+ let count = existing?.deleted ?? 0;
871
+ for (const [key, reference] of references) {
872
+ await txn.delete(`msg:${reference.messageId}`);
873
+ await txn.delete(`inflight:${reference.messageId}`);
874
+ if (reference.dueKey) await txn.delete(reference.dueKey);
875
+ if (reference.dlqKey) await txn.delete(reference.dlqKey);
876
+ if (reference.idempotencyKey) {
877
+ const dedupKey = `key:${reference.idempotencyKey}`;
878
+ if (await txn.get(dedupKey) === reference.messageId) await txn.delete(dedupKey);
879
+ }
880
+ await txn.delete(key);
881
+ count++;
882
+ }
883
+ await txn.put(expiredRunKey(runId), {
884
+ expiredAt,
885
+ deleted: count
886
+ });
887
+ await this.#scheduleNextAlarm(txn, this.#now());
888
+ return count;
889
+ }) };
890
+ }
728
891
  };
729
892
  //#endregion
730
893
  //#region src/worker/durable-objects/StreamDO.ts
@@ -732,6 +895,8 @@ const META_KEY = "meta";
732
895
  const CHUNK_KEY_PREFIX = "chunk:";
733
896
  /** Registry keys used when this DO instance acts as a per-run stream index. */
734
897
  const STREAM_REGISTRY_PREFIX = "stream:";
898
+ const REGISTRY_OWNER_KEY = "registry:owner";
899
+ const REGISTRY_EXPIRED_KEY = "registry:expired";
735
900
  /**
736
901
  * Zero-pad chunk indexes so `storage.list({ prefix })` returns chunks in
737
902
  * write order. 12 digits comfortably exceeds any realistic chunk count.
@@ -756,22 +921,28 @@ var StreamDO = class extends DurableObject {
756
921
  closed: false
757
922
  };
758
923
  }
924
+ assertOwner(meta, runId) {
925
+ if (meta.expiredAt !== void 0) throw new Error(`Workflow run "${runId}" has expired`);
926
+ if (meta.ownerRunId !== void 0 && meta.ownerRunId !== runId) throw new Error(`Stream is owned by workflow run "${meta.ownerRunId}"`);
927
+ }
759
928
  /**
760
929
  * Append a chunk. The index is allocated inside the transaction, so
761
930
  * concurrent writers can never collide or skip.
762
931
  */
763
- async writeChunk(data) {
932
+ async writeChunk(runId, data) {
764
933
  return await this.ctx.storage.transaction(async (txn) => {
765
934
  const meta = await txn.get(META_KEY) ?? {
766
935
  count: 0,
767
936
  closed: false
768
937
  };
938
+ this.assertOwner(meta, runId);
769
939
  if (meta.closed) throw new Error("Cannot write to a closed stream");
770
940
  const index = meta.count;
771
941
  await txn.put(chunkKey(index), data);
772
942
  await txn.put(META_KEY, {
773
943
  count: index + 1,
774
- closed: false
944
+ closed: false,
945
+ ownerRunId: runId
775
946
  });
776
947
  return index;
777
948
  });
@@ -780,14 +951,16 @@ var StreamDO = class extends DurableObject {
780
951
  * Close the stream (idempotent). Readers observe `done: true` once all
781
952
  * previously written chunks have been consumed.
782
953
  */
783
- async closeStream() {
954
+ async closeStream(runId) {
784
955
  await this.ctx.storage.transaction(async (txn) => {
785
956
  const meta = await txn.get(META_KEY) ?? {
786
957
  count: 0,
787
958
  closed: false
788
959
  };
960
+ this.assertOwner(meta, runId);
789
961
  await txn.put(META_KEY, {
790
962
  ...meta,
963
+ ownerRunId: runId,
791
964
  closed: true
792
965
  });
793
966
  });
@@ -797,6 +970,7 @@ var StreamDO = class extends DurableObject {
797
970
  */
798
971
  async getChunks(params) {
799
972
  const meta = await this.getMeta();
973
+ if (meta.expiredAt !== void 0) throw new Error("Stream has expired");
800
974
  const start = Math.max(0, params.startIndex);
801
975
  const entries = await this.ctx.storage.list({
802
976
  prefix: CHUNK_KEY_PREFIX,
@@ -814,6 +988,7 @@ var StreamDO = class extends DurableObject {
814
988
  */
815
989
  async getInfo() {
816
990
  const meta = await this.getMeta();
991
+ if (meta.expiredAt !== void 0) throw new Error("Stream has expired");
817
992
  return {
818
993
  tailIndex: meta.count - 1,
819
994
  done: meta.closed
@@ -822,16 +997,66 @@ var StreamDO = class extends DurableObject {
822
997
  /**
823
998
  * Register a stream name against this per-run registry instance.
824
999
  */
825
- async registerStream(name) {
826
- await this.ctx.storage.put(`${STREAM_REGISTRY_PREFIX}${name}`, true);
1000
+ async registerStream(runId, name) {
1001
+ await this.ctx.storage.transaction(async (txn) => {
1002
+ if (await txn.get(REGISTRY_EXPIRED_KEY) !== void 0) throw new Error(`Workflow run "${runId}" has expired`);
1003
+ const owner = await txn.get(REGISTRY_OWNER_KEY);
1004
+ if (owner !== void 0 && owner !== runId) throw new Error(`Stream registry is owned by workflow run "${owner}"`);
1005
+ await txn.put(REGISTRY_OWNER_KEY, runId);
1006
+ await txn.put(`${STREAM_REGISTRY_PREFIX}${name}`, true);
1007
+ });
827
1008
  }
828
1009
  /**
829
1010
  * List stream names registered against this per-run registry instance.
830
1011
  */
831
1012
  async listStreams() {
1013
+ if (await this.ctx.storage.get(REGISTRY_EXPIRED_KEY) !== void 0) throw new Error("Workflow run streams have expired");
832
1014
  const entries = await this.ctx.storage.list({ prefix: STREAM_REGISTRY_PREFIX });
833
1015
  return Array.from(entries.keys()).map((key) => key.slice(7));
834
1016
  }
1017
+ /** Fence a run's registry before any stream cells are removed. */
1018
+ async expireRegistry(runId, expiredAt) {
1019
+ return await this.ctx.storage.transaction(async (txn) => {
1020
+ const owner = await txn.get(REGISTRY_OWNER_KEY);
1021
+ if (owner !== void 0 && owner !== runId) throw new Error(`Stream registry is owned by workflow run "${owner}"`);
1022
+ await txn.put(REGISTRY_OWNER_KEY, runId);
1023
+ await txn.put(REGISTRY_EXPIRED_KEY, expiredAt);
1024
+ const entries = await txn.list({ prefix: STREAM_REGISTRY_PREFIX });
1025
+ return { streams: Array.from(entries.keys()).map((key) => key.slice(7)) };
1026
+ });
1027
+ }
1028
+ /** Remove the registry payload after every referenced stream was fenced. */
1029
+ async finalizeRegistry(runId) {
1030
+ await this.ctx.storage.transaction(async (txn) => {
1031
+ const owner = await txn.get(REGISTRY_OWNER_KEY);
1032
+ const expiredAt = await txn.get(REGISTRY_EXPIRED_KEY);
1033
+ if (owner !== runId || expiredAt === void 0) throw new Error(`Stream registry for workflow run "${runId}" is not expired`);
1034
+ const entries = await txn.list({ prefix: STREAM_REGISTRY_PREFIX });
1035
+ for (const key of entries.keys()) await txn.delete(key);
1036
+ });
1037
+ }
1038
+ /** Delete stream payload while leaving a small ownership tombstone. */
1039
+ async expireStream(runId, expiredAt) {
1040
+ return await this.ctx.storage.transaction(async (txn) => {
1041
+ const meta = await txn.get(META_KEY) ?? {
1042
+ count: 0,
1043
+ closed: false
1044
+ };
1045
+ if (meta.ownerRunId !== void 0 && meta.ownerRunId !== runId) throw new Error(`Stream is owned by workflow run "${meta.ownerRunId}"`);
1046
+ const chunks = await txn.list({ prefix: CHUNK_KEY_PREFIX });
1047
+ for (const key of chunks.keys()) await txn.delete(key);
1048
+ await txn.put(META_KEY, {
1049
+ count: 0,
1050
+ closed: true,
1051
+ ownerRunId: runId,
1052
+ expiredAt
1053
+ });
1054
+ return {
1055
+ deleted: true,
1056
+ chunks: chunks.size
1057
+ };
1058
+ });
1059
+ }
835
1060
  };
836
1061
  //#endregion
837
1062
  //#region src/apply-event.ts
@@ -1367,7 +1592,52 @@ async function applyEvent(store, ctx) {
1367
1592
  }
1368
1593
  }
1369
1594
  //#endregion
1595
+ //#region src/retention.ts
1596
+ const CLEANUP_RECORD_KEY = "retention:cleanup";
1597
+ const TOMBSTONE_KEY = "retention:tombstone";
1598
+ const INDEX_MARKER_PREFIX = "retention:index:";
1599
+ const HOOK_MARKER_PREFIX = "retention:hook:";
1600
+ function sortableTimestamp(date) {
1601
+ return date.getTime().toString().padStart(13, "0");
1602
+ }
1603
+ function workflowRunIndexKey(run) {
1604
+ return `run:${run.workflowName}:${sortableTimestamp(run.createdAt)}:${run.runId}`;
1605
+ }
1606
+ function globalRunIndexKey(run) {
1607
+ return `runall:${sortableTimestamp(run.createdAt)}:${run.runId}`;
1608
+ }
1609
+ function correlationIndexKey(correlationId, createdAt, eventId, runId) {
1610
+ return `correlation:${encodeURIComponent(correlationId)}:${sortableTimestamp(createdAt)}:${eventId}:${runId}`;
1611
+ }
1612
+ function indexMarkerKey(indexKey) {
1613
+ return `${INDEX_MARKER_PREFIX}${encodeURIComponent(indexKey)}`;
1614
+ }
1615
+ function hookMarkerKey(reference) {
1616
+ return `${HOOK_MARKER_PREFIX}${encodeURIComponent(reference.hookId)}:${encodeURIComponent(reference.token)}`;
1617
+ }
1618
+ function cleanupTombstone(record, now) {
1619
+ return {
1620
+ version: 1,
1621
+ runId: record.runId,
1622
+ terminalStatus: record.terminalStatus,
1623
+ completedAt: record.completedAt,
1624
+ expiredAt: record.dueAt,
1625
+ tombstonedAt: now
1626
+ };
1627
+ }
1628
+ function expiredRead(tombstone) {
1629
+ return {
1630
+ ok: false,
1631
+ code: "RUN_EXPIRED",
1632
+ message: `Workflow run "${tombstone.runId}" expired at ${tombstone.expiredAt.toISOString()}`,
1633
+ tombstone
1634
+ };
1635
+ }
1636
+ //#endregion
1370
1637
  //#region src/worker/durable-objects/WorkflowRunDO.ts
1638
+ const PAYLOAD_DELETE_BATCH = 256;
1639
+ const STREAM_DELETE_CONCURRENCY = 16;
1640
+ const CLEANUP_RETRY_MAX_MS = 36e5;
1371
1641
  /**
1372
1642
  * Current schema version for DO storage.
1373
1643
  * Increment when making breaking changes to the storage layout.
@@ -1452,104 +1722,347 @@ function storeFrom(storage) {
1452
1722
  */
1453
1723
  var WorkflowRunDO = class extends DurableObject {
1454
1724
  schemaReady = null;
1455
- /**
1456
- * Lazily ensure schema migrations have run.
1457
- * Memoized so it only runs once per DO instantiation.
1458
- */
1725
+ now() {
1726
+ const clock = this.env?.clock;
1727
+ return typeof clock === "function" ? clock() : Date.now();
1728
+ }
1459
1729
  ensureSchema() {
1460
1730
  if (!this.schemaReady) this.schemaReady = ensureSchemaUpToDate(this.ctx.storage);
1461
1731
  return this.schemaReady;
1462
1732
  }
1463
- /**
1464
- * Apply an event: validate, guard, append the event and mutate the entity
1465
- * in ONE storage transaction. Guard failures are returned as structured
1466
- * outcomes (not thrown) because typed errors do not survive the RPC
1467
- * boundary with their `name` intact.
1468
- */
1733
+ async expiredTombstone(storage, now = this.now()) {
1734
+ const tombstone = await storage.get(TOMBSTONE_KEY);
1735
+ if (tombstone) return tombstone;
1736
+ const cleanup = await storage.get(CLEANUP_RECORD_KEY);
1737
+ if (!cleanup || cleanup.phase === "retained" && cleanup.dueAt.getTime() > now) return null;
1738
+ return cleanupTombstone(cleanup, cleanup.tombstonedAt ?? cleanup.dueAt);
1739
+ }
1740
+ async read(reader) {
1741
+ await this.ensureSchema();
1742
+ const tombstone = await this.expiredTombstone(this.ctx.storage);
1743
+ if (tombstone) return expiredRead(tombstone);
1744
+ return {
1745
+ ok: true,
1746
+ value: await reader()
1747
+ };
1748
+ }
1749
+ /** Apply an event and capture all retention metadata in the same transaction. */
1469
1750
  async applyEvent(request) {
1470
1751
  await this.ensureSchema();
1471
1752
  return await this.ctx.storage.transaction(async (txn) => {
1753
+ const now = new Date(this.now());
1754
+ const tombstone = await this.expiredTombstone(txn, now.getTime());
1755
+ if (tombstone) return {
1756
+ ok: false,
1757
+ code: "RUN_EXPIRED",
1758
+ message: `Workflow run "${request.runId}" expired at ${tombstone.expiredAt.toISOString()}`
1759
+ };
1472
1760
  let eventSequence = await txn.get("event_sequence") ?? 0;
1473
1761
  const outcome = await applyEvent(storeFrom(txn), {
1474
1762
  ...request,
1475
1763
  nextEventId: () => `wevt_z${String(++eventSequence).padStart(20, "0")}`,
1476
- now: /* @__PURE__ */ new Date()
1764
+ now
1477
1765
  });
1478
- if (outcome.ok) await txn.put("event_sequence", eventSequence);
1766
+ if (!outcome.ok) return outcome;
1767
+ await txn.put("event_sequence", eventSequence);
1768
+ if (outcome.event?.correlationId) {
1769
+ const key = correlationIndexKey(outcome.event.correlationId, outcome.event.createdAt, outcome.event.eventId, request.runId);
1770
+ await txn.put(indexMarkerKey(key), key);
1771
+ }
1772
+ if (outcome.hookToIndex) {
1773
+ const reference = {
1774
+ hookId: outcome.hookToIndex.hookId,
1775
+ token: outcome.hookToIndex.token
1776
+ };
1777
+ await txn.put(hookMarkerKey(reference), reference);
1778
+ }
1779
+ for (const reference of outcome.releasedHooks) await txn.put(hookMarkerKey(reference), reference);
1780
+ const retentionMs = request.cleanup?.retentionMs ?? 0;
1781
+ if (retentionMs > 0 && outcome.run && isTerminalWorkflowRunStatus(outcome.run.status) && !await txn.get("retention:cleanup")) {
1782
+ const terminalRun = outcome.run;
1783
+ const dueAt = new Date(terminalRun.completedAt.getTime() + retentionMs);
1784
+ const run = WorkflowRunSchema.parse({
1785
+ ...terminalRun,
1786
+ expiredAt: dueAt
1787
+ });
1788
+ const cleanup = {
1789
+ version: 1,
1790
+ runId: run.runId,
1791
+ workflowName: run.workflowName,
1792
+ createdAt: run.createdAt,
1793
+ completedAt: run.completedAt,
1794
+ terminalStatus: run.status,
1795
+ dueAt,
1796
+ queueShards: request.cleanup?.queueShards ?? 1,
1797
+ phase: "retained",
1798
+ attempts: 0,
1799
+ deletedPayloadKeys: 0,
1800
+ deletedStreams: 0,
1801
+ deletedQueueMessages: 0
1802
+ };
1803
+ await txn.put("run", run);
1804
+ await txn.put(CLEANUP_RECORD_KEY, cleanup);
1805
+ await txn.setAlarm(dueAt);
1806
+ outcome.run = run;
1807
+ }
1479
1808
  return outcome;
1480
1809
  });
1481
1810
  }
1482
- /**
1483
- * Get the workflow run
1484
- */
1485
1811
  async getRun() {
1486
- await this.ensureSchema();
1487
- return await this.ctx.storage.get("run") ?? null;
1812
+ return this.read(async () => await this.ctx.storage.get("run") ?? null);
1488
1813
  }
1489
- /**
1490
- * Get a specific step
1491
- */
1492
1814
  async getStep(stepId) {
1493
- await this.ensureSchema();
1494
- return await this.ctx.storage.get(`step:${stepId}`) ?? null;
1815
+ return this.read(async () => await this.ctx.storage.get(`step:${stepId}`) ?? null);
1495
1816
  }
1496
- /**
1497
- * List steps with real limit/cursor pagination (cursor = last stepId).
1498
- */
1499
1817
  async listSteps(params) {
1500
- await this.ensureSchema();
1501
- return listByCreationTime(storeFrom(this.ctx.storage), STEP_CREATED_KEY_PREFIX, STEP_KEY_PREFIX, {
1818
+ return this.read(() => listByCreationTime(storeFrom(this.ctx.storage), STEP_CREATED_KEY_PREFIX, STEP_KEY_PREFIX, {
1502
1819
  limit: params?.limit ?? 20,
1503
1820
  cursor: params?.cursor,
1504
1821
  sortOrder: params?.sortOrder ?? "asc"
1505
- });
1822
+ }));
1506
1823
  }
1507
- /**
1508
- * Get a specific event
1509
- */
1510
1824
  async getEvent(eventId) {
1511
- await this.ensureSchema();
1512
- return await this.ctx.storage.get(`event:${eventId}`) ?? null;
1825
+ return this.read(async () => await this.ctx.storage.get(`event:${eventId}`) ?? null);
1513
1826
  }
1514
- /**
1515
- * List events with real limit/cursor pagination. Events are keyed by their
1516
- * monotonic ULID eventId, so storage order == event order.
1517
- */
1518
1827
  async listEvents(params) {
1519
- await this.ensureSchema();
1520
- return listByPrefix(storeFrom(this.ctx.storage), EVENT_KEY_PREFIX, {
1828
+ return this.read(() => listByPrefix(storeFrom(this.ctx.storage), EVENT_KEY_PREFIX, {
1521
1829
  limit: params?.limit ?? 100,
1522
1830
  cursor: params?.cursor,
1523
1831
  sortOrder: params?.sortOrder ?? "asc"
1524
- }, (event) => event.eventId);
1832
+ }, (event) => event.eventId));
1525
1833
  }
1526
- /**
1527
- * List hooks with real limit/cursor pagination (cursor = last hookId).
1528
- */
1529
1834
  async listHooks(params) {
1530
- await this.ensureSchema();
1531
- return listByCreationTime(storeFrom(this.ctx.storage), HOOK_CREATED_KEY_PREFIX, HOOK_KEY_PREFIX, {
1835
+ return this.read(() => listByCreationTime(storeFrom(this.ctx.storage), HOOK_CREATED_KEY_PREFIX, HOOK_KEY_PREFIX, {
1532
1836
  limit: params?.limit ?? 100,
1533
1837
  cursor: params?.cursor,
1534
1838
  sortOrder: params?.sortOrder ?? "asc"
1839
+ }));
1840
+ }
1841
+ async getCleanupStatus() {
1842
+ await this.ensureSchema();
1843
+ return await this.ctx.storage.get("retention:cleanup") ?? null;
1844
+ }
1845
+ /** Explicitly schedule an existing terminal run, used for opt-in backfills. */
1846
+ async scheduleCleanup(request) {
1847
+ await this.ensureSchema();
1848
+ if (request.retentionMs <= 0) return this.getCleanupStatus();
1849
+ return await this.ctx.storage.transaction(async (txn) => {
1850
+ const existing = await txn.get(CLEANUP_RECORD_KEY);
1851
+ if (existing) return existing;
1852
+ if (await txn.get("retention:tombstone")) return null;
1853
+ const current = await txn.get("run");
1854
+ if (!current || !isTerminalWorkflowRunStatus(current.status)) return null;
1855
+ const terminalRun = current;
1856
+ const dueAt = new Date(terminalRun.completedAt.getTime() + request.retentionMs);
1857
+ const run = WorkflowRunSchema.parse({
1858
+ ...terminalRun,
1859
+ expiredAt: dueAt
1860
+ });
1861
+ const cleanup = {
1862
+ version: 1,
1863
+ runId: run.runId,
1864
+ workflowName: run.workflowName,
1865
+ createdAt: run.createdAt,
1866
+ completedAt: run.completedAt,
1867
+ terminalStatus: run.status,
1868
+ dueAt,
1869
+ queueShards: request.queueShards,
1870
+ phase: "retained",
1871
+ attempts: 0,
1872
+ deletedPayloadKeys: 0,
1873
+ deletedStreams: 0,
1874
+ deletedQueueMessages: 0
1875
+ };
1876
+ await txn.put("run", run);
1877
+ await txn.put(CLEANUP_RECORD_KEY, cleanup);
1878
+ await txn.setAlarm(dueAt.getTime() <= this.now() ? this.now() + 1 : dueAt);
1879
+ return cleanup;
1880
+ });
1881
+ }
1882
+ async cleanupNow(request) {
1883
+ await this.ensureSchema();
1884
+ if (!await this.ctx.storage.get("retention:cleanup")) await this.scheduleCleanup({
1885
+ ...request,
1886
+ retentionMs: Math.max(1, request.retentionMs)
1887
+ });
1888
+ await this.ctx.storage.transaction(async (txn) => {
1889
+ const cleanup = await txn.get(CLEANUP_RECORD_KEY);
1890
+ if (!cleanup || cleanup.phase === "tombstoned") return;
1891
+ const dueAt = new Date(this.now());
1892
+ const run = await txn.get("run");
1893
+ if (run) await txn.put("run", WorkflowRunSchema.parse({
1894
+ ...run,
1895
+ expiredAt: dueAt
1896
+ }));
1897
+ await txn.put(CLEANUP_RECORD_KEY, {
1898
+ ...cleanup,
1899
+ dueAt
1900
+ });
1901
+ await txn.setAlarm(this.now() + 1);
1902
+ });
1903
+ try {
1904
+ await this.executeCleanup();
1905
+ } catch (error) {
1906
+ await this.recordCleanupFailure(error);
1907
+ }
1908
+ return this.getCleanupStatus();
1909
+ }
1910
+ async rearmCleanup() {
1911
+ await this.ensureSchema();
1912
+ const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
1913
+ if (!cleanup) return null;
1914
+ if (cleanup.phase === "tombstoned") await this.ctx.storage.deleteAlarm();
1915
+ else await this.ctx.storage.setAlarm(Math.max(this.now() + 1, cleanup.dueAt.getTime()));
1916
+ return cleanup;
1917
+ }
1918
+ async alarm() {
1919
+ await this.ensureSchema();
1920
+ try {
1921
+ await this.executeCleanup();
1922
+ } catch (error) {
1923
+ await this.recordCleanupFailure(error);
1924
+ }
1925
+ }
1926
+ namespace(name) {
1927
+ const namespace = this.env?.[name];
1928
+ if (!namespace || typeof namespace === "function") throw new Error(`world-celld retention missing binding ${name}`);
1929
+ return namespace;
1930
+ }
1931
+ async setCleanupPhase(expected, next, updates = {}) {
1932
+ await this.ctx.storage.transaction(async (txn) => {
1933
+ const cleanup = await txn.get(CLEANUP_RECORD_KEY);
1934
+ if (!cleanup || cleanup.phase !== expected) return;
1935
+ await txn.put(CLEANUP_RECORD_KEY, {
1936
+ ...cleanup,
1937
+ ...updates,
1938
+ phase: next,
1939
+ attempts: 0,
1940
+ lastError: void 0
1941
+ });
1942
+ });
1943
+ }
1944
+ async executeCleanup() {
1945
+ for (;;) {
1946
+ const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
1947
+ if (!cleanup) {
1948
+ await this.ctx.storage.deleteAlarm();
1949
+ return;
1950
+ }
1951
+ if (cleanup.phase === "tombstoned") {
1952
+ await this.ctx.storage.deleteAlarm();
1953
+ return;
1954
+ }
1955
+ if (cleanup.phase === "retained") {
1956
+ if (cleanup.dueAt.getTime() > this.now()) {
1957
+ await this.ctx.storage.setAlarm(cleanup.dueAt);
1958
+ return;
1959
+ }
1960
+ await this.setCleanupPhase("retained", "index");
1961
+ continue;
1962
+ }
1963
+ if (cleanup.phase === "index") {
1964
+ const [indexMarkers, hookMarkers] = await Promise.all([this.ctx.storage.list({ prefix: INDEX_MARKER_PREFIX }), this.ctx.storage.list({ prefix: HOOK_MARKER_PREFIX })]);
1965
+ const indexNamespace = this.namespace("WORKFLOW_INDEX");
1966
+ await indexNamespace.get(indexNamespace.idFromName("index")).expireRun({
1967
+ runId: cleanup.runId,
1968
+ keys: [
1969
+ workflowRunIndexKey(cleanup),
1970
+ globalRunIndexKey(cleanup),
1971
+ ...indexMarkers.values()
1972
+ ],
1973
+ hooks: Array.from(hookMarkers.values()),
1974
+ expiredAt: cleanup.dueAt.getTime()
1975
+ });
1976
+ await this.setCleanupPhase("index", "streams");
1977
+ continue;
1978
+ }
1979
+ if (cleanup.phase === "streams") {
1980
+ const streamsNamespace = this.namespace("WORKFLOW_STREAMS");
1981
+ const registry = streamsNamespace.get(streamsNamespace.idFromName(`run-streams:${cleanup.runId}`));
1982
+ const { streams } = await registry.expireRegistry(cleanup.runId, cleanup.dueAt.getTime());
1983
+ let deletedStreams = streams.length === 0 ? cleanup.deletedStreams : 0;
1984
+ for (let offset = 0; offset < streams.length; offset += STREAM_DELETE_CONCURRENCY) {
1985
+ const batch = streams.slice(offset, offset + STREAM_DELETE_CONCURRENCY);
1986
+ const results = await Promise.all(batch.map((name) => {
1987
+ return streamsNamespace.get(streamsNamespace.idFromName(`stream:${name}`)).expireStream(cleanup.runId, cleanup.dueAt.getTime());
1988
+ }));
1989
+ deletedStreams += results.filter((result) => result.deleted).length;
1990
+ }
1991
+ await this.ctx.storage.transaction(async (txn) => {
1992
+ const current = await txn.get(CLEANUP_RECORD_KEY);
1993
+ if (current?.phase === "streams") await txn.put(CLEANUP_RECORD_KEY, {
1994
+ ...current,
1995
+ deletedStreams
1996
+ });
1997
+ });
1998
+ await registry.finalizeRegistry(cleanup.runId);
1999
+ await this.setCleanupPhase("streams", "queues");
2000
+ continue;
2001
+ }
2002
+ if (cleanup.phase === "queues") {
2003
+ const queues = this.namespace("WORKFLOW_QUEUE");
2004
+ let deletedQueueMessages = 0;
2005
+ for (let shard = 0; shard < cleanup.queueShards; shard++) {
2006
+ const result = await queues.get(queues.idFromName(`q:${shard}`)).expireRun(cleanup.runId, cleanup.dueAt.getTime());
2007
+ deletedQueueMessages += result.deleted;
2008
+ }
2009
+ await this.setCleanupPhase("queues", "payload", { deletedQueueMessages });
2010
+ continue;
2011
+ }
2012
+ if (cleanup.phase === "payload") {
2013
+ if (!await this.deletePayloadBatch()) return;
2014
+ }
2015
+ }
2016
+ }
2017
+ async deletePayloadBatch() {
2018
+ return await this.ctx.storage.transaction(async (txn) => {
2019
+ const cleanup = await txn.get(CLEANUP_RECORD_KEY);
2020
+ if (!cleanup || cleanup.phase !== "payload") return true;
2021
+ const entries = await txn.list({ limit: 259 });
2022
+ const keys = Array.from(entries.keys()).filter((key) => key !== "retention:cleanup" && key !== "retention:tombstone" && key !== "schema_version").slice(0, PAYLOAD_DELETE_BATCH);
2023
+ const now = new Date(this.now());
2024
+ await txn.put(TOMBSTONE_KEY, cleanupTombstone(cleanup, now));
2025
+ for (const key of keys) await txn.delete(key);
2026
+ if (keys.length > 0) {
2027
+ await txn.put(CLEANUP_RECORD_KEY, {
2028
+ ...cleanup,
2029
+ deletedPayloadKeys: cleanup.deletedPayloadKeys + keys.length,
2030
+ lastError: void 0
2031
+ });
2032
+ await txn.setAlarm(this.now() + 1);
2033
+ return false;
2034
+ }
2035
+ await txn.put(CLEANUP_RECORD_KEY, {
2036
+ ...cleanup,
2037
+ phase: "tombstoned",
2038
+ attempts: 0,
2039
+ lastError: void 0,
2040
+ tombstonedAt: now
2041
+ });
2042
+ await txn.deleteAlarm();
2043
+ return true;
2044
+ });
2045
+ }
2046
+ async recordCleanupFailure(error) {
2047
+ await this.ctx.storage.transaction(async (txn) => {
2048
+ const cleanup = await txn.get(CLEANUP_RECORD_KEY);
2049
+ if (!cleanup || cleanup.phase === "tombstoned") return;
2050
+ const attempts = cleanup.attempts + 1;
2051
+ const delay = Math.min(CLEANUP_RETRY_MAX_MS, 1e3 * 2 ** Math.min(attempts - 1, 12));
2052
+ await txn.put(CLEANUP_RECORD_KEY, {
2053
+ ...cleanup,
2054
+ attempts,
2055
+ lastError: error instanceof Error ? error.message : String(error),
2056
+ lastAttemptAt: new Date(this.now())
2057
+ });
2058
+ await txn.setAlarm(this.now() + delay);
1535
2059
  });
1536
2060
  }
1537
- /**
1538
- * Claim a queue-message dedup slot. Used on DO instances dedicated to a
1539
- * single idempotency key (`claim:<queueName>:<idempotencyKey>`).
1540
- *
1541
- * - Unclaimed -> claim and proceed.
1542
- * - Claimed by the SAME messageId -> redelivery of the same message
1543
- * (previous attempt failed transiently) -> proceed.
1544
- * - Claimed by a DIFFERENT messageId -> concurrent duplicate -> reject,
1545
- * unless the claim is stale (holder crashed / went to the DLQ without
1546
- * releasing), in which case the claim is stolen.
1547
- */
1548
2061
  async claimInflight(params) {
1549
2062
  await this.ensureSchema();
1550
2063
  return await this.ctx.storage.transaction(async (txn) => {
1551
2064
  const existing = await txn.get("claim");
1552
- const now = Date.now();
2065
+ const now = this.now();
1553
2066
  if (existing && existing.messageId !== params.messageId && now - existing.claimedAt < params.staleMs) return { claimed: false };
1554
2067
  await txn.put("claim", {
1555
2068
  messageId: params.messageId,
@@ -1558,9 +2071,6 @@ var WorkflowRunDO = class extends DurableObject {
1558
2071
  return { claimed: true };
1559
2072
  });
1560
2073
  }
1561
- /**
1562
- * Release a queue-message dedup claim after the message was fully handled.
1563
- */
1564
2074
  async releaseInflight() {
1565
2075
  await this.ensureSchema();
1566
2076
  await this.ctx.storage.delete("claim");