@openparachute/vault 0.7.0-rc.5 → 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-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 +40 -23
- 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 +18 -0
- package/core/src/tag-schemas.ts +121 -6
- package/package.json +1 -1
- package/src/onboarding-seed.test.ts +5 -2
- package/src/routes.ts +18 -52
- package/src/vault.test.ts +33 -27
|
@@ -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
|
@@ -741,6 +741,24 @@ export class BunSqliteStore implements Store {
|
|
|
741
741
|
// Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
|
|
742
742
|
indexedFieldOps.validateFieldName(fieldName);
|
|
743
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
|
+
}
|
|
744
762
|
}
|
|
745
763
|
|
|
746
764
|
// Persist the record + reconcile the indexed-field lifecycle atomically.
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -44,6 +44,25 @@ export interface TagFieldSchema {
|
|
|
44
44
|
// "one" (scalar, default) or "many" (array). Advisory unless `strict:true`.
|
|
45
45
|
// Distinct from relationship cardinality. vault#299.
|
|
46
46
|
cardinality?: "one" | "many";
|
|
47
|
+
/**
|
|
48
|
+
* Explicit backfill value (vault#553 Decision B). When a note gains this
|
|
49
|
+
* tag and doesn't set the field, `applySchemaDefaults` (core/src/mcp.ts)
|
|
50
|
+
* writes THIS value onto the note. Undeclared (the default) means the
|
|
51
|
+
* field stays ABSENT on notes that don't set it — no more implicit
|
|
52
|
+
* first-enum-value / type-zero-value backfill. This is what makes
|
|
53
|
+
* `exists:false` trustworthy: "never set" and "explicitly set to the
|
|
54
|
+
* default" are now distinguishable states.
|
|
55
|
+
*
|
|
56
|
+
* Must conform to this field's own `type` (and `enum`, when declared) —
|
|
57
|
+
* `upsertTagRecord` (core/src/store.ts, the single chokepoint both REST
|
|
58
|
+
* and MCP funnel through) validates the default BEFORE persisting via
|
|
59
|
+
* {@link validateFieldDefault}; a non-conforming default is a tag-schema
|
|
60
|
+
* error (`invalid_field_default` / `TagFieldViolation` reason
|
|
61
|
+
* `"invalid_default"`), not a silent typo. `undefined` is JSON-serialized
|
|
62
|
+
* away by `JSON.stringify` — an omitted `default` key round-trips as "not
|
|
63
|
+
* declared", never as a stored `null`.
|
|
64
|
+
*/
|
|
65
|
+
default?: unknown;
|
|
47
66
|
}
|
|
48
67
|
|
|
49
68
|
/**
|
|
@@ -147,6 +166,86 @@ export function getTagRecord(db: Database, tag: string): TagRecord | null {
|
|
|
147
166
|
* for a tag-scoped caller (`scrubParentCycleError` in src/tag-scope.ts),
|
|
148
167
|
* same posture as `TagFieldConflictError`.
|
|
149
168
|
*/
|
|
169
|
+
/**
|
|
170
|
+
* Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint — REST
|
|
171
|
+
* `PUT /api/tags/:name` and MCP `update-tag` both funnel through it) when a
|
|
172
|
+
* field's declared `default` (vault#553 Decision B) doesn't conform to that
|
|
173
|
+
* SAME field's own `type` (or `enum`, when declared). A bad default is a
|
|
174
|
+
* tag-schema authoring error, not silently-accepted junk that would poison
|
|
175
|
+
* every note gaining the tag — fail closed, same posture as
|
|
176
|
+
* `IndexedFieldError` and `ParentCycleError`. `error_type` is stamped
|
|
177
|
+
* directly (own-field validation leaf, one caller-facing shape) so the
|
|
178
|
+
* generic MCP domain-error mapping (`src/mcp-http.ts`) and REST's dedicated
|
|
179
|
+
* catch branch both surface it without a bespoke class-specific mapping.
|
|
180
|
+
*/
|
|
181
|
+
export class InvalidFieldDefaultError extends Error {
|
|
182
|
+
code = "INVALID_FIELD_DEFAULT" as const;
|
|
183
|
+
error_type = "invalid_field_default" as const;
|
|
184
|
+
field: string;
|
|
185
|
+
|
|
186
|
+
constructor(field: string, message: string) {
|
|
187
|
+
super(message);
|
|
188
|
+
this.name = "InvalidFieldDefaultError";
|
|
189
|
+
this.field = field;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Validate that a field's declared `default` (vault#553 Decision B) conforms
|
|
195
|
+
* to its own `type` and (when declared) `enum`. Returns `null` when the
|
|
196
|
+
* field has no `default` (the common case) or the default is valid;
|
|
197
|
+
* otherwise a {@link TagFieldViolation} with `reason: "invalid_default"`.
|
|
198
|
+
* Pure — never throws; the two callers (the store chokepoint's fail-fast
|
|
199
|
+
* pre-validate, and {@link collectTagFieldViolations}'s bundled MCP report)
|
|
200
|
+
* each decide how to surface it.
|
|
201
|
+
*
|
|
202
|
+
* Deliberately independent of `schema-defaults.ts`'s `valueMatchesType` (this
|
|
203
|
+
* module doesn't import that one, and vice versa — no cycle either way) so
|
|
204
|
+
* the two modules' type vocabularies can drift without cross-coupling; the
|
|
205
|
+
* rules are kept in lockstep by hand (string/number/integer/boolean/array/
|
|
206
|
+
* object, the SAME six as `TagFieldSchema.type`'s documented vocabulary).
|
|
207
|
+
*/
|
|
208
|
+
export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFieldViolation | null {
|
|
209
|
+
if (spec.default === undefined) return null;
|
|
210
|
+
const value = spec.default;
|
|
211
|
+
const typeOk = defaultMatchesType(value, spec.type);
|
|
212
|
+
if (!typeOk) {
|
|
213
|
+
return {
|
|
214
|
+
field,
|
|
215
|
+
reason: "invalid_default",
|
|
216
|
+
message: `field "${field}" declares default ${JSON.stringify(value)}, which does not match its own type "${spec.type}"`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (spec.enum && spec.enum.length > 0 && typeof value === "string" && !spec.enum.includes(value)) {
|
|
220
|
+
return {
|
|
221
|
+
field,
|
|
222
|
+
reason: "invalid_default",
|
|
223
|
+
message: `field "${field}" declares default "${value}", which is not one of its own enum values [${spec.enum.join(", ")}]`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Same six-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
|
|
230
|
+
function defaultMatchesType(value: unknown, type: string): boolean {
|
|
231
|
+
switch (type) {
|
|
232
|
+
case "string":
|
|
233
|
+
return typeof value === "string";
|
|
234
|
+
case "number":
|
|
235
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
236
|
+
case "integer":
|
|
237
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
238
|
+
case "boolean":
|
|
239
|
+
return typeof value === "boolean";
|
|
240
|
+
case "array":
|
|
241
|
+
return Array.isArray(value);
|
|
242
|
+
case "object":
|
|
243
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
244
|
+
default:
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
150
249
|
export class ParentCycleError extends Error {
|
|
151
250
|
code = "PARENT_CYCLE" as const;
|
|
152
251
|
error_type = "parent_cycle" as const;
|
|
@@ -403,7 +502,12 @@ function jsonOrNull(value: unknown): string | null {
|
|
|
403
502
|
*/
|
|
404
503
|
export interface TagFieldViolation {
|
|
405
504
|
field: string;
|
|
406
|
-
reason:
|
|
505
|
+
reason:
|
|
506
|
+
| "type_conflict"
|
|
507
|
+
| "indexed_flag_conflict"
|
|
508
|
+
| "unsupported_indexed_type"
|
|
509
|
+
| "invalid_field_name"
|
|
510
|
+
| "invalid_default";
|
|
407
511
|
message: string;
|
|
408
512
|
/**
|
|
409
513
|
* The conflicting declarer tag — present on the cross-tag reasons
|
|
@@ -522,12 +626,17 @@ export function collectCrossTagFieldViolations(
|
|
|
522
626
|
/**
|
|
523
627
|
* Full field-violation collection: {@link collectCrossTagFieldViolations}
|
|
524
628
|
* PLUS the own-field checks (unsupported type for indexing, invalid
|
|
525
|
-
* identifier
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
629
|
+
* identifier, and — vault#553 Decision B — a non-conforming `default`).
|
|
630
|
+
* Used by the MCP `update-tag` tool, which — unlike REST — had no prior
|
|
631
|
+
* single-violation status-code contract to preserve for those checks (its
|
|
632
|
+
* old inline loop threw an unstructured `Error` for them, same as
|
|
633
|
+
* everything else pre-#554); collecting everything here is a strict
|
|
529
634
|
* improvement. See {@link collectCrossTagFieldViolations}'s doc comment for
|
|
530
|
-
* why REST calls that narrower function directly instead of this one
|
|
635
|
+
* why REST calls that narrower function directly instead of this one — REST
|
|
636
|
+
* gets the SAME `invalid_default` coverage via `store.upsertTagRecord`'s
|
|
637
|
+
* fail-fast pre-validate (mirrors its indexed-type/name checks), just as a
|
|
638
|
+
* single-violation `InvalidFieldDefaultError` → 400 rather than a bundled
|
|
639
|
+
* `TagFieldConflictError` → 422.
|
|
531
640
|
*/
|
|
532
641
|
export function collectTagFieldViolations(
|
|
533
642
|
db: Database,
|
|
@@ -537,6 +646,12 @@ export function collectTagFieldViolations(
|
|
|
537
646
|
const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
|
|
538
647
|
|
|
539
648
|
for (const [fieldName, spec] of Object.entries(incomingFields)) {
|
|
649
|
+
// Own-field default-conformance check (vault#553 Decision B) — applies
|
|
650
|
+
// to EVERY field (not just indexed ones); a bad default is a tag-schema
|
|
651
|
+
// error regardless of whether the field is queryable.
|
|
652
|
+
const defaultViolation = validateFieldDefault(fieldName, spec);
|
|
653
|
+
if (defaultViolation) violations.push(defaultViolation);
|
|
654
|
+
|
|
540
655
|
if (spec.indexed === true) {
|
|
541
656
|
const mapped = mapFieldType(spec.type);
|
|
542
657
|
if (!mapped) {
|
package/package.json
CHANGED
|
@@ -166,8 +166,11 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
166
166
|
expect(gs!.content).toContain("## Write gotchas");
|
|
167
167
|
expect(gs!.content).toContain("if_updated_at"); // optimistic concurrency
|
|
168
168
|
expect(gs!.content).toContain("force: true");
|
|
169
|
-
// schema-default back-fill
|
|
170
|
-
expect(gs!.content).toContain("
|
|
169
|
+
// schema-default back-fill is explicit-`default`-only (vault#553 Decision B).
|
|
170
|
+
expect(gs!.content).toContain("explicit `default`");
|
|
171
|
+
expect(gs!.content).toContain("exists: false");
|
|
172
|
+
// indexed⇒strict type enforcement (vault#553 Decision A).
|
|
173
|
+
expect(gs!.content).toContain("field's TYPE is always enforced");
|
|
171
174
|
});
|
|
172
175
|
|
|
173
176
|
test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
|
package/src/routes.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
contentRangeRequiresContent,
|
|
25
25
|
type ContentRange,
|
|
26
26
|
} from "../core/src/content-range.ts";
|
|
27
|
-
import { attachValidationStatus, enforceStrictWrite } from "../core/src/mcp.ts";
|
|
27
|
+
import { attachValidationStatus, enforceStrictWrite, applySchemaDefaults } from "../core/src/mcp.ts";
|
|
28
28
|
import type { ValidationWarning } from "../core/src/schema-defaults.ts";
|
|
29
29
|
import { logStrictBypass } from "./scopes.ts";
|
|
30
30
|
import * as linkOps from "../core/src/links.ts";
|
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
tagScopeForbidden,
|
|
49
49
|
tagsWithinScope,
|
|
50
50
|
} from "./tag-scope.ts";
|
|
51
|
-
import { ParentCycleError } from "../core/src/tag-schemas.ts";
|
|
51
|
+
import { ParentCycleError, InvalidFieldDefaultError } from "../core/src/tag-schemas.ts";
|
|
52
52
|
import { findTokensReferencingTag } from "./token-store.ts";
|
|
53
53
|
|
|
54
54
|
/**
|
|
@@ -2668,6 +2668,18 @@ export async function handleTags(
|
|
|
2668
2668
|
400,
|
|
2669
2669
|
);
|
|
2670
2670
|
}
|
|
2671
|
+
if (err instanceof InvalidFieldDefaultError) {
|
|
2672
|
+
// vault#553 Decision B — a declared `default` doesn't conform to its
|
|
2673
|
+
// own field's type/enum. Own-field error (no cross-tag data), so no
|
|
2674
|
+
// scrub needed. Mirrors IndexedFieldError's 400 shape/posture — see
|
|
2675
|
+
// store.upsertTagRecord's pre-validate comment for why REST hits
|
|
2676
|
+
// this (fail-fast, single violation) while MCP's update-tag usually
|
|
2677
|
+
// reports it bundled via `TagFieldConflictError` instead.
|
|
2678
|
+
return json(
|
|
2679
|
+
{ error: err.message, error_type: "invalid_field_default", field: err.field },
|
|
2680
|
+
400,
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2671
2683
|
if (err instanceof ParentCycleError) {
|
|
2672
2684
|
// vault#552: parent_names would close a cycle. Nothing was
|
|
2673
2685
|
// persisted. Scope-scrub the cycle path for a tag-scoped caller —
|
|
@@ -3735,56 +3747,10 @@ export async function handleStorage(
|
|
|
3735
3747
|
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3736
3748
|
}
|
|
3737
3749
|
|
|
3738
|
-
//
|
|
3739
|
-
//
|
|
3740
|
-
//
|
|
3741
|
-
|
|
3742
|
-
// Returns the IDs of notes whose metadata was actually default-filled, so
|
|
3743
|
-
// the caller can re-read ONLY the mutated notes (and skip the re-read when
|
|
3744
|
-
// nothing changed). Mirrors the core/src/mcp.ts contract.
|
|
3745
|
-
async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
3746
|
-
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
3747
|
-
if (Object.keys(schemas).length === 0) return [];
|
|
3748
|
-
|
|
3749
|
-
const defaults: Record<string, unknown> = {};
|
|
3750
|
-
for (const tag of tags) {
|
|
3751
|
-
const schema = schemas[tag];
|
|
3752
|
-
if (!schema?.fields) continue;
|
|
3753
|
-
for (const [field, fieldSchema] of Object.entries(schema.fields)) {
|
|
3754
|
-
if (!(field in defaults)) {
|
|
3755
|
-
defaults[field] = defaultForField(fieldSchema);
|
|
3756
|
-
}
|
|
3757
|
-
}
|
|
3758
|
-
}
|
|
3759
|
-
if (Object.keys(defaults).length === 0) return [];
|
|
3760
|
-
|
|
3761
|
-
const mutated: string[] = [];
|
|
3762
|
-
for (const noteId of noteIds) {
|
|
3763
|
-
const note = await store.getNote(noteId);
|
|
3764
|
-
if (!note) continue;
|
|
3765
|
-
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
3766
|
-
const missing: Record<string, unknown> = {};
|
|
3767
|
-
for (const [field, value] of Object.entries(defaults)) {
|
|
3768
|
-
if (!(field in existing)) missing[field] = value;
|
|
3769
|
-
}
|
|
3770
|
-
if (Object.keys(missing).length === 0) continue;
|
|
3771
|
-
await store.updateNote(noteId, {
|
|
3772
|
-
metadata: { ...existing, ...missing },
|
|
3773
|
-
skipUpdatedAt: true,
|
|
3774
|
-
});
|
|
3775
|
-
mutated.push(noteId);
|
|
3776
|
-
}
|
|
3777
|
-
return mutated;
|
|
3778
|
-
}
|
|
3779
|
-
|
|
3780
|
-
function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
|
3781
|
-
if (field.enum && field.enum.length > 0) return field.enum[0];
|
|
3782
|
-
switch (field.type) {
|
|
3783
|
-
case "boolean": return false;
|
|
3784
|
-
case "integer": return 0;
|
|
3785
|
-
default: return "";
|
|
3786
|
-
}
|
|
3787
|
-
}
|
|
3750
|
+
// applySchemaDefaults lives in core/src/mcp.ts (vault#553) — REST used to
|
|
3751
|
+
// carry a byte-identical duplicate; importing it means REST and MCP can
|
|
3752
|
+
// never drift on this behavior again, and the cloud runtime (which imports
|
|
3753
|
+
// core directly) inherits it without any handler-side code.
|
|
3788
3754
|
|
|
3789
3755
|
function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
3790
3756
|
const escaped = targetPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|