@openparachute/vault 0.7.0-rc.6 → 0.7.0-rc.8
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 +413 -0
- package/core/src/mcp.ts +194 -30
- package/core/src/notes.ts +97 -24
- package/core/src/query-warnings.ts +110 -0
- package/core/src/schema.ts +168 -9
- package/core/src/search-fts-v25.test.ts +362 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/types.ts +20 -0
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +186 -21
- package/package.json +1 -1
- package/src/contract-errors.test.ts +73 -0
- package/src/contract-search.test.ts +168 -0
- package/src/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-query-notes-search-scope.test.ts +53 -0
- package/src/mcp-tools.ts +11 -0
- package/src/routes.ts +192 -26
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +291 -4
package/core/src/mcp.ts
CHANGED
|
@@ -5,9 +5,17 @@ import * as noteOps from "./notes.js";
|
|
|
5
5
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
6
6
|
import { QueryError } from "./query-operators.js";
|
|
7
7
|
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
collectUnknownTagWarnings,
|
|
10
|
+
emptySearchWarning,
|
|
11
|
+
ignoredParamWarning,
|
|
12
|
+
computeSearchDidYouMean,
|
|
13
|
+
searchDidYouMeanWarning,
|
|
14
|
+
type QueryWarning,
|
|
15
|
+
} from "./query-warnings.js";
|
|
9
16
|
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
|
|
10
17
|
import * as linkOps from "./links.js";
|
|
18
|
+
import { resolveOrQueueLink, resolveStructuredLinkNote } from "./wikilinks.js";
|
|
11
19
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
12
20
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
13
21
|
import {
|
|
@@ -256,6 +264,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
256
264
|
|
|
257
265
|
Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
|
|
258
266
|
|
|
267
|
+
Each result carries \`validation_status\` when any tag it carries declares \`fields\` (vault#555) — same advisory-warnings shape create-note/update-note attach, now also on reads (an out-of-enum value on a non-strict field is stored and findable, but still surfaces its \`enum_mismatch\` warning here, not just on the write that introduced it). Absent entirely when no tag on the note declares a schema.
|
|
268
|
+
|
|
259
269
|
Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bounded read of note content — the response carries the slice plus \`content_total_length\` and \`content_next_offset\` (null when complete). Loop, feeding \`content_next_offset\` back as \`content_offset\`, to read a note too large for one response.
|
|
260
270
|
|
|
261
271
|
Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.
|
|
@@ -265,7 +275,9 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
265
275
|
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
266
276
|
- Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way.
|
|
267
277
|
|
|
268
|
-
\`search\` is literal-by-default (vault#551): your text is escaped and phrase-quoted before it reaches FTS5, so ordinary punctuation ("didn't", "eleven-day", "18.6") is matched as literal content instead of being parsed as query syntax (a bare hyphen used to mean NOT; an apostrophe or decimal point used to break the parse and silently return \`[]\`). Pass \`search_mode: "advanced"\` to opt back into raw FTS5 syntax (AND/OR/NOT, manual phrase quoting, prefix \`*\`) — a malformed advanced query now throws a structured error instead of silently returning \`[]\`. \`sort\` is honored under \`search\` too: omit it for relevance ranking (default), or pass "asc"/"desc" to order by \`created_at\` instead
|
|
278
|
+
\`search\` is literal-by-default (vault#551): your text is escaped and phrase-quoted before it reaches FTS5, so ordinary punctuation ("didn't", "eleven-day", "18.6") is matched as literal content instead of being parsed as query syntax (a bare hyphen used to mean NOT; an apostrophe or decimal point used to break the parse and silently return \`[]\`). Pass \`search_mode: "advanced"\` to opt back into raw FTS5 syntax (AND/OR/NOT, manual phrase quoting, prefix \`*\`) — a malformed advanced query now throws a structured error instead of silently returning \`[]\`. \`sort\` is honored under \`search\` too: omit it for relevance ranking (default), or pass "asc"/"desc" to order by \`created_at\` instead.
|
|
279
|
+
|
|
280
|
+
\`search\` indexes BOTH a note's title (\`path\`) and its \`content\` (vault#551 WS2C, schema v25) — a title match is weighted far above a passing body mention, so a dedicated note on a topic outranks another note that merely references it. Every result carries a \`score\` field (higher = more relevant; only meaningful as a RELATIVE comparison within one result set). Word matching also stems regular English affixes ("firefighter" matches "firefighters", "microbe" matches "microbes") — irregular plurals with a consonant change ("wolf"/"wolves") aren't covered by stemming. A search that returns ZERO results may carry a \`search_did_you_mean\` warning suggesting the closest indexed term when one looks like a likely typo (only unscoped sessions — tag-scoped tokens never see it, since the suggestion is computed vault-wide).`,
|
|
269
281
|
inputSchema: {
|
|
270
282
|
type: "object",
|
|
271
283
|
properties: {
|
|
@@ -322,7 +334,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
322
334
|
search: {
|
|
323
335
|
type: "string",
|
|
324
336
|
description:
|
|
325
|
-
'Full-text search query. Literal-by-default (vault#551): your text is escaped and phrase-quoted before reaching FTS5, so punctuation ("didn\'t", "eleven-day", "18.6") is matched as literal content rather than parsed as FTS5 query syntax. Pass `search_mode: "advanced"` for raw FTS5 syntax (boolean/phrase/prefix operators). `sort` is honored under search (see below) — default is relevance ranking.',
|
|
337
|
+
'Full-text search query, matched against BOTH a note\'s title (path) and its content — a title match ranks far above a passing body mention. Literal-by-default (vault#551): your text is escaped and phrase-quoted before reaching FTS5, so punctuation ("didn\'t", "eleven-day", "18.6") is matched as literal content rather than parsed as FTS5 query syntax. Pass `search_mode: "advanced"` for raw FTS5 syntax (boolean/phrase/prefix operators). `sort` is honored under search (see below) — default is relevance ranking. Matching stems regular affixes ("firefighter"/"firefighters") but not irregular plurals ("wolf"/"wolves"). Results carry a `score` field (higher = more relevant, relative within this result set only). A zero-result search may carry a `search_did_you_mean` warning (unscoped sessions only).',
|
|
326
338
|
},
|
|
327
339
|
search_mode: {
|
|
328
340
|
type: "string",
|
|
@@ -449,6 +461,24 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
449
461
|
// `expand`).
|
|
450
462
|
if (contentRange && !includeContent) throw contentRangeRequiresContent();
|
|
451
463
|
let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
|
|
464
|
+
// --- Attach validation_status (vault#555 fix 3) ---
|
|
465
|
+
// Mirrors create/update-note's `attachValidationStatus` — additive,
|
|
466
|
+
// present only when at least one tag on the note declares
|
|
467
|
+
// `fields`. Before this, `validation_status` (including an
|
|
468
|
+
// `enum_mismatch` on a non-strict indexed field) was surfaced ONLY
|
|
469
|
+
// on the one-time create/update WRITE response; a caller reading
|
|
470
|
+
// the note back via `query-notes` (the natural way to re-check a
|
|
471
|
+
// value after write) saw nothing at all — contradicting the
|
|
472
|
+
// documented "advisory violations surface as warnings" for every
|
|
473
|
+
// read after the initial write.
|
|
474
|
+
{
|
|
475
|
+
const status = store.validateNoteAgainstSchemas({
|
|
476
|
+
path: note.path,
|
|
477
|
+
tags: note.tags,
|
|
478
|
+
metadata: note.metadata as Record<string, unknown> | undefined,
|
|
479
|
+
});
|
|
480
|
+
if (status) result.validation_status = status;
|
|
481
|
+
}
|
|
452
482
|
if (expandCtx && includeContent && typeof result.content === "string") {
|
|
453
483
|
// Mark the top-level note as already expanded so it can't recursively inline itself.
|
|
454
484
|
expandCtx.expanded.add(note.id);
|
|
@@ -603,6 +633,20 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
603
633
|
mode,
|
|
604
634
|
sort: params.sort as "asc" | "desc" | undefined,
|
|
605
635
|
});
|
|
636
|
+
// Zero-result `did_you_mean` (vault#551 WS2B) — cheap (a bounded
|
|
637
|
+
// FTS5-vocabulary scan) and ONLY computed on the already-rare
|
|
638
|
+
// empty-result path, mirroring `unknown_tag`'s did_you_mean.
|
|
639
|
+
// Scope-unaware by construction (same as `collectUnknownTagWarnings`
|
|
640
|
+
// above) — safe here because `applyTagScopeWrappers`'s
|
|
641
|
+
// `query-notes` wrapper (`src/mcp-tools.ts`) strips the ENTIRE
|
|
642
|
+
// `warnings` array for a tag-scoped session before it reaches the
|
|
643
|
+
// caller, so this never leaks out-of-scope vocabulary to one.
|
|
644
|
+
if (results.length === 0) {
|
|
645
|
+
const suggestion = computeSearchDidYouMean(db, params.search as string);
|
|
646
|
+
if (suggestion) {
|
|
647
|
+
queryWarnings.push(searchDidYouMeanWarning(params.search as string, suggestion));
|
|
648
|
+
}
|
|
649
|
+
}
|
|
606
650
|
}
|
|
607
651
|
} else {
|
|
608
652
|
// --- Structured query ---
|
|
@@ -715,6 +759,29 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
715
759
|
for (const n of output) applyContentRange(n, contentRange);
|
|
716
760
|
}
|
|
717
761
|
|
|
762
|
+
// --- Attach validation_status (vault#555 fix 3) ---
|
|
763
|
+
// Same reasoning as the single-note path above — additive, present
|
|
764
|
+
// only when at least one tag on the note declares `fields`. Runs
|
|
765
|
+
// BEFORE metadata filtering so it sees the note's full metadata
|
|
766
|
+
// regardless of `include_metadata`; the `validation_status` key
|
|
767
|
+
// itself survives `filterMetadata` (which only touches `metadata`).
|
|
768
|
+
{
|
|
769
|
+
const statusById = new Map(
|
|
770
|
+
results.map((n) => [
|
|
771
|
+
n.id,
|
|
772
|
+
store.validateNoteAgainstSchemas({
|
|
773
|
+
path: n.path,
|
|
774
|
+
tags: n.tags,
|
|
775
|
+
metadata: n.metadata as Record<string, unknown> | undefined,
|
|
776
|
+
}),
|
|
777
|
+
]),
|
|
778
|
+
);
|
|
779
|
+
for (const n of output as any[]) {
|
|
780
|
+
const status = statusById.get(n.id);
|
|
781
|
+
if (status) n.validation_status = status;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
718
785
|
// --- Apply metadata filtering ---
|
|
719
786
|
if (includeMetadata !== undefined && includeMetadata !== true) {
|
|
720
787
|
output = output.map((n: any) => filterMetadata(n, includeMetadata));
|
|
@@ -803,7 +870,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
803
870
|
},
|
|
804
871
|
required: ["target", "relationship"],
|
|
805
872
|
},
|
|
806
|
-
description: "Links to create from this note",
|
|
873
|
+
description: "Links to create from this note. `target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title. A target created LATER in the same `notes` batch, or by a future call, resolves automatically (queued + backfilled) — the response carries an `unresolved_link` warning naming the target in the meantime; never silently dropped.",
|
|
807
874
|
},
|
|
808
875
|
created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
|
|
809
876
|
// Batch
|
|
@@ -835,6 +902,20 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
835
902
|
}
|
|
836
903
|
|
|
837
904
|
const created: Note[] = [];
|
|
905
|
+
// Structured `links` are resolved in a SECOND pass, after every
|
|
906
|
+
// note in this batch has been created (vault#555) — resolving
|
|
907
|
+
// inline (the old behavior) meant a link from item 0 to item 1's
|
|
908
|
+
// path silently dropped, since item 1 didn't exist yet at the
|
|
909
|
+
// moment item 0's links were processed. Deferring makes
|
|
910
|
+
// within-batch forward-refs resolve exactly like a same-content
|
|
911
|
+
// [[wikilink]] already does. `pendingLinks` carries the SOURCE
|
|
912
|
+
// note id (known immediately — createNote assigns it synchronously)
|
|
913
|
+
// alongside each item's raw `links` array.
|
|
914
|
+
const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
|
|
915
|
+
// Per-note `unresolved_link` warnings (vault#555 — "never silent").
|
|
916
|
+
// Populated in the second pass; folded into each note's response
|
|
917
|
+
// below, same pattern as `validation_status`.
|
|
918
|
+
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
838
919
|
// Wrap multi-item batches in a SQLite transaction so a mid-batch
|
|
839
920
|
// failure rolls back every prior insert — see #236. This guards
|
|
840
921
|
// anything thrown from store.createNote / createLink (path
|
|
@@ -869,18 +950,37 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
869
950
|
via: writeVia,
|
|
870
951
|
});
|
|
871
952
|
|
|
872
|
-
// Create explicit links (not wikilinks — those are automatic)
|
|
873
953
|
if (item.links) {
|
|
874
|
-
|
|
875
|
-
const target = resolveNote(db, link.target);
|
|
876
|
-
if (target) {
|
|
877
|
-
await store.createLink(note.id, target.id, link.relationship);
|
|
878
|
-
}
|
|
879
|
-
}
|
|
954
|
+
pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
|
|
880
955
|
}
|
|
881
956
|
|
|
882
957
|
created.push(noteOps.getNote(db, note.id) ?? note);
|
|
883
958
|
}
|
|
959
|
+
|
|
960
|
+
// --- Resolve structured links (vault#555) ---
|
|
961
|
+
// Same semantics as [[wikilinks]]: ID or path/title match, tried
|
|
962
|
+
// now that every sibling note in this batch exists. A target that
|
|
963
|
+
// STILL doesn't resolve (typo, or a note that arrives in a later
|
|
964
|
+
// call) is queued for lazy resolution — it backfills automatically
|
|
965
|
+
// the moment a matching note is created — and surfaces an
|
|
966
|
+
// `unresolved_link` warning naming the target. Never silent.
|
|
967
|
+
for (const { sourceId, links } of pendingLinks) {
|
|
968
|
+
for (const link of links) {
|
|
969
|
+
const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
970
|
+
if (targetId) {
|
|
971
|
+
await store.createLink(sourceId, targetId, link.relationship);
|
|
972
|
+
} else {
|
|
973
|
+
const list = linkWarningsByNote.get(sourceId) ?? [];
|
|
974
|
+
list.push({
|
|
975
|
+
code: "unresolved_link",
|
|
976
|
+
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.`,
|
|
977
|
+
target: link.target,
|
|
978
|
+
relationship: link.relationship,
|
|
979
|
+
});
|
|
980
|
+
linkWarningsByNote.set(sourceId, list);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
884
984
|
};
|
|
885
985
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
886
986
|
|
|
@@ -911,8 +1011,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
911
1011
|
})();
|
|
912
1012
|
|
|
913
1013
|
// Attach `validation_status` from any tag's `fields` declaration that
|
|
914
|
-
// applies to this note, against the post-defaults state.
|
|
915
|
-
|
|
1014
|
+
// applies to this note, against the post-defaults state. Fold in any
|
|
1015
|
+
// `unresolved_link` warnings collected above (vault#555) — additive,
|
|
1016
|
+
// present only when this note's `links` had a target that didn't
|
|
1017
|
+
// resolve.
|
|
1018
|
+
const final = refreshed.map((n) => {
|
|
1019
|
+
const validated = attachValidationStatus(store, db, n);
|
|
1020
|
+
const warnings = linkWarningsByNote.get(n.id);
|
|
1021
|
+
return warnings && warnings.length > 0
|
|
1022
|
+
? ({ ...validated, warnings } as Note & { warnings: QueryWarning[] })
|
|
1023
|
+
: validated;
|
|
1024
|
+
});
|
|
916
1025
|
return batch ? final : final[0];
|
|
917
1026
|
},
|
|
918
1027
|
},
|
|
@@ -1005,7 +1114,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1005
1114
|
},
|
|
1006
1115
|
},
|
|
1007
1116
|
},
|
|
1008
|
-
description: "Links to add/remove",
|
|
1117
|
+
description: "Links to add/remove. `add[].target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title — and lazily backfills (queued) when the target arrives later; the response carries an `unresolved_link` warning naming the target in the meantime, never a silent drop.",
|
|
1009
1118
|
},
|
|
1010
1119
|
include_content: {
|
|
1011
1120
|
type: "boolean",
|
|
@@ -1099,6 +1208,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1099
1208
|
// key this so the create-on-missing branch, which assigns the id
|
|
1100
1209
|
// late, can register correctly.
|
|
1101
1210
|
const echoLinkIds = new Set<string>();
|
|
1211
|
+
// Structured `links.add` entries, deferred to a second pass AFTER
|
|
1212
|
+
// every item in this batch has been created/updated (vault#555) —
|
|
1213
|
+
// resolving inline would silently drop a link from item 0 to a note
|
|
1214
|
+
// item 1 (later in the same batch) creates via `if_missing:
|
|
1215
|
+
// "create"`. Same fix as create-note's `pendingLinks`.
|
|
1216
|
+
const pendingLinks: { sourceId: string; links: { target: string; relationship: string; metadata?: Record<string, unknown> }[] }[] = [];
|
|
1217
|
+
// Per-note `unresolved_link` warnings (vault#555 — "never silent").
|
|
1218
|
+
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
1102
1219
|
// Wrap multi-item batches in a SQLite transaction so any mid-batch
|
|
1103
1220
|
// failure (precondition error, content_edit miss, ConflictError, …)
|
|
1104
1221
|
// rolls back every prior mutation in the batch — see #236.
|
|
@@ -1189,13 +1306,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1189
1306
|
});
|
|
1190
1307
|
const created = await store.createNote(content, createOpts);
|
|
1191
1308
|
await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
|
|
1192
|
-
//
|
|
1309
|
+
// Defer links.add resolution to the second pass (vault#555)
|
|
1310
|
+
// — same reasoning as create-note.
|
|
1193
1311
|
const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
1194
1312
|
if (linksAdd) {
|
|
1195
|
-
|
|
1196
|
-
const target = resolveNote(db, link.target);
|
|
1197
|
-
if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
|
|
1198
|
-
}
|
|
1313
|
+
pendingLinks.push({ sourceId: created.id, links: linksAdd });
|
|
1199
1314
|
}
|
|
1200
1315
|
const fresh = noteOps.getNote(db, created.id) ?? created;
|
|
1201
1316
|
updated.push(fresh);
|
|
@@ -1303,7 +1418,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1303
1418
|
const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
|
|
1304
1419
|
if (linksRemove) {
|
|
1305
1420
|
for (const link of linksRemove) {
|
|
1306
|
-
const target =
|
|
1421
|
+
const target = resolveStructuredLinkNote(db, link.target);
|
|
1307
1422
|
if (!target) continue;
|
|
1308
1423
|
resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
|
|
1309
1424
|
if (link.relationship === "wikilink" && target.path) {
|
|
@@ -1377,8 +1492,25 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1377
1492
|
enforceStrict({ path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
1378
1493
|
}
|
|
1379
1494
|
|
|
1495
|
+
// vault#555 fix 2 — tag and link mutations must bump `updated_at`
|
|
1496
|
+
// too, not just core-field (content/path/metadata) changes. Before
|
|
1497
|
+
// this, a tags-only or links-only call with `force: true` (no
|
|
1498
|
+
// `if_updated_at`) left `updates` completely empty — the branch
|
|
1499
|
+
// below skipped `store.updateNote` ENTIRELY, so `updated_at` never
|
|
1500
|
+
// moved even though tags/note_tags or links genuinely changed.
|
|
1501
|
+
// (A tags-only call that happened to pass `if_updated_at` already
|
|
1502
|
+
// worked by accident — it populated `updates.if_updated_at` — which
|
|
1503
|
+
// is why this was easy to miss in ad hoc testing.) This breaks
|
|
1504
|
+
// cursor polling (`ORDER BY updated_at`) and any `updated_at`-based
|
|
1505
|
+
// sync filter: the mutation is real but invisible to a
|
|
1506
|
+
// since-last-check loop.
|
|
1507
|
+
const hasTagMutation = ((item.tags as any)?.add?.length ?? 0) > 0
|
|
1508
|
+
|| ((item.tags as any)?.remove?.length ?? 0) > 0;
|
|
1509
|
+
const hasLinkMutation = (item.links as any)?.add !== undefined
|
|
1510
|
+
|| (item.links as any)?.remove !== undefined;
|
|
1511
|
+
|
|
1380
1512
|
let result: Note;
|
|
1381
|
-
if (Object.keys(updates).length > 0) {
|
|
1513
|
+
if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
|
|
1382
1514
|
// Write-attribution (vault#298): stamp the most-recent-write
|
|
1383
1515
|
// columns on the same UPDATE that bumps `updated_at`. Only set when
|
|
1384
1516
|
// there's a real change to write (the empty-updates branch below
|
|
@@ -1389,7 +1521,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1389
1521
|
// store.updateNote routes through noteOps.updateNote, which runs
|
|
1390
1522
|
// the UPDATE (with optional `AND updated_at IS ?`) atomically and
|
|
1391
1523
|
// throws ConflictError on mismatch. No mutations have happened
|
|
1392
|
-
// yet, so a throw here leaves the note untouched.
|
|
1524
|
+
// yet, so a throw here leaves the note untouched. `updates` may
|
|
1525
|
+
// carry no core fields at all (a pure tag/link mutation) — that's
|
|
1526
|
+
// fine: `noteOps.updateNote` unconditionally SETs
|
|
1527
|
+
// `updated_at`/`last_updated_by`/`last_updated_via` whenever
|
|
1528
|
+
// `skipUpdatedAt` isn't set, so this still issues a real UPDATE
|
|
1529
|
+
// (not the true no-op/precondition-only branch, which only fires
|
|
1530
|
+
// when the caller passes ZERO fields AND no mutation is pending).
|
|
1393
1531
|
result = await store.updateNote(note.id, updates);
|
|
1394
1532
|
} else {
|
|
1395
1533
|
result = note;
|
|
@@ -1410,15 +1548,10 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1410
1548
|
await store.untagNote(note.id, tagsOp.remove);
|
|
1411
1549
|
}
|
|
1412
1550
|
|
|
1413
|
-
// --- Add links ---
|
|
1551
|
+
// --- Add links (deferred to the second pass — vault#555) ---
|
|
1414
1552
|
const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
1415
1553
|
if (linksAdd) {
|
|
1416
|
-
|
|
1417
|
-
const target = resolveNote(db, link.target);
|
|
1418
|
-
if (target) {
|
|
1419
|
-
await store.createLink(note.id, target.id, link.relationship, link.metadata);
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1554
|
+
pendingLinks.push({ sourceId: note.id, links: linksAdd });
|
|
1422
1555
|
}
|
|
1423
1556
|
|
|
1424
1557
|
// Echo links if this update mutated them (`links.add`/`links.remove`)
|
|
@@ -1431,6 +1564,32 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1431
1564
|
// Re-read for final state
|
|
1432
1565
|
updated.push(noteOps.getNote(db, note.id) ?? result);
|
|
1433
1566
|
}
|
|
1567
|
+
|
|
1568
|
+
// --- Resolve structured `links.add` (vault#555) ---
|
|
1569
|
+
// Deferred until every item in this batch has been created/
|
|
1570
|
+
// updated — same [[wikilink]] semantics (ID or path/title match),
|
|
1571
|
+
// now tried against the FULL post-batch note set so a forward-ref
|
|
1572
|
+
// to a sibling item resolves. A target that still doesn't resolve
|
|
1573
|
+
// is queued for lazy resolution (backfills when a matching note
|
|
1574
|
+
// is created later) and surfaces an `unresolved_link` warning —
|
|
1575
|
+
// never silently dropped.
|
|
1576
|
+
for (const { sourceId, links } of pendingLinks) {
|
|
1577
|
+
for (const link of links) {
|
|
1578
|
+
const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
1579
|
+
if (targetId) {
|
|
1580
|
+
await store.createLink(sourceId, targetId, link.relationship, link.metadata);
|
|
1581
|
+
} else {
|
|
1582
|
+
const list = linkWarningsByNote.get(sourceId) ?? [];
|
|
1583
|
+
list.push({
|
|
1584
|
+
code: "unresolved_link",
|
|
1585
|
+
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.`,
|
|
1586
|
+
target: link.target,
|
|
1587
|
+
relationship: link.relationship,
|
|
1588
|
+
});
|
|
1589
|
+
linkWarningsByNote.set(sourceId, list);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1434
1593
|
};
|
|
1435
1594
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
1436
1595
|
|
|
@@ -1452,9 +1611,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1452
1611
|
// when triggered — mirrors the GET / query-notes shape exactly via
|
|
1453
1612
|
// the shared `linkOps.getLinksHydrated` call. vault feedback #8.
|
|
1454
1613
|
const echoLinks = echoLinkIds.has(n.id);
|
|
1614
|
+
// `unresolved_link` warnings (vault#555) — additive, present only
|
|
1615
|
+
// when this note's `links.add` had a target that didn't resolve.
|
|
1616
|
+
const warnings = linkWarningsByNote.get(n.id);
|
|
1455
1617
|
if (includeContent) {
|
|
1456
1618
|
const full: any = { ...validated, created };
|
|
1457
1619
|
if (echoLinks) full.links = linkOps.getLinksHydrated(db, n.id);
|
|
1620
|
+
if (warnings && warnings.length > 0) full.warnings = warnings;
|
|
1458
1621
|
return full as Note & { created: boolean };
|
|
1459
1622
|
}
|
|
1460
1623
|
const lean: any = noteOps.toNoteIndex(validated);
|
|
@@ -1464,6 +1627,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1464
1627
|
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
1465
1628
|
// drops unknown fields.
|
|
1466
1629
|
if (echoLinks) lean.links = linkOps.getLinksHydrated(db, n.id);
|
|
1630
|
+
if (warnings && warnings.length > 0) lean.warnings = warnings;
|
|
1467
1631
|
return lean;
|
|
1468
1632
|
});
|
|
1469
1633
|
return batch ? final : final[0];
|
|
@@ -1592,7 +1756,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1592
1756
|
additionalProperties: {
|
|
1593
1757
|
type: "object",
|
|
1594
1758
|
properties: {
|
|
1595
|
-
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation. Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
|
|
1759
|
+
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
|
|
1596
1760
|
description: { type: "string" },
|
|
1597
1761
|
enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
|
|
1598
1762
|
default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
|
package/core/src/notes.ts
CHANGED
|
@@ -22,7 +22,12 @@ import {
|
|
|
22
22
|
import { getIndexedField, releaseField } from "./indexed-fields.js";
|
|
23
23
|
import { computeExpandedTagCounts, loadTagHierarchy, stripTagHash } from "./tag-hierarchy.js";
|
|
24
24
|
import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
buildLiteralSearchQuery,
|
|
27
|
+
SEARCH_WEIGHT_PATH,
|
|
28
|
+
SEARCH_WEIGHT_CONTENT,
|
|
29
|
+
type SearchMode,
|
|
30
|
+
} from "./search-query.js";
|
|
26
31
|
|
|
27
32
|
let idCounter = 0;
|
|
28
33
|
|
|
@@ -1336,12 +1341,41 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
|
|
|
1336
1341
|
* invariant is ever broken it still surfaces as an honest error, not a
|
|
1337
1342
|
* 500. Signals a vault bug worth reporting.
|
|
1338
1343
|
*/
|
|
1344
|
+
/**
|
|
1345
|
+
* FTS5's own error text for a syntax mistake is written for someone who
|
|
1346
|
+
* already knows FTS5's grammar — "no such column: espresso" for a bare
|
|
1347
|
+
* leading `-espresso` (NOT with no left-hand term — FTS5's `-`/`NOT` is a
|
|
1348
|
+
* BINARY operator, so a lone `-token` with nothing before it gets
|
|
1349
|
+
* misparsed as `column:term` filter syntax and fails looking for a column
|
|
1350
|
+
* named after the token) is confusing/leaky rather than actionable: it
|
|
1351
|
+
* reads as if a column literally named "espresso" was expected, which
|
|
1352
|
+
* exposes FTS5-internal parsing behavior instead of explaining the actual
|
|
1353
|
+
* mistake (vault#551 WS2B item 4 — interim harness finding). Detected
|
|
1354
|
+
* generically off the FTS5 error text (`/no such column:/i`) rather than
|
|
1355
|
+
* re-parsing the query ourselves, since the SAME message also covers the
|
|
1356
|
+
* other real cause (an explicit `column:term` filter naming a column that
|
|
1357
|
+
* isn't one of `notes_fts`'s declared columns, `path`/`content`) — the hint
|
|
1358
|
+
* below covers both without claiming to know which one happened.
|
|
1359
|
+
*/
|
|
1360
|
+
function advancedModeColumnHint(causeMessage: string): string | null {
|
|
1361
|
+
if (!/no such column:/i.test(causeMessage)) return null;
|
|
1362
|
+
return (
|
|
1363
|
+
`FTS5 read part of this query as column-filter syntax ("column:term") or as a ` +
|
|
1364
|
+
`leading "-" with no term to its left — NOT/"-" is a BINARY operator in FTS5 ` +
|
|
1365
|
+
`("good -bad", not "-bad" alone). Indexed columns are "path" and "content". ` +
|
|
1366
|
+
`Add a preceding positive term before a NOT, quote the phrase to search it ` +
|
|
1367
|
+
`literally, or use search_mode:"literal" (the default) to skip advanced syntax entirely.`
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1339
1371
|
function searchSyntaxError(rawQuery: string, err: unknown, mode: SearchMode): QueryError {
|
|
1340
1372
|
const causeMessage = err instanceof Error ? err.message : String(err);
|
|
1373
|
+
const columnHint = mode === "advanced" ? advancedModeColumnHint(causeMessage) : null;
|
|
1341
1374
|
const hint =
|
|
1342
|
-
|
|
1375
|
+
columnHint ??
|
|
1376
|
+
(mode === "advanced"
|
|
1343
1377
|
? `FTS5 rejected this as advanced query syntax (${causeMessage}). Fix the syntax, or omit search_mode:"advanced" for literal (punctuation-safe) search.`
|
|
1344
|
-
: `FTS5 rejected the escaped literal query (${causeMessage}) — this should be impossible after literal-mode escaping + control-char sanitization; please report it as a vault bug
|
|
1378
|
+
: `FTS5 rejected the escaped literal query (${causeMessage}) — this should be impossible after literal-mode escaping + control-char sanitization; please report it as a vault bug.`);
|
|
1345
1379
|
return new QueryError(`invalid search syntax: ${causeMessage}`, "INVALID_QUERY", {
|
|
1346
1380
|
error_type: "invalid_search_syntax",
|
|
1347
1381
|
field: "search",
|
|
@@ -1377,20 +1411,33 @@ export function searchNotes(
|
|
|
1377
1411
|
ftsQuery = query;
|
|
1378
1412
|
}
|
|
1379
1413
|
|
|
1414
|
+
// Weighted bm25 relevance expression (vault#551 WS2C, schema v25):
|
|
1415
|
+
// `notes_fts` now indexes `path` (title, column 0) then `content` (body,
|
|
1416
|
+
// column 1) — SEARCH_WEIGHT_PATH/SEARCH_WEIGHT_CONTENT bias ranking so a
|
|
1417
|
+
// title match outranks a passing body mention. Raw SQLite bm25 is
|
|
1418
|
+
// negative-is-better; `-1.0 * bm25(...)` flips it so bigger is more
|
|
1419
|
+
// relevant (see `Note.score`'s doc comment for the external contract).
|
|
1420
|
+
// The weights are our own numeric constants (not user input) interpolated
|
|
1421
|
+
// directly — bm25()'s weight arguments are positional per-column
|
|
1422
|
+
// multipliers, not general SQL expressions callers can influence.
|
|
1423
|
+
const scoreExpr = `(-1.0 * bm25(notes_fts, ${SEARCH_WEIGHT_PATH}, ${SEARCH_WEIGHT_CONTENT}))`;
|
|
1424
|
+
|
|
1380
1425
|
// `sort` honored under search (vault#551 WS2A item 3): default stays FTS5
|
|
1381
|
-
// relevance (`
|
|
1382
|
-
// to `created_at` ordering. Checked against
|
|
1383
|
-
// truthiness) so an absent `sort` can
|
|
1384
|
-
// `n.id ${direction}` is appended as a
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1426
|
+
// relevance (the weighted `score` expression); an EXPLICIT
|
|
1427
|
+
// `sort: "asc"|"desc"` switches to `created_at` ordering. Checked against
|
|
1428
|
+
// the literal string values (not truthiness) so an absent `sort` can
|
|
1429
|
+
// never accidentally match a branch. `n.id ${direction}` is appended as a
|
|
1430
|
+
// deterministic tiebreaker in every branch — two notes at the same
|
|
1431
|
+
// created_at millisecond (structured-sort branches) OR with an
|
|
1432
|
+
// identical weighted score (relevance branch — much more likely than
|
|
1433
|
+
// with unweighted `rank`, since many notes share "zero path matches")
|
|
1434
|
+
// would otherwise return in arbitrary/unstable order.
|
|
1388
1435
|
const orderBy =
|
|
1389
1436
|
opts?.sort === "asc"
|
|
1390
1437
|
? "n.created_at ASC, n.id ASC"
|
|
1391
1438
|
: opts?.sort === "desc"
|
|
1392
1439
|
? "n.created_at DESC, n.id DESC"
|
|
1393
|
-
: "
|
|
1440
|
+
: "score DESC, n.id ASC";
|
|
1394
1441
|
|
|
1395
1442
|
if (opts?.tags && opts.tags.length > 0) {
|
|
1396
1443
|
// Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
|
|
@@ -1406,14 +1453,14 @@ export function searchNotes(
|
|
|
1406
1453
|
// DISTINCT over full rows. The FTS join itself is 1:1 on rowid.
|
|
1407
1454
|
const tagPlaceholders = searchTags.map(() => "?").join(", ");
|
|
1408
1455
|
const rows = db.prepare(`
|
|
1409
|
-
SELECT n
|
|
1456
|
+
SELECT n.*, ${scoreExpr} AS score FROM notes n
|
|
1410
1457
|
JOIN notes_fts fts ON fts.rowid = n.rowid
|
|
1411
1458
|
WHERE notes_fts MATCH ?
|
|
1412
1459
|
AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
|
|
1413
1460
|
ORDER BY ${orderBy}
|
|
1414
1461
|
LIMIT ?
|
|
1415
|
-
`).all(ftsQuery, ...searchTags, limit) as NoteRow[];
|
|
1416
|
-
return notesWithTags(db, rows);
|
|
1462
|
+
`).all(ftsQuery, ...searchTags, limit) as (NoteRow & { score: number })[];
|
|
1463
|
+
return notesWithTags(db, rows, scoresById(rows));
|
|
1417
1464
|
} catch (err) {
|
|
1418
1465
|
// Surface EVERY FTS5 error structured, never a raw rethrow (vault#551):
|
|
1419
1466
|
// advanced mode expects it (the caller passed raw syntax); literal
|
|
@@ -1429,27 +1476,44 @@ export function searchNotes(
|
|
|
1429
1476
|
|
|
1430
1477
|
try {
|
|
1431
1478
|
const rows = db.prepare(`
|
|
1432
|
-
SELECT n
|
|
1479
|
+
SELECT n.*, ${scoreExpr} AS score FROM notes n
|
|
1433
1480
|
JOIN notes_fts fts ON fts.rowid = n.rowid
|
|
1434
1481
|
WHERE notes_fts MATCH ?
|
|
1435
1482
|
ORDER BY ${orderBy}
|
|
1436
1483
|
LIMIT ?
|
|
1437
|
-
`).all(ftsQuery, limit) as NoteRow[];
|
|
1438
|
-
return notesWithTags(db, rows);
|
|
1484
|
+
`).all(ftsQuery, limit) as (NoteRow & { score: number })[];
|
|
1485
|
+
return notesWithTags(db, rows, scoresById(rows));
|
|
1439
1486
|
} catch (err) {
|
|
1440
1487
|
if (err instanceof QueryError) throw err;
|
|
1441
1488
|
throw searchSyntaxError(query, err, mode);
|
|
1442
1489
|
}
|
|
1443
1490
|
}
|
|
1444
1491
|
|
|
1445
|
-
/**
|
|
1446
|
-
|
|
1492
|
+
/**
|
|
1493
|
+
* Map rows → Notes with tags hydrated in one batched query. `scores`
|
|
1494
|
+
* (vault#551 WS2C) is an optional id → weighted-bm25-score map, attached
|
|
1495
|
+
* onto each returned `Note.score` when present — ONLY `searchNotes`'s two
|
|
1496
|
+
* callers pass it; every other caller (plain `queryNotes`, `getNotesByIds`,
|
|
1497
|
+
* ...) omits it and gets byte-identical output to before `score` existed.
|
|
1498
|
+
*/
|
|
1499
|
+
function notesWithTags(db: Database, rows: NoteRow[], scores?: Map<string, number>): Note[] {
|
|
1447
1500
|
const notes = rows.map(rowToNote);
|
|
1448
1501
|
const tagsById = getNoteTagsForNotes(db, notes.map((n) => n.id));
|
|
1449
|
-
for (const note of notes)
|
|
1502
|
+
for (const note of notes) {
|
|
1503
|
+
note.tags = tagsById.get(note.id) ?? [];
|
|
1504
|
+
if (scores) {
|
|
1505
|
+
const s = scores.get(note.id);
|
|
1506
|
+
if (s !== undefined) note.score = s;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1450
1509
|
return notes;
|
|
1451
1510
|
}
|
|
1452
1511
|
|
|
1512
|
+
/** Build an id → score map from search result rows carrying a `score` column. */
|
|
1513
|
+
function scoresById(rows: (NoteRow & { score: number })[]): Map<string, number> {
|
|
1514
|
+
return new Map(rows.map((r) => [r.id, r.score]));
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1453
1517
|
// ---- Tag Operations ----
|
|
1454
1518
|
|
|
1455
1519
|
export function tagNote(db: Database, noteId: string, tags: string[]): void {
|
|
@@ -1862,11 +1926,17 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1862
1926
|
const candidates = db
|
|
1863
1927
|
.prepare(`SELECT id, content FROM notes WHERE content IS NOT NULL AND content != '' AND (${orClauses})`)
|
|
1864
1928
|
.all(...params) as { id: string; content: string }[];
|
|
1865
|
-
|
|
1929
|
+
// vault#555 fix 2 — this content rewrite is a real persisted-state
|
|
1930
|
+
// change (the note's `#oldtag` references literally became
|
|
1931
|
+
// `#newtag`) and must bump `updated_at` like any other content
|
|
1932
|
+
// write, or a cursor/sync-poll loop never sees it. Shares the one
|
|
1933
|
+
// `now` timestamp for the whole cascade (same convention as the
|
|
1934
|
+
// tag-row rename pass above).
|
|
1935
|
+
const updateStmt = db.prepare("UPDATE notes SET content = ?, updated_at = ? WHERE id = ?");
|
|
1866
1936
|
for (const row of candidates) {
|
|
1867
1937
|
const next = rewriteNoteBody(row.content, renames);
|
|
1868
1938
|
if (next === row.content) continue;
|
|
1869
|
-
updateStmt.run(next, row.id);
|
|
1939
|
+
updateStmt.run(next, now, row.id);
|
|
1870
1940
|
notesRewritten++;
|
|
1871
1941
|
}
|
|
1872
1942
|
}
|
|
@@ -1881,11 +1951,13 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1881
1951
|
const candidates = db
|
|
1882
1952
|
.prepare(`SELECT id, path FROM notes WHERE path IS NOT NULL AND (${orClauses})`)
|
|
1883
1953
|
.all(...params) as { id: string; path: string }[];
|
|
1884
|
-
|
|
1954
|
+
// vault#555 fix 2 — same reasoning as the content rewrite above: a
|
|
1955
|
+
// path rewrite is a real persisted-state change.
|
|
1956
|
+
const updateStmt = db.prepare("UPDATE notes SET path = ?, updated_at = ? WHERE id = ?");
|
|
1885
1957
|
for (const row of candidates) {
|
|
1886
1958
|
const next = rewriteTagConfigPath(row.path, renames);
|
|
1887
1959
|
if (next === row.path) continue;
|
|
1888
|
-
updateStmt.run(next, row.id);
|
|
1960
|
+
updateStmt.run(next, now, row.id);
|
|
1889
1961
|
pathsRenamed++;
|
|
1890
1962
|
}
|
|
1891
1963
|
}
|
|
@@ -2120,6 +2192,7 @@ export function toNoteIndex(note: Note): NoteIndex {
|
|
|
2120
2192
|
metadata: note.metadata,
|
|
2121
2193
|
byteSize,
|
|
2122
2194
|
preview,
|
|
2195
|
+
...(note.score !== undefined ? { score: note.score } : {}),
|
|
2123
2196
|
};
|
|
2124
2197
|
}
|
|
2125
2198
|
|