@openparachute/vault 0.7.0-rc.9 → 0.7.2-rc.3

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 (47) 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 +737 -2
  5. package/core/src/cursor-keyset-ms.test.ts +537 -0
  6. package/core/src/cursor.ts +76 -6
  7. package/core/src/doctor.ts +23 -1
  8. package/core/src/hooks.ts +9 -0
  9. package/core/src/indexed-fields.test.ts +4 -1
  10. package/core/src/indexed-fields.ts +6 -1
  11. package/core/src/links.ts +22 -0
  12. package/core/src/mcp.ts +322 -53
  13. package/core/src/notes.ts +518 -67
  14. package/core/src/paths.ts +4 -0
  15. package/core/src/portable-md.test.ts +161 -0
  16. package/core/src/portable-md.ts +140 -9
  17. package/core/src/query-warnings.ts +60 -12
  18. package/core/src/schema-defaults.ts +7 -1
  19. package/core/src/schema.ts +128 -2
  20. package/core/src/seed-packs.ts +55 -15
  21. package/core/src/store.ts +141 -7
  22. package/core/src/tag-schemas.ts +20 -7
  23. package/core/src/txn.test.ts +100 -4
  24. package/core/src/txn.ts +119 -24
  25. package/core/src/types.ts +89 -0
  26. package/core/src/ulid.test.ts +56 -0
  27. package/core/src/ulid.ts +116 -0
  28. package/core/src/vault-projection.ts +19 -1
  29. package/core/src/wikilinks.test.ts +205 -1
  30. package/core/src/wikilinks.ts +200 -35
  31. package/package.json +1 -1
  32. package/src/aggregate-routes.test.ts +230 -0
  33. package/src/cli.ts +6 -0
  34. package/src/contract-errors.test.ts +2 -1
  35. package/src/contract-search.test.ts +17 -0
  36. package/src/mcp-link-warnings-scope.test.ts +144 -0
  37. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  38. package/src/mcp-tools.ts +76 -17
  39. package/src/mirror-import.ts +5 -0
  40. package/src/routes.ts +298 -24
  41. package/src/routing.test.ts +167 -0
  42. package/src/routing.ts +47 -11
  43. package/src/tag-integrity-mcp.test.ts +45 -6
  44. package/src/tag-scope.ts +10 -1
  45. package/src/vault.test.ts +557 -21
  46. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  47. package/web/ui/dist/index.html +1 -1
package/core/src/notes.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Database, type SQLQueryBindings } from "bun:sqlite";
2
- import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats } from "./types.js";
2
+ import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats, VaultMap, AggregateSpec, AggregateRow } from "./types.js";
3
3
  import { normalizePath } from "./paths.js";
4
4
  import { transaction } from "./txn.js";
5
5
  import {
@@ -14,8 +14,7 @@ import {
14
14
  computeQueryHash,
15
15
  decodeCursor,
16
16
  encodeCursor,
17
- isoToMillis,
18
- millisToIso,
17
+ timestampToMs,
19
18
  type CursorPayload,
20
19
  type QueryHashInputs,
21
20
  } from "./cursor.js";
@@ -28,8 +27,7 @@ import {
28
27
  SEARCH_WEIGHT_CONTENT,
29
28
  type SearchMode,
30
29
  } from "./search-query.js";
31
-
32
- let idCounter = 0;
30
+ import { generateUlid } from "./ulid.js";
33
31
 
34
32
  /**
35
33
  * Write-attribution context (vault#298) — the two axes of provenance threaded
@@ -61,20 +59,25 @@ function attrValue(v: string | null | undefined): string | null {
61
59
  return t.length === 0 ? null : t;
62
60
  }
63
61
 
64
- /** Generate a timestamp-based ID: YYYY-MM-DD-HH-MM-SS-ffffff */
62
+ /**
63
+ * Generate a new note/attachment ID.
64
+ *
65
+ * As of vault#ulid-ids this returns a ULID (see `ulid.ts`) — monotonic,
66
+ * lexicographically time-sortable, Crockford base32, opaque, and
67
+ * collision-resistant. Previously this returned a timestamp-format ID
68
+ * (`YYYY-MM-DD-HH-MM-SS-ffffff`).
69
+ *
70
+ * IMPORTANT: existing notes are NOT migrated. Old timestamp-format IDs
71
+ * stay exactly as they are — only newly-generated IDs use the ULID
72
+ * format, so a vault's `id` column is (and must remain) a mix of both
73
+ * shapes indefinitely. Nothing may assume a uniform id format, and
74
+ * nothing may parse a note's ID to recover its creation time — that's
75
+ * what the `created_at` column is for. The cursor-pagination tiebreaker
76
+ * (`cursor.ts`) treats `id` as an opaque, stable string for ordering
77
+ * ties only, which holds for any id format.
78
+ */
65
79
  export function generateId(): string {
66
- const now = new Date();
67
- const pad = (n: number, len = 2) => String(n).padStart(len, "0");
68
- const micro = now.getMilliseconds() * 1000 + (idCounter++ % 1000);
69
- return [
70
- now.getFullYear(),
71
- pad(now.getMonth() + 1),
72
- pad(now.getDate()),
73
- pad(now.getHours()),
74
- pad(now.getMinutes()),
75
- pad(now.getSeconds()),
76
- pad(micro, 6),
77
- ].join("-");
80
+ return generateUlid();
78
81
  }
79
82
 
80
83
  export function createNote(
@@ -111,10 +114,18 @@ export function createNote(
111
114
  // value. Hook-style writes with `skipUpdatedAt` preserve this; real user
112
115
  // edits bump it strictly upward, so `updated_at > created_at` still means
113
116
  // "user-touched since creation."
117
+ //
118
+ // `updated_at_ms` (vault#586) is the integer keyset-ordering mirror of
119
+ // `updated_at`. Derived from the SAME `createdAt` value with the UTC-correct
120
+ // `timestampToMs`; a `null` (unparseable caller-supplied `created_at`, e.g.
121
+ // via `createNoteRaw` during import) falls back to wall-clock now so a fresh
122
+ // row never lands with a NULL keyset key. Import's `restoreNoteTimestamps`
123
+ // overwrites both columns from the exported bytes immediately after.
124
+ const updatedAtMs = timestampToMs(createdAt) ?? Date.now();
114
125
  try {
115
126
  db.prepare(
116
- `INSERT INTO notes (id, content, path, metadata, created_at, updated_at, extension, created_by, created_via, last_updated_by, last_updated_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
117
- ).run(id, content, path, metadata, createdAt, createdAt, extension, actor, via, actor, via);
127
+ `INSERT INTO notes (id, content, path, metadata, created_at, updated_at, updated_at_ms, extension, created_by, created_via, last_updated_by, last_updated_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
128
+ ).run(id, content, path, metadata, createdAt, createdAt, updatedAtMs, extension, actor, via, actor, via);
118
129
  } catch (err) {
119
130
  if (path !== null && isPathUniqueError(err)) {
120
131
  throw new PathConflictError(path);
@@ -183,6 +194,66 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
183
194
  );
184
195
  }
185
196
 
197
+ /**
198
+ * Extract a note's "H1 title" — the first line in `content` that starts
199
+ * with a literal `"# "` (a level-1 Markdown heading; `"## "`+ don't match
200
+ * because `^#[ \t]` fails at that line's start once a second `#` follows
201
+ * the first). Returns null when no such line exists, or the text after
202
+ * the marker is blank once trimmed.
203
+ */
204
+ export function extractH1Title(content: string): string | null {
205
+ const match = content.match(/^#[ \t]+(.+)$/m);
206
+ if (!match) return null;
207
+ const title = match[1]!.trim();
208
+ return title.length > 0 ? title : null;
209
+ }
210
+
211
+ /**
212
+ * Find every note whose H1 title (see {@link extractH1Title}) equals
213
+ * `title`, case-insensitively (matches the COLLATE NOCASE policy every
214
+ * other path/basename lookup in this file uses). Used as the title-fallback
215
+ * step for wikilink/id/path resolution — a target that misses on
216
+ * id/path/basename gets one more chance against notes whose displayed
217
+ * title differs from their path.
218
+ *
219
+ * This is a full-content scan: no index exists on "first heading line," so
220
+ * every note's content is read once and matched in JS. Acceptable because
221
+ * every call site reaches this ONLY after the cheap indexed lookups (id,
222
+ * path, basename) have already missed — a rare fallback path, not the hot
223
+ * resolution route.
224
+ */
225
+ export function findNotesByTitle(db: Database, title: string): { id: string; path: string | null }[] {
226
+ const needle = title.trim().toLowerCase();
227
+ if (!needle) return [];
228
+ const rows = db.prepare("SELECT id, path, content FROM notes").all() as {
229
+ id: string;
230
+ path: string | null;
231
+ content: string;
232
+ }[];
233
+ const matches: { id: string; path: string | null }[] = [];
234
+ for (const row of rows) {
235
+ const h1 = extractH1Title(row.content);
236
+ if (h1 && h1.toLowerCase() === needle) {
237
+ matches.push({ id: row.id, path: row.path });
238
+ }
239
+ }
240
+ return matches;
241
+ }
242
+
243
+ /**
244
+ * Title-fallback lookup: resolve `title` to a note ONLY when exactly one
245
+ * note in the vault carries that H1 title (see {@link findNotesByTitle}).
246
+ * Zero matches or 2+ matches (ambiguous) both return null — "don't guess"
247
+ * mirrors the existing basename-ambiguity policy (vault#328). Callers use
248
+ * this as the LAST resort after id and path/basename resolution have
249
+ * already missed; exact id/path matches always win first.
250
+ */
251
+ export function getNoteByTitle(db: Database, title: string): Note | null {
252
+ const matches = findNotesByTitle(db, title);
253
+ if (matches.length !== 1) return null;
254
+ return getNote(db, matches[0]!.id);
255
+ }
256
+
186
257
  export function getNotes(db: Database, ids: string[]): Note[] {
187
258
  if (ids.length === 0) return [];
188
259
  // Dedupe before chunking: a duplicate id straddling two chunk boundaries
@@ -402,6 +473,63 @@ export function validateExtension(extension: unknown): string {
402
473
  return extension;
403
474
  }
404
475
 
476
+ /**
477
+ * Thrown by {@link validatePath} when a caller-supplied note `path` can never
478
+ * round-trip safely (vault#589 / FIX 2). Carries a stable `error_type`
479
+ * (`invalid_path`) so REST's catch and the generic MCP domain-error mapping
480
+ * (`src/mcp-http.ts`) both surface a clean 400 — same shape rule as
481
+ * `ExtensionValidationError`.
482
+ */
483
+ export class PathValidationError extends Error {
484
+ code = "INVALID_PATH" as const;
485
+ error_type = "invalid_path" as const;
486
+ path: string;
487
+ reason: string;
488
+
489
+ constructor(path: string, reason: string) {
490
+ super(`invalid_path: "${path}" ${reason}`);
491
+ this.name = "PathValidationError";
492
+ this.path = path;
493
+ this.reason = reason;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Reject a caller-supplied note path that can never round-trip safely
499
+ * (vault#589 / FIX 2). Two rules, both enforced at the WRITE surface only
500
+ * (REST + MCP create/update, exactly where `validateExtension` sits):
501
+ *
502
+ * 1. **No NUL byte** — never valid in a filename. A NUL-in-path note keeps
503
+ * its resolved target inside the export root (so it slips the traversal
504
+ * guard), then uncaught-throws `writeFileSync`, aborting the ENTIRE vault
505
+ * export for everyone. Rejecting NUL at write is the primary fix; the
506
+ * export sink's per-file try/catch (portable-md.ts) is the belt for rows
507
+ * that predate this guard.
508
+ * 2. **No `..` path segment** — traversal-shaped. Export guard-skips such a
509
+ * note (silently un-round-trippable) and it has no legitimate use as a
510
+ * vault note path.
511
+ *
512
+ * A `null`/empty-after-normalize path is fine — notes need no path. Reads /
513
+ * queries never call this, so a lookup by a `..`/NUL path degrades to
514
+ * not-found rather than throwing. The Store itself still trusts internal /
515
+ * importer writes (mirrors the `validateExtension` split). Throws
516
+ * `PathValidationError`.
517
+ */
518
+ export function validatePath(path: unknown): void {
519
+ if (path === null || path === undefined) return;
520
+ if (typeof path !== "string") return; // non-string: not a path; caller's shape check owns it
521
+ // NUL check on the RAW value — `normalizePath` strips NUL, so this must run
522
+ // before normalization to actually reject rather than silently clean.
523
+ if (path.includes("\0")) {
524
+ throw new PathValidationError(path, "contains a NUL byte");
525
+ }
526
+ const normalized = normalizePath(path);
527
+ if (normalized === null) return;
528
+ if (normalized.split("/").some((seg) => seg === "..")) {
529
+ throw new PathValidationError(path, "contains a '..' path segment (path traversal)");
530
+ }
531
+ }
532
+
405
533
  /**
406
534
  * Match bun:sqlite's UNIQUE-constraint error on the notes path index.
407
535
  * Post-vault#328 the unique index is composite `(path, extension)`, so
@@ -478,7 +606,9 @@ export function updateNote(
478
606
  // and path has been removed.
479
607
 
480
608
  const sets: string[] = [];
481
- const values: (string | null)[] = [];
609
+ // `updated_at_ms` binds as a number (INTEGER column), so the value list
610
+ // carries numbers alongside strings/nulls (vault#586).
611
+ const values: (string | number | null)[] = [];
482
612
 
483
613
  // Hooks and other machine-level writers pass `skipUpdatedAt: true` so
484
614
  // their metadata markers don't look like user activity. See issue #44.
@@ -496,6 +626,12 @@ export function updateNote(
496
626
  }
497
627
  sets.push("updated_at = ?");
498
628
  values.push(now);
629
+ // `updated_at_ms` (vault#586) rides the SAME gate as `updated_at` — the
630
+ // integer keyset-ordering mirror must move in lockstep with the string.
631
+ // `now` is canonical `.toISOString()`, so `timestampToMs` never returns
632
+ // null here; the fallback is belt-and-suspenders.
633
+ sets.push("updated_at_ms = ?");
634
+ values.push(timestampToMs(now) ?? Date.now());
499
635
 
500
636
  // Write-attribution (vault#298): the most-recent-write columns ride the
501
637
  // SAME gate as `updated_at`. A `skipUpdatedAt` machine write doesn't bump
@@ -769,8 +905,20 @@ function validateIsoDateValue(field: string, value: string): void {
769
905
  }
770
906
  }
771
907
 
772
- export function queryNotes(db: Database, opts: QueryOpts): Note[] {
773
- validateLimitOffset(opts);
908
+ /**
909
+ * Build the shared WHERE-clause conditions + bound params for the filter
910
+ * surface both `queryNotes` and `aggregateNotes` apply: tag membership
911
+ * (include/exclude/presence), link/broken-link presence, id-set scoping,
912
+ * exact path / path-prefix / extension, write-attribution, metadata
913
+ * (operator + plain-equality), and date range. Does NOT include the
914
+ * cursor-keyset predicate, ORDER BY, or LIMIT/OFFSET — those are
915
+ * query-shape concerns specific to `queryNotes`'s paginated-list contract
916
+ * and don't apply to an aggregate rollup (which spans every matching row,
917
+ * not a page of them). Extracted so aggregation reuses the EXACT filter
918
+ * semantics a normal query applies, rather than risking the two drifting
919
+ * apart.
920
+ */
921
+ function buildFilterConditions(db: Database, opts: QueryOpts): { conditions: string[]; params: SQLQueryBindings[] } {
774
922
  const conditions: string[] = [];
775
923
  const params: SQLQueryBindings[] = [];
776
924
 
@@ -1024,6 +1172,11 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
1024
1172
  if (field === "created_at") {
1025
1173
  column = "n.created_at";
1026
1174
  } else if (field === "updated_at") {
1175
+ // NOTE (vault#586 follow-up): this range filter compares the TEXT
1176
+ // `updated_at`, which is inconsistent on non-canonical rows the same way
1177
+ // the cursor keyset was pre-v26. Cursor pagination moved to the integer
1178
+ // `updated_at_ms` column; `date_filter`/`order_by`/export `--since` still
1179
+ // read the string and are tracked separately. Behavior unchanged here.
1027
1180
  column = "n.updated_at";
1028
1181
  } else {
1029
1182
  // Re-uses the same indexed-field gate as `metadata` operator queries
@@ -1054,7 +1207,22 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
1054
1207
  }
1055
1208
  }
1056
1209
 
1057
- // ---- Cursor predicate (vault#313) ----
1210
+ return { conditions, params };
1211
+ }
1212
+
1213
+ /**
1214
+ * `_outUpdatedAtMs` (internal, vault#586) — when supplied, the phase-1 page
1215
+ * query writes each returned id's integer `updated_at_ms` keyset key into it.
1216
+ * Only `queryNotesPaged` passes it, to derive the cursor watermark from the
1217
+ * SAME statement that determined page membership + order (one consistent
1218
+ * snapshot — see the call site). Ordinary callers omit it and are unaffected;
1219
+ * it never touches the returned `Note` shape.
1220
+ */
1221
+ export function queryNotes(db: Database, opts: QueryOpts, _outUpdatedAtMs?: Map<string, number>): Note[] {
1222
+ validateLimitOffset(opts);
1223
+ const { conditions, params } = buildFilterConditions(db, opts);
1224
+
1225
+ // ---- Cursor predicate (vault#313; keyset column vault#586) ----
1058
1226
  //
1059
1227
  // Cursor mode is keyed on PRESENCE of `opts.cursor`, not truthiness
1060
1228
  // (vault#550 bootstrap fix). `cursor: ""` is the bootstrap call — "I want
@@ -1065,24 +1233,38 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
1065
1233
  // Before this fix, `if (opts.cursor)` treated an empty string exactly
1066
1234
  // like "no cursor" — the caller's bootstrap intent silently vanished and
1067
1235
  // the first page came back in `created_at` order instead of the
1068
- // `updated_at` keyset order the SECOND page (a real cursor) would use,
1236
+ // keyset order the SECOND page (a real cursor) would use,
1069
1237
  // so naive "did I see this note already" comparisons could skip or
1070
1238
  // duplicate rows across the boundary.
1071
1239
  //
1240
+ // The keyset orders on the integer `n.updated_at_ms` column (vault#586),
1241
+ // NOT the TEXT `n.updated_at`. That column is the single source of truth
1242
+ // for cursor ordering: walk-order, the boundary predicate below, and the
1243
+ // watermark in `queryNotesPaged` all read the SAME integer, so they can't
1244
+ // diverge the way three separate readings of `updated_at` did on
1245
+ // aged/imported vaults whose timestamps aren't canonical `.toISOString()`
1246
+ // (space-form / offset / no-`Z`) — under which TEXT-lex order, an ISO
1247
+ // boundary string, and a `Date.parse` watermark disagreed and silently
1248
+ // skipped or re-delivered rows.
1249
+ //
1072
1250
  // When a REAL (non-empty) cursor is present, decode it, verify its
1073
1251
  // query_hash matches the current query, and add a keyset predicate of
1074
1252
  // the form:
1075
1253
  //
1076
- // (updated_at > last_updated_at)
1077
- // OR (updated_at = last_updated_at AND id > last_id)
1254
+ // (updated_at_ms > last_updated_at)
1255
+ // OR (updated_at_ms = last_updated_at AND id > last_id)
1078
1256
  //
1079
- // The cursor also forces ORDER BY n.updated_at ASC, n.id ASC so the
1080
- // watermark math is soundpaginating by updated_at while ordering
1081
- // by created_at would skip rows whose update timestamp differs from
1082
- // their creation timestamp. `orderBy` and `sort: "desc"` are mutually
1083
- // exclusive with cursor mode (a "since last checked" loop wants
1084
- // ascending updated_at, full stop); we reject with INVALID_QUERY so
1085
- // callers don't silently get a broken iteration.
1257
+ // The cursor payload's `last_updated_at` is ALREADY a millisecond epoch
1258
+ // (see cursor.ts) the same units as the column so it binds directly
1259
+ // with no ISO round-trip. This is also why existing client cursors keep
1260
+ // working unchanged across the upgrade: the encoded watermark was always
1261
+ // ms. The cursor forces ORDER BY n.updated_at_ms ASC, n.id ASC so the
1262
+ // watermark math is sound paginating by update time while ordering by
1263
+ // created_at would skip rows whose update differs from their creation.
1264
+ // `orderBy` and `sort: "desc"` are mutually exclusive with cursor mode (a
1265
+ // "since last checked" loop wants ascending update order, full stop); we
1266
+ // reject with INVALID_QUERY so callers don't silently get a broken
1267
+ // iteration.
1086
1268
  const cursorMode = opts.cursor !== undefined;
1087
1269
  let cursorPayload: CursorPayload | null = null;
1088
1270
  if (cursorMode) {
@@ -1107,20 +1289,17 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
1107
1289
  "cursor_query_mismatch",
1108
1290
  );
1109
1291
  }
1110
- // Translate the millis watermark back to an ISO string for the SQL
1111
- // comparison. SQLite's `n.updated_at` is TEXT in canonical ISO form
1112
- // (the store's `toISOString()` output), and ISO timestamps sort
1113
- // lexicographically in the same order as their millisecond epochs
1114
- // when they all use the same canonical form which every timestamp
1115
- // vault mints does. Cursors minted on heterogeneous timestamps
1116
- // (e.g. an import that preserved unusual formatting) are still
1117
- // safe: we round-trip the cursor's millis through `new Date()`'s
1118
- // canonical ISO so the comparison is apples-to-apples.
1119
- const cursorIso = millisToIso(cursorPayload.last_updated_at);
1292
+ // The cursor's `last_updated_at` is a millisecond epoch (cursor.ts)
1293
+ // the SAME units as the integer `n.updated_at_ms` column so it binds
1294
+ // straight into the keyset predicate with no ISO round-trip. Ordering
1295
+ // and boundary now read one numeric column, so heterogeneous /
1296
+ // non-canonical `updated_at` TEXT (space-form, offset, no-`Z`) can no
1297
+ // longer make the walk and the watermark disagree.
1298
+ const cursorMs = cursorPayload.last_updated_at;
1120
1299
  conditions.push(
1121
- "(n.updated_at > ? OR (n.updated_at = ? AND n.id > ?))",
1300
+ "(n.updated_at_ms > ? OR (n.updated_at_ms = ? AND n.id > ?))",
1122
1301
  );
1123
- params.push(cursorIso, cursorIso, cursorPayload.last_id);
1302
+ params.push(cursorMs, cursorMs, cursorPayload.last_id);
1124
1303
  }
1125
1304
  // else: bootstrap call (`cursor === ""`) — no watermark yet, no
1126
1305
  // predicate to add, but the ORDER BY below still switches to the
@@ -1131,11 +1310,12 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
1131
1310
  const direction = opts.sort === "desc" ? "DESC" : "ASC";
1132
1311
  let orderBy: string;
1133
1312
  if (cursorMode) {
1134
- // Cursor mode forces a deterministic keyset order. `id` is the
1135
- // tiebreaker without it, two notes sharing an `updated_at` would
1136
- // be at the mercy of SQLite's row order and the next page could
1137
- // miss or duplicate one.
1138
- orderBy = "n.updated_at ASC, n.id ASC";
1313
+ // Cursor mode forces a deterministic keyset order on the integer
1314
+ // `updated_at_ms` column (vault#586), matching the boundary predicate
1315
+ // above and the `idx_notes_updated_ms` index. `id` is the tiebreaker
1316
+ // without it, two notes sharing an `updated_at_ms` would be at the mercy
1317
+ // of SQLite's row order and the next page could miss or duplicate one.
1318
+ orderBy = "n.updated_at_ms ASC, n.id ASC";
1139
1319
  } else if (opts.orderBy === "link_count") {
1140
1320
  // `link_count` is a pseudo-field — like `created_at`/`updated_at` in the
1141
1321
  // dateFilter block above, it bypasses `requireIndexedField` (it's not a
@@ -1190,18 +1370,156 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
1190
1370
  // Phase 2 fetches full rows for just the page (≤ limit ids) and re-orders
1191
1371
  // to the phase-1 order; tags are hydrated in ONE batched query instead of
1192
1372
  // one query per returned note.
1373
+ // Phase 1 also selects `n.updated_at_ms` (vault#586) so a paging caller can
1374
+ // read the keyset key from the SAME statement that applied the keyset order
1375
+ // — the watermark then comes from one consistent snapshot, immune to a
1376
+ // cross-process writer bumping a page row's ms between two separate reads.
1193
1377
  const idSql = `
1194
- SELECT n.id FROM notes n
1378
+ SELECT n.id, n.updated_at_ms FROM notes n
1195
1379
  ${whereClause}
1196
1380
  ORDER BY ${orderBy}
1197
1381
  LIMIT ? OFFSET ?
1198
1382
  `;
1199
1383
  params.push(limit, offset);
1200
1384
 
1201
- const idRows = db.prepare(idSql).all(...params) as { id: string }[];
1385
+ const idRows = db.prepare(idSql).all(...params) as { id: string; updated_at_ms: number | null }[];
1386
+ if (_outUpdatedAtMs) {
1387
+ for (const r of idRows) {
1388
+ if (r.updated_at_ms !== null && r.updated_at_ms !== undefined) {
1389
+ _outUpdatedAtMs.set(r.id, r.updated_at_ms);
1390
+ }
1391
+ }
1392
+ }
1202
1393
  return fetchNotesByIdsOrdered(db, idRows.map((r) => r.id));
1203
1394
  }
1204
1395
 
1396
+ /**
1397
+ * Aggregation / rollup query (top new-feature ask from a UX round — see
1398
+ * `QueryOpts.aggregate` / `AggregateSpec` in types.ts). Applies the SAME
1399
+ * filter surface `queryNotes` does — via the shared `buildFilterConditions`:
1400
+ * tags, exclude-tags, presence filters, `ids`, path/extension, write-
1401
+ * attribution, metadata, date range — BEFORE grouping, so aggregating over a
1402
+ * filtered query rolls up exactly the notes that query would have listed.
1403
+ *
1404
+ * Tag-scope is NOT enforced here — core stays scope-unaware, same division
1405
+ * every other core query surface keeps. A tag-scoped caller narrows via
1406
+ * `opts.ids` to a pre-computed visible-note-id set (see `src/mcp-tools.ts`'s
1407
+ * `aggregateVisibility` wiring for MCP and `src/routes.ts`'s REST aggregate
1408
+ * branch — both filter to visible notes FIRST, then pass the resulting ids
1409
+ * in here, exactly like a normal query's `near` neighborhood scoping does).
1410
+ *
1411
+ * `group_by: "tag"` groups by tag MEMBERSHIP, not partition: implemented as
1412
+ * a JOIN against `note_tags`, so a note carrying N of the tags present in
1413
+ * the filtered result set contributes to N separate group rows (the
1414
+ * existing filter conditions, which reference `n.*`, compose unchanged
1415
+ * against the join). Any other `group_by` value must be a declared
1416
+ * `indexed: true` metadata field — reuses `requireIndexedField`, the same
1417
+ * FIELD_NOT_INDEXED contract `metadata` operator queries and `order_by`
1418
+ * use — and groups by its generated `meta_<field>` column.
1419
+ *
1420
+ * `op: "sum"` requires `field` — another indexed field whose declared
1421
+ * storage type is `INTEGER` (the only indexable numeric shape; a bare
1422
+ * `type: "number"` schema field is never indexed — see
1423
+ * `indexed-fields.ts`'s `TYPE_MAP` — and a `TEXT`-backed field can't be
1424
+ * summed). `op: "count"` ignores `field`.
1425
+ *
1426
+ * A note whose group_by value is absent/null collects into one
1427
+ * `{group: null, ...}` row — standard SQL `GROUP BY` behavior, not silently
1428
+ * dropped.
1429
+ */
1430
+ export function aggregateNotes(db: Database, opts: QueryOpts): AggregateRow[] {
1431
+ const spec = opts.aggregate;
1432
+ if (!spec) {
1433
+ throw new QueryError(
1434
+ `aggregateNotes requires opts.aggregate`,
1435
+ "INVALID_QUERY",
1436
+ {
1437
+ error_type: "invalid_query",
1438
+ field: "aggregate",
1439
+ hint: `pass { group_by, op } — group_by is an indexed metadata field or "tag"; op is "count" or "sum"`,
1440
+ },
1441
+ );
1442
+ }
1443
+ if (typeof spec.group_by !== "string" || spec.group_by.length === 0) {
1444
+ throw new QueryError(
1445
+ `aggregate.group_by is required — an indexed metadata field name, or "tag"`,
1446
+ "INVALID_QUERY",
1447
+ {
1448
+ error_type: "invalid_query",
1449
+ field: "aggregate.group_by",
1450
+ got: spec.group_by,
1451
+ hint: `pass an indexed metadata field name, or "tag"`,
1452
+ },
1453
+ );
1454
+ }
1455
+ if (spec.op !== "count" && spec.op !== "sum") {
1456
+ throw new QueryError(
1457
+ `invalid aggregate.op: ${JSON.stringify(spec.op)} — must be "count" or "sum"`,
1458
+ "INVALID_QUERY",
1459
+ { error_type: "invalid_query", field: "aggregate.op", got: spec.op, hint: `pass "count" or "sum"` },
1460
+ );
1461
+ }
1462
+ if (spec.op === "sum" && (typeof spec.field !== "string" || spec.field.length === 0)) {
1463
+ throw new QueryError(
1464
+ `aggregate.field is required when aggregate.op is "sum"`,
1465
+ "INVALID_QUERY",
1466
+ {
1467
+ error_type: "invalid_query",
1468
+ field: "aggregate.field",
1469
+ hint: "pass the indexed numeric metadata field to sum",
1470
+ },
1471
+ );
1472
+ }
1473
+
1474
+ const { conditions, params } = buildFilterConditions(db, opts);
1475
+
1476
+ const groupByTag = spec.group_by === "tag";
1477
+ let groupExpr: string;
1478
+ let fromClause: string;
1479
+ if (groupByTag) {
1480
+ groupExpr = "nt.tag_name";
1481
+ fromClause = "FROM notes n JOIN note_tags nt ON nt.note_id = n.id";
1482
+ } else {
1483
+ // `group_by` came from indexed_fields (validated via FIELD_NAME_RE at
1484
+ // declaration time), so interpolating the column name is safe — same
1485
+ // justification `orderBy`/`buildOperatorClause` use.
1486
+ requireIndexedField(db, spec.group_by);
1487
+ groupExpr = `"meta_${spec.group_by}"`;
1488
+ fromClause = "FROM notes n";
1489
+ }
1490
+
1491
+ let valueExpr: string;
1492
+ if (spec.op === "count") {
1493
+ valueExpr = "COUNT(*)";
1494
+ } else {
1495
+ const fieldInfo = requireIndexedField(db, spec.field!);
1496
+ if (fieldInfo.sqliteType !== "INTEGER") {
1497
+ throw new QueryError(
1498
+ `aggregate.field "${spec.field}" is not numeric (declared sqlite type ${fieldInfo.sqliteType}) — "sum" requires a field declared type: "integer" or "boolean" in its tag schema`,
1499
+ "INVALID_QUERY",
1500
+ {
1501
+ error_type: "invalid_query",
1502
+ field: "aggregate.field",
1503
+ got: spec.field,
1504
+ hint: `sum requires a numeric indexed field (type: "integer"/"boolean"); "${spec.field}" is declared a non-numeric type`,
1505
+ },
1506
+ );
1507
+ }
1508
+ valueExpr = `SUM("meta_${spec.field}")`;
1509
+ }
1510
+
1511
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1512
+ const sql = `
1513
+ SELECT ${groupExpr} AS group_key, ${valueExpr} AS value
1514
+ ${fromClause}
1515
+ ${whereClause}
1516
+ GROUP BY ${groupExpr}
1517
+ ORDER BY ${groupExpr}
1518
+ `;
1519
+ const rows = db.prepare(sql).all(...params) as { group_key: string | number | null; value: number | null }[];
1520
+ return rows.map((r) => ({ group: r.group_key, value: r.value ?? 0 }));
1521
+ }
1522
+
1205
1523
  /**
1206
1524
  * Fetch full note rows for `ids`, preserving the input order, with tags
1207
1525
  * hydrated via ONE batched query per chunk (not one per note). Ids not
@@ -1307,7 +1625,12 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
1307
1625
  * with a non-null `updated_at` greater than the unix epoch).
1308
1626
  */
1309
1627
  export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
1310
- const notes = queryNotes(db, opts);
1628
+ // Capture each page row's integer keyset key (vault#586) from the SAME
1629
+ // phase-1 statement that ordered + sliced the page — one consistent
1630
+ // snapshot, so the watermark can't leapfrog rows a cross-process writer
1631
+ // bumps between reads. This replaces a separate post-query round-trip.
1632
+ const updatedAtMsById = new Map<string, number>();
1633
+ const notes = queryNotes(db, opts, updatedAtMsById);
1311
1634
  const queryHash = computeQueryHash(toQueryHashInputs(opts));
1312
1635
 
1313
1636
  // Watermark math: pick the larger of (last returned row, prior cursor
@@ -1326,14 +1649,20 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
1326
1649
  lastId = prior.last_id;
1327
1650
  }
1328
1651
  if (notes.length > 0) {
1329
- // queryNotes with a cursor orders by (updated_at ASC, id ASC), so
1330
- // the last note in the array is the new watermark. When no cursor
1331
- // was passed, the SQL is ordered by created_at; we still want the
1332
- // cursor to advance to the MAX (updated_at, id) of this page so
1333
- // the next call resumes correctly. Compute the max explicitly.
1652
+ // Advance the watermark to the page's MAX (updated_at_ms, id) using the
1653
+ // keyset keys captured in the phase-1 snapshot above (vault#586) the
1654
+ // SAME integer `updated_at_ms` the walk-order used, from the SAME read.
1655
+ // Using the snapshot, rather than re-deriving ms from each note's
1656
+ // `updatedAt` string or re-reading the column, is what keeps walk-order
1657
+ // and watermark from diverging: a row whose backfilled column and
1658
+ // non-canonical `updated_at` TEXT disagree would otherwise re-parse to a
1659
+ // different ms here and skip or re-deliver at the boundary. No throw — a
1660
+ // NULL key (shouldn't occur post-migrateToV26) reads as 0. When a cursor
1661
+ // is in effect the SQL already returns rows in (updated_at_ms, id) order
1662
+ // so the last row IS the max; the explicit max also covers the
1663
+ // created_at-ordered no-cursor path.
1334
1664
  for (const note of notes) {
1335
- const updatedIso = note.updatedAt ?? note.createdAt;
1336
- const ms = isoToMillis(updatedIso);
1665
+ const ms = updatedAtMsById.get(note.id) ?? 0;
1337
1666
  if (ms > lastUpdatedAt || (ms === lastUpdatedAt && note.id > lastId)) {
1338
1667
  lastUpdatedAt = ms;
1339
1668
  lastId = note.id;
@@ -1831,6 +2160,10 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1831
2160
  // note_tags FK on `tag_name` has no ON DELETE, so the delete must
1832
2161
  // come AFTER the repoint.
1833
2162
  const now = new Date().toISOString();
2163
+ // Integer keyset mirror of `now` for the note `updated_at_ms` bumps in the
2164
+ // content/path rewrite passes below (vault#586). `now` is canonical, so
2165
+ // `timestampToMs` never returns null here.
2166
+ const nowMs = timestampToMs(now) ?? Date.now();
1834
2167
  const readStmt = db.prepare(
1835
2168
  "SELECT description, fields, relationships, parent_names, created_at FROM tags WHERE name = ?",
1836
2169
  );
@@ -1958,12 +2291,13 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1958
2291
  // `#newtag`) and must bump `updated_at` like any other content
1959
2292
  // write, or a cursor/sync-poll loop never sees it. Shares the one
1960
2293
  // `now` timestamp for the whole cascade (same convention as the
1961
- // tag-row rename pass above).
1962
- const updateStmt = db.prepare("UPDATE notes SET content = ?, updated_at = ? WHERE id = ?");
2294
+ // tag-row rename pass above). `updated_at_ms` moves with it (vault#586)
2295
+ // so the cursor keyset actually surfaces the rewrite.
2296
+ const updateStmt = db.prepare("UPDATE notes SET content = ?, updated_at = ?, updated_at_ms = ? WHERE id = ?");
1963
2297
  for (const row of candidates) {
1964
2298
  const next = rewriteNoteBody(row.content, renames);
1965
2299
  if (next === row.content) continue;
1966
- updateStmt.run(next, now, row.id);
2300
+ updateStmt.run(next, now, nowMs, row.id);
1967
2301
  notesRewritten++;
1968
2302
  }
1969
2303
  }
@@ -1979,12 +2313,13 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1979
2313
  .prepare(`SELECT id, path FROM notes WHERE path IS NOT NULL AND (${orClauses})`)
1980
2314
  .all(...params) as { id: string; path: string }[];
1981
2315
  // vault#555 fix 2 — same reasoning as the content rewrite above: a
1982
- // path rewrite is a real persisted-state change.
1983
- const updateStmt = db.prepare("UPDATE notes SET path = ?, updated_at = ? WHERE id = ?");
2316
+ // path rewrite is a real persisted-state change. `updated_at_ms` moves
2317
+ // with `updated_at` (vault#586).
2318
+ const updateStmt = db.prepare("UPDATE notes SET path = ?, updated_at = ?, updated_at_ms = ? WHERE id = ?");
1984
2319
  for (const row of candidates) {
1985
2320
  const next = rewriteTagConfigPath(row.path, renames);
1986
2321
  if (next === row.path) continue;
1987
- updateStmt.run(next, now, row.id);
2322
+ updateStmt.run(next, now, nowMs, row.id);
1988
2323
  pathsRenamed++;
1989
2324
  }
1990
2325
  }
@@ -2359,6 +2694,122 @@ export function getVaultStats(
2359
2694
  };
2360
2695
  }
2361
2696
 
2697
+ // ---- Vault map (front-door structural orientation) ----
2698
+
2699
+ /** Shared bucket-expression for the top-level path segment: the text before
2700
+ * the first `/`, or the whole path when it has none. Applied to whichever
2701
+ * `path` column reference the caller substitutes in (`path` or `n.path`). */
2702
+ function pathBucketExpr(pathCol: string): string {
2703
+ return `CASE WHEN instr(${pathCol}, '/') > 0 THEN substr(${pathCol}, 1, instr(${pathCol}, '/') - 1) ELSE ${pathCol} END`;
2704
+ }
2705
+
2706
+ /**
2707
+ * Compute the compact structural map `vault-info` surfaces for one-call
2708
+ * orientation: total note count, every tag currently carried by at least one
2709
+ * note with its membership count, and every top-level path "bucket" (the
2710
+ * first `/`-delimited segment of `path`) with its note count — plus a count
2711
+ * of notes with no path at all (excluded from `path_buckets`, nothing to
2712
+ * bucket). Counts only, no content; three grouped-COUNT queries, safe on
2713
+ * large vaults.
2714
+ *
2715
+ * `opts.tagFilter`, when the KEY IS PRESENT, restricts every count to notes
2716
+ * reachable through that exact tag-name set (an `IN`-clause join through
2717
+ * `note_tags`) — the scope-aware path a tag-scoped caller's already-expanded
2718
+ * allowlist takes (server layer; core stays scope-unaware, it just filters
2719
+ * by the plain tag-name list it's given). An empty array is a valid,
2720
+ * meaningful filter — "nothing is in scope" — and short-circuits to an
2721
+ * all-zero map WITHOUT falling through to the unfiltered/full-vault query;
2722
+ * only OMITTING `tagFilter` entirely computes the vault-wide map.
2723
+ */
2724
+ export function getVaultMap(
2725
+ db: Database,
2726
+ opts?: { tagFilter?: string[] },
2727
+ ): VaultMap {
2728
+ const hasFilter = opts !== undefined && opts.tagFilter !== undefined;
2729
+ const tagFilter = opts?.tagFilter ?? [];
2730
+
2731
+ if (hasFilter && tagFilter.length === 0) {
2732
+ return { total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 };
2733
+ }
2734
+
2735
+ if (hasFilter) {
2736
+ const placeholders = tagFilter.map(() => "?").join(",");
2737
+
2738
+ const totalRow = db
2739
+ .prepare(`SELECT COUNT(DISTINCT note_id) as c FROM note_tags WHERE tag_name IN (${placeholders})`)
2740
+ .get(...tagFilter) as { c: number };
2741
+
2742
+ const tagRows = db
2743
+ .prepare(
2744
+ `SELECT tag_name AS name, COUNT(*) AS count
2745
+ FROM note_tags
2746
+ WHERE tag_name IN (${placeholders})
2747
+ GROUP BY tag_name
2748
+ ORDER BY count DESC, name ASC`,
2749
+ )
2750
+ .all(...tagFilter) as { name: string; count: number }[];
2751
+
2752
+ const bucketRows = db
2753
+ .prepare(
2754
+ `SELECT ${pathBucketExpr("n.path")} AS name, COUNT(DISTINCT n.id) AS count
2755
+ FROM notes n
2756
+ JOIN note_tags nt ON nt.note_id = n.id
2757
+ WHERE nt.tag_name IN (${placeholders}) AND n.path IS NOT NULL
2758
+ GROUP BY name
2759
+ ORDER BY count DESC, name ASC`,
2760
+ )
2761
+ .all(...tagFilter) as { name: string; count: number }[];
2762
+
2763
+ const unfiledRow = db
2764
+ .prepare(
2765
+ `SELECT COUNT(DISTINCT n.id) as c
2766
+ FROM notes n
2767
+ JOIN note_tags nt ON nt.note_id = n.id
2768
+ WHERE nt.tag_name IN (${placeholders}) AND n.path IS NULL`,
2769
+ )
2770
+ .get(...tagFilter) as { c: number };
2771
+
2772
+ return {
2773
+ total_notes: totalRow.c,
2774
+ tags: tagRows,
2775
+ path_buckets: bucketRows,
2776
+ unfiled_notes: unfiledRow.c,
2777
+ };
2778
+ }
2779
+
2780
+ const totalRow = db.prepare("SELECT COUNT(*) as c FROM notes").get() as { c: number };
2781
+
2782
+ const tagRows = db
2783
+ .prepare(
2784
+ `SELECT tag_name AS name, COUNT(*) AS count
2785
+ FROM note_tags
2786
+ GROUP BY tag_name
2787
+ ORDER BY count DESC, name ASC`,
2788
+ )
2789
+ .all() as { name: string; count: number }[];
2790
+
2791
+ const bucketRows = db
2792
+ .prepare(
2793
+ `SELECT ${pathBucketExpr("path")} AS name, COUNT(*) AS count
2794
+ FROM notes
2795
+ WHERE path IS NOT NULL
2796
+ GROUP BY name
2797
+ ORDER BY count DESC, name ASC`,
2798
+ )
2799
+ .all() as { name: string; count: number }[];
2800
+
2801
+ const unfiledRow = db
2802
+ .prepare("SELECT COUNT(*) as c FROM notes WHERE path IS NULL")
2803
+ .get() as { c: number };
2804
+
2805
+ return {
2806
+ total_notes: totalRow.c,
2807
+ tags: tagRows,
2808
+ path_buckets: bucketRows,
2809
+ unfiled_notes: unfiledRow.c,
2810
+ };
2811
+ }
2812
+
2362
2813
  // ---- Bulk Operations ----
2363
2814
 
2364
2815
  export interface BulkNoteInput {