@openparachute/vault 0.7.0-rc.7 → 0.7.0-rc.9
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 +895 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/mcp.ts +439 -49
- package/core/src/notes.ts +39 -4
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +23 -0
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/types.ts +9 -0
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +230 -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 +36 -3
- package/src/routes.ts +363 -39
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +745 -4
package/src/routes.ts
CHANGED
|
@@ -22,9 +22,16 @@ import {
|
|
|
22
22
|
type QueryWarning,
|
|
23
23
|
} from "../core/src/query-warnings.ts";
|
|
24
24
|
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
listUnresolvedWikilinks,
|
|
27
|
+
resolveOrQueueLink,
|
|
28
|
+
resolveStructuredLinkNote,
|
|
29
|
+
getUnresolvedLinksForNote,
|
|
30
|
+
getUnresolvedLinksForNotes,
|
|
31
|
+
} from "../core/src/wikilinks.ts";
|
|
26
32
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
27
|
-
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
33
|
+
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError } from "../core/src/notes.ts";
|
|
34
|
+
import { normalizePath } from "../core/src/paths.ts";
|
|
28
35
|
import {
|
|
29
36
|
parseContentRange,
|
|
30
37
|
applyContentRange,
|
|
@@ -52,10 +59,11 @@ import {
|
|
|
52
59
|
scrubParentCycleError,
|
|
53
60
|
scrubReferencingTagsByScope,
|
|
54
61
|
scrubTagFieldViolationsByScope,
|
|
62
|
+
scrubValidationStatusByScope,
|
|
55
63
|
tagScopeForbidden,
|
|
56
64
|
tagsWithinScope,
|
|
57
65
|
} from "./tag-scope.ts";
|
|
58
|
-
import { ParentCycleError, InvalidFieldDefaultError } from "../core/src/tag-schemas.ts";
|
|
66
|
+
import { ParentCycleError, InvalidFieldDefaultError, InvalidFieldTypeError } from "../core/src/tag-schemas.ts";
|
|
59
67
|
import { findTokensReferencingTag } from "./token-store.ts";
|
|
60
68
|
|
|
61
69
|
/**
|
|
@@ -811,6 +819,9 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
811
819
|
excludeTags: parseQueryList(url, "exclude_tag"),
|
|
812
820
|
hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
|
|
813
821
|
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
822
|
+
// Presence filter on dangling outbound wikilinks/structured links
|
|
823
|
+
// (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
|
|
824
|
+
hasBrokenLinks: parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
814
825
|
path: parseQuery(url, "path") ?? undefined,
|
|
815
826
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
816
827
|
extension: parseExtensionFilter(url),
|
|
@@ -1016,6 +1027,13 @@ async function handleNotesInner(
|
|
|
1016
1027
|
tagScope.raw,
|
|
1017
1028
|
);
|
|
1018
1029
|
}
|
|
1030
|
+
if (parseBool(parseQuery(url, "include_broken_links"), false)) {
|
|
1031
|
+
// No tag-scope filtering needed (unlike include_links above): a
|
|
1032
|
+
// broken-link `target` never resolved to a note, so there's no
|
|
1033
|
+
// neighbor identity/content to leak — just the string this note's
|
|
1034
|
+
// own [[wikilink]]/structured link already named.
|
|
1035
|
+
result.broken_links = getUnresolvedLinksForNote(db, note.id);
|
|
1036
|
+
}
|
|
1019
1037
|
if (parseBool(parseQuery(url, "include_attachments"), false)) {
|
|
1020
1038
|
result.attachments = await store.getAttachments(note.id);
|
|
1021
1039
|
}
|
|
@@ -1335,6 +1353,7 @@ async function handleNotesInner(
|
|
|
1335
1353
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1336
1354
|
if (contentRange.error) return contentRange.error;
|
|
1337
1355
|
const includeLinks = parseBool(parseQuery(url, "include_links"), false);
|
|
1356
|
+
const includeBrokenLinks = parseBool(parseQuery(url, "include_broken_links"), false);
|
|
1338
1357
|
const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
|
|
1339
1358
|
const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
|
|
1340
1359
|
const inclMeta = parseIncludeMetadata(url);
|
|
@@ -1352,6 +1371,34 @@ async function handleNotesInner(
|
|
|
1352
1371
|
if (contentRange.range && includeContent) {
|
|
1353
1372
|
for (const n of output) applyContentRange(n, contentRange.range);
|
|
1354
1373
|
}
|
|
1374
|
+
// vault#555 fix 3 — attach validation_status on reads too (mirrors
|
|
1375
|
+
// MCP query-notes). Additive, present only when a tag declares
|
|
1376
|
+
// `fields`. Runs BEFORE metadata filtering so it sees full metadata
|
|
1377
|
+
// regardless of `include_metadata`. Scrubbed for a tag-scoped caller
|
|
1378
|
+
// (vault#555 auth review) so an out-of-scope co-tag's schema shape
|
|
1379
|
+
// doesn't leak — `results` here is already scope-filtered, but a
|
|
1380
|
+
// surviving note may carry an out-of-scope co-tag whose schema would
|
|
1381
|
+
// otherwise appear in its validation_status. No-op when unscoped.
|
|
1382
|
+
{
|
|
1383
|
+
const statusById = new Map(
|
|
1384
|
+
results.map((n) => [
|
|
1385
|
+
n.id,
|
|
1386
|
+
scrubValidationStatusByScope(
|
|
1387
|
+
store.validateNoteAgainstSchemas({
|
|
1388
|
+
path: n.path,
|
|
1389
|
+
tags: n.tags,
|
|
1390
|
+
metadata: n.metadata as Record<string, unknown> | undefined,
|
|
1391
|
+
}),
|
|
1392
|
+
tagScope.allowed,
|
|
1393
|
+
tagScope.raw,
|
|
1394
|
+
),
|
|
1395
|
+
]),
|
|
1396
|
+
);
|
|
1397
|
+
for (const n of output as any[]) {
|
|
1398
|
+
const status = statusById.get(n.id);
|
|
1399
|
+
if (status) n.validation_status = status;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1355
1402
|
if (inclMeta !== undefined && inclMeta !== true) {
|
|
1356
1403
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
1357
1404
|
}
|
|
@@ -1398,13 +1445,19 @@ async function handleNotesInner(
|
|
|
1398
1445
|
);
|
|
1399
1446
|
}
|
|
1400
1447
|
|
|
1401
|
-
if (includeLinks || includeAttachments) {
|
|
1448
|
+
if (includeLinks || includeBrokenLinks || includeAttachments) {
|
|
1402
1449
|
// Whole-page link hydration in a constant number of queries — the
|
|
1403
1450
|
// per-note variant cost (1 link query + 1 summary query + N tag
|
|
1404
1451
|
// queries) × page size. 2026-06-10 perf measurements.
|
|
1405
1452
|
const linksByNote = includeLinks
|
|
1406
1453
|
? linkOps.getLinksHydratedForNotes(db, output.map((n: any) => n.id))
|
|
1407
1454
|
: null;
|
|
1455
|
+
// Same batched-per-page shape as links (vault#555). No tag-scope
|
|
1456
|
+
// filtering needed — a broken-link `target` never resolved to a
|
|
1457
|
+
// note, so there's no neighbor identity to leak.
|
|
1458
|
+
const brokenLinksByNote = includeBrokenLinks
|
|
1459
|
+
? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id))
|
|
1460
|
+
: null;
|
|
1408
1461
|
const enrichedOut: any[] = [];
|
|
1409
1462
|
for (const n of output) {
|
|
1410
1463
|
const enriched: any = { ...n };
|
|
@@ -1416,6 +1469,7 @@ async function handleNotesInner(
|
|
|
1416
1469
|
tagScope.raw,
|
|
1417
1470
|
);
|
|
1418
1471
|
}
|
|
1472
|
+
if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
|
|
1419
1473
|
if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
|
|
1420
1474
|
enrichedOut.push(enriched);
|
|
1421
1475
|
}
|
|
@@ -1475,13 +1529,111 @@ async function handleNotesInner(
|
|
|
1475
1529
|
}
|
|
1476
1530
|
|
|
1477
1531
|
const created: Note[] = [];
|
|
1532
|
+
// Structured `links` are resolved in a second pass, after every note
|
|
1533
|
+
// in this batch has been created (vault#555) — resolving inline (the
|
|
1534
|
+
// old behavior) meant a link from item 0 to item 1's path silently
|
|
1535
|
+
// dropped, since item 1 didn't exist yet at the moment item 0's links
|
|
1536
|
+
// were processed. Mirrors the MCP create-note fix exactly (both
|
|
1537
|
+
// layers share `resolveOrQueueLink` from core/wikilinks.ts).
|
|
1538
|
+
const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
|
|
1539
|
+
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
1540
|
+
// `if_exists` bookkeeping (vault#555) — mirrors core/src/mcp.ts's
|
|
1541
|
+
// create-note tool exactly (same contract, independent REST-layer
|
|
1542
|
+
// reimplementation sharing the same core primitives). See that file's
|
|
1543
|
+
// doc comments for the full rationale.
|
|
1544
|
+
const existedMap = new Map<string, boolean>();
|
|
1545
|
+
const noMutateIds = new Set<string>();
|
|
1546
|
+
const recordExistedBranch = (resultNote: Note, noMutate: boolean): void => {
|
|
1547
|
+
existedMap.set(resultNote.id, true);
|
|
1548
|
+
if (noMutate) noMutateIds.add(resultNote.id);
|
|
1549
|
+
created.push(resultNote);
|
|
1550
|
+
};
|
|
1551
|
+
const applyExistingNote = async (
|
|
1552
|
+
item: any,
|
|
1553
|
+
existingNote: Note,
|
|
1554
|
+
mode: "ignore" | "update" | "replace",
|
|
1555
|
+
): Promise<{ note: Note; noMutate: boolean }> => {
|
|
1556
|
+
// CRITICAL scope guard (vault#555 auth-review must-fix): `existingNote`
|
|
1557
|
+
// was resolved VAULT-WIDE by path (store.getNoteByPath) at BOTH call
|
|
1558
|
+
// sites below (proactive + race-backstop). The batch's incoming-tag
|
|
1559
|
+
// pre-validation guards only each item's OWN tags — NOT this resolved
|
|
1560
|
+
// note — so without this a token scoped to `public` could READ (ignore)
|
|
1561
|
+
// or OVERWRITE (update/replace) a note carrying only an out-of-scope
|
|
1562
|
+
// tag `secret` just by naming its path. When the note is outside the
|
|
1563
|
+
// caller's tag scope, treat it as a path conflict — the path is taken,
|
|
1564
|
+
// but invisible to this caller — throwing BEFORE any read/mutation so
|
|
1565
|
+
// we never expose content or write. `noteWithinTagScope` is a no-op for
|
|
1566
|
+
// unscoped tokens (tagScope.raw === null → always true). The throw
|
|
1567
|
+
// propagates to runBatch's PATH_CONFLICT catch → 409 path_conflict,
|
|
1568
|
+
// byte-identical to a genuine conflict (leaks nothing about WHY).
|
|
1569
|
+
if (!noteWithinTagScope(existingNote, tagScope.allowed, tagScope.raw)) {
|
|
1570
|
+
throw new PathConflictError(existingNote.path ?? "");
|
|
1571
|
+
}
|
|
1572
|
+
if (mode === "ignore") {
|
|
1573
|
+
return { note: existingNote, noMutate: true };
|
|
1574
|
+
}
|
|
1575
|
+
const updates: { content?: string; metadata?: Record<string, unknown> } = {};
|
|
1576
|
+
if (mode === "update") {
|
|
1577
|
+
if (item.content !== undefined) updates.content = item.content;
|
|
1578
|
+
if (item.metadata !== undefined) {
|
|
1579
|
+
updates.metadata = mergeMetadata(
|
|
1580
|
+
existingNote.metadata as Record<string, unknown> | null | undefined,
|
|
1581
|
+
item.metadata,
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
} else {
|
|
1585
|
+
// "replace" — PUT semantics: content/metadata become exactly this
|
|
1586
|
+
// payload (empty default when omitted), never merged.
|
|
1587
|
+
updates.content = item.content ?? "";
|
|
1588
|
+
updates.metadata = item.metadata ?? {};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const incomingTags: string[] = item.tags ?? [];
|
|
1592
|
+
const projectedTags = new Set<string>([...(existingNote.tags ?? []), ...incomingTags]);
|
|
1593
|
+
const projectedMetadata = updates.metadata ?? ((existingNote.metadata as Record<string, unknown>) ?? {});
|
|
1594
|
+
gateStrictWrite(store, writeCtx, { path: existingNote.path, tags: [...projectedTags], metadata: projectedMetadata });
|
|
1595
|
+
|
|
1596
|
+
// vault#555 fix 2 (W8 fix-2 bug class, generalist must-fix): gate the
|
|
1597
|
+
// core UPDATE on the tag/link mutation too, not just `updates` — a
|
|
1598
|
+
// tags-only/links-only "update" leaves `updates` empty, and skipping
|
|
1599
|
+
// store.updateNote would freeze `updated_at` even though store.tagNote
|
|
1600
|
+
// genuinely mutates the note (breaking cursor polling + updated_at
|
|
1601
|
+
// sync). Mirrors the update-note/PATCH hasTagMutation||hasLinkMutation
|
|
1602
|
+
// gate. "replace" always sets content+metadata, so it was unaffected.
|
|
1603
|
+
const hasLinkMutation = item.links !== undefined;
|
|
1604
|
+
let result: Note = existingNote;
|
|
1605
|
+
if (Object.keys(updates).length > 0 || incomingTags.length > 0 || hasLinkMutation) {
|
|
1606
|
+
result = await store.updateNote(existingNote.id, {
|
|
1607
|
+
...updates,
|
|
1608
|
+
actor: writeCtx.actor,
|
|
1609
|
+
via: writeCtx.via,
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
if (incomingTags.length > 0) {
|
|
1614
|
+
await store.tagNote(result.id, incomingTags);
|
|
1615
|
+
// Redundant with the outer batch loop's applySchemaDefaults (this
|
|
1616
|
+
// note isn't in `noMutateIds` — only "ignore" hits are), but harmless
|
|
1617
|
+
// (idempotent) — kept so the update/replace branch is self-contained.
|
|
1618
|
+
await applySchemaDefaults(store, db, [result.id], incomingTags);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (item.links) {
|
|
1622
|
+
pendingLinks.push({ sourceId: result.id, links: item.links as { target: string; relationship: string }[] });
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
return { note: (await store.getNote(result.id)) ?? result, noMutate: false };
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1478
1628
|
// Wrap multi-item batches in a SQLite transaction so a mid-batch
|
|
1479
1629
|
// failure (path conflict, etc.) rolls back every prior insert. Without
|
|
1480
1630
|
// this, callers got half-applied batches where the prefix landed and
|
|
1481
1631
|
// the offending entry surfaced the 409 — see #236. Single-item posts
|
|
1482
1632
|
// are already atomic at the store layer and skip the wrap so they
|
|
1483
1633
|
// don't collide with concurrent single-item callers on the shared
|
|
1484
|
-
// bun:sqlite connection.
|
|
1634
|
+
// bun:sqlite connection. A caught PATH_CONFLICT (the if_exists race
|
|
1635
|
+
// backstop below) does NOT roll back the transaction — only a throw
|
|
1636
|
+
// that escapes `runBatch` does (see core/src/txn.ts).
|
|
1485
1637
|
const batched = items.length > 1;
|
|
1486
1638
|
const runBatch = async (): Promise<void> => {
|
|
1487
1639
|
for (const item of items) {
|
|
@@ -1491,6 +1643,26 @@ async function handleNotesInner(
|
|
|
1491
1643
|
const extension = item.extension !== undefined
|
|
1492
1644
|
? validateExtension(item.extension)
|
|
1493
1645
|
: undefined;
|
|
1646
|
+
const effectiveExtension = extension ?? "md";
|
|
1647
|
+
const ifExists: string = item.if_exists ?? "error";
|
|
1648
|
+
const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
|
|
1649
|
+
|
|
1650
|
+
// Proactive existence check (vault#555) — only possible with a
|
|
1651
|
+
// path. Handles the common, non-racing case cleanly: skips the
|
|
1652
|
+
// raw-item strict gate below (it would validate the wrong shape —
|
|
1653
|
+
// see `applyExistingNote`) and goes straight to the collision
|
|
1654
|
+
// branch. The catch below is the backstop for a true race.
|
|
1655
|
+
let existingNote: Note | null = null;
|
|
1656
|
+
if (upsertMode && item.path) {
|
|
1657
|
+
const normalized = normalizePath(item.path);
|
|
1658
|
+
existingNote = normalized ? await store.getNoteByPath(normalized, effectiveExtension) : null;
|
|
1659
|
+
}
|
|
1660
|
+
if (existingNote) {
|
|
1661
|
+
const { note: resultNote, noMutate } = await applyExistingNote(item, existingNote, ifExists as "ignore" | "update" | "replace");
|
|
1662
|
+
recordExistedBranch(resultNote, noMutate);
|
|
1663
|
+
continue;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1494
1666
|
// Strict-schema gate (vault#299) — reject before any write so a
|
|
1495
1667
|
// mid-batch violation rolls back the batch transaction.
|
|
1496
1668
|
gateStrictWrite(store, writeCtx, {
|
|
@@ -1498,28 +1670,67 @@ async function handleNotesInner(
|
|
|
1498
1670
|
tags: item.tags,
|
|
1499
1671
|
metadata: item.metadata,
|
|
1500
1672
|
});
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1673
|
+
let note: Note;
|
|
1674
|
+
try {
|
|
1675
|
+
note = await store.createNote(item.content ?? "", {
|
|
1676
|
+
id: item.id,
|
|
1677
|
+
path: item.path,
|
|
1678
|
+
tags: item.tags,
|
|
1679
|
+
metadata: item.metadata,
|
|
1680
|
+
created_at: item.createdAt ?? item.created_at,
|
|
1681
|
+
...(extension !== undefined ? { extension } : {}),
|
|
1682
|
+
// Write-attribution (vault#298) — REST batch create.
|
|
1683
|
+
actor: writeCtx.actor,
|
|
1684
|
+
via: writeCtx.via,
|
|
1685
|
+
});
|
|
1686
|
+
} catch (err: any) {
|
|
1687
|
+
// Race backstop (vault#555): a concurrent writer's INSERT
|
|
1688
|
+
// committed between our proactive check and this one.
|
|
1689
|
+
// Re-resolve the now-existing row and fall through to the SAME
|
|
1690
|
+
// branch a proactive hit would have taken.
|
|
1691
|
+
if (upsertMode && err?.code === "PATH_CONFLICT") {
|
|
1692
|
+
const winner = await store.getNoteByPath(err.path, effectiveExtension);
|
|
1693
|
+
if (winner) {
|
|
1694
|
+
const { note: resultNote, noMutate } = await applyExistingNote(item, winner, ifExists as "ignore" | "update" | "replace");
|
|
1695
|
+
recordExistedBranch(resultNote, noMutate);
|
|
1696
|
+
continue;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
throw err;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
if (upsertMode) existedMap.set(note.id, false);
|
|
1512
1703
|
|
|
1513
|
-
// Create explicit links
|
|
1514
1704
|
if (item.links) {
|
|
1515
|
-
|
|
1516
|
-
const target = await resolveNote(store, link.target);
|
|
1517
|
-
if (target) await store.createLink(note.id, target.id, link.relationship);
|
|
1518
|
-
}
|
|
1705
|
+
pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
|
|
1519
1706
|
}
|
|
1520
1707
|
|
|
1521
1708
|
created.push((await store.getNote(note.id)) ?? note);
|
|
1522
1709
|
}
|
|
1710
|
+
|
|
1711
|
+
// --- Resolve structured links (vault#555) ---
|
|
1712
|
+
// Same semantics as [[wikilinks]]: ID or path/title match, tried now
|
|
1713
|
+
// that every sibling note in this batch exists. A target that still
|
|
1714
|
+
// doesn't resolve is queued for lazy resolution (backfills
|
|
1715
|
+
// automatically when a matching note is created later) and surfaces
|
|
1716
|
+
// an `unresolved_link` warning naming the target — never silent.
|
|
1717
|
+
for (const { sourceId, links } of pendingLinks) {
|
|
1718
|
+
for (const link of links) {
|
|
1719
|
+
const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
1720
|
+
if (targetId) {
|
|
1721
|
+
await store.createLink(sourceId, targetId, link.relationship);
|
|
1722
|
+
} else {
|
|
1723
|
+
const list = linkWarningsByNote.get(sourceId) ?? [];
|
|
1724
|
+
list.push({
|
|
1725
|
+
code: "unresolved_link",
|
|
1726
|
+
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.`,
|
|
1727
|
+
target: link.target,
|
|
1728
|
+
relationship: link.relationship,
|
|
1729
|
+
});
|
|
1730
|
+
linkWarningsByNote.set(sourceId, list);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1523
1734
|
};
|
|
1524
1735
|
try {
|
|
1525
1736
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
@@ -1567,6 +1778,10 @@ async function handleNotesInner(
|
|
|
1567
1778
|
// applied so the common no-defaults path adds zero extra reads.
|
|
1568
1779
|
const mutatedIds = new Set<string>();
|
|
1569
1780
|
for (const note of created) {
|
|
1781
|
+
// "ignore" hits (vault#555) promise ZERO mutation, including
|
|
1782
|
+
// schema-default backfill of a since-added default the existing
|
|
1783
|
+
// note predates — skip them entirely.
|
|
1784
|
+
if (noMutateIds.has(note.id)) continue;
|
|
1570
1785
|
if (note.tags?.length) {
|
|
1571
1786
|
for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
|
|
1572
1787
|
mutatedIds.add(id);
|
|
@@ -1584,8 +1799,37 @@ async function handleNotesInner(
|
|
|
1584
1799
|
// Attach `validation_status` so HTTP create-note matches the MCP
|
|
1585
1800
|
// surface (vault#287). `attachValidationStatus` returns the note
|
|
1586
1801
|
// unchanged when no tag declares fields, so vaults without any tag
|
|
1587
|
-
// schemas see no behavior change.
|
|
1588
|
-
|
|
1802
|
+
// schemas see no behavior change. Fold in any `unresolved_link`
|
|
1803
|
+
// warnings (vault#555) — additive, present only when this note's
|
|
1804
|
+
// `links` had a target that didn't resolve. `existed` (vault#555) is
|
|
1805
|
+
// attached ONLY for items whose `if_exists` actually engaged — the
|
|
1806
|
+
// default "error" path's response shape is untouched.
|
|
1807
|
+
const final = refreshed.map((n) => {
|
|
1808
|
+
const validated = attachValidationStatus(store, db, n);
|
|
1809
|
+
const warnings = linkWarningsByNote.get(n.id);
|
|
1810
|
+
const existed = existedMap.get(n.id);
|
|
1811
|
+
let out: any = validated;
|
|
1812
|
+
if (warnings && warnings.length > 0) out = { ...out, warnings };
|
|
1813
|
+
if (existed !== undefined) out = { ...out, existed };
|
|
1814
|
+
return out;
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
// Batch summary (vault#555) — compact shape instead of N full note
|
|
1818
|
+
// objects. Batch-only (a `notes` array); `summary` is ignored on a
|
|
1819
|
+
// single-note POST. Mirrors the MCP create-note tool exactly.
|
|
1820
|
+
if (body.notes && body.summary === true) {
|
|
1821
|
+
return json(
|
|
1822
|
+
{
|
|
1823
|
+
created: final.filter((n: any) => n.existed !== true).length,
|
|
1824
|
+
ids: final.map((n: any) => n.id),
|
|
1825
|
+
// Reserved for future partial-batch-failure reporting — a batch
|
|
1826
|
+
// create is all-or-nothing today, so this is always empty.
|
|
1827
|
+
failed: [] as unknown[],
|
|
1828
|
+
},
|
|
1829
|
+
201,
|
|
1830
|
+
);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1589
1833
|
return json(body.notes ? final : final[0], 201);
|
|
1590
1834
|
}
|
|
1591
1835
|
|
|
@@ -1741,6 +1985,24 @@ async function handleNotesInner(
|
|
|
1741
1985
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1742
1986
|
if (contentRange.error) return contentRange.error;
|
|
1743
1987
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
1988
|
+
// vault#555 fix 3 — mirror the MCP query-notes fix: attach
|
|
1989
|
+
// validation_status on reads too, not just on the one-time create/update
|
|
1990
|
+
// write response. See core/src/mcp.ts's query-notes handler for the
|
|
1991
|
+
// full rationale. Scrubbed for a tag-scoped caller (vault#555 auth
|
|
1992
|
+
// review) so an out-of-scope co-tag's schema shape doesn't leak — no-op
|
|
1993
|
+
// for unscoped callers (tagScope.raw === null).
|
|
1994
|
+
{
|
|
1995
|
+
const status = scrubValidationStatusByScope(
|
|
1996
|
+
store.validateNoteAgainstSchemas({
|
|
1997
|
+
path: note.path,
|
|
1998
|
+
tags: note.tags,
|
|
1999
|
+
metadata: note.metadata as Record<string, unknown> | undefined,
|
|
2000
|
+
}),
|
|
2001
|
+
tagScope.allowed,
|
|
2002
|
+
tagScope.raw,
|
|
2003
|
+
);
|
|
2004
|
+
if (status) result.validation_status = status;
|
|
2005
|
+
}
|
|
1744
2006
|
const expand = parseExpandParams(url, db, tagScope);
|
|
1745
2007
|
if (expand && includeContent && typeof result.content === "string") {
|
|
1746
2008
|
expand.ctx.expanded.add(note.id);
|
|
@@ -1834,22 +2096,35 @@ async function handleNotesInner(
|
|
|
1834
2096
|
// fresh note).
|
|
1835
2097
|
// - Missing target notes skip silently (mirrors MCP).
|
|
1836
2098
|
const linksAdd = (body.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
2099
|
+
// `unresolved_link` warnings (vault#555) — a target that doesn't
|
|
2100
|
+
// resolve is queued for lazy resolution (backfills automatically
|
|
2101
|
+
// when a matching note is created later), never silently dropped.
|
|
2102
|
+
const createWarnings: QueryWarning[] = [];
|
|
1837
2103
|
if (linksAdd) {
|
|
1838
2104
|
for (const link of linksAdd) {
|
|
1839
|
-
const
|
|
1840
|
-
if (
|
|
1841
|
-
await store.createLink(created.id,
|
|
2105
|
+
const targetId = resolveOrQueueLink(db, created.id, link.target, link.relationship);
|
|
2106
|
+
if (targetId) {
|
|
2107
|
+
await store.createLink(created.id, targetId, link.relationship, link.metadata);
|
|
2108
|
+
} else {
|
|
2109
|
+
createWarnings.push({
|
|
2110
|
+
code: "unresolved_link",
|
|
2111
|
+
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.`,
|
|
2112
|
+
target: link.target,
|
|
2113
|
+
relationship: link.relationship,
|
|
2114
|
+
});
|
|
1842
2115
|
}
|
|
1843
2116
|
}
|
|
1844
2117
|
}
|
|
1845
2118
|
const final = await store.getNote(created.id);
|
|
1846
2119
|
if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
|
|
1847
|
-
const validated = attachValidationStatus(store, db, final);
|
|
2120
|
+
const validated: any = attachValidationStatus(store, db, final);
|
|
2121
|
+
if (createWarnings.length > 0) validated.warnings = createWarnings;
|
|
1848
2122
|
const includeContentResp = body.include_content !== false;
|
|
1849
2123
|
if (includeContentResp) return json({ ...validated, created: true });
|
|
1850
2124
|
const lean: any = toNoteIndex(validated);
|
|
1851
2125
|
const vs = (validated as any).validation_status;
|
|
1852
2126
|
if (vs !== undefined) lean.validation_status = vs;
|
|
2127
|
+
if (createWarnings.length > 0) lean.warnings = createWarnings;
|
|
1853
2128
|
lean.created = true;
|
|
1854
2129
|
return json(lean);
|
|
1855
2130
|
}
|
|
@@ -1992,7 +2267,7 @@ async function handleNotesInner(
|
|
|
1992
2267
|
const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
|
|
1993
2268
|
if (linksRemove) {
|
|
1994
2269
|
for (const link of linksRemove) {
|
|
1995
|
-
const target =
|
|
2270
|
+
const target = resolveStructuredLinkNote(db, link.target);
|
|
1996
2271
|
if (!target) continue;
|
|
1997
2272
|
resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
|
|
1998
2273
|
if (link.relationship === "wikilink" && target.path) {
|
|
@@ -2069,9 +2344,21 @@ async function handleNotesInner(
|
|
|
2069
2344
|
gateStrictWrite(store, writeCtx, { path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
2070
2345
|
}
|
|
2071
2346
|
|
|
2072
|
-
|
|
2347
|
+
// vault#555 fix 2 — tag and link mutations must bump `updated_at` too,
|
|
2348
|
+
// not just core-field (content/path/metadata) changes. Mirrors the MCP
|
|
2349
|
+
// `update-note` fix exactly: a tags-only or links-only PATCH with
|
|
2350
|
+
// `force: true` (no `if_updated_at`) left `updates` empty, so
|
|
2351
|
+
// `store.updateNote` was never even called and `updated_at` never
|
|
2352
|
+
// moved despite a real tags/note_tags or links change.
|
|
2353
|
+
const hasTagMutation = (body.tags?.add?.length ?? 0) > 0 || (body.tags?.remove?.length ?? 0) > 0;
|
|
2354
|
+
const hasLinkMutation = body.links?.add !== undefined || body.links?.remove !== undefined;
|
|
2355
|
+
if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
|
|
2073
2356
|
// Write-attribution (vault#298) — REST update. Stamp the most-recent-
|
|
2074
|
-
// write columns on the same UPDATE that bumps updated_at.
|
|
2357
|
+
// write columns on the same UPDATE that bumps updated_at. `updates`
|
|
2358
|
+
// may carry no core fields at all (a pure tag/link mutation) — that's
|
|
2359
|
+
// fine: `noteOps.updateNote` unconditionally SETs
|
|
2360
|
+
// `updated_at`/`last_updated_by`/`last_updated_via` whenever
|
|
2361
|
+
// `skipUpdatedAt` isn't set, so this still issues a real UPDATE.
|
|
2075
2362
|
updates.actor = writeCtx.actor;
|
|
2076
2363
|
updates.via = writeCtx.via;
|
|
2077
2364
|
await store.updateNote(note.id, updates);
|
|
@@ -2091,11 +2378,24 @@ async function handleNotesInner(
|
|
|
2091
2378
|
await store.untagNote(note.id, body.tags.remove);
|
|
2092
2379
|
}
|
|
2093
2380
|
|
|
2094
|
-
// Add links
|
|
2381
|
+
// Add links. `unresolved_link` warnings (vault#555) — a target that
|
|
2382
|
+
// doesn't resolve is queued for lazy resolution (backfills
|
|
2383
|
+
// automatically when a matching note is created later), never
|
|
2384
|
+
// silently dropped.
|
|
2385
|
+
const linkWarnings: QueryWarning[] = [];
|
|
2095
2386
|
if (body.links?.add) {
|
|
2096
2387
|
for (const link of body.links.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[]) {
|
|
2097
|
-
const
|
|
2098
|
-
if (
|
|
2388
|
+
const targetId = resolveOrQueueLink(db, note.id, link.target, link.relationship);
|
|
2389
|
+
if (targetId) {
|
|
2390
|
+
await store.createLink(note.id, targetId, link.relationship, link.metadata);
|
|
2391
|
+
} else {
|
|
2392
|
+
linkWarnings.push({
|
|
2393
|
+
code: "unresolved_link",
|
|
2394
|
+
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.`,
|
|
2395
|
+
target: link.target,
|
|
2396
|
+
relationship: link.relationship,
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2099
2399
|
}
|
|
2100
2400
|
}
|
|
2101
2401
|
|
|
@@ -2130,6 +2430,7 @@ async function handleNotesInner(
|
|
|
2130
2430
|
tagScope.raw,
|
|
2131
2431
|
);
|
|
2132
2432
|
}
|
|
2433
|
+
if (linkWarnings.length > 0) validated.warnings = linkWarnings;
|
|
2133
2434
|
const includeContentResp = body.include_content !== false;
|
|
2134
2435
|
// `created: false` is appended to every update-path response so
|
|
2135
2436
|
// sync-loop callers using `if_missing: "create"` can distinguish
|
|
@@ -2142,6 +2443,7 @@ async function handleNotesInner(
|
|
|
2142
2443
|
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
2143
2444
|
// drops unknown fields, same as the `validation_status` recipe above.
|
|
2144
2445
|
if (validated.links !== undefined) lean.links = validated.links;
|
|
2446
|
+
if (linkWarnings.length > 0) lean.warnings = linkWarnings;
|
|
2145
2447
|
lean.created = false;
|
|
2146
2448
|
return json(lean);
|
|
2147
2449
|
} catch (e: any) {
|
|
@@ -2642,11 +2944,22 @@ export async function handleTags(
|
|
|
2642
2944
|
// — silent 200s on main — is non-indexed type conflicts and indexed-flag
|
|
2643
2945
|
// conflicts. See `collectCrossTagFieldViolations`'s doc comment.
|
|
2644
2946
|
if (body.fields && typeof body.fields === "object" && !Array.isArray(body.fields)) {
|
|
2947
|
+
const incomingFields = body.fields as Record<string, tagSchemaOps.TagFieldSchema>;
|
|
2948
|
+
// vault#555 fix 5 — fold the own-field `default`/`type` checks into
|
|
2949
|
+
// the SAME bundled report as the cross-tag ones. Before this, a bad
|
|
2950
|
+
// `default` (and, since fix 4 added the check, an unrecognized
|
|
2951
|
+
// `type`) went through `store.upsertTagRecord`'s fail-fast pre-
|
|
2952
|
+
// validate below INSTEAD — a single-violation 400 that silently never
|
|
2953
|
+
// mentioned any OTHER bad field in the same call. `unsupported_
|
|
2954
|
+
// indexed_type`/`invalid_field_name` deliberately stay OUT of this
|
|
2955
|
+
// bundle — REST's established single-violation `400
|
|
2956
|
+
// invalid_indexed_field` contract for those two is unchanged
|
|
2957
|
+
// (vault#478; see `collectCrossTagFieldViolations`'s doc comment).
|
|
2645
2958
|
const fieldViolations = tagSchemaOps.collectCrossTagFieldViolations(
|
|
2646
2959
|
store.db,
|
|
2647
2960
|
putTagName,
|
|
2648
|
-
|
|
2649
|
-
);
|
|
2961
|
+
incomingFields,
|
|
2962
|
+
).concat(tagSchemaOps.collectOwnFieldDefaultAndTypeViolations(incomingFields));
|
|
2650
2963
|
if (fieldViolations.length > 0) {
|
|
2651
2964
|
// Tag-scope scrub (vault#554 auth-and-scope fold): the write is
|
|
2652
2965
|
// still rejected, but a violation whose conflicting declarer is
|
|
@@ -2694,15 +3007,26 @@ export async function handleTags(
|
|
|
2694
3007
|
if (err instanceof InvalidFieldDefaultError) {
|
|
2695
3008
|
// vault#553 Decision B — a declared `default` doesn't conform to its
|
|
2696
3009
|
// own field's type/enum. Own-field error (no cross-tag data), so no
|
|
2697
|
-
// scrub needed.
|
|
2698
|
-
//
|
|
2699
|
-
//
|
|
2700
|
-
// reports it bundled via `
|
|
3010
|
+
// scrub needed. DEFENSE-IN-DEPTH ONLY as of vault#555 fix 5 — the
|
|
3011
|
+
// pre-check above (`collectOwnFieldDefaultAndTypeViolations`) now
|
|
3012
|
+
// catches every `invalid_default` BEFORE reaching `store.upsertTagRecord`
|
|
3013
|
+
// and reports it bundled via `tag_field_conflict` 422 instead; this
|
|
3014
|
+
// branch only fires for a caller that somehow bypasses that pre-check.
|
|
2701
3015
|
return json(
|
|
2702
3016
|
{ error: err.message, error_type: "invalid_field_default", field: err.field },
|
|
2703
3017
|
400,
|
|
2704
3018
|
);
|
|
2705
3019
|
}
|
|
3020
|
+
if (err instanceof InvalidFieldTypeError) {
|
|
3021
|
+
// vault#555 fix 4 — a declared `type` isn't one of the six
|
|
3022
|
+
// recognized values. Same defense-in-depth posture as
|
|
3023
|
+
// InvalidFieldDefaultError above — the pre-check catches this in
|
|
3024
|
+
// the normal path and reports it bundled instead.
|
|
3025
|
+
return json(
|
|
3026
|
+
{ error: err.message, error_type: "invalid_field_type", field: err.field, type: err.type, valid_types: err.valid_types },
|
|
3027
|
+
400,
|
|
3028
|
+
);
|
|
3029
|
+
}
|
|
2706
3030
|
if (err instanceof ParentCycleError) {
|
|
2707
3031
|
// vault#552: parent_names would close a cycle. Nothing was
|
|
2708
3032
|
// persisted. Scope-scrub the cycle path for a tag-scoped caller —
|
|
@@ -330,4 +330,48 @@ describe("invalid_indexed_field × tag scope — the 400 door is scrubbed too (w
|
|
|
330
330
|
expect(caught).toBeInstanceOf(IndexedFieldError);
|
|
331
331
|
expect(caught.message).toContain("mine-too");
|
|
332
332
|
});
|
|
333
|
+
|
|
334
|
+
// vault#555 fix 4/5 — `invalid_type` and `invalid_default` are own-field
|
|
335
|
+
// violations (no `other_tag`, no cross-tag data to leak) so
|
|
336
|
+
// `scrubTagFieldViolationsByScope` is a documented no-op for them by
|
|
337
|
+
// construction. Pin that a scoped caller still gets the FULL violation
|
|
338
|
+
// (field/reason/message) — nothing accidentally strips it.
|
|
339
|
+
test("invalid_type and invalid_default violations pass through the scope-scrub untouched (no other_tag to scrub)", async () => {
|
|
340
|
+
seedVault("journal");
|
|
341
|
+
getVaultStore("journal");
|
|
342
|
+
|
|
343
|
+
// REST
|
|
344
|
+
const res = await handleTags(
|
|
345
|
+
new Request("http://localhost/api/tags/mine", {
|
|
346
|
+
method: "PUT",
|
|
347
|
+
body: JSON.stringify({
|
|
348
|
+
fields: {
|
|
349
|
+
weird: { type: "frobnicator" },
|
|
350
|
+
bad_default: { type: "string", enum: ["a", "b"], default: "zzz" },
|
|
351
|
+
},
|
|
352
|
+
}),
|
|
353
|
+
}),
|
|
354
|
+
getVaultStore("journal"),
|
|
355
|
+
"/mine",
|
|
356
|
+
{ allowed: new Set(["mine"]), raw: ["mine"] },
|
|
357
|
+
);
|
|
358
|
+
expect(res.status).toBe(422);
|
|
359
|
+
const body: any = await res.json();
|
|
360
|
+
expect(body.violations).toHaveLength(2);
|
|
361
|
+
const byField = new Map(body.violations.map((v: any) => [v.field, v]));
|
|
362
|
+
expect(byField.get("weird").reason).toBe("invalid_type");
|
|
363
|
+
expect(byField.get("weird").other_tag).toBeUndefined();
|
|
364
|
+
expect(byField.get("bad_default").reason).toBe("invalid_default");
|
|
365
|
+
|
|
366
|
+
// MCP
|
|
367
|
+
const tool = await updateTagTool("journal", ["mine"]);
|
|
368
|
+
let caught: any;
|
|
369
|
+
try {
|
|
370
|
+
await tool.execute({ tag: "mine", fields: { weird: { type: "frobnicator" } } });
|
|
371
|
+
} catch (e) {
|
|
372
|
+
caught = e;
|
|
373
|
+
}
|
|
374
|
+
expect(caught.violations).toHaveLength(1);
|
|
375
|
+
expect(caught.violations[0].reason).toBe("invalid_type");
|
|
376
|
+
});
|
|
333
377
|
});
|