@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.
- package/core/src/aggregate.test.ts +260 -0
- package/core/src/contract-taxonomy.test.ts +26 -2
- package/core/src/contract-typed-index.test.ts +4 -2
- package/core/src/core.test.ts +1151 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/doctor.ts +23 -1
- package/core/src/indexed-fields.test.ts +4 -1
- package/core/src/indexed-fields.ts +6 -1
- package/core/src/links.ts +22 -0
- package/core/src/mcp.ts +587 -79
- package/core/src/notes.ts +371 -18
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +42 -5
- package/core/src/store.ts +129 -5
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/types.ts +98 -0
- package/core/src/ulid.test.ts +56 -0
- package/core/src/ulid.ts +116 -0
- package/core/src/vault-projection.ts +19 -1
- package/core/src/wikilinks.test.ts +205 -1
- package/core/src/wikilinks.ts +244 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-search.test.ts +17 -0
- package/src/mcp-link-warnings-scope.test.ts +144 -0
- package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
- package/src/mcp-tools.ts +100 -19
- package/src/routes.ts +469 -39
- package/src/routing.test.ts +167 -0
- package/src/routing.ts +47 -11
- package/src/tag-integrity-mcp.test.ts +45 -6
- package/src/tag-scope.ts +10 -1
- package/src/vault.test.ts +923 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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
|
|
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
|
|
1566
|
-
if (
|
|
1567
|
-
await store.createLink(sourceId,
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1919
|
-
// resolve is queued for lazy resolution
|
|
1920
|
-
// when a matching note is created later),
|
|
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
|
|
1925
|
-
if (
|
|
1926
|
-
await store.createLink(created.id,
|
|
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
|
|
2201
|
-
// doesn't resolve is queued for lazy
|
|
2202
|
-
// automatically when a matching note is created
|
|
2203
|
-
//
|
|
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
|
|
2208
|
-
if (
|
|
2209
|
-
await store.createLink(note.id,
|
|
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
|
}
|