@michaelrwalker/stagehand 0.2.0 → 0.4.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
@@ -6,6 +6,7 @@ A fully typesafe, saga-style script framework for TypeScript, with a live termin
6
6
  - **Phases** are a collection of **steps**.
7
7
  - **Steps** have a `handler` and an optional `rollback` that fires when a *later* step fails.
8
8
  - Whatever a handler returns is merged into a **typed context** that every later step can read.
9
+ - A step can `clean` keys it is done with, and a `rollback` declares the keys it needs.
9
10
  - Zero runtime dependencies. Live rendering on a TTY, plain line output in CI.
10
11
 
11
12
  ```ts
@@ -27,8 +28,9 @@ const result = await new Script<{ service: string }>({ name: "deploy" })
27
28
 
28
29
  return { uploadId: await upload(ctx.sha, bar) };
29
30
  },
30
- rollback: async ({ output }) => cdn.delete(output.uploadId),
31
- // ^ typed as { uploadId: string }
31
+ rollbackKeys: ["sha"],
32
+ rollback: async ({ ctx, output }) => cdn.delete(output.uploadId, ctx.sha),
33
+ // ^ { sha: string } ^ typed as { uploadId: string }
32
34
  })
33
35
  .run({ service: "api" });
34
36
 
@@ -46,7 +48,7 @@ if (result.ok) result.ctx.uploadId; // string
46
48
  ✔ check permissions 517ms
47
49
 
48
50
  ⠸ Build 17.7s
49
- ✔ install dependencies 14.4s
51
+ ✔ install dependencies (428 packages) 14.4s
50
52
  ⠸ compile bundle › transform 3.3s
51
53
  ▕██████████████░░░░░░░░░░▏ 58% 412/710 src/router.ts
52
54
  ✔ typecheck
@@ -86,6 +88,47 @@ Reaching for a key a previous step did not produce is a compile error, as is
86
88
  reading the wrong shape in a `rollback`. A handler that returns nothing leaves
87
89
  the context unchanged.
88
90
 
91
+ ## Cleaning the context
92
+
93
+ If a step produces — or receives — data that later steps do not need, listing
94
+ those keys in `clean` deletes them from the context at runtime **and** removes
95
+ them from the type that flows on:
96
+
97
+ ```ts
98
+ new Script<{ password: string; email: string }>({ name: "signup" })
99
+ .addStep({
100
+ name: "read input",
101
+ handler: ({ input }) => ({ ...input }),
102
+ })
103
+ .addStep({
104
+ name: "hash",
105
+ handler: async ({ ctx }) => ({ hash: await hashIt(ctx.password) }),
106
+ clean: ["password"], // not needed past this point
107
+ })
108
+ .addStep({
109
+ name: "persist",
110
+ handler: ({ ctx }) => {
111
+ ctx.hash; // string
112
+ ctx.email; // string
113
+ ctx.password; // compile error — cleaned away
114
+ },
115
+ });
116
+ ```
117
+
118
+ Rules:
119
+
120
+ - Keys are checked against the context as it stands *entering* that step — an
121
+ unknown key is a compile error, and the editor autocompletes the valid ones.
122
+ A step cannot clean a key it produces itself; that key does not exist yet
123
+ when `clean` is read.
124
+ - A key reserved by any step's `rollbackKeys` can never be cleaned, by that
125
+ step or any later one. Trying to throws `StepDefinitionError` from `addStep`,
126
+ while the script is being built, before anything runs.
127
+ - `clean` is part of the declared shape, not of the work, so it applies even
128
+ when the step is skipped by `when` — runtime and types never disagree.
129
+ - A step's own `output` is kept separately and is never cleaned, so its
130
+ `rollback` still sees everything the handler returned.
131
+
89
132
  ## Input from a schema
90
133
 
91
134
  `In` doesn't have to be written by hand either. `defineInput` takes any
@@ -122,6 +165,45 @@ A rollback that throws is recorded in `result.rollbacks` and surfaced in the
122
165
  summary; it never masks the original error, and the remaining rollbacks still
123
166
  run.
124
167
 
168
+ ### What a rollback can see
169
+
170
+ A rollback always gets its own step's `output` in full. It gets **no context at
171
+ all** by default — it has to ask, via `rollbackKeys`. Asking does two things: it
172
+ narrows `ctx` inside the rollback to exactly those keys, and it reserves them,
173
+ so neither that step nor any later one can `clean` them away. Whatever the
174
+ rollback declared is therefore guaranteed to still be there if it ever runs.
175
+
176
+ ```ts
177
+ new Script<{ accountId: string; amount: number }>({ name: "charge" })
178
+ .addStep({ name: "read input", handler: ({ input }) => ({ ...input }) })
179
+ .addStep({
180
+ name: "reserve funds",
181
+ handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId, ctx.amount) }),
182
+ rollbackKeys: ["accountId", "reservationId"],
183
+ rollback: async ({ ctx }) => {
184
+ // ctx is { accountId: string; reservationId: string } — and nothing else
185
+ await release(ctx.accountId, ctx.reservationId);
186
+ },
187
+ })
188
+ .addStep({
189
+ name: "charge card",
190
+ handler: () => {
191
+ throw new Error("payment gateway timeout");
192
+ },
193
+ });
194
+ ```
195
+
196
+ `rollbackKeys` accepts the step's incoming context keys and its own output keys;
197
+ anything else is a compile error. Cleaning a reserved key is a compile error
198
+ too, and throws `StepDefinitionError` at build time:
199
+
200
+ ```ts
201
+ script
202
+ .addStep({ name: "one", handler: () => {}, rollbackKeys: ["a"], rollback: async () => {} })
203
+ .addStep({ name: "two", handler: () => {}, clean: ["a"] });
204
+ // ^ StepDefinitionError: reserved by rollbackKeys
205
+ ```
206
+
125
207
  ## The handler surface
126
208
 
127
209
  Every handler receives one object:
@@ -132,14 +214,75 @@ Every handler receives one object:
132
214
  | `ctx` | everything earlier steps produced, fully typed |
133
215
  | `signal` | aborts on timeout, Ctrl-C, or an external signal |
134
216
  | `attempt` | 1-based, useful with `retry` |
135
- | `log` / `info` / `warn` / `error` / `success` | permanent lines above the live frame |
136
- | `status(text)` | transient one-liner beside the step |
217
+ | `log` / `info` / `warn` / `error` / `success` | a line, placed per `logPlacement` (default: permanent, above the live frame) |
218
+ | `status(text)` | transient one-liner beside the step, cleared when it settles |
219
+ | `note(text)` | annotates the step title — `(400 rows found)` — and stays |
137
220
  | `progress({ total, label })` | a progress bar → `update` / `increment` / `setTotal` / `done` |
138
221
  | `task(label)` | one nested checklist item → `succeed` / `fail` / `skip` |
139
222
  | `tasks([...] as const)` | a whole checklist, keyed for typed lookup |
140
223
 
141
- `rollback` receives the same, plus `output` (that step's own return value) and
142
- `error` (what triggered the unwind).
224
+ `rollback` receives `input`, `signal`, `phase`, `step`, `log`, `status`, `note`
225
+ and `progress`, plus `output` (that step's own return value) and `error` (what
226
+ triggered the unwind). Its `ctx` holds only the keys named in `rollbackKeys`.
227
+
228
+ ### `status` vs `note`
229
+
230
+ Both write next to the step name, and they differ in lifetime. `status` is
231
+ scratch space for a step in flight — it is wiped the moment the step settles.
232
+ `note` is part of the title: it is written once and stays on the finished line,
233
+ which is where a count, a size, or an id belongs.
234
+
235
+ ```ts
236
+ .addStep({
237
+ name: "query database",
238
+ handler: async ({ status, note }) => {
239
+ status("scanning"); // ⠸ query database › scanning
240
+ const rows = await db.all(sql);
241
+ note(`${rows.length} rows found`);
242
+ return { rows };
243
+ },
244
+ })
245
+ // ✔ query database (400 rows found) 1.2s
246
+ ```
247
+
248
+ Calling `note` again replaces the text; `note("")` removes it. Both renderers
249
+ show it — the live frame in the title, the CI renderer on the step's line.
250
+
251
+ ## Log placement
252
+
253
+ By default, `log`/`info`/`warn`/`error`/`success` write a permanent line above
254
+ the whole frame — the complete record for the run, in order. `logPlacement`
255
+ moves them somewhere more local instead:
256
+
257
+ ```ts
258
+ new Script({ name: "checkout", logPlacement: "step" })
259
+ ```
260
+
261
+ | `logPlacement` | where it lands |
262
+ | --- | --- |
263
+ | `"scrollback"` (default) | a permanent line above the frame — the full run, in order |
264
+ | `"step"` | nested under the step that logged it, beside its tasks and progress bar |
265
+ | `"bottom"` | a rolling tail of the most recent lines, below the whole phase tree |
266
+
267
+ ```
268
+ ✖ Main 320ms
269
+ ✔ reserve inventory 320ms
270
+ • checked warehouse A ← "step": nested where it happened
271
+ • checked warehouse B
272
+ ✖ boom 0ms
273
+ boom
274
+ ```
275
+
276
+ `"step"` and `"bottom"` both keep only the most recent few lines — they trade
277
+ completeness for locality, which is exactly what makes a rollback's `log()`
278
+ land next to the step it is undoing instead of scrolling past at the top.
279
+ Reach for `"scrollback"` when the log is the thing you need to still have once
280
+ the run is over. Only the live renderer honours this: the plain/CI renderer is
281
+ already sequential, so every placement just prints each line immediately, in
282
+ order, which is already both complete and in place.
283
+
284
+ Run `npm run example:log-placement` to see the same script three times, once
285
+ per placement.
143
286
 
144
287
  ## Step options
145
288
 
@@ -150,6 +293,8 @@ Every handler receives one object:
150
293
  when: ({ input, ctx }) => input.env === "production",
151
294
  retry: { attempts: 3, delayMs: (attempt) => attempt * 250, retryIf: isTransient },
152
295
  timeoutMs: 30_000,
296
+ clean: ["draftId"], // drop from the context once this step settles
297
+ rollbackKeys: ["tagName"], // the context keys `rollback` needs, and reserves
153
298
  handler,
154
299
  rollback,
155
300
  })
@@ -158,6 +303,85 @@ Every handler receives one object:
158
303
  `when` returning false marks the step **skipped** — its rollback never runs.
159
304
  Phases accept `when` too, which skips all their steps.
160
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
+ **Stores** are three methods, so Redis or S3 is a dozen lines:
368
+
369
+ ```ts
370
+ interface CacheStore {
371
+ read(slot: string): Awaitable<CachedEntry | undefined>;
372
+ write(slot: string, entry: CachedEntry): Awaitable<void>;
373
+ clear(slot: string): Awaitable<void>;
374
+ }
375
+ ```
376
+
377
+ `slot` is derived from the phase or step name so several phases can share one
378
+ store; scripts never write it. `fileStore(path)` keeps every slot in one JSON
379
+ file — a missing, unreadable or corrupt file all read as no cache at all, so
380
+ deleting it is always a valid reset. `memoryStore()` lasts for the process.
381
+
382
+ A store that throws — unreadable file, full disk, a `stale` predicate with a bug
383
+ — logs a warning and does the work. Caching never decides whether a run passes.
384
+
161
385
  ## Script options
162
386
 
163
387
  ```ts
@@ -169,6 +393,7 @@ new Script<Input>({
169
393
  plain: undefined, // force the CI renderer; default auto-detects a TTY
170
394
  silent: false,
171
395
  handleSignals: true, // Ctrl-C aborts and compensates; a second one gives up
396
+ logPlacement: "scrollback", // "scrollback" | "step" | "bottom"
172
397
  })
173
398
  ```
174
399
 
@@ -191,90 +416,185 @@ type RunResult<Ctx> =
191
416
  export const verifyId = stepFor<Input, { user: User }>()({
192
417
  name: "verify id",
193
418
  handler: async ({ ctx }) => ({ verified: ctx.user.id }),
419
+ rollbackKeys: ["verified"],
420
+ rollback: async ({ ctx }) => unverify(ctx.verified),
194
421
  });
195
422
  ```
196
423
 
197
- ## Scripts
424
+ `rollbackKeys` narrows the rollback here exactly as it does inline, and the keys
425
+ stay reserved once the step is handed to `addStep`.
198
426
 
427
+ `addStep` is where the requirement is enforced: TypeScript checks that the
428
+ script has actually produced `Ctx` by that point. Dropping a step that needs
429
+ `{ user: User }` into a script that has not loaded a user yet is a compile
430
+ error, not a runtime `undefined`.
431
+
432
+ ## Reusable scripts
433
+
434
+ `routineFor` does for whole phases what `stepFor` does for one step. Declare a
435
+ fragment once, mount it wherever it fits:
436
+
437
+ ```ts
438
+ // routines/channel.ts
439
+ export const pullChannel = routineFor<{ channel: string; since: string }>()(
440
+ "pull channel",
441
+ (script) =>
442
+ script
443
+ .addPhase("Fetch", { cache: { store: cache, stale: … } })
444
+ .addStep({ name: "authenticate", handler: … })
445
+ .addStep({ name: "pull orders", handler: … })
446
+ .addPhase("Normalize")
447
+ .addStep({ name: "dedupe and total", handler: … }),
448
+ );
199
449
  ```
200
- npm run build # emit dist/
201
- npm test # node:test suite
202
- npm run typecheck # library + examples + tests + scripts
203
- npm run example # the happy path, live
204
- npm run example:fail
205
- npm run release # dry run of the publish pipeline
450
+
451
+ ```ts
452
+ new Script<Input>({ name: "channel sync" })
453
+ .use(pullChannel)
454
+ .addPhase("Validation")
455
+ .addStep({ name: "check totals", handler: ({ ctx }) => … }) // ctx.orderCount, ctx.gross
456
+
457
+ // months later, a different job entirely
458
+ new Script<ReportInput>({ name: "monthly report" })
459
+ .use(pullChannel)
460
+ .addPhase("Report")
461
+ .addStep({ name: "build summary", handler: ({ ctx }) => … })
206
462
  ```
207
463
 
208
- ## TypeScript layout
464
+ The two parameters on `routineFor<In, Ctx>()` are the **minimum** the fragment
465
+ needs — the input it reads, and the context it expects to already exist. Both
466
+ are checked at the `use()` call:
209
467
 
210
- One config per directory, because editors resolve a file's config by finding
211
- the nearest `tsconfig.json` — a non-standard filename like `tsconfig.foo.json`
212
- is invisible to them, so the IDE and the CLI disagree.
468
+ ```
469
+ Property 'hash' is missing in type '{}' but required in type '{ hash: string; }'.
470
+ ```
213
471
 
214
- | Config | Covers | Why it differs |
215
- | --- | --- | --- |
216
- | `tsconfig.json` | `src` | The library. `noEmit`, strict. |
217
- | `tsconfig.build.json` | `src` | The only config that emits `dist/`. |
218
- | `examples/tsconfig.json` | `examples` | Imports the built `dist/`. |
219
- | `test/tsconfig.json` | `test` | Same. |
220
- | `scripts/tsconfig.json` | `scripts` | Adds `allowImportingTsExtensions`. |
472
+ A plain `Script` mounts too, which is what keeps a pipeline runnable on its
473
+ own — `pipeline.run(input)` today, `.use(pipeline)` tomorrow. Its own
474
+ `ScriptOptions` (rollback mode, `logPlacement`, `silent`) are ignored in favour
475
+ of the host's; only its phases and steps come across.
221
476
 
222
- Only `scripts/` needs that last flag. Node's type stripping does not rewrite
223
- import extensions, so a script importing a sibling script must write
224
- `./exec.ts` rather than `./exec.js`. The flag cannot go in the root config:
225
- `tsconfig.build.json` extends it and sets `noEmit: false`, and TypeScript
226
- rejects `allowImportingTsExtensions` unless `noEmit` or `emitDeclarationOnly`
227
- is set (TS5096).
477
+ **Everything travels with the fragment.** Phase-level `when` and `cache`,
478
+ step-level `retry`, `clean`, `rollback` — a spliced rollback compensates during
479
+ the host's unwind, and keys it reserved through `rollbackKeys` stay reserved in
480
+ the host, so a later `clean` there still can't delete them.
481
+
482
+ That combines well with caching: mount a routine whose phase caches, and the
483
+ second script to mount it gets the data the first one pulled.
484
+
485
+ ```
486
+ › Fetch
487
+ ⊙ authenticate cached
488
+ ⊙ pull orders cached
489
+ › Report
490
+ ✔ build summary (avg 24.50) 263ms
491
+ ```
228
492
 
229
- ## The release pipeline
493
+ ### Giving a mount its own input
230
494
 
231
- `scripts/release.ts` builds and publishes this package, and is itself written
232
- with this framework — the pipeline is the integration test.
495
+ A mount can feed its fragment something other than the host's input — which is
496
+ what lets one routine serve two storefronts, each with its own credentials:
233
497
 
498
+ ```ts
499
+ new Script<{ since: string; amazonKey: string; shopifyKey: string }>({ name: "all channels" })
500
+ .use(pullChannel, {
501
+ as: "Amazon",
502
+ input: ({ input }) => ({ channel: "amazon", since: input.since, apiKey: input.amazonKey }),
503
+ })
504
+ .use(pullChannel, {
505
+ as: "Shopify",
506
+ input: ({ input }) => ({ channel: "shopify", since: input.since, apiKey: input.shopifyKey }),
507
+ })
234
508
  ```
235
- npm run release # dry run; mutates nothing
236
- npm run release -- --simulate-failure # rehearse the rollback path
237
- npm run release -- --publish # for real
238
- npm run release -- --publish --bump minor
239
- npm run release -- --publish --version 1.0.0 --tag next
509
+
510
+ `input` takes a fixed value or a function of `{ input, ctx }`, and may be
511
+ async. It is resolved **once per mount**, when the mount is reached, and every
512
+ phase and step of that fragment sees the result — including its `when`, its
513
+ `cache`, and its rollbacks during an unwind.
514
+
515
+ With a mapper the host no longer has to match the routine's input shape at all;
516
+ it only has to produce it. TypeScript checks the return value:
517
+
518
+ ```
519
+ Property 'apiKey' is missing in type '{ channel: string; }' but required in type 'ChannelInput'.
240
520
  ```
241
521
 
242
- | Phase | Steps |
243
- | --- | --- |
244
- | Preflight | read manifest · authenticate npm · inspect git · resolve version · check registry |
245
- | Verify | typecheck · run tests |
246
- | Package | write version · clean dist · build · verify artifacts · audit tarball |
247
- | Publish | tag release · push tag · publish to npm |
522
+ Without one, the host's own input must satisfy the routine, as `stepFor` steps
523
+ require.
248
524
 
249
- Three steps compensate, and the ordering is deliberate — `publish to npm` is
250
- last, so a failure anywhere *before* it unwinds everything and nothing reaches
251
- the registry:
525
+ **Mounting twice** needs `as`, because phase names have to stay unique:
252
526
 
253
- | Step | Rollback |
254
- | --- | --- |
255
- | `write version` | restore the previous version in package.json |
256
- | `clean dist` | rebuild from source |
257
- | `tag release` | delete the local tag |
258
- | `push tag` | delete the remote tag |
259
- | `publish to npm` | `npm deprecate` — a published version cannot be recalled |
527
+ ```ts
528
+ .use(pullChannel, { as: "Amazon" }) // → "Amazon / Fetch", "Amazon / Normalize"
529
+ .use(pullChannel, { as: "Shopify" })
530
+ ```
531
+
532
+ Without it you get a `DuplicateNameError` at build time. That rule is not
533
+ routine-specific — two phases anywhere in a script, or two steps within one
534
+ phase, are refused. Names label the frame, are what `outline()` reports, and
535
+ identify a cache entry; two units sharing a name share a slot, which surfaces
536
+ as wrong data rather than as a failure.
537
+
538
+ A routine owns whole phases, so `addStep` directly after `use()` is refused
539
+ too — open a phase of your own first, rather than quietly appending to a
540
+ fragment someone else's script also mounts.
260
541
 
261
- `--simulate-failure` performs the local mutations and then fails at the publish
262
- step. Nothing leaves the machine, and you get to watch the unwind:
542
+ ## Examples
543
+
544
+ Nine runnable scripts, each aimed at a different part of the API. Most take a
545
+ flag to switch between the happy path and the interesting one.
546
+
547
+ | | |
548
+ | --- | --- |
549
+ | [`deploy.ts`](examples/deploy.ts) | The tour: phases, progress bars, checklists, retry, `note`, `clean`, rollback. `npm run example`, `npm run example:fail` |
550
+ | [`checkout.ts`](examples/checkout.ts) | Saga semantics end to end — three mutations, three compensations, a secret dropped with `clean`, and `logPlacement: "step"` nesting each rollback's log under the step it undoes. `-- --ok` for the happy path |
551
+ | [`resilience.ts`](examples/resilience.ts) | `retry` with backoff, `retryIf` refusing to retry a 401, `timeoutMs` on a hung step, and an external `AbortSignal`. `-- --timeout`, `-- --fatal`, `-- --cancel` |
552
+ | [`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 |
553
+ | [`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` |
554
+ | [`log-placement.ts`](examples/log-placement.ts) | The same script run three times, once per `logPlacement` value, so the difference is visible directly |
555
+ | [`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 |
556
+ | [`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 |
557
+ | [`validate.ts`](examples/validate.ts) | The smallest thing that runs: two steps, `rollback: "none"` |
263
558
 
264
559
  ```
265
- ✖ publish to npm 0ms simulated publish failure (--simulate-failure)
266
- ▲ deleted local tag v0.2.0
267
- ▲ dist rebuilt from source
268
- ▲ package.json version restored to 0.1.0
560
+ npm run example:checkout -- --ok
561
+ npm run example:resilience -- --cancel
562
+ npm run example:intake -- --bad
563
+ npm run example:modular -- --fail
564
+ npm run example:routine -- --multi
565
+ npm run example:log-placement
566
+ ```
567
+
568
+ ## npm scripts
269
569
 
270
- ✖ Failed at Publish › publish to npm after 8.1s
271
- ↺ Rolled back 3 steps
272
570
  ```
571
+ npm run build # emit dist/
572
+ npm test # node:test suite
573
+ npm run typecheck # library + examples + tests
574
+ npm run example # the happy path, live
575
+ npm run example:fail
576
+ npm run release # release-it: version, tag, publish
577
+ ```
578
+
579
+ ## TypeScript layout
273
580
 
274
- Other things it checks: a scoped package without `publishConfig.access`
275
- (`"public"`) would be published as restricted, so preflight refuses; the tarball
276
- is audited path-by-path against an allowlist so `src/` can never leak; and the
277
- version is checked against the registry before any work is done.
581
+ One config per directory, because editors resolve a file's config by finding
582
+ the nearest `tsconfig.json` — a non-standard filename like `tsconfig.foo.json`
583
+ is invisible to them, so the IDE and the CLI disagree.
584
+
585
+ | Config | Covers | Why it differs |
586
+ | --- | --- | --- |
587
+ | `tsconfig.json` | `src` | The library. `noEmit`, strict. |
588
+ | `tsconfig.build.json` | `src` | The only config that emits `dist/`. |
589
+ | `examples/tsconfig.json` | `examples` | Imports the built `dist/`; adds `allowImportingTsExtensions`. |
590
+ | `test/tsconfig.json` | `test` | Same, without the flag. |
591
+
592
+ Only `examples/` needs that flag, and only because `modular.ts` imports a
593
+ sibling: Node's type stripping does not rewrite import extensions, so the
594
+ import has to say `./steps/tenancy.ts` rather than `./steps/tenancy.js`. The
595
+ flag cannot go in the root config — `tsconfig.build.json` extends it and sets
596
+ `noEmit: false`, and TypeScript rejects `allowImportingTsExtensions` unless
597
+ `noEmit` or `emitDeclarationOnly` is set (TS5096).
278
598
 
279
599
  ## Rendering notes
280
600
 
@@ -0,0 +1,33 @@
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.
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, which is what `schema` is for.
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
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,SAAS,EACT,YAAY,EACZ,WAAW,EACX,UAAU,EAEX,MAAM,YAAY,CAAC;AAMpB,+EAA+E;AAC/E,wBAAgB,WAAW,IAAI,UAAU,CAWxC;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAuCtD;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"}