@openparachute/vault 0.7.0-rc.5 → 0.7.0-rc.7
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/attribution.test.ts +11 -3
- package/core/src/contract-typed-index.test.ts +228 -11
- package/core/src/core.test.ts +38 -8
- package/core/src/doctor.ts +43 -13
- package/core/src/mcp.ts +66 -26
- package/core/src/notes.ts +85 -20
- package/core/src/query-warnings.ts +110 -0
- package/core/src/schema-defaults.ts +48 -7
- package/core/src/schema.ts +314 -10
- package/core/src/search-fts-v25.test.ts +362 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/seed-packs.ts +20 -13
- package/core/src/store.ts +18 -0
- package/core/src/tag-schemas.ts +121 -6
- package/core/src/types.ts +20 -0
- package/package.json +1 -1
- package/src/contract-search.test.ts +168 -0
- package/src/mcp-query-notes-search-scope.test.ts +53 -0
- package/src/onboarding-seed.test.ts +5 -2
- package/src/routes.ts +42 -53
- package/src/vault.test.ts +33 -27
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 {
|
|
@@ -2120,6 +2184,7 @@ export function toNoteIndex(note: Note): NoteIndex {
|
|
|
2120
2184
|
metadata: note.metadata,
|
|
2121
2185
|
byteSize,
|
|
2122
2186
|
preview,
|
|
2187
|
+
...(note.score !== undefined ? { score: note.score } : {}),
|
|
2123
2188
|
};
|
|
2124
2189
|
}
|
|
2125
2190
|
|
|
@@ -217,3 +217,113 @@ export function ignoredParamWarning(param: string, reason: string): QueryWarning
|
|
|
217
217
|
param,
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Cheap zero-result search suggestion (vault#551 WS2B — mirrors the tag
|
|
223
|
+
* `did_you_mean` above via the SAME `suggestSimilarTag` scorer, just over a
|
|
224
|
+
* different candidate pool). Callers MUST only invoke this AFTER a search
|
|
225
|
+
* already returned zero rows — it is never on the hot non-empty path, so
|
|
226
|
+
* the extra vocabulary scan costs nothing in the overwhelmingly common
|
|
227
|
+
* case where the search actually found something.
|
|
228
|
+
*
|
|
229
|
+
* Candidates are drawn from two cheap-to-reach sources, unioned:
|
|
230
|
+
*
|
|
231
|
+
* - the FTS5 vocabulary — a `notes_fts_vocab` `fts5vocab('row')` table
|
|
232
|
+
* created here LAZILY and BEST-EFFORT (not part of the schema/
|
|
233
|
+
* migration — see the try/catch below) — the terms actually indexed,
|
|
234
|
+
* POST-tokenization/stemming (porter). A suggestion therefore
|
|
235
|
+
* sometimes reads as a stemmed form ("propoli" rather than "propolis")
|
|
236
|
+
* rather than the original dictionary word — an accepted tradeoff
|
|
237
|
+
* (documented in docs/HTTP_API.md) rather than maintaining a second,
|
|
238
|
+
* unstemmed index just for spelling suggestions.
|
|
239
|
+
* - tag names, when the caller passes `tagNames` (already cached by the
|
|
240
|
+
* caller's tag hierarchy — free to include).
|
|
241
|
+
*
|
|
242
|
+
* `fts5vocab` is registered by the same FTS5 extension init as `notes_fts`
|
|
243
|
+
* itself (not a separately-enabled module) so it's expected to be
|
|
244
|
+
* available anywhere search already works — but this is NOT exercised
|
|
245
|
+
* against Cloudflare DO SQLite (flagged for the wire reviewer). The WHOLE
|
|
246
|
+
* function is wrapped in try/catch and degrades to "no suggestion" on ANY
|
|
247
|
+
* failure — including a runtime where `fts5vocab` is unavailable — rather
|
|
248
|
+
* than ever throwing; a spelling hint is a nicety, never worth risking the
|
|
249
|
+
* search response itself.
|
|
250
|
+
*
|
|
251
|
+
* Per-token length filtering (`length(term) BETWEEN len-2 AND len+2`) keeps
|
|
252
|
+
* the vocabulary scan bounded on a large vault without a hardcoded row cap
|
|
253
|
+
* that could silently exclude the very term a caller needs (a `LIMIT`
|
|
254
|
+
* ORDER-BY-frequency would bias toward common words, which is backwards
|
|
255
|
+
* for a name/typo lookup — the word you're trying to find is often rare).
|
|
256
|
+
*/
|
|
257
|
+
export function computeSearchDidYouMean(
|
|
258
|
+
db: Database,
|
|
259
|
+
rawQuery: string,
|
|
260
|
+
tagNames?: Iterable<string>,
|
|
261
|
+
): string | undefined {
|
|
262
|
+
try {
|
|
263
|
+
const tokens = rawQuery.trim().split(/\s+/).filter(Boolean);
|
|
264
|
+
if (tokens.length === 0) return undefined;
|
|
265
|
+
|
|
266
|
+
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts_vocab USING fts5vocab(notes_fts, 'row')");
|
|
267
|
+
const exactStmt = db.prepare("SELECT 1 FROM notes_fts_vocab WHERE term = ? LIMIT 1");
|
|
268
|
+
const rangeStmt = db.prepare("SELECT term FROM notes_fts_vocab WHERE length(term) BETWEEN ? AND ?");
|
|
269
|
+
|
|
270
|
+
let changed = false;
|
|
271
|
+
const corrected = tokens.map((tok) => {
|
|
272
|
+
// Strip leading/trailing punctuation for the lookup (the FTS5
|
|
273
|
+
// vocabulary never contains punctuation) but leave short tokens
|
|
274
|
+
// (numbers, "a", "of", ...) alone — too little signal to safely
|
|
275
|
+
// "correct" without a high false-positive rate.
|
|
276
|
+
const clean = tok.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
|
|
277
|
+
if (clean.length < 3) return tok;
|
|
278
|
+
const lower = clean.toLowerCase();
|
|
279
|
+
|
|
280
|
+
// Already indexed verbatim (post-stemming) — no typo signal.
|
|
281
|
+
if (exactStmt.get(lower)) return tok;
|
|
282
|
+
|
|
283
|
+
const lo = Math.max(1, clean.length - 2);
|
|
284
|
+
const hi = clean.length + 2;
|
|
285
|
+
const rows = rangeStmt.all(lo, hi) as { term: string }[];
|
|
286
|
+
const candidates = new Set<string>(rows.map((r) => r.term));
|
|
287
|
+
if (tagNames) for (const t of tagNames) candidates.add(t);
|
|
288
|
+
|
|
289
|
+
const suggestion = suggestSimilarTag(candidates, lower);
|
|
290
|
+
if (suggestion && suggestion.toLowerCase() !== lower) {
|
|
291
|
+
changed = true;
|
|
292
|
+
return suggestion;
|
|
293
|
+
}
|
|
294
|
+
return tok;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (!changed) return undefined;
|
|
298
|
+
return corrected.join(" ");
|
|
299
|
+
} catch {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* `search_did_you_mean` warning (vault#551 WS2B) — wraps
|
|
306
|
+
* {@link computeSearchDidYouMean}'s suggestion in the standard warning
|
|
307
|
+
* shape, mirroring `unknown_tag`'s `did_you_mean` field. Returns
|
|
308
|
+
* `undefined` (nothing to push) when no suggestion clears the bar — a
|
|
309
|
+
* zero-result search is a perfectly ordinary outcome on its own and does
|
|
310
|
+
* NOT warrant a warning by itself; only an actual spelling suggestion does.
|
|
311
|
+
*
|
|
312
|
+
* Scope-unaware by construction, exactly like `collectUnknownTagWarnings`
|
|
313
|
+
* (see this file's top-of-file doc comment) — the FTS5 vocabulary spans
|
|
314
|
+
* the WHOLE vault regardless of any caller's tag scope. Callers on a
|
|
315
|
+
* tag-scoped session MUST NOT surface this warning directly:
|
|
316
|
+
* `src/mcp-tools.ts`'s `query-notes` wrapper already strips the entire
|
|
317
|
+
* `warnings` array for a scoped caller (so MCP is safe with no extra
|
|
318
|
+
* work); `src/routes.ts` must gate the call itself behind
|
|
319
|
+
* `tagScope.allowed === null`, same as it already does for
|
|
320
|
+
* `collectUnknownTagWarnings`.
|
|
321
|
+
*/
|
|
322
|
+
export function searchDidYouMeanWarning(rawQuery: string, suggestion: string): QueryWarning {
|
|
323
|
+
return {
|
|
324
|
+
code: "search_did_you_mean",
|
|
325
|
+
message: `no results for "${rawQuery}" — did you mean "${suggestion}"?`,
|
|
326
|
+
query: rawQuery,
|
|
327
|
+
did_you_mean: suggestion,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -60,6 +60,20 @@ export interface SchemaField {
|
|
|
60
60
|
type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
|
61
61
|
enum?: string[];
|
|
62
62
|
description?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Mirrors `TagFieldSchema.indexed` (core/src/tag-schemas.ts) — this field
|
|
65
|
+
* has a generated column + B-tree index maintained on `notes` (see
|
|
66
|
+
* core/src/indexed-fields.ts). Parsed here (not just there) because
|
|
67
|
+
* `validateNote` uses it: an indexed field is a QUERY CONTRACT, not just a
|
|
68
|
+
* storage hint, so a `type_mismatch` on an indexed field is always
|
|
69
|
+
* enforced (`strict: true` on that ONE warning) regardless of this field's
|
|
70
|
+
* own `strict` flag (vault#553 Decision A). Every other constraint
|
|
71
|
+
* (enum/required/cardinality) on an indexed field stays governed by
|
|
72
|
+
* `strict` as before — only the TYPE contract is unconditional, because
|
|
73
|
+
* that's the one a type-mismatched write can silently poison range
|
|
74
|
+
* queries with (SQLite's TEXT-sorts-above-INTEGER affinity ordering).
|
|
75
|
+
*/
|
|
76
|
+
indexed?: boolean;
|
|
63
77
|
/**
|
|
64
78
|
* Strict enforcement opt-in (vault#299, Part A). Default `false` — when
|
|
65
79
|
* unset/false, ALL constraints on this field are advisory: violations
|
|
@@ -169,6 +183,7 @@ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
|
|
|
169
183
|
if (typeof f.type === "string") field.type = f.type as SchemaField["type"];
|
|
170
184
|
if (Array.isArray(f.enum)) field.enum = f.enum.filter((x): x is string => typeof x === "string");
|
|
171
185
|
if (typeof f.description === "string") field.description = f.description;
|
|
186
|
+
if (f.indexed === true) field.indexed = true;
|
|
172
187
|
if (f.strict === true) field.strict = true;
|
|
173
188
|
if (f.required === true) field.required = true;
|
|
174
189
|
if (f.cardinality === "one" || f.cardinality === "many") field.cardinality = f.cardinality;
|
|
@@ -319,6 +334,20 @@ function stringArraysEqual(a: string[] | undefined, b: string[] | undefined): bo
|
|
|
319
334
|
return true;
|
|
320
335
|
}
|
|
321
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Human-readable JSON-shape name for a value, used to name the "got" side of
|
|
339
|
+
* a `type_mismatch` message (vault#553 — "message names field + expected
|
|
340
|
+
* type + got type"). Distinct from SQLite's `json_type()` vocabulary
|
|
341
|
+
* (integer/real/true/false/text/...) — this is the JSON-Schema-ish vocabulary
|
|
342
|
+
* `SchemaField.type` already uses, so the message reads as "expected X, got
|
|
343
|
+
* Y" in the SAME words the schema declares.
|
|
344
|
+
*/
|
|
345
|
+
function jsonTypeOf(value: unknown): string {
|
|
346
|
+
if (value === null) return "null";
|
|
347
|
+
if (Array.isArray(value)) return "array";
|
|
348
|
+
return typeof value; // "string" | "number" | "boolean" | "object" | ...
|
|
349
|
+
}
|
|
350
|
+
|
|
322
351
|
function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
323
352
|
if (type === undefined) return true;
|
|
324
353
|
switch (type) {
|
|
@@ -357,11 +386,15 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
|
357
386
|
* → `cardinality_mismatch`
|
|
358
387
|
*
|
|
359
388
|
* Every violation carries `strict: true` iff its field declared `strict:true`
|
|
360
|
-
* (vault#299)
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
389
|
+
* (vault#299) — EXCEPT a `type_mismatch` on an `indexed: true` field, which is
|
|
390
|
+
* ALWAYS `strict: true` regardless of the field's own `strict` setting
|
|
391
|
+
* (vault#553 Decision A — an indexed field's type is a query contract, not
|
|
392
|
+
* just guidance). The list itself is the SAME whether or not a field is
|
|
393
|
+
* strict — the difference is only the per-warning `strict` flag, which the
|
|
394
|
+
* write path uses to decide block-vs-warn. Under `strict:false` (and
|
|
395
|
+
* `indexed:false`) this is byte-identical to the historical advisory
|
|
396
|
+
* behavior PLUS the new `required`/`cardinality` advisory reasons (which
|
|
397
|
+
* fire for any field declaring those, strict or not).
|
|
365
398
|
*
|
|
366
399
|
* Fields not declared by any ancestor's schema are ignored entirely.
|
|
367
400
|
*/
|
|
@@ -397,12 +430,20 @@ export function validateNote(
|
|
|
397
430
|
if (absent) continue;
|
|
398
431
|
|
|
399
432
|
if (spec.type && !valueMatchesType(value, spec.type)) {
|
|
433
|
+
// Decision A (vault#553): an INDEXED field's type is a query
|
|
434
|
+
// contract — a type-mismatched write poisons range-query ordering
|
|
435
|
+
// (SQLite's TEXT-sorts-above-INTEGER affinity) regardless of whether
|
|
436
|
+
// this field opted into `strict`. Force `strict: true` on THIS
|
|
437
|
+
// warning alone when the field is indexed; every other constraint
|
|
438
|
+
// (enum/required/cardinality) stays governed by `spec.strict` as
|
|
439
|
+
// before.
|
|
440
|
+
const indexedTypeStrict = spec.indexed === true;
|
|
400
441
|
warnings.push({
|
|
401
442
|
field: fieldName,
|
|
402
443
|
schema: sourceTag,
|
|
403
444
|
reason: "type_mismatch",
|
|
404
|
-
message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}')`,
|
|
405
|
-
...strictFlag,
|
|
445
|
+
message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}'), got ${jsonTypeOf(value)}${indexedTypeStrict ? " — indexed fields reject type-mismatched writes (vault#553)" : ""}`,
|
|
446
|
+
...(strictFlag.strict || indexedTypeStrict ? { strict: true } : {}),
|
|
406
447
|
});
|
|
407
448
|
}
|
|
408
449
|
|