@michaelrwalker/stagehand 0.3.0 → 0.5.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/README.md CHANGED
@@ -303,6 +303,139 @@ per placement.
303
303
  `when` returning false marks the step **skipped** — its rollback never runs.
304
304
  Phases accept `when` too, which skips all their steps.
305
305
 
306
+ ## Caching
307
+
308
+ A phase or a step can reuse what it produced on an earlier run. Point it at a
309
+ store and it stops repeating work:
310
+
311
+ ```ts
312
+ import { Script, fileStore } from "@michaelrwalker/stagehand";
313
+
314
+ const cache = fileStore("./.stagehand-cache.json");
315
+
316
+ new Script<Input>({ name: "deploy" })
317
+ .addPhase("Build", { cache })
318
+ .addStep({ name: "install", handler: … })
319
+ .addStep({ name: "compile", handler: … })
320
+
321
+ .addPhase("Release")
322
+ .addStep({ name: "warm CDN", cache, handler: … })
323
+ ```
324
+
325
+ On a hit the work is skipped and the stored value is merged into the context —
326
+ downstream steps see the same keys, at the same types, either way.
327
+
328
+ **What gets stored.** A phase stores its **delta**: the keys its steps
329
+ contributed, minus anything they `clean`ed. Keys from earlier phases are never
330
+ part of it, so a hit can't overwrite a value this run just computed. A step
331
+ stores exactly what its handler returned.
332
+
333
+ **There is no key.** The store is the identity. Say when an entry stops being
334
+ good with `stale`, or delete the file:
335
+
336
+ ```ts
337
+ .addPhase("Build", {
338
+ cache: {
339
+ store: cache,
340
+ stale: ({ value, ctx, ageMs }) => ageMs > 3_600_000 || value.sha !== ctx.sha,
341
+ schema: BuildSchema, // optional; a value that no longer fits is a miss
342
+ },
343
+ })
344
+ ```
345
+
346
+ `stale` and `schema` both see the context as it stands when the phase is
347
+ reached, so `ctx` is typed with no annotation. `value` arrives as `unknown` —
348
+ annotate the parameter, or hand over a `schema` and let it narrow. A stored
349
+ value that fails its schema is treated as a **miss**, not an error, which is
350
+ what stops a cache written by an older version of the script from feeding the
351
+ wrong shape into the context.
352
+
353
+ **Rollback.** Cached work never ran, so its `rollback` can't fire during a
354
+ later unwind — the side effect belongs to the run that wrote the entry and was
355
+ never undone. For the same reason a failure downstream leaves entries alone.
356
+
357
+ **Per run**, without touching the script:
358
+
359
+ ```ts
360
+ await deploy.run(input, { cache: argv.includes("--no-cache") ? "off" : "on" });
361
+ ```
362
+
363
+ `"on"` (default) reads and writes · `"off"` ignores caching entirely ·
364
+ `"refresh"` runs everything and overwrites · `"read-only"` uses hits but never
365
+ writes.
366
+
367
+ ### Reaching the cache from a step
368
+
369
+ Every handler and rollback gets a `cache` handle, typed to the cached phases
370
+ declared **before** it:
371
+
372
+ ```ts
373
+ .addPhase("Fetch", { cache: { store } })
374
+ .addStep({ name: "pull orders", handler: … }) // → { orders }
375
+
376
+ .addPhase("Adjust")
377
+ .addStep({
378
+ name: "restock",
379
+ handler: async ({ ctx, cache }) => {
380
+ await cache.clear("Fetch"); // next run refetches
381
+ await cache.write("Fetch", { orders: ctx.orders }, { keepAge: true });
382
+ const age = await cache.ageOf("Fetch");
383
+ },
384
+ })
385
+ ```
386
+
387
+ Slot names are checked, not stringly-typed. An uncached phase isn't a slot at
388
+ all, and `as` renames a mounted routine's slots along with its phases — so the
389
+ moment you write `.use(pullChannel, { as: "Amazon" })`, every
390
+ `cache.clear("Fetch")` in the script goes red and offers `"Amazon / Fetch"`
391
+ instead. That's the failure a hand-written string can't catch: it would keep
392
+ compiling and silently address nothing.
393
+
394
+ Invalidation flows backwards only — a later step can drop an earlier phase's
395
+ entry, not the reverse.
396
+
397
+ `write` restamps the entry as written *now* unless you pass `keepAge`. Reach
398
+ for it when you are correcting a value rather than refreshing it: restamping
399
+ silently buys another full TTL for data that is exactly as old as it was a
400
+ moment ago, which is the opposite of what you want when the cache is there to
401
+ keep you inside a rate limit.
402
+
403
+ **Values are typed too.** A slot with a `schema` reads back as the schema's
404
+ output, because that is the one thing actually *checked* against the stored
405
+ JSON. Without a schema the phase's own delta stands in, which is convenient and
406
+ true right up until an entry outlives the code that wrote it. For that case
407
+ `read` returns the value behind a guard:
408
+
409
+ ```
410
+ CacheShapeError: Read "orders.0.date_fixed" from cache slot "Fetch", but the
411
+ stored value has no such property. The entry was probably written before this
412
+ script's shape changed — drop it with cache.clear("Fetch"), or declare a schema
413
+ on the slot to have mismatches rejected on read.
414
+ ```
415
+
416
+ Only reads are trapped; assigning a new property is how you'd patch an entry.
417
+ Missing array indices, `JSON.stringify`, `await` and iteration all behave
418
+ normally. Pass `{ raw: true }` to opt out — do that before putting the value
419
+ into the context, or the guard travels with it into later steps.
420
+
421
+ **Stores** are three methods, so Redis or S3 is a dozen lines:
422
+
423
+ ```ts
424
+ interface CacheStore {
425
+ read(slot: string): Awaitable<CachedEntry | undefined>;
426
+ write(slot: string, entry: CachedEntry): Awaitable<void>;
427
+ clear(slot: string): Awaitable<void>;
428
+ }
429
+ ```
430
+
431
+ `slot` is derived from the phase or step name so several phases can share one
432
+ store; scripts never write it. `fileStore(path)` keeps every slot in one JSON
433
+ file — a missing, unreadable or corrupt file all read as no cache at all, so
434
+ deleting it is always a valid reset. `memoryStore()` lasts for the process.
435
+
436
+ A store that throws — unreadable file, full disk, a `stale` predicate with a bug
437
+ — logs a warning and does the work. Caching never decides whether a run passes.
438
+
306
439
  ## Script options
307
440
 
308
441
  ```ts
@@ -350,9 +483,119 @@ script has actually produced `Ctx` by that point. Dropping a step that needs
350
483
  `{ user: User }` into a script that has not loaded a user yet is a compile
351
484
  error, not a runtime `undefined`.
352
485
 
486
+ ## Reusable scripts
487
+
488
+ `routineFor` does for whole phases what `stepFor` does for one step. Declare a
489
+ fragment once, mount it wherever it fits:
490
+
491
+ ```ts
492
+ // routines/channel.ts
493
+ export const pullChannel = routineFor<{ channel: string; since: string }>()(
494
+ "pull channel",
495
+ (script) =>
496
+ script
497
+ .addPhase("Fetch", { cache: { store: cache, stale: … } })
498
+ .addStep({ name: "authenticate", handler: … })
499
+ .addStep({ name: "pull orders", handler: … })
500
+ .addPhase("Normalize")
501
+ .addStep({ name: "dedupe and total", handler: … }),
502
+ );
503
+ ```
504
+
505
+ ```ts
506
+ new Script<Input>({ name: "channel sync" })
507
+ .use(pullChannel)
508
+ .addPhase("Validation")
509
+ .addStep({ name: "check totals", handler: ({ ctx }) => … }) // ctx.orderCount, ctx.gross
510
+
511
+ // months later, a different job entirely
512
+ new Script<ReportInput>({ name: "monthly report" })
513
+ .use(pullChannel)
514
+ .addPhase("Report")
515
+ .addStep({ name: "build summary", handler: ({ ctx }) => … })
516
+ ```
517
+
518
+ The two parameters on `routineFor<In, Ctx>()` are the **minimum** the fragment
519
+ needs — the input it reads, and the context it expects to already exist. Both
520
+ are checked at the `use()` call:
521
+
522
+ ```
523
+ Property 'hash' is missing in type '{}' but required in type '{ hash: string; }'.
524
+ ```
525
+
526
+ A plain `Script` mounts too, which is what keeps a pipeline runnable on its
527
+ own — `pipeline.run(input)` today, `.use(pipeline)` tomorrow. Its own
528
+ `ScriptOptions` (rollback mode, `logPlacement`, `silent`) are ignored in favour
529
+ of the host's; only its phases and steps come across.
530
+
531
+ **Everything travels with the fragment.** Phase-level `when` and `cache`,
532
+ step-level `retry`, `clean`, `rollback` — a spliced rollback compensates during
533
+ the host's unwind, and keys it reserved through `rollbackKeys` stay reserved in
534
+ the host, so a later `clean` there still can't delete them.
535
+
536
+ That combines well with caching: mount a routine whose phase caches, and the
537
+ second script to mount it gets the data the first one pulled.
538
+
539
+ ```
540
+ › Fetch
541
+ ⊙ authenticate cached
542
+ ⊙ pull orders cached
543
+ › Report
544
+ ✔ build summary (avg 24.50) 263ms
545
+ ```
546
+
547
+ ### Giving a mount its own input
548
+
549
+ A mount can feed its fragment something other than the host's input — which is
550
+ what lets one routine serve two storefronts, each with its own credentials:
551
+
552
+ ```ts
553
+ new Script<{ since: string; amazonKey: string; shopifyKey: string }>({ name: "all channels" })
554
+ .use(pullChannel, {
555
+ as: "Amazon",
556
+ input: ({ input }) => ({ channel: "amazon", since: input.since, apiKey: input.amazonKey }),
557
+ })
558
+ .use(pullChannel, {
559
+ as: "Shopify",
560
+ input: ({ input }) => ({ channel: "shopify", since: input.since, apiKey: input.shopifyKey }),
561
+ })
562
+ ```
563
+
564
+ `input` takes a fixed value or a function of `{ input, ctx }`, and may be
565
+ async. It is resolved **once per mount**, when the mount is reached, and every
566
+ phase and step of that fragment sees the result — including its `when`, its
567
+ `cache`, and its rollbacks during an unwind.
568
+
569
+ With a mapper the host no longer has to match the routine's input shape at all;
570
+ it only has to produce it. TypeScript checks the return value:
571
+
572
+ ```
573
+ Property 'apiKey' is missing in type '{ channel: string; }' but required in type 'ChannelInput'.
574
+ ```
575
+
576
+ Without one, the host's own input must satisfy the routine, as `stepFor` steps
577
+ require.
578
+
579
+ **Mounting twice** needs `as`, because phase names have to stay unique:
580
+
581
+ ```ts
582
+ .use(pullChannel, { as: "Amazon" }) // → "Amazon / Fetch", "Amazon / Normalize"
583
+ .use(pullChannel, { as: "Shopify" })
584
+ ```
585
+
586
+ Without it you get a `DuplicateNameError` at build time. That rule is not
587
+ routine-specific — two phases anywhere in a script, or two steps within one
588
+ phase, are refused. Names label the frame, are what `outline()` reports, and
589
+ identify a cache entry; two units sharing a name share a slot, which surfaces
590
+ as wrong data rather than as a failure.
591
+
592
+ A routine owns whole phases, so `addStep` directly after `use()` is refused
593
+ too — open a phase of your own first, rather than quietly appending to a
594
+ fragment someone else's script also mounts.
595
+
353
596
  ## Examples
354
597
 
355
- Seven runnable scripts, each aimed at a different part of the API. Most take a
598
+ Ten runnable scripts, each aimed at a different part of the API. Most take a
356
599
  flag to switch between the happy path and the interesting one.
357
600
 
358
601
  | | |
@@ -363,6 +606,9 @@ flag to switch between the happy path and the interesting one.
363
606
  | [`intake.ts`](examples/intake.ts) | `defineInput` with a hand-rolled Standard Schema, plus the full handler UI surface. `-- --bad` to watch validation reject the run, `-- --dirty` for a failing checklist item |
364
607
  | [`modular.ts`](examples/modular.ts) | `stepFor` steps living in [`steps/tenancy.ts`](examples/steps/tenancy.ts), shared by two unrelated scripts — the imported rollback compensates in both. `-- --fail` |
365
608
  | [`log-placement.ts`](examples/log-placement.ts) | The same script run three times, once per `logPlacement` value, so the difference is visible directly |
609
+ | [`cache.ts`](examples/cache.ts) | A cached `Build` phase and a cached step. Run it twice to watch the second run skip both. `-- --fresh` to rebuild and overwrite |
610
+ | [`invalidate.ts`](examples/invalidate.ts) | `context.cache` from inside a step: correct an entry without refetching, drop one that is beyond saving, and watch the shape guard catch an entry written before the code changed. `-- --patch`, `-- --drop`, `-- --drift` |
611
+ | [`routine.ts`](examples/routine.ts) | One reusable fragment in [`routines/channel.ts`](examples/routines/channel.ts), mounted by a sync script and, months later, a report that maps its own input onto the routine's — and gets the pull from cache. `-- --multi` mounts it twice, one storefront and API key per mount |
366
612
  | [`validate.ts`](examples/validate.ts) | The smallest thing that runs: two steps, `rollback: "none"` |
367
613
 
368
614
  ```
@@ -370,6 +616,8 @@ npm run example:checkout -- --ok
370
616
  npm run example:resilience -- --cancel
371
617
  npm run example:intake -- --bad
372
618
  npm run example:modular -- --fail
619
+ npm run example:invalidate -- --drift
620
+ npm run example:routine -- --multi
373
621
  npm run example:log-placement
374
622
  ```
375
623
 
@@ -378,12 +626,42 @@ npm run example:log-placement
378
626
  ```
379
627
  npm run build # emit dist/
380
628
  npm test # node:test suite
381
- npm run typecheck # library + examples + tests
629
+ npm run typecheck # library + examples + tests + scripts
630
+ npm run preflight # the release gate (add -- --allow-dirty while working)
382
631
  npm run example # the happy path, live
383
632
  npm run example:fail
384
633
  npm run release # release-it: version, tag, publish
385
634
  ```
386
635
 
636
+ ## Releasing
637
+
638
+ [`scripts/preflight.ts`](scripts/preflight.ts) is the gate, and it is written
639
+ with this library — so every release exercises phases, tasks, `note`, `clean`,
640
+ `when` and `retry` on real work:
641
+
642
+ ```
643
+ › Quality
644
+ ✔ typecheck 1.2s
645
+ ✔ tests (120 passing in 9 files) 5.1s
646
+ › Package
647
+ ✔ build 404ms
648
+ ✔ entry point resolves 0ms
649
+ ✔ tarball contents (47 files, 66kB) 180ms
650
+ › Registry
651
+ ✔ version is unpublished 212ms
652
+ ```
653
+
654
+ It checks the working tree is clean, typechecks all four projects separately so
655
+ a failure names the one that broke, runs the suite, then imports `dist/index.js`
656
+ the way a consumer would and asserts the public surface is actually there.
657
+ `npm pack --dry-run` has to contain the entry point, its declarations, LICENSE
658
+ and README — and must not leak `src/`, `test/`, `examples/` or `scripts/`.
659
+ Finally it asks npm whether the version is already published.
660
+
661
+ release-it runs it from `after:bump`, which is the first point at which
662
+ package.json holds the version being released. A failure aborts before anything
663
+ is committed, tagged or published.
664
+
387
665
  ## TypeScript layout
388
666
 
389
667
  One config per directory, because editors resolve a file's config by finding
@@ -0,0 +1,65 @@
1
+ import type { CacheMode, CacheOptions, CacheSource, CacheStore } from "./types.js";
2
+ /** Lives for the lifetime of the process. Useful in tests, and for re-runs. */
3
+ export declare function memoryStore(): CacheStore;
4
+ /**
5
+ * One JSON file, holding every slot that points at it. Several phases may
6
+ * share a path safely, and concurrent writes to one path are serialised.
7
+ *
8
+ * A missing, unreadable or malformed file all mean the same thing — no cache
9
+ * yet — so deleting the file is always a valid way to start over. Values must
10
+ * survive `JSON.stringify`; anything that does not (a `Date`, a class
11
+ * instance) comes back as its JSON shape.
12
+ */
13
+ export declare function fileStore(filePath: string): CacheStore;
14
+ /** `cache: someStore` is shorthand for `cache: { store: someStore }`. */
15
+ export declare function normalizeCache(source: CacheSource | undefined): CacheOptions | undefined;
16
+ export type CacheLookup = {
17
+ hit: true;
18
+ value: unknown;
19
+ ageMs: number;
20
+ } | {
21
+ hit: false;
22
+ };
23
+ /**
24
+ * Read an entry and put it through every gate. A failure at any gate is a
25
+ * miss — the work runs and the entry is replaced — never an error.
26
+ */
27
+ export declare function readCache(cache: CacheOptions, slot: string, input: unknown, ctx: unknown, mode: CacheMode): Promise<CacheLookup>;
28
+ /** Only ever called after the phase or step has fully succeeded. */
29
+ export declare function writeCache(cache: CacheOptions, slot: string, value: unknown, mode: CacheMode): Promise<void>;
30
+ /** Identifies a phase's entry. Steps hang off their phase to avoid collisions. */
31
+ export declare const phaseSlot: (phase: string) => string;
32
+ export declare const stepSlot: (phase: string, step: string) => string;
33
+ /**
34
+ * Wrap a value read back from a store so that touching a field it does not
35
+ * have throws {@link CacheShapeError} instead of yielding `undefined`.
36
+ *
37
+ * Only reads are trapped. Assigning a new property is how you'd legitimately
38
+ * patch an entry before writing it back, so it is left alone — the lie is
39
+ * always on the read side, where the type promised something the JSON lacks.
40
+ *
41
+ * Absent array indices pass through too: `orders[9]` on a short list is
42
+ * ordinary JavaScript, not a shape mismatch.
43
+ */
44
+ export declare function guardShape<T>(value: T, slot: string, path?: string): T;
45
+ /** Which store backs each slot, and whether that slot validates on read. */
46
+ export interface SlotBinding {
47
+ store: CacheStore;
48
+ schema: boolean;
49
+ }
50
+ /**
51
+ * Build the `cache` handle a handler sees. Slots it does not know about are
52
+ * already unreachable through the types; at runtime they throw rather than
53
+ * silently doing nothing, which is what a plain `store.clear("typo")` does.
54
+ */
55
+ export declare function createCacheHandle(slots: Map<string, SlotBinding>): {
56
+ read(slot: string, options?: {
57
+ raw?: boolean;
58
+ }): Promise<unknown>;
59
+ write(slot: string, value: unknown, options?: {
60
+ keepAge?: boolean;
61
+ }): Promise<void>;
62
+ clear(slot: string): Promise<void>;
63
+ ageOf(slot: string): Promise<number | undefined>;
64
+ };
65
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,SAAS,EACT,YAAY,EACZ,WAAW,EACX,UAAU,EAEX,MAAM,YAAY,CAAC;AAMpB,+EAA+E;AAC/E,wBAAgB,WAAW,IAAI,UAAU,CAWxC;AA2BD;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAoEtD;AAgBD,yEAAyE;AACzE,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAMxF;AAED,MAAM,MAAM,WAAW,GACnB;IAAE,GAAG,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,GAAG,EAAE,KAAK,CAAA;CAAE,CAAC;AAInB;;;GAGG;AACH,wBAAsB,SAAS,CAC7B,KAAK,EAAE,YAAY,EACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,SAAS,GACd,OAAO,CAAC,WAAW,CAAC,CAoBtB;AAED,oEAAoE;AACpE,wBAAsB,UAAU,CAC9B,KAAK,EAAE,YAAY,EACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,OAAO,EACd,IAAI,EAAE,SAAS,GACd,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,kFAAkF;AAClF,eAAO,MAAM,SAAS,GAAI,OAAO,MAAM,KAAG,MAAe,CAAC;AAC1D,eAAO,MAAM,QAAQ,GAAI,OAAO,MAAM,EAAE,MAAM,MAAM,KAAG,MAA6B,CAAC;AAqBrF;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,SAAK,GAAG,CAAC,CAelE;AAMD,4EAA4E;AAC5E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,UAAU,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG;IAClE,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClE,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpF,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAClD,CAoCA"}
package/dist/cache.js ADDED
@@ -0,0 +1,251 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { CacheShapeError, StepDefinitionError } from "./errors.js";
4
+ /* -------------------------------------------------------------------------- */
5
+ /* Stores */
6
+ /* -------------------------------------------------------------------------- */
7
+ /** Lives for the lifetime of the process. Useful in tests, and for re-runs. */
8
+ export function memoryStore() {
9
+ const slots = new Map();
10
+ return {
11
+ read: (slot) => slots.get(slot),
12
+ write: (slot, entry) => {
13
+ slots.set(slot, entry);
14
+ },
15
+ clear: (slot) => {
16
+ slots.delete(slot);
17
+ },
18
+ };
19
+ }
20
+ /**
21
+ * In-process write queues, keyed by resolved path so two `fileStore` calls on
22
+ * the same file share one.
23
+ *
24
+ * Every write is a read-modify-write of the whole file, which is only correct
25
+ * if writes to that file never overlap. They otherwise lose each other's slots
26
+ * and race on the temp file. Cross-process safety is out of scope: two `node`
27
+ * processes writing one cache file can still interleave.
28
+ */
29
+ const writeQueues = new Map();
30
+ let tempCounter = 0;
31
+ function serialize(key, task) {
32
+ const next = (writeQueues.get(key) ?? Promise.resolve()).then(task, task);
33
+ // A failed write must not poison the writes queued behind it.
34
+ writeQueues.set(key, next.then(() => undefined, () => undefined));
35
+ return next;
36
+ }
37
+ /**
38
+ * One JSON file, holding every slot that points at it. Several phases may
39
+ * share a path safely, and concurrent writes to one path are serialised.
40
+ *
41
+ * A missing, unreadable or malformed file all mean the same thing — no cache
42
+ * yet — so deleting the file is always a valid way to start over. Values must
43
+ * survive `JSON.stringify`; anything that does not (a `Date`, a class
44
+ * instance) comes back as its JSON shape.
45
+ */
46
+ export function fileStore(filePath) {
47
+ const target = resolve(filePath);
48
+ const readAll = async () => {
49
+ try {
50
+ const parsed = JSON.parse(await readFile(target, "utf8"));
51
+ if (parsed === null || typeof parsed !== "object")
52
+ return {};
53
+ return parsed;
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ };
59
+ // Written through a temp file so an interrupted run cannot leave a
60
+ // half-serialised cache behind for the next one to choke on. The temp name
61
+ // carries a counter as well as the pid — the pid alone is shared by every
62
+ // write in the process, which is exactly how concurrent writes used to
63
+ // rename each other's files out from under themselves.
64
+ const writeAll = async (data) => {
65
+ await mkdir(dirname(target), { recursive: true });
66
+ const payload = `${JSON.stringify(data, null, 2)}\n`;
67
+ const temp = `${target}.${process.pid}.${(tempCounter += 1)}.tmp`;
68
+ try {
69
+ await writeFile(temp, payload, "utf8");
70
+ try {
71
+ await rename(temp, target);
72
+ }
73
+ catch (error) {
74
+ // Windows, antivirus, editors and file watchers can all hold the
75
+ // destination open and refuse the atomic swap. Writing in place gives
76
+ // up atomicity rather than the write.
77
+ const code = error.code;
78
+ if (code !== "EPERM" && code !== "EACCES" && code !== "EBUSY" && code !== "EEXIST") {
79
+ throw error;
80
+ }
81
+ await writeFile(target, payload, "utf8");
82
+ }
83
+ }
84
+ finally {
85
+ await rm(temp, { force: true });
86
+ }
87
+ };
88
+ const update = (mutate) => serialize(target, async () => {
89
+ const data = await readAll();
90
+ if (!mutate(data))
91
+ return;
92
+ await writeAll(data);
93
+ });
94
+ return {
95
+ async read(slot) {
96
+ const entry = (await readAll())[slot];
97
+ return isEntry(entry) ? entry : undefined;
98
+ },
99
+ write(slot, entry) {
100
+ return update((data) => {
101
+ data[slot] = entry;
102
+ return true;
103
+ });
104
+ },
105
+ clear(slot) {
106
+ return update((data) => {
107
+ if (!Object.hasOwn(data, slot))
108
+ return false;
109
+ delete data[slot];
110
+ return true;
111
+ });
112
+ },
113
+ };
114
+ }
115
+ /** A hand-edited or older-format file should read as a miss, not as a crash. */
116
+ function isEntry(value) {
117
+ return (typeof value === "object" &&
118
+ value !== null &&
119
+ "value" in value &&
120
+ typeof value.savedAt === "number");
121
+ }
122
+ /* -------------------------------------------------------------------------- */
123
+ /* Runtime */
124
+ /* -------------------------------------------------------------------------- */
125
+ /** `cache: someStore` is shorthand for `cache: { store: someStore }`. */
126
+ export function normalizeCache(source) {
127
+ if (!source)
128
+ return undefined;
129
+ if (typeof source.read === "function") {
130
+ return { store: source };
131
+ }
132
+ return source;
133
+ }
134
+ const MISS = { hit: false };
135
+ /**
136
+ * Read an entry and put it through every gate. A failure at any gate is a
137
+ * miss — the work runs and the entry is replaced — never an error.
138
+ */
139
+ export async function readCache(cache, slot, input, ctx, mode) {
140
+ if (mode === "off" || mode === "refresh")
141
+ return MISS;
142
+ const entry = await cache.store.read(slot);
143
+ if (!entry)
144
+ return MISS;
145
+ let value = entry.value;
146
+ if (cache.schema) {
147
+ const result = await cache.schema["~standard"].validate(value);
148
+ if (result.issues)
149
+ return MISS;
150
+ value = result.value;
151
+ }
152
+ const ageMs = Math.max(0, Date.now() - entry.savedAt);
153
+ if (cache.stale && (await cache.stale({ value, input, ctx, savedAt: entry.savedAt, ageMs }))) {
154
+ return MISS;
155
+ }
156
+ return { hit: true, value, ageMs };
157
+ }
158
+ /** Only ever called after the phase or step has fully succeeded. */
159
+ export async function writeCache(cache, slot, value, mode) {
160
+ if (mode === "off" || mode === "read-only")
161
+ return;
162
+ await cache.store.write(slot, { value, savedAt: Date.now() });
163
+ }
164
+ /** Identifies a phase's entry. Steps hang off their phase to avoid collisions. */
165
+ export const phaseSlot = (phase) => phase;
166
+ export const stepSlot = (phase, step) => `${phase}::${step}`;
167
+ /* -------------------------------------------------------------------------- */
168
+ /* Shape guard */
169
+ /* -------------------------------------------------------------------------- */
170
+ /**
171
+ * Properties every JavaScript runtime reaches for on an arbitrary object.
172
+ * Trapping them breaks `await` (which probes `then`), `JSON.stringify`,
173
+ * `console.log` and template interpolation — so they pass straight through.
174
+ */
175
+ const PASSTHROUGH = new Set([
176
+ "then",
177
+ "toJSON",
178
+ "constructor",
179
+ "valueOf",
180
+ "toString",
181
+ "inspect",
182
+ "length",
183
+ ]);
184
+ /**
185
+ * Wrap a value read back from a store so that touching a field it does not
186
+ * have throws {@link CacheShapeError} instead of yielding `undefined`.
187
+ *
188
+ * Only reads are trapped. Assigning a new property is how you'd legitimately
189
+ * patch an entry before writing it back, so it is left alone — the lie is
190
+ * always on the read side, where the type promised something the JSON lacks.
191
+ *
192
+ * Absent array indices pass through too: `orders[9]` on a short list is
193
+ * ordinary JavaScript, not a shape mismatch.
194
+ */
195
+ export function guardShape(value, slot, path = "") {
196
+ if (value === null || typeof value !== "object")
197
+ return value;
198
+ return new Proxy(value, {
199
+ get(target, key, receiver) {
200
+ const actual = Reflect.get(target, key, receiver);
201
+ if (typeof key === "symbol" || PASSTHROUGH.has(key))
202
+ return actual;
203
+ const isIndex = Array.isArray(target) && /^(0|[1-9]\d*)$/.test(key);
204
+ const known = Object.hasOwn(target, key) || key in (Object.getPrototypeOf(target) ?? {});
205
+ if (!isIndex && !known)
206
+ throw new CacheShapeError(slot, path ? `${path}.${key}` : key);
207
+ return guardShape(actual, slot, path ? `${path}.${key}` : key);
208
+ },
209
+ });
210
+ }
211
+ /**
212
+ * Build the `cache` handle a handler sees. Slots it does not know about are
213
+ * already unreachable through the types; at runtime they throw rather than
214
+ * silently doing nothing, which is what a plain `store.clear("typo")` does.
215
+ */
216
+ export function createCacheHandle(slots) {
217
+ const bindingFor = (slot) => {
218
+ const binding = slots.get(slot);
219
+ if (!binding) {
220
+ const known = [...slots.keys()].map((name) => `"${name}"`).join(", ") || "none";
221
+ throw new StepDefinitionError(slot, `No cached phase named "${slot}" has been declared before this step. Known slots: ${known}.`);
222
+ }
223
+ return binding;
224
+ };
225
+ return {
226
+ async read(slot, options) {
227
+ const binding = bindingFor(slot);
228
+ const entry = await binding.store.read(slot);
229
+ if (!entry)
230
+ return undefined;
231
+ // A slot with a schema was validated on the way in, so its type is
232
+ // already backed by a check; only the inferred kind needs guarding.
233
+ if (options?.raw || binding.schema)
234
+ return entry.value;
235
+ return guardShape(entry.value, slot);
236
+ },
237
+ async write(slot, value, options) {
238
+ const store = bindingFor(slot).store;
239
+ const previous = options?.keepAge ? await store.read(slot) : undefined;
240
+ await store.write(slot, { value, savedAt: previous?.savedAt ?? Date.now() });
241
+ },
242
+ async clear(slot) {
243
+ await bindingFor(slot).store.clear(slot);
244
+ },
245
+ async ageOf(slot) {
246
+ const entry = await bindingFor(slot).store.read(slot);
247
+ return entry ? Math.max(0, Date.now() - entry.savedAt) : undefined;
248
+ },
249
+ };
250
+ }
251
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.js","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"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"}
package/dist/context.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CacheHandle } from "./types.js";
1
2
  import type { StepState } from "./state.js";
2
3
  import type { Renderer } from "./ui/renderer.js";
3
4
  import type { RollbackContext, StepContext } from "./types.js";
@@ -5,6 +6,8 @@ export interface ContextDeps {
5
6
  renderer: Renderer;
6
7
  step: StepState;
7
8
  phaseName: string;
9
+ /** Backs `context.cache`; slots declared before the current step. */
10
+ cache: CacheHandle<Record<string, unknown>>;
8
11
  }
9
12
  export declare function createStepContext<In, Ctx>(deps: ContextDeps, input: In, ctx: Ctx, signal: AbortSignal, attempt: number): StepContext<In, Ctx>;
10
13
  export declare function createRollbackContext<In, Ctx, Out>(deps: ContextDeps, input: In, ctx: Ctx, output: Out, error: unknown, signal: AbortSignal): RollbackContext<In, Ctx, Out>;