@appsoftwareltd/etherpk-mcp 0.8.54 → 0.8.60
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/dist/main.js +780 -141
- package/dist/main.js.map +1 -1
- package/package.json +3 -3
package/dist/main.js
CHANGED
|
@@ -36,7 +36,7 @@ import { UndirectedGraph } from "graphology";
|
|
|
36
36
|
import louvain from "graphology-communities-louvain";
|
|
37
37
|
var package_default = {
|
|
38
38
|
name: "@appsoftwareltd/etherpk-mcp",
|
|
39
|
-
version: "0.8.
|
|
39
|
+
version: "0.8.60",
|
|
40
40
|
license: "Elastic-2.0",
|
|
41
41
|
description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
|
|
42
42
|
type: "module",
|
|
@@ -2673,9 +2673,14 @@ function withoutRanges(input, ranges) {
|
|
|
2673
2673
|
* it. A rename's "N documents link to X and will be updated" counts the documents whose text
|
|
2674
2674
|
* the rewrite touches; a page that merely carries the scope in its title is retitled by the
|
|
2675
2675
|
* cascade and reported there, so counting it here would describe it twice (ADR 0083).
|
|
2676
|
+
*
|
|
2677
|
+
* Given `name`, only links that say that name count. The rewrite changes `[[name]]` and no
|
|
2678
|
+
* other name the page answers to, so a rename asks by the name it renames (ADR 0065, amended
|
|
2679
|
+
* 2026-10-04): the page's title and its aliases share one group of backlinks.
|
|
2676
2680
|
*/
|
|
2677
|
-
function referencedInBody(group) {
|
|
2678
|
-
return group.refs.some((ref) => ref.kind !== "title");
|
|
2681
|
+
function referencedInBody(group, name) {
|
|
2682
|
+
if (name === void 0) return group.refs.some((ref) => ref.kind !== "title");
|
|
2683
|
+
return group.bodyNames.includes(conceptKey$1(name));
|
|
2679
2684
|
}
|
|
2680
2685
|
var MAX_CONTEXT = 1e3;
|
|
2681
2686
|
var SCHEMA$1 = `
|
|
@@ -2810,8 +2815,39 @@ function isUsableIndex(db) {
|
|
|
2810
2815
|
return false;
|
|
2811
2816
|
}
|
|
2812
2817
|
}
|
|
2813
|
-
|
|
2814
|
-
|
|
2818
|
+
/**
|
|
2819
|
+
* The page the live generation holds under `key`, as an ingest compares a document with it: its
|
|
2820
|
+
* hash, and the title and kind it is shown under, which a title changed only in case moves while
|
|
2821
|
+
* its key and its text stay the same.
|
|
2822
|
+
*/
|
|
2823
|
+
function indexedPage(db, key) {
|
|
2824
|
+
const row = db.all("SELECT text_hash, concept, kind FROM pages WHERE generation = ? AND concept_key = ? LIMIT 1", [activeIndexGeneration(db), key])[0];
|
|
2825
|
+
return row ? {
|
|
2826
|
+
hash: row.text_hash,
|
|
2827
|
+
concept: row.concept,
|
|
2828
|
+
kind: row.kind
|
|
2829
|
+
} : void 0;
|
|
2830
|
+
}
|
|
2831
|
+
/**
|
|
2832
|
+
* Every document the live generation holds, by its names: what a change to the graph's names is
|
|
2833
|
+
* reconciled against (`reconcileNames`). Read from the tables, not the concept candidates, which
|
|
2834
|
+
* hold one row per name and so lose a page whose title is another page's alias.
|
|
2835
|
+
*/
|
|
2836
|
+
function indexedNames(db) {
|
|
2837
|
+
const generation = activeIndexGeneration(db);
|
|
2838
|
+
const pages = db.all("SELECT id, concept, kind FROM pages WHERE generation = ?", [generation]);
|
|
2839
|
+
const aliases = db.all("SELECT a.page_id, a.display FROM aliases a JOIN pages p ON p.id = a.page_id WHERE p.generation = ? ORDER BY a.rowid", [generation]);
|
|
2840
|
+
const byPage = /* @__PURE__ */ new Map();
|
|
2841
|
+
for (const alias of aliases) {
|
|
2842
|
+
const list = byPage.get(alias.page_id);
|
|
2843
|
+
if (list) list.push(alias.display);
|
|
2844
|
+
else byPage.set(alias.page_id, [alias.display]);
|
|
2845
|
+
}
|
|
2846
|
+
return pages.map((page) => ({
|
|
2847
|
+
concept: page.concept,
|
|
2848
|
+
kind: page.kind,
|
|
2849
|
+
aliases: byPage.get(page.id) ?? []
|
|
2850
|
+
}));
|
|
2815
2851
|
}
|
|
2816
2852
|
/**
|
|
2817
2853
|
* cyrb53 — a fast, non-cryptographic 53-bit string hash. Not a security boundary: it decides
|
|
@@ -2826,10 +2862,11 @@ function documentHash(db, key) {
|
|
|
2826
2862
|
function indexDocHash(doc) {
|
|
2827
2863
|
const includes = doc.includes ?? [];
|
|
2828
2864
|
const properties = doc.properties ?? [];
|
|
2829
|
-
|
|
2865
|
+
const aliases = doc.aliases ?? [];
|
|
2866
|
+
if (includes.length === 0 && properties.length === 0 && aliases.length === 0) return hashText(doc.text);
|
|
2830
2867
|
const includeText = includes.map((i) => `${i.publication}\u0001${i.slot}\u0001${i.concept}`).join("\0");
|
|
2831
2868
|
const propertyText = properties.map((p) => `${p.key}\u0001${p.value ?? ""}`).join("\0");
|
|
2832
|
-
return hashText(`${doc.text}\u0000${includeText}\u0003${propertyText}`);
|
|
2869
|
+
return hashText(`${doc.text}\u0000${includeText}\u0003${propertyText}\u0004${aliases.join("\0")}`);
|
|
2833
2870
|
}
|
|
2834
2871
|
function hashText(text) {
|
|
2835
2872
|
let h1 = 3735928559;
|
|
@@ -3147,16 +3184,7 @@ function ingestOne(db, doc) {
|
|
|
3147
3184
|
const leaving = found.length > 0 && protectedFlag(doc) === 1 ? db.all("SELECT text FROM block_fts WHERE rowid >= ? AND rowid < ?", blockFtsRange(found[0].id)).map((row) => row.text) : [];
|
|
3148
3185
|
if (found.length > 0) {
|
|
3149
3186
|
pageId = found[0].id;
|
|
3150
|
-
db
|
|
3151
|
-
db.run("DELETE FROM publication_includes WHERE page_id = ?", [pageId]);
|
|
3152
|
-
const [ftsFrom, ftsTo] = blockFtsRange(pageId);
|
|
3153
|
-
db.run("DELETE FROM block_fts WHERE rowid >= ? AND rowid < ?", [ftsFrom, ftsTo]);
|
|
3154
|
-
db.run("DELETE FROM blocks WHERE page_id = ?", [pageId]);
|
|
3155
|
-
db.run("DELETE FROM links WHERE page_id = ?", [pageId]);
|
|
3156
|
-
db.run("DELETE FROM tasks WHERE page_id = ?", [pageId]);
|
|
3157
|
-
db.run("DELETE FROM task_concepts WHERE page_id = ?", [pageId]);
|
|
3158
|
-
db.run("DELETE FROM passages WHERE page_id = ?", [pageId]);
|
|
3159
|
-
db.run("DELETE FROM properties WHERE page_id = ?", [pageId]);
|
|
3187
|
+
deleteDerivedRows(db, pageId);
|
|
3160
3188
|
db.run("UPDATE pages SET concept = ?, kind = ?, text_hash = ?, protected = ? WHERE id = ?", [
|
|
3161
3189
|
doc.concept,
|
|
3162
3190
|
doc.kind,
|
|
@@ -3180,6 +3208,40 @@ function ingestOne(db, doc) {
|
|
|
3180
3208
|
if (leaving.some((text) => !written.has(text))) purgeDeletedText(db);
|
|
3181
3209
|
});
|
|
3182
3210
|
}
|
|
3211
|
+
/**
|
|
3212
|
+
* Every row derived from page `pageId`, the page row itself aside: what an ingest replaces and a
|
|
3213
|
+
* removal drops. One list, so a table added to the schema is cleared by both.
|
|
3214
|
+
*/
|
|
3215
|
+
function deleteDerivedRows(db, pageId) {
|
|
3216
|
+
db.run("DELETE FROM aliases WHERE page_id = ?", [pageId]);
|
|
3217
|
+
db.run("DELETE FROM publication_includes WHERE page_id = ?", [pageId]);
|
|
3218
|
+
const [ftsFrom, ftsTo] = blockFtsRange(pageId);
|
|
3219
|
+
db.run("DELETE FROM block_fts WHERE rowid >= ? AND rowid < ?", [ftsFrom, ftsTo]);
|
|
3220
|
+
db.run("DELETE FROM blocks WHERE page_id = ?", [pageId]);
|
|
3221
|
+
db.run("DELETE FROM links WHERE page_id = ?", [pageId]);
|
|
3222
|
+
db.run("DELETE FROM tasks WHERE page_id = ?", [pageId]);
|
|
3223
|
+
db.run("DELETE FROM task_concepts WHERE page_id = ?", [pageId]);
|
|
3224
|
+
db.run("DELETE FROM passages WHERE page_id = ?", [pageId]);
|
|
3225
|
+
db.run("DELETE FROM properties WHERE page_id = ?", [pageId]);
|
|
3226
|
+
}
|
|
3227
|
+
/**
|
|
3228
|
+
* Drop the document with key `key` from the live generation: its page row and every row derived
|
|
3229
|
+
* from it. A rename, a delete, or a title becoming another page's alias leaves rows under a name
|
|
3230
|
+
* no document has any more, and this takes them out one document at a time, where a whole
|
|
3231
|
+
* replacement would re-read the graph. Links other documents hold to the name are theirs and stay,
|
|
3232
|
+
* so a name still linked to lives on as a [[Pageless Concept]]. Returns whether there was one.
|
|
3233
|
+
*/
|
|
3234
|
+
function removeDocument(db, key) {
|
|
3235
|
+
let removed = false;
|
|
3236
|
+
inTransaction(db, () => {
|
|
3237
|
+
const found = db.all("SELECT id FROM pages WHERE generation = ? AND concept_key = ?", [activeIndexGeneration(db), key]);
|
|
3238
|
+
if (found.length === 0) return;
|
|
3239
|
+
deleteDerivedRows(db, found[0].id);
|
|
3240
|
+
db.run("DELETE FROM pages WHERE id = ?", [found[0].id]);
|
|
3241
|
+
removed = true;
|
|
3242
|
+
});
|
|
3243
|
+
return removed;
|
|
3244
|
+
}
|
|
3183
3245
|
/** Every concept key that resolves to a real document (canonical name or alias). */
|
|
3184
3246
|
function existingConceptKeys(db) {
|
|
3185
3247
|
const generation = activeIndexGeneration(db);
|
|
@@ -3505,7 +3567,7 @@ function backlinksFor(db, concept) {
|
|
|
3505
3567
|
if (names.length === 0) return [];
|
|
3506
3568
|
const placeholders = names.map(() => "?").join(",");
|
|
3507
3569
|
const generation = activeIndexGeneration(db);
|
|
3508
|
-
const hits = db.all(`SELECT l.page_id, p.concept AS sourceConcept, p.kind AS sourceKind, l.line, l.line_text,
|
|
3570
|
+
const hits = db.all(`SELECT l.page_id, l.concept_key, p.concept AS sourceConcept, p.kind AS sourceKind, l.line, l.line_text,
|
|
3509
3571
|
l.match_start, l.match_end, l.block_local_id, l.in_title
|
|
3510
3572
|
FROM links l JOIN pages p ON p.id = l.page_id
|
|
3511
3573
|
WHERE p.generation=? AND l.concept_key IN (${placeholders})
|
|
@@ -3531,18 +3593,20 @@ function backlinksFor(db, concept) {
|
|
|
3531
3593
|
const groups = /* @__PURE__ */ new Map();
|
|
3532
3594
|
const places = /* @__PURE__ */ new Set();
|
|
3533
3595
|
for (const h of hits) {
|
|
3534
|
-
const place = h.in_title ? `${h.page_id}:title` : h.block_local_id != null ? `${h.page_id}:block:${h.block_local_id}` : `${h.page_id}:line:${h.line}`;
|
|
3535
|
-
if (places.has(place)) continue;
|
|
3536
|
-
places.add(place);
|
|
3537
3596
|
let group = groups.get(h.sourceConcept);
|
|
3538
3597
|
if (!group) {
|
|
3539
3598
|
group = {
|
|
3540
3599
|
sourceConcept: h.sourceConcept,
|
|
3541
3600
|
sourceKind: h.sourceKind,
|
|
3542
|
-
refs: []
|
|
3601
|
+
refs: [],
|
|
3602
|
+
bodyNames: []
|
|
3543
3603
|
};
|
|
3544
3604
|
groups.set(h.sourceConcept, group);
|
|
3545
3605
|
}
|
|
3606
|
+
if (!h.in_title && !group.bodyNames.includes(h.concept_key)) group.bodyNames.push(h.concept_key);
|
|
3607
|
+
const place = h.in_title ? `${h.page_id}:title` : h.block_local_id != null ? `${h.page_id}:block:${h.block_local_id}` : `${h.page_id}:line:${h.line}`;
|
|
3608
|
+
if (places.has(place)) continue;
|
|
3609
|
+
places.add(place);
|
|
3546
3610
|
group.refs.push(buildRef(h, blocksByPage.get(h.page_id) ?? []));
|
|
3547
3611
|
}
|
|
3548
3612
|
const rank = (ref) => ref.kind === "title" ? 0 : 1;
|
|
@@ -8835,6 +8899,44 @@ function lastNonBlank(lines) {
|
|
|
8835
8899
|
return -1;
|
|
8836
8900
|
}
|
|
8837
8901
|
//#endregion
|
|
8902
|
+
//#region ../client/src/lib/document/index-worker/name-reconcile.ts
|
|
8903
|
+
/**
|
|
8904
|
+
* Which documents' names differ between the [[Derived Index]] and the store, so a change to the
|
|
8905
|
+
* graph's names (a create, a rename, an alias change, a delete) re-reads only those documents
|
|
8906
|
+
* rather than every document in the graph.
|
|
8907
|
+
*
|
|
8908
|
+
* Both sides are documents by their names, without their text: the index's are its own page and
|
|
8909
|
+
* alias rows (the worker's \`names\` answer), the store's its listing. A document whose title, kind
|
|
8910
|
+
* or aliases differ is \`changed\` and read again; a title the index has and the store does not is
|
|
8911
|
+
* \`removed\`. Matched by title key, so a page whose title another page has as an alias is still a
|
|
8912
|
+
* document of its own. A name only links hold (a [[Pageless Concept]]) has no page row, so it is
|
|
8913
|
+
* never removed here: the linking documents keep it.
|
|
8914
|
+
*
|
|
8915
|
+
* Pure, so the rule is unit-tested without a worker or a store.
|
|
8916
|
+
*/
|
|
8917
|
+
function reconcileNames(indexed, listed) {
|
|
8918
|
+
const held = new Map(indexed.map((document) => [conceptKey$1(document.concept), document]));
|
|
8919
|
+
const changed = [];
|
|
8920
|
+
for (const document of listed) {
|
|
8921
|
+
const key = conceptKey$1(document.concept);
|
|
8922
|
+
const known = held.get(key);
|
|
8923
|
+
held.delete(key);
|
|
8924
|
+
if (!known || known.concept !== document.concept || known.kind !== document.kind || !sameNames(known.aliases ?? [], document.aliases ?? [])) changed.push(document.concept);
|
|
8925
|
+
}
|
|
8926
|
+
return {
|
|
8927
|
+
changed,
|
|
8928
|
+
removed: [...held.values()].map((document) => document.concept)
|
|
8929
|
+
};
|
|
8930
|
+
}
|
|
8931
|
+
/** Whether two alias lists name the same names, as the index keys them: order and case aside. */
|
|
8932
|
+
function sameNames(a, b) {
|
|
8933
|
+
const keys = new Set(a.map(conceptKey$1));
|
|
8934
|
+
const other = new Set(b.map(conceptKey$1));
|
|
8935
|
+
if (keys.size !== other.size) return false;
|
|
8936
|
+
for (const key of keys) if (!other.has(key)) return false;
|
|
8937
|
+
return true;
|
|
8938
|
+
}
|
|
8939
|
+
//#endregion
|
|
8838
8940
|
//#region ../client/src/lib/document/index-link-graph.ts
|
|
8839
8941
|
/**
|
|
8840
8942
|
* The [[Graph View]]'s query over the [[Derived Index]]: every [[Concept]] and every pair of
|
|
@@ -9133,10 +9235,27 @@ function createIndexCore(host) {
|
|
|
9133
9235
|
case "ingest": {
|
|
9134
9236
|
if (!db) throw new Error("index not open");
|
|
9135
9237
|
const changed = [];
|
|
9136
|
-
for (const doc of request.docs)
|
|
9137
|
-
|
|
9238
|
+
for (const doc of request.docs) {
|
|
9239
|
+
const stored = indexedPage(db, conceptKey$1(doc.concept));
|
|
9240
|
+
if (!(stored !== void 0 && stored.hash === indexDocHash(doc) && stored.concept === doc.concept && stored.kind === doc.kind)) changed.push(doc);
|
|
9241
|
+
}
|
|
9242
|
+
const taken = new Set(request.docs.map((doc) => conceptKey$1(doc.concept)));
|
|
9243
|
+
const removed = [...new Set((request.removed ?? []).map(conceptKey$1))].filter((key) => !taken.has(key) && indexedPage(db, key) !== void 0);
|
|
9244
|
+
if (changed.length === 0 && removed.length === 0) return [];
|
|
9138
9245
|
const affected = /* @__PURE__ */ new Set();
|
|
9139
9246
|
const backlinkTargetsChanged = /* @__PURE__ */ new Set();
|
|
9247
|
+
for (const key of removed) {
|
|
9248
|
+
const before = indexedDocumentFacts(db, key);
|
|
9249
|
+
for (const name of [key, ...before.aliases]) {
|
|
9250
|
+
affected.add(name);
|
|
9251
|
+
backlinkTargetsChanged.add(name);
|
|
9252
|
+
}
|
|
9253
|
+
for (const target of before.includeTargets) for (const family of conceptFamilyKeys(db, target)) affected.add(family);
|
|
9254
|
+
for (const target of before.linkTargets) {
|
|
9255
|
+
affected.add(target);
|
|
9256
|
+
backlinkTargetsChanged.add(target);
|
|
9257
|
+
}
|
|
9258
|
+
}
|
|
9140
9259
|
for (const doc of changed) {
|
|
9141
9260
|
const key = conceptKey$1(doc.concept);
|
|
9142
9261
|
const before = indexedDocumentFacts(db, key);
|
|
@@ -9152,6 +9271,7 @@ function createIndexCore(host) {
|
|
|
9152
9271
|
const before = conceptCandidatesForKeys(db, affected);
|
|
9153
9272
|
const previousSnapshot = visibleSnapshot;
|
|
9154
9273
|
visibleSnapshot = void 0;
|
|
9274
|
+
for (const key of removed) removeDocument(db, key);
|
|
9155
9275
|
for (const doc of changed) ingestOne(db, doc);
|
|
9156
9276
|
const after = conceptCandidatesForKeys(db, affected);
|
|
9157
9277
|
const delta = deltaFor(advanceIndexRevision(db), before, after, backlinkTargetsChanged);
|
|
@@ -9237,6 +9357,11 @@ function createIndexCore(host) {
|
|
|
9237
9357
|
usage
|
|
9238
9358
|
}];
|
|
9239
9359
|
}
|
|
9360
|
+
case "names": return [{
|
|
9361
|
+
type: "names",
|
|
9362
|
+
id: request.id,
|
|
9363
|
+
documents: db ? indexedNames(db) : []
|
|
9364
|
+
}];
|
|
9240
9365
|
case "link-graph": {
|
|
9241
9366
|
const graph = db ? linkGraph(db) : {
|
|
9242
9367
|
concepts: [],
|
|
@@ -9394,6 +9519,10 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
9394
9519
|
let workerReplaced = false;
|
|
9395
9520
|
/** Set while refresh()'s initial full derivation runs; cleared by its snapshot. */
|
|
9396
9521
|
let building = false;
|
|
9522
|
+
/** Rebuilds queued or running, catch-ups and cold builds not yet finished: what `isBusy` reads. */
|
|
9523
|
+
let workInFlight = 0;
|
|
9524
|
+
let busyReported = false;
|
|
9525
|
+
const busyChanged = /* @__PURE__ */ new Set();
|
|
9397
9526
|
const updated = /* @__PURE__ */ new Set();
|
|
9398
9527
|
let timer;
|
|
9399
9528
|
let rebuildTail = Promise.resolve();
|
|
@@ -9411,6 +9540,20 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
9411
9540
|
let coldCatchUpPending = false;
|
|
9412
9541
|
/** Concepts whose content changed, or `null` for "the document set moved — replace all". */
|
|
9413
9542
|
let dirty = /* @__PURE__ */ new Set();
|
|
9543
|
+
/**
|
|
9544
|
+
* The graph's names moved since the last rebuild (a create, rename, alias change or delete):
|
|
9545
|
+
* the next one reconciles them against the source's listing (`reconcileNames`) and re-reads
|
|
9546
|
+
* only the documents they changed, where it used to replace the whole index.
|
|
9547
|
+
*/
|
|
9548
|
+
let namesMoved = false;
|
|
9549
|
+
/**
|
|
9550
|
+
* Source changes observed so far, and how many of them a completed rebuild had taken when it
|
|
9551
|
+
* started: what `settled` waits on, so it waits for the changes seen when it was asked only.
|
|
9552
|
+
*/
|
|
9553
|
+
let observedChanges = 0;
|
|
9554
|
+
let ingestedThrough = 0;
|
|
9555
|
+
/** Rebuilds queued on the tail and not finished: whether a `settled` wait can still end. */
|
|
9556
|
+
let queuedRuns = 0;
|
|
9414
9557
|
let nextId = 1;
|
|
9415
9558
|
let nextOpenId = 1;
|
|
9416
9559
|
const pending = /* @__PURE__ */ new Map();
|
|
@@ -9777,8 +9920,33 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
9777
9920
|
return response;
|
|
9778
9921
|
}
|
|
9779
9922
|
}
|
|
9923
|
+
/** Tell `onBusyChanged` listeners when `isBusy()` has changed. */
|
|
9924
|
+
function reportBusy() {
|
|
9925
|
+
const busy = workInFlight > 0;
|
|
9926
|
+
if (busy === busyReported) return;
|
|
9927
|
+
busyReported = busy;
|
|
9928
|
+
for (const listener of busyChanged) listener(busy);
|
|
9929
|
+
}
|
|
9930
|
+
/**
|
|
9931
|
+
* Count `work` towards `isBusy()` until it settles, whichever way. Started at once, not a
|
|
9932
|
+
* microtask later: `runRebuild` chains the next run onto the tail it reads synchronously.
|
|
9933
|
+
*/
|
|
9934
|
+
function busyWhile(work) {
|
|
9935
|
+
workInFlight++;
|
|
9936
|
+
reportBusy();
|
|
9937
|
+
let started;
|
|
9938
|
+
try {
|
|
9939
|
+
started = work();
|
|
9940
|
+
} catch (error) {
|
|
9941
|
+
started = Promise.reject(error);
|
|
9942
|
+
}
|
|
9943
|
+
return started.finally(() => {
|
|
9944
|
+
workInFlight--;
|
|
9945
|
+
reportBusy();
|
|
9946
|
+
});
|
|
9947
|
+
}
|
|
9780
9948
|
function catchUpWarmIndex({ verifyExternalSource = true } = {}) {
|
|
9781
|
-
Promise.resolve().then(() => withCatchUpLock(async () => {
|
|
9949
|
+
busyWhile(() => Promise.resolve().then(() => withCatchUpLock(async () => {
|
|
9782
9950
|
if (source.catchUpPersistedIndex) {
|
|
9783
9951
|
await source.catchUpPersistedIndex();
|
|
9784
9952
|
if (timer !== void 0) {
|
|
@@ -9791,7 +9959,7 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
9791
9959
|
if (!verifyExternalSource) return;
|
|
9792
9960
|
dirty = null;
|
|
9793
9961
|
await runRebuild();
|
|
9794
|
-
})).catch((error) => options.onBackgroundError?.(error instanceof Error ? error : new Error(String(error))));
|
|
9962
|
+
}))).catch((error) => options.onBackgroundError?.(error instanceof Error ? error : new Error(String(error))));
|
|
9795
9963
|
}
|
|
9796
9964
|
/** Hand the worker every document. Used when nothing usable is persisted. */
|
|
9797
9965
|
async function rebuildAll(checkpointStream) {
|
|
@@ -9865,40 +10033,87 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
9865
10033
|
const checkpointDocuments = new Map((checkpoint?.documents ?? []).map((document) => [conceptKey$1(document.concept), document]));
|
|
9866
10034
|
if (checkpoint?.changes === null) dirty = null;
|
|
9867
10035
|
else if (checkpoint && dirty !== null) for (const change of checkpoint.changes) dirty.add(change.concept);
|
|
10036
|
+
const listing = checkpoint?.names ?? source.listDocuments?.();
|
|
10037
|
+
if (checkpoint?.namesMoved) if (listing) namesMoved = true;
|
|
10038
|
+
else dirty = null;
|
|
10039
|
+
const through = observedChanges;
|
|
9868
10040
|
const changed = dirty;
|
|
10041
|
+
const reconcile = namesMoved;
|
|
9869
10042
|
dirty = /* @__PURE__ */ new Set();
|
|
10043
|
+
namesMoved = false;
|
|
10044
|
+
/** Titles the source no longer has, found by reconciling names; read nothing to drop. */
|
|
10045
|
+
let removed = [];
|
|
9870
10046
|
try {
|
|
10047
|
+
if (changed !== null && reconcile && listing) {
|
|
10048
|
+
const names = reconcileNames(await indexedNames(), listing);
|
|
10049
|
+
for (const concept of names.changed) changed.add(concept);
|
|
10050
|
+
removed = names.removed;
|
|
10051
|
+
}
|
|
9871
10052
|
const checkpointCoversChanged = changed !== null && [...changed].every((concept) => checkpointDocuments.has(conceptKey$1(concept)));
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
9877
|
-
|
|
9878
|
-
|
|
9879
|
-
|
|
9880
|
-
|
|
9881
|
-
|
|
9882
|
-
|
|
9883
|
-
|
|
9884
|
-
|
|
9885
|
-
await request((id) => ({
|
|
9886
|
-
type: "barrier",
|
|
9887
|
-
id
|
|
9888
|
-
}));
|
|
9889
|
-
}
|
|
10053
|
+
const targeted = changed !== null && (changed.size + removed.length <= INCREMENTAL_LIMIT || checkpointCoversChanged) ? await targetedChanges(changed, removed, checkpointDocuments, listing) : null;
|
|
10054
|
+
if (targeted === null) await rebuildAll(checkpoint?.streamForIndex);
|
|
10055
|
+
else if (targeted.docs.length > 0 || targeted.removed.length > 0) {
|
|
10056
|
+
const { docs } = targeted;
|
|
10057
|
+
for (let start = 0; start < Math.max(docs.length, 1); start += REBUILD_SEND_CHUNK) transport.send({
|
|
10058
|
+
type: "ingest",
|
|
10059
|
+
docs: docs.slice(start, start + REBUILD_SEND_CHUNK),
|
|
10060
|
+
...start === 0 && targeted.removed.length > 0 ? { removed: targeted.removed } : {}
|
|
10061
|
+
});
|
|
10062
|
+
await request((id) => ({
|
|
10063
|
+
type: "barrier",
|
|
10064
|
+
id
|
|
10065
|
+
}));
|
|
9890
10066
|
}
|
|
9891
10067
|
await checkpoint?.acknowledge();
|
|
10068
|
+
ingestedThrough = Math.max(ingestedThrough, through);
|
|
9892
10069
|
} catch (error) {
|
|
9893
10070
|
if (changed === null) dirty = null;
|
|
9894
|
-
else if (dirty !== null) for (const concept of changed) dirty.add(concept);
|
|
10071
|
+
else if (dirty !== null) for (const concept of [...changed, ...removed]) dirty.add(concept);
|
|
10072
|
+
if (reconcile) namesMoved = true;
|
|
9895
10073
|
throw error;
|
|
9896
10074
|
}
|
|
9897
10075
|
}
|
|
9898
|
-
/**
|
|
10076
|
+
/** Every document the index holds, by its names: the worker's own rows (`indexedNames`). */
|
|
10077
|
+
async function indexedNames() {
|
|
10078
|
+
return (await request((id) => ({
|
|
10079
|
+
type: "names",
|
|
10080
|
+
id
|
|
10081
|
+
}))).documents;
|
|
10082
|
+
}
|
|
10083
|
+
/**
|
|
10084
|
+
* What a targeted ingest takes for `changed` and `removed`: each changed name read again, and
|
|
10085
|
+
* dropped when it reads back as nothing (gone) or as a document under another title (now that
|
|
10086
|
+
* document's alias, or retitled). Null when a name might only have failed to read: one the
|
|
10087
|
+
* source still lists as a title, or any, from a source that cannot list its documents.
|
|
10088
|
+
*/
|
|
10089
|
+
async function targetedChanges(changed, removed, checkpointDocuments, listing) {
|
|
10090
|
+
const listed = listing ? new Set(listing.map((document) => conceptKey$1(document.concept))) : null;
|
|
10091
|
+
const docs = /* @__PURE__ */ new Map();
|
|
10092
|
+
const gone = new Set(removed);
|
|
10093
|
+
for (const concept of changed) {
|
|
10094
|
+
const doc = checkpointDocuments.get(conceptKey$1(concept)) ?? await source.snapshotDocument?.(concept);
|
|
10095
|
+
if (!doc) {
|
|
10096
|
+
if (!listed || listed.has(conceptKey$1(concept))) return null;
|
|
10097
|
+
gone.add(concept);
|
|
10098
|
+
continue;
|
|
10099
|
+
}
|
|
10100
|
+
if (conceptKey$1(doc.concept) !== conceptKey$1(concept)) gone.add(concept);
|
|
10101
|
+
docs.set(conceptKey$1(doc.concept), doc);
|
|
10102
|
+
}
|
|
10103
|
+
return {
|
|
10104
|
+
docs: [...docs.values()],
|
|
10105
|
+
removed: [...gone]
|
|
10106
|
+
};
|
|
10107
|
+
}
|
|
10108
|
+
/**
|
|
10109
|
+
* Source events and the terminal catch-up flush share one ordered main-thread pipeline. A run
|
|
10110
|
+
* counts as busy from the moment it is queued, so runs waiting on one another read as one spell.
|
|
10111
|
+
*/
|
|
9899
10112
|
function runRebuild() {
|
|
9900
|
-
|
|
10113
|
+
queuedRuns++;
|
|
10114
|
+
const next = busyWhile(() => rebuildTail.then(() => rebuild()));
|
|
9901
10115
|
rebuildTail = next.catch(() => void 0).then(() => {
|
|
10116
|
+
queuedRuns--;
|
|
9902
10117
|
const woken = drainWaiters;
|
|
9903
10118
|
drainWaiters = [];
|
|
9904
10119
|
for (const wake of woken) wake();
|
|
@@ -9909,12 +10124,19 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
9909
10124
|
if (timer !== void 0) clearTimeout(timer);
|
|
9910
10125
|
timer = setTimeout(() => {
|
|
9911
10126
|
timer = void 0;
|
|
9912
|
-
runRebuild().catch(
|
|
10127
|
+
runRebuild().catch(reportBackgroundError);
|
|
9913
10128
|
}, debounceMs);
|
|
9914
10129
|
}
|
|
10130
|
+
/** A rebuild nobody awaits failed: said, without failing anything that is waiting. */
|
|
10131
|
+
function reportBackgroundError(error) {
|
|
10132
|
+
options.onBackgroundError?.(error instanceof Error ? error : new Error(String(error)));
|
|
10133
|
+
}
|
|
9915
10134
|
function scheduleRebuild(change) {
|
|
9916
|
-
|
|
9917
|
-
|
|
10135
|
+
observedChanges++;
|
|
10136
|
+
if (change && source.snapshotDocument) {
|
|
10137
|
+
if (dirty !== null) dirty.add(change.concept);
|
|
10138
|
+
} else if (!change && source.snapshotDocument && source.listDocuments) namesMoved = true;
|
|
10139
|
+
else dirty = null;
|
|
9918
10140
|
if (activeRebuildId) {
|
|
9919
10141
|
rebuildInvalidated = true;
|
|
9920
10142
|
return;
|
|
@@ -10068,6 +10290,11 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
10068
10290
|
return () => persistenceChanged.delete(listener);
|
|
10069
10291
|
},
|
|
10070
10292
|
isBuilding: () => building,
|
|
10293
|
+
isBusy: () => busyReported,
|
|
10294
|
+
onBusyChanged(listener) {
|
|
10295
|
+
busyChanged.add(listener);
|
|
10296
|
+
return () => busyChanged.delete(listener);
|
|
10297
|
+
},
|
|
10071
10298
|
async refresh() {
|
|
10072
10299
|
if ((await preparedOpenResponse(true)).indexedDocuments > 0) {
|
|
10073
10300
|
backgroundRebuildsEnabled = true;
|
|
@@ -10075,7 +10302,7 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
10075
10302
|
return;
|
|
10076
10303
|
}
|
|
10077
10304
|
building = true;
|
|
10078
|
-
await withRebuildLock(async () => {
|
|
10305
|
+
await busyWhile(() => withRebuildLock(async () => {
|
|
10079
10306
|
if ((await openIndex()).indexedDocuments > 0) {
|
|
10080
10307
|
building = false;
|
|
10081
10308
|
backgroundRebuildsEnabled = true;
|
|
@@ -10086,7 +10313,7 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
10086
10313
|
coldCatchUpPending = true;
|
|
10087
10314
|
await runRebuild();
|
|
10088
10315
|
backgroundRebuildsEnabled = true;
|
|
10089
|
-
});
|
|
10316
|
+
}));
|
|
10090
10317
|
},
|
|
10091
10318
|
async rebuild() {
|
|
10092
10319
|
if (disposed) return;
|
|
@@ -10104,13 +10331,17 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
10104
10331
|
return () => updated.delete(listener);
|
|
10105
10332
|
},
|
|
10106
10333
|
settled() {
|
|
10334
|
+
const target = observedChanges;
|
|
10107
10335
|
return new Promise((resolve) => {
|
|
10108
10336
|
const look = () => {
|
|
10109
|
-
if (
|
|
10110
|
-
|
|
10111
|
-
|
|
10337
|
+
if (disposed || ingestedThrough >= target) return resolve();
|
|
10338
|
+
if (timer !== void 0 && backgroundRebuildsEnabled) {
|
|
10339
|
+
clearTimeout(timer);
|
|
10340
|
+
timer = void 0;
|
|
10341
|
+
runRebuild().catch(reportBackgroundError);
|
|
10112
10342
|
}
|
|
10113
|
-
|
|
10343
|
+
if (queuedRuns === 0) return resolve();
|
|
10344
|
+
drainWaiters.push(look);
|
|
10114
10345
|
};
|
|
10115
10346
|
look();
|
|
10116
10347
|
});
|
|
@@ -10125,6 +10356,7 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
10125
10356
|
unsubscribe();
|
|
10126
10357
|
updated.clear();
|
|
10127
10358
|
persistenceChanged.clear();
|
|
10359
|
+
busyChanged.clear();
|
|
10128
10360
|
transport.send({ type: "close" });
|
|
10129
10361
|
transport.close();
|
|
10130
10362
|
}
|
|
@@ -10777,7 +11009,7 @@ function normaliseAliases(aliases, concept) {
|
|
|
10777
11009
|
return out;
|
|
10778
11010
|
}
|
|
10779
11011
|
/** Set equality by identity key: order and case carry no meaning. */
|
|
10780
|
-
function sameAliases(a, b) {
|
|
11012
|
+
function sameAliases$1(a, b) {
|
|
10781
11013
|
const left = new Set(a.map(conceptKey));
|
|
10782
11014
|
const right = new Set(b.map(conceptKey));
|
|
10783
11015
|
return left.size === right.size && [...left].every((key) => right.has(key));
|
|
@@ -10796,7 +11028,7 @@ function withFrontmatterIdentity(text, patch, options = {}) {
|
|
|
10796
11028
|
if (data === null) return text;
|
|
10797
11029
|
const wantTitle = patch.title === void 0 ? titleOf(data) : patch.title;
|
|
10798
11030
|
const wantAliases = patch.aliases === void 0 ? aliasesOf({ data }) : patch.aliases;
|
|
10799
|
-
if (wantTitle === titleOf(data) && sameAliases(wantAliases, aliasesOf({ data }))) return text;
|
|
11031
|
+
if (wantTitle === titleOf(data) && sameAliases$1(wantAliases, aliasesOf({ data }))) return text;
|
|
10800
11032
|
}
|
|
10801
11033
|
return editFrontmatter(text, (block) => {
|
|
10802
11034
|
if (patch.title !== void 0) if (patch.title === null || patch.title.trim() === "") block.delete("title");
|
|
@@ -10810,7 +11042,8 @@ function withFrontmatterIdentity(text, patch, options = {}) {
|
|
|
10810
11042
|
* the rewrite added. A writer that gives a document its first block, as Publish does when it adds
|
|
10811
11043
|
* `public:`, must not make the document claim fewer aliases than it has: a block with no
|
|
10812
11044
|
* `aliases:` line claims none, and the next edit to it would clear them (ADR 0061). Only a Server
|
|
10813
|
-
* Backend has aliases without a block
|
|
11045
|
+
* Backend has aliases without a block, on a document aliased before they were always shown and not
|
|
11046
|
+
* yet repaired (amended 2026-10-03). On a Filesystem Backend they are read from the saved file,
|
|
10814
11047
|
* so `aliases` is empty there provided the caller wrote pending edits before reading them
|
|
10815
11048
|
* (`rewriteFrontmatter` flushes first). A block `before` already had is left as the writer left it.
|
|
10816
11049
|
*/
|
|
@@ -11151,6 +11384,18 @@ var DocumentSyncDegradedError = class extends Error {
|
|
|
11151
11384
|
this.name = "DocumentSyncDegradedError";
|
|
11152
11385
|
}
|
|
11153
11386
|
};
|
|
11387
|
+
/**
|
|
11388
|
+
* A synced document this device could not bring current in time, so a write that has to see its
|
|
11389
|
+
* whole text was refused rather than made blind: a block added to text that has not arrived would
|
|
11390
|
+
* sit above the document's own block once it does.
|
|
11391
|
+
*/
|
|
11392
|
+
var DocumentUnconfirmedError = class extends Error {
|
|
11393
|
+
constructor(target) {
|
|
11394
|
+
super(`"${target}" has not finished syncing to this device. Nothing was changed - try again once sync has caught up.`);
|
|
11395
|
+
this.target = target;
|
|
11396
|
+
this.name = "DocumentUnconfirmedError";
|
|
11397
|
+
}
|
|
11398
|
+
};
|
|
11154
11399
|
//#endregion
|
|
11155
11400
|
//#region ../client/src/lib/storage/file-names.ts
|
|
11156
11401
|
/**
|
|
@@ -11276,6 +11521,15 @@ function renameRefusal(options) {
|
|
|
11276
11521
|
function renameSteps(plan) {
|
|
11277
11522
|
return [plan.direct, ...plan.cascade];
|
|
11278
11523
|
}
|
|
11524
|
+
/**
|
|
11525
|
+
* The name a document called `concept` before the rename goes by after it: where a title step
|
|
11526
|
+
* lands it (its `into`, a merge's survivor included), or the same name when no step moves it.
|
|
11527
|
+
* How a holder of an alias the rename rewrites is found, and named in what the rename reports.
|
|
11528
|
+
*/
|
|
11529
|
+
function landedName(plan, concept) {
|
|
11530
|
+
const key = conceptKey(concept);
|
|
11531
|
+
return renameSteps(plan).find((step) => step.hasDocument && conceptKey(step.from) === key)?.into ?? concept;
|
|
11532
|
+
}
|
|
11279
11533
|
/** How many steps merge - what the dialog warns about. */
|
|
11280
11534
|
function mergeCount(plan) {
|
|
11281
11535
|
return renameSteps(plan).filter((s) => s.merges).length;
|
|
@@ -11292,10 +11546,12 @@ function mergeCount(plan) {
|
|
|
11292
11546
|
function planRename$1(input) {
|
|
11293
11547
|
const from = input.from.trim();
|
|
11294
11548
|
const to = input.to.trim();
|
|
11549
|
+
const renamedAlias = input.kind === null ? (input.aliases ?? []).find((alias) => conceptKey(alias.name) === conceptKey(from)) : void 0;
|
|
11550
|
+
const aliasOf = renamedAlias?.concept ?? null;
|
|
11295
11551
|
const refusal = renameRefusal({
|
|
11296
11552
|
from,
|
|
11297
11553
|
to,
|
|
11298
|
-
kind: input.kind
|
|
11554
|
+
kind: renamedAlias ? "page" : input.kind
|
|
11299
11555
|
});
|
|
11300
11556
|
const hasDocument = input.kind !== null;
|
|
11301
11557
|
const direct = {
|
|
@@ -11309,18 +11565,110 @@ function planRename$1(input) {
|
|
|
11309
11565
|
if (refusal) return {
|
|
11310
11566
|
direct,
|
|
11311
11567
|
cascade: [],
|
|
11568
|
+
aliases: [],
|
|
11569
|
+
aliasOf,
|
|
11312
11570
|
referencingDocuments: input.referencingDocuments,
|
|
11313
11571
|
refusal
|
|
11314
11572
|
};
|
|
11315
11573
|
const existing = new Map(input.concepts.map((c) => [conceptKey(c), c]));
|
|
11316
11574
|
for (const alias of input.aliases ?? []) if (!existing.has(conceptKey(alias.name))) existing.set(conceptKey(alias.name), alias.concept);
|
|
11317
11575
|
const cascade = cascadeFor(input.concepts, from, to).sort((a, b) => depthOf(b.from) - depthOf(a.from)).map((step) => stepFor(step.from, step.to, true, existing));
|
|
11318
|
-
|
|
11319
|
-
direct: stepFor(from, to, hasDocument, existing),
|
|
11576
|
+
const plan = {
|
|
11577
|
+
direct: renamedAlias ? direct : stepFor(from, to, hasDocument, existing),
|
|
11320
11578
|
cascade,
|
|
11579
|
+
aliases: [],
|
|
11580
|
+
aliasOf,
|
|
11321
11581
|
referencingDocuments: input.referencingDocuments,
|
|
11322
11582
|
refusal: null
|
|
11323
11583
|
};
|
|
11584
|
+
const rewrites = aliasRewrites(plan, renamedAlias, input.aliases ?? [], existing);
|
|
11585
|
+
return "refusal" in rewrites ? {
|
|
11586
|
+
...plan,
|
|
11587
|
+
refusal: rewrites.refusal
|
|
11588
|
+
} : {
|
|
11589
|
+
...plan,
|
|
11590
|
+
aliases: rewrites.aliases
|
|
11591
|
+
};
|
|
11592
|
+
}
|
|
11593
|
+
/**
|
|
11594
|
+
* The aliases the rename rewrites: the alias it names, when it names one (ADR 0065, amended
|
|
11595
|
+
* 2026-10-04), then every alias that names `from` as a scope, by the cascade's own rule, on
|
|
11596
|
+
* whatever document holds it (ADR 0038, amended 2026-10-03). A new form that a different document
|
|
11597
|
+
* will answer to refuses the rename: one another document already has, by title or alias; one a
|
|
11598
|
+
* title step of this same rename gives; or one another alias of this rename also becomes. Sharing
|
|
11599
|
+
* an alias does not make two documents one concept, and the name would go to whichever document a
|
|
11600
|
+
* lookup reached first.
|
|
11601
|
+
*
|
|
11602
|
+
* "Different" is judged on where each document ends up: a merge in the same rename makes two
|
|
11603
|
+
* documents one, so a parallel hierarchy merging into another, each page with its scoped alias,
|
|
11604
|
+
* is no collision. Nor is a re-casing (the name stays the same name), a form the holder itself
|
|
11605
|
+
* already has (the applier drops the duplicate), or one alias two documents already shared (the
|
|
11606
|
+
* rename carries that over rather than creating it).
|
|
11607
|
+
*/
|
|
11608
|
+
function aliasRewrites(plan, renamed, aliases, existing) {
|
|
11609
|
+
const steps = renameSteps(plan).filter((step) => step.hasDocument);
|
|
11610
|
+
const landed = new Map(steps.map((step) => [conceptKey(step.from), step.into]));
|
|
11611
|
+
const endsIn = (concept) => conceptKey(landed.get(conceptKey(concept)) ?? concept);
|
|
11612
|
+
const given = new Map(steps.map((step) => [conceptKey(step.to), step.from]));
|
|
11613
|
+
const planned = /* @__PURE__ */ new Map();
|
|
11614
|
+
const refuse = (alias, to, why) => ({ refusal: `“${alias.concept}” has the alias “${alias.name}”, which the rename would make “${to}”, but ${why}. Change one of the two names, then rename again.` });
|
|
11615
|
+
const candidates = renamed ? [{
|
|
11616
|
+
alias: renamed,
|
|
11617
|
+
to: plan.direct.to
|
|
11618
|
+
}] : [];
|
|
11619
|
+
for (const alias of aliases) {
|
|
11620
|
+
const rewritten = rewriteWikilinkScope(alias.name, plan.direct.from, plan.direct.to);
|
|
11621
|
+
if (rewritten.count > 0) candidates.push({
|
|
11622
|
+
alias,
|
|
11623
|
+
to: rewritten.text
|
|
11624
|
+
});
|
|
11625
|
+
}
|
|
11626
|
+
const out = [];
|
|
11627
|
+
for (const { alias, to } of candidates) {
|
|
11628
|
+
const key = conceptKey(to);
|
|
11629
|
+
if (key !== conceptKey(alias.name)) {
|
|
11630
|
+
const holder = endsIn(alias.concept);
|
|
11631
|
+
const owner = existing.get(key);
|
|
11632
|
+
if (owner !== void 0 && endsIn(owner) !== holder) {
|
|
11633
|
+
if (alias === renamed) return { refusal: renamedAliasTaken(alias, to, owner) };
|
|
11634
|
+
return refuse(alias, to, `that is already a name of “${owner}”`);
|
|
11635
|
+
}
|
|
11636
|
+
const receiver = given.get(key);
|
|
11637
|
+
if (receiver !== void 0 && endsIn(receiver) !== holder) return refuse(alias, to, `the rename gives that name to “${receiver}”`);
|
|
11638
|
+
const earlier = planned.get(key);
|
|
11639
|
+
if (earlier && endsIn(earlier.concept) !== holder && conceptKey(earlier.name) !== conceptKey(alias.name)) return refuse(alias, to, `it would also make “${earlier.concept}”'s alias “${earlier.name}” that name`);
|
|
11640
|
+
planned.set(key, alias);
|
|
11641
|
+
}
|
|
11642
|
+
out.push({
|
|
11643
|
+
holder: alias.concept,
|
|
11644
|
+
from: alias.name,
|
|
11645
|
+
to
|
|
11646
|
+
});
|
|
11647
|
+
}
|
|
11648
|
+
return { aliases: out };
|
|
11649
|
+
}
|
|
11650
|
+
/**
|
|
11651
|
+
* Why an alias cannot be renamed to a name another document answers to. Not a merge, as a page's
|
|
11652
|
+
* rename onto a taken name is: the alias belongs to one page, and the other page keeps its name.
|
|
11653
|
+
*/
|
|
11654
|
+
function renamedAliasTaken(alias, to, owner) {
|
|
11655
|
+
return `${conceptKey(owner) === conceptKey(to) ? `“${to}” is another page's name` : `“${to}” is already an alias of “${owner}”`}, so “${alias.concept}”'s alias “${alias.name}” cannot be renamed to it. Choose another name, or keep the link as typed.`;
|
|
11656
|
+
}
|
|
11657
|
+
/**
|
|
11658
|
+
* A holder's alias list after the rename (ADR 0038, amended 2026-10-03): under the rewrite arm
|
|
11659
|
+
* each rewritten alias is replaced in place, as the links that used it are rewritten; under the
|
|
11660
|
+
* alias arm the old form stays, since untouched links still say it, and the new form is added
|
|
11661
|
+
* after it. The caller normalises the result, which drops a new form the document already has.
|
|
11662
|
+
*/
|
|
11663
|
+
function aliasesAfterRename(current, rewrites, strategy) {
|
|
11664
|
+
const next = [];
|
|
11665
|
+
for (const alias of current) {
|
|
11666
|
+
const rewrite = rewrites.find((r) => conceptKey(r.from) === conceptKey(alias));
|
|
11667
|
+
if (!rewrite) next.push(alias);
|
|
11668
|
+
else if (strategy === "rewrite") next.push(rewrite.to);
|
|
11669
|
+
else next.push(alias, rewrite.to);
|
|
11670
|
+
}
|
|
11671
|
+
return next;
|
|
11324
11672
|
}
|
|
11325
11673
|
/**
|
|
11326
11674
|
* A [[Protected Document]] never merges (ADR 0062): not into another document, and nothing into
|
|
@@ -11353,13 +11701,16 @@ async function refuseProtectedMerges(plan, isProtected) {
|
|
|
11353
11701
|
* Refuse a plan whose steps would rewrite a [[Frontmatter]] block that does not parse. A
|
|
11354
11702
|
* Filesystem Backend rebuilds each renamed document's block, and a merge survivor's, from its
|
|
11355
11703
|
* parsed data, which is empty for such a block, so every key it holds would be lost from the file:
|
|
11356
|
-
* the only copy. `textOf` gives a
|
|
11357
|
-
*
|
|
11704
|
+
* the only copy. `textOf` gives the text a step rewrites, or null for a name with no document
|
|
11705
|
+
* behind it. A document whose alias the rename rewrites has its block rewritten too, and
|
|
11706
|
+
* `holderTextOf` gives the text that write starts from (an open buffer, where a step's is the
|
|
11707
|
+
* file), defaulting to `textOf`.
|
|
11358
11708
|
*/
|
|
11359
|
-
async function refuseUnreadableBlocks(plan, textOf) {
|
|
11709
|
+
async function refuseUnreadableBlocks(plan, textOf, holderTextOf = textOf) {
|
|
11360
11710
|
if (plan.refusal) return plan;
|
|
11361
|
-
|
|
11362
|
-
|
|
11711
|
+
const checks = [...renameSteps(plan).flatMap((step) => step.merges ? [step.from, step.into] : [step.from]).map((concept) => [concept, textOf]), ...plan.aliases.map((alias) => [alias.holder, holderTextOf])];
|
|
11712
|
+
for (const [concept, read] of checks) {
|
|
11713
|
+
const text = await read(concept);
|
|
11363
11714
|
if (text === null || frontmatterIdentity(text).readable) continue;
|
|
11364
11715
|
return {
|
|
11365
11716
|
...plan,
|
|
@@ -11494,6 +11845,48 @@ async function scanGraph(adapter, options = {}) {
|
|
|
11494
11845
|
return [...journals, ...pages];
|
|
11495
11846
|
}
|
|
11496
11847
|
//#endregion
|
|
11848
|
+
//#region ../client/src/lib/storage/fs/registry-changes.ts
|
|
11849
|
+
/**
|
|
11850
|
+
* What a rescan of a graph folder changed, as the [[Derived Index]] needs to hear it: the
|
|
11851
|
+
* documents whose files changed on disk, each by name, and whether any name changed (a document
|
|
11852
|
+
* added, removed, retitled or re-cased, or given other aliases). The two are separate because they
|
|
11853
|
+
* cost differently: a changed file is one document to re-read, while moved names are reconciled
|
|
11854
|
+
* against the index's own names (`reconcileNames`) and re-read only the documents they touch.
|
|
11855
|
+
*
|
|
11856
|
+
* The store keeps an entry's file stamps in step with its own writes, so a save it made is never
|
|
11857
|
+
* reported here a second time: what is left is an edit made outside the app.
|
|
11858
|
+
*
|
|
11859
|
+
* A document new under its name, or held by another file than before, is named as well as moving
|
|
11860
|
+
* the names. Names alone cannot tell it from the document the index last saw under that name: a
|
|
11861
|
+
* page renamed away and another created under its old title, both before the index caught up,
|
|
11862
|
+
* left the index holding the first page's rows for the second.
|
|
11863
|
+
*
|
|
11864
|
+
* Pure, so the rule is unit-tested without a folder.
|
|
11865
|
+
*/
|
|
11866
|
+
function registryChanges(previous, next) {
|
|
11867
|
+
let namesMoved = previous.size !== next.length;
|
|
11868
|
+
const contentChanged = [];
|
|
11869
|
+
for (const entry of next) {
|
|
11870
|
+
const before = previous.get(entry.key);
|
|
11871
|
+
if (!before) {
|
|
11872
|
+
namesMoved = true;
|
|
11873
|
+
contentChanged.push(entry.concept);
|
|
11874
|
+
continue;
|
|
11875
|
+
}
|
|
11876
|
+
if (before.concept !== entry.concept || before.kind !== entry.kind || !sameAliases(before.aliases, entry.aliases)) namesMoved = true;
|
|
11877
|
+
if (before.subdir !== entry.subdir || before.fileName !== entry.fileName || before.lastModified !== entry.lastModified || before.size !== entry.size) contentChanged.push(entry.concept);
|
|
11878
|
+
}
|
|
11879
|
+
return {
|
|
11880
|
+
namesMoved,
|
|
11881
|
+
contentChanged
|
|
11882
|
+
};
|
|
11883
|
+
}
|
|
11884
|
+
function sameAliases(a, b) {
|
|
11885
|
+
if (a.length !== b.length) return false;
|
|
11886
|
+
const keys = new Set(a.map(conceptKey));
|
|
11887
|
+
return b.every((alias) => keys.has(conceptKey(alias)));
|
|
11888
|
+
}
|
|
11889
|
+
//#endregion
|
|
11497
11890
|
//#region ../client/src/lib/storage/fs/filesystem-store.ts
|
|
11498
11891
|
/**
|
|
11499
11892
|
* The snapshot for one document's full text: identity from the registry, aliases and include
|
|
@@ -11529,6 +11922,13 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11529
11922
|
const { autosaveMs = 400, onConflict } = options;
|
|
11530
11923
|
const registry = /* @__PURE__ */ new Map();
|
|
11531
11924
|
let registrySig = "";
|
|
11925
|
+
/**
|
|
11926
|
+
* The registry as the last rescan announced it, what the next one's changes are measured
|
|
11927
|
+
* against. Not `registry` itself: a rename's steps patch that in place before the closing
|
|
11928
|
+
* rescan, which would then find nothing moved. The same entry objects, so a save's stamps,
|
|
11929
|
+
* kept in step on its entry, are never mistaken for an edit made outside the app.
|
|
11930
|
+
*/
|
|
11931
|
+
let announced = /* @__PURE__ */ new Map();
|
|
11532
11932
|
const open = /* @__PURE__ */ new Map();
|
|
11533
11933
|
const documentsChanged = /* @__PURE__ */ new Set();
|
|
11534
11934
|
const documentRemoved = /* @__PURE__ */ new Set();
|
|
@@ -11545,9 +11945,16 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11545
11945
|
function conceptOf(doc) {
|
|
11546
11946
|
return registry.get(doc.key)?.concept ?? doc.target;
|
|
11547
11947
|
}
|
|
11548
|
-
|
|
11948
|
+
/**
|
|
11949
|
+
* Tell listeners what a rescan changed. The document-set listeners hear whenever the listing
|
|
11950
|
+
* moved; the index hears it precisely (`registryChanges`): each file changed outside the app,
|
|
11951
|
+
* by name, and the names, once, when they moved. Unnamed for every listing change, it re-read
|
|
11952
|
+
* every document after each save, since a save's new file stamp moves the listing too.
|
|
11953
|
+
*/
|
|
11954
|
+
function emitRegistryChanged(changes) {
|
|
11549
11955
|
for (const listener of documentsChanged) listener();
|
|
11550
|
-
emitChange();
|
|
11956
|
+
for (const concept of changes.contentChanged) emitChange({ concept });
|
|
11957
|
+
if (changes.namesMoved) emitChange();
|
|
11551
11958
|
}
|
|
11552
11959
|
function emitDocumentRemoved(target) {
|
|
11553
11960
|
for (const listener of documentRemoved) listener(target);
|
|
@@ -11576,7 +11983,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11576
11983
|
/** Aliases are the file's to say: keep the entry in step without waiting for the next scan. */
|
|
11577
11984
|
function adoptAliases(entry, text) {
|
|
11578
11985
|
const aliases = aliasesOf(parseFrontmatter(text));
|
|
11579
|
-
if (sameAliases(aliases, entry.aliases)) return;
|
|
11986
|
+
if (sameAliases$1(aliases, entry.aliases)) return;
|
|
11580
11987
|
entry.aliases = aliases;
|
|
11581
11988
|
emitRegistryOnly();
|
|
11582
11989
|
}
|
|
@@ -11614,10 +12021,13 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11614
12021
|
});
|
|
11615
12022
|
registry.clear();
|
|
11616
12023
|
for (const entry of entries) registry.set(entry.key, entry);
|
|
12024
|
+
for (const doc of [...open.values()]) if (doc.removed && registry.has(doc.key)) await takeUpFileAgain(doc);
|
|
11617
12025
|
const sig = registrySignature(entries);
|
|
11618
12026
|
if (sig !== registrySig) {
|
|
11619
12027
|
registrySig = sig;
|
|
11620
|
-
|
|
12028
|
+
const changes = registryChanges(announced, entries);
|
|
12029
|
+
announced = new Map(registry);
|
|
12030
|
+
emitRegistryChanged(changes);
|
|
11621
12031
|
}
|
|
11622
12032
|
return entries;
|
|
11623
12033
|
}
|
|
@@ -11668,6 +12078,34 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11668
12078
|
}
|
|
11669
12079
|
}
|
|
11670
12080
|
/**
|
|
12081
|
+
* The aliases a rename carries along (ADR 0038, amended 2026-10-03), after its title steps,
|
|
12082
|
+
* on both arms: each holder's list with its rewrites applied, handed to `write` (the store's
|
|
12083
|
+
* `setAliases`, which goes through the open buffer or the file and patches the registry).
|
|
12084
|
+
* Never through `applyStep`, which retitles and moves files and would give a journal a
|
|
12085
|
+
* title. A holder a step retitled, or merged into another document, is found under the name
|
|
12086
|
+
* it landed on. Its aliases are read from the open buffer where there is one, so an alias
|
|
12087
|
+
* typed but not yet saved is kept. Returns the holders, by their names now.
|
|
12088
|
+
*/
|
|
12089
|
+
async function rewriteScopedAliases(plan, strategy, write) {
|
|
12090
|
+
const byHolder = /* @__PURE__ */ new Map();
|
|
12091
|
+
for (const rewrite of plan.aliases) {
|
|
12092
|
+
const key = conceptKey(landedName(plan, rewrite.holder));
|
|
12093
|
+
byHolder.set(key, [...byHolder.get(key) ?? [], rewrite]);
|
|
12094
|
+
}
|
|
12095
|
+
const holders = [];
|
|
12096
|
+
for (const [key, rewrites] of byHolder) {
|
|
12097
|
+
const entry = registry.get(key);
|
|
12098
|
+
if (!entry) continue;
|
|
12099
|
+
const openDoc = open.get(entry.key);
|
|
12100
|
+
if (openDoc) await openDoc.ready;
|
|
12101
|
+
const text = openDoc?.loaded ? openDoc.buffer : (await adapter.read(entry.subdir, entry.fileName)).text;
|
|
12102
|
+
if (!frontmatterIdentity(text).readable) throw new Error(unreadableBlockRefusal(plan.direct.from, entry.concept));
|
|
12103
|
+
await write(entry.concept, aliasesAfterRename(aliasesOf(parseFrontmatter(text)), rewrites, strategy));
|
|
12104
|
+
holders.push(entry.concept);
|
|
12105
|
+
}
|
|
12106
|
+
return holders;
|
|
12107
|
+
}
|
|
12108
|
+
/**
|
|
11671
12109
|
* One step of a rename plan: retitle the document, and if the target name is already
|
|
11672
12110
|
* taken, [[Merge]] into it instead.
|
|
11673
12111
|
*
|
|
@@ -11695,8 +12133,15 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11695
12133
|
}
|
|
11696
12134
|
const targetEntry = step.merges ? registry.get(conceptKey(step.into)) : void 0;
|
|
11697
12135
|
const survivor = targetEntry && targetEntry.key !== entry.key ? targetEntry : void 0;
|
|
12136
|
+
const survivorDoc = survivor ? open.get(survivor.key) : void 0;
|
|
12137
|
+
if (survivorDoc) {
|
|
12138
|
+
survivorDoc.save.cancel();
|
|
12139
|
+
await settled(survivorDoc);
|
|
12140
|
+
await survivorDoc.ready;
|
|
12141
|
+
}
|
|
12142
|
+
const throughBuffer = survivorDoc?.loaded === true;
|
|
11698
12143
|
if (survivor) {
|
|
11699
|
-
const existing = await adapter.read(survivor.subdir, survivor.fileName);
|
|
12144
|
+
const existing = { text: throughBuffer ? survivorDoc.buffer : (await adapter.read(survivor.subdir, survivor.fileName)).text };
|
|
11700
12145
|
if (!frontmatterIdentity(existing.text).readable) throw new Error(unreadableBlockRefusal(step.from, survivor.concept));
|
|
11701
12146
|
const existingFm = parseFrontmatter(existing.text);
|
|
11702
12147
|
const merged = mergeDocuments({
|
|
@@ -11718,7 +12163,19 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11718
12163
|
}, { addBlock: true });
|
|
11719
12164
|
const subdir = survivor?.subdir ?? entry.subdir;
|
|
11720
12165
|
const fileName = survivor?.fileName ?? await allocateFileName(subdir, step.into, entry);
|
|
11721
|
-
|
|
12166
|
+
let written;
|
|
12167
|
+
if (throughBuffer && survivorDoc) {
|
|
12168
|
+
survivorDoc.handle.applyChange({
|
|
12169
|
+
from: 0,
|
|
12170
|
+
to: survivorDoc.buffer.length,
|
|
12171
|
+
insert: content
|
|
12172
|
+
}, "external");
|
|
12173
|
+
await saveNow(survivorDoc);
|
|
12174
|
+
written = {
|
|
12175
|
+
lastModified: survivorDoc.lastModified,
|
|
12176
|
+
size: survivorDoc.size
|
|
12177
|
+
};
|
|
12178
|
+
} else written = await adapter.write(subdir, fileName, content);
|
|
11722
12179
|
if (!(subdir === entry.subdir && fileName === entry.fileName)) await adapter.remove(entry.subdir, entry.fileName);
|
|
11723
12180
|
registry.delete(entry.key);
|
|
11724
12181
|
if (survivor) registry.delete(survivor.key);
|
|
@@ -11733,6 +12190,8 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11733
12190
|
size: written.size
|
|
11734
12191
|
});
|
|
11735
12192
|
if (openDoc) open.delete(entry.key);
|
|
12193
|
+
const stale = open.get(conceptKey(step.into));
|
|
12194
|
+
if (stale?.removed) await takeUpFileAgain(stale);
|
|
11736
12195
|
}
|
|
11737
12196
|
/**
|
|
11738
12197
|
* Start the document's save if none is in flight, else note that one is wanted. One writable
|
|
@@ -11823,7 +12282,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11823
12282
|
/** The open document `target` names, opening it if it is not; throws when nothing has that name. */
|
|
11824
12283
|
function openDocNamed(target) {
|
|
11825
12284
|
const existing = open.get(conceptKey(target));
|
|
11826
|
-
if (existing) return existing;
|
|
12285
|
+
if (existing && !(existing.removed && existing.deletedHere)) return existing;
|
|
11827
12286
|
const entry = entryNamed(target);
|
|
11828
12287
|
if (!entry) throw new DocumentNotFoundError(target);
|
|
11829
12288
|
const opened = open.get(entry.key);
|
|
@@ -11847,6 +12306,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11847
12306
|
loadError: null,
|
|
11848
12307
|
ready: Promise.resolve(),
|
|
11849
12308
|
removed: false,
|
|
12309
|
+
deletedHere: false,
|
|
11850
12310
|
conflict: null,
|
|
11851
12311
|
listeners: /* @__PURE__ */ new Set(),
|
|
11852
12312
|
save: void 0,
|
|
@@ -11866,6 +12326,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11866
12326
|
doc.dirty = true;
|
|
11867
12327
|
if (doc.removed) {
|
|
11868
12328
|
doc.removed = false;
|
|
12329
|
+
doc.deletedHere = false;
|
|
11869
12330
|
options.onResurrected?.(doc.target);
|
|
11870
12331
|
}
|
|
11871
12332
|
doc.save.call();
|
|
@@ -11941,6 +12402,27 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11941
12402
|
doc.save.cancel();
|
|
11942
12403
|
onConflict?.(doc.conflict);
|
|
11943
12404
|
}
|
|
12405
|
+
/**
|
|
12406
|
+
* A document removed while open keeps its buffer, so its tab still shows it and an edit there
|
|
12407
|
+
* brings it back (ADR 0039 §4). It answers to its name only until a file does again: a page
|
|
12408
|
+
* created under the name, in the app or by another program. Then it takes that file up as an
|
|
12409
|
+
* opened document would. A buffer with no unsaved edits reloads from the file, and one with
|
|
12410
|
+
* unsaved edits raises a conflict rather than being written over it. Before, it kept the name
|
|
12411
|
+
* for the session: the name opened the deleted text, the index read it, and the first
|
|
12412
|
+
* keystroke saved it over the new file.
|
|
12413
|
+
*/
|
|
12414
|
+
async function takeUpFileAgain(doc) {
|
|
12415
|
+
const entry = registry.get(doc.key);
|
|
12416
|
+
if (!doc.removed || !entry) return;
|
|
12417
|
+
const read = await readIfPresent(entry.subdir, entry.fileName);
|
|
12418
|
+
if (!read) return;
|
|
12419
|
+
doc.removed = false;
|
|
12420
|
+
doc.deletedHere = false;
|
|
12421
|
+
doc.subdir = entry.subdir;
|
|
12422
|
+
doc.fileName = entry.fileName;
|
|
12423
|
+
doc.target = entry.concept;
|
|
12424
|
+
followDisk(doc, read);
|
|
12425
|
+
}
|
|
11944
12426
|
/** A file's content, or null when it does not exist; any other failure rejects. */
|
|
11945
12427
|
async function readIfPresent(subdir, fileName) {
|
|
11946
12428
|
try {
|
|
@@ -12019,8 +12501,8 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
12019
12501
|
if (!entry) throw new DocumentNotFoundError(target);
|
|
12020
12502
|
const next = normaliseAliases(aliases, entry.concept);
|
|
12021
12503
|
const doc = open.get(entry.key);
|
|
12022
|
-
if (doc)
|
|
12023
|
-
|
|
12504
|
+
if (doc) await doc.ready;
|
|
12505
|
+
if (doc?.loaded) {
|
|
12024
12506
|
const text = withFrontmatterIdentity(doc.buffer, { aliases: next }, { addBlock: next.length > 0 });
|
|
12025
12507
|
if (text !== doc.buffer) doc.handle.applyChange({
|
|
12026
12508
|
from: 0,
|
|
@@ -12037,7 +12519,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
12037
12519
|
emitChange({ concept: entry.concept });
|
|
12038
12520
|
}
|
|
12039
12521
|
}
|
|
12040
|
-
if (!sameAliases(entry.aliases, next)) {
|
|
12522
|
+
if (!sameAliases$1(entry.aliases, next)) {
|
|
12041
12523
|
entry.aliases = next;
|
|
12042
12524
|
emitRegistryOnly();
|
|
12043
12525
|
}
|
|
@@ -12100,8 +12582,10 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
12100
12582
|
return indexSnapshotOf(entry, text);
|
|
12101
12583
|
},
|
|
12102
12584
|
async reconcile() {
|
|
12585
|
+
const wasRemoved = new Set([...open.values()].filter((doc) => doc.removed));
|
|
12103
12586
|
await refreshRegistry();
|
|
12104
12587
|
for (const doc of [...open.values()]) {
|
|
12588
|
+
if (wasRemoved.has(doc) && !doc.removed) continue;
|
|
12105
12589
|
if (!registry.has(doc.key)) followFile(doc);
|
|
12106
12590
|
await reconcileDoc(doc);
|
|
12107
12591
|
}
|
|
@@ -12178,12 +12662,19 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
12178
12662
|
const openDoc = open.get(other.key);
|
|
12179
12663
|
if (openDoc) await settled(openDoc);
|
|
12180
12664
|
return (await adapter.read(other.subdir, other.fileName)).text;
|
|
12665
|
+
}, async (concept) => {
|
|
12666
|
+
const other = registry.get(conceptKey(concept));
|
|
12667
|
+
if (!other) return null;
|
|
12668
|
+
const openDoc = open.get(other.key);
|
|
12669
|
+
if (openDoc) await openDoc.ready;
|
|
12670
|
+
return openDoc?.loaded ? openDoc.buffer : (await adapter.read(other.subdir, other.fileName)).text;
|
|
12181
12671
|
});
|
|
12182
12672
|
},
|
|
12183
12673
|
async renamePage(from, to, options) {
|
|
12184
12674
|
const plan = await this.planRename(from, to, 0);
|
|
12185
12675
|
if (plan.refusal) throw new Error(plan.refusal);
|
|
12186
12676
|
for (const step of renameSteps(plan)) await applyStep(step, options.strategy);
|
|
12677
|
+
const aliasesRewritten = await rewriteScopedAliases(plan, options.strategy, (target, aliases) => this.setAliases(target, aliases));
|
|
12187
12678
|
let rewritten = 0;
|
|
12188
12679
|
const rewrittenDocuments = [];
|
|
12189
12680
|
if (options.strategy === "rewrite") for (const other of [...registry.values()]) {
|
|
@@ -12214,12 +12705,16 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
12214
12705
|
rewrittenDocuments.push(other.concept);
|
|
12215
12706
|
}
|
|
12216
12707
|
await refreshRegistry();
|
|
12708
|
+
const wrote = [...renameSteps(plan).filter((step) => step.hasDocument).map((step) => step.into), ...rewrittenDocuments];
|
|
12709
|
+
for (const concept of new Set(wrote)) emitChange({ concept });
|
|
12217
12710
|
return {
|
|
12218
12711
|
concept: plan.direct.into,
|
|
12712
|
+
aliasOf: plan.aliasOf === null ? null : landedName(plan, plan.aliasOf),
|
|
12219
12713
|
rewritten,
|
|
12220
12714
|
rewrittenDocuments,
|
|
12221
12715
|
cascaded: plan.cascade.length,
|
|
12222
|
-
merged: mergeCount(plan)
|
|
12716
|
+
merged: mergeCount(plan),
|
|
12717
|
+
aliasesRewritten
|
|
12223
12718
|
};
|
|
12224
12719
|
},
|
|
12225
12720
|
async deleteDocument(concept) {
|
|
@@ -12229,6 +12724,8 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
12229
12724
|
if (doc) {
|
|
12230
12725
|
doc.save.cancel();
|
|
12231
12726
|
doc.removed = true;
|
|
12727
|
+
doc.deletedHere = true;
|
|
12728
|
+
doc.dirty = false;
|
|
12232
12729
|
await settled(doc);
|
|
12233
12730
|
}
|
|
12234
12731
|
await adapter.remove(entry.subdir, entry.fileName);
|
|
@@ -12242,7 +12739,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
12242
12739
|
},
|
|
12243
12740
|
async flushAll() {
|
|
12244
12741
|
await Promise.all([...open.values()].map(saveNow));
|
|
12245
|
-
const unwritten = [...open.values()].filter((doc) => doc.dirty).map((doc) => doc.target);
|
|
12742
|
+
const unwritten = [...open.values()].filter((doc) => doc.dirty && !doc.removed).map((doc) => doc.target);
|
|
12246
12743
|
if (unwritten.length > 0) throw new Error(`Edits to ${unwritten.join(", ")} could not be written to the folder yet, so it does not hold them. Try again once they are saved.`);
|
|
12247
12744
|
},
|
|
12248
12745
|
async dispose() {
|
|
@@ -12313,6 +12810,23 @@ function bodyView(handle) {
|
|
|
12313
12810
|
subscribe: (listener) => handle.subscribe((text) => listener(bodyOf$1(text).body))
|
|
12314
12811
|
};
|
|
12315
12812
|
}
|
|
12813
|
+
//#endregion
|
|
12814
|
+
//#region ../client/src/lib/reached-within.ts
|
|
12815
|
+
/**
|
|
12816
|
+
* Whether `promise` settled within `timeoutMs`. A milestone that never arrives (offline, a silent
|
|
12817
|
+
* server, an index that keeps being handed changes) is a bounded wait and a `false`, never a hang
|
|
12818
|
+
* and never an unhandled rejection: the caller decides what an unreached milestone means.
|
|
12819
|
+
*/
|
|
12820
|
+
async function reachedWithin(promise, timeoutMs) {
|
|
12821
|
+
let timer;
|
|
12822
|
+
try {
|
|
12823
|
+
return await Promise.race([promise.then(() => true, () => false), new Promise((resolve) => {
|
|
12824
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
12825
|
+
})]);
|
|
12826
|
+
} finally {
|
|
12827
|
+
if (timer) clearTimeout(timer);
|
|
12828
|
+
}
|
|
12829
|
+
}
|
|
12316
12830
|
function createSemanticIndex(options) {
|
|
12317
12831
|
const { index, model } = options;
|
|
12318
12832
|
const floor = options.floor ?? .25;
|
|
@@ -12430,21 +12944,6 @@ function createBreather(now = () => performance.now()) {
|
|
|
12430
12944
|
function conceptOf(entry) {
|
|
12431
12945
|
return entry.kind === "journal" ? entry.date ?? "" : entry.title ?? "";
|
|
12432
12946
|
}
|
|
12433
|
-
/**
|
|
12434
|
-
* Whether `promise` settled within `timeoutMs`. A sync milestone that never arrives (offline, a
|
|
12435
|
-
* silent server) is a bounded wait and a `false`, never a hang and never an unhandled rejection:
|
|
12436
|
-
* the caller decides what an unreached milestone means.
|
|
12437
|
-
*/
|
|
12438
|
-
async function reachedWithin(promise, timeoutMs) {
|
|
12439
|
-
let timer;
|
|
12440
|
-
try {
|
|
12441
|
-
return await Promise.race([promise.then(() => true, () => false), new Promise((resolve) => {
|
|
12442
|
-
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
12443
|
-
})]);
|
|
12444
|
-
} finally {
|
|
12445
|
-
if (timer) clearTimeout(timer);
|
|
12446
|
-
}
|
|
12447
|
-
}
|
|
12448
12947
|
function createServerDocumentStore(graph, options) {
|
|
12449
12948
|
const registry = graph.registry();
|
|
12450
12949
|
/** Engines created between yields while snapshotting — small enough to keep frames free. */
|
|
@@ -12566,9 +13065,11 @@ function createServerDocumentStore(graph, options) {
|
|
|
12566
13065
|
const additions = [];
|
|
12567
13066
|
const renames = [];
|
|
12568
13067
|
let additionsOnly = true;
|
|
13068
|
+
const named = /* @__PURE__ */ new Map();
|
|
12569
13069
|
for (const docId of changedDocIds) {
|
|
12570
13070
|
const before = knownRegistry.get(docId);
|
|
12571
13071
|
const after = nextRegistry.get(docId);
|
|
13072
|
+
for (const entry of [before, after]) if (entry) named.set(conceptKey(entry.concept), entry.concept);
|
|
12572
13073
|
if (fromElsewhere && before && after && before.concept !== after.concept) renames.push({
|
|
12573
13074
|
docId,
|
|
12574
13075
|
from: before.concept,
|
|
@@ -12588,7 +13089,10 @@ function createServerDocumentStore(graph, options) {
|
|
|
12588
13089
|
knownRegistry = nextRegistry;
|
|
12589
13090
|
docsChangedListeners.forEach((l) => l());
|
|
12590
13091
|
if (!restored) if (additionsOnly) for (const concept of additions) changeListeners.forEach((listener) => listener({ concept }));
|
|
12591
|
-
else
|
|
13092
|
+
else {
|
|
13093
|
+
for (const concept of named.values()) changeListeners.forEach((listener) => listener({ concept }));
|
|
13094
|
+
changeListeners.forEach((listener) => listener());
|
|
13095
|
+
}
|
|
12592
13096
|
for (const { docId, from, to } of renames) {
|
|
12593
13097
|
const first = renamesFromElsewhere.get(docId)?.from ?? from;
|
|
12594
13098
|
if (first === to) renamesFromElsewhere.delete(docId);
|
|
@@ -12659,18 +13163,31 @@ function createServerDocumentStore(graph, options) {
|
|
|
12659
13163
|
* Bring a block the text carries in line with the registry (ADR 0061). Part of the action that
|
|
12660
13164
|
* changed the registry - a rename, an alias change - and never a reaction to one arriving: if
|
|
12661
13165
|
* every member's device rewrote the block on seeing the registry move, the CRDT would merge N
|
|
12662
|
-
* identical concurrent edits into duplicated text.
|
|
12663
|
-
*
|
|
13166
|
+
* identical concurrent edits into duplicated text. A journal's title is its date, so only its
|
|
13167
|
+
* aliases are mirrored.
|
|
13168
|
+
*
|
|
13169
|
+
* A document with aliases and no block is given one (amended 2026-10-03): an alias is never
|
|
13170
|
+
* invisible, so a page that answers to another name says so in its text. Without aliases a
|
|
13171
|
+
* document without a block keeps none. The caller has brought the text current first, or the
|
|
13172
|
+
* block would be written above a block still on its way.
|
|
12664
13173
|
*/
|
|
12665
13174
|
function writeBackIdentity(docId) {
|
|
12666
13175
|
const entry = registry.get(docId);
|
|
12667
13176
|
if (!entry) return;
|
|
12668
13177
|
const ytext = graph.docSync(docId).doc.getText("content");
|
|
12669
13178
|
const text = ytext.toString();
|
|
12670
|
-
const
|
|
13179
|
+
const aliases = entry.aliases ?? [];
|
|
13180
|
+
replaceBlock(ytext, text, withFrontmatterIdentity(text, {
|
|
12671
13181
|
...entry.kind === "page" ? { title: conceptOf(entry) } : {},
|
|
12672
|
-
aliases
|
|
12673
|
-
});
|
|
13182
|
+
aliases
|
|
13183
|
+
}, { addBlock: aliases.length > 0 }));
|
|
13184
|
+
}
|
|
13185
|
+
/**
|
|
13186
|
+
* Write `next`'s block over `text`'s. Only the block is replaced: the body is verbatim in
|
|
13187
|
+
* `next`, and a whole-document rewrite would be a whole-document edit for every other member
|
|
13188
|
+
* to receive.
|
|
13189
|
+
*/
|
|
13190
|
+
function replaceBlock(ytext, text, next) {
|
|
12674
13191
|
if (next === text) return;
|
|
12675
13192
|
const before = frontmatterSpan(text)?.end ?? 0;
|
|
12676
13193
|
const after = frontmatterSpan(next)?.end ?? 0;
|
|
@@ -12899,12 +13416,7 @@ function createServerDocumentStore(graph, options) {
|
|
|
12899
13416
|
if (timer) clearTimeout(timer);
|
|
12900
13417
|
}
|
|
12901
13418
|
},
|
|
12902
|
-
|
|
12903
|
-
if (registryConfirmed) return true;
|
|
12904
|
-
if (!graph.isConnected()) return false;
|
|
12905
|
-
if (await reachedWithin(graph.rootCaughtUp(), timeoutMs)) registryConfirmed = true;
|
|
12906
|
-
return registryConfirmed;
|
|
12907
|
-
},
|
|
13419
|
+
confirmRegistry,
|
|
12908
13420
|
onDocumentsChanged(listener) {
|
|
12909
13421
|
docsChangedListeners.add(listener);
|
|
12910
13422
|
return () => docsChangedListeners.delete(listener);
|
|
@@ -13015,19 +13527,25 @@ function createServerDocumentStore(graph, options) {
|
|
|
13015
13527
|
});
|
|
13016
13528
|
const concepts = /* @__PURE__ */ new Map();
|
|
13017
13529
|
const documents = [];
|
|
13018
|
-
let
|
|
13530
|
+
let namesMoved = false;
|
|
13019
13531
|
for (const docId of checkpoint.docIds) {
|
|
13020
13532
|
const entry = entriesByDocId.get(docId);
|
|
13021
13533
|
if (!entry) {
|
|
13022
|
-
|
|
13023
|
-
|
|
13534
|
+
namesMoved = true;
|
|
13535
|
+
continue;
|
|
13024
13536
|
}
|
|
13025
13537
|
const concept = conceptOf(entry);
|
|
13026
13538
|
concepts.set(conceptKey(concept), concept);
|
|
13027
13539
|
documents.push(await snapshot(docId, entry));
|
|
13028
13540
|
}
|
|
13029
13541
|
return {
|
|
13030
|
-
changes:
|
|
13542
|
+
changes: [...concepts.values()].map((concept) => ({ concept })),
|
|
13543
|
+
namesMoved,
|
|
13544
|
+
...namesMoved ? { names: entries.map(([, entry]) => ({
|
|
13545
|
+
concept: conceptOf(entry),
|
|
13546
|
+
kind: entry.kind,
|
|
13547
|
+
aliases: entry.aliases ?? []
|
|
13548
|
+
})) } : {},
|
|
13031
13549
|
documents,
|
|
13032
13550
|
streamForIndex: checkpointStream,
|
|
13033
13551
|
acknowledge: checkpoint.acknowledge
|
|
@@ -13054,14 +13572,64 @@ function createServerDocumentStore(graph, options) {
|
|
|
13054
13572
|
},
|
|
13055
13573
|
async setAliases(target, aliases) {
|
|
13056
13574
|
const docId = docIdFor(target);
|
|
13057
|
-
const
|
|
13058
|
-
if (!docId || !
|
|
13059
|
-
|
|
13060
|
-
|
|
13061
|
-
|
|
13062
|
-
|
|
13063
|
-
|
|
13064
|
-
|
|
13575
|
+
const known = docId ? registry.get(docId) : void 0;
|
|
13576
|
+
if (!docId || !known) throw new DocumentNotFoundError(target);
|
|
13577
|
+
if (!givesFirstBlock(docId, known, aliases)) {
|
|
13578
|
+
writeAliases(docId, known, aliases);
|
|
13579
|
+
return;
|
|
13580
|
+
}
|
|
13581
|
+
const materialised = await materialise([docId], COLD_CONTENT_TIMEOUT_MS);
|
|
13582
|
+
try {
|
|
13583
|
+
const entry = registry.get(docId);
|
|
13584
|
+
if (!entry) throw new DocumentNotFoundError(target);
|
|
13585
|
+
if (materialised.unconfirmed.length > 0) throw new DocumentUnconfirmedError(target);
|
|
13586
|
+
writeAliases(docId, entry, aliases);
|
|
13587
|
+
} finally {
|
|
13588
|
+
materialised.release();
|
|
13589
|
+
}
|
|
13590
|
+
},
|
|
13591
|
+
async revealHiddenAliases() {
|
|
13592
|
+
if (!graph.isConnected() || !await confirmRegistry()) throw new Error("This graph has not finished syncing to this device, so nothing was changed.");
|
|
13593
|
+
const report = {
|
|
13594
|
+
revealed: [],
|
|
13595
|
+
unreadable: [],
|
|
13596
|
+
unconfirmed: []
|
|
13597
|
+
};
|
|
13598
|
+
const holders = [];
|
|
13599
|
+
registry.forEach((entry, docId) => {
|
|
13600
|
+
if ((entry.aliases ?? []).length > 0) holders.push(docId);
|
|
13601
|
+
});
|
|
13602
|
+
if (holders.length === 0) return report;
|
|
13603
|
+
const materialised = await materialise(holders, COLD_CONTENT_TIMEOUT_MS);
|
|
13604
|
+
try {
|
|
13605
|
+
const unconfirmed = new Set(materialised.unconfirmed);
|
|
13606
|
+
for (const docId of holders) {
|
|
13607
|
+
const entry = registry.get(docId);
|
|
13608
|
+
const aliases = entry?.aliases ?? [];
|
|
13609
|
+
if (!entry || aliases.length === 0) continue;
|
|
13610
|
+
const name = conceptOf(entry);
|
|
13611
|
+
if (unconfirmed.has(docId)) {
|
|
13612
|
+
report.unconfirmed.push(name);
|
|
13613
|
+
continue;
|
|
13614
|
+
}
|
|
13615
|
+
const ytext = graph.docSync(docId).doc.getText("content");
|
|
13616
|
+
const text = ytext.toString();
|
|
13617
|
+
const claim = frontmatterIdentity(text);
|
|
13618
|
+
if (claim.hasBlock && !claim.readable) {
|
|
13619
|
+
report.unreadable.push(name);
|
|
13620
|
+
continue;
|
|
13621
|
+
}
|
|
13622
|
+
if (claim.hasAliasesKey) continue;
|
|
13623
|
+
replaceBlock(ytext, text, claim.hasBlock ? withFrontmatterIdentity(text, { aliases }) : withFrontmatterIdentity(text, {
|
|
13624
|
+
...entry.kind === "page" ? { title: name } : {},
|
|
13625
|
+
aliases
|
|
13626
|
+
}, { addBlock: true }));
|
|
13627
|
+
report.revealed.push(name);
|
|
13628
|
+
}
|
|
13629
|
+
} finally {
|
|
13630
|
+
materialised.release();
|
|
13631
|
+
}
|
|
13632
|
+
return report;
|
|
13065
13633
|
},
|
|
13066
13634
|
async spliceIfUnchanged(docId, expected, splices, spliceOptions) {
|
|
13067
13635
|
if (!registry.has(docId)) return "gone";
|
|
@@ -13118,7 +13686,8 @@ function createServerDocumentStore(graph, options) {
|
|
|
13118
13686
|
},
|
|
13119
13687
|
async planRename(from, to, referencingDocuments) {
|
|
13120
13688
|
const docId = docIdFor(from);
|
|
13121
|
-
const
|
|
13689
|
+
const resolved = docId ? registry.get(docId) : void 0;
|
|
13690
|
+
const entry = resolved && conceptKey(conceptOf(resolved)) === conceptKey(from) ? resolved : void 0;
|
|
13122
13691
|
let counted = 0;
|
|
13123
13692
|
const concepts = [];
|
|
13124
13693
|
const aliases = [];
|
|
@@ -13152,6 +13721,10 @@ function createServerDocumentStore(graph, options) {
|
|
|
13152
13721
|
const id = docIdFor(concept);
|
|
13153
13722
|
if (id) wanted.add(id);
|
|
13154
13723
|
}
|
|
13724
|
+
for (const alias of plan.aliases) {
|
|
13725
|
+
const id = docIdFor(alias.holder);
|
|
13726
|
+
if (id) wanted.add(id);
|
|
13727
|
+
}
|
|
13155
13728
|
if (options.strategy === "rewrite") if (options.referencing) for (const concept of options.referencing) {
|
|
13156
13729
|
const id = docIdFor(concept);
|
|
13157
13730
|
if (id) wanted.add(id);
|
|
@@ -13184,12 +13757,55 @@ function createServerDocumentStore(graph, options) {
|
|
|
13184
13757
|
graph.dispose();
|
|
13185
13758
|
}
|
|
13186
13759
|
};
|
|
13760
|
+
async function confirmRegistry(timeoutMs = 5e3) {
|
|
13761
|
+
if (registryConfirmed) return true;
|
|
13762
|
+
if (!graph.isConnected()) return false;
|
|
13763
|
+
if (await reachedWithin(graph.rootCaughtUp(), timeoutMs)) registryConfirmed = true;
|
|
13764
|
+
return registryConfirmed;
|
|
13765
|
+
}
|
|
13766
|
+
/** Whether writing `aliases` back would give the document its first block (amended 2026-10-03). */
|
|
13767
|
+
function givesFirstBlock(docId, entry, aliases) {
|
|
13768
|
+
if (normaliseAliases(aliases, conceptOf(entry)).length === 0) return false;
|
|
13769
|
+
return frontmatterSpan(graph.docSync(docId).doc.getText("content").toString()) === null;
|
|
13770
|
+
}
|
|
13771
|
+
/** Write a document's aliases to the registry, the authority, and show them in its block, added if need be. */
|
|
13772
|
+
function writeAliases(docId, entry, aliases) {
|
|
13773
|
+
const next = normaliseAliases(aliases, conceptOf(entry));
|
|
13774
|
+
const updated = { ...entry };
|
|
13775
|
+
if (next.length > 0) updated.aliases = next;
|
|
13776
|
+
else delete updated.aliases;
|
|
13777
|
+
registry.set(docId, updated);
|
|
13778
|
+
writeBackIdentity(docId);
|
|
13779
|
+
}
|
|
13780
|
+
/**
|
|
13781
|
+
* The aliases a rename carries along (ADR 0038, amended 2026-10-03), after its title steps,
|
|
13782
|
+
* on both arms: each holder's registry list with its rewrites applied. A holder a step
|
|
13783
|
+
* retitled, or merged into another document, is found under the name it landed on. Returns
|
|
13784
|
+
* the holders, by their names now.
|
|
13785
|
+
*/
|
|
13786
|
+
function rewriteScopedAliases(plan, options) {
|
|
13787
|
+
const byHolder = /* @__PURE__ */ new Map();
|
|
13788
|
+
for (const rewrite of plan.aliases) {
|
|
13789
|
+
const docId = docIdFor(landedName(plan, rewrite.holder));
|
|
13790
|
+
if (docId) byHolder.set(docId, [...byHolder.get(docId) ?? [], rewrite]);
|
|
13791
|
+
}
|
|
13792
|
+
const holders = [];
|
|
13793
|
+
for (const [docId, rewrites] of byHolder) {
|
|
13794
|
+
const entry = registry.get(docId);
|
|
13795
|
+
if (!entry) continue;
|
|
13796
|
+
writeAliases(docId, entry, aliasesAfterRename(entry.aliases ?? [], rewrites, options.strategy));
|
|
13797
|
+
holders.push(conceptOf(entry));
|
|
13798
|
+
}
|
|
13799
|
+
return holders;
|
|
13800
|
+
}
|
|
13187
13801
|
/**
|
|
13188
13802
|
* The writes of a rename, over documents `renamePage` has already brought current and
|
|
13189
|
-
* holds live: the registry steps, then the body pass over `readable`
|
|
13803
|
+
* holds live: the registry steps, the scoped aliases, then the body pass over `readable`
|
|
13804
|
+
* (ADR 0038).
|
|
13190
13805
|
*/
|
|
13191
13806
|
function applyRename(plan, options, readable) {
|
|
13192
13807
|
for (const step of renameSteps(plan)) {
|
|
13808
|
+
if (!step.hasDocument) continue;
|
|
13193
13809
|
const docId = docIdFor(step.from);
|
|
13194
13810
|
if (!docId) continue;
|
|
13195
13811
|
const entry = registry.get(docId);
|
|
@@ -13233,6 +13849,7 @@ function createServerDocumentStore(graph, options) {
|
|
|
13233
13849
|
registry.set(docId, next);
|
|
13234
13850
|
writeBackIdentity(docId);
|
|
13235
13851
|
}
|
|
13852
|
+
const aliasesRewritten = rewriteScopedAliases(plan, options);
|
|
13236
13853
|
let rewritten = 0;
|
|
13237
13854
|
const rewrittenDocuments = [];
|
|
13238
13855
|
if (options.strategy === "rewrite") {
|
|
@@ -13267,10 +13884,12 @@ function createServerDocumentStore(graph, options) {
|
|
|
13267
13884
|
}
|
|
13268
13885
|
return {
|
|
13269
13886
|
concept: plan.direct.into,
|
|
13887
|
+
aliasOf: plan.aliasOf === null ? null : landedName(plan, plan.aliasOf),
|
|
13270
13888
|
rewritten,
|
|
13271
13889
|
rewrittenDocuments,
|
|
13272
13890
|
cascaded: plan.cascade.length,
|
|
13273
|
-
merged: mergeCount(plan)
|
|
13891
|
+
merged: mergeCount(plan),
|
|
13892
|
+
aliasesRewritten
|
|
13274
13893
|
};
|
|
13275
13894
|
}
|
|
13276
13895
|
}
|
|
@@ -13726,6 +14345,12 @@ var INDEX_FOLLOW_MS = 5e3;
|
|
|
13726
14345
|
* stays for an index without `settled`.
|
|
13727
14346
|
*/
|
|
13728
14347
|
function followIndex(index, source, followMs = INDEX_FOLLOW_MS) {
|
|
14348
|
+
if (index.settled) return {
|
|
14349
|
+
async settled() {
|
|
14350
|
+
await reachedWithin(index.settled(), followMs);
|
|
14351
|
+
},
|
|
14352
|
+
dispose() {}
|
|
14353
|
+
};
|
|
13729
14354
|
let pending = false;
|
|
13730
14355
|
let waiters = [];
|
|
13731
14356
|
const stopSource = source.onChange(() => {
|
|
@@ -13742,13 +14367,7 @@ function followIndex(index, source, followMs = INDEX_FOLLOW_MS) {
|
|
|
13742
14367
|
if (!pending) return Promise.resolve();
|
|
13743
14368
|
return new Promise((resolve) => {
|
|
13744
14369
|
waiters.push(resolve);
|
|
13745
|
-
|
|
13746
|
-
timer.unref?.();
|
|
13747
|
-
if (index.settled) index.settled().then(() => {
|
|
13748
|
-
pending = false;
|
|
13749
|
-
clearTimeout(timer);
|
|
13750
|
-
resolve();
|
|
13751
|
-
});
|
|
14370
|
+
setTimeout(resolve, followMs).unref?.();
|
|
13752
14371
|
});
|
|
13753
14372
|
},
|
|
13754
14373
|
dispose() {
|
|
@@ -14153,7 +14772,11 @@ async function openHeadlessFolder(deps) {
|
|
|
14153
14772
|
planRename: (from, to, referencingDocuments) => store.planRename(from, to, referencingDocuments),
|
|
14154
14773
|
async renamePage(from, to, options) {
|
|
14155
14774
|
const result = await store.renamePage(from, to, options);
|
|
14156
|
-
for (const concept of [
|
|
14775
|
+
for (const concept of [
|
|
14776
|
+
result.concept,
|
|
14777
|
+
...result.rewrittenDocuments,
|
|
14778
|
+
...result.aliasesRewritten
|
|
14779
|
+
]) await store.flushDocument(concept);
|
|
14157
14780
|
return result;
|
|
14158
14781
|
},
|
|
14159
14782
|
async readBodies() {
|
|
@@ -15751,8 +16374,8 @@ function createDocumentRenderer(options) {
|
|
|
15751
16374
|
*
|
|
15752
16375
|
* Ten places used to spell this out, and each spelled the destination `[^)\s]+`, which stops at
|
|
15753
16376
|
* the FIRST closing parenthesis. That is wrong the moment a file name contains one, and imported
|
|
15754
|
-
* attachments contain them all the time - `
|
|
15755
|
-
*
|
|
16377
|
+
* attachments contain them all the time - `Quote_320_from_Garden_Fencing_ltd_(1)_1700000000000_0.pdf`
|
|
16378
|
+
* has the shape they take. Every reader then saw the same truncated target, so the link styling and the
|
|
15756
16379
|
* download and delete controls landed in the middle of the text, and the [[Local Mirror]] reported
|
|
15757
16380
|
* the stub as a document link to an attachment the graph plainly had (2026-09-10).
|
|
15758
16381
|
*
|
|
@@ -18580,9 +19203,13 @@ function conceptExists(graph, concept) {
|
|
|
18580
19203
|
if (resolveIdentity(graph, concept)) return true;
|
|
18581
19204
|
return graph.index.candidate(concept) !== void 0;
|
|
18582
19205
|
}
|
|
18583
|
-
/**
|
|
19206
|
+
/**
|
|
19207
|
+
* The documents whose BODIES link to the concept by that very name - what a rewrite has to read
|
|
19208
|
+
* (ADR 0083: a title reference is the cascade's). The page's other names share its backlinks, and
|
|
19209
|
+
* the rewrite leaves links that say them alone.
|
|
19210
|
+
*/
|
|
18584
19211
|
async function referencingDocuments(graph, concept) {
|
|
18585
|
-
return (await graph.index.backlinks(concept)).filter(referencedInBody).map((group) => group.sourceConcept);
|
|
19212
|
+
return (await graph.index.backlinks(concept)).filter((group) => referencedInBody(group, concept)).map((group) => group.sourceConcept);
|
|
18586
19213
|
}
|
|
18587
19214
|
/** Protected documents in the graph: their references can be neither seen nor rewritten. */
|
|
18588
19215
|
function protectedDocumentCount(graph) {
|
|
@@ -18611,11 +19238,12 @@ async function planRename(graph, args) {
|
|
|
18611
19238
|
if (!conceptExists(graph, from)) throw new ToolError("not_found", `Nothing in the graph is named "${from}".`);
|
|
18612
19239
|
const identity = resolveIdentity(graph, from);
|
|
18613
19240
|
if (identity) refuseIfProtected(identity.concept, await liveText(graph, identity));
|
|
18614
|
-
const
|
|
18615
|
-
const
|
|
19241
|
+
const subject = identity?.concept ?? from;
|
|
19242
|
+
const referencing = await referencingDocuments(graph, subject);
|
|
19243
|
+
const plan = await graph.store.planRename(subject, to, referencing.length);
|
|
18616
19244
|
const steps = renameSteps(plan);
|
|
18617
19245
|
return {
|
|
18618
|
-
from:
|
|
19246
|
+
from: subject,
|
|
18619
19247
|
to,
|
|
18620
19248
|
refusal: plan.refusal,
|
|
18621
19249
|
direct: renameStepView(plan.direct),
|
|
@@ -18625,6 +19253,11 @@ async function planRename(graph, args) {
|
|
|
18625
19253
|
from: step.from,
|
|
18626
19254
|
into: step.into
|
|
18627
19255
|
})),
|
|
19256
|
+
aliases: plan.aliases.map((alias) => ({
|
|
19257
|
+
document: alias.holder,
|
|
19258
|
+
from: alias.from,
|
|
19259
|
+
to: alias.to
|
|
19260
|
+
})),
|
|
18628
19261
|
protectedDocuments: protectedDocumentCount(graph)
|
|
18629
19262
|
};
|
|
18630
19263
|
}
|
|
@@ -18670,7 +19303,8 @@ async function rename$1(graph, args) {
|
|
|
18670
19303
|
from: step.from,
|
|
18671
19304
|
to: step.into
|
|
18672
19305
|
})),
|
|
18673
|
-
merged: result.merged
|
|
19306
|
+
merged: result.merged,
|
|
19307
|
+
aliasesRewritten: result.aliasesRewritten
|
|
18674
19308
|
};
|
|
18675
19309
|
}
|
|
18676
19310
|
/**
|
|
@@ -18691,7 +19325,12 @@ async function setAliases(graph, args) {
|
|
|
18691
19325
|
if (holder && holder.key !== identity.key) throw new ToolError("name_taken", `"${alias}" is already a name of "${holder.concept}", so it cannot be an alias of "${identity.concept}".`);
|
|
18692
19326
|
aliases.push(alias);
|
|
18693
19327
|
}
|
|
18694
|
-
|
|
19328
|
+
try {
|
|
19329
|
+
await graph.store.setAliases(identity.concept, aliases);
|
|
19330
|
+
} catch (error) {
|
|
19331
|
+
if (error instanceof DocumentUnconfirmedError) throw new ToolError("unconfirmed_documents", error.message);
|
|
19332
|
+
throw error;
|
|
19333
|
+
}
|
|
18695
19334
|
await settle$2(graph);
|
|
18696
19335
|
await graph.store.refresh();
|
|
18697
19336
|
return {
|
|
@@ -20206,7 +20845,7 @@ function createMcpServer(graph, info) {
|
|
|
20206
20845
|
}, async (args) => run(() => createPage(graph, args)));
|
|
20207
20846
|
server.registerTool("plan_rename", {
|
|
20208
20847
|
title: "Plan a rename",
|
|
20209
|
-
description: "What renaming a page or concept would do, before anything changes: the scoped concepts carried along (cascade), the documents whose links would be rewritten (referencingDocuments, by name - read or backlinks any of them), any merge (the new name is already a document's name or alias, so the two would be joined), a refusal if the rename is not allowed, and how many protected documents exist whose references cannot be seen. Journal entries cannot be renamed.",
|
|
20848
|
+
description: "What renaming a page or concept would do, before anything changes: the scoped concepts carried along (cascade), the documents whose links would be rewritten (referencingDocuments, by name - read or backlinks any of them), any merge (the new name is already a document's name or alias, so the two would be joined), the aliases that name it as a scope and would be rewritten with it (aliases: the document, the alias and its new form), a refusal if the rename is not allowed, and how many protected documents exist whose references cannot be seen. Journal entries cannot be renamed.",
|
|
20210
20849
|
inputSchema: {
|
|
20211
20850
|
from: z.string().min(1).describe("The current name: a page title or a concept that only links name."),
|
|
20212
20851
|
to: z.string().min(1).describe("The new name.")
|
|
@@ -20214,7 +20853,7 @@ function createMcpServer(graph, info) {
|
|
|
20214
20853
|
}, async (args) => run(() => planRename(graph, args)));
|
|
20215
20854
|
server.registerTool("rename", {
|
|
20216
20855
|
title: "Rename",
|
|
20217
|
-
description: "Rename a page or concept. Its page (if any) and every scoped concept beneath it (\"[[Old]] Notes\" becomes \"[[New]] Notes\") move with it. strategy \"rewrite\" (default) rewrites every [[Old]] link in the graph to the new name. \"alias\" keeps the old name as an alias so existing links keep resolving. If the new name is already taken the two documents would merge, which is irreversible: refused with error \"merge_requires_confirmation\" unless confirm_merge is true. Refused with \"unconfirmed_documents\" if a document it must read has not finished syncing to this device (nothing changes - try again). Call plan_rename first to see what will happen.",
|
|
20856
|
+
description: "Rename a page or concept. Its page (if any) and every scoped concept beneath it (\"[[Old]] Notes\" becomes \"[[New]] Notes\") move with it, and so does any alias that names it as a scope (\"[[Old]] lab\" on another page becomes \"[[New]] lab\"; with strategy \"alias\" the old alias is kept and the new form added). Refused if such an alias would become a name another document already has. strategy \"rewrite\" (default) rewrites every [[Old]] link in the graph to the new name. \"alias\" keeps the old name as an alias so existing links keep resolving. If the new name is already taken the two documents would merge, which is irreversible: refused with error \"merge_requires_confirmation\" unless confirm_merge is true. Refused with \"unconfirmed_documents\" if a document it must read has not finished syncing to this device (nothing changes - try again). Call plan_rename first to see what will happen.",
|
|
20218
20857
|
inputSchema: {
|
|
20219
20858
|
from: z.string().min(1),
|
|
20220
20859
|
to: z.string().min(1),
|
|
@@ -20224,7 +20863,7 @@ function createMcpServer(graph, info) {
|
|
|
20224
20863
|
}, async (args) => run(() => rename$1(graph, args)));
|
|
20225
20864
|
server.registerTool("set_aliases", {
|
|
20226
20865
|
title: "Set aliases",
|
|
20227
|
-
description: "Replace a document's aliases: other names it answers to in [[wikilinks]], Quick Find and on a published site. The list you pass is the complete list. A name that is already another document's title or alias is refused with error \"name_taken\".",
|
|
20866
|
+
description: "Replace a document's aliases: other names it answers to in [[wikilinks]], Quick Find and on a published site. The list you pass is the complete list, and it is shown in the document's frontmatter, in a block added if there is none. A name that is already another document's title or alias is refused with error \"name_taken\".",
|
|
20228
20867
|
inputSchema: {
|
|
20229
20868
|
concept,
|
|
20230
20869
|
aliases: z.array(z.string()).describe("Every alias the document should have - an empty list removes them all.")
|