@openparachute/vault 0.7.0-rc.4 → 0.7.0-rc.6
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-taxonomy.test.ts +193 -17
- package/core/src/contract-typed-index.test.ts +228 -11
- package/core/src/core.test.ts +67 -14
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +360 -0
- package/core/src/mcp.ts +164 -28
- package/core/src/notes.ts +98 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/schema-defaults.ts +48 -7
- package/core/src/schema.ts +147 -2
- package/core/src/seed-packs.ts +20 -13
- package/core/src/store.ts +35 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +166 -6
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +90 -5
- package/src/mirror-import.ts +5 -0
- package/src/onboarding-seed.test.ts +5 -2
- package/src/routes.ts +91 -52
- package/src/routing.ts +29 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +43 -0
- package/src/vault.test.ts +58 -35
|
@@ -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
|
|
package/core/src/schema.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
|
-
import { rebuildIndexes } from "./indexed-fields.js";
|
|
3
|
+
import { rebuildIndexes, listIndexedFields } from "./indexed-fields.js";
|
|
4
|
+
import { findMixedTypeIndexedFieldNotes } from "./doctor.js";
|
|
4
5
|
import { transaction } from "./txn.js";
|
|
5
6
|
|
|
6
|
-
export const SCHEMA_VERSION =
|
|
7
|
+
export const SCHEMA_VERSION = 24;
|
|
7
8
|
|
|
8
9
|
export const SCHEMA_SQL = `
|
|
9
10
|
-- Notes: the universal record.
|
|
@@ -502,6 +503,10 @@ export function initSchema(db: Database): void {
|
|
|
502
503
|
// vault#298.
|
|
503
504
|
migrateToV23(db);
|
|
504
505
|
|
|
506
|
+
// Migrate v23 → v24: coerce existing typed-index poison where lossless,
|
|
507
|
+
// leave the rest in place for `doctor` to surface. See vault#553.
|
|
508
|
+
migrateToV24(db);
|
|
509
|
+
|
|
505
510
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
506
511
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
507
512
|
rebuildIndexes(db);
|
|
@@ -1187,6 +1192,146 @@ function migrateToV23(db: Database): void {
|
|
|
1187
1192
|
}
|
|
1188
1193
|
}
|
|
1189
1194
|
|
|
1195
|
+
/** Sentinel returned by {@link coerceIndexedValue} when no lossless conversion exists. */
|
|
1196
|
+
const NOT_COERCIBLE = Symbol("not-coercible");
|
|
1197
|
+
|
|
1198
|
+
/** Full JSON-number grammar — used to gate string→number coercion to values that round-trip exactly. */
|
|
1199
|
+
const JSON_NUMBER_RE = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/;
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Decide the lossless coercion (if any) for a single poisoned indexed-field
|
|
1203
|
+
* value, given `jt` — the SQLite `json_type()` of its CURRENT value (the
|
|
1204
|
+
* SAME vocabulary `doctor`'s `findMixedTypeIndexedFieldNotes` compares
|
|
1205
|
+
* against: "text", "integer", "real", "true", "false", "array", "object")
|
|
1206
|
+
* — and `targetSqliteType`, the field's declared storage class ("INTEGER"
|
|
1207
|
+
* or "TEXT"). Returns the coerced value, or {@link NOT_COERCIBLE} when no
|
|
1208
|
+
* exact round-trip conversion exists — the caller leaves the value
|
|
1209
|
+
* untouched (never deletes or nulls note data).
|
|
1210
|
+
*
|
|
1211
|
+
* Coercible cases (vault#553 Decision D):
|
|
1212
|
+
* - INTEGER target, TEXT source: a clean numeric string ("5", "5.5",
|
|
1213
|
+
* "-3") → the JSON number, ONLY when `String(Number(str)) === str`
|
|
1214
|
+
* (exact round-trip — rejects "5.50", scientific-notation reformats,
|
|
1215
|
+
* and anything outside safe double precision that wouldn't survive the
|
|
1216
|
+
* trip). "true"/"false" (exact string match) → the JSON boolean.
|
|
1217
|
+
* - TEXT target, INTEGER/REAL/boolean source: a number or boolean → its
|
|
1218
|
+
* JSON string form via `String(value)` — always exact for finite JS
|
|
1219
|
+
* numbers and booleans within IEEE-754 double precision.
|
|
1220
|
+
* Never coercible: array/object/null values in either direction, or a TEXT
|
|
1221
|
+
* value that isn't a clean number/boolean string (e.g. "four", "5,000").
|
|
1222
|
+
*/
|
|
1223
|
+
function coerceIndexedValue(
|
|
1224
|
+
value: unknown,
|
|
1225
|
+
jt: string,
|
|
1226
|
+
targetSqliteType: string,
|
|
1227
|
+
): unknown | typeof NOT_COERCIBLE {
|
|
1228
|
+
if (targetSqliteType === "INTEGER") {
|
|
1229
|
+
if (jt !== "text" || typeof value !== "string") return NOT_COERCIBLE;
|
|
1230
|
+
if (value === "true") return true;
|
|
1231
|
+
if (value === "false") return false;
|
|
1232
|
+
if (JSON_NUMBER_RE.test(value)) {
|
|
1233
|
+
const n = Number(value);
|
|
1234
|
+
if (Number.isFinite(n) && String(n) === value) return n;
|
|
1235
|
+
}
|
|
1236
|
+
return NOT_COERCIBLE;
|
|
1237
|
+
}
|
|
1238
|
+
if (targetSqliteType === "TEXT") {
|
|
1239
|
+
if ((jt === "true" || jt === "false") && typeof value === "boolean") {
|
|
1240
|
+
return jt; // "true" / "false" — the json_type string IS the target text
|
|
1241
|
+
}
|
|
1242
|
+
if ((jt === "integer" || jt === "real") && typeof value === "number") {
|
|
1243
|
+
const s = String(value);
|
|
1244
|
+
if (Number(s) === value) return s;
|
|
1245
|
+
}
|
|
1246
|
+
return NOT_COERCIBLE;
|
|
1247
|
+
}
|
|
1248
|
+
return NOT_COERCIBLE;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Migrate v23 → v24: typed-index poison coercion (vault#553 Decision D).
|
|
1253
|
+
*
|
|
1254
|
+
* For every declared indexed field (`indexed_fields` — the SSOT from
|
|
1255
|
+
* `core/src/indexed-fields.ts`) and every note whose `metadata.<field>` JSON
|
|
1256
|
+
* type disagrees with the field's declared sqlite storage class — reusing
|
|
1257
|
+
* `doctor`'s `findMixedTypeIndexedFieldNotes` (`core/src/doctor.ts`) as the
|
|
1258
|
+
* DETECTOR, the exact same query that powers the `mixed_type_indexed_field`
|
|
1259
|
+
* finding, so migration and diagnostic can never disagree on what counts as
|
|
1260
|
+
* poisoned:
|
|
1261
|
+
*
|
|
1262
|
+
* - COERCE where {@link coerceIndexedValue} finds a lossless conversion —
|
|
1263
|
+
* rewrites `notes.metadata` in place (the generated `meta_<field>`
|
|
1264
|
+
* column re-derives via `json_extract` automatically; nothing else to
|
|
1265
|
+
* touch).
|
|
1266
|
+
* - LEAVE IN PLACE everything else. NEVER DELETE OR NULL NOTE DATA in a
|
|
1267
|
+
* startup migration — a non-coercible value (e.g. `"hello"` in an
|
|
1268
|
+
* integer field) stays exactly as written and remains visible via
|
|
1269
|
+
* `doctor`'s `mixed_type_indexed_field` finding for the operator to
|
|
1270
|
+
* clean up deliberately.
|
|
1271
|
+
*
|
|
1272
|
+
* Rewrites happen at the JS level — `SELECT metadata` → `JSON.parse` →
|
|
1273
|
+
* mutate the one field → `JSON.stringify` → single-row `UPDATE ... WHERE id
|
|
1274
|
+
* = ?` (two bound params) — rather than SQL `json_set`, so the migration
|
|
1275
|
+
* stays portable to a narrower json1 surface (Cloudflare DO SQLite; flagged
|
|
1276
|
+
* for the wire reviewer to confirm). Detection still uses
|
|
1277
|
+
* `json_extract`/`json_type`/`json_valid`, the same functions the indexed
|
|
1278
|
+
* generated columns and `doctor` already depend on everywhere in this
|
|
1279
|
+
* codebase — not a new cross-runtime risk. One row at a time also means this
|
|
1280
|
+
* never approaches SQLite's bound-parameter ceiling regardless of how many
|
|
1281
|
+
* notes are poisoned (no batched `WHERE id IN (...)`).
|
|
1282
|
+
*
|
|
1283
|
+
* Idempotent: every step is naturally re-runnable (a coerced value now
|
|
1284
|
+
* agrees with the declared type and is never re-detected as a mismatch; a
|
|
1285
|
+
* left-in-place value re-detects identically and is re-skipped identically).
|
|
1286
|
+
* Safe on a vault with zero indexed fields (the field-list guard short-
|
|
1287
|
+
* circuits before any table scan) and safe to run on every boot, matching
|
|
1288
|
+
* the unconditional-migration-function idiom the rest of this file uses.
|
|
1289
|
+
*/
|
|
1290
|
+
function migrateToV24(db: Database): void {
|
|
1291
|
+
if (!hasTable(db, "notes") || !hasTable(db, "indexed_fields")) return;
|
|
1292
|
+
const fields = listIndexedFields(db);
|
|
1293
|
+
if (fields.length === 0) return;
|
|
1294
|
+
|
|
1295
|
+
let coerced = 0;
|
|
1296
|
+
let left = 0;
|
|
1297
|
+
|
|
1298
|
+
transaction(db, () => {
|
|
1299
|
+
const readStmt = db.prepare("SELECT metadata FROM notes WHERE id = ?");
|
|
1300
|
+
const updateStmt = db.prepare("UPDATE notes SET metadata = ? WHERE id = ?");
|
|
1301
|
+
|
|
1302
|
+
for (const f of fields) {
|
|
1303
|
+
const mismatches = findMixedTypeIndexedFieldNotes(db, f.field, f.sqliteType);
|
|
1304
|
+
for (const { id, jt } of mismatches) {
|
|
1305
|
+
const row = readStmt.get(id) as { metadata: string | null } | null;
|
|
1306
|
+
if (!row?.metadata) continue;
|
|
1307
|
+
let meta: unknown;
|
|
1308
|
+
try {
|
|
1309
|
+
meta = JSON.parse(row.metadata);
|
|
1310
|
+
} catch {
|
|
1311
|
+
continue; // shouldn't happen — findMixedTypeIndexedFieldNotes already filters to json_valid rows
|
|
1312
|
+
}
|
|
1313
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) continue;
|
|
1314
|
+
const obj = meta as Record<string, unknown>;
|
|
1315
|
+
if (!(f.field in obj)) continue;
|
|
1316
|
+
const coercedValue = coerceIndexedValue(obj[f.field], jt, f.sqliteType);
|
|
1317
|
+
if (coercedValue === NOT_COERCIBLE) {
|
|
1318
|
+
left++;
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
obj[f.field] = coercedValue;
|
|
1322
|
+
updateStmt.run(JSON.stringify(obj), id);
|
|
1323
|
+
coerced++;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
if (coerced > 0 || left > 0) {
|
|
1329
|
+
console.log(
|
|
1330
|
+
`[vault] migrated to schema v24 (vault#553): typed-index poison scan — coerced ${coerced} value(s) to their declared indexed type; left ${left} non-coercible value(s) in place (see \`doctor\`'s mixed_type_indexed_field finding for cleanup).`,
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1190
1335
|
function hasTable(db: Database, name: string): boolean {
|
|
1191
1336
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
1192
1337
|
return !!row;
|
package/core/src/seed-packs.ts
CHANGED
|
@@ -468,9 +468,9 @@ update-tag {
|
|
|
468
468
|
tag: "meeting",
|
|
469
469
|
description: "A meeting with notes",
|
|
470
470
|
fields: {
|
|
471
|
-
held_on: { type: "string", indexed: true },
|
|
472
|
-
status: { type: "string", enum: ["scheduled", "done"] },
|
|
473
|
-
rating: { type: "integer" }
|
|
471
|
+
held_on: { type: "string", indexed: true }, // queryable with operators
|
|
472
|
+
status: { type: "string", enum: ["scheduled", "done"], default: "scheduled" }, // explicit default — declare one to auto-fill
|
|
473
|
+
rating: { type: "integer" } // no default — stays unset until written
|
|
474
474
|
}
|
|
475
475
|
}
|
|
476
476
|
\`\`\`
|
|
@@ -490,16 +490,23 @@ A few behaviors worth knowing before you write at scale:
|
|
|
490
490
|
conflict error (re-read, reconcile, retry). For bulk/scripted writes where
|
|
491
491
|
concurrency is known-safe, pass \`force: true\` to waive the *requirement to
|
|
492
492
|
supply* it. \`append\`/\`prepend\`-only updates are exempt (no-conflict-by-design).
|
|
493
|
-
- **A schema field
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
- **Validation is advisory, never blocking
|
|
501
|
-
|
|
502
|
-
|
|
493
|
+
- **A schema field only back-fills when you declare an explicit \`default\`.**
|
|
494
|
+
When a note gets a tag whose schema declares a field with a \`default\`, an
|
|
495
|
+
unset field is filled with THAT value. A field with no \`default\` stays
|
|
496
|
+
genuinely absent — \`query-notes { metadata: { rating: { exists: false } } }\`
|
|
497
|
+
reliably finds notes that never set \`rating\`. Declare \`default\` on a field
|
|
498
|
+
only when "unset" and "explicitly set to X" should read the same; leave it
|
|
499
|
+
off when you need to tell them apart.
|
|
500
|
+
- **Validation is advisory, never blocking — EXCEPT an \`indexed: true\`
|
|
501
|
+
field's type.** A type/enum mismatch on a non-indexed field (or a
|
|
502
|
+
non-\`strict\` constraint on any field) comes back as a \`validation_status\`
|
|
503
|
+
warning and the write still lands — read those warnings and self-correct on
|
|
504
|
+
the next turn. An \`indexed: true\` field's TYPE is always enforced, though:
|
|
505
|
+
writing a string into an indexed \`integer\` field is REJECTED with a
|
|
506
|
+
\`schema_validation\` error, because a bad-typed value would otherwise
|
|
507
|
+
silently poison range queries (\`gt\`/\`gte\`/\`lt\`/\`lte\`) on that field. Mark a
|
|
508
|
+
field \`strict: true\` to enforce its OTHER constraints
|
|
509
|
+
(enum/required/cardinality) the same hard way.
|
|
503
510
|
|
|
504
511
|
(Full design guide, with copy-paste examples: https://parachute.computer/scripting/)
|
|
505
512
|
|
package/core/src/store.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type ConformanceReport,
|
|
36
36
|
} from "./conformance.js";
|
|
37
37
|
import type { SearchMode } from "./search-query.js";
|
|
38
|
+
import { runDoctorScan, type DoctorReport, type DoctorScanOpts } from "./doctor.js";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
@@ -457,10 +458,15 @@ export class BunSqliteStore implements Store {
|
|
|
457
458
|
return noteOps.listTags(this.db);
|
|
458
459
|
}
|
|
459
460
|
|
|
460
|
-
async deleteTag(name: string): Promise<
|
|
461
|
-
const result = noteOps.deleteTag(this.db, name);
|
|
461
|
+
async deleteTag(name: string, opts?: noteOps.DeleteTagOpts): Promise<noteOps.DeleteTagResult> {
|
|
462
|
+
const result = noteOps.deleteTag(this.db, name, opts);
|
|
463
|
+
// Referential-integrity refusal (vault#552) — nothing was written;
|
|
464
|
+
// caches and hooks are untouched.
|
|
465
|
+
if ("error" in result) return result;
|
|
462
466
|
// The deleted tag may have been a parent or child in the hierarchy
|
|
463
|
-
// and may have declared `fields` powering schema validation.
|
|
467
|
+
// and may have declared `fields` powering schema validation. A
|
|
468
|
+
// cascade/detach repair also rewrites OTHER tags' parent_names, so
|
|
469
|
+
// busting the hierarchy cache covers both cases.
|
|
464
470
|
this._tagHierarchy = null;
|
|
465
471
|
this._schemaConfig = null;
|
|
466
472
|
// Fire "deleted" only when SOMETHING happened (the underlying
|
|
@@ -644,6 +650,14 @@ export class BunSqliteStore implements Store {
|
|
|
644
650
|
return count;
|
|
645
651
|
}
|
|
646
652
|
|
|
653
|
+
/**
|
|
654
|
+
* Read-only taxonomy/metadata integrity scan (vault#552). Pure read — no
|
|
655
|
+
* cache invalidation needed since nothing is written.
|
|
656
|
+
*/
|
|
657
|
+
async doctor(opts?: DoctorScanOpts): Promise<DoctorReport> {
|
|
658
|
+
return runDoctorScan(this.db, opts);
|
|
659
|
+
}
|
|
660
|
+
|
|
647
661
|
// ---- Tag Records (post-v14: full identity row) ----
|
|
648
662
|
|
|
649
663
|
async listTagRecords() {
|
|
@@ -727,6 +741,24 @@ export class BunSqliteStore implements Store {
|
|
|
727
741
|
// Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
|
|
728
742
|
indexedFieldOps.validateFieldName(fieldName);
|
|
729
743
|
}
|
|
744
|
+
// Default-conformance pre-validate (vault#553 Decision B) — mirrors the
|
|
745
|
+
// indexed-type/name checks above: fail BEFORE any persistence so a bad
|
|
746
|
+
// `default` never gets written. Runs over EVERY field in the full
|
|
747
|
+
// (already-merged) `nextFields` map, not just indexed ones — a default
|
|
748
|
+
// that doesn't match its own field's type/enum is a tag-schema error
|
|
749
|
+
// regardless of queryability. This is the REST parity path: MCP's
|
|
750
|
+
// `update-tag` tool ALSO calls `collectTagFieldViolations` before
|
|
751
|
+
// reaching here (bundling every violation into one `TagFieldConflictError`
|
|
752
|
+
// — see that function's doc comment), so a conforming MCP call never
|
|
753
|
+
// trips this; a non-conforming one throws there first. REST has no such
|
|
754
|
+
// pre-check, so this is REST's only gate — same asymmetry as the
|
|
755
|
+
// indexed-type/name checks it sits beside.
|
|
756
|
+
for (const [fieldName, spec] of Object.entries(nextFields ?? {})) {
|
|
757
|
+
const violation = tagSchemaOps.validateFieldDefault(fieldName, spec);
|
|
758
|
+
if (violation) {
|
|
759
|
+
throw new tagSchemaOps.InvalidFieldDefaultError(fieldName, violation.message);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
730
762
|
}
|
|
731
763
|
|
|
732
764
|
// Persist the record + reconcile the indexed-field lifecycle atomically.
|
|
@@ -388,3 +388,69 @@ export function findHierarchyCycles(h: TagHierarchy): string[] {
|
|
|
388
388
|
}
|
|
389
389
|
return cycles;
|
|
390
390
|
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Cycle guard for a PROSPECTIVE `parent_names` write (vault#552). Setting
|
|
394
|
+
* `tag`'s parents to include `p` adds the edge `childrenOf[p] += tag` (tag
|
|
395
|
+
* becomes a child of `p`). That closes a cycle iff `tag` can ALREADY reach
|
|
396
|
+
* `p` via the existing hierarchy — i.e. `p` is already in `tag`'s descendant
|
|
397
|
+
* set — because the new edge would then let `p` reach back to itself through
|
|
398
|
+
* `tag`. Self-parent (`parentNames` containing `tag` itself) is caught by the
|
|
399
|
+
* same check: `getTagDescendants` always includes `tag` in its own result.
|
|
400
|
+
*
|
|
401
|
+
* Returns the offending cycle path (`[tag, ...existing chain down to p, p,
|
|
402
|
+
* tag]`, closing the loop) for the FIRST conflicting parent found, or `null`
|
|
403
|
+
* when no cycle would result. `h` must reflect the hierarchy BEFORE this
|
|
404
|
+
* write — the check only depends on OTHER tags' parent_names (edges pointing
|
|
405
|
+
* away from `tag`), never `tag`'s own current parent_names, so passing the
|
|
406
|
+
* pre-write hierarchy is always correct regardless of write ordering.
|
|
407
|
+
*/
|
|
408
|
+
export function findParentCycle(
|
|
409
|
+
h: TagHierarchy,
|
|
410
|
+
tag: string,
|
|
411
|
+
parentNames: string[],
|
|
412
|
+
): string[] | null {
|
|
413
|
+
const descendants = getTagDescendants(h, tag);
|
|
414
|
+
for (const p of parentNames) {
|
|
415
|
+
if (descendants.has(p)) {
|
|
416
|
+
return [...findChildPath(h, tag, p), tag];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* BFS shortest path from `from` to `to` following `childrenOf` edges
|
|
424
|
+
* (parent → child). Used only to render a human-legible cycle path for
|
|
425
|
+
* {@link findParentCycle}'s error — the existence of a path is already
|
|
426
|
+
* established by the caller (`to` is a known member of `from`'s descendant
|
|
427
|
+
* set) before this runs, so the fallback return is unreachable in practice
|
|
428
|
+
* and exists only to keep the function total.
|
|
429
|
+
*/
|
|
430
|
+
function findChildPath(h: TagHierarchy, from: string, to: string): string[] {
|
|
431
|
+
if (from === to) return [from];
|
|
432
|
+
const queue: string[] = [from];
|
|
433
|
+
const cameFrom = new Map<string, string>();
|
|
434
|
+
const visited = new Set<string>([from]);
|
|
435
|
+
while (queue.length > 0) {
|
|
436
|
+
const current = queue.shift()!;
|
|
437
|
+
const children = h.childrenOf.get(current);
|
|
438
|
+
if (!children) continue;
|
|
439
|
+
for (const child of children) {
|
|
440
|
+
if (visited.has(child)) continue;
|
|
441
|
+
visited.add(child);
|
|
442
|
+
cameFrom.set(child, current);
|
|
443
|
+
if (child === to) {
|
|
444
|
+
const path = [to];
|
|
445
|
+
let node = to;
|
|
446
|
+
while (cameFrom.has(node)) {
|
|
447
|
+
node = cameFrom.get(node)!;
|
|
448
|
+
path.unshift(node);
|
|
449
|
+
}
|
|
450
|
+
return path;
|
|
451
|
+
}
|
|
452
|
+
queue.push(child);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return [from, to];
|
|
456
|
+
}
|