@openparachute/vault 0.7.9-rc.2 → 0.7.9-rc.4
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/core/src/core.test.ts +546 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/link-count.test.ts +29 -0
- package/core/src/links.ts +43 -0
- package/core/src/mcp-manifest.ts +10 -6
- package/core/src/mcp.ts +129 -44
- package/core/src/notes.ts +51 -2
- package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
- package/core/src/schema.ts +28 -1
- package/core/src/store.ts +180 -57
- package/core/src/txn.test.ts +33 -1
- package/core/src/txn.ts +80 -7
- package/core/src/types.ts +11 -0
- package/core/src/vault-projection.ts +8 -1
- package/core/src/wikilinks.ts +873 -44
- package/package.json +2 -2
- package/src/aggregate-routes.test.ts +76 -0
- package/src/config.ts +8 -2
- package/src/mcp-http.test.ts +51 -1
- package/src/mcp-tools.ts +106 -5
- package/src/mirror-routes.test.ts +47 -0
- package/src/release-plan.test.ts +90 -1
- package/src/routes.ts +280 -66
- package/src/routing.test.ts +67 -0
- package/src/tag-scope-query-tag.test.ts +374 -0
- package/src/tag-scope.ts +116 -0
- package/src/test-support/vault-714-find-path.json +58 -0
- package/src/test-support/vault-714-graph.json +27 -0
- package/src/test-support/vault-714-has_links.json +96 -0
- package/src/test-support/vault-714-include_broken_links.json +118 -0
- package/src/test-support/vault-714-include_link_count.json +348 -0
- package/src/test-support/vault-714-include_links.json +86 -0
- package/src/test-support/vault-714-near.json +78 -0
- package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
- package/src/vault.test.ts +928 -1
- package/src/write-warnings-scope.test.ts +344 -0
- package/src/ws-server.ts +16 -3
- package/src/ws-subscribe.test.ts +76 -2
package/core/src/wikilinks.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Note } from "./types.js";
|
|
|
3
3
|
import * as linkOps from "./links.js";
|
|
4
4
|
import { getNote, findNotesByTitle, extractH1Title } from "./notes.js";
|
|
5
5
|
import { chunkForInClause } from "./sql-in.js";
|
|
6
|
+
import { pathTitle } from "./paths.js";
|
|
6
7
|
import { transaction } from "./txn.js";
|
|
7
8
|
import type { QueryWarning } from "./query-warnings.js";
|
|
8
9
|
|
|
@@ -107,6 +108,117 @@ function stripCode(content: string): string {
|
|
|
107
108
|
return result;
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Rewrite the TARGET portion of `[[wikilinks]]` in `content`, in place.
|
|
113
|
+
*
|
|
114
|
+
* `rename` is called with each bracket's parsed target (trimmed, exactly as
|
|
115
|
+
* {@link parseWikilinks} would report it) and returns the replacement target
|
|
116
|
+
* string, or `null`/`undefined` to leave that bracket alone. Everything else
|
|
117
|
+
* about the bracket survives verbatim — the `!` embed marker, a `|display`
|
|
118
|
+
* alias, a `#Heading` anchor, a `#^block-ref`, and any surrounding text.
|
|
119
|
+
*
|
|
120
|
+
* Brackets inside fenced/inline code are skipped, because {@link stripCode}
|
|
121
|
+
* is applied before matching — same as the parser. That is deliberate: a
|
|
122
|
+
* `[[link]]` in a code fence is not a link (it is never parsed, never
|
|
123
|
+
* resolved, never given a `links` row), so a rename must not silently edit
|
|
124
|
+
* someone's sample text. The pre-vault#708 cascade rewrote those too, via a
|
|
125
|
+
* blind content-wide regex.
|
|
126
|
+
*/
|
|
127
|
+
export function rewriteWikilinkTargets(
|
|
128
|
+
content: string,
|
|
129
|
+
rename: (target: string) => string | null | undefined,
|
|
130
|
+
): string {
|
|
131
|
+
const stripped = stripCode(content);
|
|
132
|
+
const regex = /(!)?\[\[([^\[\]\n]+?)\]\]/g;
|
|
133
|
+
let out = "";
|
|
134
|
+
let last = 0;
|
|
135
|
+
let match: RegExpExecArray | null;
|
|
136
|
+
|
|
137
|
+
while ((match = regex.exec(stripped)) !== null) {
|
|
138
|
+
const inner = match[2]!;
|
|
139
|
+
// Target part = everything before the first `#` (anchor/block-ref) or
|
|
140
|
+
// `|` (display alias), whichever comes first. Matches how
|
|
141
|
+
// `parseWikilinks` splits, for every ordering of the two.
|
|
142
|
+
let boundary = inner.length;
|
|
143
|
+
const hashIdx = inner.indexOf("#");
|
|
144
|
+
const pipeIdx = inner.indexOf("|");
|
|
145
|
+
if (hashIdx !== -1) boundary = Math.min(boundary, hashIdx);
|
|
146
|
+
if (pipeIdx !== -1) boundary = Math.min(boundary, pipeIdx);
|
|
147
|
+
const targetPart = inner.slice(0, boundary);
|
|
148
|
+
const target = targetPart.trim();
|
|
149
|
+
if (!target) continue;
|
|
150
|
+
|
|
151
|
+
const next = rename(target);
|
|
152
|
+
if (!next || next === targetPart) continue;
|
|
153
|
+
|
|
154
|
+
// `stripCode` preserves offsets, so the match indices address `content`.
|
|
155
|
+
const start = match.index;
|
|
156
|
+
const end = start + match[0].length;
|
|
157
|
+
const innerStart = start + (match[1] ? 3 : 2);
|
|
158
|
+
out += content.slice(last, innerStart) + next + content.slice(innerStart + boundary, end);
|
|
159
|
+
last = end;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return last === 0 ? content : out + content.slice(last);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* How a wikilink target names a note PATH — the shapes
|
|
167
|
+
* {@link resolveWikilink} can match a path by, in its resolution order.
|
|
168
|
+
* `null` means the target is not a path-derived name for `path` at all (e.g.
|
|
169
|
+
* it resolved through the H1-title fallback, which a repath doesn't affect).
|
|
170
|
+
*
|
|
171
|
+
* Used by the rename cascade (vault#708) to rewrite a bracket into the SAME
|
|
172
|
+
* shape it already had rather than forcing every reference to a full path.
|
|
173
|
+
*/
|
|
174
|
+
export type WikilinkPathForm =
|
|
175
|
+
| { form: "path" }
|
|
176
|
+
| { form: "title" }
|
|
177
|
+
| { form: "path-ext"; ext: string };
|
|
178
|
+
|
|
179
|
+
export function wikilinkPathForm(target: string, path: string): WikilinkPathForm | null {
|
|
180
|
+
const lower = target.toLowerCase();
|
|
181
|
+
const title = pathTitle(path);
|
|
182
|
+
// Order mirrors `resolveWikilink`: a literal path wins over the
|
|
183
|
+
// explicit-extension reading of the same string (`Recipe.v2`).
|
|
184
|
+
if (lower === path.toLowerCase()) return { form: "path" };
|
|
185
|
+
if (lower === title.toLowerCase()) return { form: "title" };
|
|
186
|
+
// Only the FULL-path `.ext` form exists: `resolveWikilink`'s
|
|
187
|
+
// explicit-extension rule matches on `(path, extension)`, so a
|
|
188
|
+
// basename+ext bracket (`[[Data.csv]]` for `move/Data`) never resolved in
|
|
189
|
+
// the first place and is not this cascade's to repair.
|
|
190
|
+
const extMatch = target.match(/^(.*)\.([a-z0-9]{1,16})$/i);
|
|
191
|
+
if (extMatch && extMatch[1]!.toLowerCase() === path.toLowerCase()) {
|
|
192
|
+
return { form: "path-ext", ext: extMatch[2]! };
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The candidate replacement texts for a bracket of `form` after the note
|
|
199
|
+
* moved to `newPath`, most-preferred first. The caller picks the first one
|
|
200
|
+
* that resolves back to the renamed note (see `Store.cascadeRename`):
|
|
201
|
+
* a basename bracket stays a basename bracket unless the new basename would
|
|
202
|
+
* now be ambiguous (or collide across extensions), in which case it widens
|
|
203
|
+
* to the full path.
|
|
204
|
+
*/
|
|
205
|
+
export function wikilinkRenameCandidates(
|
|
206
|
+
form: WikilinkPathForm,
|
|
207
|
+
newPath: string,
|
|
208
|
+
noteExtension: string | null | undefined,
|
|
209
|
+
): string[] {
|
|
210
|
+
const newTitle = pathTitle(newPath);
|
|
211
|
+
const ext = noteExtension || "md";
|
|
212
|
+
switch (form.form) {
|
|
213
|
+
case "path":
|
|
214
|
+
return [newPath, `${newPath}.${ext}`];
|
|
215
|
+
case "title":
|
|
216
|
+
return [newTitle, newPath, `${newPath}.${ext}`];
|
|
217
|
+
case "path-ext":
|
|
218
|
+
return [`${newPath}.${form.ext}`];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
110
222
|
// ---------------------------------------------------------------------------
|
|
111
223
|
// Resolution — match wikilink targets to notes by path
|
|
112
224
|
// ---------------------------------------------------------------------------
|
|
@@ -336,6 +448,86 @@ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved:
|
|
|
336
448
|
return { unresolved, count: total };
|
|
337
449
|
}
|
|
338
450
|
|
|
451
|
+
/**
|
|
452
|
+
* The `ambiguous_wikilinks` rows that are BROKEN in a tag-scoped reader's own
|
|
453
|
+
* sub-vault — every candidate invisible to it — shaped as
|
|
454
|
+
* {@link UnresolvedWikilink} so `/unresolved-wikilinks` can report them
|
|
455
|
+
* alongside the genuinely-unresolved ones (vault#239).
|
|
456
|
+
*
|
|
457
|
+
* Without this the admin listing is the third face of the same delete-time
|
|
458
|
+
* oracle `getUnresolvedLinksForNotes` closes on the note surfaces: the row
|
|
459
|
+
* only appears there once the last invisible candidate is deleted and
|
|
460
|
+
* `refreshAmbiguousLinks` demotes it. Callers are expected to apply their own
|
|
461
|
+
* source-note tag-scope filter afterwards, exactly as they do for the base
|
|
462
|
+
* listing. Scanned under the same `limit` slice as
|
|
463
|
+
* {@link listUnresolvedWikilinks}; `[]` when the table has never been created
|
|
464
|
+
* (and never called at all for an unscoped reader, whose answer is the
|
|
465
|
+
* persisted tables as-is).
|
|
466
|
+
*/
|
|
467
|
+
export function listZeroVisibleAmbiguousAsUnresolved(
|
|
468
|
+
db: Database,
|
|
469
|
+
visible: (noteId: string) => boolean,
|
|
470
|
+
limit = 50,
|
|
471
|
+
): UnresolvedWikilink[] {
|
|
472
|
+
let rows: { source_id: string; target_path: string; relationship: string }[];
|
|
473
|
+
try {
|
|
474
|
+
rows = db.prepare(
|
|
475
|
+
"SELECT source_id, target_path, relationship FROM ambiguous_wikilinks ORDER BY source_id LIMIT ?",
|
|
476
|
+
).all(limit) as typeof rows;
|
|
477
|
+
} catch {
|
|
478
|
+
return []; // Table doesn't exist — nothing has ever been ambiguous here.
|
|
479
|
+
}
|
|
480
|
+
const out: UnresolvedWikilink[] = [];
|
|
481
|
+
for (const r of rows) {
|
|
482
|
+
const relationship = r.relationship || WIKILINK_REL;
|
|
483
|
+
if (visibleResolutionCount(db, r.target_path, relationship, visible) > 0) continue;
|
|
484
|
+
const note = db.prepare("SELECT path FROM notes WHERE id = ?").get(r.source_id) as { path: string | null } | null;
|
|
485
|
+
out.push({
|
|
486
|
+
source_id: r.source_id,
|
|
487
|
+
source_path: note?.path ?? undefined,
|
|
488
|
+
target_path: r.target_path,
|
|
489
|
+
relationship,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Resolved-to-invisible content wikilinks, shaped for the scoped unresolved
|
|
497
|
+
* listing (vault#714). Bounded and non-exhaustive: scan at most 2000 edges,
|
|
498
|
+
* fetch content only for sources with a hidden target, and stop at limit.
|
|
499
|
+
* Structured links cannot recover their original caller string from links;
|
|
500
|
+
* never disclose the hidden target's stored path as a replacement.
|
|
501
|
+
*/
|
|
502
|
+
export function listResolvedToInvisibleAsUnresolved(
|
|
503
|
+
db: Database,
|
|
504
|
+
visible: (noteId: string) => boolean,
|
|
505
|
+
limit = 50,
|
|
506
|
+
): UnresolvedWikilink[] {
|
|
507
|
+
if (limit <= 0) return [];
|
|
508
|
+
const rows = db.prepare(
|
|
509
|
+
"SELECT source_id, target_id, relationship FROM links ORDER BY source_id, target_id LIMIT ?",
|
|
510
|
+
).all(Math.min(2000, Math.max(200, limit * 20))) as { source_id: string; target_id: string; relationship: string }[];
|
|
511
|
+
const sources = new Set<string>();
|
|
512
|
+
const out: UnresolvedWikilink[] = [];
|
|
513
|
+
for (const row of rows) {
|
|
514
|
+
if (visible(row.target_id) || sources.has(row.source_id)) continue;
|
|
515
|
+
sources.add(row.source_id);
|
|
516
|
+
const note = db.prepare("SELECT path, content FROM notes WHERE id = ?").get(row.source_id) as { path: string | null; content: string } | null;
|
|
517
|
+
if (!note) continue;
|
|
518
|
+
const seen = new Set<string>();
|
|
519
|
+
for (const { target } of parseWikilinks(note.content)) {
|
|
520
|
+
if (visibleResolutionCount(db, target, WIKILINK_REL, visible) > 0) continue;
|
|
521
|
+
const key = target.toLowerCase();
|
|
522
|
+
if (seen.has(key)) continue;
|
|
523
|
+
seen.add(key);
|
|
524
|
+
out.push({ source_id: row.source_id, source_path: note.path ?? undefined, target_path: target, relationship: WIKILINK_REL });
|
|
525
|
+
if (out.length >= limit) return out;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
|
|
339
531
|
/** One note's dangling outbound link, as surfaced on a note read (vault#555). */
|
|
340
532
|
export interface BrokenLink {
|
|
341
533
|
target: string;
|
|
@@ -347,11 +539,35 @@ export interface BrokenLink {
|
|
|
347
539
|
* `include_broken_links` surfacing on `query-notes` / `GET /notes`. ONE
|
|
348
540
|
* query for the whole page (mirrors `getLinksHydratedForNotes`'s batching),
|
|
349
541
|
* not one per note. Returns a map with an entry (possibly `[]`) for every
|
|
350
|
-
* requested id
|
|
351
|
-
*
|
|
352
|
-
*
|
|
542
|
+
* requested id.
|
|
543
|
+
*
|
|
544
|
+
* `visible` (vault#239) is an OPTIONAL per-candidate-note predicate, injected
|
|
545
|
+
* by the server layer for a TAG-SCOPED reader — the SAME closure the
|
|
546
|
+
* ambiguity surface uses, because "which of this target's candidates can the
|
|
547
|
+
* reader see" is one question with two answers. Core stays scope-unaware; it
|
|
548
|
+
* only invokes the closure.
|
|
549
|
+
*
|
|
550
|
+
* When it is supplied, the persisted `ambiguous_wikilinks` rows are folded in
|
|
551
|
+
* too, as broken, whenever NONE of their candidates is visible: such a target
|
|
552
|
+
* matches nothing in the reader's sub-vault, which is what "broken" means.
|
|
553
|
+
* Without that, `refreshAmbiguousLinks`' delete-time demotion into
|
|
554
|
+
* `unresolved_wikilinks` is the only thing that ever makes the note broken —
|
|
555
|
+
* so the answer moves when notes the reader cannot see are deleted, which is
|
|
556
|
+
* an oracle for exactly the cross-scope naming collision
|
|
557
|
+
* {@link getAmbiguousLinksForNotes} narrows away.
|
|
558
|
+
*
|
|
559
|
+
* Cost: one extra query plus one re-resolution per ambiguous row, and only
|
|
560
|
+
* for scoped readers — the same profile the ambiguity surface accepts, on a
|
|
561
|
+
* table that is rare by nature. Scoped reads also scan outbound edges for
|
|
562
|
+
* the requested page, fetching content only for sources with hidden targets
|
|
563
|
+
* and re-resolving their own bracket text (vault#714). An unscoped reader
|
|
564
|
+
* does no extra work.
|
|
353
565
|
*/
|
|
354
|
-
export function getUnresolvedLinksForNotes(
|
|
566
|
+
export function getUnresolvedLinksForNotes(
|
|
567
|
+
db: Database,
|
|
568
|
+
noteIds: string[],
|
|
569
|
+
visible?: (noteId: string) => boolean,
|
|
570
|
+
): Map<string, BrokenLink[]> {
|
|
355
571
|
const result = new Map<string, BrokenLink[]>(noteIds.map((id) => [id, []]));
|
|
356
572
|
if (noteIds.length === 0) return result;
|
|
357
573
|
|
|
@@ -365,19 +581,117 @@ export function getUnresolvedLinksForNotes(db: Database, noteIds: string[]): Map
|
|
|
365
581
|
).all(...chunk) as typeof rows);
|
|
366
582
|
}
|
|
367
583
|
} catch {
|
|
368
|
-
// Table doesn't exist —
|
|
369
|
-
return
|
|
584
|
+
// Table doesn't exist — no note in this vault has ever had a link go
|
|
585
|
+
// unresolved. NOT an early return: a tag-scoped reader can still have
|
|
586
|
+
// broken references whose rows live in `ambiguous_wikilinks` (vault#239),
|
|
587
|
+
// and this is the common case for them — a vault where every collision
|
|
588
|
+
// was ambiguous vault-wide never creates `unresolved_wikilinks` at all.
|
|
589
|
+
rows.length = 0;
|
|
370
590
|
}
|
|
371
591
|
|
|
592
|
+
const seen = new Set<string>();
|
|
372
593
|
for (const row of rows) {
|
|
373
|
-
|
|
594
|
+
const relationship = row.relationship || WIKILINK_REL;
|
|
595
|
+
seen.add(`${row.source_id}\u0000${relationship}\u0000${row.target_path.toLowerCase()}`);
|
|
596
|
+
result.get(row.source_id)?.push({ target: row.target_path, relationship });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// vault#239 — a target whose candidates are ALL invisible to this reader
|
|
600
|
+
// is broken in the reader's sub-vault, whichever table its row happens to
|
|
601
|
+
// live in right now. Without this the note only becomes "broken" once the
|
|
602
|
+
// last invisible candidate is DELETED and `refreshAmbiguousLinks` demotes
|
|
603
|
+
// the row into `unresolved_wikilinks` — a timing oracle for exactly the
|
|
604
|
+
// cross-scope naming collision `getAmbiguousLinksForNotes` narrows away.
|
|
605
|
+
// No-op (and no extra query) for an unscoped reader.
|
|
606
|
+
if (visible) {
|
|
607
|
+
for (const row of readAmbiguousRows(db, noteIds)) {
|
|
608
|
+
const relationship = row.relationship || WIKILINK_REL;
|
|
609
|
+
if (visibleResolutionCount(db, row.target_path, relationship, visible) > 0) continue;
|
|
610
|
+
const key = `${row.source_id}\u0000${relationship}\u0000${row.target_path.toLowerCase()}`;
|
|
611
|
+
if (seen.has(key)) continue;
|
|
612
|
+
seen.add(key);
|
|
613
|
+
result.get(row.source_id)?.push({ target: row.target_path, relationship });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// vault#714: the persisted edge may resolve only to a hidden note.
|
|
617
|
+
// Reparse the source's own brackets, never the hidden target's path.
|
|
618
|
+
const hiddenTargetSources = new Set<string>();
|
|
619
|
+
for (const chunk of chunkForInClause(noteIds)) {
|
|
620
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
621
|
+
const links = db.prepare(
|
|
622
|
+
`SELECT source_id, target_id FROM links WHERE source_id IN (${placeholders})`,
|
|
623
|
+
).all(...chunk) as { source_id: string; target_id: string }[];
|
|
624
|
+
for (const link of links) {
|
|
625
|
+
if (!visible(link.target_id)) hiddenTargetSources.add(link.source_id);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
for (const chunk of chunkForInClause([...hiddenTargetSources])) {
|
|
629
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
630
|
+
const notes = db.prepare(
|
|
631
|
+
`SELECT id, content FROM notes WHERE id IN (${placeholders})`,
|
|
632
|
+
).all(...chunk) as { id: string; content: string }[];
|
|
633
|
+
for (const note of notes) {
|
|
634
|
+
for (const { target } of parseWikilinks(note.content)) {
|
|
635
|
+
if (visibleResolutionCount(db, target, WIKILINK_REL, visible) > 0) continue;
|
|
636
|
+
const key = `${note.id}\u0000${WIKILINK_REL}\u0000${target.toLowerCase()}`;
|
|
637
|
+
if (seen.has(key)) continue;
|
|
638
|
+
seen.add(key);
|
|
639
|
+
result.get(note.id)?.push({ target, relationship: WIKILINK_REL });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
374
643
|
}
|
|
375
644
|
return result;
|
|
376
645
|
}
|
|
377
646
|
|
|
378
647
|
/** Single-note convenience wrapper around {@link getUnresolvedLinksForNotes}. */
|
|
379
|
-
export function getUnresolvedLinksForNote(
|
|
380
|
-
|
|
648
|
+
export function getUnresolvedLinksForNote(
|
|
649
|
+
db: Database,
|
|
650
|
+
noteId: string,
|
|
651
|
+
visible?: (noteId: string) => boolean,
|
|
652
|
+
): BrokenLink[] {
|
|
653
|
+
return getUnresolvedLinksForNotes(db, [noteId], visible).get(noteId) ?? [];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Narrow a page of notes by the `has_broken_links` filter as a TAG-SCOPED
|
|
658
|
+
* reader should see it — the vault#239 twin of
|
|
659
|
+
* {@link narrowByVisibleAmbiguity}, and the reason
|
|
660
|
+
* {@link sqlHasBrokenLinks} lifts the SQL filter entirely under scope: the
|
|
661
|
+
* `unresolved_wikilinks` EXISTS test is neither a superset NOR a subset of
|
|
662
|
+
* the right answer (it MISSES a note whose only candidates are invisible,
|
|
663
|
+
* and it INCLUDES nothing it shouldn't), so neither polarity can be pushed
|
|
664
|
+
* down and both are re-decided here on the reader's own sub-vault.
|
|
665
|
+
*
|
|
666
|
+
* Page-shortening is the same effect `filterNotesByTagScope` already has on
|
|
667
|
+
* every scoped read. No-op when `wanted` is undefined or no predicate is
|
|
668
|
+
* injected (unscoped).
|
|
669
|
+
*/
|
|
670
|
+
export function narrowByVisibleBrokenness<T extends { id: string }>(
|
|
671
|
+
db: Database,
|
|
672
|
+
notes: T[],
|
|
673
|
+
wanted: boolean | undefined,
|
|
674
|
+
visible: ((noteId: string) => boolean) | undefined,
|
|
675
|
+
): T[] {
|
|
676
|
+
if (wanted === undefined || !visible || notes.length === 0) return notes;
|
|
677
|
+
const byNote = getUnresolvedLinksForNotes(db, notes.map((n) => n.id), visible);
|
|
678
|
+
return notes.filter((n) => ((byNote.get(n.id)?.length ?? 0) > 0) === wanted);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The `hasBrokenLinks` value to push into SQL for a reader that will be
|
|
683
|
+
* narrowed by {@link narrowByVisibleBrokenness} afterwards. Unlike
|
|
684
|
+
* {@link sqlHasAmbiguousLinks} — where `true` survives because every
|
|
685
|
+
* truly-ambiguous note also has a persisted row — BOTH polarities are lifted
|
|
686
|
+
* under scope, because a note can be broken in the reader's sub-vault while
|
|
687
|
+
* having no `unresolved_wikilinks` row at all (vault#239). Identity for
|
|
688
|
+
* unscoped readers. Shared by both doors so REST and MCP cannot drift.
|
|
689
|
+
*/
|
|
690
|
+
export function sqlHasBrokenLinks(
|
|
691
|
+
wanted: boolean | undefined,
|
|
692
|
+
scoped: boolean,
|
|
693
|
+
): boolean | undefined {
|
|
694
|
+
return scoped ? undefined : wanted;
|
|
381
695
|
}
|
|
382
696
|
|
|
383
697
|
// ---------------------------------------------------------------------------
|
|
@@ -425,7 +739,10 @@ export interface AmbiguousWikilinkTarget {
|
|
|
425
739
|
* THIRD same-named note rather than reporting the collision. The caller
|
|
426
740
|
* (MCP `create-note`/`update-note`, REST `POST`/`PATCH /notes`) surfaces
|
|
427
741
|
* `ambiguous` as an `ambiguous_link` warning naming the target + match
|
|
428
|
-
* count, distinct from `unresolved`'s `unresolved_link`.
|
|
742
|
+
* count, distinct from `unresolved`'s `unresolved_link`. Each ambiguous
|
|
743
|
+
* target is ALSO persisted to `ambiguous_wikilinks` (vault#581) so the
|
|
744
|
+
* collision stays queryable after the write, symmetric with how
|
|
745
|
+
* `unresolved_wikilinks` backs `has_broken_links`/`include_broken_links`.
|
|
429
746
|
*/
|
|
430
747
|
export function syncWikilinks(
|
|
431
748
|
db: Database,
|
|
@@ -491,8 +808,13 @@ export function syncWikilinks(
|
|
|
491
808
|
}
|
|
492
809
|
|
|
493
810
|
// Store unresolved wikilinks for later resolution. Ambiguous targets are
|
|
494
|
-
// deliberately NOT queued
|
|
811
|
+
// deliberately NOT queued into `unresolved_wikilinks` — see the doc comment
|
|
812
|
+
// above — but they ARE recorded in their own `ambiguous_wikilinks` table
|
|
813
|
+
// (vault#581) so a later audit can find them via
|
|
814
|
+
// `has_ambiguous_links`/`include_ambiguous_links` instead of only seeing
|
|
815
|
+
// the transient write-time warning.
|
|
495
816
|
syncUnresolvedWikilinks(db, noteId, unresolved);
|
|
817
|
+
syncAmbiguousWikilinks(db, noteId, ambiguous);
|
|
496
818
|
|
|
497
819
|
return { added, removed, unresolved, ambiguous };
|
|
498
820
|
}
|
|
@@ -515,8 +837,15 @@ export function syncWikilinks(
|
|
|
515
837
|
* already migrated. `PRAGMA table_info` on a nonexistent table returns zero
|
|
516
838
|
* rows rather than throwing, so this is safe to call unconditionally,
|
|
517
839
|
* including from read paths that don't want to create the table lazily.
|
|
840
|
+
*
|
|
841
|
+
* Also the body of `migrateToV28` (vault#567 item 2): boot runs this once
|
|
842
|
+
* via `initSchema` so a pre-#555 2-column table is healed before the first
|
|
843
|
+
* wikilink touch. Gated the existing way — no table → return; column
|
|
844
|
+
* present → return; only the 2-column shape rebuilds. Does NOT create the
|
|
845
|
+
* table on vaults that never queued an unresolved link (lazy creation
|
|
846
|
+
* stays). The per-touch calls below become a no-op after v28.
|
|
518
847
|
*/
|
|
519
|
-
function ensureRelationshipColumn(db: Database): void {
|
|
848
|
+
export function ensureRelationshipColumn(db: Database): void {
|
|
520
849
|
const cols = db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as { name: string }[];
|
|
521
850
|
if (cols.length === 0) return; // table doesn't exist — nothing to heal
|
|
522
851
|
if (cols.some((c) => c.name === "relationship")) return; // already migrated
|
|
@@ -746,6 +1075,515 @@ export function resolveUnresolvedWikilinks(
|
|
|
746
1075
|
return resolved;
|
|
747
1076
|
}
|
|
748
1077
|
|
|
1078
|
+
|
|
1079
|
+
// ---------------------------------------------------------------------------
|
|
1080
|
+
// Ambiguous links (vault#581) — the queryable twin of `unresolved_wikilinks`
|
|
1081
|
+
//
|
|
1082
|
+
// An ambiguous target (≥2 notes match) is deliberately never linked and never
|
|
1083
|
+
// queued for backfill (see {@link syncWikilinks}). Before #581 that meant it
|
|
1084
|
+
// existed ONLY in the create/update response's transient `ambiguous_link`
|
|
1085
|
+
// warning: `has_broken_links` didn't match the note (`unresolved_wikilinks`
|
|
1086
|
+
// held no row for it), so a later audit couldn't surface the collision at all
|
|
1087
|
+
// — asymmetric with the unresolved-link story. These helpers persist it in its
|
|
1088
|
+
// own lazily-created table, which backs the `has_ambiguous_links` /
|
|
1089
|
+
// `include_ambiguous_links` filters on `query-notes` / `GET /notes`.
|
|
1090
|
+
//
|
|
1091
|
+
// Kept in a SEPARATE table rather than folded into `unresolved_wikilinks` on
|
|
1092
|
+
// purpose: the two states mean different things to a caller ("nothing there
|
|
1093
|
+
// yet, will heal itself when the note arrives" vs. "too many things there,
|
|
1094
|
+
// disambiguate the reference"), the backfill sweep must never treat an
|
|
1095
|
+
// ambiguous row as pending-resolution, and folding them would have silently
|
|
1096
|
+
// widened what `has_broken_links: true` returns for every existing caller.
|
|
1097
|
+
//
|
|
1098
|
+
// Like `unresolved_wikilinks`, the table lives outside `SCHEMA_SQL` and is
|
|
1099
|
+
// created lazily on first write — a vault where no link has ever been
|
|
1100
|
+
// ambiguous never grows it, and no schema-version bump is needed.
|
|
1101
|
+
// ---------------------------------------------------------------------------
|
|
1102
|
+
|
|
1103
|
+
/** One note's ambiguous outbound link, as surfaced on a note read (vault#581). */
|
|
1104
|
+
export interface AmbiguousLink {
|
|
1105
|
+
target: string;
|
|
1106
|
+
relationship: string;
|
|
1107
|
+
/** How many notes the target matched at the time it was last evaluated. */
|
|
1108
|
+
candidate_count: number;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Ensure the ambiguous_wikilinks table exists. Called lazily — only when we
|
|
1113
|
+
* actually have an ambiguous link. Same shape as `unresolved_wikilinks`
|
|
1114
|
+
* (3-column PK, `ON DELETE CASCADE` so deleting the SOURCE note drops its
|
|
1115
|
+
* rows) plus the match count the write-time warning already carries.
|
|
1116
|
+
*/
|
|
1117
|
+
export function ensureAmbiguousTable(db: Database): void {
|
|
1118
|
+
db.exec(`
|
|
1119
|
+
CREATE TABLE IF NOT EXISTS ambiguous_wikilinks (
|
|
1120
|
+
source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
|
1121
|
+
target_path TEXT NOT NULL COLLATE NOCASE,
|
|
1122
|
+
relationship TEXT NOT NULL DEFAULT '${WIKILINK_REL}',
|
|
1123
|
+
candidate_count INTEGER NOT NULL DEFAULT 0,
|
|
1124
|
+
PRIMARY KEY (source_id, target_path, relationship)
|
|
1125
|
+
)
|
|
1126
|
+
`);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Replace this note's `relationship = "wikilink"` ambiguous rows with the
|
|
1131
|
+
* targets a fresh content parse just found ambiguous. Scoped to wikilink-kind
|
|
1132
|
+
* rows only, exactly like {@link syncUnresolvedWikilinks} — a content
|
|
1133
|
+
* re-parse must not clobber ambiguous STRUCTURED-link rows recorded for this
|
|
1134
|
+
* note by {@link queueAmbiguousLink} (a different relationship, same table).
|
|
1135
|
+
*/
|
|
1136
|
+
function syncAmbiguousWikilinks(
|
|
1137
|
+
db: Database,
|
|
1138
|
+
noteId: string,
|
|
1139
|
+
ambiguous: AmbiguousWikilinkTarget[],
|
|
1140
|
+
): void {
|
|
1141
|
+
if (ambiguous.length === 0) {
|
|
1142
|
+
// Clean up any old wikilink-kind ambiguous entries for this note (the
|
|
1143
|
+
// `[[Dup]]` was edited out, or one of the colliding notes went away and
|
|
1144
|
+
// the target now resolves).
|
|
1145
|
+
try {
|
|
1146
|
+
db.prepare("DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ?").run(noteId, WIKILINK_REL);
|
|
1147
|
+
} catch {
|
|
1148
|
+
// Table may not exist yet — that's fine.
|
|
1149
|
+
}
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
ensureAmbiguousTable(db);
|
|
1154
|
+
db.prepare("DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ?").run(noteId, WIKILINK_REL);
|
|
1155
|
+
const insert = db.prepare(
|
|
1156
|
+
"INSERT OR REPLACE INTO ambiguous_wikilinks (source_id, target_path, relationship, candidate_count) VALUES (?, ?, ?, ?)",
|
|
1157
|
+
);
|
|
1158
|
+
for (const entry of ambiguous) {
|
|
1159
|
+
insert.run(noteId, entry.target, WIKILINK_REL, entry.count);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Record a structured link (or typed `reference` field value) whose target
|
|
1165
|
+
* matched ≥2 notes. `INSERT OR REPLACE` so a re-write with a different match
|
|
1166
|
+
* count refreshes rather than keeping a stale one. Called from
|
|
1167
|
+
* {@link resolveOrQueueLink}, the single funnel every structured-link write
|
|
1168
|
+
* path goes through.
|
|
1169
|
+
*/
|
|
1170
|
+
export function queueAmbiguousLink(
|
|
1171
|
+
db: Database,
|
|
1172
|
+
sourceId: string,
|
|
1173
|
+
targetPath: string,
|
|
1174
|
+
relationship: string,
|
|
1175
|
+
candidateCount: number,
|
|
1176
|
+
): void {
|
|
1177
|
+
ensureAmbiguousTable(db);
|
|
1178
|
+
db.prepare(
|
|
1179
|
+
"INSERT OR REPLACE INTO ambiguous_wikilinks (source_id, target_path, relationship, candidate_count) VALUES (?, ?, ?, ?)",
|
|
1180
|
+
).run(sourceId, targetPath, relationship, candidateCount);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Ambiguous-table twin of {@link clearQueuedLink} — drop every ambiguous row
|
|
1185
|
+
* for `sourceId` under `relationship`, whatever (stale) target it names. Used
|
|
1186
|
+
* by the scalar `reference`-field sync before re-resolving a changed value.
|
|
1187
|
+
* Safe no-op when the table doesn't exist yet.
|
|
1188
|
+
*/
|
|
1189
|
+
export function clearAmbiguousLink(db: Database, sourceId: string, relationship: string): void {
|
|
1190
|
+
try {
|
|
1191
|
+
db.prepare(
|
|
1192
|
+
"DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ?",
|
|
1193
|
+
).run(sourceId, relationship);
|
|
1194
|
+
} catch {
|
|
1195
|
+
// Table may not exist yet — nothing to clear.
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Ambiguous-table twin of {@link clearQueuedLinkTarget} — drop exactly ONE
|
|
1201
|
+
* row, scoped by `targetPath` as well, so a `cardinality: "many"` reference
|
|
1202
|
+
* field losing one element doesn't blanket-clear the others' rows.
|
|
1203
|
+
* Safe no-op when the table doesn't exist yet.
|
|
1204
|
+
*/
|
|
1205
|
+
export function clearAmbiguousLinkTarget(
|
|
1206
|
+
db: Database,
|
|
1207
|
+
sourceId: string,
|
|
1208
|
+
relationship: string,
|
|
1209
|
+
targetPath: string,
|
|
1210
|
+
): void {
|
|
1211
|
+
try {
|
|
1212
|
+
db.prepare(
|
|
1213
|
+
"DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ? AND target_path = ? COLLATE NOCASE",
|
|
1214
|
+
).run(sourceId, relationship, targetPath);
|
|
1215
|
+
} catch {
|
|
1216
|
+
// Table may not exist yet — nothing to clear.
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Batch-fetch each note's ambiguous outbound links — the
|
|
1222
|
+
* `include_ambiguous_links` surfacing on `query-notes` / `GET /notes`. ONE
|
|
1223
|
+
* query for the whole page (mirrors {@link getUnresolvedLinksForNotes}), not
|
|
1224
|
+
* one per note. Every requested id gets an entry (possibly `[]`); when the
|
|
1225
|
+
* table has never been created every id maps to `[]` without a query attempt.
|
|
1226
|
+
*
|
|
1227
|
+
* `visible` (vault#581 auth review) is an OPTIONAL per-candidate-note
|
|
1228
|
+
* predicate, injected by the server layer for a TAG-SCOPED reader. Core
|
|
1229
|
+
* stays scope-unaware — it only invokes the closure — exactly like
|
|
1230
|
+
* `nearTraversable` / `expandVisibility` / `aggregateVisibility` on the MCP
|
|
1231
|
+
* tool layer.
|
|
1232
|
+
*
|
|
1233
|
+
* Why it's needed: `candidate_count` is derived VAULT-WIDE by
|
|
1234
|
+
* {@link resolveWikilinkDetailed}, so the stored `2` on a `[[Dup]]` whose
|
|
1235
|
+
* candidates are one `#work` and one `#personal` note tells a `work`-scoped
|
|
1236
|
+
* reader that a second `Dup` exists somewhere it cannot see. Unlike
|
|
1237
|
+
* `broken_links` — which is safe by construction, since "unresolved" can
|
|
1238
|
+
* only mean *matched nothing* and therefore can't fingerprint anything —
|
|
1239
|
+
* an ambiguous row exists ONLY because ≥2 notes matched, and the count
|
|
1240
|
+
* quantifies exactly that.
|
|
1241
|
+
*
|
|
1242
|
+
* So when `visible` is supplied, the persisted count is NOT trusted: each
|
|
1243
|
+
* row's target is re-resolved and its candidates narrowed to the ones the
|
|
1244
|
+
* reader can see. A row is reported only when ≥2 VISIBLE candidates remain,
|
|
1245
|
+
* and `candidate_count` is the visible count. That is precisely the answer
|
|
1246
|
+
* an unscoped reader would get on a vault containing only the visible notes
|
|
1247
|
+
* — the same "answer on the visible sub-vault" rule vault#674 (`.tags`
|
|
1248
|
+
* scrubbing) and vault#675 (out-of-scope query tags match nothing) chose,
|
|
1249
|
+
* rather than refusing the request. It also incidentally hides a row that
|
|
1250
|
+
* has gone stale (its target resolves cleanly again but no sweep has
|
|
1251
|
+
* touched it yet), since that too collapses to <2 candidates.
|
|
1252
|
+
*
|
|
1253
|
+
* Cost: one re-resolution per persisted row, and only for scoped readers.
|
|
1254
|
+
* Ambiguous rows are rare by nature (each is a genuine naming collision),
|
|
1255
|
+
* and a page with none does no extra work at all.
|
|
1256
|
+
*/
|
|
1257
|
+
interface AmbiguousRow {
|
|
1258
|
+
source_id: string;
|
|
1259
|
+
target_path: string;
|
|
1260
|
+
relationship: string;
|
|
1261
|
+
candidate_count: number;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Read the persisted `ambiguous_wikilinks` rows for a set of source notes,
|
|
1266
|
+
* batched under the bound-param cap. `[]` when the table has never been
|
|
1267
|
+
* created. Shared by the ambiguity surface and the vault#239 brokenness
|
|
1268
|
+
* surface so the two read the same rows the same way.
|
|
1269
|
+
*/
|
|
1270
|
+
function readAmbiguousRows(db: Database, noteIds: string[]): AmbiguousRow[] {
|
|
1271
|
+
const rows: AmbiguousRow[] = [];
|
|
1272
|
+
try {
|
|
1273
|
+
for (const chunk of chunkForInClause(noteIds)) {
|
|
1274
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
1275
|
+
rows.push(...db.prepare(
|
|
1276
|
+
`SELECT source_id, target_path, relationship, candidate_count FROM ambiguous_wikilinks WHERE source_id IN (${placeholders})`,
|
|
1277
|
+
).all(...chunk) as AmbiguousRow[]);
|
|
1278
|
+
}
|
|
1279
|
+
} catch {
|
|
1280
|
+
return []; // Table doesn't exist — nothing has ever been ambiguous here.
|
|
1281
|
+
}
|
|
1282
|
+
return rows;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* How many notes this target resolves to IN THE READER'S OWN SUB-VAULT — the
|
|
1287
|
+
* single place both scoped link surfaces decide what a reference means for a
|
|
1288
|
+
* tag-scoped reader, so they cannot drift:
|
|
1289
|
+
*
|
|
1290
|
+
* - `>= 2` → ambiguous for this reader ({@link getAmbiguousLinksForNotes})
|
|
1291
|
+
* - `0` → broken for this reader ({@link getUnresolvedLinksForNotes})
|
|
1292
|
+
* - `1` → resolves cleanly; neither surface reports it
|
|
1293
|
+
*
|
|
1294
|
+
* A row that has gone stale (its target resolves to exactly one note again,
|
|
1295
|
+
* but no sweep has touched it yet) resolves through the SAME resolver its
|
|
1296
|
+
* write path used, so `resolved` has to be honoured explicitly — a resolved
|
|
1297
|
+
* `WikilinkResolution` carries an EMPTY `candidates` array, which would
|
|
1298
|
+
* otherwise read as "0 visible", i.e. broken.
|
|
1299
|
+
*/
|
|
1300
|
+
function visibleResolutionCount(
|
|
1301
|
+
db: Database,
|
|
1302
|
+
targetPath: string,
|
|
1303
|
+
relationship: string,
|
|
1304
|
+
visible: (noteId: string) => boolean,
|
|
1305
|
+
): number {
|
|
1306
|
+
const detail = relationship === WIKILINK_REL
|
|
1307
|
+
? resolveWikilinkDetailed(db, targetPath)
|
|
1308
|
+
: resolveLinkTargetDetailed(db, targetPath);
|
|
1309
|
+
if (detail.resolved) return detail.note_id && visible(detail.note_id) ? 1 : 0;
|
|
1310
|
+
return detail.candidates.filter((c) => visible(c.note_id)).length;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Build the `ambiguous_link` write-time warning. ONE template per relationship
|
|
1315
|
+
* shape (content `[[wikilink]]` vs a structured `links` entry), shared by every
|
|
1316
|
+
* producer AND by {@link narrowLinkWarningsForVisibility}'s re-decision, so a
|
|
1317
|
+
* scoped caller's warning text can never drift from the unscoped one.
|
|
1318
|
+
*/
|
|
1319
|
+
export function ambiguousLinkWarning(
|
|
1320
|
+
target: string,
|
|
1321
|
+
relationship: string,
|
|
1322
|
+
candidateCount: number,
|
|
1323
|
+
): QueryWarning {
|
|
1324
|
+
return {
|
|
1325
|
+
code: "ambiguous_link",
|
|
1326
|
+
message: relationship === WIKILINK_REL
|
|
1327
|
+
? `wikilink target "${target}" matched ${candidateCount} notes — ambiguous, no link created. Use a more specific path, [[Target.ext]], or the note's ID to disambiguate.`
|
|
1328
|
+
: `link target "${target}" (relationship "${relationship}") matched ${candidateCount} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
|
|
1329
|
+
target,
|
|
1330
|
+
relationship,
|
|
1331
|
+
candidate_count: candidateCount,
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** Twin of {@link ambiguousLinkWarning} for the "matched nothing" outcome. */
|
|
1336
|
+
export function unresolvedLinkWarning(target: string, relationship: string): QueryWarning {
|
|
1337
|
+
return {
|
|
1338
|
+
code: "unresolved_link",
|
|
1339
|
+
message: relationship === WIKILINK_REL
|
|
1340
|
+
? `wikilink target "${target}" did not resolve to any note — queued and will backfill automatically if a matching note is created later.`
|
|
1341
|
+
: `link target "${target}" (relationship "${relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
1342
|
+
target,
|
|
1343
|
+
relationship,
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* WRITE-side twin of {@link getAmbiguousLinksForNotes}'s narrowing (vault#707).
|
|
1349
|
+
*
|
|
1350
|
+
* `create-note` / `update-note` echo a per-note `warnings` array, and an
|
|
1351
|
+
* `ambiguous_link` entry there carries a `candidate_count` derived VAULT-WIDE.
|
|
1352
|
+
* Without this, a `work`-scoped WRITER who saves a note containing `[[Dup]]`
|
|
1353
|
+
* learns from `candidate_count: 2` that a second `Dup` exists in a scope it
|
|
1354
|
+
* cannot see — the exact oracle vault#707 closed on the read side, reachable
|
|
1355
|
+
* through the write door instead.
|
|
1356
|
+
*
|
|
1357
|
+
* Same contract, same single decision function ({@link visibleResolutionCount}),
|
|
1358
|
+
* so the two doors cannot drift: a scoped writer gets exactly what an unscoped
|
|
1359
|
+
* writer would get on a vault containing only the notes it can see.
|
|
1360
|
+
*
|
|
1361
|
+
* - `>= 2` visible → keep, with `candidate_count` = the VISIBLE count
|
|
1362
|
+
* - `1` visible → not ambiguous in this sub-vault; drop the warning
|
|
1363
|
+
* - `0` visible → demote to `unresolved_link` (a plain broken link)
|
|
1364
|
+
*
|
|
1365
|
+
* Non-`ambiguous_link` warnings pass through untouched: `unresolved_link`
|
|
1366
|
+
* means "matched nothing", which by construction fingerprints nothing (the
|
|
1367
|
+
* same reasoning vault#707 applied to `include_broken_links`). Callers pass
|
|
1368
|
+
* `visible` ONLY for a tag-scoped session, so the unscoped response is
|
|
1369
|
+
* byte-identical — this function is never called there.
|
|
1370
|
+
*/
|
|
1371
|
+
export function narrowLinkWarningsForVisibility(
|
|
1372
|
+
db: Database,
|
|
1373
|
+
warnings: QueryWarning[],
|
|
1374
|
+
visible: (noteId: string) => boolean,
|
|
1375
|
+
): QueryWarning[] {
|
|
1376
|
+
const out: QueryWarning[] = [];
|
|
1377
|
+
for (const w of warnings) {
|
|
1378
|
+
if (w.code !== "ambiguous_link" || typeof w.target !== "string") {
|
|
1379
|
+
out.push(w);
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
const relationship = typeof w.relationship === "string" ? w.relationship : WIKILINK_REL;
|
|
1383
|
+
const count = visibleResolutionCount(db, w.target, relationship, visible);
|
|
1384
|
+
if (count >= 2) out.push(ambiguousLinkWarning(w.target, relationship, count));
|
|
1385
|
+
else if (count === 0) out.push(unresolvedLinkWarning(w.target, relationship));
|
|
1386
|
+
// count === 1 → resolves cleanly in this sub-vault; report nothing.
|
|
1387
|
+
}
|
|
1388
|
+
return out;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
export function getAmbiguousLinksForNotes(
|
|
1392
|
+
db: Database,
|
|
1393
|
+
noteIds: string[],
|
|
1394
|
+
visible?: (noteId: string) => boolean,
|
|
1395
|
+
): Map<string, AmbiguousLink[]> {
|
|
1396
|
+
const result = new Map<string, AmbiguousLink[]>(noteIds.map((id) => [id, []]));
|
|
1397
|
+
if (noteIds.length === 0) return result;
|
|
1398
|
+
|
|
1399
|
+
const rows = readAmbiguousRows(db, noteIds);
|
|
1400
|
+
|
|
1401
|
+
for (const row of rows) {
|
|
1402
|
+
const relationship = row.relationship || WIKILINK_REL;
|
|
1403
|
+
let candidateCount = row.candidate_count;
|
|
1404
|
+
if (visible) {
|
|
1405
|
+
candidateCount = visibleResolutionCount(db, row.target_path, relationship, visible);
|
|
1406
|
+
if (candidateCount < 2) continue; // not ambiguous in the reader's sub-vault
|
|
1407
|
+
}
|
|
1408
|
+
result.get(row.source_id)?.push({
|
|
1409
|
+
target: row.target_path,
|
|
1410
|
+
relationship,
|
|
1411
|
+
candidate_count: candidateCount,
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
return result;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** Single-note convenience wrapper around {@link getAmbiguousLinksForNotes}. */
|
|
1418
|
+
export function getAmbiguousLinksForNote(
|
|
1419
|
+
db: Database,
|
|
1420
|
+
noteId: string,
|
|
1421
|
+
visible?: (noteId: string) => boolean,
|
|
1422
|
+
): AmbiguousLink[] {
|
|
1423
|
+
return getAmbiguousLinksForNotes(db, [noteId], visible).get(noteId) ?? [];
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Narrow a page of notes by the vault#581 `has_ambiguous_links` filter as a
|
|
1428
|
+
* TAG-SCOPED reader should see it. Core's SQL filter counts a persisted row
|
|
1429
|
+
* regardless of whether the reader can see the notes that collided, so on
|
|
1430
|
+
* its own it is an oracle: a scoped caller could sweep its whole in-scope
|
|
1431
|
+
* corpus and enumerate cross-scope naming collisions.
|
|
1432
|
+
*
|
|
1433
|
+
* The server layer therefore asks core for a SUPERSET and applies the real
|
|
1434
|
+
* predicate here:
|
|
1435
|
+
* - `wanted === true` — the SQL filter is kept (every truly-ambiguous note
|
|
1436
|
+
* also has a row, so `EXISTS` is a superset) and this drops the notes
|
|
1437
|
+
* whose rows collapse to <2 visible candidates.
|
|
1438
|
+
* - `wanted === false` — the SQL filter is LIFTED (it would have excluded
|
|
1439
|
+
* notes that are not ambiguous in the reader's sub-vault, and a
|
|
1440
|
+
* post-filter cannot add rows back), and this keeps only notes with no
|
|
1441
|
+
* surviving row.
|
|
1442
|
+
*
|
|
1443
|
+
* Page-shortening is the same effect `filterNotesByTagScope` already has on
|
|
1444
|
+
* every scoped read — the page is narrowed after core drew it, so a scoped
|
|
1445
|
+
* page can come back shorter than `limit` while more results remain.
|
|
1446
|
+
* No-op when `wanted` is undefined or no predicate is injected (unscoped).
|
|
1447
|
+
*/
|
|
1448
|
+
export function narrowByVisibleAmbiguity<T extends { id: string }>(
|
|
1449
|
+
db: Database,
|
|
1450
|
+
notes: T[],
|
|
1451
|
+
wanted: boolean | undefined,
|
|
1452
|
+
visible: ((noteId: string) => boolean) | undefined,
|
|
1453
|
+
): T[] {
|
|
1454
|
+
if (wanted === undefined || !visible || notes.length === 0) return notes;
|
|
1455
|
+
const byNote = getAmbiguousLinksForNotes(db, notes.map((n) => n.id), visible);
|
|
1456
|
+
return notes.filter((n) => ((byNote.get(n.id)?.length ?? 0) > 0) === wanted);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* The `hasAmbiguousLinks` value to push into SQL for a reader that will be
|
|
1461
|
+
* narrowed by {@link narrowByVisibleAmbiguity} afterwards. `true` stays (it
|
|
1462
|
+
* is a superset); `false` is lifted to `undefined` (it is not). Identity for
|
|
1463
|
+
* unscoped readers. Shared by both doors so REST and MCP cannot drift on
|
|
1464
|
+
* which polarity is safe to push down.
|
|
1465
|
+
*/
|
|
1466
|
+
export function sqlHasAmbiguousLinks(
|
|
1467
|
+
wanted: boolean | undefined,
|
|
1468
|
+
scoped: boolean,
|
|
1469
|
+
): boolean | undefined {
|
|
1470
|
+
return scoped && wanted === false ? undefined : wanted;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Every string a `[[wikilink]]` / structured-link target could have used to
|
|
1475
|
+
* match `note` — its path, its basename, its `path.ext` form, and its H1
|
|
1476
|
+
* title: the four legs {@link resolveWikilinkDetailed} matches on, so this is
|
|
1477
|
+
* a complete necessary-condition superset. Lower-cased and trimmed. Shared by
|
|
1478
|
+
* {@link requeueInboundWikilinksForDelete}'s pre-filter and
|
|
1479
|
+
* {@link refreshAmbiguousLinks}'s scope so the two can't drift on what
|
|
1480
|
+
* "targets this note" means.
|
|
1481
|
+
*/
|
|
1482
|
+
export function noteResolutionKeys(note: Pick<Note, "path" | "extension" | "content"> | null | undefined): string[] {
|
|
1483
|
+
const keys = new Set<string>();
|
|
1484
|
+
const addKey = (s: string | null | undefined): void => {
|
|
1485
|
+
const k = s?.trim().toLowerCase();
|
|
1486
|
+
if (k) keys.add(k);
|
|
1487
|
+
};
|
|
1488
|
+
if (note?.path) {
|
|
1489
|
+
addKey(note.path);
|
|
1490
|
+
const slash = note.path.lastIndexOf("/");
|
|
1491
|
+
addKey(slash >= 0 ? note.path.slice(slash + 1) : note.path); // basename
|
|
1492
|
+
if (note.extension) addKey(`${note.path}.${note.extension}`);
|
|
1493
|
+
}
|
|
1494
|
+
if (note?.content) addKey(extractH1Title(note.content));
|
|
1495
|
+
return [...keys];
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/** {@link noteResolutionKeys} for a bare path string (no note row to read) — used for a note's OLD path after a rename. */
|
|
1499
|
+
export function pathResolutionKeys(path: string | null | undefined, extension?: string | null): string[] {
|
|
1500
|
+
if (!path) return [];
|
|
1501
|
+
const keys = [path];
|
|
1502
|
+
const slash = path.lastIndexOf("/");
|
|
1503
|
+
keys.push(slash >= 0 ? path.slice(slash + 1) : path);
|
|
1504
|
+
if (extension) keys.push(`${path}.${extension}`);
|
|
1505
|
+
return keys.map((k) => k.trim().toLowerCase()).filter(Boolean);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Re-evaluate every persisted ambiguous row whose target is one of `keys` —
|
|
1510
|
+
* the self-healing counterpart of {@link resolveUnresolvedWikilinks}, and the
|
|
1511
|
+
* reason `has_ambiguous_links` can't go stale when the collision is cleaned
|
|
1512
|
+
* up. Called AFTER a note is created, deleted, or repathed, with that note's
|
|
1513
|
+
* {@link noteResolutionKeys} (plus the OLD path's keys on a rename), so the
|
|
1514
|
+
* scan is bounded to rows that note could possibly have been a candidate for.
|
|
1515
|
+
*
|
|
1516
|
+
* Each surviving row is re-run through the SAME resolver its write path used
|
|
1517
|
+
* (picked by `relationship`, exactly as the unresolved sweep does), and lands
|
|
1518
|
+
* in one of three places:
|
|
1519
|
+
*
|
|
1520
|
+
* - resolves to exactly one note now (a candidate was deleted or renamed
|
|
1521
|
+
* away) → the link is created and the row is dropped. The reference is
|
|
1522
|
+
* no longer ambiguous, so it must stop being reported as such.
|
|
1523
|
+
* - still ambiguous → the row stays; `candidate_count` is refreshed if the
|
|
1524
|
+
* number of colliding notes changed.
|
|
1525
|
+
* - matches nothing now (every candidate is gone) → the row moves to
|
|
1526
|
+
* `unresolved_wikilinks`, i.e. it becomes an ordinary BROKEN link that
|
|
1527
|
+
* `has_broken_links` reports and the normal backfill can later heal.
|
|
1528
|
+
*
|
|
1529
|
+
* Returns the number of rows changed. No-op (one bounded SELECT, or none at
|
|
1530
|
+
* all when the table has never been created) on a vault with no ambiguity.
|
|
1531
|
+
*/
|
|
1532
|
+
export function refreshAmbiguousLinks(db: Database, keys: (string | null | undefined)[]): number {
|
|
1533
|
+
const normalized = [...new Set(
|
|
1534
|
+
keys.map((k) => k?.trim().toLowerCase()).filter((k): k is string => Boolean(k)),
|
|
1535
|
+
)];
|
|
1536
|
+
if (normalized.length === 0) return 0;
|
|
1537
|
+
|
|
1538
|
+
let rows: { source_id: string; target_path: string; relationship: string; candidate_count: number }[];
|
|
1539
|
+
try {
|
|
1540
|
+
// `keys` is at most a handful of strings (one note's resolution keys,
|
|
1541
|
+
// optionally plus an old path's), so this stays well under the bound-param
|
|
1542
|
+
// cap that `chunkForInClause` guards elsewhere.
|
|
1543
|
+
const clauses = normalized.map(() => "target_path = ? COLLATE NOCASE").join(" OR ");
|
|
1544
|
+
rows = db.prepare(
|
|
1545
|
+
`SELECT source_id, target_path, relationship, candidate_count FROM ambiguous_wikilinks WHERE ${clauses}`,
|
|
1546
|
+
).all(...normalized) as typeof rows;
|
|
1547
|
+
} catch {
|
|
1548
|
+
return 0; // Table doesn't exist — nothing has ever been ambiguous here.
|
|
1549
|
+
}
|
|
1550
|
+
if (rows.length === 0) return 0;
|
|
1551
|
+
|
|
1552
|
+
const drop = db.prepare(
|
|
1553
|
+
"DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND target_path = ? AND relationship = ?",
|
|
1554
|
+
);
|
|
1555
|
+
const bumpCount = db.prepare(
|
|
1556
|
+
"UPDATE ambiguous_wikilinks SET candidate_count = ? WHERE source_id = ? AND target_path = ? AND relationship = ?",
|
|
1557
|
+
);
|
|
1558
|
+
|
|
1559
|
+
let changed = 0;
|
|
1560
|
+
for (const row of rows) {
|
|
1561
|
+
const relationship = row.relationship || WIKILINK_REL;
|
|
1562
|
+
const detail = relationship === WIKILINK_REL
|
|
1563
|
+
? resolveWikilinkDetailed(db, row.target_path)
|
|
1564
|
+
: resolveLinkTargetDetailed(db, row.target_path);
|
|
1565
|
+
|
|
1566
|
+
if (detail.resolved) {
|
|
1567
|
+
// Wikilinks never self-link (write-time skips them), so guard here too.
|
|
1568
|
+
if (detail.note_id !== row.source_id) {
|
|
1569
|
+
linkOps.createLink(db, row.source_id, detail.note_id!, relationship);
|
|
1570
|
+
}
|
|
1571
|
+
drop.run(row.source_id, row.target_path, relationship);
|
|
1572
|
+
changed++;
|
|
1573
|
+
} else if (detail.ambiguous) {
|
|
1574
|
+
if (detail.candidates.length !== row.candidate_count) {
|
|
1575
|
+
bumpCount.run(detail.candidates.length, row.source_id, row.target_path, relationship);
|
|
1576
|
+
changed++;
|
|
1577
|
+
}
|
|
1578
|
+
} else {
|
|
1579
|
+
drop.run(row.source_id, row.target_path, relationship);
|
|
1580
|
+
queueUnresolvedLink(db, row.source_id, row.target_path, relationship);
|
|
1581
|
+
changed++;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
return changed;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
749
1587
|
// ---------------------------------------------------------------------------
|
|
750
1588
|
// Structured links — same resolution + lazy forward-ref semantics as
|
|
751
1589
|
// [[wikilinks]] (vault#555). A structured `links: [{target, relationship}]`
|
|
@@ -879,10 +1717,12 @@ export function clearQueuedLinkTarget(
|
|
|
879
1717
|
* Returns a {@link ResolveOrQueueOutcome}:
|
|
880
1718
|
* - `"resolved"` — the edge should be created against `note_id` now.
|
|
881
1719
|
* - `"ambiguous"` (vault#570) — the target matched ≥2 notes (e.g. two
|
|
882
|
-
* notes sharing an H1 title). NEITHER linked nor queued —
|
|
883
|
-
* {@link syncWikilinks}'s doc comment for why queuing an ambiguous
|
|
884
|
-
* target would be wrong
|
|
885
|
-
* `
|
|
1720
|
+
* notes sharing an H1 title). NEITHER linked nor queued for backfill —
|
|
1721
|
+
* see {@link syncWikilinks}'s doc comment for why queuing an ambiguous
|
|
1722
|
+
* target into `unresolved_wikilinks` would be wrong — but recorded in
|
|
1723
|
+
* `ambiguous_wikilinks` (vault#581) so it stays queryable after the
|
|
1724
|
+
* write. Callers MUST surface a distinct `ambiguous_link` warning
|
|
1725
|
+
* naming the target + `candidates.length`.
|
|
886
1726
|
* - `"queued"` — the target matched NO note; queued for lazy backfill.
|
|
887
1727
|
* Callers MUST surface an `unresolved_link` warning naming the target.
|
|
888
1728
|
*
|
|
@@ -897,8 +1737,18 @@ export function resolveOrQueueLink(
|
|
|
897
1737
|
relationship: string,
|
|
898
1738
|
): ResolveOrQueueOutcome {
|
|
899
1739
|
const detail = resolveLinkTargetDetailed(db, target);
|
|
1740
|
+
if (detail.ambiguous) {
|
|
1741
|
+
// vault#581 — record the collision so it survives the response. Every
|
|
1742
|
+
// structured-link write path (MCP create/update-note, REST POST/PATCH,
|
|
1743
|
+
// typed `reference` fields) funnels through here, so persisting once
|
|
1744
|
+
// here covers all of them and can't drift from the warning.
|
|
1745
|
+
queueAmbiguousLink(db, sourceId, target, relationship, detail.candidates.length);
|
|
1746
|
+
return { status: "ambiguous", candidates: detail.candidates };
|
|
1747
|
+
}
|
|
1748
|
+
// Not ambiguous (any more): drop a stale row this exact (source, target,
|
|
1749
|
+
// relationship) may have left behind on an earlier write.
|
|
1750
|
+
clearAmbiguousLinkTarget(db, sourceId, relationship, target);
|
|
900
1751
|
if (detail.resolved) return { status: "resolved", note_id: detail.note_id! };
|
|
901
|
-
if (detail.ambiguous) return { status: "ambiguous", candidates: detail.candidates };
|
|
902
1752
|
queueUnresolvedLink(db, sourceId, target, relationship);
|
|
903
1753
|
return { status: "queued" };
|
|
904
1754
|
}
|
|
@@ -932,20 +1782,9 @@ export function getContentWikilinkWarnings(
|
|
|
932
1782
|
const detail = resolveWikilinkDetailed(db, wl.target);
|
|
933
1783
|
if (detail.resolved) continue; // resolved (incl. self-link) — nothing to warn about
|
|
934
1784
|
if (detail.ambiguous) {
|
|
935
|
-
warnings.push(
|
|
936
|
-
code: "ambiguous_link",
|
|
937
|
-
message: `wikilink target "${wl.target}" matched ${detail.candidates.length} notes — ambiguous, no link created. Use a more specific path, [[Target.ext]], or the note's ID to disambiguate.`,
|
|
938
|
-
target: wl.target,
|
|
939
|
-
relationship: WIKILINK_REL,
|
|
940
|
-
candidate_count: detail.candidates.length,
|
|
941
|
-
});
|
|
1785
|
+
warnings.push(ambiguousLinkWarning(wl.target, WIKILINK_REL, detail.candidates.length));
|
|
942
1786
|
} else {
|
|
943
|
-
warnings.push(
|
|
944
|
-
code: "unresolved_link",
|
|
945
|
-
message: `wikilink target "${wl.target}" did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
946
|
-
target: wl.target,
|
|
947
|
-
relationship: WIKILINK_REL,
|
|
948
|
-
});
|
|
1787
|
+
warnings.push(unresolvedLinkWarning(wl.target, WIKILINK_REL));
|
|
949
1788
|
}
|
|
950
1789
|
}
|
|
951
1790
|
|
|
@@ -1005,25 +1844,15 @@ export function requeueInboundWikilinksForDelete(db: Database, noteId: string):
|
|
|
1005
1844
|
// resolution keys — its path, basename, H1 title, or `path.ext` form (the
|
|
1006
1845
|
// four legs {@link resolveWikilinkDetailed} matches on; every leg produces a
|
|
1007
1846
|
// target string equal to one of these, so the key set is a complete
|
|
1008
|
-
// necessary-condition superset
|
|
1009
|
-
//
|
|
1847
|
+
// necessary-condition superset — {@link noteResolutionKeys} owns that key
|
|
1848
|
+
// set, shared with the vault#581 ambiguity sweep). Computed ONCE, then each
|
|
1849
|
+
// source wikilink's target is gated on set membership BEFORE the resolver call
|
|
1010
1850
|
// (whose title-fallback leg scans every note's content). Without this, a hub
|
|
1011
1851
|
// note with hundreds of inbound sources would fire hundreds of full-vault
|
|
1012
1852
|
// scans in one delete. The resolver still CONFIRMS each survivor — the
|
|
1013
1853
|
// pre-filter narrows, it doesn't decide.
|
|
1014
1854
|
const deleted = getNote(db, noteId);
|
|
1015
|
-
const keys = new Set
|
|
1016
|
-
const addKey = (s: string | null | undefined): void => {
|
|
1017
|
-
const k = s?.trim().toLowerCase();
|
|
1018
|
-
if (k) keys.add(k);
|
|
1019
|
-
};
|
|
1020
|
-
if (deleted?.path) {
|
|
1021
|
-
addKey(deleted.path);
|
|
1022
|
-
const slash = deleted.path.lastIndexOf("/");
|
|
1023
|
-
addKey(slash >= 0 ? deleted.path.slice(slash + 1) : deleted.path); // basename
|
|
1024
|
-
if (deleted.extension) addKey(`${deleted.path}.${deleted.extension}`);
|
|
1025
|
-
}
|
|
1026
|
-
if (deleted?.content) addKey(extractH1Title(deleted.content));
|
|
1855
|
+
const keys = new Set(noteResolutionKeys(deleted));
|
|
1027
1856
|
if (keys.size === 0) return; // no key any wikilink could have matched on
|
|
1028
1857
|
|
|
1029
1858
|
for (const { source_id } of inbound) {
|