@egildo/provenance 0.0.0-stage → 0.6.2

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.
@@ -0,0 +1,893 @@
1
+ // A session: roots, every source reachable from them, and the declared edges between them.
2
+ // Rules and states are specs/source-graph-kernel/data-model.md's.
3
+ import { byteOffsets } from "./offsets.js";
4
+ import { hasScheme, resolve } from "./resolvers.js";
5
+ /** The refusal codes the session reads (FIX-003), Cascata's names: the key is there; the place is gone. */
6
+ const EXISTS_CODE = "edit-target-exists";
7
+ const GONE_CODES = new Set(["edit-target-absent", "edit-parent-scalar"]);
8
+ const sameBytes = (a, b) => a.length === b.length && a.every((byte, i) => byte === b[i]);
9
+ /**
10
+ * Whether two ranges overlap. Empty ranges at one offset do not: they are inserts at the same spot, spliced in
11
+ * staging order (insert-delete FR-008). An empty range strictly inside another does.
12
+ */
13
+ const overlaps = (a, b) => a.start < b.end && b.start < a.end;
14
+ /** A writer is still pure, so one function asks its opened document to do an operation, or says the document cannot. */
15
+ function perform(doc, key, operation) {
16
+ const cannot = (verb) => ({ ok: false, reason: `the writer for ${key} cannot ${verb}: its document has no ${verb}` });
17
+ if (operation.op === "remove")
18
+ return doc.remove ? doc.remove(operation.path) : cannot("remove");
19
+ if (operation.op === "add")
20
+ return doc.add ? doc.add(operation.path, operation.value) : cannot("add");
21
+ return doc.write(operation.path, operation.value);
22
+ }
23
+ /** A writer's range outside the bytes it was given is its bug, like an index `byteOffsets` cannot convert. */
24
+ function checkRange(length, start, end, what) {
25
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end > length) {
26
+ throw new RangeError(`${what} gave the range ${start}-${end}, outside the ${length} bytes it was given`);
27
+ }
28
+ }
29
+ /**
30
+ * The base with every edit spliced in, applied from the end backwards so each range stays valid. `edits`
31
+ * are in staging order; of several empty-ranged edits at one offset the earlier-staged ends up first in the
32
+ * text, which is to say it is applied last (FR-008).
33
+ */
34
+ function splice(base, edits) {
35
+ let out = base;
36
+ const backwards = edits.map((edit, order) => ({ edit, order })).sort((a, b) => b.edit.start - a.edit.start || b.edit.end - a.edit.end || b.order - a.order);
37
+ for (const { edit: e } of backwards) {
38
+ const next = new Uint8Array(e.start + e.bytes.length + (out.length - e.end));
39
+ next.set(out.subarray(0, e.start));
40
+ next.set(e.bytes, e.start);
41
+ next.set(out.subarray(e.end), e.start + e.bytes.length);
42
+ out = next;
43
+ }
44
+ return out;
45
+ }
46
+ /** Findings per handler and version, shared by every session that uses the handler. */
47
+ const cache = new WeakMap();
48
+ /**
49
+ * What every open session holds: the handler and version of each source it has analysed, and of
50
+ * each finding its running piece of work has taken from the cache or made so far. A finding lives
51
+ * while some open session holds it (specs/held-versions/); `sweep` drops the rest.
52
+ */
53
+ const open = new Set();
54
+ /**
55
+ * Drops from the cache of each given handler every version no open session holds. Safe at the end
56
+ * of any session's piece of work: a session mid-work holds what its piece has met.
57
+ * ponytail: walks every held source of every open session per sweep; keep a count per version if
58
+ * a session of many thousands of sources ever makes that cost show.
59
+ */
60
+ function sweep(handlers) {
61
+ const held = new Map();
62
+ for (const holds of open) {
63
+ for (const [handler, version] of holds()) {
64
+ const versions = held.get(handler) ?? new Set();
65
+ held.set(handler, versions.add(version));
66
+ }
67
+ }
68
+ for (const handler of handlers) {
69
+ const byVersion = cache.get(handler);
70
+ if (byVersion)
71
+ for (const version of byVersion.keys())
72
+ if (!held.get(handler)?.has(version))
73
+ byVersion.delete(version);
74
+ }
75
+ }
76
+ async function sha256(bytes) {
77
+ // `slice` copies onto a plain ArrayBuffer: digest refuses a view that may be shared memory.
78
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes.slice()));
79
+ return Array.from(digest, b => b.toString(16).padStart(2, "0")).join("");
80
+ }
81
+ /** A root-absolute URL path, such as `<base href="/">`'s: a path on the embedder's site, never a directory. */
82
+ const isRooted = (base) => base.startsWith("/") && !base.startsWith("//");
83
+ /**
84
+ * Joins a relative request to a rooted base the way a browser does, so it resolves as the
85
+ * root-absolute request that results, which is the embedder's (research.md, Resolution). Any other
86
+ * request stays as written.
87
+ */
88
+ function joined(request, base) {
89
+ if (base === undefined || !isRooted(base) || request === "" || /^[#/]/.test(request) || hasScheme(request))
90
+ return request;
91
+ const url = new URL(request, new URL(base, "http://site"));
92
+ return url.pathname + url.search + url.hash;
93
+ }
94
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
95
+ export async function openSession(options) {
96
+ const { host, handlers, resolvers = [], writers = [], writable = [] } = options;
97
+ if (typeof options.entry !== "string" || options.entry === "")
98
+ throw new TypeError("entry must be a location");
99
+ const ids = new Map();
100
+ const nodes = new Map();
101
+ /** The entry and what the embedder added; no read pass touches these. */
102
+ const roots = new Set();
103
+ /** What each label's latest ended read pass recorded. */
104
+ const passRoots = new Map();
105
+ /** The open pass per label, by identity, so a newer pass can discard an older one. */
106
+ const livePasses = new Map();
107
+ const listeners = new Set();
108
+ // No call started this work, so a throw has nowhere to go but out: re-rejecting makes it unhandled.
109
+ const watch = host.watch(locations => void serialize(() => absorb(locations)).catch(error => Promise.reject(error)));
110
+ let queue = Promise.resolve();
111
+ let closed = false;
112
+ /** What the running piece of work has analysed or read from the cache; released when it ends. */
113
+ const inFlight = [];
114
+ /** The staged edits, by the source's key. A preview of them is a version like any other. */
115
+ const staged = new Map();
116
+ function* holds() {
117
+ yield* inFlight;
118
+ for (const [key, staging] of staged) {
119
+ const handler = staging.preview === undefined ? undefined : handlers.find(h => h.claims(key));
120
+ if (handler && staging.preview !== undefined)
121
+ yield [handler, staging.preview];
122
+ }
123
+ for (const node of nodes.values()) {
124
+ if (node.state !== "analysed")
125
+ continue;
126
+ const handler = handlers.find(h => h.claims(node.key));
127
+ if (handler)
128
+ yield [handler, node.version];
129
+ }
130
+ }
131
+ open.add(holds);
132
+ /**
133
+ * Runs session work one piece at a time, so a change batch never interleaves with another.
134
+ * Work still queued at `close()` never runs. Work that throws leaves the session as it was, and
135
+ * rejects the returned promise; the queue goes on. The change the work returns is announced only
136
+ * after the work is kept, so a listener's throw rejects the call and undoes nothing.
137
+ */
138
+ function serialize(work) {
139
+ const next = queue.then(async () => {
140
+ if (closed)
141
+ return;
142
+ const before = {
143
+ ids: new Map(ids),
144
+ nodes: new Map(nodes),
145
+ roots: new Set(roots),
146
+ passRoots: new Map(passRoots),
147
+ // `doc` is an opened document, never changed once opened: copying the reference is a copy.
148
+ staged: new Map([...staged].map(([key, staging]) => [key, { ...staging, edits: staging.edits.map(e => ({ ...e })) }])),
149
+ // Links are mutated in place when the world changes under them.
150
+ links: [...nodes.values()].flatMap(node => node.links.map(l => ({ link: l, target: l.target, probes: l.probes }))),
151
+ };
152
+ let change;
153
+ notices.length = 0;
154
+ writtenKeys.length = 0;
155
+ try {
156
+ change = await work();
157
+ }
158
+ catch (error) {
159
+ for (const { link: l, target, probes } of before.links) {
160
+ l.target = target;
161
+ l.probes = probes;
162
+ }
163
+ restore(ids, before.ids);
164
+ restore(nodes, before.nodes);
165
+ restore(passRoots, before.passRoots);
166
+ restore(staged, before.staged);
167
+ notices.length = 0;
168
+ for (const key of writtenKeys)
169
+ staged.delete(key); // their edits are on disk; the host's change report brings the session up to them
170
+ writtenKeys.length = 0;
171
+ roots.clear();
172
+ for (const key of before.roots)
173
+ roots.add(key);
174
+ throw error;
175
+ }
176
+ finally {
177
+ // After a rollback has restored the nodes, so the versions it went back to are still held.
178
+ inFlight.length = 0;
179
+ sweep(handlers);
180
+ }
181
+ if (change)
182
+ emit(change);
183
+ });
184
+ queue = next.catch(() => undefined);
185
+ return next;
186
+ }
187
+ function restore(live, saved) {
188
+ live.clear();
189
+ for (const [key, value] of saved)
190
+ live.set(key, value);
191
+ }
192
+ const idFor = (key) => {
193
+ let id = ids.get(key);
194
+ if (id === undefined)
195
+ ids.set(key, (id = `s${ids.size + 1}`));
196
+ return id;
197
+ };
198
+ const keyFor = async (location) => (await host.canonicalize(location)) ?? host.paths.resolve(location);
199
+ /** Resolves a link in place. False means the request makes no edge at all (`data:`, a fragment). */
200
+ async function link(link) {
201
+ const found = await resolve(link.lookup, link.base, host, resolvers);
202
+ if (found === null)
203
+ return false;
204
+ if ("external" in found) {
205
+ link.target = { key: found.external, external: true };
206
+ link.probes = [];
207
+ }
208
+ else {
209
+ link.target = found.target === undefined ? undefined : { key: found.target, external: false };
210
+ link.probes = found.probes;
211
+ }
212
+ return true;
213
+ }
214
+ function baseFor(key, base) {
215
+ const directory = host.paths.dirname(key);
216
+ if (base === undefined || isRooted(base))
217
+ return directory;
218
+ if (base.startsWith("//"))
219
+ return `https:${base}`;
220
+ if (/^https?:/i.test(base))
221
+ return base;
222
+ const resolved = host.paths.resolve(directory, base);
223
+ return base.endsWith("/") ? resolved : host.paths.dirname(resolved);
224
+ }
225
+ /** The handler's findings for one version, from the cache or made now; held until the piece of work ends. */
226
+ function findingsFor(handler, version, text) {
227
+ let byVersion = cache.get(handler);
228
+ if (!byVersion)
229
+ cache.set(handler, (byVersion = new Map()));
230
+ let findings = byVersion.get(version);
231
+ if (!findings)
232
+ byVersion.set(version, (findings = handler.analyze(text)));
233
+ inFlight.push([handler, version]);
234
+ return findings;
235
+ }
236
+ /** What rebases did to staged edits during the running piece of work, for the change it reports. */
237
+ const notices = [];
238
+ /** Sources the running commit has written. A disk write cannot be rolled back, so they stay committed. */
239
+ const writtenKeys = [];
240
+ const drained = () => {
241
+ const edits = notices.splice(0);
242
+ return edits.length === 0 ? {} : { edits };
243
+ };
244
+ /** Every staged edit of a source whose target is gone is conflicted: dropped, and reported. */
245
+ function conflict(key) {
246
+ const staging = staged.get(key);
247
+ if (!staging)
248
+ return;
249
+ staged.delete(key);
250
+ for (const e of staging.edits)
251
+ notices.push({ source: idFor(key), path: e.path, outcome: "conflicted", reason: "gone" });
252
+ }
253
+ /** Moves a source's staged edits onto `bytes`, its new version, finding each by its path (FR-008). */
254
+ function rebase(key, staging, bytes, version) {
255
+ const source = idFor(key);
256
+ const kept = [];
257
+ // One opening of the new version serves every edit; bytes that do not open leave none of them writable.
258
+ const opening = staging.writer.open(bytes);
259
+ if (!opening.ok) {
260
+ // A version that does not open (a file saved mid-typing) costs no staged edit: they wait against their base
261
+ // until a version that opens arrives, and rebase onto that one (FIX-001, the owner's ruling).
262
+ staging.waiting = opening.reason;
263
+ for (const e of staging.edits)
264
+ notices.push({ source, path: e.path, outcome: "waiting", detail: opening.reason });
265
+ return;
266
+ }
267
+ staging.waiting = undefined;
268
+ const doc = opening.document;
269
+ for (const e of staging.edits) {
270
+ // The shift guard (FR-013) comes first: through a list that changed length, the path may name another item.
271
+ if (staging.doc.shifted?.(e.path, doc)) {
272
+ notices.push({ source, path: e.path, outcome: "conflicted", reason: "shifted" });
273
+ continue;
274
+ }
275
+ let again;
276
+ if (e.op === "set") {
277
+ const found = doc.locate(e.path);
278
+ if (!found.ok) {
279
+ notices.push({ source, path: e.path, outcome: "conflicted", reason: "gone", detail: found.reason });
280
+ continue;
281
+ }
282
+ checkRange(bytes.length, found.start, found.end, "the writer");
283
+ // The value is written again on the new bytes: its spelling can depend on its neighbours.
284
+ again = doc.write(e.path, e.value);
285
+ if (!again.ok) {
286
+ notices.push({ source, path: e.path, outcome: "conflicted", reason: "unwritable", detail: again.reason });
287
+ continue;
288
+ }
289
+ }
290
+ else if (e.op === "remove") {
291
+ // A delete (FR-010) acts on the whole entry, which someone else's work may live inside: if the entry's
292
+ // located bytes are not the ones this edit was staged against, it is the colleague's now and is kept.
293
+ const found = doc.locate(e.path);
294
+ if (!found.ok) {
295
+ notices.push({ source, path: e.path, outcome: "conflicted", reason: "gone", detail: found.reason });
296
+ continue;
297
+ }
298
+ checkRange(bytes.length, found.start, found.end, "the writer");
299
+ if (!doc.remove) {
300
+ notices.push({ source, path: e.path, outcome: "conflicted", reason: "unwritable" });
301
+ continue;
302
+ }
303
+ again = doc.remove(e.path);
304
+ if (!again.ok) {
305
+ notices.push({ source, path: e.path, outcome: "conflicted", reason: GONE_CODES.has(again.code ?? "") ? "gone" : "unwritable", detail: again.reason });
306
+ continue;
307
+ }
308
+ checkRange(bytes.length, again.edit.start, again.edit.end, "the writer");
309
+ // What the delete cuts now must be what it cut when it was staged, attached comments included (FIX-002):
310
+ // anything a colleague put there is theirs, and the delete yields.
311
+ if (!sameBytes(bytes.subarray(again.edit.start, again.edit.end), staging.base.subarray(e.start, e.end))) {
312
+ notices.push({ source, path: e.path, outcome: "conflicted", reason: "changed" });
313
+ continue;
314
+ }
315
+ }
316
+ else {
317
+ // An insert (FR-007). A refusal's code says why (FIX-003); a writer that gives none is asked whether the key is
318
+ // there now: if so a colleague added it (`exists`), and if not the place it went into is gone.
319
+ if (!doc.add) {
320
+ notices.push({ source, path: e.path, outcome: "conflicted", reason: "unwritable" });
321
+ continue;
322
+ }
323
+ again = doc.add(e.path, e.value);
324
+ if (!again.ok) {
325
+ const code = again.code;
326
+ const reason = code === undefined ? (doc.locate(e.path).ok ? "exists" : "gone") : code === EXISTS_CODE ? "exists" : GONE_CODES.has(code) ? "gone" : "unwritable";
327
+ notices.push({ source, path: e.path, outcome: "conflicted", reason, detail: again.reason });
328
+ continue;
329
+ }
330
+ }
331
+ const { start, end } = again.edit;
332
+ checkRange(bytes.length, start, end, "the writer");
333
+ // Staging refused overlap on the old bytes; the writer's answers on the new ones can overlap.
334
+ // The earlier-staged edit keeps its place and the later one is conflicted, never spliced over it.
335
+ if (kept.some(k => overlaps(k, again.edit))) {
336
+ notices.push({ source, path: e.path, outcome: "conflicted", reason: "overlap" });
337
+ continue;
338
+ }
339
+ const now = bytes.slice(start, end);
340
+ const was = staging.base.subarray(e.start, e.end);
341
+ // Only a set replaces a value that is there; an insert has no disk value to override.
342
+ const same = e.op !== "set" || (now.length === was.length && now.every((byte, i) => byte === was[i]));
343
+ const moved = start !== e.start || end !== e.end;
344
+ const status = same ? e.status : "overrides";
345
+ const disk = same ? e.disk : now;
346
+ const rewritten = { path: e.path, bytes: again.edit.bytes, start, end, status, ...(disk === undefined ? {} : { disk }) };
347
+ kept.push(e.op === "remove" ? { ...rewritten, op: "remove" } : { ...rewritten, op: e.op, value: e.value });
348
+ if (!same)
349
+ notices.push({ source, path: e.path, outcome: "overrides", start, end, disk: now });
350
+ else if (moved)
351
+ notices.push({ source, path: e.path, outcome: "moved", start, end });
352
+ else if (!sameBytes(again.edit.bytes, e.bytes))
353
+ notices.push({ source, path: e.path, outcome: "respelled", start, end });
354
+ }
355
+ staging.edits = kept;
356
+ staging.doc = doc;
357
+ staging.base = bytes;
358
+ staging.version = version;
359
+ staging.preview = undefined;
360
+ if (kept.length === 0)
361
+ staged.delete(key);
362
+ }
363
+ /** Takes a source's new version into the session, rebasing the edits staged against the old one. */
364
+ function adopt(key, fresh) {
365
+ nodes.set(key, fresh);
366
+ const staging = staged.get(key);
367
+ if (!staging)
368
+ return;
369
+ if (fresh.state === "refused" || fresh.state === "external" || fresh.bytes === undefined)
370
+ conflict(key);
371
+ else
372
+ rebase(key, staging, fresh.bytes, fresh.version);
373
+ }
374
+ /** Reads, hashes and analyses one source. Never throws for the state of the world. */
375
+ async function load(key, external) {
376
+ const id = idFor(key);
377
+ if (external)
378
+ return { id, key, state: "external", links: [] };
379
+ const read = await host.read(key);
380
+ if (!read.ok)
381
+ return { id, key, state: "refused", reason: read.reason, links: [] };
382
+ const version = await sha256(read.bytes);
383
+ const handler = handlers.find(h => h.claims(key));
384
+ const kept = writers.some(w => w.claims(key)) ? { bytes: read.bytes } : {};
385
+ if (!handler)
386
+ return { id, key, state: "leaf", version, links: [], ...kept };
387
+ let text;
388
+ try {
389
+ text = decoder.decode(read.bytes);
390
+ }
391
+ catch {
392
+ return { id, key, state: "undecodable", version, links: [], ...kept };
393
+ }
394
+ const findings = findingsFor(handler, version, text);
395
+ const indices = findings.requests.flatMap(r => [r.start, r.end]);
396
+ const offsets = byteOffsets(text, indices);
397
+ const base = baseFor(key, findings.base);
398
+ const links = [];
399
+ for (const [k, r] of findings.requests.entries()) {
400
+ const l = { from: { start: offsets[2 * k], end: offsets[2 * k + 1] }, request: r.request, lookup: joined(r.request, findings.base), kind: r.kind, base, probes: [] };
401
+ if (await link(l))
402
+ links.push(l);
403
+ }
404
+ return { id, key, state: "analysed", version, links, ...kept };
405
+ }
406
+ /**
407
+ * Brings the session to its fixpoint: every source reachable from the roots is present, every
408
+ * other one is gone, and the host watches what matters. Returns the keys added and removed.
409
+ */
410
+ async function settle() {
411
+ const reachable = new Set();
412
+ const added = [];
413
+ const all = new Set([...roots, ...[...passRoots.values()].flatMap(keys => [...keys])]);
414
+ const worklist = [...all].map(key => ({ key, external: false }));
415
+ for (let next = worklist.shift(); next; next = worklist.shift()) {
416
+ if (reachable.has(next.key))
417
+ continue;
418
+ reachable.add(next.key);
419
+ let node = nodes.get(next.key);
420
+ if (!node) {
421
+ node = await load(next.key, next.external);
422
+ nodes.set(next.key, node);
423
+ added.push(next.key);
424
+ }
425
+ for (const l of node.links)
426
+ if (l.target)
427
+ worklist.push(l.target);
428
+ }
429
+ const removed = [...nodes.keys()].filter(key => !reachable.has(key));
430
+ for (const key of removed) {
431
+ nodes.delete(key);
432
+ conflict(key); // a source that left the graph has nowhere for its edits to land
433
+ }
434
+ const watched = new Set();
435
+ for (const node of nodes.values()) {
436
+ if (node.state !== "external")
437
+ watched.add(node.key);
438
+ for (const l of node.links)
439
+ for (const probe of l.probes)
440
+ watched.add(probe);
441
+ }
442
+ // Work already running at `close()` finishes, but must not watch again: a Node host would
443
+ // open directory watchers that keep the process alive.
444
+ if (!closed)
445
+ watch.set([...watched]);
446
+ return { added, removed };
447
+ }
448
+ /** Tells every listener, each even if another throws; the first throw is then rethrown. */
449
+ function emit(change) {
450
+ if (change.added.length + change.removed.length + change.changed.length + (change.edits?.length ?? 0) === 0)
451
+ return;
452
+ const thrown = [];
453
+ for (const listener of listeners) {
454
+ try {
455
+ listener(change);
456
+ }
457
+ catch (error) {
458
+ thrown.push(error);
459
+ }
460
+ }
461
+ if (thrown.length > 0)
462
+ throw thrown[0];
463
+ }
464
+ /** Settles, for anything that changes the roots or the world; returns the change to announce. */
465
+ async function report(changedKeys) {
466
+ const { added, removed } = await settle();
467
+ return {
468
+ added: added.map(idFor),
469
+ removed: removed.map(idFor),
470
+ changed: changedKeys.filter(key => nodes.has(key)).map(idFor),
471
+ ...drained(),
472
+ };
473
+ }
474
+ const versionOf = (node) => node === undefined ? undefined : node.state === "refused" ? `refused:${node.reason}` : "version" in node ? node.version : "external";
475
+ /** Absorbs one batch of host events (data-model.md, rules 3 and 4). */
476
+ async function absorb(locations) {
477
+ const touched = new Set(locations);
478
+ const changed = [];
479
+ for (const node of [...nodes.values()]) {
480
+ if (touched.has(node.key) && node.state !== "external") {
481
+ const fresh = await load(node.key, false);
482
+ if (versionOf(fresh) !== versionOf(node)) {
483
+ adopt(node.key, fresh);
484
+ changed.push(node.key);
485
+ }
486
+ }
487
+ }
488
+ for (const node of nodes.values()) {
489
+ for (const l of node.links)
490
+ if (l.probes.some(p => touched.has(p)))
491
+ await link(l);
492
+ }
493
+ return report(changed);
494
+ }
495
+ const edge = (node, l) => {
496
+ const target = l.target && nodes.get(l.target.key)?.id;
497
+ const version = "version" in node ? node.version : "";
498
+ return {
499
+ from: { source: node.id, version, start: l.from.start, end: l.from.end },
500
+ request: l.request,
501
+ ...(target === undefined ? {} : { target }),
502
+ kind: l.kind,
503
+ origin: "declared",
504
+ probes: l.probes,
505
+ };
506
+ };
507
+ const view = (node) => {
508
+ const { id, key: location } = node;
509
+ switch (node.state) {
510
+ case "refused":
511
+ return { id, location, state: node.state, reason: node.reason };
512
+ case "external":
513
+ return { id, location, state: node.state };
514
+ default:
515
+ return { id, location, state: node.state, version: node.version };
516
+ }
517
+ };
518
+ const allEdges = () => [...nodes.values()].flatMap(node => node.links.map(l => edge(node, l)));
519
+ const nodeById = (id) => [...nodes.values()].find(node => node.id === id);
520
+ /** Strongly connected components with more than one source, or a source asking for itself (Tarjan). */
521
+ function cycles() {
522
+ const index = new Map();
523
+ const low = new Map();
524
+ const stack = [];
525
+ const found = [];
526
+ const next = (key) => (nodes.get(key)?.links ?? []).flatMap(l => (l.target && nodes.has(l.target.key) ? [l.target.key] : []));
527
+ const visit = (key) => {
528
+ index.set(key, index.size);
529
+ low.set(key, index.get(key) ?? 0);
530
+ stack.push(key);
531
+ for (const to of next(key)) {
532
+ if (!index.has(to)) {
533
+ visit(to);
534
+ low.set(key, Math.min(low.get(key) ?? 0, low.get(to) ?? 0));
535
+ }
536
+ else if (stack.includes(to)) {
537
+ low.set(key, Math.min(low.get(key) ?? 0, index.get(to) ?? 0));
538
+ }
539
+ }
540
+ if (low.get(key) === index.get(key)) {
541
+ const component = [];
542
+ let member;
543
+ do {
544
+ member = stack.pop();
545
+ if (member !== undefined)
546
+ component.push(member);
547
+ } while (member !== undefined && member !== key);
548
+ if (component.length > 1 || next(key).includes(key))
549
+ found.push(component.map(idFor));
550
+ }
551
+ };
552
+ for (const key of nodes.keys())
553
+ if (!index.has(key))
554
+ visit(key);
555
+ return found;
556
+ }
557
+ /** Ends a pass that is still its label's live one: its keys become the label's roots (data-model.md, rule 5). */
558
+ async function endPass(label, keys, reads) {
559
+ passRoots.set(label, keys);
560
+ const changed = [];
561
+ for (const [key, version] of reads) {
562
+ const node = nodes.get(key);
563
+ if (!node || versionOf(node) === version)
564
+ continue;
565
+ const fresh = await load(key, false);
566
+ if (versionOf(fresh) !== versionOf(node)) {
567
+ adopt(key, fresh);
568
+ changed.push(key);
569
+ }
570
+ }
571
+ const { added, removed } = await settle();
572
+ // The render used other bytes than the session now holds: a change, not a new source.
573
+ const mismatched = [...reads].filter(([key, version]) => nodes.has(key) && versionOf(nodes.get(key)) !== version).map(([key]) => key);
574
+ return {
575
+ added: added.filter(key => !mismatched.includes(key)).map(idFor),
576
+ removed: removed.map(idFor),
577
+ changed: [...new Set([...changed, ...mismatched])].filter(key => nodes.has(key)).map(idFor),
578
+ ...drained(),
579
+ };
580
+ }
581
+ function read(label = "") {
582
+ const token = {};
583
+ livePasses.set(label, token);
584
+ const reads = new Map();
585
+ const notes = new Set();
586
+ const missing = new Set();
587
+ const recording = [];
588
+ let manifest;
589
+ const recordMissing = (location) => void recording.push(keyFor(location).then(key => void missing.add(key)));
590
+ const passHost = {
591
+ paths: host.paths,
592
+ async canonicalize(location) {
593
+ const found = await host.canonicalize(location);
594
+ if (found === undefined && manifest === undefined)
595
+ missing.add(host.paths.resolve(location));
596
+ return found;
597
+ },
598
+ // Hands back the host's answer at once; hashing and canonicalizing are recorded on the side.
599
+ async read(location) {
600
+ const result = await host.read(location);
601
+ if (manifest !== undefined)
602
+ return result;
603
+ if (!result.ok)
604
+ recordMissing(location);
605
+ else {
606
+ const bytes = result.bytes;
607
+ recording.push(Promise.all([keyFor(location), sha256(bytes)]).then(([key, version]) => void reads.set(key, version)));
608
+ }
609
+ return result;
610
+ },
611
+ cacheRead: async () => undefined,
612
+ cacheWrite: async () => {
613
+ throw new Error("no cache configured");
614
+ },
615
+ };
616
+ async function finish() {
617
+ await Promise.all(recording);
618
+ for (const key of [...reads.keys(), ...notes])
619
+ missing.delete(key);
620
+ const keys = new Set([...reads.keys(), ...notes, ...missing]);
621
+ if (livePasses.get(label) === token) {
622
+ livePasses.delete(label);
623
+ await serialize(() => endPass(label, keys, reads));
624
+ }
625
+ const noted = [...notes].filter(key => !reads.has(key)).map(key => ({ key, node: nodes.get(key) }));
626
+ const versioned = (node) => (node && "version" in node ? node.version : undefined);
627
+ return {
628
+ read: [
629
+ ...[...reads].map(([key, version]) => ({ source: idFor(key), version })),
630
+ ...noted.flatMap(({ key, node }) => {
631
+ const version = versioned(node);
632
+ return version === undefined ? [] : [{ source: idFor(key), version }];
633
+ }),
634
+ ],
635
+ missing: [...missing, ...noted.filter(({ node }) => versioned(node) === undefined).map(({ key }) => key)],
636
+ };
637
+ }
638
+ return {
639
+ host: passHost,
640
+ note(location) {
641
+ if (manifest === undefined)
642
+ recording.push(keyFor(location).then(key => void notes.add(key)));
643
+ },
644
+ end: () => (manifest ??= finish()),
645
+ };
646
+ }
647
+ async function stageEdit(location, operation) {
648
+ const { path } = operation;
649
+ const key = await keyFor(location);
650
+ const node = nodes.get(key);
651
+ if (!node)
652
+ return { ok: false, reason: "not a source in the session's graph" };
653
+ if (node.state === "external")
654
+ return { ok: false, reason: `${key} is external, and cannot be edited` };
655
+ if (node.state === "refused")
656
+ return { ok: false, reason: `${key} could not be read: ${node.reason}` };
657
+ const writer = writers.find(w => w.claims(key));
658
+ if (!writer)
659
+ return { ok: false, reason: `no writer claims ${key}` };
660
+ let staging = staged.get(key);
661
+ // The base the session holds: the bytes of the version it last took in. A disk that has moved
662
+ // since is the rebase's business, when the host reports it.
663
+ const base = staging?.base ?? ("bytes" in node ? node.bytes : undefined);
664
+ if (!base)
665
+ return { ok: false, reason: `${key} was not read` };
666
+ let doc = staging?.doc;
667
+ if (!doc) {
668
+ const opening = writer.open(base);
669
+ if (!opening.ok)
670
+ return { ok: false, reason: opening.reason };
671
+ doc = opening.document;
672
+ }
673
+ const others = staging?.edits ?? [];
674
+ const same = others.find(e => e.path === path);
675
+ const inside = doc.inside;
676
+ // What lies inside what is the writer's to say: its path language, its reader (ruling 8). At most one staged
677
+ // edit can contain a path, since an edit inside another amends it instead of joining the index.
678
+ const within = inside && others.find(e => e.path !== path && inside.call(doc, path, e.path));
679
+ /** Stages the edit again with a new value, in the place it held; the writer's refusal is the caller's. */
680
+ const restage = (edit, value) => {
681
+ const again = perform(doc, key, { op: edit.op, path: edit.path, value });
682
+ if (!again.ok)
683
+ return { ok: false, reason: again.reason };
684
+ const { start, end, bytes } = again.edit;
685
+ checkRange(base.length, start, end, "the writer");
686
+ const clash = others.find(e => e !== edit && overlaps(e, again.edit));
687
+ if (clash)
688
+ return { ok: false, reason: `the edit overlaps the staged edit at ${clash.path}` };
689
+ if (staging) {
690
+ staging.edits = staging.edits.map(e => (e === edit ? { ...edit, value, start, end, bytes } : e));
691
+ staging.preview = undefined;
692
+ }
693
+ return { ok: true };
694
+ };
695
+ if (within) {
696
+ // An edit inside a staged edit (FR-011): inside a delete it is refused; inside a set or an insert it
697
+ // amends the staged value, which the writer then writes again, so the bytes stay the writer's.
698
+ if (within.op === "remove")
699
+ return { ok: false, reason: `the entry at ${within.path} is staged for removal` };
700
+ if (!doc.amend)
701
+ return { ok: false, reason: `the writer for ${key} cannot amend the staged edit at ${within.path}: its document has no amend` };
702
+ const amended = doc.amend({ path: within.path, value: within.value }, operation);
703
+ // The writer said the path is inside; an amend that says otherwise is the writer contradicting itself, a bug (FIX-005).
704
+ if (!amended.inside)
705
+ throw new Error(`the writer for ${key} says ${operation.path} is inside the staged edit at ${within.path}, and its amend says it is not`);
706
+ return amended.ok ? restage(within, amended.value) : { ok: false, reason: amended.reason };
707
+ }
708
+ if (staging && same?.op === "add") {
709
+ // On a staged insert's own path: a set or an add changes the insert wholesale, a remove undoes it.
710
+ if (operation.op !== "remove")
711
+ return restage(same, operation.value);
712
+ staging.edits = staging.edits.filter(e => e !== same);
713
+ staging.preview = undefined;
714
+ if (staging.edits.length === 0)
715
+ staged.delete(key);
716
+ return { ok: true };
717
+ }
718
+ // On a staged delete's path, a set or an add is a set of the entry, which is in the base; otherwise the
719
+ // operation is staged as it came, and a staged edit at its path (a set, a delete) is replaced.
720
+ const effective = same?.op === "remove" && operation.op !== "remove" ? { op: "set", path, value: operation.value } : operation;
721
+ const written = perform(doc, key, effective);
722
+ if (!written.ok) {
723
+ const blind = !doc.inside && others.some(e => e.op === "add");
724
+ return { ok: false, reason: blind ? `${written.reason}; the writer's document has no inside, so it cannot say whether the path lies inside a staged insert` : written.reason };
725
+ }
726
+ const { start, end, bytes } = written.edit;
727
+ checkRange(base.length, start, end, "the writer");
728
+ const clash = others.find(e => e.path !== path && overlaps(e, written.edit));
729
+ if (clash)
730
+ return { ok: false, reason: `the edit overlaps the staged edit at ${clash.path}` };
731
+ if (!staging)
732
+ staged.set(key, (staging = { writer, version: node.version, base, doc, edits: [] }));
733
+ const edit = { path, start, end, bytes, status: "staged", ...(effective.op === "remove" ? { op: "remove" } : { op: effective.op, value: effective.value }) };
734
+ // Replaced, the edit goes last: the list is in the order edits were staged, which a rebase reads.
735
+ staging.edits = [...staging.edits.filter(e => e.path !== path), edit];
736
+ staging.preview = undefined;
737
+ return { ok: true };
738
+ }
739
+ /** Whether a commit may write here: under a writable root, judged on whole segments of canonical locations. */
740
+ async function isWritable(key) {
741
+ const { separator, resolve } = host.paths;
742
+ for (const root of writable) {
743
+ const canonical = (await host.canonicalize(root)) ?? resolve(root);
744
+ const prefix = canonical.endsWith(separator) ? canonical : canonical + separator;
745
+ if (key === canonical || key.startsWith(prefix))
746
+ return true;
747
+ }
748
+ return false;
749
+ }
750
+ /** Commits one source's staged edits (FR-009). The edits leave the index only when written or dropped. */
751
+ async function commitSource(key, staging, changed) {
752
+ const source = idFor(key);
753
+ const nothing = (outcome, reason) => ({ source, location: key, outcome, reason, edits: [] });
754
+ if (!(await isWritable(key)))
755
+ return nothing("refused", `${key} is outside every writable root`);
756
+ if (host.write === undefined)
757
+ return nothing("refused", "the host cannot write");
758
+ const read = await host.read(key);
759
+ if (!read.ok)
760
+ return nothing("failed", read.reason);
761
+ const first = notices.length;
762
+ const version = await sha256(read.bytes);
763
+ if (version !== staging.version)
764
+ rebase(key, staging, read.bytes, version);
765
+ if (staging.waiting !== undefined && staged.get(key) === staging)
766
+ return nothing("refused", `the file on disk does not open: ${staging.waiting}`);
767
+ const dropped = notices.slice(first).filter(n => n.outcome === "conflicted" && n.source === source);
768
+ const edits = dropped.map(n => ({ path: n.path, outcome: "conflicted", ...(n.reason === undefined ? {} : { reason: n.reason }), ...(n.detail === undefined ? {} : { detail: n.detail }) }));
769
+ const remaining = staged.get(key);
770
+ if (!remaining)
771
+ return { source, location: key, outcome: "conflicted", edits };
772
+ const bytes = splice(remaining.base, remaining.edits);
773
+ const written = await host.write(key, bytes);
774
+ if (!written.ok)
775
+ return nothing("failed", written.reason);
776
+ for (const e of remaining.edits)
777
+ edits.push(e.status === "overrides" && e.disk ? { path: e.path, outcome: "overrode", disk: e.disk } : { path: e.path, outcome: "written" });
778
+ staged.delete(key);
779
+ writtenKeys.push(key);
780
+ const fresh = await load(key, false);
781
+ if (versionOf(fresh) !== versionOf(nodes.get(key)))
782
+ changed.push(key);
783
+ nodes.set(key, fresh);
784
+ return { source, location: key, outcome: "written", version: await sha256(bytes), edits };
785
+ }
786
+ const session = {
787
+ read,
788
+ sources: () => [...nodes.values()].map(view),
789
+ source: id => {
790
+ const node = nodeById(id);
791
+ return node && view(node);
792
+ },
793
+ edgesFrom: id => allEdges().filter(e => e.from.source === id),
794
+ edgesInto: id => allEdges().filter(e => e.target === id),
795
+ unresolved: () => allEdges().filter(e => e.target === undefined),
796
+ cycles,
797
+ addRoot: location => serialize(async () => {
798
+ roots.add(await keyFor(location));
799
+ return report([]);
800
+ }),
801
+ removeRoot: location => serialize(async () => {
802
+ roots.delete(await keyFor(location));
803
+ return report([]);
804
+ }),
805
+ onChange(listener) {
806
+ listeners.add(listener);
807
+ return () => void listeners.delete(listener);
808
+ },
809
+ stage(location, operation) {
810
+ let result = { ok: false, reason: "the session is closed" };
811
+ return serialize(async () => void (result = await stageEdit(location, operation))).then(() => result);
812
+ },
813
+ index: () => [...staged].flatMap(([key, staging]) => staging.edits.map((e) => ({
814
+ source: idFor(key),
815
+ path: e.path,
816
+ op: e.op,
817
+ ...(e.value === undefined ? {} : { value: e.value }),
818
+ base: staging.version,
819
+ start: e.start,
820
+ end: e.end,
821
+ status: e.status,
822
+ ...(e.disk === undefined ? {} : { disk: e.disk }),
823
+ ...(staging.waiting === undefined ? {} : { waiting: staging.waiting }),
824
+ }))),
825
+ unstage: (location, path) => serialize(async () => {
826
+ const key = await keyFor(location);
827
+ const staging = staged.get(key);
828
+ if (!staging)
829
+ return;
830
+ if (path === undefined)
831
+ staged.delete(key);
832
+ else {
833
+ staging.edits = staging.edits.filter(e => e.path !== path);
834
+ staging.preview = undefined;
835
+ if (staging.edits.length === 0)
836
+ staged.delete(key);
837
+ }
838
+ }),
839
+ preview(id) {
840
+ let result;
841
+ return serialize(async () => {
842
+ const node = nodeById(id);
843
+ const staging = node && staged.get(node.key);
844
+ if (!node || !staging)
845
+ return;
846
+ const bytes = splice(staging.base, staging.edits);
847
+ const version = await sha256(bytes);
848
+ const handler = handlers.find(h => h.claims(node.key));
849
+ let text;
850
+ try {
851
+ text = decoder.decode(bytes);
852
+ }
853
+ catch {
854
+ text = undefined;
855
+ }
856
+ if (handler && text !== undefined) {
857
+ findingsFor(handler, version, text);
858
+ staging.preview = version;
859
+ }
860
+ result = { bytes, version };
861
+ }).then(() => result);
862
+ },
863
+ commit() {
864
+ let result = { sources: [] };
865
+ return serialize(async () => {
866
+ const changed = [];
867
+ const sources = [];
868
+ for (const [key, staging] of [...staged])
869
+ sources.push(await commitSource(key, staging, changed));
870
+ result = { sources };
871
+ return report(changed);
872
+ }).then(() => result);
873
+ },
874
+ close() {
875
+ closed = true;
876
+ watch.close();
877
+ listeners.clear();
878
+ open.delete(holds);
879
+ sweep(handlers);
880
+ },
881
+ };
882
+ try {
883
+ await serialize(async () => {
884
+ roots.add(await keyFor(options.entry));
885
+ await settle();
886
+ });
887
+ }
888
+ catch (error) {
889
+ session.close(); // nobody can close a session that never opened, and it would hold its finds forever
890
+ throw error;
891
+ }
892
+ return session;
893
+ }