@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 +387 -67
- package/dist/cache.d.ts +33 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +120 -0
- package/dist/cache.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +14 -2
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +23 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +32 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/script.d.ts +149 -9
- package/dist/script.d.ts.map +1 -1
- package/dist/script.js +273 -14
- package/dist/script.js.map +1 -1
- package/dist/state.d.ts +17 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +18 -1
- package/dist/state.js.map +1 -1
- package/dist/types.d.ts +175 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/frame.d.ts +1 -6
- package/dist/ui/frame.d.ts.map +1 -1
- package/dist/ui/frame.js +55 -16
- package/dist/ui/frame.js.map +1 -1
- package/dist/ui/renderer.d.ts +8 -4
- package/dist/ui/renderer.d.ts.map +1 -1
- package/dist/ui/renderer.js +36 -10
- package/dist/ui/renderer.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -0
- package/dist/ui/theme.d.ts.map +1 -1
- package/dist/ui/theme.js +2 -0
- package/dist/ui/theme.js.map +1 -1
- package/package.json +9 -2
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
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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
|
|
142
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
468
|
+
```
|
|
469
|
+
Property 'hash' is missing in type '{}' but required in type '{ hash: string; }'.
|
|
470
|
+
```
|
|
213
471
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
493
|
+
### Giving a mount its own input
|
|
230
494
|
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
is
|
|
277
|
-
|
|
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
|
|
package/dist/cache.d.ts
ADDED
|
@@ -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"}
|