@1agh/maude 0.22.2 → 0.24.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 +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +84 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +135 -10
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +222 -11
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/hubs-config.mjs +42 -4
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +47 -27
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +358 -245
- package/plugins/design/dev-server/client/app.jsx +191 -21
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +131 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +31 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +144 -22
- package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +125 -64
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +141 -1
- package/plugins/design/dev-server/sync/agent.ts +418 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +324 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +918 -0
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +63 -5
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// Sidecar cache layer — Phase C / DDR-061.
|
|
2
|
+
//
|
|
3
|
+
// A tiny, zero-dependency JSON cache that lets expensive cross-session work
|
|
4
|
+
// (ux-research-agent discovery, codebase-intelligence scans, design-context
|
|
5
|
+
// token parsing, security-review reuse) be reused instead of recomputed.
|
|
6
|
+
//
|
|
7
|
+
// Used by:
|
|
8
|
+
// - cli/commands/cache.mjs (`maude cache list|clear|inspect|stats`)
|
|
9
|
+
// - plugins/design/agents/ux-research-agent.md (research/domain + research/project)
|
|
10
|
+
// - plugins/flow/skills/codebase-intelligence/SKILL.md (codebase-intelligence)
|
|
11
|
+
// - plugins/design/commands/{edit,new}.md (design-context)
|
|
12
|
+
// - plugins/flow/commands/{done,validate,validate-security}.md (security)
|
|
13
|
+
// - plugins/flow/commands/validate.md (validate — skip-if-clean)
|
|
14
|
+
//
|
|
15
|
+
// On-disk layout (relative to the cache root, default `<repo>/.ai/cache/`):
|
|
16
|
+
//
|
|
17
|
+
// .ai/cache/
|
|
18
|
+
// ├── _stats.json # hit/miss counters (best-effort telemetry)
|
|
19
|
+
// ├── research/domain/<slug>.<sha8>.json # committed — shareable per-domain pool
|
|
20
|
+
// ├── research/project/<repo-sha>/<sha>.json # gitignored — brief-specific layer
|
|
21
|
+
// ├── codebase-intelligence/<files-sha>.json # committed — keyed on git SHA
|
|
22
|
+
// ├── design-context/<ds-name>/<tokens-sha>.json
|
|
23
|
+
// ├── security/<head-sha>.json # gitignored — review reuse, 1 h TTL
|
|
24
|
+
// ├── scenario/<name>/<route-sha>.json # gitignored — per-run
|
|
25
|
+
// └── validate/<branch>.json # gitignored — skip-if-clean, 24 h TTL
|
|
26
|
+
//
|
|
27
|
+
// ## Invalidation policy (load-bearing — see C6 + .ai/cache/README.md)
|
|
28
|
+
//
|
|
29
|
+
// | Layer | Fresh window (ttlMs) | Hard ceiling (maxAgeMs) | Invalidates on |
|
|
30
|
+
// | ---------------------- | -------------------- | ----------------------- | --------------------- |
|
|
31
|
+
// | research/domain | 7 days | 30 days | domain slug change |
|
|
32
|
+
// | research/project | 30 days | 90 days | brief SHA change |
|
|
33
|
+
// | codebase-intelligence | until SHA changes | — | git HEAD / files SHA |
|
|
34
|
+
// | design-context | until tokens change | — | tokens.css mtime SHA |
|
|
35
|
+
// | security | 1 hour | 1 hour | HEAD SHA change |
|
|
36
|
+
// | scenario | until covered change | — | covered-file mtime |
|
|
37
|
+
// | validate | 24 hours | 24 hours | tree fingerprint Δ |
|
|
38
|
+
//
|
|
39
|
+
// Most layers are keyed on a content/SHA so the key itself IS the invalidation
|
|
40
|
+
// signal (a changed input produces a new key → guaranteed miss). The TTL fields
|
|
41
|
+
// matter for the time-decayed layers (research/*, security) where the same key
|
|
42
|
+
// can legitimately go stale without its inputs changing.
|
|
43
|
+
//
|
|
44
|
+
// Correctness > hit-rate: a wrong cached payload silently biases everything
|
|
45
|
+
// downstream, so `getOrCompute` only serves a *stale* (past-ttl, within-maxAge)
|
|
46
|
+
// entry as a fallback when `compute()` THROWS — never speculatively.
|
|
47
|
+
|
|
48
|
+
import { spawnSync } from 'node:child_process';
|
|
49
|
+
import {
|
|
50
|
+
existsSync,
|
|
51
|
+
mkdirSync,
|
|
52
|
+
readFileSync,
|
|
53
|
+
readdirSync,
|
|
54
|
+
renameSync,
|
|
55
|
+
rmSync,
|
|
56
|
+
statSync,
|
|
57
|
+
writeFileSync,
|
|
58
|
+
} from 'node:fs';
|
|
59
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
60
|
+
|
|
61
|
+
const STATS_FILE = '_stats.json';
|
|
62
|
+
const RESERVED = new Set([STATS_FILE, 'README.md']);
|
|
63
|
+
|
|
64
|
+
// Written into a fresh cache root the first time the cache is used on a machine,
|
|
65
|
+
// so the invalidation policy is discoverable next to the data it governs.
|
|
66
|
+
// This file is gitignored (see ai-skeleton/gitignore + repo .gitignore).
|
|
67
|
+
const README = `# Maude sidecar cache
|
|
68
|
+
|
|
69
|
+
Generated by \`cli/lib/cache.mjs\` (Phase C / DDR-061). Reusable, cross-session
|
|
70
|
+
results for expensive work so the same computation is not re-run every invocation.
|
|
71
|
+
|
|
72
|
+
## Layers
|
|
73
|
+
|
|
74
|
+
| Layer | Key | Fresh (ttl) | Ceiling (maxAge) | Invalidates on | Git |
|
|
75
|
+
| ----------------------- | ---------------------------- | ----------- | ---------------- | --------------------- | -------- |
|
|
76
|
+
| \`research/domain\` | \`<domain-slug>.<sha8>\` | 7 days | 30 days | domain slug change | committed |
|
|
77
|
+
| \`research/project\` | \`<repo-sha>/<brief-sha>\` | 30 days | 90 days | brief SHA change | ignored |
|
|
78
|
+
| \`codebase-intelligence\` | \`<files-or-head-sha>\` | until SHA Δ | — | git HEAD / files SHA | committed |
|
|
79
|
+
| \`design-context\` | \`<ds-name>/<tokens-sha>\` | until Δ | — | tokens.css mtime SHA | ignored |
|
|
80
|
+
| \`security\` | \`<head-sha>\` | 1 hour | 1 hour | HEAD SHA change | ignored |
|
|
81
|
+
| \`scenario\` | \`<name>/<covers-sha>\` | until Δ | — | covered-file mtime | ignored |
|
|
82
|
+
| \`validate\` | \`<branch>\` | 24 hours | 24 hours | tree fingerprint Δ | ignored |
|
|
83
|
+
|
|
84
|
+
## Invalidation policy (load-bearing — correctness > hit-rate)
|
|
85
|
+
|
|
86
|
+
- Most layers are **content-addressed**: the key embeds a SHA of the inputs, so
|
|
87
|
+
a changed input produces a new key → a guaranteed miss. The key IS the
|
|
88
|
+
invalidation signal.
|
|
89
|
+
- The **time-decayed** layers (\`research/*\`, \`security\`) can go stale without
|
|
90
|
+
their inputs changing, so they carry an explicit TTL. \`security\` is a hard
|
|
91
|
+
1-hour window keyed on HEAD; \`research/domain\` is fresh for 7 days and is
|
|
92
|
+
served stale (up to 30 days) ONLY as a fallback when recompute throws.
|
|
93
|
+
- A wrong cached payload silently biases everything downstream, so
|
|
94
|
+
\`getOrCompute\` never serves a stale entry speculatively — only on compute error.
|
|
95
|
+
|
|
96
|
+
## Management
|
|
97
|
+
|
|
98
|
+
\`\`\`sh
|
|
99
|
+
maude cache list # layers, entry counts, sizes, last-write
|
|
100
|
+
maude cache stats # hit/miss counters per layer
|
|
101
|
+
maude cache inspect <layer> # pretty-print entries in a layer
|
|
102
|
+
maude cache clear [layer] # wipe one layer, or everything
|
|
103
|
+
\`\`\`
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const ensuredReadme = new Set();
|
|
107
|
+
|
|
108
|
+
// Lazily drop the README into a fresh cache root (once per root per process).
|
|
109
|
+
function ensureReadme(cacheRoot) {
|
|
110
|
+
if (ensuredReadme.has(cacheRoot)) return;
|
|
111
|
+
ensuredReadme.add(cacheRoot);
|
|
112
|
+
try {
|
|
113
|
+
const readmePath = join(cacheRoot, 'README.md');
|
|
114
|
+
if (!existsSync(readmePath)) {
|
|
115
|
+
mkdirSync(cacheRoot, { recursive: true });
|
|
116
|
+
writeFileSync(readmePath, README, 'utf8');
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
/* documentation is non-fatal */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve the cache root directory.
|
|
125
|
+
* Order: opts.cacheDir → $MAUDE_CACHE_DIR → $CLAUDE_PROJECT_DIR/.ai/cache → cwd/.ai/cache.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveCacheRoot(opts = {}) {
|
|
128
|
+
if (opts.cacheDir) return resolve(opts.cacheDir);
|
|
129
|
+
if (process.env.MAUDE_CACHE_DIR) return resolve(process.env.MAUDE_CACHE_DIR);
|
|
130
|
+
if (process.env.CLAUDE_PROJECT_DIR) return resolve(process.env.CLAUDE_PROJECT_DIR, '.ai/cache');
|
|
131
|
+
return resolve(process.cwd(), '.ai/cache');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Resolve <root>/<layer>/<key>.json, refusing any path that escapes the root.
|
|
135
|
+
function entryPath(cacheRoot, layer, key) {
|
|
136
|
+
if (typeof layer !== 'string' || layer.length === 0) {
|
|
137
|
+
throw new TypeError('cache: layer must be a non-empty string');
|
|
138
|
+
}
|
|
139
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
140
|
+
throw new TypeError('cache: key must be a non-empty string');
|
|
141
|
+
}
|
|
142
|
+
const full = resolve(cacheRoot, layer, `${key}.json`);
|
|
143
|
+
const rel = relative(cacheRoot, full);
|
|
144
|
+
if (rel.startsWith('..') || rel.startsWith(sep) || resolve(cacheRoot, rel) !== full) {
|
|
145
|
+
throw new Error(`cache: layer/key escapes cache root: ${layer}/${key}`);
|
|
146
|
+
}
|
|
147
|
+
return full;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// tempfile + rename — readers never observe a half-written file, and two
|
|
151
|
+
// concurrent writers each land an intact JSON document (last rename wins).
|
|
152
|
+
function atomicWriteJson(filePath, obj) {
|
|
153
|
+
mkdirSync(resolve(filePath, '..'), { recursive: true });
|
|
154
|
+
const tmp = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
155
|
+
writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
|
|
156
|
+
renameSync(tmp, filePath);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function bumpStat(cacheRoot, layer, kind) {
|
|
160
|
+
// Best-effort telemetry — never throw into the caller's hot path.
|
|
161
|
+
try {
|
|
162
|
+
const statsPath = join(cacheRoot, STATS_FILE);
|
|
163
|
+
let stats = { hits: {}, misses: {}, since: Date.now() };
|
|
164
|
+
if (existsSync(statsPath)) {
|
|
165
|
+
try {
|
|
166
|
+
stats = JSON.parse(readFileSync(statsPath, 'utf8'));
|
|
167
|
+
} catch {
|
|
168
|
+
/* corrupt stats — reset */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
stats.hits ||= {};
|
|
172
|
+
stats.misses ||= {};
|
|
173
|
+
const bucket = kind === 'hit' ? stats.hits : stats.misses;
|
|
174
|
+
bucket[layer] = (bucket[layer] || 0) + 1;
|
|
175
|
+
atomicWriteJson(statsPath, stats);
|
|
176
|
+
} catch {
|
|
177
|
+
/* telemetry is non-fatal */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Record a hit/miss against a layer's counters. Lets out-of-process callers
|
|
183
|
+
* (the `maude cache get` CLI used by plugin slash-commands) feed the same
|
|
184
|
+
* telemetry `getOrCompute` does, so `maude cache stats` reflects real usage.
|
|
185
|
+
*/
|
|
186
|
+
export function recordAccess(layer, kind, opts = {}) {
|
|
187
|
+
bumpStat(resolveCacheRoot(opts), layer, kind === 'hit' ? 'hit' : 'miss');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Read a cache entry. Returns `null` on miss or corruption.
|
|
192
|
+
* @returns {{value:any, meta:object, writtenAt:number, ageMs:number, path:string}|null}
|
|
193
|
+
*/
|
|
194
|
+
export function check(layer, key, opts = {}) {
|
|
195
|
+
const cacheRoot = resolveCacheRoot(opts);
|
|
196
|
+
const filePath = entryPath(cacheRoot, layer, key);
|
|
197
|
+
if (!existsSync(filePath)) return null;
|
|
198
|
+
let entry;
|
|
199
|
+
try {
|
|
200
|
+
entry = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
201
|
+
} catch {
|
|
202
|
+
return null; // treat corrupt entries as a miss
|
|
203
|
+
}
|
|
204
|
+
if (!entry || typeof entry !== 'object' || typeof entry.writtenAt !== 'number') return null;
|
|
205
|
+
return {
|
|
206
|
+
value: entry.value,
|
|
207
|
+
meta: entry.meta || {},
|
|
208
|
+
writtenAt: entry.writtenAt,
|
|
209
|
+
ageMs: Date.now() - entry.writtenAt,
|
|
210
|
+
path: filePath,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Write a cache entry atomically.
|
|
216
|
+
* @returns {{path:string, writtenAt:number}}
|
|
217
|
+
*/
|
|
218
|
+
export function write(layer, key, value, meta = {}, opts = {}) {
|
|
219
|
+
const cacheRoot = resolveCacheRoot(opts);
|
|
220
|
+
const filePath = entryPath(cacheRoot, layer, key);
|
|
221
|
+
ensureReadme(cacheRoot);
|
|
222
|
+
const writtenAt = Date.now();
|
|
223
|
+
atomicWriteJson(filePath, { v: 1, layer, key, writtenAt, meta, value });
|
|
224
|
+
return { path: filePath, writtenAt };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get-or-compute. Returns the cached value when fresh (age ≤ ttlMs), otherwise
|
|
229
|
+
* runs `compute()`, caches the result, and returns it. If `compute()` throws
|
|
230
|
+
* and a stale-but-not-expired entry exists (age ≤ maxAgeMs), that stale value
|
|
231
|
+
* is served as a graceful fallback; otherwise the error propagates.
|
|
232
|
+
*
|
|
233
|
+
* @param {object} o
|
|
234
|
+
* @param {string} o.layer
|
|
235
|
+
* @param {string} o.key
|
|
236
|
+
* @param {number} [o.ttlMs=Infinity] fresh window; within it the cache is returned directly
|
|
237
|
+
* @param {number} [o.maxAgeMs=Infinity] hard ceiling for stale-on-error fallback
|
|
238
|
+
* @param {boolean} [o.force=false] bypass the cache read; always recompute
|
|
239
|
+
* @param {Function} o.compute () => value | Promise<value>
|
|
240
|
+
* @param {object} [o.meta] stored alongside the value
|
|
241
|
+
*/
|
|
242
|
+
export async function getOrCompute({
|
|
243
|
+
layer,
|
|
244
|
+
key,
|
|
245
|
+
ttlMs = Number.POSITIVE_INFINITY,
|
|
246
|
+
maxAgeMs = Number.POSITIVE_INFINITY,
|
|
247
|
+
force = false,
|
|
248
|
+
compute,
|
|
249
|
+
meta = {},
|
|
250
|
+
...opts
|
|
251
|
+
}) {
|
|
252
|
+
const cacheRoot = resolveCacheRoot(opts);
|
|
253
|
+
const hit = check(layer, key, { cacheDir: cacheRoot });
|
|
254
|
+
if (!force && hit && hit.ageMs <= ttlMs) {
|
|
255
|
+
bumpStat(cacheRoot, layer, 'hit');
|
|
256
|
+
return hit.value;
|
|
257
|
+
}
|
|
258
|
+
bumpStat(cacheRoot, layer, 'miss');
|
|
259
|
+
try {
|
|
260
|
+
const value = await compute();
|
|
261
|
+
write(layer, key, value, meta, { cacheDir: cacheRoot });
|
|
262
|
+
return value;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
if (hit && hit.ageMs <= maxAgeMs) return hit.value; // stale-on-error fallback
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Remove a single entry, a whole layer subtree, or (with no layer) every entry.
|
|
271
|
+
* Never deletes the cache root itself, the README, or _stats.json on a layer clear.
|
|
272
|
+
*/
|
|
273
|
+
export function clear(layer, key, opts = {}) {
|
|
274
|
+
const cacheRoot = resolveCacheRoot(opts);
|
|
275
|
+
if (!existsSync(cacheRoot)) return { removed: 0 };
|
|
276
|
+
if (layer && key) {
|
|
277
|
+
const filePath = entryPath(cacheRoot, layer, key);
|
|
278
|
+
if (existsSync(filePath)) {
|
|
279
|
+
rmSync(filePath);
|
|
280
|
+
return { removed: 1 };
|
|
281
|
+
}
|
|
282
|
+
return { removed: 0 };
|
|
283
|
+
}
|
|
284
|
+
if (layer) {
|
|
285
|
+
const dir = resolve(cacheRoot, layer);
|
|
286
|
+
const rel = relative(cacheRoot, dir);
|
|
287
|
+
if (rel.startsWith('..') || resolve(cacheRoot, rel) !== dir) {
|
|
288
|
+
throw new Error(`cache: layer escapes cache root: ${layer}`);
|
|
289
|
+
}
|
|
290
|
+
if (existsSync(dir)) {
|
|
291
|
+
rmSync(dir, { recursive: true, force: true });
|
|
292
|
+
return { removed: 1 };
|
|
293
|
+
}
|
|
294
|
+
return { removed: 0 };
|
|
295
|
+
}
|
|
296
|
+
// Wipe everything except the README; reset stats.
|
|
297
|
+
let removed = 0;
|
|
298
|
+
for (const name of readdirSync(cacheRoot)) {
|
|
299
|
+
if (name === 'README.md') continue;
|
|
300
|
+
rmSync(join(cacheRoot, name), { recursive: true, force: true });
|
|
301
|
+
removed += 1;
|
|
302
|
+
}
|
|
303
|
+
return { removed };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* List layers with aggregate stats. Each entry: { layer, entries, bytes, newest }.
|
|
308
|
+
*/
|
|
309
|
+
export function list(opts = {}) {
|
|
310
|
+
const cacheRoot = resolveCacheRoot(opts);
|
|
311
|
+
if (!existsSync(cacheRoot)) return [];
|
|
312
|
+
const layers = new Map();
|
|
313
|
+
const walk = (dir, topLayer) => {
|
|
314
|
+
for (const name of readdirSync(dir)) {
|
|
315
|
+
if (dir === cacheRoot && RESERVED.has(name)) continue;
|
|
316
|
+
const full = join(dir, name);
|
|
317
|
+
const st = statSync(full);
|
|
318
|
+
if (st.isDirectory()) {
|
|
319
|
+
walk(full, topLayer || name);
|
|
320
|
+
} else if (name.endsWith('.json') && topLayer) {
|
|
321
|
+
const agg = layers.get(topLayer) || { layer: topLayer, entries: 0, bytes: 0, newest: 0 };
|
|
322
|
+
agg.entries += 1;
|
|
323
|
+
agg.bytes += st.size;
|
|
324
|
+
agg.newest = Math.max(agg.newest, st.mtimeMs);
|
|
325
|
+
layers.set(topLayer, agg);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
walk(cacheRoot, null);
|
|
330
|
+
return [...layers.values()].sort((a, b) => a.layer.localeCompare(b.layer));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Enumerate the entries within one layer. Each: { key, writtenAt, ageMs, bytes }.
|
|
335
|
+
* The `key` is the entry path relative to the layer dir, sans `.json` extension.
|
|
336
|
+
*/
|
|
337
|
+
export function entries(layer, opts = {}) {
|
|
338
|
+
const cacheRoot = resolveCacheRoot(opts);
|
|
339
|
+
const layerDir = resolve(cacheRoot, layer);
|
|
340
|
+
const rel = relative(cacheRoot, layerDir);
|
|
341
|
+
if (rel.startsWith('..') || resolve(cacheRoot, rel) !== layerDir) {
|
|
342
|
+
throw new Error(`cache: layer escapes cache root: ${layer}`);
|
|
343
|
+
}
|
|
344
|
+
if (!existsSync(layerDir)) return [];
|
|
345
|
+
const out = [];
|
|
346
|
+
const walk = (dir) => {
|
|
347
|
+
for (const name of readdirSync(dir)) {
|
|
348
|
+
const full = join(dir, name);
|
|
349
|
+
const st = statSync(full);
|
|
350
|
+
if (st.isDirectory()) {
|
|
351
|
+
walk(full);
|
|
352
|
+
} else if (name.endsWith('.json')) {
|
|
353
|
+
let writtenAt = st.mtimeMs;
|
|
354
|
+
try {
|
|
355
|
+
const parsed = JSON.parse(readFileSync(full, 'utf8'));
|
|
356
|
+
if (typeof parsed.writtenAt === 'number') writtenAt = parsed.writtenAt;
|
|
357
|
+
} catch {
|
|
358
|
+
/* fall back to mtime */
|
|
359
|
+
}
|
|
360
|
+
out.push({
|
|
361
|
+
key: relative(layerDir, full).slice(0, -'.json'.length),
|
|
362
|
+
writtenAt,
|
|
363
|
+
ageMs: Date.now() - writtenAt,
|
|
364
|
+
bytes: st.size,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
walk(layerDir);
|
|
370
|
+
return out.sort((a, b) => b.writtenAt - a.writtenAt);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Read the hit/miss counters. Returns { hits, misses, since } (empty maps if none).
|
|
375
|
+
*/
|
|
376
|
+
export function stats(opts = {}) {
|
|
377
|
+
const cacheRoot = resolveCacheRoot(opts);
|
|
378
|
+
const statsPath = join(cacheRoot, STATS_FILE);
|
|
379
|
+
if (!existsSync(statsPath)) return { hits: {}, misses: {}, since: null };
|
|
380
|
+
try {
|
|
381
|
+
const s = JSON.parse(readFileSync(statsPath, 'utf8'));
|
|
382
|
+
return { hits: s.hits || {}, misses: s.misses || {}, since: s.since || null };
|
|
383
|
+
} catch {
|
|
384
|
+
return { hits: {}, misses: {}, since: null };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Convenience: 8-char hex digest of arbitrary input, for building cache keys.
|
|
390
|
+
* Uses git's object hashing via a pure JS fallback-free path is unnecessary —
|
|
391
|
+
* callers usually pass a git SHA directly; this is for slug/brief digests.
|
|
392
|
+
*/
|
|
393
|
+
export function sha8(input) {
|
|
394
|
+
const r = spawnSync('git', ['hash-object', '--stdin'], {
|
|
395
|
+
input: String(input),
|
|
396
|
+
encoding: 'utf8',
|
|
397
|
+
});
|
|
398
|
+
if (r.status === 0 && r.stdout) return r.stdout.trim().slice(0, 8);
|
|
399
|
+
// Fallback: tiny non-crypto FNV-1a → 8 hex chars (only when git is absent).
|
|
400
|
+
let h = 0x811c9dc5;
|
|
401
|
+
const str = String(input);
|
|
402
|
+
for (let i = 0; i < str.length; i++) {
|
|
403
|
+
h ^= str.charCodeAt(i);
|
|
404
|
+
h = Math.imul(h, 0x01000193);
|
|
405
|
+
}
|
|
406
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
407
|
+
}
|