@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 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.54",
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
- function documentHash(db, key) {
2814
- return db.all("SELECT text_hash FROM pages WHERE generation = ? AND concept_key = ? LIMIT 1", [activeIndexGeneration(db), key])[0]?.text_hash;
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
- if (includes.length === 0 && properties.length === 0) return hashText(doc.text);
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.run("DELETE FROM aliases WHERE page_id = ?", [pageId]);
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) if (documentHash(db, conceptKey$1(doc.concept)) !== indexDocHash(doc)) changed.push(doc);
9137
- if (changed.length === 0) return [];
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
- if (changed === null || changed.size > INCREMENTAL_LIMIT && !checkpointCoversChanged) await rebuildAll(checkpoint?.streamForIndex);
9873
- else if (changed.size > 0) {
9874
- const docs = [];
9875
- for (const concept of changed) {
9876
- const doc = checkpointDocuments.get(conceptKey$1(concept)) ?? await source.snapshotDocument?.(concept);
9877
- if (doc) docs.push(doc);
9878
- }
9879
- if (docs.length !== changed.size) await rebuildAll(checkpoint?.streamForIndex);
9880
- else {
9881
- for (let start = 0; start < docs.length; start += REBUILD_SEND_CHUNK) transport.send({
9882
- type: "ingest",
9883
- docs: docs.slice(start, start + REBUILD_SEND_CHUNK)
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
- /** Source events and the terminal catch-up flush share one ordered main-thread pipeline. */
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
- const next = rebuildTail.then(() => rebuild());
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((error) => options.onBackgroundError?.(error instanceof Error ? error : new Error(String(error))));
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
- if (!change || !source.snapshotDocument) dirty = null;
9917
- else if (dirty !== null) dirty.add(change.concept);
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 (timer !== void 0 && !disposed) {
10110
- drainWaiters.push(look);
10111
- return;
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
- rebuildTail.then(resolve, resolve);
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; on a Filesystem Backend they are read from the saved file,
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
- return {
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 document's current text (an open buffer, else its file), or
11357
- * null for a name with no document behind it.
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
- for (const step of renameSteps(plan)) for (const concept of step.merges ? [step.from, step.into] : [step.from]) {
11362
- const text = await textOf(concept);
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
- function emitDocumentsChanged() {
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
- emitDocumentsChanged();
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
- const written = await adapter.write(subdir, fileName, content);
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
- await doc.ready;
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 changeListeners.forEach((listener) => listener());
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. Adds no block to a document without one. A
12663
- * journal's title is its date, so only its aliases are mirrored.
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 next = withFrontmatterIdentity(text, {
13179
+ const aliases = entry.aliases ?? [];
13180
+ replaceBlock(ytext, text, withFrontmatterIdentity(text, {
12671
13181
  ...entry.kind === "page" ? { title: conceptOf(entry) } : {},
12672
- aliases: entry.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
- async confirmRegistry(timeoutMs = 5e3) {
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 requiresFullReplacement = false;
13530
+ let namesMoved = false;
13019
13531
  for (const docId of checkpoint.docIds) {
13020
13532
  const entry = entriesByDocId.get(docId);
13021
13533
  if (!entry) {
13022
- requiresFullReplacement = true;
13023
- break;
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: requiresFullReplacement ? null : [...concepts.values()].map((concept) => ({ concept })),
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 entry = docId ? registry.get(docId) : void 0;
13058
- if (!docId || !entry) throw new DocumentNotFoundError(target);
13059
- const next = normaliseAliases(aliases, conceptOf(entry));
13060
- const updated = { ...entry };
13061
- if (next.length > 0) updated.aliases = next;
13062
- else delete updated.aliases;
13063
- registry.set(docId, updated);
13064
- writeBackIdentity(docId);
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 entry = docId ? registry.get(docId) : void 0;
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` (ADR 0038).
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
- const timer = setTimeout(resolve, followMs);
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 [result.concept, ...result.rewrittenDocuments]) await store.flushDocument(concept);
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 - `Estimate_320_from_B_Sprake_ltd_(1)_1706517395516_0.pdf`
15755
- * is a real example. Every reader then saw the same truncated target, so the link styling and the
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
- /** The documents whose BODIES link to the concept, by name - what a rewrite has to read (ADR 0083: a title reference is the cascade's). */
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 referencing = await referencingDocuments(graph, from);
18615
- const plan = await graph.store.planRename(identity?.concept ?? from, to, referencing.length);
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: identity?.concept ?? 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
- await graph.store.setAliases(identity.concept, aliases);
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.")