@kenjura/ursa 0.96.0 → 0.98.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/CHANGELOG.md +44 -0
- package/README.md +144 -16
- package/bin/ursa.js +14 -1
- package/meta/templates/default-template/default.css +144 -0
- package/meta/templates/default-template/menu.js +18 -1
- package/meta/templates/default-template/search.js +11 -0
- package/meta/templates/default-template/sectionify.js +17 -9
- package/meta/templates/default-template/widgets.js +4 -0
- package/package.json +1 -2
- package/src/dev.js +13 -23
- package/src/helper/__test__/contentHash.test.js +16 -6
- package/src/helper/__test__/inlineMenu.test.js +142 -0
- package/src/helper/assetBundler.js +93 -19
- package/src/helper/automenu.js +39 -13
- package/src/helper/build/__test__/autoIndex.test.js +2 -132
- package/src/helper/build/__test__/graph.test.js +259 -3
- package/src/helper/build/__test__/pass.test.js +664 -0
- package/src/helper/build/autoIndex.js +6 -371
- package/src/helper/build/excludeFilter.js +1 -2
- package/src/helper/build/footer.js +27 -14
- package/src/helper/build/graph.js +575 -152
- package/src/helper/build/index.js +0 -2
- package/src/helper/build/metadata.js +19 -5
- package/src/helper/build/pass.js +497 -0
- package/src/helper/build/precedence.js +174 -0
- package/src/helper/build/site.js +1392 -0
- package/src/helper/build/templates.js +1 -2
- package/src/helper/build/tracedFs.js +247 -0
- package/src/helper/contentHash.js +0 -78
- package/src/helper/customMenu.js +27 -4
- package/src/helper/fileRenderer.js +119 -111
- package/src/helper/findScriptJs.js +1 -1
- package/src/helper/findStyleCss.js +1 -1
- package/src/helper/folderConfig.js +7 -18
- package/src/helper/fullTextIndex.js +41 -29
- package/src/helper/imageProcessor.js +45 -0
- package/src/helper/inlineMenu.js +275 -0
- package/src/helper/linkValidator.js +118 -127
- package/src/helper/mdxRenderer.js +27 -5
- package/src/helper/menuLabels.js +30 -5
- package/src/helper/whitelistFilter.js +1 -2
- package/src/jobs/generate.js +67 -1829
- package/src/serve.js +317 -697
- package/src/helper/__test__/dependencyTracker.test.js +0 -157
- package/src/helper/build/cacheBust.js +0 -141
- package/src/helper/build/navCache.js +0 -145
- package/src/helper/build/watchCache.js +0 -33
- package/src/helper/dependencyTracker.js +0 -384
|
@@ -2,47 +2,69 @@
|
|
|
2
2
|
* Incremental build graph for Ursa (Make/Shake/Salsa semantics).
|
|
3
3
|
*
|
|
4
4
|
* Every output is a derived node; a node recomputes if and only if one of its
|
|
5
|
-
* recorded input fingerprints changed. See docs/
|
|
5
|
+
* recorded input fingerprints changed. See docs/SERVE.md §4–5.
|
|
6
6
|
*
|
|
7
7
|
* Concepts:
|
|
8
|
-
* - Leaf nodes are
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
8
|
+
* - Leaf nodes are observed facts about the filesystem: a file's content
|
|
9
|
+
* (`file:`), a path's existence (`lookup:`), a directory's listing (`dir:`).
|
|
10
|
+
* They are created implicitly when a compute function touches the disk —
|
|
11
|
+
* through `ctx.read` / `ctx.exists` / `ctx.listDir`, or through any helper
|
|
12
|
+
* that imports its fs calls from tracedFs.js while the node is computing.
|
|
11
13
|
* Lookup leaves make file *creation* an observable change: probing for a
|
|
12
14
|
* style.css that isn't there records an edge that dirties the subtree when
|
|
13
|
-
* the file appears.
|
|
14
|
-
*
|
|
15
|
+
* the file appears. Directory leaves make add/remove/rename observable
|
|
16
|
+
* without depending on any file's content.
|
|
17
|
+
* - Derived nodes are registered with `graph.node(id, fn)`, or resolved on
|
|
18
|
+
* demand from a family resolver (`graph.resolver(id => fn)`) so that
|
|
19
|
+
* `pageHtml:<doc>` exists for whatever documents the source tree holds.
|
|
15
20
|
* Edges are recorded fresh on every recompute (replace, not append), so
|
|
16
|
-
* dynamic dependencies —
|
|
17
|
-
*
|
|
21
|
+
* dynamic dependencies — which template a doc's frontmatter selects — are
|
|
22
|
+
* always correct and can shrink.
|
|
18
23
|
* - Early cutoff: after a recompute, the node's own fingerprint is derived
|
|
19
24
|
* from its value. If it is unchanged, dependents' recorded fingerprints
|
|
20
25
|
* still match and propagation stops.
|
|
21
26
|
* - Verification is demand-driven and topological: verifying a node verifies
|
|
22
27
|
* its recorded dependencies first. `build(roots)` verifies roots in the
|
|
23
|
-
* given order, so callers can schedule client-viewed pages first.
|
|
28
|
+
* given order, so callers can schedule client-viewed pages first. Roots may
|
|
29
|
+
* be verified concurrently; a node demanded by two roots at once is
|
|
30
|
+
* computed once.
|
|
31
|
+
* - Output ownership: a compute function declares the files it wrote with
|
|
32
|
+
* `ctx.own(path)`. When a node's owned set shrinks, or the node is removed,
|
|
33
|
+
* the files no longer owned are handed to `onOrphans` for deletion. Every
|
|
34
|
+
* file in the output directory is owned by exactly one node.
|
|
24
35
|
* - Values live in memory only. Persistence stores {edges, fingerprints,
|
|
25
|
-
* leafStats}; after a restart a clean node is *verified* without
|
|
26
|
-
* and is only recomputed if a dependent actually demands its
|
|
36
|
+
* leafStats, owned}; after a restart a clean node is *verified* without
|
|
37
|
+
* recompute, and is only recomputed if a dependent actually demands its
|
|
38
|
+
* value.
|
|
27
39
|
*
|
|
28
40
|
* Compute functions should be deterministic. Non-determinism (timestamps,
|
|
29
41
|
* randomness) degrades to extra recomputes, never to staleness across passes.
|
|
30
|
-
* To force global invalidation on Ursa upgrades, wire
|
|
31
|
-
*
|
|
42
|
+
* To force global invalidation on Ursa upgrades, wire the version in as a
|
|
43
|
+
* leaf (see `versionLeaf`) rather than versioning nodes.
|
|
32
44
|
*/
|
|
33
45
|
|
|
34
|
-
import { createHash } from "crypto";
|
|
35
46
|
import { existsSync } from "fs";
|
|
36
47
|
import { mkdir, readFile, stat, writeFile } from "fs/promises";
|
|
37
|
-
import
|
|
48
|
+
import fsp from "fs/promises";
|
|
49
|
+
import { dirname, join } from "path";
|
|
38
50
|
import { getUrsaDir } from "../contentHash.js";
|
|
39
|
-
|
|
40
|
-
|
|
51
|
+
import {
|
|
52
|
+
ABSENT,
|
|
53
|
+
EXISTS,
|
|
54
|
+
MISSING,
|
|
55
|
+
dirFingerprintFromEntries,
|
|
56
|
+
hashBytes,
|
|
57
|
+
isIgnoredDirEntry,
|
|
58
|
+
withRecorder,
|
|
59
|
+
} from "./tracedFs.js";
|
|
60
|
+
|
|
61
|
+
export const GRAPH_SCHEMA_VERSION = 3;
|
|
41
62
|
const GRAPH_FILE = "graph.json";
|
|
42
63
|
|
|
43
64
|
const FILE_PREFIX = "file:";
|
|
44
65
|
const LOOKUP_PREFIX = "lookup:";
|
|
45
|
-
const
|
|
66
|
+
const DIR_PREFIX = "dir:";
|
|
67
|
+
const CONST_PREFIX = "const:";
|
|
46
68
|
|
|
47
69
|
export function fileNodeId(path) {
|
|
48
70
|
return FILE_PREFIX + path;
|
|
@@ -52,18 +74,34 @@ export function lookupNodeId(path) {
|
|
|
52
74
|
return LOOKUP_PREFIX + path;
|
|
53
75
|
}
|
|
54
76
|
|
|
55
|
-
function
|
|
56
|
-
return
|
|
77
|
+
export function dirNodeId(path) {
|
|
78
|
+
return DIR_PREFIX + path;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A constant leaf (e.g. `const:ursa-version`) whose fingerprint is set by the caller. */
|
|
82
|
+
export function constNodeId(name) {
|
|
83
|
+
return CONST_PREFIX + name;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isLeafId(id) {
|
|
87
|
+
return (
|
|
88
|
+
id.startsWith(FILE_PREFIX) ||
|
|
89
|
+
id.startsWith(LOOKUP_PREFIX) ||
|
|
90
|
+
id.startsWith(DIR_PREFIX) ||
|
|
91
|
+
id.startsWith(CONST_PREFIX)
|
|
92
|
+
);
|
|
57
93
|
}
|
|
58
94
|
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
function leafKind(id) {
|
|
96
|
+
if (id.startsWith(FILE_PREFIX)) return "file";
|
|
97
|
+
if (id.startsWith(LOOKUP_PREFIX)) return "lookup";
|
|
98
|
+
if (id.startsWith(DIR_PREFIX)) return "dir";
|
|
99
|
+
if (id.startsWith(CONST_PREFIX)) return "const";
|
|
100
|
+
return null;
|
|
63
101
|
}
|
|
64
102
|
|
|
65
|
-
function
|
|
66
|
-
return
|
|
103
|
+
function leafRawPath(id) {
|
|
104
|
+
return id.slice(id.indexOf(":") + 1);
|
|
67
105
|
}
|
|
68
106
|
|
|
69
107
|
function defaultValueFingerprint(value) {
|
|
@@ -83,9 +121,15 @@ export class GraphComputeError extends Error {
|
|
|
83
121
|
}
|
|
84
122
|
|
|
85
123
|
export class BuildGraph {
|
|
86
|
-
|
|
124
|
+
/**
|
|
125
|
+
* @param {{onOrphans?: (paths: string[], nodeId: string) => Promise<void>|void}} [opts]
|
|
126
|
+
* onOrphans receives output paths a node stopped owning (or owned when removed).
|
|
127
|
+
*/
|
|
128
|
+
constructor({ onOrphans = null } = {}) {
|
|
87
129
|
/** @type {Map<string, {fn: Function, fingerprint?: Function}>} derived node definitions */
|
|
88
130
|
this.fns = new Map();
|
|
131
|
+
/** @type {((id: string) => ({fn: Function, fingerprint?: Function}|null))|null} */
|
|
132
|
+
this._resolver = null;
|
|
89
133
|
/** @type {Map<string, any>} last computed values (in-memory only, not persisted) */
|
|
90
134
|
this.values = new Map();
|
|
91
135
|
/** @type {Map<string, string>} node id → current fingerprint (persisted) */
|
|
@@ -96,21 +140,43 @@ export class BuildGraph {
|
|
|
96
140
|
this.rdeps = new Map();
|
|
97
141
|
/** @type {Map<string, {size: number, mtimeMs: number}>} file leaf id → stat fast-path info (persisted) */
|
|
98
142
|
this.leafStats = new Map();
|
|
143
|
+
/** @type {Map<string, string[]>} node id → output paths it owns (persisted) */
|
|
144
|
+
this.owned = new Map();
|
|
145
|
+
/** @type {Map<string, string>} output path → owning node id (derived from owned) */
|
|
146
|
+
this.ownerOf = new Map();
|
|
99
147
|
/** @type {Map<string, string>} node id → error message for nodes whose last compute threw */
|
|
100
148
|
this.failed = new Map();
|
|
149
|
+
/** @type {Map<string, string>} node id → the input whose fingerprint moved (for --explain) */
|
|
150
|
+
this.reasons = new Map();
|
|
151
|
+
|
|
152
|
+
this.onOrphans = onOrphans;
|
|
101
153
|
|
|
102
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* Path roots for relocatable leaf ids: a leaf under roots.S is stored as
|
|
156
|
+
* `$S/rel`, so a graph persisted in `.ursa/` survives the docroot moving.
|
|
157
|
+
* @type {Record<string, string>}
|
|
158
|
+
*/
|
|
159
|
+
this.roots = {};
|
|
160
|
+
|
|
161
|
+
/** @type {Set<string>} leaf ids the watcher reported changed since last verification */
|
|
103
162
|
this._staleLeaves = new Set();
|
|
104
163
|
// Per-pass state
|
|
105
164
|
this._verified = null;
|
|
106
|
-
this.
|
|
165
|
+
this._pending = null;
|
|
107
166
|
this._computedThisPass = new Set();
|
|
167
|
+
/** @type {Set<string>} nodes computed this pass whose fingerprint moved (or that were new) */
|
|
168
|
+
this._changedThisPass = new Set();
|
|
169
|
+
this._passOpen = false;
|
|
108
170
|
}
|
|
109
171
|
|
|
172
|
+
// -------------------------------------------------------------------------
|
|
173
|
+
// Definition
|
|
174
|
+
// -------------------------------------------------------------------------
|
|
175
|
+
|
|
110
176
|
/**
|
|
111
177
|
* Define (or replace) a derived node.
|
|
112
|
-
* @param {string} id - Node id, by convention "kind:key" (e.g. "pageHtml
|
|
113
|
-
* @param {(ctx: {read: Function, exists: Function, get: Function}) => Promise<any>} fn
|
|
178
|
+
* @param {string} id - Node id, by convention "kind:key" (e.g. "pageHtml:docs/a.md")
|
|
179
|
+
* @param {(ctx: {read: Function, exists: Function, listDir: Function, get: Function, own: Function}) => Promise<any>} fn
|
|
114
180
|
* @param {{fingerprint?: (value: any) => string}} [opts] - Custom value fingerprint
|
|
115
181
|
*/
|
|
116
182
|
node(id, fn, opts = {}) {
|
|
@@ -118,19 +184,86 @@ export class BuildGraph {
|
|
|
118
184
|
this.fns.set(id, { fn, fingerprint: opts.fingerprint });
|
|
119
185
|
}
|
|
120
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Install a resolver for node families. Called with an unknown node id, it
|
|
189
|
+
* returns `{fn, fingerprint?}` or null. Resolved definitions are memoized.
|
|
190
|
+
*/
|
|
191
|
+
resolver(fn) {
|
|
192
|
+
this._resolver = fn;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_def(id) {
|
|
196
|
+
let def = this.fns.get(id);
|
|
197
|
+
if (!def && this._resolver && !isLeafId(id)) {
|
|
198
|
+
def = this._resolver(id);
|
|
199
|
+
if (def) this.fns.set(id, def);
|
|
200
|
+
}
|
|
201
|
+
return def ?? null;
|
|
202
|
+
}
|
|
203
|
+
|
|
121
204
|
hasNode(id) {
|
|
122
|
-
return this.
|
|
205
|
+
return this._def(id) !== null;
|
|
123
206
|
}
|
|
124
207
|
|
|
125
208
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
209
|
+
* Path roots for relocatable ids: `setRoots({S: source, M: meta})`.
|
|
210
|
+
* Must be set before any compute and before `load()`.
|
|
128
211
|
*/
|
|
129
|
-
|
|
212
|
+
setRoots(roots) {
|
|
213
|
+
this.roots = { ...roots };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Absolute path → stored leaf path (`$S/rel` under a root). */
|
|
217
|
+
encodePath(abs) {
|
|
218
|
+
for (const [key, root] of Object.entries(this.roots)) {
|
|
219
|
+
const r = root.endsWith("/") ? root.slice(0, -1) : root;
|
|
220
|
+
if (abs === r) return `$${key}`;
|
|
221
|
+
if (abs.startsWith(r + "/")) return `$${key}${abs.slice(r.length)}`;
|
|
222
|
+
}
|
|
223
|
+
return abs;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Stored leaf path → absolute path. */
|
|
227
|
+
decodePath(stored) {
|
|
228
|
+
if (stored.startsWith("$")) {
|
|
229
|
+
const slash = stored.indexOf("/");
|
|
230
|
+
const key = slash === -1 ? stored.slice(1) : stored.slice(1, slash);
|
|
231
|
+
const root = this.roots[key];
|
|
232
|
+
if (root) {
|
|
233
|
+
const r = root.endsWith("/") ? root.slice(0, -1) : root;
|
|
234
|
+
return slash === -1 ? r : r + stored.slice(slash);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return stored;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Absolute path of a leaf id. */
|
|
241
|
+
leafPath(id) {
|
|
242
|
+
return this.decodePath(leafRawPath(id));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Set a constant leaf's fingerprint (e.g. the running ursa version). */
|
|
246
|
+
setConst(name, value) {
|
|
247
|
+
const id = constNodeId(name);
|
|
248
|
+
this.fingerprints.set(id, String(value));
|
|
249
|
+
this._staleLeaves.delete(id);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// -------------------------------------------------------------------------
|
|
253
|
+
// Removal and ownership
|
|
254
|
+
// -------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Remove a derived node (e.g. its source document was deleted). Files it
|
|
258
|
+
* owned are handed to `onOrphans`. Dependents holding a recorded edge to it
|
|
259
|
+
* will recompute on next verify.
|
|
260
|
+
*/
|
|
261
|
+
async removeNode(id) {
|
|
130
262
|
this.fns.delete(id);
|
|
131
263
|
this.values.delete(id);
|
|
132
264
|
this.fingerprints.delete(id);
|
|
133
265
|
this.failed.delete(id);
|
|
266
|
+
this.reasons.delete(id);
|
|
134
267
|
const deps = this.edges.get(id);
|
|
135
268
|
if (deps) {
|
|
136
269
|
for (const depId of deps.keys()) {
|
|
@@ -142,41 +275,126 @@ export class BuildGraph {
|
|
|
142
275
|
}
|
|
143
276
|
this.edges.delete(id);
|
|
144
277
|
}
|
|
278
|
+
await this._setOwned(id, []);
|
|
145
279
|
}
|
|
146
280
|
|
|
147
281
|
/**
|
|
148
282
|
* Drop persisted state for derived nodes not in keepIds (e.g. deleted
|
|
149
283
|
* documents), then drop leaves no longer referenced by any edge.
|
|
150
|
-
* @param {Set<string
|
|
284
|
+
* @param {Set<string>|((id: string) => boolean)} keep - Node ids (or predicate) that survive
|
|
151
285
|
*/
|
|
152
|
-
gc(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (!isLeafId(id) && !keepIds.has(id)) this.removeNode(id);
|
|
286
|
+
async gc(keep) {
|
|
287
|
+
const keeps = typeof keep === "function" ? keep : (id) => keep.has(id);
|
|
288
|
+
const candidates = new Set([...this.edges.keys(), ...this.fingerprints.keys(), ...this.owned.keys()]);
|
|
289
|
+
for (const id of candidates) {
|
|
290
|
+
if (!isLeafId(id) && !keeps(id)) await this.removeNode(id);
|
|
158
291
|
}
|
|
159
292
|
// Orphaned leaves: no remaining dependents
|
|
160
293
|
for (const id of [...this.fingerprints.keys()]) {
|
|
161
|
-
if (isLeafId(id) && !this.rdeps.has(id)) {
|
|
294
|
+
if (isLeafId(id) && !this.rdeps.has(id) && leafKind(id) !== "const") {
|
|
162
295
|
this.fingerprints.delete(id);
|
|
163
296
|
this.leafStats.delete(id);
|
|
297
|
+
this._staleLeaves.delete(id);
|
|
164
298
|
}
|
|
165
299
|
}
|
|
166
300
|
}
|
|
167
301
|
|
|
302
|
+
/** Output paths a node owns. */
|
|
303
|
+
ownedBy(id) {
|
|
304
|
+
return this.owned.get(id) ?? [];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The node owning an output path, if any. */
|
|
308
|
+
ownerOfPath(path) {
|
|
309
|
+
return this.ownerOf.get(path) ?? null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async _setOwned(id, paths) {
|
|
313
|
+
const before = this.owned.get(id) ?? [];
|
|
314
|
+
const after = [...new Set(paths)].sort();
|
|
315
|
+
const afterSet = new Set(after);
|
|
316
|
+
const orphans = before.filter((p) => !afterSet.has(p));
|
|
317
|
+
for (const p of before) if (this.ownerOf.get(p) === id) this.ownerOf.delete(p);
|
|
318
|
+
if (after.length > 0) {
|
|
319
|
+
this.owned.set(id, after);
|
|
320
|
+
for (const p of after) this.ownerOf.set(p, id);
|
|
321
|
+
} else {
|
|
322
|
+
this.owned.delete(id);
|
|
323
|
+
}
|
|
324
|
+
if (orphans.length > 0 && this.onOrphans) {
|
|
325
|
+
// A path that another node now owns is not an orphan (ownership moved)
|
|
326
|
+
const truly = orphans.filter((p) => !this.ownerOf.has(p));
|
|
327
|
+
if (truly.length > 0) await this.onOrphans(truly, id);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// -------------------------------------------------------------------------
|
|
332
|
+
// Invalidation
|
|
333
|
+
// -------------------------------------------------------------------------
|
|
334
|
+
|
|
168
335
|
/**
|
|
169
|
-
* Watcher hook:
|
|
170
|
-
* before they are next trusted.
|
|
336
|
+
* Watcher hook: a path event. Marks the file, lookup and parent-directory
|
|
337
|
+
* leaves as needing a re-check before they are next trusted.
|
|
171
338
|
* @param {string} path - Absolute path reported by the file watcher
|
|
172
339
|
*/
|
|
173
340
|
invalidatePath(path) {
|
|
174
|
-
this.
|
|
175
|
-
this._staleLeaves.add(
|
|
341
|
+
const stored = this.encodePath(path);
|
|
342
|
+
this._staleLeaves.add(fileNodeId(stored));
|
|
343
|
+
this._staleLeaves.add(lookupNodeId(stored));
|
|
344
|
+
this._staleLeaves.add(dirNodeId(stored));
|
|
345
|
+
this._staleLeaves.add(dirNodeId(this.encodePath(dirname(path))));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Watcher hook for a directory event: every known leaf beneath the path is
|
|
350
|
+
* re-checked (a renamed folder reports one event, for the folder).
|
|
351
|
+
* @param {string} path - Absolute directory path
|
|
352
|
+
*/
|
|
353
|
+
invalidateSubtree(path) {
|
|
354
|
+
this.invalidateSubtrees([path]);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** As invalidateSubtree, for several paths in one sweep of the known leaves. */
|
|
358
|
+
invalidateSubtrees(paths) {
|
|
359
|
+
if (paths.length === 0) return;
|
|
360
|
+
const prefixes = [];
|
|
361
|
+
for (const path of paths) {
|
|
362
|
+
this.invalidatePath(path);
|
|
363
|
+
prefixes.push(this.encodePath(path) + "/");
|
|
364
|
+
}
|
|
365
|
+
for (const id of this.fingerprints.keys()) {
|
|
366
|
+
if (!isLeafId(id) || leafKind(id) === "const") continue;
|
|
367
|
+
const raw = leafRawPath(id);
|
|
368
|
+
for (const prefix of prefixes) {
|
|
369
|
+
if (raw.startsWith(prefix)) {
|
|
370
|
+
this._staleLeaves.add(id);
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
176
375
|
}
|
|
177
376
|
|
|
178
377
|
/**
|
|
179
|
-
*
|
|
378
|
+
* Refresh every stale leaf (the ones the watcher flagged) and return the
|
|
379
|
+
* ids whose fingerprint actually changed. Unknown stale leaves (never
|
|
380
|
+
* recorded by any node) are dropped: nothing depends on them.
|
|
381
|
+
* @returns {Promise<string[]>}
|
|
382
|
+
*/
|
|
383
|
+
async refreshStale() {
|
|
384
|
+
const changed = [];
|
|
385
|
+
const stale = [...this._staleLeaves];
|
|
386
|
+
this._staleLeaves.clear();
|
|
387
|
+
for (const id of stale) {
|
|
388
|
+
if (!this.fingerprints.has(id)) continue;
|
|
389
|
+
const before = this.fingerprints.get(id);
|
|
390
|
+
await this._refreshLeaf(id, { force: true });
|
|
391
|
+
if (this.fingerprints.get(id) !== before) changed.push(id);
|
|
392
|
+
}
|
|
393
|
+
return changed;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Re-check every known leaf (warm start). Uses the size+mtime fast path and
|
|
180
398
|
* only re-hashes content when the stat changed. Returns leaf ids whose
|
|
181
399
|
* fingerprint actually changed.
|
|
182
400
|
* @returns {Promise<string[]>}
|
|
@@ -184,7 +402,7 @@ export class BuildGraph {
|
|
|
184
402
|
async scanLeaves() {
|
|
185
403
|
const changed = [];
|
|
186
404
|
for (const id of [...this.fingerprints.keys()]) {
|
|
187
|
-
if (!isLeafId(id)) continue;
|
|
405
|
+
if (!isLeafId(id) || leafKind(id) === "const") continue;
|
|
188
406
|
const before = this.fingerprints.get(id);
|
|
189
407
|
await this._refreshLeaf(id, { force: true });
|
|
190
408
|
if (this.fingerprints.get(id) !== before) changed.push(id);
|
|
@@ -193,29 +411,88 @@ export class BuildGraph {
|
|
|
193
411
|
return changed;
|
|
194
412
|
}
|
|
195
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Every derived node that transitively depends on any of the given ids.
|
|
416
|
+
* An upper bound on what a pass will recompute (early cutoff trims it).
|
|
417
|
+
* @param {Iterable<string>} ids
|
|
418
|
+
* @returns {Set<string>}
|
|
419
|
+
*/
|
|
420
|
+
dependents(ids) {
|
|
421
|
+
const out = new Set();
|
|
422
|
+
const stack = [...ids];
|
|
423
|
+
while (stack.length > 0) {
|
|
424
|
+
const cur = stack.pop();
|
|
425
|
+
const deps = this.rdeps.get(cur);
|
|
426
|
+
if (!deps) continue;
|
|
427
|
+
for (const d of deps) {
|
|
428
|
+
if (out.has(d)) continue;
|
|
429
|
+
out.add(d);
|
|
430
|
+
stack.push(d);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// -------------------------------------------------------------------------
|
|
437
|
+
// Building
|
|
438
|
+
// -------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
/** Begin a pass: nothing verified yet. Idempotent while a pass is open. */
|
|
441
|
+
beginPass() {
|
|
442
|
+
if (this._passOpen) return;
|
|
443
|
+
this._verified = new Set();
|
|
444
|
+
this._pending = new Map();
|
|
445
|
+
this._computedThisPass = new Set();
|
|
446
|
+
this._changedThisPass = new Set();
|
|
447
|
+
this.reasons = new Map();
|
|
448
|
+
this._passOpen = true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** End a pass. */
|
|
452
|
+
endPass() {
|
|
453
|
+
this._passOpen = false;
|
|
454
|
+
this._verified = null;
|
|
455
|
+
this._pending = null;
|
|
456
|
+
}
|
|
457
|
+
|
|
196
458
|
/**
|
|
197
459
|
* Bring the given nodes (and everything they depend on) up to date, in
|
|
198
460
|
* order — schedule client-viewed pages first for priority regeneration.
|
|
199
461
|
* A node whose compute function throws is marked failed (and retried next
|
|
200
462
|
* pass) without corrupting the rest of the graph.
|
|
463
|
+
*
|
|
464
|
+
* When called inside an open pass (beginPass), verification state is shared
|
|
465
|
+
* with earlier build() calls of the same pass; otherwise this call is a
|
|
466
|
+
* pass of its own.
|
|
467
|
+
*
|
|
201
468
|
* @param {string[]} rootIds
|
|
469
|
+
* @param {{concurrency?: number, onDone?: (id: string, err: Error|null) => void}} [opts]
|
|
202
470
|
* @returns {Promise<{ok: boolean, results: Map<string, any>, errors: Map<string, Error>, computed: Set<string>}>}
|
|
203
471
|
*/
|
|
204
|
-
async build(rootIds) {
|
|
205
|
-
|
|
206
|
-
this.
|
|
207
|
-
this._computedThisPass = new Set();
|
|
472
|
+
async build(rootIds, { concurrency = 1, onDone = null } = {}) {
|
|
473
|
+
const ownPass = !this._passOpen;
|
|
474
|
+
if (ownPass) this.beginPass();
|
|
208
475
|
const results = new Map();
|
|
209
476
|
const errors = new Map();
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
477
|
+
const queue = [...rootIds];
|
|
478
|
+
const worker = async () => {
|
|
479
|
+
while (queue.length > 0) {
|
|
480
|
+
const id = queue.shift();
|
|
481
|
+
try {
|
|
482
|
+
await this._verify(id, new Set());
|
|
483
|
+
results.set(id, this.values.get(id));
|
|
484
|
+
if (onDone) await onDone(id, null);
|
|
485
|
+
} catch (e) {
|
|
486
|
+
errors.set(id, e);
|
|
487
|
+
if (onDone) await onDone(id, e);
|
|
488
|
+
}
|
|
216
489
|
}
|
|
217
|
-
}
|
|
218
|
-
|
|
490
|
+
};
|
|
491
|
+
const n = Math.max(1, Math.min(concurrency, queue.length || 1));
|
|
492
|
+
await Promise.all(Array.from({ length: n }, worker));
|
|
493
|
+
const computed = new Set(this._computedThisPass);
|
|
494
|
+
if (ownPass) this.endPass();
|
|
495
|
+
return { ok: errors.size === 0, results, errors, computed };
|
|
219
496
|
}
|
|
220
497
|
|
|
221
498
|
/**
|
|
@@ -224,16 +501,17 @@ export class BuildGraph {
|
|
|
224
501
|
* @param {string} id
|
|
225
502
|
*/
|
|
226
503
|
async demand(id) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
this.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
504
|
+
const ownPass = !this._passOpen;
|
|
505
|
+
if (ownPass) this.beginPass();
|
|
506
|
+
try {
|
|
507
|
+
await this._verify(id, new Set());
|
|
508
|
+
if (!this.values.has(id) && this._def(id)) {
|
|
509
|
+
await this._compute(id, new Set([id]));
|
|
510
|
+
}
|
|
511
|
+
return this.values.get(id);
|
|
512
|
+
} finally {
|
|
513
|
+
if (ownPass) this.endPass();
|
|
235
514
|
}
|
|
236
|
-
return this.values.get(id);
|
|
237
515
|
}
|
|
238
516
|
|
|
239
517
|
// -------------------------------------------------------------------------
|
|
@@ -243,76 +521,95 @@ export class BuildGraph {
|
|
|
243
521
|
/**
|
|
244
522
|
* Ensure a node is up to date: verify its recorded deps (topologically),
|
|
245
523
|
* recompute when any recorded input fingerprint differs from current.
|
|
524
|
+
* `chain` is the set of ids on the current verification path (cycle check);
|
|
525
|
+
* concurrent verifications of the same node share one promise.
|
|
246
526
|
*/
|
|
247
|
-
async _verify(id) {
|
|
527
|
+
async _verify(id, chain) {
|
|
248
528
|
if (this._verified.has(id)) return;
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
529
|
+
if (chain.has(id)) throw new Error(`Dependency cycle detected at "${id}"`);
|
|
530
|
+
const pending = this._pending.get(id);
|
|
531
|
+
if (pending) return pending;
|
|
532
|
+
|
|
533
|
+
const p = this._verifyUncached(id, chain).finally(() => {
|
|
534
|
+
if (this._pending?.get(id) === p) this._pending.delete(id);
|
|
535
|
+
});
|
|
536
|
+
this._pending.set(id, p);
|
|
537
|
+
return p;
|
|
538
|
+
}
|
|
252
539
|
|
|
540
|
+
async _verifyUncached(id, chain) {
|
|
253
541
|
if (isLeafId(id)) {
|
|
254
542
|
await this._refreshLeaf(id);
|
|
255
543
|
this._verified.add(id);
|
|
256
544
|
return;
|
|
257
545
|
}
|
|
258
546
|
|
|
259
|
-
const def = this.
|
|
547
|
+
const def = this._def(id);
|
|
260
548
|
if (!def) throw new Error(`Unknown node: "${id}"`);
|
|
261
549
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
550
|
+
const nextChain = new Set(chain);
|
|
551
|
+
nextChain.add(id);
|
|
552
|
+
let needsCompute = false;
|
|
553
|
+
let reason = null;
|
|
554
|
+
const deps = this.edges.get(id);
|
|
555
|
+
if (!this.fingerprints.has(id)) {
|
|
556
|
+
needsCompute = true;
|
|
557
|
+
reason = "never computed";
|
|
558
|
+
} else if (this.failed.has(id)) {
|
|
559
|
+
needsCompute = true;
|
|
560
|
+
reason = "failed last pass";
|
|
561
|
+
} else if (!deps) {
|
|
562
|
+
needsCompute = true;
|
|
563
|
+
reason = "no recorded inputs";
|
|
564
|
+
} else {
|
|
565
|
+
for (const [depId, recordedFp] of deps) {
|
|
566
|
+
if (!isLeafId(depId) && !this._def(depId)) {
|
|
567
|
+
needsCompute = true;
|
|
568
|
+
reason = `input ${depId} no longer defined`;
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
await this._verify(depId, nextChain);
|
|
572
|
+
if (this.fingerprints.get(depId) !== recordedFp) {
|
|
573
|
+
needsCompute = true;
|
|
574
|
+
reason = depId;
|
|
575
|
+
break;
|
|
281
576
|
}
|
|
282
577
|
}
|
|
283
|
-
if (needsCompute) {
|
|
284
|
-
await this._compute(id);
|
|
285
|
-
}
|
|
286
|
-
this._verified.add(id);
|
|
287
|
-
} finally {
|
|
288
|
-
this._inProgress.delete(id);
|
|
289
578
|
}
|
|
579
|
+
if (needsCompute) {
|
|
580
|
+
this.reasons.set(id, reason);
|
|
581
|
+
await this._compute(id, nextChain);
|
|
582
|
+
}
|
|
583
|
+
this._verified.add(id);
|
|
290
584
|
}
|
|
291
585
|
|
|
292
586
|
/** Run a node's compute function, recording fresh edges as inputs are consumed. */
|
|
293
|
-
async _compute(id) {
|
|
294
|
-
const def = this.
|
|
587
|
+
async _compute(id, chain) {
|
|
588
|
+
const def = this._def(id);
|
|
295
589
|
if (!def) throw new Error(`Unknown node: "${id}"`);
|
|
296
590
|
const depMap = new Map();
|
|
297
|
-
const
|
|
591
|
+
const owns = [];
|
|
592
|
+
const ctx = this._makeCtx(depMap, owns, chain);
|
|
298
593
|
|
|
299
594
|
let value;
|
|
300
595
|
try {
|
|
301
|
-
value = await def.fn(ctx);
|
|
596
|
+
value = await withRecorder(ctx._recorder, () => def.fn(ctx));
|
|
302
597
|
} catch (e) {
|
|
303
|
-
// Mark failed; keep previous edges/fingerprint intact so the
|
|
304
|
-
// is not corrupted. The node is retried on the next pass.
|
|
598
|
+
// Mark failed; keep previous edges/fingerprint/ownership intact so the
|
|
599
|
+
// graph is not corrupted. The node is retried on the next pass.
|
|
305
600
|
this.failed.set(id, String(e?.message ?? e));
|
|
306
601
|
throw e instanceof GraphComputeError ? e : new GraphComputeError(id, e);
|
|
307
602
|
}
|
|
308
603
|
|
|
309
604
|
this.failed.delete(id);
|
|
310
605
|
this._setEdges(id, depMap);
|
|
606
|
+
await this._setOwned(id, owns);
|
|
311
607
|
this.values.set(id, value);
|
|
312
608
|
const oldFp = this.fingerprints.get(id);
|
|
313
609
|
const newFp = (def.fingerprint ?? defaultValueFingerprint)(value);
|
|
314
610
|
this.fingerprints.set(id, newFp);
|
|
315
611
|
this._computedThisPass.add(id);
|
|
612
|
+
if (oldFp !== newFp) this._changedThisPass.add(id);
|
|
316
613
|
if (oldFp !== undefined && oldFp !== newFp) {
|
|
317
614
|
// Fingerprint moved mid-pass (normally only when an input changed):
|
|
318
615
|
// anything already verified that depends on this must be re-checked.
|
|
@@ -322,48 +619,107 @@ export class BuildGraph {
|
|
|
322
619
|
}
|
|
323
620
|
|
|
324
621
|
/** Compute context handed to node functions; records edges as they are consumed. */
|
|
325
|
-
_makeCtx(depMap) {
|
|
622
|
+
_makeCtx(depMap, owns, chain) {
|
|
326
623
|
const graph = this;
|
|
624
|
+
const recorder = {
|
|
625
|
+
/** tracedFs hook: reuse a known content hash when size+mtime match (skips re-hashing). */
|
|
626
|
+
knownFileFingerprint(absPath, stats) {
|
|
627
|
+
const id = fileNodeId(graph.encodePath(absPath));
|
|
628
|
+
const prev = graph.leafStats.get(id);
|
|
629
|
+
const fp = graph.fingerprints.get(id);
|
|
630
|
+
if (prev && fp && fp !== MISSING && prev.size === stats.size && prev.mtimeMs === stats.mtimeMs) {
|
|
631
|
+
return fp;
|
|
632
|
+
}
|
|
633
|
+
return undefined;
|
|
634
|
+
},
|
|
635
|
+
file(absPath, fp, stats) {
|
|
636
|
+
const id = fileNodeId(graph.encodePath(absPath));
|
|
637
|
+
if (stats) graph.leafStats.set(id, stats);
|
|
638
|
+
else graph.leafStats.delete(id);
|
|
639
|
+
graph.fingerprints.set(id, fp);
|
|
640
|
+
graph._markLeafFresh(id);
|
|
641
|
+
depMap.set(id, fp);
|
|
642
|
+
},
|
|
643
|
+
lookup(absPath, fp) {
|
|
644
|
+
const id = lookupNodeId(graph.encodePath(absPath));
|
|
645
|
+
graph.fingerprints.set(id, fp);
|
|
646
|
+
graph._markLeafFresh(id);
|
|
647
|
+
depMap.set(id, fp);
|
|
648
|
+
},
|
|
649
|
+
dir(absPath, fp) {
|
|
650
|
+
const id = dirNodeId(graph.encodePath(absPath));
|
|
651
|
+
graph.fingerprints.set(id, fp);
|
|
652
|
+
graph._markLeafFresh(id);
|
|
653
|
+
depMap.set(id, fp);
|
|
654
|
+
},
|
|
655
|
+
};
|
|
327
656
|
return {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
let
|
|
657
|
+
_recorder: recorder,
|
|
658
|
+
/** Read a file (utf8), recording a file-leaf dependency. Throws if missing (the miss is still recorded). */
|
|
659
|
+
async read(path, encoding = "utf8") {
|
|
660
|
+
let st;
|
|
332
661
|
try {
|
|
333
|
-
|
|
334
|
-
graph.leafStats.set(id, { size: st.size, mtimeMs: st.mtimeMs });
|
|
335
|
-
graph.fingerprints.set(id, hashBytes(buf));
|
|
336
|
-
content = buf.toString("utf8");
|
|
662
|
+
st = await stat(path);
|
|
337
663
|
} catch (e) {
|
|
338
|
-
|
|
339
|
-
graph.fingerprints.set(id, MISSING);
|
|
340
|
-
graph._markLeafFresh(id);
|
|
341
|
-
depMap.set(id, MISSING);
|
|
664
|
+
recorder.file(path, MISSING, null);
|
|
342
665
|
throw e;
|
|
343
666
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
667
|
+
const stats = { size: st.size, mtimeMs: st.mtimeMs };
|
|
668
|
+
const known = recorder.knownFileFingerprint(path, stats);
|
|
669
|
+
const buf = await readFile(path);
|
|
670
|
+
recorder.file(path, known ?? hashBytes(buf), stats);
|
|
671
|
+
return encoding === null ? buf : buf.toString(encoding);
|
|
347
672
|
},
|
|
348
|
-
/** Probe for a
|
|
673
|
+
/** Probe for a path's existence, recording a lookup-leaf dependency. */
|
|
349
674
|
exists(path) {
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
675
|
+
const found = existsSync(path);
|
|
676
|
+
recorder.lookup(path, found ? EXISTS : ABSENT);
|
|
677
|
+
return found;
|
|
678
|
+
},
|
|
679
|
+
/**
|
|
680
|
+
* List a directory's build-input entries (sorted, with kind), recording
|
|
681
|
+
* a dir-leaf dependency. A missing directory lists as [].
|
|
682
|
+
* @returns {Promise<{name: string, kind: 'file'|'dir'|'other'}[]>}
|
|
683
|
+
*/
|
|
684
|
+
async listDir(path) {
|
|
685
|
+
let entries;
|
|
686
|
+
try {
|
|
687
|
+
entries = await fsp.readdir(path, { withFileTypes: true });
|
|
688
|
+
} catch {
|
|
689
|
+
recorder.dir(path, MISSING);
|
|
690
|
+
return [];
|
|
691
|
+
}
|
|
692
|
+
recorder.dir(path, dirFingerprintFromEntries(entries));
|
|
693
|
+
return entries
|
|
694
|
+
.filter((e) => !isIgnoredDirEntry(e.name))
|
|
695
|
+
.map((e) => ({
|
|
696
|
+
name: e.name,
|
|
697
|
+
kind: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other",
|
|
698
|
+
}))
|
|
699
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
700
|
+
},
|
|
701
|
+
/** Depend on a constant leaf (e.g. the ursa version). */
|
|
702
|
+
constant(name) {
|
|
703
|
+
const id = constNodeId(name);
|
|
704
|
+
const fp = graph.fingerprints.get(id) ?? "";
|
|
353
705
|
graph._markLeafFresh(id);
|
|
354
706
|
depMap.set(id, fp);
|
|
355
|
-
return fp
|
|
707
|
+
return fp;
|
|
356
708
|
},
|
|
357
709
|
/** Get another node's value, recording a derived dependency. */
|
|
358
710
|
async get(otherId) {
|
|
359
|
-
await graph._verify(otherId);
|
|
360
|
-
if (!graph.values.has(otherId) && graph.
|
|
711
|
+
await graph._verify(otherId, chain);
|
|
712
|
+
if (!graph.values.has(otherId) && graph._def(otherId)) {
|
|
361
713
|
// Clean but value not in memory (restart) — recompute on demand
|
|
362
|
-
await graph._compute(otherId);
|
|
714
|
+
await graph._compute(otherId, new Set([...chain, otherId]));
|
|
363
715
|
}
|
|
364
716
|
depMap.set(otherId, graph.fingerprints.get(otherId));
|
|
365
717
|
return graph.values.get(otherId);
|
|
366
718
|
},
|
|
719
|
+
/** Declare an output file this node wrote and therefore owns. */
|
|
720
|
+
own(path) {
|
|
721
|
+
owns.push(path);
|
|
722
|
+
},
|
|
367
723
|
};
|
|
368
724
|
}
|
|
369
725
|
|
|
@@ -413,17 +769,30 @@ export class BuildGraph {
|
|
|
413
769
|
/**
|
|
414
770
|
* Refresh a leaf's fingerprint. File leaves use the size+mtime fast path
|
|
415
771
|
* and re-hash content only on stat mismatch; lookup leaves re-probe
|
|
416
|
-
* existence. Unless forced (scanLeaves) or
|
|
417
|
-
* an already-known leaf is trusted.
|
|
772
|
+
* existence; directory leaves re-list. Unless forced (scanLeaves) or
|
|
773
|
+
* flagged stale (invalidatePath), an already-known leaf is trusted.
|
|
418
774
|
*/
|
|
419
775
|
async _refreshLeaf(id, { force = false } = {}) {
|
|
420
|
-
const
|
|
776
|
+
const kind = leafKind(id);
|
|
777
|
+
if (kind === "const") return;
|
|
778
|
+
const path = this.leafPath(id);
|
|
421
779
|
const known = this.fingerprints.has(id);
|
|
422
780
|
if (known && !force && !this._staleLeaves.has(id)) return;
|
|
423
781
|
this._staleLeaves.delete(id);
|
|
424
782
|
|
|
425
|
-
if (
|
|
426
|
-
this.fingerprints.set(id, existsSync(path) ?
|
|
783
|
+
if (kind === "lookup") {
|
|
784
|
+
this.fingerprints.set(id, existsSync(path) ? EXISTS : ABSENT);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (kind === "dir") {
|
|
789
|
+
let entries = null;
|
|
790
|
+
try {
|
|
791
|
+
entries = await fsp.readdir(path, { withFileTypes: true });
|
|
792
|
+
} catch {
|
|
793
|
+
entries = null;
|
|
794
|
+
}
|
|
795
|
+
this.fingerprints.set(id, dirFingerprintFromEntries(entries));
|
|
427
796
|
return;
|
|
428
797
|
}
|
|
429
798
|
|
|
@@ -433,13 +802,13 @@ export class BuildGraph {
|
|
|
433
802
|
} catch {
|
|
434
803
|
st = null;
|
|
435
804
|
}
|
|
436
|
-
if (!st) {
|
|
805
|
+
if (!st || !st.isFile()) {
|
|
437
806
|
this.leafStats.delete(id);
|
|
438
807
|
this.fingerprints.set(id, MISSING);
|
|
439
808
|
return;
|
|
440
809
|
}
|
|
441
810
|
const prev = this.leafStats.get(id);
|
|
442
|
-
if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs && known) {
|
|
811
|
+
if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs && known && this.fingerprints.get(id) !== MISSING) {
|
|
443
812
|
return; // fast path: stat unchanged, trust existing content hash
|
|
444
813
|
}
|
|
445
814
|
const buf = await readFile(path);
|
|
@@ -451,22 +820,42 @@ export class BuildGraph {
|
|
|
451
820
|
// Persistence
|
|
452
821
|
// -------------------------------------------------------------------------
|
|
453
822
|
|
|
454
|
-
/**
|
|
823
|
+
/**
|
|
824
|
+
* Serialize {edges, fingerprints, leafStats, owned} for .ursa/graph.json.
|
|
825
|
+
* Ids are interned into a string table: a 10k-page site has several hundred
|
|
826
|
+
* thousand edges, and each id would otherwise be repeated once per edge.
|
|
827
|
+
*/
|
|
455
828
|
serialize() {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
829
|
+
const table = [];
|
|
830
|
+
const index = new Map();
|
|
831
|
+
const intern = (s) => {
|
|
832
|
+
let i = index.get(s);
|
|
833
|
+
if (i === undefined) {
|
|
834
|
+
i = table.length;
|
|
835
|
+
table.push(s);
|
|
836
|
+
index.set(s, i);
|
|
837
|
+
}
|
|
838
|
+
return i;
|
|
463
839
|
};
|
|
840
|
+
const fingerprints = [];
|
|
841
|
+
for (const [id, fp] of this.fingerprints) fingerprints.push(intern(id), fp);
|
|
842
|
+
const edges = [];
|
|
843
|
+
for (const [id, deps] of this.edges) {
|
|
844
|
+
const row = [intern(id)];
|
|
845
|
+
for (const [depId, fp] of deps) row.push(intern(depId), fp);
|
|
846
|
+
edges.push(row);
|
|
847
|
+
}
|
|
848
|
+
const leafStats = [];
|
|
849
|
+
for (const [id, st] of this.leafStats) leafStats.push(intern(id), st.size, st.mtimeMs);
|
|
850
|
+
const owned = [];
|
|
851
|
+
for (const [id, paths] of this.owned) owned.push([intern(id), ...paths]);
|
|
852
|
+
return { version: GRAPH_SCHEMA_VERSION, table, fingerprints, edges, leafStats, owned };
|
|
464
853
|
}
|
|
465
854
|
|
|
466
855
|
/**
|
|
467
856
|
* Load persisted state. Returns false (leaving the graph empty for a clean
|
|
468
857
|
* pass) when the schema version is stale or the data is malformed.
|
|
469
|
-
* All leaves are marked stale so the first pass re-
|
|
858
|
+
* All leaves are marked stale so the first pass re-checks them — call
|
|
470
859
|
* scanLeaves() to do this eagerly on warm start.
|
|
471
860
|
* @param {object} data - Previously serialized graph
|
|
472
861
|
* @returns {boolean} Whether the data was loaded
|
|
@@ -474,15 +863,37 @@ export class BuildGraph {
|
|
|
474
863
|
load(data) {
|
|
475
864
|
if (!data || data.version !== GRAPH_SCHEMA_VERSION) return false;
|
|
476
865
|
try {
|
|
477
|
-
|
|
478
|
-
|
|
866
|
+
const table = data.table ?? [];
|
|
867
|
+
const id = (i) => {
|
|
868
|
+
const s = table[i];
|
|
869
|
+
if (typeof s !== "string") throw new Error("bad id index");
|
|
870
|
+
return s;
|
|
871
|
+
};
|
|
872
|
+
this.fingerprints = new Map();
|
|
873
|
+
for (let i = 0; i < (data.fingerprints ?? []).length; i += 2) {
|
|
874
|
+
this.fingerprints.set(id(data.fingerprints[i]), data.fingerprints[i + 1]);
|
|
875
|
+
}
|
|
876
|
+
this.leafStats = new Map();
|
|
877
|
+
for (let i = 0; i < (data.leafStats ?? []).length; i += 3) {
|
|
878
|
+
this.leafStats.set(id(data.leafStats[i]), { size: data.leafStats[i + 1], mtimeMs: data.leafStats[i + 2] });
|
|
879
|
+
}
|
|
479
880
|
this.edges = new Map();
|
|
480
881
|
this.rdeps = new Map();
|
|
481
|
-
for (const
|
|
482
|
-
|
|
882
|
+
for (const row of data.edges ?? []) {
|
|
883
|
+
const deps = new Map();
|
|
884
|
+
for (let i = 1; i < row.length; i += 2) deps.set(id(row[i]), row[i + 1]);
|
|
885
|
+
this._setEdges(id(row[0]), deps);
|
|
886
|
+
}
|
|
887
|
+
this.owned = new Map();
|
|
888
|
+
this.ownerOf = new Map();
|
|
889
|
+
for (const row of data.owned ?? []) {
|
|
890
|
+
const nodeId = id(row[0]);
|
|
891
|
+
const paths = row.slice(1);
|
|
892
|
+
this.owned.set(nodeId, paths);
|
|
893
|
+
for (const p of paths) this.ownerOf.set(p, nodeId);
|
|
483
894
|
}
|
|
484
|
-
for (const
|
|
485
|
-
if (isLeafId(
|
|
895
|
+
for (const key of this.fingerprints.keys()) {
|
|
896
|
+
if (isLeafId(key) && leafKind(key) !== "const") this._staleLeaves.add(key);
|
|
486
897
|
}
|
|
487
898
|
return true;
|
|
488
899
|
} catch {
|
|
@@ -490,6 +901,8 @@ export class BuildGraph {
|
|
|
490
901
|
this.leafStats = new Map();
|
|
491
902
|
this.edges = new Map();
|
|
492
903
|
this.rdeps = new Map();
|
|
904
|
+
this.owned = new Map();
|
|
905
|
+
this.ownerOf = new Map();
|
|
493
906
|
return false;
|
|
494
907
|
}
|
|
495
908
|
}
|
|
@@ -505,8 +918,18 @@ export class BuildGraph {
|
|
|
505
918
|
leaves,
|
|
506
919
|
edges: [...this.edges.values()].reduce((sum, m) => sum + m.size, 0),
|
|
507
920
|
failed: this.failed.size,
|
|
921
|
+
owned: this.ownerOf.size,
|
|
508
922
|
};
|
|
509
923
|
}
|
|
924
|
+
|
|
925
|
+
/** Derived node ids currently known (defined or persisted). */
|
|
926
|
+
knownNodeIds() {
|
|
927
|
+
const ids = new Set();
|
|
928
|
+
for (const id of this.edges.keys()) if (!isLeafId(id)) ids.add(id);
|
|
929
|
+
for (const id of this.fingerprints.keys()) if (!isLeafId(id)) ids.add(id);
|
|
930
|
+
for (const id of this.owned.keys()) ids.add(id);
|
|
931
|
+
return ids;
|
|
932
|
+
}
|
|
510
933
|
}
|
|
511
934
|
|
|
512
935
|
/** Path to the persisted graph for a source directory. */
|