@ewhauser/world-celld 0.1.1 → 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/testing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { i as parse, n as rpcParse, r as rpcStringify, t as compact } from "./util-BvWlkqgV.js";
1
+ import { a as cleanupTombstone, c as globalRunIndexKey, d as workflowRunIndexKey, f as compact, h as parse, i as TOMBSTONE_KEY, l as hookMarkerKey, m as rpcStringify, n as HOOK_MARKER_PREFIX, o as correlationIndexKey, p as rpcParse, r as INDEX_MARKER_PREFIX, s as expiredRead, t as CLEANUP_RECORD_KEY, u as indexMarkerKey } from "./retention-bLY1vcOu.js";
2
2
  import { EventSchema, HookSchema, SPEC_VERSION_CURRENT, StepSchema, WorkflowRunSchema, isLegacySpecVersion, isTerminalStepStatus, isTerminalWorkflowRunStatus, requiresNewerWorld } from "@workflow/world";
3
3
  import http from "node:http";
4
4
  //#region src/testing/fake-cell.ts
@@ -309,7 +309,11 @@ const BINDINGS = {
309
309
  "getEvent",
310
310
  "listEvents",
311
311
  "listSteps",
312
- "listHooks"
312
+ "listHooks",
313
+ "getCleanupStatus",
314
+ "scheduleCleanup",
315
+ "cleanupNow",
316
+ "rearmCleanup"
313
317
  ])
314
318
  },
315
319
  streams: {
@@ -320,7 +324,10 @@ const BINDINGS = {
320
324
  "getChunks",
321
325
  "getInfo",
322
326
  "registerStream",
323
- "listStreams"
327
+ "listStreams",
328
+ "expireRegistry",
329
+ "finalizeRegistry",
330
+ "expireStream"
324
331
  ])
325
332
  },
326
333
  index: {
@@ -329,6 +336,8 @@ const BINDINGS = {
329
336
  "get",
330
337
  "put",
331
338
  "delete",
339
+ "putOwned",
340
+ "expireRun",
332
341
  "list",
333
342
  "reserveHookToken",
334
343
  "finalizeHookIndexes",
@@ -344,7 +353,8 @@ const BINDINGS = {
344
353
  "listDeadLetters",
345
354
  "redriveDeadLetter",
346
355
  "purgeDeadLetters",
347
- "rearmAlarm"
356
+ "rearmAlarm",
357
+ "expireRun"
348
358
  ])
349
359
  }
350
360
  };
@@ -478,6 +488,14 @@ var IndexDO = class extends DurableObject {
478
488
  async put(key, value) {
479
489
  await this.ctx.storage.put(key, value);
480
490
  }
491
+ /** Publish a derived index only while its owning run is not expired. */
492
+ async putOwned(runId, key, value) {
493
+ return await this.ctx.storage.transaction(async (txn) => {
494
+ if (await txn.get(`expired:${runId}`) !== void 0) return { stored: false };
495
+ await txn.put(key, value);
496
+ return { stored: true };
497
+ });
498
+ }
481
499
  async delete(key) {
482
500
  await this.ctx.storage.delete(key);
483
501
  }
@@ -523,6 +541,11 @@ var IndexDO = class extends DurableObject {
523
541
  }
524
542
  async finalizeHookIndexes(token, hookId, serializedHook, owner) {
525
543
  await this.ctx.storage.transaction(async (txn) => {
544
+ if (await txn.get(`expired:${owner.runId}`) !== void 0) {
545
+ const claim = await txn.get(`hookclaim:${token}`);
546
+ if (claim !== void 0 && sameOwner(claim.owner, owner)) await txn.delete(`hookclaim:${token}`);
547
+ return;
548
+ }
526
549
  const indexedHook = await txn.get(`hook:${token}`);
527
550
  if (indexedHook !== void 0 && !sameOwner(ownerFromHook(indexedHook), owner)) throw new Error(`Hook token ${token} is owned by another hook`);
528
551
  const claim = await txn.get(`hookclaim:${token}`);
@@ -554,6 +577,42 @@ var IndexDO = class extends DurableObject {
554
577
  if (claim !== void 0 && sameOwner(claim.owner, owner)) await txn.delete(claimKey);
555
578
  });
556
579
  }
580
+ /**
581
+ * Fence a run and remove all of its known derived indexes atomically.
582
+ * Once the fence exists, putOwned() and hook finalization cannot resurrect
583
+ * entries from a delayed request.
584
+ */
585
+ async expireRun(request) {
586
+ return await this.ctx.storage.transaction(async (txn) => {
587
+ await txn.put(`expired:${request.runId}`, request.expiredAt);
588
+ let deleted = 0;
589
+ for (const key of new Set(request.keys)) if (await txn.delete(key)) deleted++;
590
+ for (const hook of request.hooks) {
591
+ const owner = {
592
+ runId: request.runId,
593
+ hookId: hook.hookId
594
+ };
595
+ const tokenKey = `hook:${hook.token}`;
596
+ const idKey = `hookid:${hook.hookId}`;
597
+ const claimKey = `hookclaim:${hook.token}`;
598
+ const [byToken, byId, claim] = await Promise.all([
599
+ txn.get(tokenKey),
600
+ txn.get(idKey),
601
+ txn.get(claimKey)
602
+ ]);
603
+ if (byToken !== void 0 && sameOwner(ownerFromHook(byToken), owner)) {
604
+ if (await txn.delete(tokenKey)) deleted++;
605
+ }
606
+ if (byId !== void 0 && sameOwner(ownerFromHook(byId), owner)) {
607
+ if (await txn.delete(idKey)) deleted++;
608
+ }
609
+ if (claim !== void 0 && sameOwner(claim.owner, owner)) {
610
+ if (await txn.delete(claimKey)) deleted++;
611
+ }
612
+ }
613
+ return { deleted };
614
+ });
615
+ }
557
616
  };
558
617
  //#endregion
559
618
  //#region src/worker/durable-objects/QueueDO.ts
@@ -576,6 +635,12 @@ function pad(ms) {
576
635
  function dueKey(atMs, messageId) {
577
636
  return `due:${pad(atMs)}:${messageId}`;
578
637
  }
638
+ function runReferenceKey(runId, messageId) {
639
+ return `run:${runId}:${messageId}`;
640
+ }
641
+ function expiredRunKey(runId) {
642
+ return `expired-run:${runId}`;
643
+ }
579
644
  function backoffSeconds(attempt) {
580
645
  return Math.min(60, 2 ** attempt);
581
646
  }
@@ -597,6 +662,11 @@ var QueueDO = class extends DurableObject {
597
662
  const storage = this.ctx.storage;
598
663
  const now = this.#now();
599
664
  return await storage.transaction(async (txn) => {
665
+ if (request.runId && await txn.get(expiredRunKey(request.runId)) !== void 0) return {
666
+ ok: false,
667
+ code: "RUN_EXPIRED",
668
+ message: `Workflow run "${request.runId}" has expired`
669
+ };
600
670
  const pinned = await txn.get("cfg");
601
671
  if (!pinned) await txn.put("cfg", {
602
672
  queueShards: request.config.queueShards,
@@ -627,14 +697,21 @@ var QueueDO = class extends DurableObject {
627
697
  queueName: request.queueName,
628
698
  pathname: request.pathname,
629
699
  body: request.body,
700
+ runId: request.runId,
630
701
  targetBaseUrl: request.config.targetBaseUrl,
631
702
  idempotencyKey: request.idempotencyKey,
632
703
  attempt: 0,
633
704
  enqueuedAt: now
634
705
  };
635
706
  const dueAt = now + Math.max(0, request.delaySeconds ?? 0) * 1e3;
707
+ const scheduleKey = dueKey(dueAt, row.messageId);
636
708
  await txn.put(`msg:${row.messageId}`, row);
637
- await txn.put(dueKey(dueAt, row.messageId), row.messageId);
709
+ await txn.put(scheduleKey, row.messageId);
710
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
711
+ messageId: row.messageId,
712
+ dueKey: scheduleKey,
713
+ idempotencyKey: row.idempotencyKey
714
+ });
638
715
  await this.#armAlarmAtMost(txn, dueAt, now);
639
716
  return {
640
717
  ok: true,
@@ -672,7 +749,16 @@ var QueueDO = class extends DurableObject {
672
749
  for (const [key, deadline] of inflight) if (deadline <= now) {
673
750
  const messageId = key.slice(9);
674
751
  await txn.delete(key);
675
- if (await txn.get(`msg:${messageId}`)) await txn.put(dueKey(now, messageId), messageId);
752
+ const row = await txn.get(`msg:${messageId}`);
753
+ if (row) {
754
+ const scheduleKey = dueKey(now, messageId);
755
+ await txn.put(scheduleKey, messageId);
756
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
757
+ messageId: row.messageId,
758
+ dueKey: scheduleKey,
759
+ idempotencyKey: row.idempotencyKey
760
+ });
761
+ }
676
762
  } else inflightCount++;
677
763
  const rows = [];
678
764
  if (inflightCount < maxInflight) {
@@ -727,8 +813,25 @@ var QueueDO = class extends DurableObject {
727
813
  timeoutSeconds = void 0;
728
814
  }
729
815
  if (typeof timeoutSeconds === "number") await storage.transaction(async (txn) => {
816
+ if (row.runId && await txn.get(expiredRunKey(row.runId)) !== void 0) {
817
+ await txn.delete(`msg:${row.messageId}`);
818
+ await txn.delete(`inflight:${row.messageId}`);
819
+ await txn.delete(runReferenceKey(row.runId, row.messageId));
820
+ if (row.idempotencyKey) {
821
+ const dedupKey = `key:${row.idempotencyKey}`;
822
+ if (await txn.get(dedupKey) === row.messageId) await txn.delete(dedupKey);
823
+ }
824
+ await this.#scheduleNextAlarm(txn, now);
825
+ return;
826
+ }
730
827
  await txn.delete(`inflight:${row.messageId}`);
731
- await txn.put(dueKey(now + timeoutSeconds * 1e3, row.messageId), row.messageId);
828
+ const scheduleKey = dueKey(now + timeoutSeconds * 1e3, row.messageId);
829
+ await txn.put(scheduleKey, row.messageId);
830
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
831
+ messageId: row.messageId,
832
+ dueKey: scheduleKey,
833
+ idempotencyKey: row.idempotencyKey
834
+ });
732
835
  await this.#scheduleNextAlarm(txn, now);
733
836
  });
734
837
  else await this.#retry(row, "HTTP 503");
@@ -745,6 +848,7 @@ var QueueDO = class extends DurableObject {
745
848
  if (row.idempotencyKey) {
746
849
  if (await txn.get(`key:${row.idempotencyKey}`) === row.messageId) await txn.delete(`key:${row.idempotencyKey}`);
747
850
  }
851
+ if (row.runId) await txn.delete(runReferenceKey(row.runId, row.messageId));
748
852
  await this.#scheduleNextAlarm(txn, this.#now());
749
853
  });
750
854
  }
@@ -754,6 +858,17 @@ var QueueDO = class extends DurableObject {
754
858
  const maxAttempts = this.#intVar("QUEUE_MAX_ATTEMPTS", DEFAULT_MAX_ATTEMPTS);
755
859
  const attempt = row.attempt + 1;
756
860
  await storage.transaction(async (txn) => {
861
+ if (row.runId && await txn.get(expiredRunKey(row.runId)) !== void 0) {
862
+ await txn.delete(`msg:${row.messageId}`);
863
+ await txn.delete(`inflight:${row.messageId}`);
864
+ await txn.delete(runReferenceKey(row.runId, row.messageId));
865
+ if (row.idempotencyKey) {
866
+ const dedupKey = `key:${row.idempotencyKey}`;
867
+ if (await txn.get(dedupKey) === row.messageId) await txn.delete(dedupKey);
868
+ }
869
+ await this.#scheduleNextAlarm(txn, now);
870
+ return;
871
+ }
757
872
  await txn.delete(`inflight:${row.messageId}`);
758
873
  if (attempt >= maxAttempts) {
759
874
  const dead = {
@@ -762,11 +877,17 @@ var QueueDO = class extends DurableObject {
762
877
  lastError: reason,
763
878
  failedAt: now
764
879
  };
765
- await txn.put(`dlq:${pad(now)}:${row.messageId}`, dead);
880
+ const deadKey = `dlq:${pad(now)}:${row.messageId}`;
881
+ await txn.put(deadKey, dead);
766
882
  await txn.delete(`msg:${row.messageId}`);
767
883
  if (row.idempotencyKey) {
768
884
  if (await txn.get(`key:${row.idempotencyKey}`) === row.messageId) await txn.delete(`key:${row.idempotencyKey}`);
769
885
  }
886
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
887
+ messageId: row.messageId,
888
+ dlqKey: deadKey,
889
+ idempotencyKey: row.idempotencyKey
890
+ });
770
891
  await this.#scheduleNextAlarm(txn, now);
771
892
  return;
772
893
  }
@@ -775,8 +896,14 @@ var QueueDO = class extends DurableObject {
775
896
  attempt,
776
897
  lastError: reason
777
898
  };
899
+ const scheduleKey = dueKey(now + backoffSeconds(attempt) * 1e3, row.messageId);
778
900
  await txn.put(`msg:${row.messageId}`, updated);
779
- await txn.put(dueKey(now + backoffSeconds(attempt) * 1e3, row.messageId), row.messageId);
901
+ await txn.put(scheduleKey, row.messageId);
902
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
903
+ messageId: row.messageId,
904
+ dueKey: scheduleKey,
905
+ idempotencyKey: row.idempotencyKey
906
+ });
780
907
  await this.#scheduleNextAlarm(txn, now);
781
908
  });
782
909
  }
@@ -860,8 +987,14 @@ var QueueDO = class extends DurableObject {
860
987
  await txn.put(dedupKey, row.messageId);
861
988
  }
862
989
  await txn.put(`msg:${row.messageId}`, row);
863
- await txn.put(dueKey(now, row.messageId), row.messageId);
990
+ const scheduleKey = dueKey(now, row.messageId);
991
+ await txn.put(scheduleKey, row.messageId);
864
992
  await txn.delete(key);
993
+ if (row.runId) await txn.put(runReferenceKey(row.runId, row.messageId), {
994
+ messageId: row.messageId,
995
+ dueKey: scheduleKey,
996
+ idempotencyKey: row.idempotencyKey
997
+ });
865
998
  await this.#armAlarmAtMost(txn, now, now);
866
999
  return { ok: true };
867
1000
  }
@@ -871,7 +1004,11 @@ var QueueDO = class extends DurableObject {
871
1004
  async purgeDeadLetters() {
872
1005
  return await this.ctx.storage.transaction(async (txn) => {
873
1006
  const dlq = await txn.list({ prefix: "dlq:" });
874
- for (const key of dlq.keys()) await txn.delete(key);
1007
+ for (const [key, value] of dlq) {
1008
+ await txn.delete(key);
1009
+ const dead = value;
1010
+ if (dead.runId) await txn.delete(runReferenceKey(dead.runId, dead.messageId));
1011
+ }
875
1012
  return { purged: dlq.size };
876
1013
  });
877
1014
  }
@@ -883,6 +1020,32 @@ var QueueDO = class extends DurableObject {
883
1020
  await this.#rearm();
884
1021
  return { alarmAt: await this.ctx.storage.getAlarm() };
885
1022
  }
1023
+ /** Fence a run and purge every live, inflight, and dead-letter message it owns. */
1024
+ async expireRun(runId, expiredAt) {
1025
+ return { deleted: await this.ctx.storage.transaction(async (txn) => {
1026
+ const existing = await txn.get(expiredRunKey(runId));
1027
+ const references = await txn.list({ prefix: `run:${runId}:` });
1028
+ let count = existing?.deleted ?? 0;
1029
+ for (const [key, reference] of references) {
1030
+ await txn.delete(`msg:${reference.messageId}`);
1031
+ await txn.delete(`inflight:${reference.messageId}`);
1032
+ if (reference.dueKey) await txn.delete(reference.dueKey);
1033
+ if (reference.dlqKey) await txn.delete(reference.dlqKey);
1034
+ if (reference.idempotencyKey) {
1035
+ const dedupKey = `key:${reference.idempotencyKey}`;
1036
+ if (await txn.get(dedupKey) === reference.messageId) await txn.delete(dedupKey);
1037
+ }
1038
+ await txn.delete(key);
1039
+ count++;
1040
+ }
1041
+ await txn.put(expiredRunKey(runId), {
1042
+ expiredAt,
1043
+ deleted: count
1044
+ });
1045
+ await this.#scheduleNextAlarm(txn, this.#now());
1046
+ return count;
1047
+ }) };
1048
+ }
886
1049
  };
887
1050
  //#endregion
888
1051
  //#region src/worker/durable-objects/StreamDO.ts
@@ -890,6 +1053,8 @@ const META_KEY = "meta";
890
1053
  const CHUNK_KEY_PREFIX = "chunk:";
891
1054
  /** Registry keys used when this DO instance acts as a per-run stream index. */
892
1055
  const STREAM_REGISTRY_PREFIX = "stream:";
1056
+ const REGISTRY_OWNER_KEY = "registry:owner";
1057
+ const REGISTRY_EXPIRED_KEY = "registry:expired";
893
1058
  /**
894
1059
  * Zero-pad chunk indexes so `storage.list({ prefix })` returns chunks in
895
1060
  * write order. 12 digits comfortably exceeds any realistic chunk count.
@@ -914,22 +1079,28 @@ var StreamDO = class extends DurableObject {
914
1079
  closed: false
915
1080
  };
916
1081
  }
1082
+ assertOwner(meta, runId) {
1083
+ if (meta.expiredAt !== void 0) throw new Error(`Workflow run "${runId}" has expired`);
1084
+ if (meta.ownerRunId !== void 0 && meta.ownerRunId !== runId) throw new Error(`Stream is owned by workflow run "${meta.ownerRunId}"`);
1085
+ }
917
1086
  /**
918
1087
  * Append a chunk. The index is allocated inside the transaction, so
919
1088
  * concurrent writers can never collide or skip.
920
1089
  */
921
- async writeChunk(data) {
1090
+ async writeChunk(runId, data) {
922
1091
  return await this.ctx.storage.transaction(async (txn) => {
923
1092
  const meta = await txn.get(META_KEY) ?? {
924
1093
  count: 0,
925
1094
  closed: false
926
1095
  };
1096
+ this.assertOwner(meta, runId);
927
1097
  if (meta.closed) throw new Error("Cannot write to a closed stream");
928
1098
  const index = meta.count;
929
1099
  await txn.put(chunkKey(index), data);
930
1100
  await txn.put(META_KEY, {
931
1101
  count: index + 1,
932
- closed: false
1102
+ closed: false,
1103
+ ownerRunId: runId
933
1104
  });
934
1105
  return index;
935
1106
  });
@@ -938,14 +1109,16 @@ var StreamDO = class extends DurableObject {
938
1109
  * Close the stream (idempotent). Readers observe `done: true` once all
939
1110
  * previously written chunks have been consumed.
940
1111
  */
941
- async closeStream() {
1112
+ async closeStream(runId) {
942
1113
  await this.ctx.storage.transaction(async (txn) => {
943
1114
  const meta = await txn.get(META_KEY) ?? {
944
1115
  count: 0,
945
1116
  closed: false
946
1117
  };
1118
+ this.assertOwner(meta, runId);
947
1119
  await txn.put(META_KEY, {
948
1120
  ...meta,
1121
+ ownerRunId: runId,
949
1122
  closed: true
950
1123
  });
951
1124
  });
@@ -955,6 +1128,7 @@ var StreamDO = class extends DurableObject {
955
1128
  */
956
1129
  async getChunks(params) {
957
1130
  const meta = await this.getMeta();
1131
+ if (meta.expiredAt !== void 0) throw new Error("Stream has expired");
958
1132
  const start = Math.max(0, params.startIndex);
959
1133
  const entries = await this.ctx.storage.list({
960
1134
  prefix: CHUNK_KEY_PREFIX,
@@ -972,6 +1146,7 @@ var StreamDO = class extends DurableObject {
972
1146
  */
973
1147
  async getInfo() {
974
1148
  const meta = await this.getMeta();
1149
+ if (meta.expiredAt !== void 0) throw new Error("Stream has expired");
975
1150
  return {
976
1151
  tailIndex: meta.count - 1,
977
1152
  done: meta.closed
@@ -980,16 +1155,66 @@ var StreamDO = class extends DurableObject {
980
1155
  /**
981
1156
  * Register a stream name against this per-run registry instance.
982
1157
  */
983
- async registerStream(name) {
984
- await this.ctx.storage.put(`${STREAM_REGISTRY_PREFIX}${name}`, true);
1158
+ async registerStream(runId, name) {
1159
+ await this.ctx.storage.transaction(async (txn) => {
1160
+ if (await txn.get(REGISTRY_EXPIRED_KEY) !== void 0) throw new Error(`Workflow run "${runId}" has expired`);
1161
+ const owner = await txn.get(REGISTRY_OWNER_KEY);
1162
+ if (owner !== void 0 && owner !== runId) throw new Error(`Stream registry is owned by workflow run "${owner}"`);
1163
+ await txn.put(REGISTRY_OWNER_KEY, runId);
1164
+ await txn.put(`${STREAM_REGISTRY_PREFIX}${name}`, true);
1165
+ });
985
1166
  }
986
1167
  /**
987
1168
  * List stream names registered against this per-run registry instance.
988
1169
  */
989
1170
  async listStreams() {
1171
+ if (await this.ctx.storage.get(REGISTRY_EXPIRED_KEY) !== void 0) throw new Error("Workflow run streams have expired");
990
1172
  const entries = await this.ctx.storage.list({ prefix: STREAM_REGISTRY_PREFIX });
991
1173
  return Array.from(entries.keys()).map((key) => key.slice(7));
992
1174
  }
1175
+ /** Fence a run's registry before any stream cells are removed. */
1176
+ async expireRegistry(runId, expiredAt) {
1177
+ return await this.ctx.storage.transaction(async (txn) => {
1178
+ const owner = await txn.get(REGISTRY_OWNER_KEY);
1179
+ if (owner !== void 0 && owner !== runId) throw new Error(`Stream registry is owned by workflow run "${owner}"`);
1180
+ await txn.put(REGISTRY_OWNER_KEY, runId);
1181
+ await txn.put(REGISTRY_EXPIRED_KEY, expiredAt);
1182
+ const entries = await txn.list({ prefix: STREAM_REGISTRY_PREFIX });
1183
+ return { streams: Array.from(entries.keys()).map((key) => key.slice(7)) };
1184
+ });
1185
+ }
1186
+ /** Remove the registry payload after every referenced stream was fenced. */
1187
+ async finalizeRegistry(runId) {
1188
+ await this.ctx.storage.transaction(async (txn) => {
1189
+ const owner = await txn.get(REGISTRY_OWNER_KEY);
1190
+ const expiredAt = await txn.get(REGISTRY_EXPIRED_KEY);
1191
+ if (owner !== runId || expiredAt === void 0) throw new Error(`Stream registry for workflow run "${runId}" is not expired`);
1192
+ const entries = await txn.list({ prefix: STREAM_REGISTRY_PREFIX });
1193
+ for (const key of entries.keys()) await txn.delete(key);
1194
+ });
1195
+ }
1196
+ /** Delete stream payload while leaving a small ownership tombstone. */
1197
+ async expireStream(runId, expiredAt) {
1198
+ return await this.ctx.storage.transaction(async (txn) => {
1199
+ const meta = await txn.get(META_KEY) ?? {
1200
+ count: 0,
1201
+ closed: false
1202
+ };
1203
+ if (meta.ownerRunId !== void 0 && meta.ownerRunId !== runId) throw new Error(`Stream is owned by workflow run "${meta.ownerRunId}"`);
1204
+ const chunks = await txn.list({ prefix: CHUNK_KEY_PREFIX });
1205
+ for (const key of chunks.keys()) await txn.delete(key);
1206
+ await txn.put(META_KEY, {
1207
+ count: 0,
1208
+ closed: true,
1209
+ ownerRunId: runId,
1210
+ expiredAt
1211
+ });
1212
+ return {
1213
+ deleted: true,
1214
+ chunks: chunks.size
1215
+ };
1216
+ });
1217
+ }
993
1218
  };
994
1219
  //#endregion
995
1220
  //#region src/apply-event.ts
@@ -1526,6 +1751,9 @@ async function applyEvent(store, ctx) {
1526
1751
  }
1527
1752
  //#endregion
1528
1753
  //#region src/worker/durable-objects/WorkflowRunDO.ts
1754
+ const PAYLOAD_DELETE_BATCH = 256;
1755
+ const STREAM_DELETE_CONCURRENCY = 16;
1756
+ const CLEANUP_RETRY_MAX_MS = 36e5;
1529
1757
  /**
1530
1758
  * Current schema version for DO storage.
1531
1759
  * Increment when making breaking changes to the storage layout.
@@ -1610,104 +1838,347 @@ function storeFrom(storage) {
1610
1838
  */
1611
1839
  var WorkflowRunDO = class extends DurableObject {
1612
1840
  schemaReady = null;
1613
- /**
1614
- * Lazily ensure schema migrations have run.
1615
- * Memoized so it only runs once per DO instantiation.
1616
- */
1841
+ now() {
1842
+ const clock = this.env?.clock;
1843
+ return typeof clock === "function" ? clock() : Date.now();
1844
+ }
1617
1845
  ensureSchema() {
1618
1846
  if (!this.schemaReady) this.schemaReady = ensureSchemaUpToDate(this.ctx.storage);
1619
1847
  return this.schemaReady;
1620
1848
  }
1621
- /**
1622
- * Apply an event: validate, guard, append the event and mutate the entity
1623
- * in ONE storage transaction. Guard failures are returned as structured
1624
- * outcomes (not thrown) because typed errors do not survive the RPC
1625
- * boundary with their `name` intact.
1626
- */
1849
+ async expiredTombstone(storage, now = this.now()) {
1850
+ const tombstone = await storage.get(TOMBSTONE_KEY);
1851
+ if (tombstone) return tombstone;
1852
+ const cleanup = await storage.get(CLEANUP_RECORD_KEY);
1853
+ if (!cleanup || cleanup.phase === "retained" && cleanup.dueAt.getTime() > now) return null;
1854
+ return cleanupTombstone(cleanup, cleanup.tombstonedAt ?? cleanup.dueAt);
1855
+ }
1856
+ async read(reader) {
1857
+ await this.ensureSchema();
1858
+ const tombstone = await this.expiredTombstone(this.ctx.storage);
1859
+ if (tombstone) return expiredRead(tombstone);
1860
+ return {
1861
+ ok: true,
1862
+ value: await reader()
1863
+ };
1864
+ }
1865
+ /** Apply an event and capture all retention metadata in the same transaction. */
1627
1866
  async applyEvent(request) {
1628
1867
  await this.ensureSchema();
1629
1868
  return await this.ctx.storage.transaction(async (txn) => {
1869
+ const now = new Date(this.now());
1870
+ const tombstone = await this.expiredTombstone(txn, now.getTime());
1871
+ if (tombstone) return {
1872
+ ok: false,
1873
+ code: "RUN_EXPIRED",
1874
+ message: `Workflow run "${request.runId}" expired at ${tombstone.expiredAt.toISOString()}`
1875
+ };
1630
1876
  let eventSequence = await txn.get("event_sequence") ?? 0;
1631
1877
  const outcome = await applyEvent(storeFrom(txn), {
1632
1878
  ...request,
1633
1879
  nextEventId: () => `wevt_z${String(++eventSequence).padStart(20, "0")}`,
1634
- now: /* @__PURE__ */ new Date()
1880
+ now
1635
1881
  });
1636
- if (outcome.ok) await txn.put("event_sequence", eventSequence);
1882
+ if (!outcome.ok) return outcome;
1883
+ await txn.put("event_sequence", eventSequence);
1884
+ if (outcome.event?.correlationId) {
1885
+ const key = correlationIndexKey(outcome.event.correlationId, outcome.event.createdAt, outcome.event.eventId, request.runId);
1886
+ await txn.put(indexMarkerKey(key), key);
1887
+ }
1888
+ if (outcome.hookToIndex) {
1889
+ const reference = {
1890
+ hookId: outcome.hookToIndex.hookId,
1891
+ token: outcome.hookToIndex.token
1892
+ };
1893
+ await txn.put(hookMarkerKey(reference), reference);
1894
+ }
1895
+ for (const reference of outcome.releasedHooks) await txn.put(hookMarkerKey(reference), reference);
1896
+ const retentionMs = request.cleanup?.retentionMs ?? 0;
1897
+ if (retentionMs > 0 && outcome.run && isTerminalWorkflowRunStatus(outcome.run.status) && !await txn.get("retention:cleanup")) {
1898
+ const terminalRun = outcome.run;
1899
+ const dueAt = new Date(terminalRun.completedAt.getTime() + retentionMs);
1900
+ const run = WorkflowRunSchema.parse({
1901
+ ...terminalRun,
1902
+ expiredAt: dueAt
1903
+ });
1904
+ const cleanup = {
1905
+ version: 1,
1906
+ runId: run.runId,
1907
+ workflowName: run.workflowName,
1908
+ createdAt: run.createdAt,
1909
+ completedAt: run.completedAt,
1910
+ terminalStatus: run.status,
1911
+ dueAt,
1912
+ queueShards: request.cleanup?.queueShards ?? 1,
1913
+ phase: "retained",
1914
+ attempts: 0,
1915
+ deletedPayloadKeys: 0,
1916
+ deletedStreams: 0,
1917
+ deletedQueueMessages: 0
1918
+ };
1919
+ await txn.put("run", run);
1920
+ await txn.put(CLEANUP_RECORD_KEY, cleanup);
1921
+ await txn.setAlarm(dueAt);
1922
+ outcome.run = run;
1923
+ }
1637
1924
  return outcome;
1638
1925
  });
1639
1926
  }
1640
- /**
1641
- * Get the workflow run
1642
- */
1643
1927
  async getRun() {
1644
- await this.ensureSchema();
1645
- return await this.ctx.storage.get("run") ?? null;
1928
+ return this.read(async () => await this.ctx.storage.get("run") ?? null);
1646
1929
  }
1647
- /**
1648
- * Get a specific step
1649
- */
1650
1930
  async getStep(stepId) {
1651
- await this.ensureSchema();
1652
- return await this.ctx.storage.get(`step:${stepId}`) ?? null;
1931
+ return this.read(async () => await this.ctx.storage.get(`step:${stepId}`) ?? null);
1653
1932
  }
1654
- /**
1655
- * List steps with real limit/cursor pagination (cursor = last stepId).
1656
- */
1657
1933
  async listSteps(params) {
1658
- await this.ensureSchema();
1659
- return listByCreationTime(storeFrom(this.ctx.storage), STEP_CREATED_KEY_PREFIX, STEP_KEY_PREFIX, {
1934
+ return this.read(() => listByCreationTime(storeFrom(this.ctx.storage), STEP_CREATED_KEY_PREFIX, STEP_KEY_PREFIX, {
1660
1935
  limit: params?.limit ?? 20,
1661
1936
  cursor: params?.cursor,
1662
1937
  sortOrder: params?.sortOrder ?? "asc"
1663
- });
1938
+ }));
1664
1939
  }
1665
- /**
1666
- * Get a specific event
1667
- */
1668
1940
  async getEvent(eventId) {
1669
- await this.ensureSchema();
1670
- return await this.ctx.storage.get(`event:${eventId}`) ?? null;
1941
+ return this.read(async () => await this.ctx.storage.get(`event:${eventId}`) ?? null);
1671
1942
  }
1672
- /**
1673
- * List events with real limit/cursor pagination. Events are keyed by their
1674
- * monotonic ULID eventId, so storage order == event order.
1675
- */
1676
1943
  async listEvents(params) {
1677
- await this.ensureSchema();
1678
- return listByPrefix(storeFrom(this.ctx.storage), EVENT_KEY_PREFIX, {
1944
+ return this.read(() => listByPrefix(storeFrom(this.ctx.storage), EVENT_KEY_PREFIX, {
1679
1945
  limit: params?.limit ?? 100,
1680
1946
  cursor: params?.cursor,
1681
1947
  sortOrder: params?.sortOrder ?? "asc"
1682
- }, (event) => event.eventId);
1948
+ }, (event) => event.eventId));
1683
1949
  }
1684
- /**
1685
- * List hooks with real limit/cursor pagination (cursor = last hookId).
1686
- */
1687
1950
  async listHooks(params) {
1688
- await this.ensureSchema();
1689
- return listByCreationTime(storeFrom(this.ctx.storage), HOOK_CREATED_KEY_PREFIX, HOOK_KEY_PREFIX, {
1951
+ return this.read(() => listByCreationTime(storeFrom(this.ctx.storage), HOOK_CREATED_KEY_PREFIX, HOOK_KEY_PREFIX, {
1690
1952
  limit: params?.limit ?? 100,
1691
1953
  cursor: params?.cursor,
1692
1954
  sortOrder: params?.sortOrder ?? "asc"
1955
+ }));
1956
+ }
1957
+ async getCleanupStatus() {
1958
+ await this.ensureSchema();
1959
+ return await this.ctx.storage.get("retention:cleanup") ?? null;
1960
+ }
1961
+ /** Explicitly schedule an existing terminal run, used for opt-in backfills. */
1962
+ async scheduleCleanup(request) {
1963
+ await this.ensureSchema();
1964
+ if (request.retentionMs <= 0) return this.getCleanupStatus();
1965
+ return await this.ctx.storage.transaction(async (txn) => {
1966
+ const existing = await txn.get(CLEANUP_RECORD_KEY);
1967
+ if (existing) return existing;
1968
+ if (await txn.get("retention:tombstone")) return null;
1969
+ const current = await txn.get("run");
1970
+ if (!current || !isTerminalWorkflowRunStatus(current.status)) return null;
1971
+ const terminalRun = current;
1972
+ const dueAt = new Date(terminalRun.completedAt.getTime() + request.retentionMs);
1973
+ const run = WorkflowRunSchema.parse({
1974
+ ...terminalRun,
1975
+ expiredAt: dueAt
1976
+ });
1977
+ const cleanup = {
1978
+ version: 1,
1979
+ runId: run.runId,
1980
+ workflowName: run.workflowName,
1981
+ createdAt: run.createdAt,
1982
+ completedAt: run.completedAt,
1983
+ terminalStatus: run.status,
1984
+ dueAt,
1985
+ queueShards: request.queueShards,
1986
+ phase: "retained",
1987
+ attempts: 0,
1988
+ deletedPayloadKeys: 0,
1989
+ deletedStreams: 0,
1990
+ deletedQueueMessages: 0
1991
+ };
1992
+ await txn.put("run", run);
1993
+ await txn.put(CLEANUP_RECORD_KEY, cleanup);
1994
+ await txn.setAlarm(dueAt.getTime() <= this.now() ? this.now() + 1 : dueAt);
1995
+ return cleanup;
1996
+ });
1997
+ }
1998
+ async cleanupNow(request) {
1999
+ await this.ensureSchema();
2000
+ if (!await this.ctx.storage.get("retention:cleanup")) await this.scheduleCleanup({
2001
+ ...request,
2002
+ retentionMs: Math.max(1, request.retentionMs)
2003
+ });
2004
+ await this.ctx.storage.transaction(async (txn) => {
2005
+ const cleanup = await txn.get(CLEANUP_RECORD_KEY);
2006
+ if (!cleanup || cleanup.phase === "tombstoned") return;
2007
+ const dueAt = new Date(this.now());
2008
+ const run = await txn.get("run");
2009
+ if (run) await txn.put("run", WorkflowRunSchema.parse({
2010
+ ...run,
2011
+ expiredAt: dueAt
2012
+ }));
2013
+ await txn.put(CLEANUP_RECORD_KEY, {
2014
+ ...cleanup,
2015
+ dueAt
2016
+ });
2017
+ await txn.setAlarm(this.now() + 1);
2018
+ });
2019
+ try {
2020
+ await this.executeCleanup();
2021
+ } catch (error) {
2022
+ await this.recordCleanupFailure(error);
2023
+ }
2024
+ return this.getCleanupStatus();
2025
+ }
2026
+ async rearmCleanup() {
2027
+ await this.ensureSchema();
2028
+ const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
2029
+ if (!cleanup) return null;
2030
+ if (cleanup.phase === "tombstoned") await this.ctx.storage.deleteAlarm();
2031
+ else await this.ctx.storage.setAlarm(Math.max(this.now() + 1, cleanup.dueAt.getTime()));
2032
+ return cleanup;
2033
+ }
2034
+ async alarm() {
2035
+ await this.ensureSchema();
2036
+ try {
2037
+ await this.executeCleanup();
2038
+ } catch (error) {
2039
+ await this.recordCleanupFailure(error);
2040
+ }
2041
+ }
2042
+ namespace(name) {
2043
+ const namespace = this.env?.[name];
2044
+ if (!namespace || typeof namespace === "function") throw new Error(`world-celld retention missing binding ${name}`);
2045
+ return namespace;
2046
+ }
2047
+ async setCleanupPhase(expected, next, updates = {}) {
2048
+ await this.ctx.storage.transaction(async (txn) => {
2049
+ const cleanup = await txn.get(CLEANUP_RECORD_KEY);
2050
+ if (!cleanup || cleanup.phase !== expected) return;
2051
+ await txn.put(CLEANUP_RECORD_KEY, {
2052
+ ...cleanup,
2053
+ ...updates,
2054
+ phase: next,
2055
+ attempts: 0,
2056
+ lastError: void 0
2057
+ });
2058
+ });
2059
+ }
2060
+ async executeCleanup() {
2061
+ for (;;) {
2062
+ const cleanup = await this.ctx.storage.get(CLEANUP_RECORD_KEY);
2063
+ if (!cleanup) {
2064
+ await this.ctx.storage.deleteAlarm();
2065
+ return;
2066
+ }
2067
+ if (cleanup.phase === "tombstoned") {
2068
+ await this.ctx.storage.deleteAlarm();
2069
+ return;
2070
+ }
2071
+ if (cleanup.phase === "retained") {
2072
+ if (cleanup.dueAt.getTime() > this.now()) {
2073
+ await this.ctx.storage.setAlarm(cleanup.dueAt);
2074
+ return;
2075
+ }
2076
+ await this.setCleanupPhase("retained", "index");
2077
+ continue;
2078
+ }
2079
+ if (cleanup.phase === "index") {
2080
+ const [indexMarkers, hookMarkers] = await Promise.all([this.ctx.storage.list({ prefix: INDEX_MARKER_PREFIX }), this.ctx.storage.list({ prefix: HOOK_MARKER_PREFIX })]);
2081
+ const indexNamespace = this.namespace("WORKFLOW_INDEX");
2082
+ await indexNamespace.get(indexNamespace.idFromName("index")).expireRun({
2083
+ runId: cleanup.runId,
2084
+ keys: [
2085
+ workflowRunIndexKey(cleanup),
2086
+ globalRunIndexKey(cleanup),
2087
+ ...indexMarkers.values()
2088
+ ],
2089
+ hooks: Array.from(hookMarkers.values()),
2090
+ expiredAt: cleanup.dueAt.getTime()
2091
+ });
2092
+ await this.setCleanupPhase("index", "streams");
2093
+ continue;
2094
+ }
2095
+ if (cleanup.phase === "streams") {
2096
+ const streamsNamespace = this.namespace("WORKFLOW_STREAMS");
2097
+ const registry = streamsNamespace.get(streamsNamespace.idFromName(`run-streams:${cleanup.runId}`));
2098
+ const { streams } = await registry.expireRegistry(cleanup.runId, cleanup.dueAt.getTime());
2099
+ let deletedStreams = streams.length === 0 ? cleanup.deletedStreams : 0;
2100
+ for (let offset = 0; offset < streams.length; offset += STREAM_DELETE_CONCURRENCY) {
2101
+ const batch = streams.slice(offset, offset + STREAM_DELETE_CONCURRENCY);
2102
+ const results = await Promise.all(batch.map((name) => {
2103
+ return streamsNamespace.get(streamsNamespace.idFromName(`stream:${name}`)).expireStream(cleanup.runId, cleanup.dueAt.getTime());
2104
+ }));
2105
+ deletedStreams += results.filter((result) => result.deleted).length;
2106
+ }
2107
+ await this.ctx.storage.transaction(async (txn) => {
2108
+ const current = await txn.get(CLEANUP_RECORD_KEY);
2109
+ if (current?.phase === "streams") await txn.put(CLEANUP_RECORD_KEY, {
2110
+ ...current,
2111
+ deletedStreams
2112
+ });
2113
+ });
2114
+ await registry.finalizeRegistry(cleanup.runId);
2115
+ await this.setCleanupPhase("streams", "queues");
2116
+ continue;
2117
+ }
2118
+ if (cleanup.phase === "queues") {
2119
+ const queues = this.namespace("WORKFLOW_QUEUE");
2120
+ let deletedQueueMessages = 0;
2121
+ for (let shard = 0; shard < cleanup.queueShards; shard++) {
2122
+ const result = await queues.get(queues.idFromName(`q:${shard}`)).expireRun(cleanup.runId, cleanup.dueAt.getTime());
2123
+ deletedQueueMessages += result.deleted;
2124
+ }
2125
+ await this.setCleanupPhase("queues", "payload", { deletedQueueMessages });
2126
+ continue;
2127
+ }
2128
+ if (cleanup.phase === "payload") {
2129
+ if (!await this.deletePayloadBatch()) return;
2130
+ }
2131
+ }
2132
+ }
2133
+ async deletePayloadBatch() {
2134
+ return await this.ctx.storage.transaction(async (txn) => {
2135
+ const cleanup = await txn.get(CLEANUP_RECORD_KEY);
2136
+ if (!cleanup || cleanup.phase !== "payload") return true;
2137
+ const entries = await txn.list({ limit: 259 });
2138
+ const keys = Array.from(entries.keys()).filter((key) => key !== "retention:cleanup" && key !== "retention:tombstone" && key !== "schema_version").slice(0, PAYLOAD_DELETE_BATCH);
2139
+ const now = new Date(this.now());
2140
+ await txn.put(TOMBSTONE_KEY, cleanupTombstone(cleanup, now));
2141
+ for (const key of keys) await txn.delete(key);
2142
+ if (keys.length > 0) {
2143
+ await txn.put(CLEANUP_RECORD_KEY, {
2144
+ ...cleanup,
2145
+ deletedPayloadKeys: cleanup.deletedPayloadKeys + keys.length,
2146
+ lastError: void 0
2147
+ });
2148
+ await txn.setAlarm(this.now() + 1);
2149
+ return false;
2150
+ }
2151
+ await txn.put(CLEANUP_RECORD_KEY, {
2152
+ ...cleanup,
2153
+ phase: "tombstoned",
2154
+ attempts: 0,
2155
+ lastError: void 0,
2156
+ tombstonedAt: now
2157
+ });
2158
+ await txn.deleteAlarm();
2159
+ return true;
2160
+ });
2161
+ }
2162
+ async recordCleanupFailure(error) {
2163
+ await this.ctx.storage.transaction(async (txn) => {
2164
+ const cleanup = await txn.get(CLEANUP_RECORD_KEY);
2165
+ if (!cleanup || cleanup.phase === "tombstoned") return;
2166
+ const attempts = cleanup.attempts + 1;
2167
+ const delay = Math.min(CLEANUP_RETRY_MAX_MS, 1e3 * 2 ** Math.min(attempts - 1, 12));
2168
+ await txn.put(CLEANUP_RECORD_KEY, {
2169
+ ...cleanup,
2170
+ attempts,
2171
+ lastError: error instanceof Error ? error.message : String(error),
2172
+ lastAttemptAt: new Date(this.now())
2173
+ });
2174
+ await txn.setAlarm(this.now() + delay);
1693
2175
  });
1694
2176
  }
1695
- /**
1696
- * Claim a queue-message dedup slot. Used on DO instances dedicated to a
1697
- * single idempotency key (`claim:<queueName>:<idempotencyKey>`).
1698
- *
1699
- * - Unclaimed -> claim and proceed.
1700
- * - Claimed by the SAME messageId -> redelivery of the same message
1701
- * (previous attempt failed transiently) -> proceed.
1702
- * - Claimed by a DIFFERENT messageId -> concurrent duplicate -> reject,
1703
- * unless the claim is stale (holder crashed / went to the DLQ without
1704
- * releasing), in which case the claim is stolen.
1705
- */
1706
2177
  async claimInflight(params) {
1707
2178
  await this.ensureSchema();
1708
2179
  return await this.ctx.storage.transaction(async (txn) => {
1709
2180
  const existing = await txn.get("claim");
1710
- const now = Date.now();
2181
+ const now = this.now();
1711
2182
  if (existing && existing.messageId !== params.messageId && now - existing.claimedAt < params.staleMs) return { claimed: false };
1712
2183
  await txn.put("claim", {
1713
2184
  messageId: params.messageId,
@@ -1716,9 +2187,6 @@ var WorkflowRunDO = class extends DurableObject {
1716
2187
  return { claimed: true };
1717
2188
  });
1718
2189
  }
1719
- /**
1720
- * Release a queue-message dedup claim after the message was fully handled.
1721
- */
1722
2190
  async releaseInflight() {
1723
2191
  await this.ensureSchema();
1724
2192
  await this.ctx.storage.delete("claim");
@@ -1731,13 +2199,21 @@ var WorkflowRunDO = class extends DurableObject {
1731
2199
  * behind a node:http server — the full wire protocol without a celld binary.
1732
2200
  */
1733
2201
  async function startHarness(options = {}) {
2202
+ const cellEnv = { ...options.cellEnv };
1734
2203
  const fleet = new FakeFleet({
1735
2204
  runs: WorkflowRunDO,
1736
2205
  streams: StreamDO,
1737
2206
  index: IndexDO,
1738
2207
  queue: QueueDO,
1739
2208
  ...options.extraClasses
1740
- }, options.cellEnv ?? {});
2209
+ }, cellEnv);
2210
+ cellEnv.clock ??= options.virtualClock ? () => fleet.now : () => Date.now();
2211
+ Object.assign(cellEnv, {
2212
+ WORKFLOW_DB: fleet.namespace("runs"),
2213
+ WORKFLOW_STREAMS: fleet.namespace("streams"),
2214
+ WORKFLOW_INDEX: fleet.namespace("index"),
2215
+ WORKFLOW_QUEUE: fleet.namespace("queue")
2216
+ });
1741
2217
  const router = createRouter({
1742
2218
  WORKFLOW_DB: fleet.namespace("runs"),
1743
2219
  WORKFLOW_STREAMS: fleet.namespace("streams"),
@@ -1771,7 +2247,10 @@ async function startHarness(options = {}) {
1771
2247
  return {
1772
2248
  url: `http://127.0.0.1:${port}`,
1773
2249
  fleet,
1774
- close: () => new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve()))
2250
+ close: () => new Promise((resolve, reject) => {
2251
+ server.close((err) => err ? reject(err) : resolve());
2252
+ server.closeAllConnections();
2253
+ })
1775
2254
  };
1776
2255
  }
1777
2256
  //#endregion