@openparachute/vault 0.7.9-rc.2 → 0.7.9-rc.3
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/mcp-manifest.ts +6 -2
- package/core/src/mcp.ts +113 -38
- 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 +806 -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/routes.ts +215 -61
- 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/vault.test.ts +685 -0
- 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/mcp.ts
CHANGED
|
@@ -23,7 +23,15 @@ import {
|
|
|
23
23
|
resolveStructuredLinkNote,
|
|
24
24
|
getUnresolvedLinksForNote,
|
|
25
25
|
getUnresolvedLinksForNotes,
|
|
26
|
+
narrowByVisibleBrokenness,
|
|
27
|
+
sqlHasBrokenLinks,
|
|
28
|
+
getAmbiguousLinksForNote,
|
|
29
|
+
getAmbiguousLinksForNotes,
|
|
30
|
+
narrowByVisibleAmbiguity,
|
|
31
|
+
sqlHasAmbiguousLinks,
|
|
26
32
|
getContentWikilinkWarnings,
|
|
33
|
+
ambiguousLinkWarning,
|
|
34
|
+
unresolvedLinkWarning,
|
|
27
35
|
} from "./wikilinks.js";
|
|
28
36
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
29
37
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -143,6 +151,17 @@ function structuredError(
|
|
|
143
151
|
return Object.assign(new Error(message), fields);
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
function requireNoteReference(value: unknown): string {
|
|
155
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
156
|
+
throw structuredError("`id` is required", {
|
|
157
|
+
error_type: "missing_required_field",
|
|
158
|
+
field: "id",
|
|
159
|
+
hint: "pass the note's id or path (or its unique H1 title)",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
146
165
|
/**
|
|
147
166
|
* Resolve a note identifier — tries ID first, then case-insensitive
|
|
148
167
|
* path match, then (additive fallback) an H1-title match. Works
|
|
@@ -470,6 +489,23 @@ export interface GenerateMcpToolsOpts {
|
|
|
470
489
|
* path with no extra fetch.
|
|
471
490
|
*/
|
|
472
491
|
aggregateVisibility?: (note: Note) => boolean;
|
|
492
|
+
/**
|
|
493
|
+
* `ambiguityVisible` (vault#581 auth review) is an OPTIONAL per-note
|
|
494
|
+
* predicate gating everything the ambiguous-links surface discloses.
|
|
495
|
+
* `candidate_count` is derived VAULT-WIDE, so a stored `2` on a `[[Dup]]`
|
|
496
|
+
* split across scopes tells a tag-scoped reader that a note it cannot see
|
|
497
|
+
* exists — and `has_ambiguous_links: true` would let it sweep its whole
|
|
498
|
+
* in-scope corpus for such collisions. When provided, each persisted row
|
|
499
|
+
* is re-resolved and its candidates narrowed to the visible ones: a row
|
|
500
|
+
* is disclosed only when ≥2 remain, `candidate_count` is the visible
|
|
501
|
+
* count, and `has_ambiguous_links` is answered against that same narrowed
|
|
502
|
+
* view (see `narrowByVisibleAmbiguity` / `sqlHasAmbiguousLinks` in
|
|
503
|
+
* core/src/wikilinks.ts). Same contract as `nearTraversable`: core stays
|
|
504
|
+
* scope-unaware and only invokes the injected `(noteId) => boolean`
|
|
505
|
+
* closure. Omitted (unscoped / internal callers) → the persisted counts
|
|
506
|
+
* are returned as-is and the SQL filter answers directly, unchanged.
|
|
507
|
+
*/
|
|
508
|
+
ambiguityVisible?: (noteId: string) => boolean;
|
|
473
509
|
/**
|
|
474
510
|
* `AttachmentTicketProvider` seam (vault attachment-tickets design,
|
|
475
511
|
* Wave 1 — D10 "tools omitted when unwired"). When provided,
|
|
@@ -549,6 +585,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
549
585
|
const nearTraversable = opts?.nearTraversable;
|
|
550
586
|
const ifExistsVisible = opts?.ifExistsVisible;
|
|
551
587
|
const aggregateVisibility = opts?.aggregateVisibility;
|
|
588
|
+
const ambiguityVisible = opts?.ambiguityVisible;
|
|
552
589
|
// Write-attribution (vault#298) — captured once at tool-generation time
|
|
553
590
|
// (a fresh tool set is generated per MCP request, so this is request-scoped)
|
|
554
591
|
// and folded into every create/update the tools perform.
|
|
@@ -600,6 +637,28 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
600
637
|
{
|
|
601
638
|
name: "query-notes",
|
|
602
639
|
execute: async (params) => {
|
|
640
|
+
// --- Ambiguous-links scope split (vault#581 auth review) ---
|
|
641
|
+
// `requestedHasAmbiguous` is what the caller asked for;
|
|
642
|
+
// `sqlHasAmbiguous` is what is safe to push into SQL for a reader
|
|
643
|
+
// that will be narrowed by `narrowByVisibleAmbiguity` afterwards
|
|
644
|
+
// (`true` is a superset and stays; `false` is lifted). Identical for
|
|
645
|
+
// an unscoped reader, where no predicate is injected and the SQL
|
|
646
|
+
// filter alone is the whole answer.
|
|
647
|
+
const requestedHasAmbiguous = params.has_ambiguous_links as boolean | undefined;
|
|
648
|
+
const sqlHasAmbiguous = sqlHasAmbiguousLinks(requestedHasAmbiguous, Boolean(ambiguityVisible));
|
|
649
|
+
|
|
650
|
+
// --- Broken-links scope split (vault#239) ---
|
|
651
|
+
// Same shape as the ambiguity split above, one polarity stricter:
|
|
652
|
+
// NEITHER `true` nor `false` is safe to push into SQL for a scoped
|
|
653
|
+
// reader, because a note can be broken in that reader's sub-vault
|
|
654
|
+
// while carrying no `unresolved_wikilinks` row at all (its target's
|
|
655
|
+
// only candidates are invisible, so the row sits in
|
|
656
|
+
// `ambiguous_wikilinks`). `sqlHasBrokenLinks` lifts both and
|
|
657
|
+
// `narrowByVisibleBrokenness` re-decides per note. Identical for an
|
|
658
|
+
// unscoped reader, where the SQL filter alone is the whole answer.
|
|
659
|
+
const requestedHasBroken = params.has_broken_links as boolean | undefined;
|
|
660
|
+
const sqlHasBroken = sqlHasBrokenLinks(requestedHasBroken, Boolean(ambiguityVisible));
|
|
661
|
+
|
|
603
662
|
// --- Link expansion config (shared across single + list paths) ---
|
|
604
663
|
const expandLinks = params.expand_links === true;
|
|
605
664
|
const expandMode = (params.expand_mode as ExpandMode) ?? "full";
|
|
@@ -671,7 +730,10 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
671
730
|
result.links = linkOps.getLinksHydrated(db, note.id);
|
|
672
731
|
}
|
|
673
732
|
if (params.include_broken_links) {
|
|
674
|
-
result.broken_links = getUnresolvedLinksForNote(db, note.id);
|
|
733
|
+
result.broken_links = getUnresolvedLinksForNote(db, note.id, ambiguityVisible);
|
|
734
|
+
}
|
|
735
|
+
if (params.include_ambiguous_links) {
|
|
736
|
+
result.ambiguous_links = getAmbiguousLinksForNote(db, note.id, ambiguityVisible);
|
|
675
737
|
}
|
|
676
738
|
if (params.include_attachments) {
|
|
677
739
|
result.attachments = await store.getAttachments(note.id);
|
|
@@ -816,7 +878,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
816
878
|
excludeTags: aggExcludeTags,
|
|
817
879
|
hasTags: params.has_tags as boolean | undefined,
|
|
818
880
|
hasLinks: params.has_links as boolean | undefined,
|
|
819
|
-
hasBrokenLinks:
|
|
881
|
+
hasBrokenLinks: sqlHasBroken,
|
|
882
|
+
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
820
883
|
path: params.path as string | undefined,
|
|
821
884
|
pathPrefix: params.path_prefix as string | undefined,
|
|
822
885
|
excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
|
|
@@ -842,7 +905,23 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
842
905
|
// set (reusing the `ids` semijoin `near` already pushes into SQL).
|
|
843
906
|
// Core stays scope-unaware — it only invokes the plain closure.
|
|
844
907
|
const aggAllMatches = await store.queryNotes({ ...aggFilterOpts, limit: 1000000 });
|
|
845
|
-
|
|
908
|
+
// vault#581 auth review: a rollup over `has_ambiguous_links` is the
|
|
909
|
+
// same oracle as the note-level filter (a non-zero count still
|
|
910
|
+
// reveals the collision), so narrow the visible id set by the
|
|
911
|
+
// reader's own view of each row before aggregating.
|
|
912
|
+
// vault#239: the `has_broken_links` rollup is the same oracle for
|
|
913
|
+
// the same reason — re-decide brokenness on the sub-vault too.
|
|
914
|
+
const aggVisibleIds = narrowByVisibleBrokenness(
|
|
915
|
+
db,
|
|
916
|
+
narrowByVisibleAmbiguity(
|
|
917
|
+
db,
|
|
918
|
+
aggAllMatches.filter(aggregateVisibility),
|
|
919
|
+
requestedHasAmbiguous,
|
|
920
|
+
ambiguityVisible,
|
|
921
|
+
),
|
|
922
|
+
requestedHasBroken,
|
|
923
|
+
ambiguityVisible,
|
|
924
|
+
).map((n) => n.id);
|
|
846
925
|
// Always run the rollup, even on an empty visible set: ungrouped
|
|
847
926
|
// count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
|
|
848
927
|
return await store.aggregateNotes({ ids: aggVisibleIds, aggregate: aggregateSpec });
|
|
@@ -939,7 +1018,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
939
1018
|
excludeTags,
|
|
940
1019
|
hasTags: params.has_tags as boolean | undefined,
|
|
941
1020
|
hasLinks: params.has_links as boolean | undefined,
|
|
942
|
-
hasBrokenLinks:
|
|
1021
|
+
hasBrokenLinks: sqlHasBroken,
|
|
1022
|
+
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
943
1023
|
path: params.path as string | undefined,
|
|
944
1024
|
pathPrefix: params.path_prefix as string | undefined,
|
|
945
1025
|
excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
|
|
@@ -1022,7 +1102,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1022
1102
|
excludeTags,
|
|
1023
1103
|
hasTags: params.has_tags as boolean | undefined,
|
|
1024
1104
|
hasLinks: params.has_links as boolean | undefined,
|
|
1025
|
-
hasBrokenLinks:
|
|
1105
|
+
hasBrokenLinks: sqlHasBroken,
|
|
1106
|
+
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
1026
1107
|
path: params.path as string | undefined,
|
|
1027
1108
|
pathPrefix: params.path_prefix as string | undefined,
|
|
1028
1109
|
excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
|
|
@@ -1091,7 +1172,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1091
1172
|
excludeTags,
|
|
1092
1173
|
hasTags: params.has_tags as boolean | undefined,
|
|
1093
1174
|
hasLinks: params.has_links as boolean | undefined,
|
|
1094
|
-
hasBrokenLinks:
|
|
1175
|
+
hasBrokenLinks: sqlHasBroken,
|
|
1176
|
+
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
1095
1177
|
path: params.path as string | undefined,
|
|
1096
1178
|
pathPrefix: params.path_prefix as string | undefined,
|
|
1097
1179
|
excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
|
|
@@ -1148,6 +1230,17 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1148
1230
|
results = results.filter((n) => nearScope!.has(n.id));
|
|
1149
1231
|
}
|
|
1150
1232
|
|
|
1233
|
+
// vault#581 auth review — the `has_ambiguous_links` filter must
|
|
1234
|
+
// answer on the reader's OWN sub-vault, not the whole vault. No-op
|
|
1235
|
+
// unscoped (no predicate injected) and when the filter wasn't asked
|
|
1236
|
+
// for. See `narrowByVisibleAmbiguity` for the superset contract and
|
|
1237
|
+
// the page-shortening effect.
|
|
1238
|
+
results = narrowByVisibleAmbiguity(db, results, requestedHasAmbiguous, ambiguityVisible);
|
|
1239
|
+
// vault#239 — same rule for `has_broken_links`: the SQL filter was
|
|
1240
|
+
// lifted for a scoped reader, so the real predicate is applied here
|
|
1241
|
+
// on that reader's own sub-vault. No-op unscoped.
|
|
1242
|
+
results = narrowByVisibleBrokenness(db, results, requestedHasBroken, ambiguityVisible);
|
|
1243
|
+
|
|
1151
1244
|
// --- Format output ---
|
|
1152
1245
|
const includeContent = params.include_content === true; // default false for list
|
|
1153
1246
|
// Range params require content in the response — on lists that
|
|
@@ -1219,7 +1312,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1219
1312
|
}
|
|
1220
1313
|
|
|
1221
1314
|
// --- Hydrate links/attachments/broken-links per note if requested ---
|
|
1222
|
-
if (params.include_links || params.include_attachments || params.include_broken_links) {
|
|
1315
|
+
if (params.include_links || params.include_attachments || params.include_broken_links || params.include_ambiguous_links) {
|
|
1223
1316
|
// Links hydrate for the WHOLE page in a constant number of
|
|
1224
1317
|
// queries (see getLinksHydratedForNotes) — the per-note variant
|
|
1225
1318
|
// cost (1 link query + 1 summary query + N tag queries) × page
|
|
@@ -1229,13 +1322,18 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1229
1322
|
: null;
|
|
1230
1323
|
// Same one-batched-query-for-the-page shape as links (vault#555).
|
|
1231
1324
|
const brokenLinksByNote = params.include_broken_links
|
|
1232
|
-
? getUnresolvedLinksForNotes(db, (output as any[]).map((n: any) => n.id))
|
|
1325
|
+
? getUnresolvedLinksForNotes(db, (output as any[]).map((n: any) => n.id), ambiguityVisible)
|
|
1326
|
+
: null;
|
|
1327
|
+
// Same one-batched-query-for-the-page shape for the ambiguity twin (vault#581).
|
|
1328
|
+
const ambiguousLinksByNote = params.include_ambiguous_links
|
|
1329
|
+
? getAmbiguousLinksForNotes(db, (output as any[]).map((n: any) => n.id), ambiguityVisible)
|
|
1233
1330
|
: null;
|
|
1234
1331
|
const enrichedOut: any[] = [];
|
|
1235
1332
|
for (const n of output as any[]) {
|
|
1236
1333
|
const enriched: any = { ...n };
|
|
1237
1334
|
if (linksByNote) enriched.links = linksByNote.get(n.id) ?? [];
|
|
1238
1335
|
if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
|
|
1336
|
+
if (ambiguousLinksByNote) enriched.ambiguous_links = ambiguousLinksByNote.get(n.id) ?? [];
|
|
1239
1337
|
if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
|
|
1240
1338
|
enrichedOut.push(enriched);
|
|
1241
1339
|
}
|
|
@@ -1559,20 +1657,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1559
1657
|
if (outcome.status === "resolved") {
|
|
1560
1658
|
await store.createLink(sourceId, outcome.note_id, link.relationship);
|
|
1561
1659
|
} else if (outcome.status === "ambiguous") {
|
|
1562
|
-
pushLinkWarning(sourceId,
|
|
1563
|
-
code: "ambiguous_link",
|
|
1564
|
-
message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
|
|
1565
|
-
target: link.target,
|
|
1566
|
-
relationship: link.relationship,
|
|
1567
|
-
candidate_count: outcome.candidates.length,
|
|
1568
|
-
});
|
|
1660
|
+
pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
|
|
1569
1661
|
} else {
|
|
1570
|
-
pushLinkWarning(sourceId,
|
|
1571
|
-
code: "unresolved_link",
|
|
1572
|
-
message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
1573
|
-
target: link.target,
|
|
1574
|
-
relationship: link.relationship,
|
|
1575
|
-
});
|
|
1662
|
+
pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
|
|
1576
1663
|
}
|
|
1577
1664
|
}
|
|
1578
1665
|
}
|
|
@@ -1730,7 +1817,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1730
1817
|
// branch using this same item's payload. Otherwise mirror the
|
|
1731
1818
|
// existing `requireNote` behavior (throw "Note not found").
|
|
1732
1819
|
// vault#309.
|
|
1733
|
-
const
|
|
1820
|
+
const idOrPath = requireNoteReference(item.id);
|
|
1821
|
+
const resolved = resolveNote(db, idOrPath);
|
|
1734
1822
|
if (!resolved) {
|
|
1735
1823
|
if (item.if_missing === "create") {
|
|
1736
1824
|
// Treat the update payload as a create payload. Minimum:
|
|
@@ -1765,7 +1853,6 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1765
1853
|
// processed and used by Gitcoin's sync; the
|
|
1766
1854
|
// misleading wording is fixed here so a future
|
|
1767
1855
|
// reader doesn't trust it and break the workflow.
|
|
1768
|
-
const idOrPath = item.id as string;
|
|
1769
1856
|
// Heuristic: if `path` isn't set AND the `id` looks like a
|
|
1770
1857
|
// path (contains "/" or doesn't match a typical opaque-id
|
|
1771
1858
|
// shape), use it as the path too. Otherwise treat it as a
|
|
@@ -2118,20 +2205,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
2118
2205
|
if (outcome.status === "resolved") {
|
|
2119
2206
|
await store.createLink(sourceId, outcome.note_id, link.relationship, link.metadata);
|
|
2120
2207
|
} else if (outcome.status === "ambiguous") {
|
|
2121
|
-
pushLinkWarning(sourceId,
|
|
2122
|
-
code: "ambiguous_link",
|
|
2123
|
-
message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
|
|
2124
|
-
target: link.target,
|
|
2125
|
-
relationship: link.relationship,
|
|
2126
|
-
candidate_count: outcome.candidates.length,
|
|
2127
|
-
});
|
|
2208
|
+
pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
|
|
2128
2209
|
} else {
|
|
2129
|
-
pushLinkWarning(sourceId,
|
|
2130
|
-
code: "unresolved_link",
|
|
2131
|
-
message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
2132
|
-
target: link.target,
|
|
2133
|
-
relationship: link.relationship,
|
|
2134
|
-
});
|
|
2210
|
+
pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
|
|
2135
2211
|
}
|
|
2136
2212
|
}
|
|
2137
2213
|
}
|
|
@@ -2193,7 +2269,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
2193
2269
|
{
|
|
2194
2270
|
name: "delete-note",
|
|
2195
2271
|
execute: async (params) => {
|
|
2196
|
-
const note = requireNote(db, params.id
|
|
2272
|
+
const note = requireNote(db, requireNoteReference(params.id));
|
|
2197
2273
|
await store.deleteNote(note.id);
|
|
2198
2274
|
return { deleted: true, id: note.id };
|
|
2199
2275
|
},
|
|
@@ -3032,4 +3108,3 @@ export class BatchTooLargeError extends Error {
|
|
|
3032
3108
|
this.got = got;
|
|
3033
3109
|
}
|
|
3034
3110
|
}
|
|
3035
|
-
|
package/core/src/notes.ts
CHANGED
|
@@ -1053,8 +1053,9 @@ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditio
|
|
|
1053
1053
|
// Presence: has_broken_links (vault#555) — a dangling outbound wikilink or
|
|
1054
1054
|
// structured `links` target that never resolved. The `unresolved_wikilinks`
|
|
1055
1055
|
// table is created lazily (see wikilinks.ts:ensureUnresolvedTable) only when
|
|
1056
|
-
// a link actually goes unresolved —
|
|
1057
|
-
//
|
|
1056
|
+
// a link actually goes unresolved — migrateToV28 heals a pre-#555 2-column
|
|
1057
|
+
// table at boot but does NOT create the table on a vault that never queued
|
|
1058
|
+
// one. Check existence first rather than reference it
|
|
1058
1059
|
// unconditionally: a read-only query filter shouldn't have the side effect
|
|
1059
1060
|
// of creating a table, and a bare `EXISTS`/`NOT EXISTS` against a missing
|
|
1060
1061
|
// table would throw "no such table" instead of the correct empty answer.
|
|
@@ -1076,6 +1077,28 @@ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditio
|
|
|
1076
1077
|
}
|
|
1077
1078
|
}
|
|
1078
1079
|
|
|
1080
|
+
// Presence: has_ambiguous_links (vault#581) — an outbound `[[wikilink]]` or
|
|
1081
|
+
// structured `links`/`reference` target that matched ≥2 notes, so no link
|
|
1082
|
+
// was created. Same lazily-created-table dance as `has_broken_links` above
|
|
1083
|
+
// (`ambiguous_wikilinks` is only created once a link actually goes
|
|
1084
|
+
// ambiguous), and for the same reasons: a read-only filter must not create
|
|
1085
|
+
// the table, and a bare EXISTS against a missing one throws instead of
|
|
1086
|
+
// answering "none".
|
|
1087
|
+
if (opts.hasAmbiguousLinks !== undefined) {
|
|
1088
|
+
const ambiguousTableExists = db.prepare(
|
|
1089
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ambiguous_wikilinks'",
|
|
1090
|
+
).get() !== null;
|
|
1091
|
+
if (!ambiguousTableExists) {
|
|
1092
|
+
if (opts.hasAmbiguousLinks) conditions.push("0 = 1");
|
|
1093
|
+
} else {
|
|
1094
|
+
conditions.push(
|
|
1095
|
+
opts.hasAmbiguousLinks
|
|
1096
|
+
? `EXISTS (SELECT 1 FROM ambiguous_wikilinks ual WHERE ual.source_id = n.id)`
|
|
1097
|
+
: `NOT EXISTS (SELECT 1 FROM ambiguous_wikilinks ual WHERE ual.source_id = n.id)`,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1079
1102
|
// ID set filter — used by `near` to push neighborhood scoping into SQL so
|
|
1080
1103
|
// that LIMIT applies to the neighborhood, not the whole notes table.
|
|
1081
1104
|
if (opts.ids !== undefined) {
|
|
@@ -1694,6 +1717,7 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
|
|
|
1694
1717
|
hasTags: opts.hasTags,
|
|
1695
1718
|
hasLinks: opts.hasLinks,
|
|
1696
1719
|
hasBrokenLinks: opts.hasBrokenLinks,
|
|
1720
|
+
hasAmbiguousLinks: opts.hasAmbiguousLinks,
|
|
1697
1721
|
path: opts.path,
|
|
1698
1722
|
pathPrefix: opts.pathPrefix,
|
|
1699
1723
|
excludePathPrefix: opts.excludePathPrefix,
|
|
@@ -2767,6 +2791,8 @@ export function mergeTags(
|
|
|
2767
2791
|
const deleteNoteTagsStmt = db.prepare("DELETE FROM note_tags WHERE tag_name = ?");
|
|
2768
2792
|
const deleteTagStmt = db.prepare("DELETE FROM tags WHERE name = ?");
|
|
2769
2793
|
const countStmt = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?");
|
|
2794
|
+
const sourceNoteIdsStmt = db.prepare("SELECT note_id FROM note_tags WHERE tag_name = ?");
|
|
2795
|
+
const affectedIds = new Set<string>();
|
|
2770
2796
|
|
|
2771
2797
|
for (const source of uniqueSources) {
|
|
2772
2798
|
const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(source);
|
|
@@ -2775,6 +2801,11 @@ export function mergeTags(
|
|
|
2775
2801
|
continue;
|
|
2776
2802
|
}
|
|
2777
2803
|
const before = (countStmt.get(source) as { c: number }).c;
|
|
2804
|
+
// Collect BEFORE the delete so we can bump updated_at on every note
|
|
2805
|
+
// whose tags actually change (vault#567).
|
|
2806
|
+
for (const row of sourceNoteIdsStmt.all(source) as { note_id: string }[]) {
|
|
2807
|
+
affectedIds.add(row.note_id);
|
|
2808
|
+
}
|
|
2778
2809
|
retagStmt.run(target, source);
|
|
2779
2810
|
deleteNoteTagsStmt.run(source);
|
|
2780
2811
|
// Dropping the tag row drops its identity (description, fields,
|
|
@@ -2783,6 +2814,24 @@ export function mergeTags(
|
|
|
2783
2814
|
deleteTagStmt.run(source);
|
|
2784
2815
|
merged[source] = before;
|
|
2785
2816
|
}
|
|
2817
|
+
|
|
2818
|
+
// vault#567: a tags-only `update-note` already bumps `updated_at` so
|
|
2819
|
+
// cursor/sync consumers see the retag. `merge-tags` used to skip the
|
|
2820
|
+
// bump (flood-avoidance), which made the equivalent bulk retag
|
|
2821
|
+
// invisible to since-last-check loops. Bump every note that actually
|
|
2822
|
+
// lost a source tag; notes that never carried a merged-away source
|
|
2823
|
+
// are left untouched. `updated_at_ms` moves with `updated_at`
|
|
2824
|
+
// (vault#586) so the cursor keyset surfaces the change.
|
|
2825
|
+
if (affectedIds.size > 0) {
|
|
2826
|
+
const now = new Date().toISOString();
|
|
2827
|
+
const nowMs = timestampToMs(now) ?? Date.now();
|
|
2828
|
+
const bumpStmt = db.prepare(
|
|
2829
|
+
"UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?",
|
|
2830
|
+
);
|
|
2831
|
+
for (const id of affectedIds) {
|
|
2832
|
+
bumpStmt.run(now, nowMs, id);
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2786
2835
|
});
|
|
2787
2836
|
|
|
2788
2837
|
return { merged, target };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration v27 → v28: fold the lazy unresolved_wikilinks relationship-column
|
|
3
|
+
* self-heal into the versioned chain (vault#567 item 2).
|
|
4
|
+
*
|
|
5
|
+
* The interesting path is an EXISTING vault whose `unresolved_wikilinks`
|
|
6
|
+
* table still has the pre-#555 2-column PK — not a fresh vault (those
|
|
7
|
+
* create the 3-column table lazily, or never create it). Gating is the
|
|
8
|
+
* load-bearing claim: a vault that never queued a dangling link must NOT
|
|
9
|
+
* grow the table on open.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import { initSchema, SCHEMA_VERSION } from "./schema.js";
|
|
14
|
+
import { SqliteStore } from "./store.js";
|
|
15
|
+
import { ensureRelationshipColumn } from "./wikilinks.js";
|
|
16
|
+
|
|
17
|
+
function hasTable(db: Database, name: string): boolean {
|
|
18
|
+
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function columnNames(db: Database, table: string): string[] {
|
|
22
|
+
return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("SCHEMA_VERSION v28", () => {
|
|
26
|
+
it("bumped SCHEMA_VERSION to at least 28 (unresolved_wikilinks heal is versioned)", () => {
|
|
27
|
+
expect(SCHEMA_VERSION).toBeGreaterThanOrEqual(28);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("migrateToV28 — does NOT create unresolved_wikilinks on a vault that never needed it", () => {
|
|
32
|
+
it("a fresh vault (no dangling links) still has no unresolved_wikilinks table after initSchema", () => {
|
|
33
|
+
const db = new Database(":memory:");
|
|
34
|
+
const store = new SqliteStore(db);
|
|
35
|
+
expect(store).toBeDefined();
|
|
36
|
+
expect(hasTable(db, "unresolved_wikilinks")).toBe(false);
|
|
37
|
+
expect(
|
|
38
|
+
(db.prepare("SELECT MAX(version) AS v FROM schema_version").get() as { v: number }).v,
|
|
39
|
+
).toBe(SCHEMA_VERSION);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("migrateToV28 — heals a pre-#555 2-column table at boot", () => {
|
|
44
|
+
let db: Database;
|
|
45
|
+
let store: SqliteStore;
|
|
46
|
+
let sourceId: string;
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
db = new Database(":memory:");
|
|
50
|
+
store = new SqliteStore(db);
|
|
51
|
+
const src = await store.createNote("plain body, no wikilinks", { path: "src-note" });
|
|
52
|
+
await store.createNote("plain target", { path: "Target A" });
|
|
53
|
+
sourceId = src.id;
|
|
54
|
+
db.exec(`
|
|
55
|
+
CREATE TABLE unresolved_wikilinks (
|
|
56
|
+
source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
|
57
|
+
target_path TEXT NOT NULL COLLATE NOCASE,
|
|
58
|
+
PRIMARY KEY (source_id, target_path)
|
|
59
|
+
)
|
|
60
|
+
`);
|
|
61
|
+
db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)").run(
|
|
62
|
+
src.id,
|
|
63
|
+
"Target B",
|
|
64
|
+
);
|
|
65
|
+
db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)").run(
|
|
66
|
+
src.id,
|
|
67
|
+
"Target A",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("initSchema rebuilds the 3-column PK and backfills relationship='wikilink'", () => {
|
|
72
|
+
expect(columnNames(db, "unresolved_wikilinks")).not.toContain("relationship");
|
|
73
|
+
initSchema(db);
|
|
74
|
+
expect(columnNames(db, "unresolved_wikilinks")).toContain("relationship");
|
|
75
|
+
expect(hasTable(db, "unresolved_wikilinks_pre_v555")).toBe(false);
|
|
76
|
+
const rows = db
|
|
77
|
+
.prepare("SELECT source_id, target_path, relationship FROM unresolved_wikilinks ORDER BY target_path")
|
|
78
|
+
.all() as { source_id: string; target_path: string; relationship: string }[];
|
|
79
|
+
expect(rows).toHaveLength(2);
|
|
80
|
+
expect(rows.every((r) => r.relationship === "wikilink")).toBe(true);
|
|
81
|
+
expect(rows.every((r) => r.source_id === sourceId)).toBe(true);
|
|
82
|
+
expect(
|
|
83
|
+
(db.prepare("SELECT MAX(version) AS v FROM schema_version").get() as { v: number }).v,
|
|
84
|
+
).toBe(SCHEMA_VERSION);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is idempotent — a second initSchema neither throws nor duplicates rows", () => {
|
|
88
|
+
initSchema(db);
|
|
89
|
+
initSchema(db);
|
|
90
|
+
const count = (db.prepare("SELECT COUNT(*) AS c FROM unresolved_wikilinks").get() as { c: number }).c;
|
|
91
|
+
expect(count).toBe(2);
|
|
92
|
+
expect(columnNames(db, "unresolved_wikilinks")).toContain("relationship");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("a 3-column table is a no-op (no rewrite)", () => {
|
|
96
|
+
initSchema(db); // first pass heals
|
|
97
|
+
const before = db.prepare("SELECT * FROM unresolved_wikilinks ORDER BY target_path").all();
|
|
98
|
+
ensureRelationshipColumn(db);
|
|
99
|
+
initSchema(db);
|
|
100
|
+
const after = db.prepare("SELECT * FROM unresolved_wikilinks ORDER BY target_path").all();
|
|
101
|
+
expect(after).toEqual(before);
|
|
102
|
+
});
|
|
103
|
+
});
|
package/core/src/schema.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { rebuildIndexes, listIndexedFields } from "./indexed-fields.js";
|
|
|
4
4
|
import { findMixedTypeIndexedFieldNotes } from "./doctor.js";
|
|
5
5
|
import { transaction } from "./txn.js";
|
|
6
6
|
import { timestampToMs } from "./cursor.js";
|
|
7
|
+
import { ensureRelationshipColumn } from "./wikilinks.js";
|
|
7
8
|
|
|
8
|
-
export const SCHEMA_VERSION =
|
|
9
|
+
export const SCHEMA_VERSION = 28;
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Deterministic last-resort epoch for a note whose `updated_at` AND
|
|
@@ -621,6 +622,13 @@ export function initSchema(db: Database): void {
|
|
|
621
622
|
// already reads as "needs embedding." See vault semantic-search MVP plan.
|
|
622
623
|
migrateToV27(db);
|
|
623
624
|
|
|
625
|
+
// Migrate v27 → v28: fold the lazy unresolved_wikilinks `relationship`
|
|
626
|
+
// column self-heal into the versioned chain (vault#567 item 2). Gated:
|
|
627
|
+
// no table → no-op; 3-column table → no-op; only a pre-#555 2-column
|
|
628
|
+
// table is rebuilt. Does not create the table on vaults that never
|
|
629
|
+
// queued an unresolved link, and does not rewrite notes.
|
|
630
|
+
migrateToV28(db);
|
|
631
|
+
|
|
624
632
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
625
633
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
626
634
|
rebuildIndexes(db);
|
|
@@ -1729,6 +1737,25 @@ function migrateToV27(db: Database): void {
|
|
|
1729
1737
|
});
|
|
1730
1738
|
}
|
|
1731
1739
|
|
|
1740
|
+
/**
|
|
1741
|
+
* Migrate v27 → v28: version the unresolved_wikilinks relationship-column
|
|
1742
|
+
* self-heal that used to run lazily on first touch (vault#567 item 2).
|
|
1743
|
+
*
|
|
1744
|
+
* COST: this does NOT rewrite every vault on open. `ensureRelationshipColumn`
|
|
1745
|
+
* gates on PRAGMA table_info:
|
|
1746
|
+
* - table missing (most vaults — lazy creation, never queued a dangling
|
|
1747
|
+
* link) → return immediately, no CREATE;
|
|
1748
|
+
* - `relationship` already present (post-#555 / fresh 3-column table) →
|
|
1749
|
+
* return immediately;
|
|
1750
|
+
* - pre-#555 2-column table only → one atomic 4-statement rebuild of
|
|
1751
|
+
* `unresolved_wikilinks` (typically tiny; not a notes rewrite).
|
|
1752
|
+
* Subsequent opens are the same PRAGMA no-op. The per-touch lazy call in
|
|
1753
|
+
* `wikilinks.ts` stays as a no-op safety net after this version.
|
|
1754
|
+
*/
|
|
1755
|
+
function migrateToV28(db: Database): void {
|
|
1756
|
+
ensureRelationshipColumn(db);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1732
1759
|
function hasTable(db: Database, name: string): boolean {
|
|
1733
1760
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
1734
1761
|
return !!row;
|