@openparachute/vault 0.7.0-rc.7 → 0.7.0-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/core.test.ts +413 -0
- package/core/src/mcp.ts +168 -27
- package/core/src/notes.ts +12 -4
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +186 -21
- package/package.json +1 -1
- package/src/contract-errors.test.ts +73 -0
- package/src/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-tools.ts +11 -0
- package/src/routes.ts +168 -25
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +291 -4
package/core/src/mcp.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./query-warnings.js";
|
|
16
16
|
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
|
|
17
17
|
import * as linkOps from "./links.js";
|
|
18
|
+
import { resolveOrQueueLink, resolveStructuredLinkNote } from "./wikilinks.js";
|
|
18
19
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
19
20
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
20
21
|
import {
|
|
@@ -263,6 +264,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
263
264
|
|
|
264
265
|
Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
|
|
265
266
|
|
|
267
|
+
Each result carries \`validation_status\` when any tag it carries declares \`fields\` (vault#555) — same advisory-warnings shape create-note/update-note attach, now also on reads (an out-of-enum value on a non-strict field is stored and findable, but still surfaces its \`enum_mismatch\` warning here, not just on the write that introduced it). Absent entirely when no tag on the note declares a schema.
|
|
268
|
+
|
|
266
269
|
Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bounded read of note content — the response carries the slice plus \`content_total_length\` and \`content_next_offset\` (null when complete). Loop, feeding \`content_next_offset\` back as \`content_offset\`, to read a note too large for one response.
|
|
267
270
|
|
|
268
271
|
Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.
|
|
@@ -458,6 +461,24 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
458
461
|
// `expand`).
|
|
459
462
|
if (contentRange && !includeContent) throw contentRangeRequiresContent();
|
|
460
463
|
let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
|
|
464
|
+
// --- Attach validation_status (vault#555 fix 3) ---
|
|
465
|
+
// Mirrors create/update-note's `attachValidationStatus` — additive,
|
|
466
|
+
// present only when at least one tag on the note declares
|
|
467
|
+
// `fields`. Before this, `validation_status` (including an
|
|
468
|
+
// `enum_mismatch` on a non-strict indexed field) was surfaced ONLY
|
|
469
|
+
// on the one-time create/update WRITE response; a caller reading
|
|
470
|
+
// the note back via `query-notes` (the natural way to re-check a
|
|
471
|
+
// value after write) saw nothing at all — contradicting the
|
|
472
|
+
// documented "advisory violations surface as warnings" for every
|
|
473
|
+
// read after the initial write.
|
|
474
|
+
{
|
|
475
|
+
const status = store.validateNoteAgainstSchemas({
|
|
476
|
+
path: note.path,
|
|
477
|
+
tags: note.tags,
|
|
478
|
+
metadata: note.metadata as Record<string, unknown> | undefined,
|
|
479
|
+
});
|
|
480
|
+
if (status) result.validation_status = status;
|
|
481
|
+
}
|
|
461
482
|
if (expandCtx && includeContent && typeof result.content === "string") {
|
|
462
483
|
// Mark the top-level note as already expanded so it can't recursively inline itself.
|
|
463
484
|
expandCtx.expanded.add(note.id);
|
|
@@ -738,6 +759,29 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
738
759
|
for (const n of output) applyContentRange(n, contentRange);
|
|
739
760
|
}
|
|
740
761
|
|
|
762
|
+
// --- Attach validation_status (vault#555 fix 3) ---
|
|
763
|
+
// Same reasoning as the single-note path above — additive, present
|
|
764
|
+
// only when at least one tag on the note declares `fields`. Runs
|
|
765
|
+
// BEFORE metadata filtering so it sees the note's full metadata
|
|
766
|
+
// regardless of `include_metadata`; the `validation_status` key
|
|
767
|
+
// itself survives `filterMetadata` (which only touches `metadata`).
|
|
768
|
+
{
|
|
769
|
+
const statusById = new Map(
|
|
770
|
+
results.map((n) => [
|
|
771
|
+
n.id,
|
|
772
|
+
store.validateNoteAgainstSchemas({
|
|
773
|
+
path: n.path,
|
|
774
|
+
tags: n.tags,
|
|
775
|
+
metadata: n.metadata as Record<string, unknown> | undefined,
|
|
776
|
+
}),
|
|
777
|
+
]),
|
|
778
|
+
);
|
|
779
|
+
for (const n of output as any[]) {
|
|
780
|
+
const status = statusById.get(n.id);
|
|
781
|
+
if (status) n.validation_status = status;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
741
785
|
// --- Apply metadata filtering ---
|
|
742
786
|
if (includeMetadata !== undefined && includeMetadata !== true) {
|
|
743
787
|
output = output.map((n: any) => filterMetadata(n, includeMetadata));
|
|
@@ -826,7 +870,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
826
870
|
},
|
|
827
871
|
required: ["target", "relationship"],
|
|
828
872
|
},
|
|
829
|
-
description: "Links to create from this note",
|
|
873
|
+
description: "Links to create from this note. `target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title. A target created LATER in the same `notes` batch, or by a future call, resolves automatically (queued + backfilled) — the response carries an `unresolved_link` warning naming the target in the meantime; never silently dropped.",
|
|
830
874
|
},
|
|
831
875
|
created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
|
|
832
876
|
// Batch
|
|
@@ -858,6 +902,20 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
858
902
|
}
|
|
859
903
|
|
|
860
904
|
const created: Note[] = [];
|
|
905
|
+
// Structured `links` are resolved in a SECOND pass, after every
|
|
906
|
+
// note in this batch has been created (vault#555) — resolving
|
|
907
|
+
// inline (the old behavior) meant a link from item 0 to item 1's
|
|
908
|
+
// path silently dropped, since item 1 didn't exist yet at the
|
|
909
|
+
// moment item 0's links were processed. Deferring makes
|
|
910
|
+
// within-batch forward-refs resolve exactly like a same-content
|
|
911
|
+
// [[wikilink]] already does. `pendingLinks` carries the SOURCE
|
|
912
|
+
// note id (known immediately — createNote assigns it synchronously)
|
|
913
|
+
// alongside each item's raw `links` array.
|
|
914
|
+
const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
|
|
915
|
+
// Per-note `unresolved_link` warnings (vault#555 — "never silent").
|
|
916
|
+
// Populated in the second pass; folded into each note's response
|
|
917
|
+
// below, same pattern as `validation_status`.
|
|
918
|
+
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
861
919
|
// Wrap multi-item batches in a SQLite transaction so a mid-batch
|
|
862
920
|
// failure rolls back every prior insert — see #236. This guards
|
|
863
921
|
// anything thrown from store.createNote / createLink (path
|
|
@@ -892,18 +950,37 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
892
950
|
via: writeVia,
|
|
893
951
|
});
|
|
894
952
|
|
|
895
|
-
// Create explicit links (not wikilinks — those are automatic)
|
|
896
953
|
if (item.links) {
|
|
897
|
-
|
|
898
|
-
const target = resolveNote(db, link.target);
|
|
899
|
-
if (target) {
|
|
900
|
-
await store.createLink(note.id, target.id, link.relationship);
|
|
901
|
-
}
|
|
902
|
-
}
|
|
954
|
+
pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
|
|
903
955
|
}
|
|
904
956
|
|
|
905
957
|
created.push(noteOps.getNote(db, note.id) ?? note);
|
|
906
958
|
}
|
|
959
|
+
|
|
960
|
+
// --- Resolve structured links (vault#555) ---
|
|
961
|
+
// Same semantics as [[wikilinks]]: ID or path/title match, tried
|
|
962
|
+
// now that every sibling note in this batch exists. A target that
|
|
963
|
+
// STILL doesn't resolve (typo, or a note that arrives in a later
|
|
964
|
+
// call) is queued for lazy resolution — it backfills automatically
|
|
965
|
+
// the moment a matching note is created — and surfaces an
|
|
966
|
+
// `unresolved_link` warning naming the target. Never silent.
|
|
967
|
+
for (const { sourceId, links } of pendingLinks) {
|
|
968
|
+
for (const link of links) {
|
|
969
|
+
const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
970
|
+
if (targetId) {
|
|
971
|
+
await store.createLink(sourceId, targetId, link.relationship);
|
|
972
|
+
} else {
|
|
973
|
+
const list = linkWarningsByNote.get(sourceId) ?? [];
|
|
974
|
+
list.push({
|
|
975
|
+
code: "unresolved_link",
|
|
976
|
+
message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
977
|
+
target: link.target,
|
|
978
|
+
relationship: link.relationship,
|
|
979
|
+
});
|
|
980
|
+
linkWarningsByNote.set(sourceId, list);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
907
984
|
};
|
|
908
985
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
909
986
|
|
|
@@ -934,8 +1011,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
934
1011
|
})();
|
|
935
1012
|
|
|
936
1013
|
// Attach `validation_status` from any tag's `fields` declaration that
|
|
937
|
-
// applies to this note, against the post-defaults state.
|
|
938
|
-
|
|
1014
|
+
// applies to this note, against the post-defaults state. Fold in any
|
|
1015
|
+
// `unresolved_link` warnings collected above (vault#555) — additive,
|
|
1016
|
+
// present only when this note's `links` had a target that didn't
|
|
1017
|
+
// resolve.
|
|
1018
|
+
const final = refreshed.map((n) => {
|
|
1019
|
+
const validated = attachValidationStatus(store, db, n);
|
|
1020
|
+
const warnings = linkWarningsByNote.get(n.id);
|
|
1021
|
+
return warnings && warnings.length > 0
|
|
1022
|
+
? ({ ...validated, warnings } as Note & { warnings: QueryWarning[] })
|
|
1023
|
+
: validated;
|
|
1024
|
+
});
|
|
939
1025
|
return batch ? final : final[0];
|
|
940
1026
|
},
|
|
941
1027
|
},
|
|
@@ -1028,7 +1114,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1028
1114
|
},
|
|
1029
1115
|
},
|
|
1030
1116
|
},
|
|
1031
|
-
description: "Links to add/remove",
|
|
1117
|
+
description: "Links to add/remove. `add[].target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title — and lazily backfills (queued) when the target arrives later; the response carries an `unresolved_link` warning naming the target in the meantime, never a silent drop.",
|
|
1032
1118
|
},
|
|
1033
1119
|
include_content: {
|
|
1034
1120
|
type: "boolean",
|
|
@@ -1122,6 +1208,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1122
1208
|
// key this so the create-on-missing branch, which assigns the id
|
|
1123
1209
|
// late, can register correctly.
|
|
1124
1210
|
const echoLinkIds = new Set<string>();
|
|
1211
|
+
// Structured `links.add` entries, deferred to a second pass AFTER
|
|
1212
|
+
// every item in this batch has been created/updated (vault#555) —
|
|
1213
|
+
// resolving inline would silently drop a link from item 0 to a note
|
|
1214
|
+
// item 1 (later in the same batch) creates via `if_missing:
|
|
1215
|
+
// "create"`. Same fix as create-note's `pendingLinks`.
|
|
1216
|
+
const pendingLinks: { sourceId: string; links: { target: string; relationship: string; metadata?: Record<string, unknown> }[] }[] = [];
|
|
1217
|
+
// Per-note `unresolved_link` warnings (vault#555 — "never silent").
|
|
1218
|
+
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
1125
1219
|
// Wrap multi-item batches in a SQLite transaction so any mid-batch
|
|
1126
1220
|
// failure (precondition error, content_edit miss, ConflictError, …)
|
|
1127
1221
|
// rolls back every prior mutation in the batch — see #236.
|
|
@@ -1212,13 +1306,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1212
1306
|
});
|
|
1213
1307
|
const created = await store.createNote(content, createOpts);
|
|
1214
1308
|
await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
|
|
1215
|
-
//
|
|
1309
|
+
// Defer links.add resolution to the second pass (vault#555)
|
|
1310
|
+
// — same reasoning as create-note.
|
|
1216
1311
|
const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
1217
1312
|
if (linksAdd) {
|
|
1218
|
-
|
|
1219
|
-
const target = resolveNote(db, link.target);
|
|
1220
|
-
if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
|
|
1221
|
-
}
|
|
1313
|
+
pendingLinks.push({ sourceId: created.id, links: linksAdd });
|
|
1222
1314
|
}
|
|
1223
1315
|
const fresh = noteOps.getNote(db, created.id) ?? created;
|
|
1224
1316
|
updated.push(fresh);
|
|
@@ -1326,7 +1418,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1326
1418
|
const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
|
|
1327
1419
|
if (linksRemove) {
|
|
1328
1420
|
for (const link of linksRemove) {
|
|
1329
|
-
const target =
|
|
1421
|
+
const target = resolveStructuredLinkNote(db, link.target);
|
|
1330
1422
|
if (!target) continue;
|
|
1331
1423
|
resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
|
|
1332
1424
|
if (link.relationship === "wikilink" && target.path) {
|
|
@@ -1400,8 +1492,25 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1400
1492
|
enforceStrict({ path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
1401
1493
|
}
|
|
1402
1494
|
|
|
1495
|
+
// vault#555 fix 2 — tag and link mutations must bump `updated_at`
|
|
1496
|
+
// too, not just core-field (content/path/metadata) changes. Before
|
|
1497
|
+
// this, a tags-only or links-only call with `force: true` (no
|
|
1498
|
+
// `if_updated_at`) left `updates` completely empty — the branch
|
|
1499
|
+
// below skipped `store.updateNote` ENTIRELY, so `updated_at` never
|
|
1500
|
+
// moved even though tags/note_tags or links genuinely changed.
|
|
1501
|
+
// (A tags-only call that happened to pass `if_updated_at` already
|
|
1502
|
+
// worked by accident — it populated `updates.if_updated_at` — which
|
|
1503
|
+
// is why this was easy to miss in ad hoc testing.) This breaks
|
|
1504
|
+
// cursor polling (`ORDER BY updated_at`) and any `updated_at`-based
|
|
1505
|
+
// sync filter: the mutation is real but invisible to a
|
|
1506
|
+
// since-last-check loop.
|
|
1507
|
+
const hasTagMutation = ((item.tags as any)?.add?.length ?? 0) > 0
|
|
1508
|
+
|| ((item.tags as any)?.remove?.length ?? 0) > 0;
|
|
1509
|
+
const hasLinkMutation = (item.links as any)?.add !== undefined
|
|
1510
|
+
|| (item.links as any)?.remove !== undefined;
|
|
1511
|
+
|
|
1403
1512
|
let result: Note;
|
|
1404
|
-
if (Object.keys(updates).length > 0) {
|
|
1513
|
+
if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
|
|
1405
1514
|
// Write-attribution (vault#298): stamp the most-recent-write
|
|
1406
1515
|
// columns on the same UPDATE that bumps `updated_at`. Only set when
|
|
1407
1516
|
// there's a real change to write (the empty-updates branch below
|
|
@@ -1412,7 +1521,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1412
1521
|
// store.updateNote routes through noteOps.updateNote, which runs
|
|
1413
1522
|
// the UPDATE (with optional `AND updated_at IS ?`) atomically and
|
|
1414
1523
|
// throws ConflictError on mismatch. No mutations have happened
|
|
1415
|
-
// yet, so a throw here leaves the note untouched.
|
|
1524
|
+
// yet, so a throw here leaves the note untouched. `updates` may
|
|
1525
|
+
// carry no core fields at all (a pure tag/link mutation) — that's
|
|
1526
|
+
// fine: `noteOps.updateNote` unconditionally SETs
|
|
1527
|
+
// `updated_at`/`last_updated_by`/`last_updated_via` whenever
|
|
1528
|
+
// `skipUpdatedAt` isn't set, so this still issues a real UPDATE
|
|
1529
|
+
// (not the true no-op/precondition-only branch, which only fires
|
|
1530
|
+
// when the caller passes ZERO fields AND no mutation is pending).
|
|
1416
1531
|
result = await store.updateNote(note.id, updates);
|
|
1417
1532
|
} else {
|
|
1418
1533
|
result = note;
|
|
@@ -1433,15 +1548,10 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1433
1548
|
await store.untagNote(note.id, tagsOp.remove);
|
|
1434
1549
|
}
|
|
1435
1550
|
|
|
1436
|
-
// --- Add links ---
|
|
1551
|
+
// --- Add links (deferred to the second pass — vault#555) ---
|
|
1437
1552
|
const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
1438
1553
|
if (linksAdd) {
|
|
1439
|
-
|
|
1440
|
-
const target = resolveNote(db, link.target);
|
|
1441
|
-
if (target) {
|
|
1442
|
-
await store.createLink(note.id, target.id, link.relationship, link.metadata);
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1554
|
+
pendingLinks.push({ sourceId: note.id, links: linksAdd });
|
|
1445
1555
|
}
|
|
1446
1556
|
|
|
1447
1557
|
// Echo links if this update mutated them (`links.add`/`links.remove`)
|
|
@@ -1454,6 +1564,32 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1454
1564
|
// Re-read for final state
|
|
1455
1565
|
updated.push(noteOps.getNote(db, note.id) ?? result);
|
|
1456
1566
|
}
|
|
1567
|
+
|
|
1568
|
+
// --- Resolve structured `links.add` (vault#555) ---
|
|
1569
|
+
// Deferred until every item in this batch has been created/
|
|
1570
|
+
// updated — same [[wikilink]] semantics (ID or path/title match),
|
|
1571
|
+
// now tried against the FULL post-batch note set so a forward-ref
|
|
1572
|
+
// to a sibling item resolves. A target that still doesn't resolve
|
|
1573
|
+
// is queued for lazy resolution (backfills when a matching note
|
|
1574
|
+
// is created later) and surfaces an `unresolved_link` warning —
|
|
1575
|
+
// never silently dropped.
|
|
1576
|
+
for (const { sourceId, links } of pendingLinks) {
|
|
1577
|
+
for (const link of links) {
|
|
1578
|
+
const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
1579
|
+
if (targetId) {
|
|
1580
|
+
await store.createLink(sourceId, targetId, link.relationship, link.metadata);
|
|
1581
|
+
} else {
|
|
1582
|
+
const list = linkWarningsByNote.get(sourceId) ?? [];
|
|
1583
|
+
list.push({
|
|
1584
|
+
code: "unresolved_link",
|
|
1585
|
+
message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
1586
|
+
target: link.target,
|
|
1587
|
+
relationship: link.relationship,
|
|
1588
|
+
});
|
|
1589
|
+
linkWarningsByNote.set(sourceId, list);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1457
1593
|
};
|
|
1458
1594
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
1459
1595
|
|
|
@@ -1475,9 +1611,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1475
1611
|
// when triggered — mirrors the GET / query-notes shape exactly via
|
|
1476
1612
|
// the shared `linkOps.getLinksHydrated` call. vault feedback #8.
|
|
1477
1613
|
const echoLinks = echoLinkIds.has(n.id);
|
|
1614
|
+
// `unresolved_link` warnings (vault#555) — additive, present only
|
|
1615
|
+
// when this note's `links.add` had a target that didn't resolve.
|
|
1616
|
+
const warnings = linkWarningsByNote.get(n.id);
|
|
1478
1617
|
if (includeContent) {
|
|
1479
1618
|
const full: any = { ...validated, created };
|
|
1480
1619
|
if (echoLinks) full.links = linkOps.getLinksHydrated(db, n.id);
|
|
1620
|
+
if (warnings && warnings.length > 0) full.warnings = warnings;
|
|
1481
1621
|
return full as Note & { created: boolean };
|
|
1482
1622
|
}
|
|
1483
1623
|
const lean: any = noteOps.toNoteIndex(validated);
|
|
@@ -1487,6 +1627,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1487
1627
|
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
1488
1628
|
// drops unknown fields.
|
|
1489
1629
|
if (echoLinks) lean.links = linkOps.getLinksHydrated(db, n.id);
|
|
1630
|
+
if (warnings && warnings.length > 0) lean.warnings = warnings;
|
|
1490
1631
|
return lean;
|
|
1491
1632
|
});
|
|
1492
1633
|
return batch ? final : final[0];
|
|
@@ -1615,7 +1756,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1615
1756
|
additionalProperties: {
|
|
1616
1757
|
type: "object",
|
|
1617
1758
|
properties: {
|
|
1618
|
-
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation. Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
|
|
1759
|
+
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
|
|
1619
1760
|
description: { type: "string" },
|
|
1620
1761
|
enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
|
|
1621
1762
|
default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
|
package/core/src/notes.ts
CHANGED
|
@@ -1926,11 +1926,17 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1926
1926
|
const candidates = db
|
|
1927
1927
|
.prepare(`SELECT id, content FROM notes WHERE content IS NOT NULL AND content != '' AND (${orClauses})`)
|
|
1928
1928
|
.all(...params) as { id: string; content: string }[];
|
|
1929
|
-
|
|
1929
|
+
// vault#555 fix 2 — this content rewrite is a real persisted-state
|
|
1930
|
+
// change (the note's `#oldtag` references literally became
|
|
1931
|
+
// `#newtag`) and must bump `updated_at` like any other content
|
|
1932
|
+
// write, or a cursor/sync-poll loop never sees it. Shares the one
|
|
1933
|
+
// `now` timestamp for the whole cascade (same convention as the
|
|
1934
|
+
// tag-row rename pass above).
|
|
1935
|
+
const updateStmt = db.prepare("UPDATE notes SET content = ?, updated_at = ? WHERE id = ?");
|
|
1930
1936
|
for (const row of candidates) {
|
|
1931
1937
|
const next = rewriteNoteBody(row.content, renames);
|
|
1932
1938
|
if (next === row.content) continue;
|
|
1933
|
-
updateStmt.run(next, row.id);
|
|
1939
|
+
updateStmt.run(next, now, row.id);
|
|
1934
1940
|
notesRewritten++;
|
|
1935
1941
|
}
|
|
1936
1942
|
}
|
|
@@ -1945,11 +1951,13 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1945
1951
|
const candidates = db
|
|
1946
1952
|
.prepare(`SELECT id, path FROM notes WHERE path IS NOT NULL AND (${orClauses})`)
|
|
1947
1953
|
.all(...params) as { id: string; path: string }[];
|
|
1948
|
-
|
|
1954
|
+
// vault#555 fix 2 — same reasoning as the content rewrite above: a
|
|
1955
|
+
// path rewrite is a real persisted-state change.
|
|
1956
|
+
const updateStmt = db.prepare("UPDATE notes SET path = ?, updated_at = ? WHERE id = ?");
|
|
1949
1957
|
for (const row of candidates) {
|
|
1950
1958
|
const next = rewriteTagConfigPath(row.path, renames);
|
|
1951
1959
|
if (next === row.path) continue;
|
|
1952
|
-
updateStmt.run(next, row.id);
|
|
1960
|
+
updateStmt.run(next, now, row.id);
|
|
1953
1961
|
pathsRenamed++;
|
|
1954
1962
|
}
|
|
1955
1963
|
}
|
package/core/src/store.ts
CHANGED
|
@@ -741,22 +741,29 @@ 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
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// regardless of queryability. This is
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
// pre-
|
|
755
|
-
//
|
|
744
|
+
// Default-conformance + type-vocabulary pre-validate (vault#553
|
|
745
|
+
// Decision B; vault#555 fix 4/5) — mirrors the indexed-type/name
|
|
746
|
+
// checks above: fail BEFORE any persistence so a bad `default` or an
|
|
747
|
+
// unrecognized `type` never gets written. Runs over EVERY field in the
|
|
748
|
+
// full (already-merged) `nextFields` map, not just indexed ones — both
|
|
749
|
+
// are tag-schema errors regardless of queryability. This is a
|
|
750
|
+
// DEFENSE-IN-DEPTH backstop, not the primary user-facing gate: both
|
|
751
|
+
// REST's `PUT /api/tags/:name` (`collectCrossTagFieldViolations` +
|
|
752
|
+
// `collectOwnFieldDefaultAndTypeViolations`, bundled 422) and MCP's
|
|
753
|
+
// `update-tag` (`collectTagFieldViolations`, same bundle) now
|
|
754
|
+
// pre-validate every field and report ALL violations together BEFORE
|
|
755
|
+
// ever reaching this chokepoint — a conforming call never trips this
|
|
756
|
+
// fail-fast loop. It stays here so any OTHER caller of
|
|
757
|
+
// `store.upsertTagRecord` (imports, migrations, scripts) still fails
|
|
758
|
+
// closed rather than persisting a lying schema.
|
|
756
759
|
for (const [fieldName, spec] of Object.entries(nextFields ?? {})) {
|
|
757
|
-
const
|
|
758
|
-
if (
|
|
759
|
-
throw new tagSchemaOps.
|
|
760
|
+
const typeViolation = tagSchemaOps.validateFieldType(fieldName, spec);
|
|
761
|
+
if (typeViolation) {
|
|
762
|
+
throw new tagSchemaOps.InvalidFieldTypeError(fieldName, spec.type);
|
|
763
|
+
}
|
|
764
|
+
const defaultViolation = tagSchemaOps.validateFieldDefault(fieldName, spec);
|
|
765
|
+
if (defaultViolation) {
|
|
766
|
+
throw new tagSchemaOps.InvalidFieldDefaultError(fieldName, defaultViolation.message);
|
|
760
767
|
}
|
|
761
768
|
}
|
|
762
769
|
}
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -190,6 +190,33 @@ export class InvalidFieldDefaultError extends Error {
|
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint) when a
|
|
195
|
+
* field's declared `type` isn't one of the six recognized values (vault#555
|
|
196
|
+
* — `update-tag{fields:{weird:{type:"frobnicator"}}}` used to be accepted
|
|
197
|
+
* and persisted verbatim, no error, for any NON-indexed field: the only
|
|
198
|
+
* existing type check, `mapFieldType`, ran solely on `indexed: true` fields
|
|
199
|
+
* — see {@link validateFieldType}'s doc comment). Own-field validation leaf,
|
|
200
|
+
* same posture as `InvalidFieldDefaultError`.
|
|
201
|
+
*/
|
|
202
|
+
export class InvalidFieldTypeError extends Error {
|
|
203
|
+
code = "INVALID_FIELD_TYPE" as const;
|
|
204
|
+
error_type = "invalid_field_type" as const;
|
|
205
|
+
field: string;
|
|
206
|
+
type: string;
|
|
207
|
+
valid_types: readonly string[];
|
|
208
|
+
|
|
209
|
+
constructor(field: string, type: string) {
|
|
210
|
+
super(
|
|
211
|
+
`field "${field}" declares unknown type "${type}" — must be one of [${VALID_FIELD_TYPES.join(", ")}]`,
|
|
212
|
+
);
|
|
213
|
+
this.name = "InvalidFieldTypeError";
|
|
214
|
+
this.field = field;
|
|
215
|
+
this.type = type;
|
|
216
|
+
this.valid_types = VALID_FIELD_TYPES;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
193
220
|
/**
|
|
194
221
|
* Validate that a field's declared `default` (vault#553 Decision B) conforms
|
|
195
222
|
* to its own `type` and (when declared) `enum`. Returns `null` when the
|
|
@@ -226,6 +253,73 @@ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFi
|
|
|
226
253
|
return null;
|
|
227
254
|
}
|
|
228
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The full recognized vocabulary for `TagFieldSchema.type` — storage/
|
|
258
|
+
* advisory validation accepts all six; only `string`/`integer`/`boolean`
|
|
259
|
+
* are INDEXABLE (that narrower subset is `indexed-fields.ts`'s `TYPE_MAP`,
|
|
260
|
+
* enforced separately via `mapFieldType` for `indexed: true` fields).
|
|
261
|
+
* Matches `defaultMatchesType`'s switch and `schema-defaults.ts`'s
|
|
262
|
+
* `SchemaField.type` union — kept in lockstep by hand across the two
|
|
263
|
+
* deliberately-decoupled modules (see `validateFieldDefault`'s doc comment
|
|
264
|
+
* for why they don't cross-import).
|
|
265
|
+
*/
|
|
266
|
+
export const VALID_FIELD_TYPES = ["string", "number", "integer", "boolean", "array", "object"] as const;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Validate that a field's declared `type` is one of the six recognized
|
|
270
|
+
* values (vault#555). Returns `null` when `type` is unset (own-field checks
|
|
271
|
+
* elsewhere already treat an unset type as "nothing to check against") or
|
|
272
|
+
* recognized; otherwise a {@link TagFieldViolation} with `reason:
|
|
273
|
+
* "invalid_type"`.
|
|
274
|
+
*
|
|
275
|
+
* Before this, a non-indexed field's `type` was NEVER validated anywhere —
|
|
276
|
+
* `mapFieldType` (the only existing type check) runs solely on fields
|
|
277
|
+
* declaring `indexed: true`. `update-tag{fields:{weird:{type:"frobnicator"}}}`
|
|
278
|
+
* on a plain (non-indexed) field silently persisted the bogus type
|
|
279
|
+
* verbatim; every later read of that field just skipped validation
|
|
280
|
+
* entirely (`valueMatchesType` in schema-defaults.ts has no vocabulary
|
|
281
|
+
* entry for it either). This is now the SINGLE gate — indexed fields still
|
|
282
|
+
* get their own, narrower `unsupported_indexed_type` check (which also
|
|
283
|
+
* covers a RECOGNIZED-but-unindexable type like `"array"`, a different
|
|
284
|
+
* case from a genuinely unknown token).
|
|
285
|
+
*
|
|
286
|
+
* Pure — never throws; callers ({@link collectTagFieldViolations}'s bundled
|
|
287
|
+
* MCP/REST report, and the store chokepoint's defense-in-depth pre-validate)
|
|
288
|
+
* each decide how to surface it.
|
|
289
|
+
*/
|
|
290
|
+
export function validateFieldType(field: string, spec: TagFieldSchema): TagFieldViolation | null {
|
|
291
|
+
if (!spec.type) return null;
|
|
292
|
+
if ((VALID_FIELD_TYPES as readonly string[]).includes(spec.type)) return null;
|
|
293
|
+
return {
|
|
294
|
+
field,
|
|
295
|
+
reason: "invalid_type",
|
|
296
|
+
message: `field "${field}" declares unknown type "${spec.type}" — must be one of [${VALID_FIELD_TYPES.join(", ")}]`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Combined own-field `default` + `type` violation collection — no cross-tag
|
|
302
|
+
* lookups needed, so no `db` parameter (unlike
|
|
303
|
+
* {@link collectCrossTagFieldViolations}). Used by REST's `PUT
|
|
304
|
+
* /api/tags/:name` (vault#555 fix — folds these into the SAME bundled
|
|
305
|
+
* `tag_field_conflict` 422 report as the cross-tag checks, replacing the
|
|
306
|
+
* old fail-fast single-violation `invalid_field_default` 400) and reused by
|
|
307
|
+
* {@link collectTagFieldViolations} (MCP) so the two surfaces can't drift on
|
|
308
|
+
* what counts as an own-field violation.
|
|
309
|
+
*/
|
|
310
|
+
export function collectOwnFieldDefaultAndTypeViolations(
|
|
311
|
+
incomingFields: Record<string, TagFieldSchema>,
|
|
312
|
+
): TagFieldViolation[] {
|
|
313
|
+
const violations: TagFieldViolation[] = [];
|
|
314
|
+
for (const [fieldName, spec] of Object.entries(incomingFields)) {
|
|
315
|
+
const typeViolation = validateFieldType(fieldName, spec);
|
|
316
|
+
if (typeViolation) violations.push(typeViolation);
|
|
317
|
+
const defaultViolation = validateFieldDefault(fieldName, spec);
|
|
318
|
+
if (defaultViolation) violations.push(defaultViolation);
|
|
319
|
+
}
|
|
320
|
+
return violations;
|
|
321
|
+
}
|
|
322
|
+
|
|
229
323
|
/** Same six-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
|
|
230
324
|
function defaultMatchesType(value: unknown, type: string): boolean {
|
|
231
325
|
switch (type) {
|
|
@@ -507,7 +601,8 @@ export interface TagFieldViolation {
|
|
|
507
601
|
| "indexed_flag_conflict"
|
|
508
602
|
| "unsupported_indexed_type"
|
|
509
603
|
| "invalid_field_name"
|
|
510
|
-
| "invalid_default"
|
|
604
|
+
| "invalid_default"
|
|
605
|
+
| "invalid_type";
|
|
511
606
|
message: string;
|
|
512
607
|
/**
|
|
513
608
|
* The conflicting declarer tag — present on the cross-tag reasons
|
|
@@ -625,18 +720,24 @@ export function collectCrossTagFieldViolations(
|
|
|
625
720
|
|
|
626
721
|
/**
|
|
627
722
|
* Full field-violation collection: {@link collectCrossTagFieldViolations}
|
|
628
|
-
* PLUS
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
* `
|
|
723
|
+
* PLUS EVERY own-field check — {@link collectOwnFieldDefaultAndTypeViolations}
|
|
724
|
+
* (a non-conforming `default`, vault#553 Decision B; an unrecognized `type`,
|
|
725
|
+
* vault#555) AND the indexed-only checks (unsupported type for indexing,
|
|
726
|
+
* invalid identifier). Used by the MCP `update-tag` tool, which — unlike
|
|
727
|
+
* REST's indexed-type/name path — had no prior single-violation
|
|
728
|
+
* status-code contract to preserve for those two (its old inline loop threw
|
|
729
|
+
* an unstructured `Error` for them, same as everything else pre-#554);
|
|
730
|
+
* collecting everything here is a strict improvement. See
|
|
731
|
+
* {@link collectCrossTagFieldViolations}'s doc comment for why REST calls
|
|
732
|
+
* that narrower function directly for the CROSS-tag checks, then ALSO calls
|
|
733
|
+
* {@link collectOwnFieldDefaultAndTypeViolations} itself (vault#555 fix 5 —
|
|
734
|
+
* REST used to get `invalid_default` coverage only via
|
|
735
|
+
* `store.upsertTagRecord`'s fail-fast pre-validate, a single-violation
|
|
736
|
+
* `InvalidFieldDefaultError` → 400 that silently dropped every OTHER bad
|
|
737
|
+
* field in the same call; both surfaces now report every own-field default/
|
|
738
|
+
* type violation together, bundled with the cross-tag ones) — the
|
|
739
|
+
* indexed-type/name pair stays REST's own single-violation `400
|
|
740
|
+
* invalid_indexed_field` path (unchanged wire contract, vault#478).
|
|
640
741
|
*/
|
|
641
742
|
export function collectTagFieldViolations(
|
|
642
743
|
db: Database,
|
|
@@ -644,14 +745,9 @@ export function collectTagFieldViolations(
|
|
|
644
745
|
incomingFields: Record<string, TagFieldSchema>,
|
|
645
746
|
): TagFieldViolation[] {
|
|
646
747
|
const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
|
|
748
|
+
violations.push(...collectOwnFieldDefaultAndTypeViolations(incomingFields));
|
|
647
749
|
|
|
648
750
|
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
|
-
|
|
655
751
|
if (spec.indexed === true) {
|
|
656
752
|
const mapped = mapFieldType(spec.type);
|
|
657
753
|
if (!mapped) {
|