@crewhaus/durable-execution 0.3.2 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ import { type IdempotencyKey, type IdempotencyRecord, type IdempotencyStore } from "./index";
2
+ /**
3
+ * A filesystem-backed idempotency store: one `<key>.json` per record under
4
+ * `dir`. `get` fails safe (a missing/corrupt file reads as "no record", so a
5
+ * torn write re-runs rather than throwing); `put` is a single atomic-enough
6
+ * `writeFileSync`.
7
+ */
8
+ export declare class FileIdempotencyStore implements IdempotencyStore {
9
+ private readonly dir;
10
+ constructor(dir: string);
11
+ private pathFor;
12
+ get(key: IdempotencyKey): Promise<IdempotencyRecord | undefined>;
13
+ put(record: IdempotencyRecord): Promise<void>;
14
+ }
15
+ /**
16
+ * Build the idempotency store the bundle's durable-execution wrapping uses,
17
+ * from `env.CREWHAUS_IDEMPOTENCY_STORE`. Records are namespaced by `spec` so
18
+ * two shapes sharing a working directory never collide. Throws on an
19
+ * unrecognised value so a typo fails loudly at boot rather than silently
20
+ * degrading exactly-once to in-memory.
21
+ */
22
+ export declare function createIdempotencyStore(spec: string, env?: Record<string, string | undefined>): IdempotencyStore;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Loop contract 0.4 (Batch F, temporal contract / G61 exactly-once half) —
3
+ * a DURABLE {@link IdempotencyStore} plus the env-driven factory the emitted
4
+ * graph / workflow bundles select their store with.
5
+ *
6
+ * `withIdempotency` (see `./index`) dedups a node/step by (runId, name,
7
+ * attempt), but its default {@link InMemoryIdempotencyStore} evaporates when
8
+ * the process exits — so it only guards against in-process double-invocation.
9
+ * For crash-resume exactly-once the record has to OUTLIVE the process: a
10
+ * restart re-executing the same run must find the prior attempt's cached
11
+ * result. {@link FileIdempotencyStore} persists one JSON file per key under a
12
+ * spec-scoped directory so a `bun agent.ts` restart (same runId) skips
13
+ * already-completed work instead of re-running its side effects.
14
+ *
15
+ * This is best-effort exactly-once, not transactional: a crash in the window
16
+ * between a node's external side effect and the store write still re-runs on
17
+ * resume (at-least-once). The durable record strictly TIGHTENS the guarantee
18
+ * over the in-memory default; it cannot make a non-transactional external
19
+ * effect perfectly exactly-once.
20
+ *
21
+ * Selection mirrors the channel daemon's `CREWHAUS_DEDUP_STORE` convention:
22
+ * - `memory` (default) — in-memory, no cross-restart resume.
23
+ * - `file:<dir>` / `file` — durable JSON records under
24
+ * `<dir>/<spec>/` (default dir `.crewhaus/idempotency`).
25
+ */
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { InMemoryIdempotencyStore, } from "./index";
29
+ function safeSegment(spec) {
30
+ const cleaned = spec.replace(/[^A-Za-z0-9_.-]/g, "_");
31
+ return cleaned.length > 0 ? cleaned : "spec";
32
+ }
33
+ /**
34
+ * A filesystem-backed idempotency store: one `<key>.json` per record under
35
+ * `dir`. `get` fails safe (a missing/corrupt file reads as "no record", so a
36
+ * torn write re-runs rather than throwing); `put` is a single atomic-enough
37
+ * `writeFileSync`.
38
+ */
39
+ export class FileIdempotencyStore {
40
+ dir;
41
+ constructor(dir) {
42
+ this.dir = dir;
43
+ mkdirSync(dir, { recursive: true });
44
+ }
45
+ pathFor(key) {
46
+ return join(this.dir, `${key}.json`);
47
+ }
48
+ async get(key) {
49
+ const p = this.pathFor(key);
50
+ if (!existsSync(p))
51
+ return undefined;
52
+ try {
53
+ return JSON.parse(readFileSync(p, "utf-8"));
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ async put(record) {
60
+ writeFileSync(this.pathFor(record.key), JSON.stringify(record));
61
+ }
62
+ }
63
+ const DEFAULT_IDEMPOTENCY_DIR = join(".crewhaus", "idempotency");
64
+ /**
65
+ * Build the idempotency store the bundle's durable-execution wrapping uses,
66
+ * from `env.CREWHAUS_IDEMPOTENCY_STORE`. Records are namespaced by `spec` so
67
+ * two shapes sharing a working directory never collide. Throws on an
68
+ * unrecognised value so a typo fails loudly at boot rather than silently
69
+ * degrading exactly-once to in-memory.
70
+ */
71
+ export function createIdempotencyStore(spec, env = process.env) {
72
+ const raw = env["CREWHAUS_IDEMPOTENCY_STORE"] ?? "memory";
73
+ if (raw === "memory")
74
+ return new InMemoryIdempotencyStore();
75
+ if (raw === "file") {
76
+ return new FileIdempotencyStore(join(DEFAULT_IDEMPOTENCY_DIR, safeSegment(spec)));
77
+ }
78
+ if (raw.startsWith("file:")) {
79
+ const base = raw.slice("file:".length);
80
+ const dir = base.length > 0 ? base : DEFAULT_IDEMPOTENCY_DIR;
81
+ return new FileIdempotencyStore(join(dir, safeSegment(spec)));
82
+ }
83
+ throw new Error(`[durable-execution] unknown CREWHAUS_IDEMPOTENCY_STORE "${raw}" — use "memory" (default) or "file:<dir>".`);
84
+ }
package/dist/index.d.ts CHANGED
@@ -65,4 +65,21 @@ export declare function resumeFrom(store: CheckpointStore, graphRunId: GraphRunI
65
65
  checkpointId: CheckpointId;
66
66
  nextNode: string;
67
67
  } | undefined>;
68
+ /**
69
+ * Loop contract 0.4 (Batch F, temporal contract / G61) — the linear-resume
70
+ * counterpart to {@link withIdempotency} for shapes that run steps
71
+ * sequentially in one process (the workflow target) rather than through the
72
+ * graph engine's `NodeContext`. `runId` is a plain string (the workflow's
73
+ * `CREWHAUS_RUN_ID` or a fresh id), not a branded `GraphRunId`.
74
+ *
75
+ * The first call for `(runId, name, attempt)` runs `fn` and records its
76
+ * result; a restart of the SAME `runId` against a durable store finds the
77
+ * record and skips straight to the cached value — so a crashed multi-step
78
+ * workflow resumes at the first not-yet-completed step instead of re-running
79
+ * the ones that already finished (and their side effects). With the default
80
+ * in-memory store it is transparent (each step runs once, keys are unique).
81
+ */
82
+ export declare function runOnce<T>(store: IdempotencyStore, runId: string, name: string, fn: () => Promise<T>, attempt?: number): Promise<T>;
68
83
  export { InMemoryIdempotencyStore };
84
+ export { type ArmedSchedule, type ArmScheduleHandlers, type WakeSchedule, armSchedule, nextCronMatch, nextWakeDelayMs, } from "./schedule";
85
+ export { FileIdempotencyStore, createIdempotencyStore } from "./idempotency-store";
package/dist/index.js CHANGED
@@ -84,4 +84,40 @@ export async function resumeFrom(store, graphRunId) {
84
84
  return undefined;
85
85
  return { checkpointId: head.id, nextNode: head.nodeName };
86
86
  }
87
+ /**
88
+ * Loop contract 0.4 (Batch F, temporal contract / G61) — the linear-resume
89
+ * counterpart to {@link withIdempotency} for shapes that run steps
90
+ * sequentially in one process (the workflow target) rather than through the
91
+ * graph engine's `NodeContext`. `runId` is a plain string (the workflow's
92
+ * `CREWHAUS_RUN_ID` or a fresh id), not a branded `GraphRunId`.
93
+ *
94
+ * The first call for `(runId, name, attempt)` runs `fn` and records its
95
+ * result; a restart of the SAME `runId` against a durable store finds the
96
+ * record and skips straight to the cached value — so a crashed multi-step
97
+ * workflow resumes at the first not-yet-completed step instead of re-running
98
+ * the ones that already finished (and their side effects). With the default
99
+ * in-memory store it is transparent (each step runs once, keys are unique).
100
+ */
101
+ export async function runOnce(store, runId, name, fn, attempt = 0) {
102
+ const key = idempotencyKey(runId, name, attempt);
103
+ const cached = await store.get(key);
104
+ if (cached !== undefined)
105
+ return cached.result;
106
+ const result = await fn();
107
+ await store.put({
108
+ key,
109
+ graphRunId: runId,
110
+ nodeName: name,
111
+ attempt,
112
+ result,
113
+ completedAt: new Date().toISOString(),
114
+ });
115
+ return result;
116
+ }
87
117
  export { InMemoryIdempotencyStore };
118
+ // Loop contract 0.4 (Batch F, temporal contract) — the schedule wake-loop
119
+ // runtime the channel/batch daemons arm their `schedule:` block with, and the
120
+ // durable idempotency store + env factory the graph/workflow bundles select
121
+ // for crash-resume exactly-once.
122
+ export { armSchedule, nextCronMatch, nextWakeDelayMs, } from "./schedule";
123
+ export { FileIdempotencyStore, createIdempotencyStore } from "./idempotency-store";
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Loop contract 0.4 (Batch F, temporal contract / G84 schedule half) — the
3
+ * runtime the daemon-able bundles (channel / batch) arm their `schedule:`
4
+ * wake loop with. The compiler lowers `schedule:` into an `IrSchedule`
5
+ * (cron OR interval, durations already normalized to ms); the emitters embed
6
+ * the numeric-literal {@link WakeSchedule} and hand `armSchedule` the wake
7
+ * callback, so all of the cron arithmetic lives HERE — one tested place —
8
+ * instead of duplicated across two emitted daemon templates.
9
+ *
10
+ * `WakeSchedule` is the IR's `IrSchedule` MINUS `instructions` (the synthetic
11
+ * wake prompt is the emitter's concern, threaded into `onWake`): the schedule
12
+ * runtime only decides *when* to fire, never *what* to run. That keeps this
13
+ * package free of any `@crewhaus/ir` dependency.
14
+ *
15
+ * The cron parser is a pragmatic 5-/6-field matcher: `*`, `?`, numeric,
16
+ * lists (`a,b`), ranges (`a-b`), and steps (`* /n`, `a/n`, `a-b/n`), plus
17
+ * month/weekday names, evaluated in an IANA timezone (default UTC). The
18
+ * Quartz-style extensions (`L`, `W`, `#`) are not interpreted — a field
19
+ * containing one is treated as a wildcard so the daemon never crashes on a
20
+ * cron it cannot fully model; the spec's own `schedule.cron` regex is the
21
+ * syntactic gate.
22
+ */
23
+ /** IR's `IrSchedule` without `instructions` — the "when", never the "what". */
24
+ export type WakeSchedule = {
25
+ readonly kind: "cron";
26
+ /** A 5- or 6-field cron expression (6-field is second-first, Quartz). */
27
+ readonly cron: string;
28
+ /** IANA tz the cron evaluates in; absent → UTC. */
29
+ readonly timezone?: string;
30
+ /** Random +/- delay per wake, in ms. Absent → fire exactly on time. */
31
+ readonly jitterMs?: number;
32
+ } | {
33
+ readonly kind: "interval";
34
+ readonly everyMs: number;
35
+ readonly jitterMs?: number;
36
+ };
37
+ /**
38
+ * The next `Date` strictly after `from` at which `cron` fires, evaluated in
39
+ * `timezone` (UTC when absent). Minute-granularity search; a 6-field cron's
40
+ * leading seconds field picks the smallest matching second inside the
41
+ * matched minute. Standard day-of-month / day-of-week rule: when BOTH are
42
+ * restricted the match is their UNION; when one is `*` the other decides.
43
+ */
44
+ export declare function nextCronMatch(cron: string, from: Date, timezone?: string): Date;
45
+ /**
46
+ * Milliseconds to wait from `fromMs` before the next wake. Jitter (when
47
+ * declared) adds a uniform `+/- jitterMs` offset drawn from `rand`
48
+ * (`Math.random` by default); the result never goes negative.
49
+ */
50
+ export declare function nextWakeDelayMs(schedule: WakeSchedule, fromMs: number, rand?: () => number): number;
51
+ export interface ArmScheduleHandlers {
52
+ /** Runs on every wake; awaited so overlapping ticks can't stack. */
53
+ readonly onWake: () => void | Promise<void>;
54
+ /** A thrown `onWake` lands here instead of crashing the scheduler. */
55
+ readonly onError?: (err: unknown) => void;
56
+ readonly now?: () => number;
57
+ readonly rand?: () => number;
58
+ readonly setTimer?: (fn: () => void, ms: number) => unknown;
59
+ readonly clearTimer?: (handle: unknown) => void;
60
+ }
61
+ export interface ArmedSchedule {
62
+ /** Stop the loop and clear any pending timer. Idempotent. */
63
+ cancel(): void;
64
+ }
65
+ /**
66
+ * Arm a self-rescheduling wake loop for `schedule`. Each tick computes its
67
+ * OWN next delay (so cron drift and per-wake jitter stay correct across a
68
+ * long-lived daemon) and re-arms only AFTER `onWake` resolves, so a slow tick
69
+ * can never overlap itself. All timing seams (`now`/`rand`/`setTimer`) are
70
+ * injectable for deterministic tests. Returns a cancel handle the daemon's
71
+ * shutdown path calls.
72
+ */
73
+ export declare function armSchedule(schedule: WakeSchedule, handlers: ArmScheduleHandlers): ArmedSchedule;
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Loop contract 0.4 (Batch F, temporal contract / G84 schedule half) — the
3
+ * runtime the daemon-able bundles (channel / batch) arm their `schedule:`
4
+ * wake loop with. The compiler lowers `schedule:` into an `IrSchedule`
5
+ * (cron OR interval, durations already normalized to ms); the emitters embed
6
+ * the numeric-literal {@link WakeSchedule} and hand `armSchedule` the wake
7
+ * callback, so all of the cron arithmetic lives HERE — one tested place —
8
+ * instead of duplicated across two emitted daemon templates.
9
+ *
10
+ * `WakeSchedule` is the IR's `IrSchedule` MINUS `instructions` (the synthetic
11
+ * wake prompt is the emitter's concern, threaded into `onWake`): the schedule
12
+ * runtime only decides *when* to fire, never *what* to run. That keeps this
13
+ * package free of any `@crewhaus/ir` dependency.
14
+ *
15
+ * The cron parser is a pragmatic 5-/6-field matcher: `*`, `?`, numeric,
16
+ * lists (`a,b`), ranges (`a-b`), and steps (`* /n`, `a/n`, `a-b/n`), plus
17
+ * month/weekday names, evaluated in an IANA timezone (default UTC). The
18
+ * Quartz-style extensions (`L`, `W`, `#`) are not interpreted — a field
19
+ * containing one is treated as a wildcard so the daemon never crashes on a
20
+ * cron it cannot fully model; the spec's own `schedule.cron` regex is the
21
+ * syntactic gate.
22
+ */
23
+ const MONTH_NAMES = {
24
+ jan: 1,
25
+ feb: 2,
26
+ mar: 3,
27
+ apr: 4,
28
+ may: 5,
29
+ jun: 6,
30
+ jul: 7,
31
+ aug: 8,
32
+ sep: 9,
33
+ oct: 10,
34
+ nov: 11,
35
+ dec: 12,
36
+ };
37
+ const DOW_NAMES = {
38
+ sun: 0,
39
+ mon: 1,
40
+ tue: 2,
41
+ wed: 3,
42
+ thu: 4,
43
+ fri: 5,
44
+ sat: 6,
45
+ };
46
+ function fieldMatches(field, n) {
47
+ return field === "*" || field.has(n);
48
+ }
49
+ /**
50
+ * Parse one cron field into a {@link FieldMatch}. Unrecognised tokens (the
51
+ * `L`/`W`/`#` Quartz extensions) collapse the whole field to `"*"` — a
52
+ * fail-open the daemon can survive rather than a throw that would kill the
53
+ * scheduler.
54
+ */
55
+ function parseField(field, min, max, names) {
56
+ const trimmed = field.trim();
57
+ if (trimmed === "*" || trimmed === "?")
58
+ return "*";
59
+ if (/[LW#]/i.test(trimmed))
60
+ return "*";
61
+ const out = new Set();
62
+ const tok = (t) => {
63
+ const named = names?.[t.toLowerCase()];
64
+ return named !== undefined ? named : Number.parseInt(t, 10);
65
+ };
66
+ for (const part of trimmed.split(",")) {
67
+ const slash = part.indexOf("/");
68
+ const rangeText = slash >= 0 ? part.slice(0, slash) : part;
69
+ const step = slash >= 0 ? Math.max(1, Number.parseInt(part.slice(slash + 1), 10) || 1) : 1;
70
+ let lo;
71
+ let hi;
72
+ if (rangeText === "*" || rangeText === "?" || rangeText === "") {
73
+ lo = min;
74
+ hi = max;
75
+ }
76
+ else if (rangeText.includes("-")) {
77
+ const [a, b] = rangeText.split("-");
78
+ lo = tok(a ?? "");
79
+ hi = tok(b ?? "");
80
+ }
81
+ else {
82
+ lo = tok(rangeText);
83
+ // `a/n` (a bare start with a step) means "a, a+n, a+2n, … up to max".
84
+ hi = slash >= 0 ? max : lo;
85
+ }
86
+ if (Number.isNaN(lo) || Number.isNaN(hi))
87
+ return "*";
88
+ for (let v = lo; v <= hi; v += step) {
89
+ if (v >= min && v <= max)
90
+ out.add(v);
91
+ }
92
+ }
93
+ return out.size === 0 ? "*" : out;
94
+ }
95
+ const WEEKDAY_INDEX = {
96
+ Sun: 0,
97
+ Mon: 1,
98
+ Tue: 2,
99
+ Wed: 3,
100
+ Thu: 4,
101
+ Fri: 5,
102
+ Sat: 6,
103
+ };
104
+ /** Wall-clock fields of `date` in `timezone` (UTC when absent). */
105
+ function wallClock(date, timezone) {
106
+ if (timezone === undefined) {
107
+ return {
108
+ minute: date.getUTCMinutes(),
109
+ hour: date.getUTCHours(),
110
+ dom: date.getUTCDate(),
111
+ month: date.getUTCMonth() + 1,
112
+ dow: date.getUTCDay(),
113
+ };
114
+ }
115
+ const parts = new Intl.DateTimeFormat("en-US", {
116
+ timeZone: timezone,
117
+ hour12: false,
118
+ month: "numeric",
119
+ day: "numeric",
120
+ hour: "numeric",
121
+ minute: "numeric",
122
+ weekday: "short",
123
+ }).formatToParts(date);
124
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
125
+ const hour24 = Number.parseInt(get("hour"), 10);
126
+ return {
127
+ minute: Number.parseInt(get("minute"), 10),
128
+ // Intl with hour12:false yields "24" for midnight in some ICU builds.
129
+ hour: hour24 === 24 ? 0 : hour24,
130
+ dom: Number.parseInt(get("day"), 10),
131
+ month: Number.parseInt(get("month"), 10),
132
+ dow: WEEKDAY_INDEX[get("weekday")] ?? 0,
133
+ };
134
+ }
135
+ const MINUTE_MS = 60_000;
136
+ const CRON_SEARCH_MINUTES = 366 * 24 * 60; // give up after a year without a match
137
+ /**
138
+ * The next `Date` strictly after `from` at which `cron` fires, evaluated in
139
+ * `timezone` (UTC when absent). Minute-granularity search; a 6-field cron's
140
+ * leading seconds field picks the smallest matching second inside the
141
+ * matched minute. Standard day-of-month / day-of-week rule: when BOTH are
142
+ * restricted the match is their UNION; when one is `*` the other decides.
143
+ */
144
+ export function nextCronMatch(cron, from, timezone) {
145
+ const fields = cron.trim().split(/\s+/);
146
+ const [secR, minR, hourR, domR, monR, dowR] = fields.length >= 6 ? fields : ["0", ...fields];
147
+ const secSet = parseField(secR ?? "0", 0, 59);
148
+ const minSet = parseField(minR ?? "*", 0, 59);
149
+ const hourSet = parseField(hourR ?? "*", 0, 23);
150
+ const domSet = parseField(domR ?? "*", 1, 31);
151
+ const monSet = parseField(monR ?? "*", 1, 12, MONTH_NAMES);
152
+ const dowRaw = parseField(dowR ?? "*", 0, 7, DOW_NAMES);
153
+ // Normalize Sunday: cron accepts both 0 and 7.
154
+ const dowSet = dowRaw === "*" ? "*" : new Set([...dowRaw].map((d) => (d === 7 ? 0 : d)));
155
+ const domRestricted = domSet !== "*";
156
+ const dowRestricted = dowSet !== "*";
157
+ // Search minute-by-minute from the CURRENT minute (so a 6-field cron whose
158
+ // matching second is still ahead inside `from`'s own minute is found), and
159
+ // accept only a whole timestamp STRICTLY after `from`.
160
+ const fromMs = from.getTime();
161
+ let t = new Date(Math.floor(fromMs / MINUTE_MS) * MINUTE_MS);
162
+ for (let i = 0; i <= CRON_SEARCH_MINUTES; i++) {
163
+ const wc = wallClock(t, timezone);
164
+ const domOk = fieldMatches(domSet, wc.dom);
165
+ const dowOk = fieldMatches(dowSet, wc.dow);
166
+ const dayOk = domRestricted && dowRestricted ? domOk || dowOk : domRestricted ? domOk : dowOk;
167
+ if (dayOk &&
168
+ fieldMatches(minSet, wc.minute) &&
169
+ fieldMatches(hourSet, wc.hour) &&
170
+ fieldMatches(monSet, wc.month)) {
171
+ for (let s = 0; s <= 59; s++) {
172
+ if (!fieldMatches(secSet, s))
173
+ continue;
174
+ const candidate = t.getTime() + s * 1000;
175
+ if (candidate > fromMs)
176
+ return new Date(candidate);
177
+ }
178
+ }
179
+ t = new Date(t.getTime() + MINUTE_MS);
180
+ }
181
+ throw new Error(`cron "${cron}" produced no wake within a year`);
182
+ }
183
+ /**
184
+ * Milliseconds to wait from `fromMs` before the next wake. Jitter (when
185
+ * declared) adds a uniform `+/- jitterMs` offset drawn from `rand`
186
+ * (`Math.random` by default); the result never goes negative.
187
+ */
188
+ export function nextWakeDelayMs(schedule, fromMs, rand = Math.random) {
189
+ const base = schedule.kind === "interval"
190
+ ? schedule.everyMs
191
+ : nextCronMatch(schedule.cron, new Date(fromMs), schedule.timezone).getTime() - fromMs;
192
+ const jitter = schedule.jitterMs !== undefined && schedule.jitterMs > 0
193
+ ? (rand() * 2 - 1) * schedule.jitterMs
194
+ : 0;
195
+ return Math.max(0, Math.round(base + jitter));
196
+ }
197
+ /**
198
+ * Arm a self-rescheduling wake loop for `schedule`. Each tick computes its
199
+ * OWN next delay (so cron drift and per-wake jitter stay correct across a
200
+ * long-lived daemon) and re-arms only AFTER `onWake` resolves, so a slow tick
201
+ * can never overlap itself. All timing seams (`now`/`rand`/`setTimer`) are
202
+ * injectable for deterministic tests. Returns a cancel handle the daemon's
203
+ * shutdown path calls.
204
+ */
205
+ export function armSchedule(schedule, handlers) {
206
+ const now = handlers.now ?? Date.now;
207
+ const rand = handlers.rand ?? Math.random;
208
+ const setTimer = handlers.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
209
+ const clearTimer = handlers.clearTimer ?? ((h) => clearTimeout(h));
210
+ let cancelled = false;
211
+ let handle;
212
+ const scheduleNext = () => {
213
+ if (cancelled)
214
+ return;
215
+ const delay = nextWakeDelayMs(schedule, now(), rand);
216
+ handle = setTimer(() => {
217
+ if (cancelled)
218
+ return;
219
+ void (async () => {
220
+ try {
221
+ await handlers.onWake();
222
+ }
223
+ catch (err) {
224
+ handlers.onError?.(err);
225
+ }
226
+ scheduleNext();
227
+ })();
228
+ }, delay);
229
+ };
230
+ scheduleNext();
231
+ return {
232
+ cancel() {
233
+ cancelled = true;
234
+ if (handle !== undefined)
235
+ clearTimer(handle);
236
+ },
237
+ };
238
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/durable-execution",
3
- "version": "0.3.2",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "description": "Exactly-once node execution wrapper over graph-engine — idempotency keys + crash-replay",
6
6
  "main": "dist/index.js",
@@ -15,8 +15,8 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/checkpoint-store": "0.3.2",
19
- "@crewhaus/errors": "0.3.2"
18
+ "@crewhaus/checkpoint-store": "0.4.2",
19
+ "@crewhaus/errors": "0.4.2"
20
20
  },
21
21
  "license": "Apache-2.0",
22
22
  "author": {