@openparachute/vault 0.7.0-rc.8 → 0.7.1

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.
Files changed (38) hide show
  1. package/core/src/aggregate.test.ts +260 -0
  2. package/core/src/contract-taxonomy.test.ts +26 -2
  3. package/core/src/contract-typed-index.test.ts +4 -2
  4. package/core/src/core.test.ts +1151 -0
  5. package/core/src/cursor.ts +1 -0
  6. package/core/src/doctor.ts +23 -1
  7. package/core/src/indexed-fields.test.ts +4 -1
  8. package/core/src/indexed-fields.ts +6 -1
  9. package/core/src/links.ts +22 -0
  10. package/core/src/mcp.ts +587 -79
  11. package/core/src/notes.ts +371 -18
  12. package/core/src/query-warnings.ts +60 -12
  13. package/core/src/schema-defaults.ts +7 -1
  14. package/core/src/seed-packs.test.ts +14 -0
  15. package/core/src/seed-packs.ts +42 -5
  16. package/core/src/store.ts +129 -5
  17. package/core/src/tag-schemas.ts +20 -7
  18. package/core/src/types.ts +98 -0
  19. package/core/src/ulid.test.ts +56 -0
  20. package/core/src/ulid.ts +116 -0
  21. package/core/src/vault-projection.ts +19 -1
  22. package/core/src/wikilinks.test.ts +205 -1
  23. package/core/src/wikilinks.ts +244 -35
  24. package/package.json +1 -1
  25. package/src/aggregate-routes.test.ts +230 -0
  26. package/src/contract-errors.test.ts +2 -1
  27. package/src/contract-search.test.ts +17 -0
  28. package/src/mcp-link-warnings-scope.test.ts +144 -0
  29. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  30. package/src/mcp-tools.ts +100 -19
  31. package/src/routes.ts +469 -39
  32. package/src/routing.test.ts +167 -0
  33. package/src/routing.ts +47 -11
  34. package/src/tag-integrity-mcp.test.ts +45 -6
  35. package/src/tag-scope.ts +10 -1
  36. package/src/vault.test.ts +923 -21
  37. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  38. package/web/ui/dist/index.html +1 -1
package/src/routes.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * and the Request, and returns a Response.
12
12
  */
13
13
 
14
- import type { Store, Note, QueryOpts } from "../core/src/types.ts";
14
+ import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
16
  import {
17
17
  collectUnknownTagWarnings,
@@ -22,9 +22,17 @@ 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 { listUnresolvedWikilinks, resolveOrQueueLink, resolveStructuredLinkNote } from "../core/src/wikilinks.ts";
25
+ import {
26
+ listUnresolvedWikilinks,
27
+ resolveOrQueueLink,
28
+ resolveStructuredLinkNote,
29
+ getUnresolvedLinksForNote,
30
+ getUnresolvedLinksForNotes,
31
+ getContentWikilinkWarnings,
32
+ } from "../core/src/wikilinks.ts";
26
33
  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";
34
+ import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, getVaultMap } from "../core/src/notes.ts";
35
+ import { normalizePath } from "../core/src/paths.ts";
28
36
  import {
29
37
  parseContentRange,
30
38
  applyContentRange,
@@ -55,6 +63,7 @@ import {
55
63
  scrubValidationStatusByScope,
56
64
  tagScopeForbidden,
57
65
  tagsWithinScope,
66
+ tagVisibleInScope,
58
67
  } from "./tag-scope.ts";
59
68
  import { ParentCycleError, InvalidFieldDefaultError, InvalidFieldTypeError } from "../core/src/tag-schemas.ts";
60
69
  import { findTokensReferencingTag } from "./token-store.ts";
@@ -812,6 +821,9 @@ export function parseNotesQueryOpts(url: URL): {
812
821
  excludeTags: parseQueryList(url, "exclude_tag"),
813
822
  hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
814
823
  hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
824
+ // Presence filter on dangling outbound wikilinks/structured links
825
+ // (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
826
+ hasBrokenLinks: parseBoolOrUndef(parseQuery(url, "has_broken_links")),
815
827
  path: parseQuery(url, "path") ?? undefined,
816
828
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
817
829
  extension: parseExtensionFilter(url),
@@ -840,6 +852,58 @@ export function parseNotesQueryOpts(url: URL): {
840
852
  return { queryOpts, hasSearch, hasNear, hasCursor };
841
853
  }
842
854
 
855
+ /**
856
+ * Parse the `?aggregate[group_by]=…&aggregate[op]=…&aggregate[field]=…`
857
+ * aggregation params (bracket-style, consistent with `meta[field][op]=`
858
+ * above) into an `AggregateSpec`. Absent entirely (none of the three keys
859
+ * present) → `{}` — no aggregate intent, the caller falls through to a
860
+ * normal query. `group_by` + `op` are required together when ANY of the
861
+ * three is present; `field` is optional at the parser level (its
862
+ * requiredness depends on `op`, enforced by `aggregateNotes` itself). Value
863
+ * validity beyond shape (indexed field, numeric type, sum-requires-field)
864
+ * is ALSO enforced by `aggregateNotes` — same FIELD_NOT_INDEXED /
865
+ * INVALID_QUERY contract every other query surface uses.
866
+ *
867
+ * Returns `{ aggregate? }` or `{ error }` (a 400 Response) on a malformed
868
+ * shape (missing `group_by`/`op`, or an unrecognized `op`).
869
+ */
870
+ function parseAggregateParam(url: URL): { aggregate?: AggregateSpec; error?: Response } {
871
+ const groupBy = parseQuery(url, "aggregate[group_by]");
872
+ const op = parseQuery(url, "aggregate[op]");
873
+ const field = parseQuery(url, "aggregate[field]");
874
+ if (groupBy === null && op === null && field === null) return {};
875
+ if (groupBy === null || op === null) {
876
+ return {
877
+ error: json(
878
+ {
879
+ error: `aggregate requires both aggregate[group_by] and aggregate[op] — group_by is an indexed metadata field or "tag"; op is "count" or "sum".`,
880
+ code: "INVALID_QUERY",
881
+ error_type: "invalid_query",
882
+ field: "aggregate",
883
+ hint: `pass ?aggregate[group_by]=<field|tag>&aggregate[op]=<count|sum>[&aggregate[field]=<numeric field>]`,
884
+ },
885
+ 400,
886
+ ),
887
+ };
888
+ }
889
+ if (op !== "count" && op !== "sum") {
890
+ return {
891
+ error: json(
892
+ {
893
+ error: `invalid aggregate[op]: "${op}" — must be "count" or "sum"`,
894
+ code: "INVALID_QUERY",
895
+ error_type: "invalid_query",
896
+ field: "aggregate.op",
897
+ got: op,
898
+ hint: `pass "count" or "sum"`,
899
+ },
900
+ 400,
901
+ ),
902
+ };
903
+ }
904
+ return { aggregate: { group_by: groupBy, op, field: field ?? undefined } };
905
+ }
906
+
843
907
  /**
844
908
  * Parse include_metadata query param.
845
909
  * - absent/null → undefined (all metadata, default)
@@ -897,6 +961,18 @@ function parseExpandParams(
897
961
  * differing only by extension (vault#330 S1). When the path is
898
962
  * ambiguous and no extension hint is supplied, `getNoteByPath` throws
899
963
  * `AmbiguousPathError` — REST handlers catch it and return 409.
964
+ *
965
+ * Title fallback (additive): when id AND path/extension both miss
966
+ * cleanly (no throw), tries an H1-title match via `getNoteByTitle` — the
967
+ * note whose first `# ` content line equals `idOrPath`, only when
968
+ * exactly one note has that title. Mirrors the MCP-layer `resolveNote`
969
+ * in core/src/mcp.ts and `[[wikilink]]` resolution (core/src/wikilinks.ts);
970
+ * exact id/path always wins first. Skipped (falls through to "not found")
971
+ * when `store.db` isn't available — the `Store` interface documents `db`
972
+ * as always present on the concrete class, but a handful of tests pass a
973
+ * minimal duck-typed stub (e.g. `handleViewNote`'s coverage in
974
+ * published.test.ts) that only implements `getNote`/`getNoteByPath`; this
975
+ * fallback must never turn that into a crash.
900
976
  */
901
977
  async function resolveNote(store: Store, idOrPath: string): Promise<Note | null> {
902
978
  const byId = await store.getNote(idOrPath);
@@ -906,7 +982,9 @@ async function resolveNote(store: Store, idOrPath: string): Promise<Note | null>
906
982
  const explicit = await store.getNoteByPath(extMatch[1]!, extMatch[2]!);
907
983
  if (explicit) return explicit;
908
984
  }
909
- return await store.getNoteByPath(idOrPath);
985
+ const byPath = await store.getNoteByPath(idOrPath);
986
+ if (byPath) return byPath;
987
+ return store.db ? getNoteByTitle(store.db, idOrPath) : null;
910
988
  }
911
989
 
912
990
  async function requireNote(store: Store, idOrPath: string): Promise<Note> {
@@ -1017,6 +1095,13 @@ async function handleNotesInner(
1017
1095
  tagScope.raw,
1018
1096
  );
1019
1097
  }
1098
+ if (parseBool(parseQuery(url, "include_broken_links"), false)) {
1099
+ // No tag-scope filtering needed (unlike include_links above): a
1100
+ // broken-link `target` never resolved to a note, so there's no
1101
+ // neighbor identity/content to leak — just the string this note's
1102
+ // own [[wikilink]]/structured link already named.
1103
+ result.broken_links = getUnresolvedLinksForNote(db, note.id);
1104
+ }
1020
1105
  if (parseBool(parseQuery(url, "include_attachments"), false)) {
1021
1106
  result.attachments = await store.getAttachments(note.id);
1022
1107
  }
@@ -1224,6 +1309,87 @@ async function handleNotesInner(
1224
1309
  400,
1225
1310
  );
1226
1311
  }
1312
+
1313
+ // Aggregation / rollup mode (top new-feature ask from a UX round).
1314
+ // Mutually exclusive with cursor/near — a rollup returns one row per
1315
+ // group, not a paginated/graph-scoped note list — so reject those
1316
+ // combos loudly before touching the DB, then short-circuit: none of
1317
+ // the normal-query output machinery below (expand, include_links,
1318
+ // metadata filtering, ...) applies to rollup rows.
1319
+ const aggregateParsed = parseAggregateParam(url);
1320
+ if (aggregateParsed.error) return aggregateParsed.error;
1321
+ if (aggregateParsed.aggregate) {
1322
+ if (cursorMode) {
1323
+ return json(
1324
+ {
1325
+ error: "aggregate is incompatible with cursor pagination — a rollup has no watermark to page through.",
1326
+ code: "INVALID_QUERY",
1327
+ error_type: "invalid_query",
1328
+ field: "aggregate",
1329
+ hint: "drop `cursor` when using `aggregate`",
1330
+ },
1331
+ 400,
1332
+ );
1333
+ }
1334
+ if (nearNoteIdEarly) {
1335
+ return json(
1336
+ {
1337
+ error: "aggregate is incompatible with near (graph neighborhood).",
1338
+ code: "INVALID_QUERY",
1339
+ error_type: "invalid_query",
1340
+ field: "aggregate",
1341
+ hint: "drop `near` when using `aggregate`",
1342
+ },
1343
+ 400,
1344
+ );
1345
+ }
1346
+ try {
1347
+ let rows;
1348
+ if (tagScope.raw === null) {
1349
+ rows = await store.aggregateNotes({ ...queryOpts, aggregate: aggregateParsed.aggregate });
1350
+ } else {
1351
+ // Tag-scoped: filter to visible notes FIRST — fetch every note
1352
+ // the OTHER filters match (unpaginated, same `limit: 1000000`
1353
+ // "get everything" convention core's `syncAllWikilinks` uses),
1354
+ // narrow via the SAME `filterNotesByTagScope` every other read
1355
+ // path applies, THEN aggregate over just that visible id set
1356
+ // (reusing the `ids` filter `near` already pushes into SQL).
1357
+ const allMatches = await store.queryNotes({ ...queryOpts, limit: 1000000, offset: 0 });
1358
+ const visible = filterNotesByTagScope(allMatches, tagScope.allowed, tagScope.raw);
1359
+ rows = visible.length === 0
1360
+ ? []
1361
+ : await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
1362
+ // That note-level narrowing isn't sufficient on its own under
1363
+ // `group_by: "tag"`: a note can be in scope via one tag while
1364
+ // ALSO carrying an out-of-scope co-tag, and a tag rollup's
1365
+ // `group` values ARE tag names — the co-tag would otherwise
1366
+ // surface directly as a group. Scrub group NAMES too, mirroring
1367
+ // the MCP wrapper's identical fix (src/mcp-tools.ts).
1368
+ if (aggregateParsed.aggregate.group_by === "tag") {
1369
+ rows = rows.filter(
1370
+ (r: any) => typeof r?.group === "string" && tagVisibleInScope(r.group, tagScope.allowed, tagScope.raw),
1371
+ );
1372
+ }
1373
+ }
1374
+ return json(rows);
1375
+ } catch (e: any) {
1376
+ if (e && e.name === "QueryError") {
1377
+ return json(
1378
+ {
1379
+ error: e.message,
1380
+ code: e.code ?? "INVALID_QUERY",
1381
+ error_type: e.error_type ?? "invalid_query",
1382
+ ...(e.field !== undefined ? { field: e.field } : {}),
1383
+ ...(e.got !== undefined ? { got: e.got } : {}),
1384
+ ...(e.hint !== undefined ? { hint: e.hint } : {}),
1385
+ },
1386
+ 400,
1387
+ );
1388
+ }
1389
+ throw e;
1390
+ }
1391
+ }
1392
+
1227
1393
  // Warnings channel (vault#550). `unknown_tag` stays structured-query
1228
1394
  // only (skipped entirely for a tag-scoped session:
1229
1395
  // `collectUnknownTagWarnings` resolves `did_you_mean` against the
@@ -1336,6 +1502,7 @@ async function handleNotesInner(
1336
1502
  const contentRange = parseContentRangeQuery(url, includeContent);
1337
1503
  if (contentRange.error) return contentRange.error;
1338
1504
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
1505
+ const includeBrokenLinks = parseBool(parseQuery(url, "include_broken_links"), false);
1339
1506
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
1340
1507
  const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
1341
1508
  const inclMeta = parseIncludeMetadata(url);
@@ -1427,13 +1594,19 @@ async function handleNotesInner(
1427
1594
  );
1428
1595
  }
1429
1596
 
1430
- if (includeLinks || includeAttachments) {
1597
+ if (includeLinks || includeBrokenLinks || includeAttachments) {
1431
1598
  // Whole-page link hydration in a constant number of queries — the
1432
1599
  // per-note variant cost (1 link query + 1 summary query + N tag
1433
1600
  // queries) × page size. 2026-06-10 perf measurements.
1434
1601
  const linksByNote = includeLinks
1435
1602
  ? linkOps.getLinksHydratedForNotes(db, output.map((n: any) => n.id))
1436
1603
  : null;
1604
+ // Same batched-per-page shape as links (vault#555). No tag-scope
1605
+ // filtering needed — a broken-link `target` never resolved to a
1606
+ // note, so there's no neighbor identity to leak.
1607
+ const brokenLinksByNote = includeBrokenLinks
1608
+ ? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id))
1609
+ : null;
1437
1610
  const enrichedOut: any[] = [];
1438
1611
  for (const n of output) {
1439
1612
  const enriched: any = { ...n };
@@ -1445,6 +1618,7 @@ async function handleNotesInner(
1445
1618
  tagScope.raw,
1446
1619
  );
1447
1620
  }
1621
+ if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
1448
1622
  if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
1449
1623
  enrichedOut.push(enriched);
1450
1624
  }
@@ -1512,13 +1686,122 @@ async function handleNotesInner(
1512
1686
  // layers share `resolveOrQueueLink` from core/wikilinks.ts).
1513
1687
  const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
1514
1688
  const linkWarningsByNote = new Map<string, QueryWarning[]>();
1689
+ const pushLinkWarning = (noteId: string, warning: QueryWarning): void => {
1690
+ const list = linkWarningsByNote.get(noteId) ?? [];
1691
+ list.push(warning);
1692
+ linkWarningsByNote.set(noteId, list);
1693
+ };
1694
+ // Content wikilinks whose note+content pair needs an `unresolved_link`/
1695
+ // `ambiguous_link` warning check (vault#570) — deferred to the same
1696
+ // second pass as `pendingLinks` for the same forward-ref-within-batch
1697
+ // reason (a content `[[wikilink]]` to a batch sibling created later
1698
+ // resolves via the pending-wikilink backfill by the time this runs).
1699
+ const contentWikilinkNotes: { noteId: string; content: string }[] = [];
1700
+ // `if_exists` bookkeeping (vault#555) — mirrors core/src/mcp.ts's
1701
+ // create-note tool exactly (same contract, independent REST-layer
1702
+ // reimplementation sharing the same core primitives). See that file's
1703
+ // doc comments for the full rationale.
1704
+ const existedMap = new Map<string, boolean>();
1705
+ const noMutateIds = new Set<string>();
1706
+ const recordExistedBranch = (resultNote: Note, noMutate: boolean): void => {
1707
+ existedMap.set(resultNote.id, true);
1708
+ if (noMutate) noMutateIds.add(resultNote.id);
1709
+ created.push(resultNote);
1710
+ };
1711
+ const applyExistingNote = async (
1712
+ item: any,
1713
+ existingNote: Note,
1714
+ mode: "ignore" | "update" | "replace",
1715
+ ): Promise<{ note: Note; noMutate: boolean }> => {
1716
+ // CRITICAL scope guard (vault#555 auth-review must-fix): `existingNote`
1717
+ // was resolved VAULT-WIDE by path (store.getNoteByPath) at BOTH call
1718
+ // sites below (proactive + race-backstop). The batch's incoming-tag
1719
+ // pre-validation guards only each item's OWN tags — NOT this resolved
1720
+ // note — so without this a token scoped to `public` could READ (ignore)
1721
+ // or OVERWRITE (update/replace) a note carrying only an out-of-scope
1722
+ // tag `secret` just by naming its path. When the note is outside the
1723
+ // caller's tag scope, treat it as a path conflict — the path is taken,
1724
+ // but invisible to this caller — throwing BEFORE any read/mutation so
1725
+ // we never expose content or write. `noteWithinTagScope` is a no-op for
1726
+ // unscoped tokens (tagScope.raw === null → always true). The throw
1727
+ // propagates to runBatch's PATH_CONFLICT catch → 409 path_conflict,
1728
+ // byte-identical to a genuine conflict (leaks nothing about WHY).
1729
+ if (!noteWithinTagScope(existingNote, tagScope.allowed, tagScope.raw)) {
1730
+ throw new PathConflictError(existingNote.path ?? "");
1731
+ }
1732
+ if (mode === "ignore") {
1733
+ return { note: existingNote, noMutate: true };
1734
+ }
1735
+ const updates: { content?: string; metadata?: Record<string, unknown> } = {};
1736
+ if (mode === "update") {
1737
+ if (item.content !== undefined) updates.content = item.content;
1738
+ if (item.metadata !== undefined) {
1739
+ updates.metadata = mergeMetadata(
1740
+ existingNote.metadata as Record<string, unknown> | null | undefined,
1741
+ item.metadata,
1742
+ );
1743
+ }
1744
+ } else {
1745
+ // "replace" — PUT semantics: content/metadata become exactly this
1746
+ // payload (empty default when omitted), never merged.
1747
+ updates.content = item.content ?? "";
1748
+ updates.metadata = item.metadata ?? {};
1749
+ }
1750
+
1751
+ const incomingTags: string[] = item.tags ?? [];
1752
+ const projectedTags = new Set<string>([...(existingNote.tags ?? []), ...incomingTags]);
1753
+ const projectedMetadata = updates.metadata ?? ((existingNote.metadata as Record<string, unknown>) ?? {});
1754
+ gateStrictWrite(store, writeCtx, { path: existingNote.path, tags: [...projectedTags], metadata: projectedMetadata });
1755
+
1756
+ // vault#555 fix 2 (W8 fix-2 bug class, generalist must-fix): gate the
1757
+ // core UPDATE on the tag/link mutation too, not just `updates` — a
1758
+ // tags-only/links-only "update" leaves `updates` empty, and skipping
1759
+ // store.updateNote would freeze `updated_at` even though store.tagNote
1760
+ // genuinely mutates the note (breaking cursor polling + updated_at
1761
+ // sync). Mirrors the update-note/PATCH hasTagMutation||hasLinkMutation
1762
+ // gate. "replace" always sets content+metadata, so it was unaffected.
1763
+ const hasLinkMutation = item.links !== undefined;
1764
+ let result: Note = existingNote;
1765
+ if (Object.keys(updates).length > 0 || incomingTags.length > 0 || hasLinkMutation) {
1766
+ result = await store.updateNote(existingNote.id, {
1767
+ ...updates,
1768
+ actor: writeCtx.actor,
1769
+ via: writeCtx.via,
1770
+ });
1771
+ }
1772
+
1773
+ // Content-wikilink warnings (vault#570) — this branch's content
1774
+ // update (if any) already ran `syncWikilinks` inside
1775
+ // `store.updateNote` above; queue for the shared second-pass
1776
+ // classification below.
1777
+ if (updates.content !== undefined) {
1778
+ contentWikilinkNotes.push({ noteId: result.id, content: updates.content });
1779
+ }
1780
+
1781
+ if (incomingTags.length > 0) {
1782
+ await store.tagNote(result.id, incomingTags);
1783
+ // Redundant with the outer batch loop's applySchemaDefaults (this
1784
+ // note isn't in `noMutateIds` — only "ignore" hits are), but harmless
1785
+ // (idempotent) — kept so the update/replace branch is self-contained.
1786
+ await applySchemaDefaults(store, db, [result.id], incomingTags);
1787
+ }
1788
+
1789
+ if (item.links) {
1790
+ pendingLinks.push({ sourceId: result.id, links: item.links as { target: string; relationship: string }[] });
1791
+ }
1792
+
1793
+ return { note: (await store.getNote(result.id)) ?? result, noMutate: false };
1794
+ };
1795
+
1515
1796
  // Wrap multi-item batches in a SQLite transaction so a mid-batch
1516
1797
  // failure (path conflict, etc.) rolls back every prior insert. Without
1517
1798
  // this, callers got half-applied batches where the prefix landed and
1518
1799
  // the offending entry surfaced the 409 — see #236. Single-item posts
1519
1800
  // are already atomic at the store layer and skip the wrap so they
1520
1801
  // don't collide with concurrent single-item callers on the shared
1521
- // bun:sqlite connection.
1802
+ // bun:sqlite connection. A caught PATH_CONFLICT (the if_exists race
1803
+ // backstop below) does NOT roll back the transaction — only a throw
1804
+ // that escapes `runBatch` does (see core/src/txn.ts).
1522
1805
  const batched = items.length > 1;
1523
1806
  const runBatch = async (): Promise<void> => {
1524
1807
  for (const item of items) {
@@ -1528,6 +1811,26 @@ async function handleNotesInner(
1528
1811
  const extension = item.extension !== undefined
1529
1812
  ? validateExtension(item.extension)
1530
1813
  : undefined;
1814
+ const effectiveExtension = extension ?? "md";
1815
+ const ifExists: string = item.if_exists ?? "error";
1816
+ const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
1817
+
1818
+ // Proactive existence check (vault#555) — only possible with a
1819
+ // path. Handles the common, non-racing case cleanly: skips the
1820
+ // raw-item strict gate below (it would validate the wrong shape —
1821
+ // see `applyExistingNote`) and goes straight to the collision
1822
+ // branch. The catch below is the backstop for a true race.
1823
+ let existingNote: Note | null = null;
1824
+ if (upsertMode && item.path) {
1825
+ const normalized = normalizePath(item.path);
1826
+ existingNote = normalized ? await store.getNoteByPath(normalized, effectiveExtension) : null;
1827
+ }
1828
+ if (existingNote) {
1829
+ const { note: resultNote, noMutate } = await applyExistingNote(item, existingNote, ifExists as "ignore" | "update" | "replace");
1830
+ recordExistedBranch(resultNote, noMutate);
1831
+ continue;
1832
+ }
1833
+
1531
1834
  // Strict-schema gate (vault#299) — reject before any write so a
1532
1835
  // mid-batch violation rolls back the batch transaction.
1533
1836
  gateStrictWrite(store, writeCtx, {
@@ -1535,22 +1838,48 @@ async function handleNotesInner(
1535
1838
  tags: item.tags,
1536
1839
  metadata: item.metadata,
1537
1840
  });
1538
- const note = await store.createNote(item.content ?? "", {
1539
- id: item.id,
1540
- path: item.path,
1541
- tags: item.tags,
1542
- metadata: item.metadata,
1543
- created_at: item.createdAt ?? item.created_at,
1544
- ...(extension !== undefined ? { extension } : {}),
1545
- // Write-attribution (vault#298) — REST batch create.
1546
- actor: writeCtx.actor,
1547
- via: writeCtx.via,
1548
- });
1841
+ let note: Note;
1842
+ try {
1843
+ note = await store.createNote(item.content ?? "", {
1844
+ id: item.id,
1845
+ path: item.path,
1846
+ tags: item.tags,
1847
+ metadata: item.metadata,
1848
+ created_at: item.createdAt ?? item.created_at,
1849
+ ...(extension !== undefined ? { extension } : {}),
1850
+ // Write-attribution (vault#298) — REST batch create.
1851
+ actor: writeCtx.actor,
1852
+ via: writeCtx.via,
1853
+ });
1854
+ } catch (err: any) {
1855
+ // Race backstop (vault#555): a concurrent writer's INSERT
1856
+ // committed between our proactive check and this one.
1857
+ // Re-resolve the now-existing row and fall through to the SAME
1858
+ // branch a proactive hit would have taken.
1859
+ if (upsertMode && err?.code === "PATH_CONFLICT") {
1860
+ const winner = await store.getNoteByPath(err.path, effectiveExtension);
1861
+ if (winner) {
1862
+ const { note: resultNote, noMutate } = await applyExistingNote(item, winner, ifExists as "ignore" | "update" | "replace");
1863
+ recordExistedBranch(resultNote, noMutate);
1864
+ continue;
1865
+ }
1866
+ }
1867
+ throw err;
1868
+ }
1869
+
1870
+ if (upsertMode) existedMap.set(note.id, false);
1549
1871
 
1550
1872
  if (item.links) {
1551
1873
  pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
1552
1874
  }
1553
1875
 
1876
+ // Content-wikilink warnings (vault#570) — `store.createNote` above
1877
+ // already ran `syncWikilinks` (gated on `content` truthy, same
1878
+ // condition here); queue for the shared second-pass classification.
1879
+ if (item.content) {
1880
+ contentWikilinkNotes.push({ noteId: note.id, content: item.content });
1881
+ }
1882
+
1554
1883
  created.push((await store.getNote(note.id)) ?? note);
1555
1884
  }
1556
1885
 
@@ -1559,24 +1888,40 @@ async function handleNotesInner(
1559
1888
  // that every sibling note in this batch exists. A target that still
1560
1889
  // doesn't resolve is queued for lazy resolution (backfills
1561
1890
  // automatically when a matching note is created later) and surfaces
1562
- // an `unresolved_link` warning naming the target never silent.
1891
+ // an `unresolved_link` warning naming the target. A target that
1892
+ // matched ≥2 notes (vault#570) is neither linked nor queued —
1893
+ // surfaces a distinct `ambiguous_link` warning instead. Never silent.
1563
1894
  for (const { sourceId, links } of pendingLinks) {
1564
1895
  for (const link of links) {
1565
- const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1566
- if (targetId) {
1567
- await store.createLink(sourceId, targetId, link.relationship);
1896
+ const outcome = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1897
+ if (outcome.status === "resolved") {
1898
+ await store.createLink(sourceId, outcome.note_id, link.relationship);
1899
+ } else if (outcome.status === "ambiguous") {
1900
+ pushLinkWarning(sourceId, {
1901
+ code: "ambiguous_link",
1902
+ message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
1903
+ target: link.target,
1904
+ relationship: link.relationship,
1905
+ candidate_count: outcome.candidates.length,
1906
+ });
1568
1907
  } else {
1569
- const list = linkWarningsByNote.get(sourceId) ?? [];
1570
- list.push({
1908
+ pushLinkWarning(sourceId, {
1571
1909
  code: "unresolved_link",
1572
1910
  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.`,
1573
1911
  target: link.target,
1574
1912
  relationship: link.relationship,
1575
1913
  });
1576
- linkWarningsByNote.set(sourceId, list);
1577
1914
  }
1578
1915
  }
1579
1916
  }
1917
+
1918
+ // --- Content-wikilink warnings (vault#570) ---
1919
+ // Same forward-ref-aware timing as the structured-links pass above.
1920
+ for (const { noteId, content } of contentWikilinkNotes) {
1921
+ for (const warning of getContentWikilinkWarnings(db, noteId, content)) {
1922
+ pushLinkWarning(noteId, warning);
1923
+ }
1924
+ }
1580
1925
  };
1581
1926
  try {
1582
1927
  await (batched ? transactionAsync(db, runBatch) : runBatch());
@@ -1624,6 +1969,10 @@ async function handleNotesInner(
1624
1969
  // applied so the common no-defaults path adds zero extra reads.
1625
1970
  const mutatedIds = new Set<string>();
1626
1971
  for (const note of created) {
1972
+ // "ignore" hits (vault#555) promise ZERO mutation, including
1973
+ // schema-default backfill of a since-added default the existing
1974
+ // note predates — skip them entirely.
1975
+ if (noMutateIds.has(note.id)) continue;
1627
1976
  if (note.tags?.length) {
1628
1977
  for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
1629
1978
  mutatedIds.add(id);
@@ -1643,12 +1992,35 @@ async function handleNotesInner(
1643
1992
  // unchanged when no tag declares fields, so vaults without any tag
1644
1993
  // schemas see no behavior change. Fold in any `unresolved_link`
1645
1994
  // warnings (vault#555) — additive, present only when this note's
1646
- // `links` had a target that didn't resolve.
1995
+ // `links` had a target that didn't resolve. `existed` (vault#555) is
1996
+ // attached ONLY for items whose `if_exists` actually engaged — the
1997
+ // default "error" path's response shape is untouched.
1647
1998
  const final = refreshed.map((n) => {
1648
1999
  const validated = attachValidationStatus(store, db, n);
1649
2000
  const warnings = linkWarningsByNote.get(n.id);
1650
- return warnings && warnings.length > 0 ? { ...validated, warnings } : validated;
2001
+ const existed = existedMap.get(n.id);
2002
+ let out: any = validated;
2003
+ if (warnings && warnings.length > 0) out = { ...out, warnings };
2004
+ if (existed !== undefined) out = { ...out, existed };
2005
+ return out;
1651
2006
  });
2007
+
2008
+ // Batch summary (vault#555) — compact shape instead of N full note
2009
+ // objects. Batch-only (a `notes` array); `summary` is ignored on a
2010
+ // single-note POST. Mirrors the MCP create-note tool exactly.
2011
+ if (body.notes && body.summary === true) {
2012
+ return json(
2013
+ {
2014
+ created: final.filter((n: any) => n.existed !== true).length,
2015
+ ids: final.map((n: any) => n.id),
2016
+ // Reserved for future partial-batch-failure reporting — a batch
2017
+ // create is all-or-nothing today, so this is always empty.
2018
+ failed: [] as unknown[],
2019
+ },
2020
+ 201,
2021
+ );
2022
+ }
2023
+
1652
2024
  return json(body.notes ? final : final[0], 201);
1653
2025
  }
1654
2026
 
@@ -1915,15 +2287,25 @@ async function handleNotesInner(
1915
2287
  // fresh note).
1916
2288
  // - Missing target notes skip silently (mirrors MCP).
1917
2289
  const linksAdd = (body.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
1918
- // `unresolved_link` warnings (vault#555) — a target that doesn't
1919
- // resolve is queued for lazy resolution (backfills automatically
1920
- // when a matching note is created later), never silently dropped.
2290
+ // `unresolved_link` / `ambiguous_link` warnings (vault#555, vault#570)
2291
+ // — a target that doesn't resolve is queued for lazy resolution
2292
+ // (backfills automatically when a matching note is created later),
2293
+ // and a target matching ≥2 notes is neither linked nor queued.
2294
+ // Never silently dropped.
1921
2295
  const createWarnings: QueryWarning[] = [];
1922
2296
  if (linksAdd) {
1923
2297
  for (const link of linksAdd) {
1924
- const targetId = resolveOrQueueLink(db, created.id, link.target, link.relationship);
1925
- if (targetId) {
1926
- await store.createLink(created.id, targetId, link.relationship, link.metadata);
2298
+ const outcome = resolveOrQueueLink(db, created.id, link.target, link.relationship);
2299
+ if (outcome.status === "resolved") {
2300
+ await store.createLink(created.id, outcome.note_id, link.relationship, link.metadata);
2301
+ } else if (outcome.status === "ambiguous") {
2302
+ createWarnings.push({
2303
+ code: "ambiguous_link",
2304
+ message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
2305
+ target: link.target,
2306
+ relationship: link.relationship,
2307
+ candidate_count: outcome.candidates.length,
2308
+ });
1927
2309
  } else {
1928
2310
  createWarnings.push({
1929
2311
  code: "unresolved_link",
@@ -1934,6 +2316,13 @@ async function handleNotesInner(
1934
2316
  }
1935
2317
  }
1936
2318
  }
2319
+ // Content-wikilink warnings (vault#570) — `store.createNote` above
2320
+ // already ran `syncWikilinks`; this is the single-note (non-batch)
2321
+ // create path, so there's no batch-sibling forward-ref to wait for
2322
+ // — compute right away against the committed content.
2323
+ if (content) {
2324
+ createWarnings.push(...getContentWikilinkWarnings(db, created.id, content));
2325
+ }
1937
2326
  const final = await store.getNote(created.id);
1938
2327
  if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
1939
2328
  const validated: any = attachValidationStatus(store, db, final);
@@ -2171,6 +2560,14 @@ async function handleNotesInner(
2171
2560
  // moved despite a real tags/note_tags or links change.
2172
2561
  const hasTagMutation = (body.tags?.add?.length ?? 0) > 0 || (body.tags?.remove?.length ?? 0) > 0;
2173
2562
  const hasLinkMutation = body.links?.add !== undefined || body.links?.remove !== undefined;
2563
+ // Content-wikilink warnings (vault#570) gate on the SAME condition
2564
+ // `store.updateNote`/`syncWikilinks` use to decide whether to re-sync
2565
+ // content wikilinks at all — a tags/links-only PATCH must not
2566
+ // spuriously re-warn about pre-existing broken links this call never
2567
+ // touched.
2568
+ const contentChanged = updates.content !== undefined
2569
+ || updates.append !== undefined
2570
+ || updates.prepend !== undefined;
2174
2571
  if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
2175
2572
  // Write-attribution (vault#298) — REST update. Stamp the most-recent-
2176
2573
  // write columns on the same UPDATE that bumps updated_at. `updates`
@@ -2197,16 +2594,25 @@ async function handleNotesInner(
2197
2594
  await store.untagNote(note.id, body.tags.remove);
2198
2595
  }
2199
2596
 
2200
- // Add links. `unresolved_link` warnings (vault#555) — a target that
2201
- // doesn't resolve is queued for lazy resolution (backfills
2202
- // automatically when a matching note is created later), never
2203
- // silently dropped.
2597
+ // Add links. `unresolved_link` / `ambiguous_link` warnings (vault#555,
2598
+ // vault#570) — a target that doesn't resolve is queued for lazy
2599
+ // resolution (backfills automatically when a matching note is created
2600
+ // later); a target matching ≥2 notes is neither linked nor queued.
2601
+ // Never silently dropped.
2204
2602
  const linkWarnings: QueryWarning[] = [];
2205
2603
  if (body.links?.add) {
2206
2604
  for (const link of body.links.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[]) {
2207
- const targetId = resolveOrQueueLink(db, note.id, link.target, link.relationship);
2208
- if (targetId) {
2209
- await store.createLink(note.id, targetId, link.relationship, link.metadata);
2605
+ const outcome = resolveOrQueueLink(db, note.id, link.target, link.relationship);
2606
+ if (outcome.status === "resolved") {
2607
+ await store.createLink(note.id, outcome.note_id, link.relationship, link.metadata);
2608
+ } else if (outcome.status === "ambiguous") {
2609
+ linkWarnings.push({
2610
+ code: "ambiguous_link",
2611
+ message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
2612
+ target: link.target,
2613
+ relationship: link.relationship,
2614
+ candidate_count: outcome.candidates.length,
2615
+ });
2210
2616
  } else {
2211
2617
  linkWarnings.push({
2212
2618
  code: "unresolved_link",
@@ -2228,6 +2634,13 @@ async function handleNotesInner(
2228
2634
  // `toNoteIndex` drops unknown fields).
2229
2635
  const updatedNote = await store.getNote(note.id);
2230
2636
  if (updatedNote === null) return json({ error: "Note disappeared", error_type: "not_found" }, 404);
2637
+ // Content-wikilink warnings (vault#570) — `store.updateNote` above
2638
+ // already ran `syncWikilinks` when `contentChanged`; this PATCH
2639
+ // handles a single note (no batch-sibling forward-ref to wait for),
2640
+ // so compute right away against the committed content.
2641
+ if (contentChanged) {
2642
+ linkWarnings.push(...getContentWikilinkWarnings(db, note.id, updatedNote.content));
2643
+ }
2231
2644
  const validated: any = attachValidationStatus(store, db, updatedNote);
2232
2645
  // Echo hydrated links when a link mutation was part of this request,
2233
2646
  // OR the caller explicitly asked for them via `?include_links=true`
@@ -3043,6 +3456,13 @@ export async function handleVault(
3043
3456
  * configured AND available), which is what a surface gates its mic on.
3044
3457
  */
3045
3458
  resolveCapability: () => Promise<TranscriptionCapability> = resolveTranscriptionCapability,
3459
+ /**
3460
+ * Tag-scope allowlist (front-door structural map). Threaded from
3461
+ * routing.ts the same way every other read handler gets it. Unscoped
3462
+ * (`NO_TAG_SCOPE`) by default so every pre-existing call site — none of
3463
+ * which pass this — is unaffected.
3464
+ */
3465
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
3046
3466
  ): Promise<Response> {
3047
3467
  const url = new URL(req.url);
3048
3468
 
@@ -3052,6 +3472,16 @@ export async function handleVault(
3052
3472
  // and available. `minutes_remaining` is omitted (cloud/plan concern;
3053
3473
  // self-host is unmetered). This is the field Notes gates the mic on.
3054
3474
  result.transcription = await resolveCapability();
3475
+ // Front-door structural map — ALWAYS included (unlike `stats`, which is
3476
+ // include_stats-gated): three cheap grouped-COUNT queries, so a fresh
3477
+ // reader orients in one call. Scope-aware: a tag-scoped token's map
3478
+ // covers only notes reachable through an in-scope tag (`tagFilter`
3479
+ // restricts the underlying query rather than post-hoc filtering an
3480
+ // unscoped rollup — see `getVaultMap`'s doc comment for why).
3481
+ result.map =
3482
+ tagScope.raw === null
3483
+ ? getVaultMap(store.db)
3484
+ : getVaultMap(store.db, { tagFilter: [...(tagScope.allowed ?? [])] });
3055
3485
  if (parseBool(parseQuery(url, "include_stats"), false)) {
3056
3486
  result.stats = await store.getVaultStats();
3057
3487
  }