@egildo/provenance 0.0.0-stage → 0.7.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/LICENSE +21 -0
- package/README.md +173 -2
- package/dist/css.d.ts +2 -0
- package/dist/css.js +77 -0
- package/dist/html.d.ts +2 -0
- package/dist/html.js +128 -0
- package/dist/index.d.ts +397 -0
- package/dist/index.js +3 -0
- package/dist/memory.d.ts +6 -0
- package/dist/memory.js +83 -0
- package/dist/node.d.ts +4 -0
- package/dist/node.js +110 -0
- package/dist/offsets.d.ts +6 -0
- package/dist/offsets.js +43 -0
- package/dist/resolvers.d.ts +4 -0
- package/dist/resolvers.js +42 -0
- package/dist/session.d.ts +11 -0
- package/dist/session.js +1057 -0
- package/package.json +59 -4
package/dist/session.js
ADDED
|
@@ -0,0 +1,1057 @@
|
|
|
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
|
+
/** Whether the source at `location` can have edits staged: in the graph, readable, and claimed by a writer. */
|
|
648
|
+
async function stageable(location) {
|
|
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
|
+
return { ok: true, key, node, writer };
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Stages one edit. `against` is a restore's (saved-index FR-006): the base to stage a source's first edit against
|
|
664
|
+
* in place of the one the session holds; a source already being staged keeps the base it has.
|
|
665
|
+
*/
|
|
666
|
+
async function stageEdit(location, operation, against) {
|
|
667
|
+
const { path } = operation;
|
|
668
|
+
const found = await stageable(location);
|
|
669
|
+
if (!found.ok)
|
|
670
|
+
return found;
|
|
671
|
+
const { key, node, writer } = found;
|
|
672
|
+
let staging = staged.get(key);
|
|
673
|
+
// The base the session holds: the bytes of the version it last took in. A disk that has moved
|
|
674
|
+
// since is the rebase's business, when the host reports it.
|
|
675
|
+
const base = staging?.base ?? against?.bytes ?? ("bytes" in node ? node.bytes : undefined);
|
|
676
|
+
if (!base)
|
|
677
|
+
return { ok: false, reason: `${key} was not read` };
|
|
678
|
+
let doc = staging?.doc;
|
|
679
|
+
if (!doc) {
|
|
680
|
+
const opening = writer.open(base);
|
|
681
|
+
if (!opening.ok)
|
|
682
|
+
return { ok: false, reason: opening.reason };
|
|
683
|
+
doc = opening.document;
|
|
684
|
+
}
|
|
685
|
+
const others = staging?.edits ?? [];
|
|
686
|
+
const same = others.find(e => e.path === path);
|
|
687
|
+
const inside = doc.inside;
|
|
688
|
+
// What lies inside what is the writer's to say: its path language, its reader (ruling 8). At most one staged
|
|
689
|
+
// edit can contain a path, since an edit inside another amends it instead of joining the index.
|
|
690
|
+
const within = inside && others.find(e => e.path !== path && inside.call(doc, path, e.path));
|
|
691
|
+
/** Stages the edit again with a new value, in the place it held; the writer's refusal is the caller's. */
|
|
692
|
+
const restage = (edit, value) => {
|
|
693
|
+
const again = perform(doc, key, { op: edit.op, path: edit.path, value });
|
|
694
|
+
if (!again.ok)
|
|
695
|
+
return { ok: false, reason: again.reason };
|
|
696
|
+
const { start, end, bytes } = again.edit;
|
|
697
|
+
checkRange(base.length, start, end, "the writer");
|
|
698
|
+
const clash = others.find(e => e !== edit && overlaps(e, again.edit));
|
|
699
|
+
if (clash)
|
|
700
|
+
return { ok: false, reason: `the edit overlaps the staged edit at ${clash.path}` };
|
|
701
|
+
if (staging) {
|
|
702
|
+
staging.edits = staging.edits.map(e => (e === edit ? { ...edit, value, start, end, bytes } : e));
|
|
703
|
+
staging.preview = undefined;
|
|
704
|
+
}
|
|
705
|
+
return { ok: true };
|
|
706
|
+
};
|
|
707
|
+
if (within) {
|
|
708
|
+
// An edit inside a staged edit (FR-011): inside a delete it is refused; inside a set or an insert it
|
|
709
|
+
// amends the staged value, which the writer then writes again, so the bytes stay the writer's.
|
|
710
|
+
if (within.op === "remove")
|
|
711
|
+
return { ok: false, reason: `the entry at ${within.path} is staged for removal` };
|
|
712
|
+
if (!doc.amend)
|
|
713
|
+
return { ok: false, reason: `the writer for ${key} cannot amend the staged edit at ${within.path}: its document has no amend` };
|
|
714
|
+
const amended = doc.amend({ path: within.path, value: within.value }, operation);
|
|
715
|
+
// The writer said the path is inside; an amend that says otherwise is the writer contradicting itself, a bug (FIX-005).
|
|
716
|
+
if (!amended.inside)
|
|
717
|
+
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`);
|
|
718
|
+
return amended.ok ? restage(within, amended.value) : { ok: false, reason: amended.reason };
|
|
719
|
+
}
|
|
720
|
+
if (staging && same?.op === "add") {
|
|
721
|
+
// On a staged insert's own path: a set or an add changes the insert wholesale, a remove undoes it.
|
|
722
|
+
if (operation.op !== "remove")
|
|
723
|
+
return restage(same, operation.value);
|
|
724
|
+
staging.edits = staging.edits.filter(e => e !== same);
|
|
725
|
+
staging.preview = undefined;
|
|
726
|
+
if (staging.edits.length === 0)
|
|
727
|
+
staged.delete(key);
|
|
728
|
+
return { ok: true };
|
|
729
|
+
}
|
|
730
|
+
// On a staged delete's path, a set or an add is a set of the entry, which is in the base; otherwise the
|
|
731
|
+
// operation is staged as it came, and a staged edit at its path (a set, a delete) is replaced.
|
|
732
|
+
const effective = same?.op === "remove" && operation.op !== "remove" ? { op: "set", path, value: operation.value } : operation;
|
|
733
|
+
const written = perform(doc, key, effective);
|
|
734
|
+
if (!written.ok) {
|
|
735
|
+
const blind = !doc.inside && others.some(e => e.op === "add");
|
|
736
|
+
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 };
|
|
737
|
+
}
|
|
738
|
+
const { start, end, bytes } = written.edit;
|
|
739
|
+
checkRange(base.length, start, end, "the writer");
|
|
740
|
+
const clash = others.find(e => e.path !== path && overlaps(e, written.edit));
|
|
741
|
+
if (clash)
|
|
742
|
+
return { ok: false, reason: `the edit overlaps the staged edit at ${clash.path}` };
|
|
743
|
+
if (!staging)
|
|
744
|
+
staged.set(key, (staging = { writer, version: against?.version ?? node.version, base, doc, edits: [] }));
|
|
745
|
+
const edit = { path, start, end, bytes, status: "staged", ...(effective.op === "remove" ? { op: "remove" } : { op: effective.op, value: effective.value }) };
|
|
746
|
+
// Replaced, the edit goes last: the list is in the order edits were staged, which a rebase reads.
|
|
747
|
+
staging.edits = [...staging.edits.filter(e => e.path !== path), edit];
|
|
748
|
+
staging.preview = undefined;
|
|
749
|
+
return { ok: true };
|
|
750
|
+
}
|
|
751
|
+
/** Stages one saved source again against its saved base, then rebases it onto the version the session holds (saved-index FR-006). */
|
|
752
|
+
async function restoreSource(entry) {
|
|
753
|
+
const refused = (reason) => ({ location: entry.location, outcome: "refused", reason, edits: [] });
|
|
754
|
+
const found = await stageable(entry.location);
|
|
755
|
+
if (!found.ok)
|
|
756
|
+
return refused(found.reason);
|
|
757
|
+
const { key, node } = found;
|
|
758
|
+
if (staged.has(key))
|
|
759
|
+
return refused(`${key} already has staged edits`);
|
|
760
|
+
const held = "bytes" in node ? node.bytes : undefined;
|
|
761
|
+
if (!held)
|
|
762
|
+
return refused(`${key} was not read`);
|
|
763
|
+
if ((await sha256(entry.bytes)) !== entry.base)
|
|
764
|
+
return refused("the saved bytes do not hash to the saved base version");
|
|
765
|
+
const against = { version: entry.base, bytes: entry.bytes.slice() };
|
|
766
|
+
const edits = [];
|
|
767
|
+
for (const saved of entry.edits) {
|
|
768
|
+
const operation = saved.op === "remove" ? { op: "remove", path: saved.path } : saved.value === undefined ? undefined : { op: saved.op, path: saved.path, value: saved.value };
|
|
769
|
+
const result = operation === undefined ? { ok: false, reason: `a ${saved.op} needs a value` } : await stageEdit(entry.location, operation, against);
|
|
770
|
+
if (!result.ok) {
|
|
771
|
+
edits.push({ path: saved.path, outcome: "refused", reason: result.reason });
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
edits.push({ path: saved.path, outcome: "restored" });
|
|
775
|
+
// Its status is part of what was saved (FR-003): staging made it plain `staged`.
|
|
776
|
+
const put = staged.get(key)?.edits.find(e => e.path === saved.path);
|
|
777
|
+
if (put) {
|
|
778
|
+
put.status = saved.status;
|
|
779
|
+
if (saved.disk !== undefined)
|
|
780
|
+
put.disk = saved.disk.slice();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const staging = staged.get(key);
|
|
784
|
+
if (!staging)
|
|
785
|
+
return { location: entry.location, outcome: "refused", reason: "none of its edits could be restored", edits };
|
|
786
|
+
// The session's version is not the saved base: the usual rebase, which says what it did through the notices.
|
|
787
|
+
if (node.version !== entry.base)
|
|
788
|
+
rebase(key, staging, held, node.version);
|
|
789
|
+
return { location: entry.location, outcome: "restored", edits };
|
|
790
|
+
}
|
|
791
|
+
/** Whether a commit may write here: under a writable root, judged on whole segments of canonical locations. */
|
|
792
|
+
async function isWritable(key) {
|
|
793
|
+
const { separator, resolve } = host.paths;
|
|
794
|
+
for (const root of writable) {
|
|
795
|
+
const canonical = (await host.canonicalize(root)) ?? resolve(root);
|
|
796
|
+
const prefix = canonical.endsWith(separator) ? canonical : canonical + separator;
|
|
797
|
+
if (key === canonical || key.startsWith(prefix))
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Prepares one source for a commit (atomic-commit FR-001): the checks, the read, the rebase, and the bytes to write.
|
|
804
|
+
* Touches no disk. A source that cannot be written, or that lost an edit, comes back as the report entry that
|
|
805
|
+
* says so (`cause`); it stops the commit.
|
|
806
|
+
*/
|
|
807
|
+
async function prepare(key, staging) {
|
|
808
|
+
const source = idFor(key);
|
|
809
|
+
const nothing = (outcome, reason) => ({ cause: { source, location: key, outcome, reason, edits: [] } });
|
|
810
|
+
if (!(await isWritable(key)))
|
|
811
|
+
return nothing("refused", `${key} is outside every writable root`);
|
|
812
|
+
if (host.write === undefined)
|
|
813
|
+
return nothing("refused", "the host cannot write");
|
|
814
|
+
const read = await host.read(key);
|
|
815
|
+
if (!read.ok)
|
|
816
|
+
return nothing("failed", read.reason);
|
|
817
|
+
const first = notices.length;
|
|
818
|
+
const version = await sha256(read.bytes);
|
|
819
|
+
if (version !== staging.version)
|
|
820
|
+
rebase(key, staging, read.bytes, version);
|
|
821
|
+
if (staging.waiting !== undefined && staged.get(key) === staging)
|
|
822
|
+
return nothing("refused", `the file on disk does not open: ${staging.waiting}`);
|
|
823
|
+
const dropped = notices.slice(first).filter(n => n.outcome === "conflicted" && n.source === source);
|
|
824
|
+
const remaining = staged.get(key);
|
|
825
|
+
if (dropped.length > 0 || !remaining) {
|
|
826
|
+
const edits = dropped.map(n => ({ path: n.path, outcome: "conflicted", ...(n.reason === undefined ? {} : { reason: n.reason }), ...(n.detail === undefined ? {} : { detail: n.detail }) }));
|
|
827
|
+
return { cause: { source, location: key, outcome: "conflicted", edits } };
|
|
828
|
+
}
|
|
829
|
+
const bytes = splice(remaining.base, remaining.edits);
|
|
830
|
+
// Analysed now, before any write (FR-012, ruled): a handler that throws on the new text stops the commit with
|
|
831
|
+
// nothing on disk. The findings are held for this piece of work, so the load after the write hits the cache.
|
|
832
|
+
const handler = handlers.find(h => h.claims(key));
|
|
833
|
+
if (handler) {
|
|
834
|
+
let text;
|
|
835
|
+
try {
|
|
836
|
+
text = decoder.decode(bytes);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
text = undefined;
|
|
840
|
+
}
|
|
841
|
+
if (text !== undefined)
|
|
842
|
+
findingsFor(handler, await sha256(bytes), text);
|
|
843
|
+
}
|
|
844
|
+
const edits = remaining.edits.map((e) => (e.status === "overrides" && e.disk ? { path: e.path, outcome: "overrode", disk: e.disk } : { path: e.path, outcome: "written" }));
|
|
845
|
+
return { key, source, bytes, disk: read.bytes, version: await sha256(bytes), edits };
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Commits a set of staged sources, all or none (atomic-commit): prepare every one, and only if none is a cause write
|
|
849
|
+
* them in order; a failed write puts the written ones back, newest first. `commit()` passes the whole index;
|
|
850
|
+
* a group would pass its sources.
|
|
851
|
+
*/
|
|
852
|
+
async function commitSources(entries, changed) {
|
|
853
|
+
const put = (key, bytes) => (host.write ? host.write(key, bytes) : Promise.resolve({ ok: false, reason: "the host cannot write" }));
|
|
854
|
+
const prepared = [];
|
|
855
|
+
for (const [key, staging] of entries)
|
|
856
|
+
prepared.push(await prepare(key, staging));
|
|
857
|
+
let stopper = prepared.flatMap(p => ("cause" in p ? [p.cause.location] : []))[0];
|
|
858
|
+
const wrote = [];
|
|
859
|
+
const fresh = new Map();
|
|
860
|
+
let failed;
|
|
861
|
+
if (stopper === undefined) {
|
|
862
|
+
for (const p of prepared) {
|
|
863
|
+
if ("cause" in p)
|
|
864
|
+
continue;
|
|
865
|
+
const written = await put(p.key, p.bytes);
|
|
866
|
+
if (!written.ok) {
|
|
867
|
+
failed = { source: p.source, location: p.key, outcome: "failed", reason: written.reason, edits: [] };
|
|
868
|
+
stopper = p.key;
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
writtenKeys.push(p.key);
|
|
872
|
+
wrote.push(p);
|
|
873
|
+
// Analysed before the next write: a handler that throws on the new text stops the commit here, as it always did.
|
|
874
|
+
fresh.set(p.key, await load(p.key, false));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
// Undo (FR-004): the host's own write, after checking the disk still holds what this commit wrote (ruling 6).
|
|
878
|
+
const unrestored = new Map();
|
|
879
|
+
if (failed) {
|
|
880
|
+
for (const p of wrote.reverse()) {
|
|
881
|
+
const was = await host.read(p.key);
|
|
882
|
+
let why;
|
|
883
|
+
if (!was.ok)
|
|
884
|
+
why = `putting ${p.key} back failed: could not read it: ${was.reason}`;
|
|
885
|
+
else if ((await sha256(was.bytes)) !== p.version)
|
|
886
|
+
why = `${p.key} was saved by someone else after this commit wrote it; left as it is`;
|
|
887
|
+
else {
|
|
888
|
+
// ponytail: a colleague can still save between the read above and this write; closing it needs a host verb that writes only if unchanged (parked).
|
|
889
|
+
const back = await put(p.key, p.disk);
|
|
890
|
+
if (!back.ok)
|
|
891
|
+
why = `putting ${p.key} back failed: ${back.reason}`;
|
|
892
|
+
}
|
|
893
|
+
if (why === undefined)
|
|
894
|
+
writtenKeys.splice(writtenKeys.indexOf(p.key), 1);
|
|
895
|
+
else
|
|
896
|
+
unrestored.set(p.key, why);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
const sources = [];
|
|
900
|
+
for (const p of prepared) {
|
|
901
|
+
if ("cause" in p)
|
|
902
|
+
sources.push(p.cause);
|
|
903
|
+
else if (failed && failed.location === p.key)
|
|
904
|
+
sources.push(failed);
|
|
905
|
+
else if (unrestored.has(p.key))
|
|
906
|
+
sources.push({ source: p.source, location: p.key, outcome: "unrestored", reason: unrestored.get(p.key), version: p.version, edits: p.edits });
|
|
907
|
+
else if (stopper !== undefined)
|
|
908
|
+
sources.push({ source: p.source, location: p.key, outcome: "aborted", reason: `the commit was aborted by ${stopper}`, edits: [] });
|
|
909
|
+
else
|
|
910
|
+
sources.push({ source: p.source, location: p.key, outcome: "written", version: p.version, edits: p.edits });
|
|
911
|
+
}
|
|
912
|
+
// What is on disk now leaves the index and the node catches up (a source put back keeps its edits and its node).
|
|
913
|
+
for (const p of prepared) {
|
|
914
|
+
if ("cause" in p || !writtenKeys.includes(p.key))
|
|
915
|
+
continue;
|
|
916
|
+
staged.delete(p.key);
|
|
917
|
+
const node = fresh.get(p.key);
|
|
918
|
+
if (!node)
|
|
919
|
+
continue;
|
|
920
|
+
if (versionOf(node) !== versionOf(nodes.get(p.key)))
|
|
921
|
+
changed.push(p.key);
|
|
922
|
+
nodes.set(p.key, node);
|
|
923
|
+
}
|
|
924
|
+
return { outcome: stopper === undefined ? "committed" : "aborted", sources };
|
|
925
|
+
}
|
|
926
|
+
const session = {
|
|
927
|
+
read,
|
|
928
|
+
sources: () => [...nodes.values()].map(view),
|
|
929
|
+
source: id => {
|
|
930
|
+
const node = nodeById(id);
|
|
931
|
+
return node && view(node);
|
|
932
|
+
},
|
|
933
|
+
edgesFrom: id => allEdges().filter(e => e.from.source === id),
|
|
934
|
+
edgesInto: id => allEdges().filter(e => e.target === id),
|
|
935
|
+
unresolved: () => allEdges().filter(e => e.target === undefined),
|
|
936
|
+
cycles,
|
|
937
|
+
addRoot: location => serialize(async () => {
|
|
938
|
+
roots.add(await keyFor(location));
|
|
939
|
+
return report([]);
|
|
940
|
+
}),
|
|
941
|
+
removeRoot: location => serialize(async () => {
|
|
942
|
+
roots.delete(await keyFor(location));
|
|
943
|
+
return report([]);
|
|
944
|
+
}),
|
|
945
|
+
onChange(listener) {
|
|
946
|
+
listeners.add(listener);
|
|
947
|
+
return () => void listeners.delete(listener);
|
|
948
|
+
},
|
|
949
|
+
stage(location, operation) {
|
|
950
|
+
let result = { ok: false, reason: "the session is closed" };
|
|
951
|
+
return serialize(async () => void (result = await stageEdit(location, operation))).then(() => result);
|
|
952
|
+
},
|
|
953
|
+
index: () => [...staged].flatMap(([key, staging]) => staging.edits.map((e) => ({
|
|
954
|
+
source: idFor(key),
|
|
955
|
+
path: e.path,
|
|
956
|
+
op: e.op,
|
|
957
|
+
...(e.value === undefined ? {} : { value: e.value }),
|
|
958
|
+
base: staging.version,
|
|
959
|
+
start: e.start,
|
|
960
|
+
end: e.end,
|
|
961
|
+
status: e.status,
|
|
962
|
+
...(e.disk === undefined ? {} : { disk: e.disk }),
|
|
963
|
+
...(staging.waiting === undefined ? {} : { waiting: staging.waiting }),
|
|
964
|
+
}))),
|
|
965
|
+
save: () => ({
|
|
966
|
+
format: 1,
|
|
967
|
+
sources: [...staged].map(([key, staging]) => ({
|
|
968
|
+
location: key,
|
|
969
|
+
base: staging.version,
|
|
970
|
+
bytes: staging.base.slice(),
|
|
971
|
+
edits: staging.edits.map(e => ({
|
|
972
|
+
op: e.op,
|
|
973
|
+
path: e.path,
|
|
974
|
+
...(e.value === undefined ? {} : { value: e.value }),
|
|
975
|
+
status: e.status,
|
|
976
|
+
...(e.disk === undefined ? {} : { disk: e.disk.slice() }),
|
|
977
|
+
})),
|
|
978
|
+
})),
|
|
979
|
+
}),
|
|
980
|
+
restore(saved) {
|
|
981
|
+
let result = { outcome: "refused", reason: "the session is closed", sources: [] };
|
|
982
|
+
if (saved.format !== 1)
|
|
983
|
+
return Promise.resolve({ outcome: "refused", reason: `the saved index has format ${String(saved.format)}, and only format 1 is known`, sources: [] });
|
|
984
|
+
return serialize(async () => {
|
|
985
|
+
const sources = [];
|
|
986
|
+
for (const entry of saved.sources)
|
|
987
|
+
sources.push(await restoreSource(entry));
|
|
988
|
+
result = { outcome: "restored", sources };
|
|
989
|
+
return report([]);
|
|
990
|
+
}).then(() => result);
|
|
991
|
+
},
|
|
992
|
+
unstage: (location, path) => serialize(async () => {
|
|
993
|
+
const key = await keyFor(location);
|
|
994
|
+
const staging = staged.get(key);
|
|
995
|
+
if (!staging)
|
|
996
|
+
return;
|
|
997
|
+
if (path === undefined)
|
|
998
|
+
staged.delete(key);
|
|
999
|
+
else {
|
|
1000
|
+
staging.edits = staging.edits.filter(e => e.path !== path);
|
|
1001
|
+
staging.preview = undefined;
|
|
1002
|
+
if (staging.edits.length === 0)
|
|
1003
|
+
staged.delete(key);
|
|
1004
|
+
}
|
|
1005
|
+
}),
|
|
1006
|
+
preview(id) {
|
|
1007
|
+
let result;
|
|
1008
|
+
return serialize(async () => {
|
|
1009
|
+
const node = nodeById(id);
|
|
1010
|
+
const staging = node && staged.get(node.key);
|
|
1011
|
+
if (!node || !staging)
|
|
1012
|
+
return;
|
|
1013
|
+
const bytes = splice(staging.base, staging.edits);
|
|
1014
|
+
const version = await sha256(bytes);
|
|
1015
|
+
const handler = handlers.find(h => h.claims(node.key));
|
|
1016
|
+
let text;
|
|
1017
|
+
try {
|
|
1018
|
+
text = decoder.decode(bytes);
|
|
1019
|
+
}
|
|
1020
|
+
catch {
|
|
1021
|
+
text = undefined;
|
|
1022
|
+
}
|
|
1023
|
+
if (handler && text !== undefined) {
|
|
1024
|
+
findingsFor(handler, version, text);
|
|
1025
|
+
staging.preview = version;
|
|
1026
|
+
}
|
|
1027
|
+
result = { bytes, version };
|
|
1028
|
+
}).then(() => result);
|
|
1029
|
+
},
|
|
1030
|
+
commit() {
|
|
1031
|
+
let result = { outcome: "committed", sources: [] };
|
|
1032
|
+
return serialize(async () => {
|
|
1033
|
+
const changed = [];
|
|
1034
|
+
result = await commitSources([...staged], changed);
|
|
1035
|
+
return report(changed);
|
|
1036
|
+
}).then(() => result);
|
|
1037
|
+
},
|
|
1038
|
+
close() {
|
|
1039
|
+
closed = true;
|
|
1040
|
+
watch.close();
|
|
1041
|
+
listeners.clear();
|
|
1042
|
+
open.delete(holds);
|
|
1043
|
+
sweep(handlers);
|
|
1044
|
+
},
|
|
1045
|
+
};
|
|
1046
|
+
try {
|
|
1047
|
+
await serialize(async () => {
|
|
1048
|
+
roots.add(await keyFor(options.entry));
|
|
1049
|
+
await settle();
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
catch (error) {
|
|
1053
|
+
session.close(); // nobody can close a session that never opened, and it would hold its finds forever
|
|
1054
|
+
throw error;
|
|
1055
|
+
}
|
|
1056
|
+
return session;
|
|
1057
|
+
}
|