@michaelrwalker/stagehand 0.5.3 → 0.6.1
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 +8 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/script.d.ts +40 -20
- package/dist/script.d.ts.map +1 -1
- package/dist/script.js +4 -1
- package/dist/script.js.map +1 -1
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/ansi.d.ts +12 -2
- package/dist/ui/ansi.d.ts.map +1 -1
- package/dist/ui/ansi.js +14 -2
- package/dist/ui/ansi.js.map +1 -1
- package/dist/ui/frame.d.ts +22 -2
- package/dist/ui/frame.d.ts.map +1 -1
- package/dist/ui/frame.js +38 -15
- package/dist/ui/frame.js.map +1 -1
- package/dist/ui/renderer.d.ts +6 -4
- package/dist/ui/renderer.d.ts.map +1 -1
- package/dist/ui/renderer.js +17 -9
- package/dist/ui/renderer.js.map +1 -1
- package/package.json +10 -5
- package/skills/building-scripts/getting-started/SKILL.md +210 -0
- package/skills/building-scripts/structuring-phases-and-steps/SKILL.md +197 -0
- package/skills/building-scripts/typed-context-and-input/SKILL.md +188 -0
- package/skills/building-scripts/typed-context-and-input/references/context-type-utilities.md +121 -0
- package/skills/caching/caching-expensive-work/SKILL.md +285 -0
- package/skills/caching/caching-expensive-work/references/cache-api.md +278 -0
- package/skills/compensation-safety/adding-rollbacks/SKILL.md +400 -0
- package/skills/compensation-safety/cleaning-context/SKILL.md +165 -0
- package/skills/compensation-safety/conditional-steps/SKILL.md +199 -0
- package/skills/errors-results/handling-results-and-errors/SKILL.md +281 -0
- package/skills/observability/log-placement-and-rendering/SKILL.md +167 -0
- package/skills/observability/reporting-progress/SKILL.md +203 -0
- package/skills/reuse/reusable-routines-and-mounts/SKILL.md +243 -0
- package/skills/reuse/splitting-steps-across-files/SKILL.md +226 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'caching-expensive-work'
|
|
3
|
+
description: >
|
|
4
|
+
fileStore/memoryStore, per-slot stale/schema options, per-run CacheMode
|
|
5
|
+
("on"/"off"/"refresh"/"read-only"), and reaching a cached slot with
|
|
6
|
+
context.cache (read/write/clear/ageOf) from a later step or rollback. Load
|
|
7
|
+
this for "skip re-running an expensive phase," "invalidate a cache entry
|
|
8
|
+
from a later step," "correct a cached value without resetting its TTL," or
|
|
9
|
+
"guard a cache entry against an old script shape."
|
|
10
|
+
metadata:
|
|
11
|
+
type: 'core'
|
|
12
|
+
library: 'stagehand'
|
|
13
|
+
library_version: '0.5.3'
|
|
14
|
+
sources:
|
|
15
|
+
- 'miaklwalker/stagehand:docs/guides/caching.md'
|
|
16
|
+
- 'miaklwalker/stagehand:docs/reference/cache.md'
|
|
17
|
+
- 'miaklwalker/stagehand:src/cache.ts'
|
|
18
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
19
|
+
- 'miaklwalker/stagehand:test/cache.test.ts'
|
|
20
|
+
- 'miaklwalker/stagehand:test/context-cache.test.ts'
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# Stagehand — Caching Expensive Work
|
|
24
|
+
|
|
25
|
+
A phase or step can point at a store and stop repeating its work on later
|
|
26
|
+
runs. There is no cache key to pass: a slot's name is derived from the
|
|
27
|
+
phase's or step's own name, and whether a hit is still good is entirely
|
|
28
|
+
`stale`'s job.
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { Script, fileStore } from "@michaelrwalker/stagehand";
|
|
34
|
+
|
|
35
|
+
const cache = fileStore("./.stagehand-cache.json");
|
|
36
|
+
|
|
37
|
+
interface Input {
|
|
38
|
+
ref: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const deploy = new Script<Input>({ name: "deploy" })
|
|
42
|
+
.addPhase("Build", { cache })
|
|
43
|
+
.addStep({
|
|
44
|
+
name: "compile",
|
|
45
|
+
handler: async ({ input }) => ({ artifact: `dist/${input.ref}.tgz` }),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
.addPhase("Release")
|
|
49
|
+
.addStep({
|
|
50
|
+
name: "ship",
|
|
51
|
+
handler: async ({ ctx }) => ({ shipped: ctx.artifact }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await deploy.run({ ref: "main" });
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
On the second run with the same `ref`, `compile` is skipped and `artifact`
|
|
58
|
+
is merged into the context from the store instead of being recomputed.
|
|
59
|
+
|
|
60
|
+
## Core Patterns
|
|
61
|
+
|
|
62
|
+
### Caching a step with `stale` and `schema`
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { z } from "zod";
|
|
66
|
+
|
|
67
|
+
const BuildSchema = z.object({ artifact: z.string() });
|
|
68
|
+
|
|
69
|
+
.addPhase("Build")
|
|
70
|
+
.addStep({
|
|
71
|
+
name: "compile",
|
|
72
|
+
cache: {
|
|
73
|
+
store: cache,
|
|
74
|
+
stale: ({ value, ctx, ageMs }) => ageMs > 3_600_000,
|
|
75
|
+
schema: BuildSchema,
|
|
76
|
+
},
|
|
77
|
+
handler: async ({ input }) => ({ artifact: `dist/${input.ref}.tgz` }),
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`stale` and `schema` both see the live context as of that step; a stored
|
|
82
|
+
value that fails `schema` is treated as a miss, not an error.
|
|
83
|
+
|
|
84
|
+
### Controlling caching per run without touching the script
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
await deploy.run(
|
|
88
|
+
{ ref: "main" },
|
|
89
|
+
{ cache: process.argv.includes("--no-cache") ? "off" : "on" },
|
|
90
|
+
);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`"on"` (default) reads and writes, `"off"` ignores caching entirely,
|
|
94
|
+
`"refresh"` runs everything and overwrites every entry, `"read-only"` uses
|
|
95
|
+
hits but never writes.
|
|
96
|
+
|
|
97
|
+
### Reaching a cached slot from a later step
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
.addPhase("Fetch", { cache: { store: cache } })
|
|
101
|
+
.addStep({
|
|
102
|
+
name: "pull",
|
|
103
|
+
handler: async () => ({ orders: [{ sku: "a", qty: 2 }] }),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
.addPhase("Adjust")
|
|
107
|
+
.addStep({
|
|
108
|
+
name: "restock",
|
|
109
|
+
handler: async ({ ctx, cache }) => {
|
|
110
|
+
await cache.clear("Fetch");
|
|
111
|
+
await cache.write("Fetch", { orders: ctx.orders }, { keepAge: true });
|
|
112
|
+
const age = await cache.ageOf("Fetch");
|
|
113
|
+
return { restockedAt: age };
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`context.cache` is typed to exactly the cached phases and steps declared
|
|
119
|
+
before the current one; a typo or a not-yet-declared slot is a compile
|
|
120
|
+
error, not a silent no-op.
|
|
121
|
+
|
|
122
|
+
### Opting out of the shape guard before a value enters ctx
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { CacheShapeError } from "@michaelrwalker/stagehand";
|
|
126
|
+
|
|
127
|
+
.addStep({
|
|
128
|
+
name: "restock",
|
|
129
|
+
handler: async ({ cache }) => {
|
|
130
|
+
try {
|
|
131
|
+
const stored = await cache.read("Fetch", { raw: true });
|
|
132
|
+
return { orders: stored };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (!(error instanceof CacheShapeError)) throw error;
|
|
135
|
+
await cache.clear("Fetch");
|
|
136
|
+
return { orders: [] };
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Common Mistakes
|
|
143
|
+
|
|
144
|
+
### HIGH Toggling `cache:` with a ternary instead of `run()`
|
|
145
|
+
|
|
146
|
+
Wrong:
|
|
147
|
+
```ts
|
|
148
|
+
.addStep({
|
|
149
|
+
name: "build",
|
|
150
|
+
cache: isProd ? store : undefined, // no compile error, but no typed slot either
|
|
151
|
+
handler: async () => ({ artifact: await build() }),
|
|
152
|
+
})
|
|
153
|
+
// later: context.cache.clear("build") — compile error, "build" is not a known slot
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Correct:
|
|
157
|
+
```ts
|
|
158
|
+
.addStep({
|
|
159
|
+
name: "build",
|
|
160
|
+
cache: { store }, // always a concrete CacheSource — gets a typed slot
|
|
161
|
+
handler: async () => ({ artifact: await build() }),
|
|
162
|
+
})
|
|
163
|
+
// toggle it from the call site instead:
|
|
164
|
+
await script.run(input, { cache: isProd ? "on" : "off" });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`addStep` has a separate overload for a cached step requiring `cache` to be a concrete `CacheSource`; a value typed `CacheStore | undefined` doesn't satisfy it, so TypeScript falls back to the plain overload — the step still caches at runtime whenever the value is truthy, but `context.cache` never gets a typed slot for it.
|
|
168
|
+
|
|
169
|
+
Source: src/script.ts addStep() overloads; docs/guides/caching.md ("Per-run cache control")
|
|
170
|
+
|
|
171
|
+
### MEDIUM Expecting `cache.clear()` to force a re-run mid-run
|
|
172
|
+
|
|
173
|
+
Wrong:
|
|
174
|
+
```ts
|
|
175
|
+
.addStep({
|
|
176
|
+
name: "restock",
|
|
177
|
+
handler: async ({ ctx, cache }) => {
|
|
178
|
+
await cache.clear("Fetch");
|
|
179
|
+
// assumed: "Fetch" now redoes its work this run — it does not
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Correct:
|
|
185
|
+
```ts
|
|
186
|
+
// clear now, and let the *next* invocation of the script get the miss
|
|
187
|
+
.addStep({
|
|
188
|
+
name: "restock",
|
|
189
|
+
handler: async ({ ctx, cache }) => {
|
|
190
|
+
await cache.clear("Fetch"); // takes effect starting next run
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A phase's cache read happens once, before its steps execute; invalidation only affects the next run that reads the slot, not the phase that already ran or hit cache this run.
|
|
196
|
+
|
|
197
|
+
Source: docs/guides/caching.md ("Reaching the cache from a step"); src/script.ts run()
|
|
198
|
+
|
|
199
|
+
### MEDIUM Letting a shape-guarded value travel into ctx unraw
|
|
200
|
+
|
|
201
|
+
Wrong:
|
|
202
|
+
```ts
|
|
203
|
+
const stored = await cache.read("Fetch");
|
|
204
|
+
return { orders: stored }; // guard travels into ctx.orders
|
|
205
|
+
// three steps later: ctx.orders[0].newField throws CacheShapeError there, not here
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Correct:
|
|
209
|
+
```ts
|
|
210
|
+
const stored = await cache.read("Fetch", { raw: true }); // opt out before it enters ctx
|
|
211
|
+
return { orders: stored };
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The shape guard on `cache.read` wraps the value in a Proxy that throws `CacheShapeError` the moment a missing field is actually touched, including from a step several hops downstream that just reads it off `ctx`, far from the original `cache.read()` call.
|
|
215
|
+
|
|
216
|
+
Source: docs/guides/caching.md ("The shape guard"); src/cache.ts guardShape()
|
|
217
|
+
|
|
218
|
+
### MEDIUM Trusting cache to guarantee a call never runs twice
|
|
219
|
+
|
|
220
|
+
Wrong:
|
|
221
|
+
```ts
|
|
222
|
+
// assumes fileStore failure = the run fails loudly, so a "guaranteed once" API call
|
|
223
|
+
// is safe to put behind cache alone
|
|
224
|
+
.addStep({ cache: { store: fileStore("./cache.json") }, handler: () => callRateLimitedApi() })
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Correct:
|
|
228
|
+
```ts
|
|
229
|
+
// add an idempotency key or rate-limit handling in the handler itself; treat cache
|
|
230
|
+
// as an optimization, not a guarantee
|
|
231
|
+
.addStep({
|
|
232
|
+
cache: { store: fileStore("./cache.json") },
|
|
233
|
+
handler: () => callRateLimitedApi({ idempotencyKey }),
|
|
234
|
+
})
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
A store that throws — an unreadable file, a full disk, a buggy `stale` predicate — logs a warning and lets the work run anyway; caching never decides whether a run passes or fails, so it is not a hard guarantee against re-execution.
|
|
238
|
+
|
|
239
|
+
Source: docs/guides/caching.md ("A store that throws... logs a warning and does the work")
|
|
240
|
+
|
|
241
|
+
### MEDIUM Patching a value with `cache.write` and losing its age
|
|
242
|
+
|
|
243
|
+
Wrong:
|
|
244
|
+
```ts
|
|
245
|
+
await cache.write("Fetch", { orders: corrected }); // resets the TTL clock
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Correct:
|
|
249
|
+
```ts
|
|
250
|
+
await cache.write("Fetch", { orders: corrected }, { keepAge: true });
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`write` restamps the entry as written now unless `keepAge` is passed, so a correction that resets the clock silently buys another full staleness window for data that is exactly as old as it was a moment ago.
|
|
254
|
+
|
|
255
|
+
Source: docs/guides/caching.md; docs/reference/cache.md
|
|
256
|
+
|
|
257
|
+
### LOW Renaming a mount with `as` without updating cache calls
|
|
258
|
+
|
|
259
|
+
Wrong:
|
|
260
|
+
```ts
|
|
261
|
+
.use(pullChannel, { as: "Amazon" })
|
|
262
|
+
// elsewhere: cache.clear("Fetch") — now a compile error, slot is "Amazon / Fetch"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Correct:
|
|
266
|
+
```ts
|
|
267
|
+
.use(pullChannel, { as: "Amazon" })
|
|
268
|
+
// elsewhere: cache.clear("Amazon / Fetch")
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`as` renames a mounted fragment's cache slots along with its phases, so every `context.cache` call written against the unprefixed name goes red at compile time and needs the "As / PhaseName" form instead.
|
|
272
|
+
|
|
273
|
+
Source: docs/guides/reusable-scripts.md ("Mounting twice"); docs/guides/caching.md ("Reaching the cache from a step")
|
|
274
|
+
|
|
275
|
+
### HIGH Tension: Caching speed vs. compensation guarantees
|
|
276
|
+
|
|
277
|
+
Caching a phase for speed or rate-limit safety quietly removes it from the saga's compensation guarantee, since cached work never actually runs on a hit and its rollback can never fire during a later unwind. Agents optimizing this skill's goal tend to add `cache: store` to an already-rollback-protected phase as a drive-by performance tweak because they don't account for the other skill's guarantee that every mutating step's side effect gets undone on failure.
|
|
278
|
+
|
|
279
|
+
See also: skills/compensation-safety/adding-rollbacks/SKILL.md § Common Mistakes
|
|
280
|
+
|
|
281
|
+
See also: skills/reuse/reusable-routines-and-mounts/SKILL.md — cache slots travel with a mount and get renamed by `as`, which changes every context.cache call written against the routine.
|
|
282
|
+
|
|
283
|
+
## References
|
|
284
|
+
|
|
285
|
+
- [Cache API reference](references/cache-api.md)
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# Cache API Reference
|
|
2
|
+
|
|
3
|
+
Full type surface for Stagehand's caching layer, from `src/cache.ts` and
|
|
4
|
+
`src/types.ts`. Narrative coverage lives in the main `SKILL.md`.
|
|
5
|
+
|
|
6
|
+
## `fileStore(filePath): CacheStore`
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
function fileStore(filePath: string): CacheStore
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Backs every slot passed to it with one JSON file at `filePath`, resolved to
|
|
13
|
+
an absolute path via `node:path`'s `resolve`.
|
|
14
|
+
|
|
15
|
+
- **Missing / unreadable / malformed file** all read as no cache at all — a
|
|
16
|
+
`JSON.parse` failure, a missing file, or a non-object root all fall back to
|
|
17
|
+
`{}`. Deleting the file is always a valid way to reset every slot it held.
|
|
18
|
+
- **Serialization**: values must survive `JSON.stringify`. Anything that
|
|
19
|
+
doesn't (a `Date`, a class instance) comes back as its JSON shape on the
|
|
20
|
+
next read, not as the original type.
|
|
21
|
+
- **Atomic writes**: every write does mkdir (recursive) on the file's parent
|
|
22
|
+
directory, then writes the whole file's data to a temp file named
|
|
23
|
+
`${target}.${process.pid}.${tempCounter}.tmp` (a monotonically increasing
|
|
24
|
+
counter, not just the pid, because two writes in the same process sharing a
|
|
25
|
+
pid-only temp name used to rename each other's files out from under
|
|
26
|
+
themselves), then `rename`s the temp file onto the target path.
|
|
27
|
+
- If the rename fails with `EPERM`, `EACCES`, `EBUSY`, or `EEXIST` (Windows,
|
|
28
|
+
antivirus, an editor, or a file watcher holding the destination open),
|
|
29
|
+
it falls back to an in-place `writeFile` on the target — atomicity is
|
|
30
|
+
given up, the write is not.
|
|
31
|
+
- Any other rename error is rethrown.
|
|
32
|
+
- The temp file is always removed in a `finally` (`rm(temp, { force: true })`).
|
|
33
|
+
- **Read-modify-write queueing**: every `write` and `clear` goes through an
|
|
34
|
+
in-process `Map<string, Promise<unknown>>` keyed by the *resolved* target
|
|
35
|
+
path, so two `fileStore(path)` calls on the same file (even spelled
|
|
36
|
+
differently, e.g. with a redundant `..` segment) share one writer and never
|
|
37
|
+
race. A failed write does not poison writes queued behind it. This
|
|
38
|
+
serialization is in-process only — two separate `node` processes writing
|
|
39
|
+
the same file can still interleave.
|
|
40
|
+
- `read(slot)` re-reads and re-parses the whole file each call and returns
|
|
41
|
+
the entry at `slot` only if it passes `isEntry` (an object with a `value`
|
|
42
|
+
key and a numeric `savedAt`); otherwise `undefined`, including for a
|
|
43
|
+
hand-edited or older-format file.
|
|
44
|
+
- `clear(slot)` is a no-op (skips the write entirely) if the slot isn't
|
|
45
|
+
present via `Object.hasOwn`.
|
|
46
|
+
|
|
47
|
+
## `memoryStore(): CacheStore`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
function memoryStore(): CacheStore
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Backs every slot with a plain `Map<string, CachedEntry>` that lives for the
|
|
54
|
+
process. `read`/`write`/`clear` are synchronous under the hood (still
|
|
55
|
+
`Awaitable`-compatible). Useful for tests or a long-lived process that wants
|
|
56
|
+
hits to persist across repeated runs without touching disk.
|
|
57
|
+
|
|
58
|
+
## `CacheStore`
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
interface CacheStore {
|
|
62
|
+
read(slot: string): Awaitable<CachedEntry | undefined>;
|
|
63
|
+
write(slot: string, entry: CachedEntry): Awaitable<void>;
|
|
64
|
+
clear(slot: string): Awaitable<void>;
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`Awaitable<T>` is `T | Promise<T>` — each method may be sync or async. `slot`
|
|
69
|
+
is derived from a phase's or step's own name and passed in by the script; a
|
|
70
|
+
store never generates or interprets it. A custom backing store (Redis, S3,
|
|
71
|
+
...) only needs these three methods.
|
|
72
|
+
|
|
73
|
+
Slot naming, computed internally (never passed by script authors):
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const phaseSlot = (phase: string): string => phase;
|
|
77
|
+
const stepSlot = (phase: string, step: string): string => `${phase}::${step}`;
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## `CachedEntry`
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
interface CachedEntry {
|
|
84
|
+
value: unknown;
|
|
85
|
+
savedAt: number; // Date.now() at the moment it was written
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The exact shape a `CacheStore` reads and writes — one stored result plus its
|
|
90
|
+
write timestamp.
|
|
91
|
+
|
|
92
|
+
## `CacheSource<In, Ctx, Value>`
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
type CacheSource<In = unknown, Ctx = unknown, Value = unknown> =
|
|
96
|
+
| CacheStore
|
|
97
|
+
| CacheOptions<In, Ctx, Value>;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
What `cache` accepts on a phase or a step. `normalizeCache` in `src/cache.ts`
|
|
101
|
+
detects a bare `CacheStore` by duck-typing a `read` function on it and
|
|
102
|
+
expands it to `{ store: source }`; anything else is passed through as
|
|
103
|
+
`CacheOptions` unchanged.
|
|
104
|
+
|
|
105
|
+
## `CacheOptions<In, Ctx, Value>`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
interface CacheOptions<In = unknown, Ctx = unknown, Value = unknown> {
|
|
109
|
+
store: CacheStore;
|
|
110
|
+
stale?: (context: StaleContext<In, Ctx>) => Awaitable<boolean>;
|
|
111
|
+
schema?: StandardSchemaV1<unknown, Value>;
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- `store` is required; `stale` and `schema` are both optional.
|
|
116
|
+
- `stale` returning `true` treats a stored entry as a miss — the work runs
|
|
117
|
+
again and the entry is overwritten on success.
|
|
118
|
+
- `schema`, when given, validates the stored value on every read via
|
|
119
|
+
`schema["~standard"].validate(value)`; a value with `issues` is also a
|
|
120
|
+
miss, never a thrown error. This is what stops a cache written by an older
|
|
121
|
+
version of the script from feeding the wrong shape into the context.
|
|
122
|
+
- When no `schema` is set, the reads-back type is instead the phase's or
|
|
123
|
+
step's own inferred delta type (see `SlotValue` below).
|
|
124
|
+
|
|
125
|
+
### `StaleContext<In, Ctx>`
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
interface StaleContext<In, Ctx> {
|
|
129
|
+
value: unknown;
|
|
130
|
+
input: In;
|
|
131
|
+
ctx: Ctx; // the live context as it stands when the phase/step is reached
|
|
132
|
+
savedAt: number;
|
|
133
|
+
ageMs: number;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`value` always arrives as `unknown` inside `stale` — annotate it yourself,
|
|
138
|
+
or supply `schema` to get a checked, narrowed value on the `read` path
|
|
139
|
+
instead. `ageMs` is `Math.max(0, Date.now() - entry.savedAt)`, so it is never
|
|
140
|
+
negative even with clock skew.
|
|
141
|
+
|
|
142
|
+
## `CacheMode`
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
type CacheMode = "on" | "off" | "refresh" | "read-only";
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Passed as `run(input, { cache })`. Default is `"on"`.
|
|
149
|
+
|
|
150
|
+
| Mode | read | write |
|
|
151
|
+
| --- | --- | --- |
|
|
152
|
+
| `"on"` | yes | yes |
|
|
153
|
+
| `"off"` | no (`readCache` short-circuits to a miss) | no |
|
|
154
|
+
| `"refresh"` | no (forces every entry to recompute) | yes (overwrites) |
|
|
155
|
+
| `"read-only"` | yes | no |
|
|
156
|
+
|
|
157
|
+
Implementation detail from `src/cache.ts`:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
export async function readCache(cache, slot, input, ctx, mode) {
|
|
161
|
+
if (mode === "off" || mode === "refresh") return MISS;
|
|
162
|
+
// ...store.read, schema validate, stale check...
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function writeCache(cache, slot, value, mode) {
|
|
166
|
+
if (mode === "off" || mode === "read-only") return;
|
|
167
|
+
await cache.store.write(slot, { value, savedAt: Date.now() });
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
A store or `stale`/`schema` call that throws is caught elsewhere in
|
|
172
|
+
`script.ts` and treated as a miss with a logged warning — caching never
|
|
173
|
+
decides whether the run itself passes or fails.
|
|
174
|
+
|
|
175
|
+
## `CacheHandle<Slots>`
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
interface CacheHandle<Slots> {
|
|
179
|
+
read<K extends keyof Slots & string>(
|
|
180
|
+
slot: K,
|
|
181
|
+
options?: { raw?: boolean },
|
|
182
|
+
): Promise<Slots[K] | undefined>;
|
|
183
|
+
write<K extends keyof Slots & string>(
|
|
184
|
+
slot: K,
|
|
185
|
+
value: unknown,
|
|
186
|
+
options?: { keepAge?: boolean },
|
|
187
|
+
): Promise<void>;
|
|
188
|
+
clear(slot: keyof Slots & string): Promise<void>;
|
|
189
|
+
ageOf(slot: keyof Slots & string): Promise<number | undefined>;
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
This is the `cache` property on both `StepContext` and `RollbackContext`
|
|
194
|
+
(`readonly cache: CacheHandle<Slots>`), typed to exactly the cached phases
|
|
195
|
+
and steps declared *before* the current one in the script. An uncached phase
|
|
196
|
+
contributes no slot at all.
|
|
197
|
+
|
|
198
|
+
Runtime behavior (`createCacheHandle` in `src/cache.ts`), built from a
|
|
199
|
+
`Map<string, SlotBinding>` where `SlotBinding = { store: CacheStore; schema: boolean }`:
|
|
200
|
+
|
|
201
|
+
- **`read(slot, options?)`**: looks up the slot's binding — a slot the script
|
|
202
|
+
never declared throws `StepDefinitionError` at runtime (listing the known
|
|
203
|
+
slot names), though this case is normally already excluded by the types.
|
|
204
|
+
On a store miss, returns `undefined`. On a hit:
|
|
205
|
+
- if `options.raw` is true, or the slot declared a `schema` (already
|
|
206
|
+
checked on the way in), returns `entry.value` as-is.
|
|
207
|
+
- otherwise wraps it with `guardShape(entry.value, slot)` (see below).
|
|
208
|
+
- **`write(slot, value, options?)`**: if `options.keepAge` is true, first
|
|
209
|
+
reads the existing entry and reuses its `savedAt`; otherwise stamps
|
|
210
|
+
`savedAt: Date.now()`. Always writes `{ value, savedAt }` to the slot's
|
|
211
|
+
store — `write` does not merge with the previous value, it replaces it.
|
|
212
|
+
- **`clear(slot)`**: delegates straight to the slot's `store.clear(slot)`.
|
|
213
|
+
- **`ageOf(slot)`**: reads the entry and returns
|
|
214
|
+
`Math.max(0, Date.now() - entry.savedAt)`, or `undefined` if there is no
|
|
215
|
+
entry.
|
|
216
|
+
|
|
217
|
+
Invalidation flows backward only: a later step can drop or correct an
|
|
218
|
+
earlier phase's entry, never the reverse — a phase's slot is only known to
|
|
219
|
+
the type system once that phase has actually been declared earlier in the
|
|
220
|
+
chain.
|
|
221
|
+
|
|
222
|
+
## `SlotValue<Schema, Delta>`
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
type SlotValue<Schema, Delta> = unknown extends Schema ? Delta : Schema;
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Type-level only, not called directly. Drives what `CacheHandle<Slots>`
|
|
229
|
+
infers a slot reads back as: the `schema`'s output type when one was
|
|
230
|
+
declared on that phase/step, otherwise the phase's (or step's) own inferred
|
|
231
|
+
delta type.
|
|
232
|
+
|
|
233
|
+
## Shape guard: `guardShape` and `CacheShapeError`
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
function guardShape<T>(value: T, slot: string, path?: string): T
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Wraps an object or array read back from a store (recursively, on further
|
|
240
|
+
property access) in a `Proxy` so that touching a field the stored object
|
|
241
|
+
does not actually have throws `CacheShapeError` instead of silently
|
|
242
|
+
returning `undefined`. Exists because, without a declared `schema`, the
|
|
243
|
+
type a slot reads back as is just the phase's/step's own inferred delta —
|
|
244
|
+
correct until an entry written by an older version of the script (with a
|
|
245
|
+
different shape) outlives the code that wrote it.
|
|
246
|
+
|
|
247
|
+
Mechanics:
|
|
248
|
+
|
|
249
|
+
- Only the `get` trap is installed — `set` (patching a field before writing
|
|
250
|
+
it back) is left alone, since assignment is how a value gets corrected
|
|
251
|
+
before a `cache.write`.
|
|
252
|
+
- A fixed passthrough set of property names is never guarded, because
|
|
253
|
+
runtime machinery probes them on arbitrary objects: `then` (so `await`
|
|
254
|
+
works), `toJSON`, `constructor`, `valueOf`, `toString`, `inspect`,
|
|
255
|
+
`length`. All symbol keys pass through too.
|
|
256
|
+
- An array index (`/^(0|[1-9]\d*)$/`) is treated as ordinary JS and never
|
|
257
|
+
throws even if out of bounds — `orders[9]` on a 3-item array is a normal
|
|
258
|
+
miss, not a shape violation.
|
|
259
|
+
- Any other key throws `CacheShapeError` unless it is present via
|
|
260
|
+
`Object.hasOwn(target, key)` or found on the prototype chain
|
|
261
|
+
(`key in (Object.getPrototypeOf(target) ?? {})`).
|
|
262
|
+
- On a legal access, the returned value is itself wrapped again with
|
|
263
|
+
`guardShape`, carrying forward a dotted `path` (e.g. `"orders.0.date"`) so
|
|
264
|
+
the eventual `CacheShapeError` names the exact field.
|
|
265
|
+
- A slot with a declared `schema` skips this wrapping entirely on `read`
|
|
266
|
+
(`createCacheHandle`'s `options?.raw || binding.schema` check) — the
|
|
267
|
+
schema validation on the way in already guarantees the shape.
|
|
268
|
+
|
|
269
|
+
Where the throw actually happens matters: because the guard is a live Proxy,
|
|
270
|
+
it travels with the value into `ctx` if you return it from a handler without
|
|
271
|
+
`{ raw: true }`. The `CacheShapeError` then throws wherever a later step
|
|
272
|
+
first touches the missing field — not at the original `cache.read()` call
|
|
273
|
+
site.
|
|
274
|
+
|
|
275
|
+
`CacheShapeError` (from `src/errors.ts`, re-exported from the package root)
|
|
276
|
+
carries the slot name and the offending dotted path; its message names both
|
|
277
|
+
and suggests either `cache.clear(slot)` or adding a `schema` to reject
|
|
278
|
+
mismatches on read instead of throwing downstream.
|