@openparachute/vault 0.6.5 → 0.7.0-rc.2
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/contract-concurrency.test.ts +117 -0
- package/core/src/contract-taxonomy.test.ts +119 -0
- package/core/src/contract-typed-index.test.ts +106 -0
- package/core/src/cursor.ts +27 -9
- package/core/src/links.ts +63 -3
- package/core/src/mcp.ts +67 -11
- package/core/src/notes.ts +142 -28
- package/core/src/query-operators.ts +18 -1
- package/core/src/query-warnings.ts +184 -0
- package/core/src/store.ts +9 -2
- package/core/src/tag-hierarchy.ts +113 -0
- package/core/src/types.ts +19 -4
- package/package.json +1 -1
- package/src/contract-errors.test.ts +98 -0
- package/src/contract-honest-queries.test.ts +412 -0
- package/src/contract-search.test.ts +127 -0
- package/src/live-match.test.ts +17 -0
- package/src/live-match.ts +4 -1
- package/src/mcp-http.ts +19 -0
- package/src/mcp-list-tags-scope.test.ts +154 -0
- package/src/mcp-tools.ts +49 -8
- package/src/routes.ts +234 -17
- package/web/ui/dist/assets/{index-TJ-XN4OZ.js → index-B8pGeQam.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/core/src/notes.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type QueryHashInputs,
|
|
21
21
|
} from "./cursor.js";
|
|
22
22
|
import { getIndexedField, releaseField } from "./indexed-fields.js";
|
|
23
|
-
import { stripTagHash } from "./tag-hierarchy.js";
|
|
23
|
+
import { computeExpandedTagCounts, loadTagHierarchy, stripTagHash } from "./tag-hierarchy.js";
|
|
24
24
|
import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
|
|
25
25
|
|
|
26
26
|
let idCounter = 0;
|
|
@@ -684,7 +684,78 @@ export function deleteNote(db: Database, id: string): void {
|
|
|
684
684
|
db.prepare("DELETE FROM notes WHERE id = ?").run(id);
|
|
685
685
|
}
|
|
686
686
|
|
|
687
|
+
/**
|
|
688
|
+
* Validate `limit`/`offset` before any query work (vault#550). A negative
|
|
689
|
+
* `limit` used to leak SQLite's "negative LIMIT means unlimited" semantics
|
|
690
|
+
* straight through to the caller — silently returning EVERYTHING when the
|
|
691
|
+
* caller almost certainly meant "no limit I typed by mistake." A
|
|
692
|
+
* non-numeric value (a bad MCP param type, or a REST caller that slipped
|
|
693
|
+
* past `parseNotesQueryOpts`'s own stricter check) used to silently fall
|
|
694
|
+
* back to the default via `typeof opts.limit === "number" ? opts.limit :
|
|
695
|
+
* 100` below — also silent-wrong. This is the single choke point BOTH
|
|
696
|
+
* transports funnel through (`store.queryNotes` / `store.queryNotesPaged`
|
|
697
|
+
* call this via `queryNotes`), so REST and MCP get identical validation
|
|
698
|
+
* for free.
|
|
699
|
+
*/
|
|
700
|
+
function validateLimitOffset(opts: QueryOpts): void {
|
|
701
|
+
if (opts.limit !== undefined) {
|
|
702
|
+
if (typeof opts.limit !== "number" || !Number.isFinite(opts.limit) || !Number.isInteger(opts.limit) || opts.limit < 0) {
|
|
703
|
+
throw new QueryError(
|
|
704
|
+
`invalid limit: ${JSON.stringify(opts.limit)} — must be a non-negative integer (a negative LIMIT silently means "unlimited" in SQLite semantics, which is almost never what was intended). Omit for the default of 50.`,
|
|
705
|
+
"INVALID_QUERY",
|
|
706
|
+
{
|
|
707
|
+
error_type: "invalid_query",
|
|
708
|
+
field: "limit",
|
|
709
|
+
got: opts.limit,
|
|
710
|
+
hint: "pass a non-negative integer, or omit for the default",
|
|
711
|
+
},
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (opts.offset !== undefined) {
|
|
716
|
+
if (typeof opts.offset !== "number" || !Number.isFinite(opts.offset) || !Number.isInteger(opts.offset) || opts.offset < 0) {
|
|
717
|
+
throw new QueryError(
|
|
718
|
+
`invalid offset: ${JSON.stringify(opts.offset)} — must be a non-negative integer.`,
|
|
719
|
+
"INVALID_QUERY",
|
|
720
|
+
{
|
|
721
|
+
error_type: "invalid_query",
|
|
722
|
+
field: "offset",
|
|
723
|
+
got: opts.offset,
|
|
724
|
+
hint: "pass a non-negative integer, or omit for the default of 0",
|
|
725
|
+
},
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Validate an ISO-8601 date-filter value before it's bound into a SQL
|
|
733
|
+
* comparison (vault#550). `n.created_at` / `n.updated_at` are TEXT columns
|
|
734
|
+
* compared lexicographically; an unparseable value used to bind straight
|
|
735
|
+
* through and silently match "nothing" or "everything" depending on how it
|
|
736
|
+
* happened to sort against real ISO strings, rather than erroring. Applies
|
|
737
|
+
* uniformly to BOTH `dateFilter` (bracket-style REST / MCP `date_filter`)
|
|
738
|
+
* and the legacy `dateFrom`/`dateTo` shorthand (MCP `date_from`/`date_to`,
|
|
739
|
+
* still supported) — both flow through this same function, so both get the
|
|
740
|
+
* same loud validation from one place.
|
|
741
|
+
*/
|
|
742
|
+
function validateIsoDateValue(field: string, value: string): void {
|
|
743
|
+
if (Number.isNaN(Date.parse(value))) {
|
|
744
|
+
throw new QueryError(
|
|
745
|
+
`invalid date value for "${field}": ${JSON.stringify(value)} — must be an ISO-8601 date/timestamp (e.g. "2026-07-09" or "2026-07-09T00:00:00.000Z").`,
|
|
746
|
+
"INVALID_QUERY",
|
|
747
|
+
{
|
|
748
|
+
error_type: "invalid_query",
|
|
749
|
+
field,
|
|
750
|
+
got: value,
|
|
751
|
+
hint: "pass an ISO-8601 date or timestamp",
|
|
752
|
+
},
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
687
757
|
export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
758
|
+
validateLimitOffset(opts);
|
|
688
759
|
const conditions: string[] = [];
|
|
689
760
|
const params: SQLQueryBindings[] = [];
|
|
690
761
|
|
|
@@ -920,19 +991,23 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
920
991
|
column = `"meta_${field}"`;
|
|
921
992
|
}
|
|
922
993
|
if (filter.from !== undefined) {
|
|
994
|
+
validateIsoDateValue(field === "created_at" ? "date_filter.from" : `date_filter.from (${field})`, filter.from);
|
|
923
995
|
conditions.push(`${column} >= ?`);
|
|
924
996
|
params.push(filter.from);
|
|
925
997
|
}
|
|
926
998
|
if (filter.to !== undefined) {
|
|
999
|
+
validateIsoDateValue(field === "created_at" ? "date_filter.to" : `date_filter.to (${field})`, filter.to);
|
|
927
1000
|
conditions.push(`${column} < ?`);
|
|
928
1001
|
params.push(filter.to);
|
|
929
1002
|
}
|
|
930
1003
|
} else if (hasLegacyDate) {
|
|
931
1004
|
if (opts.dateFrom) {
|
|
1005
|
+
validateIsoDateValue("date_from", opts.dateFrom);
|
|
932
1006
|
conditions.push("n.created_at >= ?");
|
|
933
1007
|
params.push(opts.dateFrom);
|
|
934
1008
|
}
|
|
935
1009
|
if (opts.dateTo) {
|
|
1010
|
+
validateIsoDateValue("date_to", opts.dateTo);
|
|
936
1011
|
conditions.push("n.created_at < ?");
|
|
937
1012
|
params.push(opts.dateTo);
|
|
938
1013
|
}
|
|
@@ -940,8 +1015,22 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
940
1015
|
|
|
941
1016
|
// ---- Cursor predicate (vault#313) ----
|
|
942
1017
|
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
1018
|
+
// Cursor mode is keyed on PRESENCE of `opts.cursor`, not truthiness
|
|
1019
|
+
// (vault#550 bootstrap fix). `cursor: ""` is the bootstrap call — "I want
|
|
1020
|
+
// to paginate, I don't have a watermark yet" — and must still force the
|
|
1021
|
+
// keyset ORDER BY below so the FIRST page is taken in the same order
|
|
1022
|
+
// subsequent pages will be. `opts.cursor === undefined` is the only way
|
|
1023
|
+
// to opt OUT of cursor mode entirely (the legacy flat-array shape).
|
|
1024
|
+
// Before this fix, `if (opts.cursor)` treated an empty string exactly
|
|
1025
|
+
// like "no cursor" — the caller's bootstrap intent silently vanished and
|
|
1026
|
+
// the first page came back in `created_at` order instead of the
|
|
1027
|
+
// `updated_at` keyset order the SECOND page (a real cursor) would use,
|
|
1028
|
+
// so naive "did I see this note already" comparisons could skip or
|
|
1029
|
+
// duplicate rows across the boundary.
|
|
1030
|
+
//
|
|
1031
|
+
// When a REAL (non-empty) cursor is present, decode it, verify its
|
|
1032
|
+
// query_hash matches the current query, and add a keyset predicate of
|
|
1033
|
+
// the form:
|
|
945
1034
|
//
|
|
946
1035
|
// (updated_at > last_updated_at)
|
|
947
1036
|
// OR (updated_at = last_updated_at AND id > last_id)
|
|
@@ -953,8 +1042,9 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
953
1042
|
// exclusive with cursor mode (a "since last checked" loop wants
|
|
954
1043
|
// ascending updated_at, full stop); we reject with INVALID_QUERY so
|
|
955
1044
|
// callers don't silently get a broken iteration.
|
|
1045
|
+
const cursorMode = opts.cursor !== undefined;
|
|
956
1046
|
let cursorPayload: CursorPayload | null = null;
|
|
957
|
-
if (
|
|
1047
|
+
if (cursorMode) {
|
|
958
1048
|
if (opts.orderBy) {
|
|
959
1049
|
throw new QueryError(
|
|
960
1050
|
`cursor and order_by are mutually exclusive — cursor pagination forces order by updated_at`,
|
|
@@ -967,33 +1057,39 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
967
1057
|
"INVALID_QUERY",
|
|
968
1058
|
);
|
|
969
1059
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1060
|
+
if (opts.cursor !== "") {
|
|
1061
|
+
cursorPayload = decodeCursor(opts.cursor!);
|
|
1062
|
+
const expectedHash = computeQueryHash(toQueryHashInputs(opts));
|
|
1063
|
+
if (cursorPayload.query_hash !== expectedHash) {
|
|
1064
|
+
throw new CursorError(
|
|
1065
|
+
`cursor was minted for a different query — drop the cursor and restart iteration`,
|
|
1066
|
+
"cursor_query_mismatch",
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
// Translate the millis watermark back to an ISO string for the SQL
|
|
1070
|
+
// comparison. SQLite's `n.updated_at` is TEXT in canonical ISO form
|
|
1071
|
+
// (the store's `toISOString()` output), and ISO timestamps sort
|
|
1072
|
+
// lexicographically in the same order as their millisecond epochs
|
|
1073
|
+
// when they all use the same canonical form — which every timestamp
|
|
1074
|
+
// vault mints does. Cursors minted on heterogeneous timestamps
|
|
1075
|
+
// (e.g. an import that preserved unusual formatting) are still
|
|
1076
|
+
// safe: we round-trip the cursor's millis through `new Date()`'s
|
|
1077
|
+
// canonical ISO so the comparison is apples-to-apples.
|
|
1078
|
+
const cursorIso = millisToIso(cursorPayload.last_updated_at);
|
|
1079
|
+
conditions.push(
|
|
1080
|
+
"(n.updated_at > ? OR (n.updated_at = ? AND n.id > ?))",
|
|
976
1081
|
);
|
|
1082
|
+
params.push(cursorIso, cursorIso, cursorPayload.last_id);
|
|
977
1083
|
}
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
// when they all use the same canonical form — which every timestamp
|
|
983
|
-
// vault mints does. Cursors minted on heterogeneous timestamps
|
|
984
|
-
// (e.g. an import that preserved unusual formatting) are still
|
|
985
|
-
// safe: we round-trip the cursor's millis through `new Date()`'s
|
|
986
|
-
// canonical ISO so the comparison is apples-to-apples.
|
|
987
|
-
const cursorIso = millisToIso(cursorPayload.last_updated_at);
|
|
988
|
-
conditions.push(
|
|
989
|
-
"(n.updated_at > ? OR (n.updated_at = ? AND n.id > ?))",
|
|
990
|
-
);
|
|
991
|
-
params.push(cursorIso, cursorIso, cursorPayload.last_id);
|
|
1084
|
+
// else: bootstrap call (`cursor === ""`) — no watermark yet, no
|
|
1085
|
+
// predicate to add, but the ORDER BY below still switches to the
|
|
1086
|
+
// keyset order so this first page is consistent with every page after
|
|
1087
|
+
// it.
|
|
992
1088
|
}
|
|
993
1089
|
|
|
994
1090
|
const direction = opts.sort === "desc" ? "DESC" : "ASC";
|
|
995
1091
|
let orderBy: string;
|
|
996
|
-
if (
|
|
1092
|
+
if (cursorMode) {
|
|
997
1093
|
// Cursor mode forces a deterministic keyset order. `id` is the
|
|
998
1094
|
// tiebreaker — without it, two notes sharing an `updated_at` would
|
|
999
1095
|
// be at the mercy of SQLite's row order and the next page could
|
|
@@ -1177,7 +1273,11 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
|
|
|
1177
1273
|
// cursor's watermark — see the JSDoc rationale above.
|
|
1178
1274
|
let lastUpdatedAt = 0;
|
|
1179
1275
|
let lastId = "";
|
|
1180
|
-
|
|
1276
|
+
// `opts.cursor === ""` is the bootstrap call (vault#550) — there's no
|
|
1277
|
+
// prior watermark to decode, so it takes the same 0/"" sentinel path as
|
|
1278
|
+
// `opts.cursor === undefined`. Only a REAL (non-empty) cursor gets
|
|
1279
|
+
// re-decoded here.
|
|
1280
|
+
if (opts.cursor !== undefined && opts.cursor !== "") {
|
|
1181
1281
|
// Re-decode (we already validated in queryNotes); this is cheap.
|
|
1182
1282
|
const prior = decodeCursor(opts.cursor);
|
|
1183
1283
|
lastUpdatedAt = prior.last_updated_at;
|
|
@@ -1303,7 +1403,18 @@ export function getNoteTags(db: Database, noteId: string): string[] {
|
|
|
1303
1403
|
return rows.map((r) => r.tag_name);
|
|
1304
1404
|
}
|
|
1305
1405
|
|
|
1306
|
-
|
|
1406
|
+
/**
|
|
1407
|
+
* List every declared tag with its literal `count` (notes carrying that
|
|
1408
|
+
* EXACT tag name) and its `expanded_count` (vault#550) — distinct notes
|
|
1409
|
+
* matching the tag OR any transitive descendant under the DEFAULT
|
|
1410
|
+
* (subtypes) expansion axis. `expanded_count` is what surfaces a parent
|
|
1411
|
+
* tag as non-empty even when every one of its notes is actually tagged
|
|
1412
|
+
* with a more specific child tag — `count` alone reports 0 for that
|
|
1413
|
+
* parent, which reads as "this tag is dead" when it's really just a
|
|
1414
|
+
* rollup label. See `computeExpandedTagCounts` for the single-pass
|
|
1415
|
+
* (no N+1) computation.
|
|
1416
|
+
*/
|
|
1417
|
+
export function listTags(db: Database): { name: string; count: number; expanded_count: number }[] {
|
|
1307
1418
|
const rows = db.prepare(`
|
|
1308
1419
|
SELECT t.name, COUNT(nt.note_id) as count
|
|
1309
1420
|
FROM tags t
|
|
@@ -1311,7 +1422,10 @@ export function listTags(db: Database): { name: string; count: number }[] {
|
|
|
1311
1422
|
GROUP BY t.name
|
|
1312
1423
|
ORDER BY t.name
|
|
1313
1424
|
`).all() as { name: string; count: number }[];
|
|
1314
|
-
|
|
1425
|
+
|
|
1426
|
+
const h = loadTagHierarchy(db);
|
|
1427
|
+
const expandedCounts = computeExpandedTagCounts(db, h);
|
|
1428
|
+
return rows.map((r) => ({ ...r, expanded_count: expandedCounts.get(r.name) ?? 0 }));
|
|
1315
1429
|
}
|
|
1316
1430
|
|
|
1317
1431
|
export function deleteTag(db: Database, name: string): { deleted: boolean; notes_untagged: number } {
|
|
@@ -37,9 +37,26 @@ const OPS_SET: ReadonlySet<string> = new Set<string>(SUPPORTED_OPS);
|
|
|
37
37
|
export class QueryError extends Error {
|
|
38
38
|
override name = "QueryError";
|
|
39
39
|
code: string;
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Structured `error_type` for the honest-queries validation errors
|
|
42
|
+
* (vault#550 — `limit`/`offset`/date-value validation). Optional: the
|
|
43
|
+
* long-standing QueryError call sites (FIELD_NOT_INDEXED,
|
|
44
|
+
* UNKNOWN_OPERATOR, generic INVALID_QUERY combos) leave this unset and
|
|
45
|
+
* keep their existing `{error, code}` response shape — only the new #550
|
|
46
|
+
* call sites opt into the richer `{error_type, field, got, hint}` shape.
|
|
47
|
+
*/
|
|
48
|
+
error_type?: string;
|
|
49
|
+
field?: string;
|
|
50
|
+
got?: unknown;
|
|
51
|
+
hint?: string;
|
|
52
|
+
constructor(
|
|
53
|
+
message: string,
|
|
54
|
+
code = "INVALID_QUERY",
|
|
55
|
+
extra?: { error_type?: string; field?: string; got?: unknown; hint?: string },
|
|
56
|
+
) {
|
|
41
57
|
super(message);
|
|
42
58
|
this.code = code;
|
|
59
|
+
if (extra) Object.assign(this, extra);
|
|
43
60
|
}
|
|
44
61
|
}
|
|
45
62
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `warnings: []` channel (vault#550 — Reliability & Usability Program
|
|
3
|
+
* WS1, "honest queries"). Ratified principle: if the vault can still answer
|
|
4
|
+
* the question asked, answer it and attach a warning; if it would answer a
|
|
5
|
+
* DIFFERENT question, return a structured named error. Silence is never the
|
|
6
|
+
* third option.
|
|
7
|
+
*
|
|
8
|
+
* This module owns the ONE validated choke point for `unknown_tag`
|
|
9
|
+
* warnings — both the REST structured-query path (`src/routes.ts`) and the
|
|
10
|
+
* MCP `query-notes` tool call into `collectUnknownTagWarnings` so the two
|
|
11
|
+
* surfaces can't drift. REST-only warnings (`removed_param` — a REST query
|
|
12
|
+
* string concept with no MCP analog) stay in `src/routes.ts`.
|
|
13
|
+
*
|
|
14
|
+
* Tag-scope note (security review): this module is deliberately
|
|
15
|
+
* SCOPE-UNAWARE — `collectUnknownTagWarnings` always resolves against the
|
|
16
|
+
* full vault-wide tag catalog, exactly like `core/src/notes.ts:queryNotes`
|
|
17
|
+
* itself. Callers on a tag-scoped session MUST NOT surface these warnings
|
|
18
|
+
* (or must first narrow the candidate pool) — `did_you_mean` naming an
|
|
19
|
+
* out-of-scope tag would leak its existence across the scope boundary,
|
|
20
|
+
* which this codebase treats as a hard "no leak" invariant elsewhere (see
|
|
21
|
+
* `docs/contracts/tag-scoped-tokens.md`). `src/routes.ts` skips this call
|
|
22
|
+
* entirely for scoped sessions; `src/mcp-tools.ts`'s `query-notes` wrapper
|
|
23
|
+
* strips any `warnings` core computed before returning to a scoped caller.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Database } from "bun:sqlite";
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_TAG_EXPAND_MODE,
|
|
29
|
+
getTagExpansion,
|
|
30
|
+
loadTagHierarchy,
|
|
31
|
+
stripTagHash,
|
|
32
|
+
suggestSimilarTag,
|
|
33
|
+
type TagExpandMode,
|
|
34
|
+
type TagHierarchy,
|
|
35
|
+
} from "./tag-hierarchy.js";
|
|
36
|
+
import { chunkForInClause } from "./sql-in.js";
|
|
37
|
+
|
|
38
|
+
export interface QueryWarning {
|
|
39
|
+
code: string;
|
|
40
|
+
message: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cap on `unknown_tag` warnings per query (vault#550 fold). A caller
|
|
46
|
+
* passing a garbage `tags` array (hundreds of junk names) would otherwise
|
|
47
|
+
* inflate the response — and the REST `X-Parachute-Warnings` header —
|
|
48
|
+
* unboundedly. Past the cap, a single `warnings_truncated` entry reports
|
|
49
|
+
* how many were suppressed.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_UNKNOWN_TAG_WARNINGS = 8;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Membership counts for a specific set of candidate tag names — NOT a
|
|
55
|
+
* full `listTags()` vault-wide scan. `listTags` additionally computes
|
|
56
|
+
* `expanded_count` (a full `note_tags` pass over EVERY tag, vault#550),
|
|
57
|
+
* which this function deliberately avoids: `collectUnknownTagWarnings` runs
|
|
58
|
+
* on every tag-filtered structured query (a hot path), so paying for a
|
|
59
|
+
* vault-wide rollup just to answer "does tag X have ANY notes" would be a
|
|
60
|
+
* real perf regression. This scopes the `note_tags` scan to only the
|
|
61
|
+
* (typically small) set of names actually reachable from the query's own
|
|
62
|
+
* tag filter and its expansion.
|
|
63
|
+
*/
|
|
64
|
+
function countMembership(db: Database, names: ReadonlySet<string>): Map<string, number> {
|
|
65
|
+
const counts = new Map<string, number>();
|
|
66
|
+
if (names.size === 0) return counts;
|
|
67
|
+
for (const chunk of chunkForInClause([...names])) {
|
|
68
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
69
|
+
const rows = db.prepare(
|
|
70
|
+
`SELECT tag_name, COUNT(*) as c FROM note_tags WHERE tag_name IN (${placeholders}) GROUP BY tag_name`,
|
|
71
|
+
).all(...chunk) as { tag_name: string; c: number }[];
|
|
72
|
+
for (const row of rows) counts.set(row.tag_name, row.c);
|
|
73
|
+
}
|
|
74
|
+
return counts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Warn on each literal tag name in a `tags` filter that can contribute
|
|
79
|
+
* NOTHING to the result set no matter what — the caller almost certainly
|
|
80
|
+
* mistyped it, or is thinking of a different vault. A tag survives (no
|
|
81
|
+
* warning) if ANY of the following holds:
|
|
82
|
+
*
|
|
83
|
+
* - it has its own identity row (a `tags` table entry — created via
|
|
84
|
+
* `update-tag` or implicitly by tagging a note), OR
|
|
85
|
+
* - at least one note literally carries that tag, OR
|
|
86
|
+
* - its mode-aware expansion (subtypes/namespace/both/exact — whatever
|
|
87
|
+
* the query's `expand` axis resolves to) contains at least one tag
|
|
88
|
+
* name that itself has notes.
|
|
89
|
+
*
|
|
90
|
+
* `did_you_mean` names the closest existing tag (case variant, prefix
|
|
91
|
+
* relationship, or small edit distance) when one exists — see
|
|
92
|
+
* `suggestSimilarTag`.
|
|
93
|
+
*
|
|
94
|
+
* Scoped to the POSITIVE `tags` filter only — `excludeTags` is not
|
|
95
|
+
* checked (excluding a nonexistent tag is a harmless no-op, not a sign of
|
|
96
|
+
* a mistaken query) and this does not run under `search=` (out of scope
|
|
97
|
+
* for this wave — see #551).
|
|
98
|
+
*
|
|
99
|
+
* Perf shape (vault#550 fold): this runs on EVERY tag-filtered structured
|
|
100
|
+
* query, so the common all-tags-known case must cost ~nothing. Pass the
|
|
101
|
+
* store's cached `hierarchy` (`Store.getTagHierarchy()` — invalidated on
|
|
102
|
+
* tag writes) so no fresh `tags`-table scan happens per request; the
|
|
103
|
+
* `note_tags` membership query below only runs for input tags MISSING
|
|
104
|
+
* from the identity set (`h.allTags`), which for a well-formed query is
|
|
105
|
+
* none — a tag with an identity row can never warn (`hasIdentity`
|
|
106
|
+
* short-circuits), so there's nothing to look up. The `hierarchy` param
|
|
107
|
+
* stays optional for direct-core callers/tests (falls back to a fresh
|
|
108
|
+
* load).
|
|
109
|
+
*/
|
|
110
|
+
export function collectUnknownTagWarnings(
|
|
111
|
+
db: Database,
|
|
112
|
+
tags: string[] | undefined,
|
|
113
|
+
expandMode: TagExpandMode | undefined,
|
|
114
|
+
hierarchy?: TagHierarchy,
|
|
115
|
+
): QueryWarning[] {
|
|
116
|
+
if (!tags || tags.length === 0) return [];
|
|
117
|
+
|
|
118
|
+
const h = hierarchy ?? loadTagHierarchy(db);
|
|
119
|
+
const mode = expandMode ?? DEFAULT_TAG_EXPAND_MODE;
|
|
120
|
+
|
|
121
|
+
// Fast path: dedupe inputs and drop every tag with an identity row —
|
|
122
|
+
// those can never warn. For a well-formed query this empties the list
|
|
123
|
+
// and we return without touching the DB at all.
|
|
124
|
+
const suspects: string[] = [];
|
|
125
|
+
const seen = new Set<string>();
|
|
126
|
+
for (const raw of tags) {
|
|
127
|
+
const tag = stripTagHash(raw);
|
|
128
|
+
if (tag === "" || seen.has(tag)) continue;
|
|
129
|
+
seen.add(tag);
|
|
130
|
+
if (h.allTags.has(tag)) continue; // identity row → never unknown
|
|
131
|
+
suspects.push(tag);
|
|
132
|
+
}
|
|
133
|
+
if (suspects.length === 0) return [];
|
|
134
|
+
|
|
135
|
+
// Slow path (only for identity-less names): pre-compute each suspect's
|
|
136
|
+
// expansion set (memoized on `h.descendantsCache` — safe to share with
|
|
137
|
+
// the store's cache, entries are pure derived state) so the membership
|
|
138
|
+
// check is ONE batched IN-list query over the union, not one per tag.
|
|
139
|
+
// An identity-less tag can still be "known" two ways: a `note_tags` row
|
|
140
|
+
// exists without a `tags` row (not produced by current writers, but
|
|
141
|
+
// contract-tolerated), or children declared it a parent (`childrenOf`
|
|
142
|
+
// edges exist for undeclared parents) and a descendant has notes.
|
|
143
|
+
const expansions = new Map<string, Set<string>>();
|
|
144
|
+
for (const tag of suspects) expansions.set(tag, getTagExpansion(h, tag, mode));
|
|
145
|
+
const candidateNames = new Set<string>();
|
|
146
|
+
for (const set of expansions.values()) for (const name of set) candidateNames.add(name);
|
|
147
|
+
const counts = countMembership(db, candidateNames);
|
|
148
|
+
|
|
149
|
+
const warnings: QueryWarning[] = [];
|
|
150
|
+
let suppressed = 0;
|
|
151
|
+
for (const tag of suspects) {
|
|
152
|
+
const hasOwnMembership = (counts.get(tag) ?? 0) > 0;
|
|
153
|
+
let hasExpansionMembers = false;
|
|
154
|
+
for (const t of expansions.get(tag)!) {
|
|
155
|
+
if ((counts.get(t) ?? 0) > 0) {
|
|
156
|
+
hasExpansionMembers = true;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!hasOwnMembership && !hasExpansionMembers) {
|
|
162
|
+
if (warnings.length >= MAX_UNKNOWN_TAG_WARNINGS) {
|
|
163
|
+
suppressed++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const suggestion = suggestSimilarTag(h.allTags, tag);
|
|
167
|
+
warnings.push({
|
|
168
|
+
code: "unknown_tag",
|
|
169
|
+
message: `tag "${tag}" has no identity row and no notes match it (directly or via expansion) — check spelling, or create it first with update-tag`,
|
|
170
|
+
tag,
|
|
171
|
+
...(suggestion ? { did_you_mean: suggestion } : {}),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (suppressed > 0) {
|
|
176
|
+
warnings.push({
|
|
177
|
+
code: "warnings_truncated",
|
|
178
|
+
message: `${suppressed} additional unknown_tag warning(s) suppressed — at most ${MAX_UNKNOWN_TAG_WARNINGS} are reported per query.`,
|
|
179
|
+
suppressed,
|
|
180
|
+
limit: MAX_UNKNOWN_TAG_WARNINGS,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return warnings;
|
|
184
|
+
}
|
package/core/src/store.ts
CHANGED
|
@@ -70,8 +70,15 @@ export class BunSqliteStore implements Store {
|
|
|
70
70
|
* boot or after an invalidation does the scan; subsequent calls hit the
|
|
71
71
|
* cache. Returns the same object until invalidated, so callers can rely
|
|
72
72
|
* on identity for memoizing per-tag descendant sets.
|
|
73
|
+
*
|
|
74
|
+
* Public (vault#550 fold): the query-warnings collector
|
|
75
|
+
* (`core/src/query-warnings.ts:collectUnknownTagWarnings`) runs on every
|
|
76
|
+
* tag-filtered structured query — threading this cached hierarchy in
|
|
77
|
+
* (instead of a fresh `loadTagHierarchy` per request) keeps the
|
|
78
|
+
* common all-tags-known case at ~zero extra cost. Treat the returned
|
|
79
|
+
* object as READ-ONLY — it's the shared cache, invalidated by writers.
|
|
73
80
|
*/
|
|
74
|
-
|
|
81
|
+
getTagHierarchy(): TagHierarchy {
|
|
75
82
|
if (!this._tagHierarchy) this._tagHierarchy = loadTagHierarchy(this.db);
|
|
76
83
|
return this._tagHierarchy;
|
|
77
84
|
}
|
|
@@ -445,7 +452,7 @@ export class BunSqliteStore implements Store {
|
|
|
445
452
|
return expanded;
|
|
446
453
|
}
|
|
447
454
|
|
|
448
|
-
async listTags(): Promise<{ name: string; count: number }[]> {
|
|
455
|
+
async listTags(): Promise<{ name: string; count: number; expanded_count: number }[]> {
|
|
449
456
|
return noteOps.listTags(this.db);
|
|
450
457
|
}
|
|
451
458
|
|
|
@@ -249,6 +249,119 @@ export function getTagExpansion(
|
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Suggest the closest EXISTING tag name to an unmatched input — the
|
|
254
|
+
* `did_you_mean` hint on `unknown_tag` warnings (vault#550) and
|
|
255
|
+
* `tag_not_found` errors. Candidates are scored, lower is better:
|
|
256
|
+
*
|
|
257
|
+
* 0. case-only difference (`Voice` vs `voice`)
|
|
258
|
+
* 1. a prefix relationship either direction (`voice` / `voice-memo` —
|
|
259
|
+
* catches plural/singular drift and namespace-child typos)
|
|
260
|
+
* 2+dist. Levenshtein edit distance, when within a length-scaled budget
|
|
261
|
+
*
|
|
262
|
+
* Returns the single best match, or `undefined` when nothing is close
|
|
263
|
+
* enough to be worth suggesting — a genuinely novel tag name shouldn't get
|
|
264
|
+
* a noisy "did you mean" pointing at an unrelated tag.
|
|
265
|
+
*/
|
|
266
|
+
export function suggestSimilarTag(
|
|
267
|
+
candidates: Iterable<string>,
|
|
268
|
+
input: string,
|
|
269
|
+
): string | undefined {
|
|
270
|
+
const lower = input.toLowerCase();
|
|
271
|
+
let best: string | undefined;
|
|
272
|
+
let bestScore = Infinity;
|
|
273
|
+
for (const candidate of candidates) {
|
|
274
|
+
if (candidate === input) continue;
|
|
275
|
+
const candLower = candidate.toLowerCase();
|
|
276
|
+
let score: number | null = null;
|
|
277
|
+
if (candLower === lower) {
|
|
278
|
+
score = 0;
|
|
279
|
+
} else if (lower.length >= 2 && candLower.length >= 2 && (candLower.startsWith(lower) || lower.startsWith(candLower))) {
|
|
280
|
+
score = 1;
|
|
281
|
+
} else {
|
|
282
|
+
const dist = levenshtein(lower, candLower);
|
|
283
|
+
const budget = Math.max(2, Math.ceil(Math.min(lower.length, candLower.length) * 0.34));
|
|
284
|
+
if (dist <= budget) score = 2 + dist;
|
|
285
|
+
}
|
|
286
|
+
if (score !== null && score < bestScore) {
|
|
287
|
+
bestScore = score;
|
|
288
|
+
best = candidate;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return best;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Classic Levenshtein edit distance, O(m·n) time / O(n) space. */
|
|
295
|
+
function levenshtein(a: string, b: string): number {
|
|
296
|
+
const m = a.length;
|
|
297
|
+
const n = b.length;
|
|
298
|
+
if (m === 0) return n;
|
|
299
|
+
if (n === 0) return m;
|
|
300
|
+
let prev = new Array<number>(n + 1);
|
|
301
|
+
let curr = new Array<number>(n + 1);
|
|
302
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
303
|
+
for (let i = 1; i <= m; i++) {
|
|
304
|
+
curr[0] = i;
|
|
305
|
+
for (let j = 1; j <= n; j++) {
|
|
306
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
307
|
+
curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
|
|
308
|
+
}
|
|
309
|
+
[prev, curr] = [curr, prev];
|
|
310
|
+
}
|
|
311
|
+
return prev[n]!;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Compute, for every declared tag, the number of DISTINCT notes matching
|
|
316
|
+
* that tag OR any transitive descendant under the subtypes axis (vault#550
|
|
317
|
+
* `expanded_count` — the rollup a parent tag whose notes are all tagged
|
|
318
|
+
* with a CHILD tag needs to report a non-zero count).
|
|
319
|
+
*
|
|
320
|
+
* One query fetches every `(tag_name, note_id)` pairing from `note_tags`;
|
|
321
|
+
* the fan-out to ancestors is pure in-memory set work over the (memoized)
|
|
322
|
+
* hierarchy — not one query per tag, so this stays cheap regardless of how
|
|
323
|
+
* many tags the vault declares. `getTagDescendants` is memoized per tag on
|
|
324
|
+
* `h`, so the ancestor-closure build below reuses that cache instead of
|
|
325
|
+
* re-walking the graph per candidate.
|
|
326
|
+
*/
|
|
327
|
+
export function computeExpandedTagCounts(
|
|
328
|
+
db: Database,
|
|
329
|
+
h: TagHierarchy,
|
|
330
|
+
): Map<string, number> {
|
|
331
|
+
const rows = db.prepare(`SELECT tag_name, note_id FROM note_tags`).all() as
|
|
332
|
+
{ tag_name: string; note_id: string }[];
|
|
333
|
+
|
|
334
|
+
const expandedNotes = new Map<string, Set<string>>();
|
|
335
|
+
const ancestorsCache = new Map<string, string[]>();
|
|
336
|
+
|
|
337
|
+
function ancestorsOrSelf(x: string): string[] {
|
|
338
|
+
const cached = ancestorsCache.get(x);
|
|
339
|
+
if (cached) return cached;
|
|
340
|
+
const result: string[] = [];
|
|
341
|
+
for (const t of h.allTags) {
|
|
342
|
+
if (getTagDescendants(h, t).has(x)) result.push(t);
|
|
343
|
+
}
|
|
344
|
+
if (!result.includes(x)) result.push(x);
|
|
345
|
+
ancestorsCache.set(x, result);
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const row of rows) {
|
|
350
|
+
for (const ancestor of ancestorsOrSelf(row.tag_name)) {
|
|
351
|
+
let set = expandedNotes.get(ancestor);
|
|
352
|
+
if (!set) {
|
|
353
|
+
set = new Set();
|
|
354
|
+
expandedNotes.set(ancestor, set);
|
|
355
|
+
}
|
|
356
|
+
set.add(row.note_id);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const counts = new Map<string, number>();
|
|
361
|
+
for (const [tag, set] of expandedNotes) counts.set(tag, set.size);
|
|
362
|
+
return counts;
|
|
363
|
+
}
|
|
364
|
+
|
|
252
365
|
/**
|
|
253
366
|
* Detect cycles in the declared hierarchy. Returns the list of tags
|
|
254
367
|
* reachable from themselves via parent declarations. Used by
|
package/core/src/types.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
2
|
import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
3
3
|
import type { PrunedField } from "./indexed-fields.js";
|
|
4
|
-
import type { TagExpandMode } from "./tag-hierarchy.js";
|
|
4
|
+
import type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
|
|
5
5
|
import type { ValidationStatus } from "./schema-defaults.js";
|
|
6
6
|
import type { ConformanceReport } from "./conformance.js";
|
|
7
|
+
import type { FindPathResult } from "./links.js";
|
|
7
8
|
|
|
8
9
|
// ---- Re-exports ----
|
|
9
10
|
|
|
10
11
|
export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
11
12
|
export type { PrunedField } from "./indexed-fields.js";
|
|
12
|
-
export type { TagExpandMode } from "./tag-hierarchy.js";
|
|
13
|
+
export type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
|
|
13
14
|
export type { ConformanceReport } from "./conformance.js";
|
|
15
|
+
export type { FindPathResult } from "./links.js";
|
|
14
16
|
|
|
15
17
|
// ---- Note ----
|
|
16
18
|
|
|
@@ -336,7 +338,20 @@ export interface Store {
|
|
|
336
338
|
* IDENTICAL expansion the snapshot query engine uses for the same `expand`.
|
|
337
339
|
*/
|
|
338
340
|
expandTags(tags: string[], mode?: TagExpandMode): Promise<Set<string>>;
|
|
339
|
-
|
|
341
|
+
/**
|
|
342
|
+
* The store's cached tag hierarchy (invalidated on tag/parent_names
|
|
343
|
+
* writes). Sync, like `db` and `transaction`. Exposed (vault#550 fold)
|
|
344
|
+
* so per-query consumers — the `unknown_tag` warning collector — reuse
|
|
345
|
+
* the cache instead of re-scanning the `tags` table per request. Treat
|
|
346
|
+
* the returned object as READ-ONLY shared state.
|
|
347
|
+
*/
|
|
348
|
+
getTagHierarchy(): TagHierarchy;
|
|
349
|
+
/**
|
|
350
|
+
* `expanded_count` (vault#550): distinct notes matching the tag OR any
|
|
351
|
+
* transitive descendant under the DEFAULT (subtypes) expansion axis,
|
|
352
|
+
* alongside the literal `count`. See `core/src/tag-hierarchy.ts:computeExpandedTagCounts`.
|
|
353
|
+
*/
|
|
354
|
+
listTags(): Promise<{ name: string; count: number; expanded_count: number }[]>;
|
|
340
355
|
deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
|
|
341
356
|
renameTag(
|
|
342
357
|
oldName: string,
|
|
@@ -375,7 +390,7 @@ export interface Store {
|
|
|
375
390
|
|
|
376
391
|
// Deeper link queries
|
|
377
392
|
traverseLinks(noteId: string, opts?: { max_depth?: number; relationship?: string }): Promise<{ noteId: string; depth: number; relationship: string; direction: "outbound" | "inbound" }[]>;
|
|
378
|
-
findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<
|
|
393
|
+
findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<FindPathResult | null>;
|
|
379
394
|
|
|
380
395
|
// Tag schemas — schema-only facade (description + fields). Back-compat
|
|
381
396
|
// surface for v13-and-earlier callers; reads/writes route through the
|