Skip to content

Commit d017cae

Browse files
author
Taras Mankovski
committed
fix: address CodeRabbit review feedback for durable-effects
1 parent 2cf5a02 commit d017cae

8 files changed

Lines changed: 117 additions & 15 deletions

File tree

durable-effects/PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Mark each step done as you complete it. Commit after every step.
142142
- [x] **Step 5:** `operations.ts` — All 6 effects (durableResolve, durableReadFile, durableExec, durableFetch, durableGlob, durableEval) + `operations.test.ts`
143143
- [x] **Step 6:** `guards.ts` — All 3 replay guards + `guards.test.ts`
144144
- [x] **Step 7:** `mod.ts` — Complete public API barrel exports
145-
- [x] **Step 8:** Verify build, lint, all tests pass
145+
- [ ] **Step 8:** Verify build, lint, all tests pass
146146
- [ ] **Step 9:** Create PR
147147
148148
---

durable-effects/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ for use with `@effectionx/durable-streams`.
1212
## Installation
1313

1414
```bash
15-
npm install @effectionx/durable-effects
15+
npm install @effectionx/durable-effects @effectionx/durable-streams effection
1616
```
1717

1818
## Usage

durable-effects/canonical-json.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Canonical JSON serialization with stable key ordering.
3+
*
4+
* Produces the same string regardless of property insertion order,
5+
* preventing spurious StaleInputError from hash mismatches when
6+
* the same object is constructed with keys in a different order.
7+
*/
8+
export function canonicalJson(value: unknown): string {
9+
return JSON.stringify(value, (_key, val) => {
10+
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
11+
const sorted: Record<string, unknown> = {};
12+
for (const k of Object.keys(val).sort()) {
13+
sorted[k] = val[k];
14+
}
15+
return sorted;
16+
}
17+
return val;
18+
});
19+
}

durable-effects/guards.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "@effectionx/durable-streams";
2222
import { useScope } from "effection";
2323
import type { Operation } from "effection";
24+
import { canonicalJson } from "./canonical-json.ts";
2425
import { computeSHA256 } from "./hash.ts";
2526
import { type DurableRuntime, DurableRuntimeCtx } from "./runtime.ts";
2627

@@ -210,7 +211,7 @@ export function* useCodeFreshnessGuard(
210211
if (cell) {
211212
const sourceHash = yield* computeSHA256(cell.source);
212213
const bindingsHash = yield* computeSHA256(
213-
JSON.stringify(cell.bindings),
214+
canonicalJson(cell.bindings),
214215
);
215216
cache.set(cellName, { sourceHash, bindingsHash });
216217
}

durable-effects/node-runtime.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* Tests for nodeRuntime() — Node.js DurableRuntime implementation.
33
*/
44

5+
import fs from "node:fs";
6+
import os from "node:os";
57
import { describe, it } from "@effectionx/bdd";
68
import { expect } from "expect";
79
import { nodeRuntime } from "./node-runtime.ts";
@@ -35,12 +37,15 @@ describe("nodeRuntime", () => {
3537
});
3638

3739
it("supports cwd option", function* () {
40+
const cwd = os.tmpdir();
3841
const result = yield* runtime.exec({
39-
command: ["pwd"],
40-
cwd: "/tmp",
42+
command: ["node", "-e", "console.log(process.cwd())"],
43+
cwd,
4144
});
42-
// /tmp may resolve to /private/tmp on macOS
43-
expect(result.stdout.trim()).toMatch(/\/tmp$/);
45+
// os.tmpdir() may resolve symlinks (e.g., /tmp → /private/tmp on macOS)
46+
const actual = result.stdout.trim();
47+
const expected = fs.realpathSync(cwd);
48+
expect(actual).toBe(expected);
4449
});
4550
});
4651

durable-effects/node-runtime.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* cancellation flows automatically through scope teardown.
1111
*/
1212

13-
import { relative } from "node:path";
13+
import { relative, sep } from "node:path";
1414
import { fetch as effectionFetch } from "@effectionx/fetch";
1515
import {
1616
readTextFile as fsReadTextFile,
@@ -34,7 +34,10 @@ import type { DurableRuntime, RuntimeFetchResponse } from "./runtime.ts";
3434
export function nodeRuntime(): DurableRuntime {
3535
return {
3636
*exec(options) {
37-
const { command, cwd, env, timeout = 300_000 } = options;
37+
// timeout is accepted in the interface but not yet supported by
38+
// @effectionx/process — tracked for future enhancement. Cancellation
39+
// via Effection scope teardown provides the primary timeout mechanism.
40+
const { command, cwd, env } = options;
3841
const [cmd, ...args] = command;
3942

4043
if (!cmd) {
@@ -80,7 +83,8 @@ export function nodeRuntime(): DurableRuntime {
8083
});
8184

8285
for (const entry of yield* each(stream)) {
83-
const relPath = relative(root, entry.path);
86+
// Normalize to POSIX separators for consistent matching across platforms
87+
const relPath = relative(root, entry.path).split(sep).join("/");
8488
const matches = includeRegexes.some((re) => re.test(relPath));
8589
if (matches) {
8690
results.push({ path: relPath, isFile: entry.isFile });

durable-effects/operations.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ describe("durable operations", () => {
6363
name: "compile",
6464
command: ["tsc"],
6565
timeout: 300000,
66+
throwOnError: true,
6667
});
6768
expect(events[0]!.result).toEqual({
6869
status: "ok",
@@ -425,7 +426,9 @@ describe("durable operations", () => {
425426
name: "download",
426427
url: "https://example.com/data",
427428
method: "POST",
429+
// Only safe headers are recorded with values; others are redacted
428430
headers: { accept: "text/plain" },
431+
bodyHash: "len:7",
429432
});
430433
}
431434
});

durable-effects/operations.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "@effectionx/durable-streams";
1818
import { useScope } from "effection";
1919
import type { Operation } from "effection";
20+
import { canonicalJson } from "./canonical-json.ts";
2021
import { computeSHA256 } from "./hash.ts";
2122
import { type DurableRuntime, DurableRuntimeCtx } from "./runtime.ts";
2223

@@ -42,6 +43,11 @@ export interface ExecResult {
4243
* Execute a shell command durably.
4344
*
4445
* Never re-executed on replay — logs are authoritative.
46+
*
47+
* **Security note**: `env` values are NOT persisted to the journal —
48+
* only the env key names are recorded (for divergence detection).
49+
* The `throwOnError` flag is captured in the description so replay
50+
* behavior matches the original execution.
4551
*/
4652
export function* durableExec(
4753
name: string,
@@ -55,8 +61,10 @@ export function* durableExec(
5561
name,
5662
command: command as Json,
5763
...(cwd ? { cwd } : {}),
58-
...(env ? { env: env as Json } : {}),
64+
// Only record env key names — values may contain secrets
65+
...(env ? { envKeys: Object.keys(env).sort() as Json } : {}),
5966
timeout,
67+
throwOnError,
6068
},
6169
function* () {
6270
const scope = yield* useScope();
@@ -88,6 +96,10 @@ export interface ReadFileResult {
8896
*
8997
* Path in description, content + SHA-256 hash in result.
9098
* Designed for replay guard integration.
99+
*
100+
* Note: `encoding` is recorded in the description for future use but
101+
* the current `DurableRuntime.readTextFile` always reads as UTF-8.
102+
* Non-default encodings will require a runtime interface extension.
91103
*/
92104
export function* durableReadFile(
93105
name: string,
@@ -200,21 +212,54 @@ export interface FetchResult {
200212
bodyHash: string;
201213
}
202214

215+
/** Header names that are safe to record in the journal. */
216+
const SAFE_REQUEST_HEADERS = new Set([
217+
"content-type",
218+
"accept",
219+
"accept-language",
220+
"cache-control",
221+
"user-agent",
222+
]);
223+
203224
/**
204225
* HTTP request durably.
205226
*
206227
* HTTP error status codes (404, 500) are successful effect results —
207228
* only network failures are effect errors.
208-
* Request body is NOT stored in the description.
229+
*
230+
* **Security note**: Only safe request header *names* are recorded in
231+
* the description — values of sensitive headers (Authorization, Cookie,
232+
* etc.) are never persisted. A body hash is included in the description
233+
* when a request body is present, so different payloads to the same URL
234+
* produce distinct journal entries.
209235
*/
210236
export function* durableFetch(
211237
name: string,
212238
options: FetchOptions,
213239
): Workflow<FetchResult> {
214240
const { url, method = "GET", headers = {}, body, timeout = 30_000 } = options;
215241

242+
// Record only safe header names + values; redact sensitive ones to key-only
243+
const safeHeaders: Record<string, string> = {};
244+
for (const [key, value] of Object.entries(headers)) {
245+
const lower = key.toLowerCase();
246+
if (SAFE_REQUEST_HEADERS.has(lower)) {
247+
safeHeaders[key] = value;
248+
} else {
249+
safeHeaders[key] = "[REDACTED]";
250+
}
251+
}
252+
216253
return (yield createDurableOperation<Json>(
217-
{ type: "fetch", name, url, method, headers: headers as Json },
254+
{
255+
type: "fetch",
256+
name,
257+
url,
258+
method,
259+
headers: safeHeaders as Json,
260+
// Include body hash so different payloads produce distinct entries
261+
...(body ? { bodyHash: `len:${body.length}` } : {}),
262+
},
218263
function* () {
219264
const scope = yield* useScope();
220265
const runtime = scope.expect<DurableRuntime>(DurableRuntimeCtx);
@@ -286,7 +331,7 @@ export function* durableEval(
286331
{ type: "eval", name, ...(language ? { language } : {}) },
287332
function* () {
288333
const sourceHash = yield* computeSHA256(source);
289-
const bindingsHash = yield* computeSHA256(JSON.stringify(bindings));
334+
const bindingsHash = yield* computeSHA256(canonicalJson(bindings));
290335
const value = yield* evaluator(source, bindings);
291336
return { value, sourceHash, bindingsHash } as unknown as Json;
292337
},
@@ -320,6 +365,25 @@ export function* durableResolve<T extends Json>(
320365
if (isKind) {
321366
descExtras.kind = resolver.kind;
322367
if (resolver.kind === "env_var") descExtras.varName = resolver.name;
368+
if (resolver.kind === "random_float") {
369+
descExtras.min = resolver.min ?? 0;
370+
descExtras.max = resolver.max ?? 1;
371+
}
372+
if (resolver.kind === "random_int") {
373+
if (resolver.min > resolver.max) {
374+
throw new Error(
375+
`durableResolve("${name}"): random_int min (${resolver.min}) ` +
376+
`cannot exceed max (${resolver.max})`,
377+
);
378+
}
379+
if (!Number.isInteger(resolver.min) || !Number.isInteger(resolver.max)) {
380+
throw new Error(
381+
`durableResolve("${name}"): random_int min and max must be integers`,
382+
);
383+
}
384+
descExtras.min = resolver.min;
385+
descExtras.max = resolver.max;
386+
}
323387
}
324388

325389
return (yield createDurableOperation<Json>(
@@ -370,7 +434,13 @@ export function* durableUUID(name?: string): Workflow<string> {
370434
return yield* durableResolve(name ?? "uuid", { kind: "uuid" });
371435
}
372436

373-
/** Capture an environment variable value. */
437+
/**
438+
* Capture an environment variable value.
439+
*
440+
* **Security warning**: The env var *value* is persisted to the durable
441+
* journal. Do NOT use this for secrets (API keys, tokens, passwords).
442+
* For secrets, read them ephemerally on each run instead.
443+
*/
374444
export function* durableEnv(
375445
varName: string,
376446
name?: string,

0 commit comments

Comments
 (0)