@openparachute/vault 0.3.3 → 0.4.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 (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
package/core/src/notes.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { Database } from "bun:sqlite";
1
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
2
2
  import type { Note, NoteIndex, QueryOpts, VaultStats } from "./types.js";
3
3
  import { normalizePath } from "./paths.js";
4
4
  import {
5
5
  buildOperatorClause,
6
6
  isOperatorObject,
7
+ QueryError,
7
8
  requireIndexedField,
8
9
  } from "./query-operators.js";
9
10
 
@@ -35,15 +36,32 @@ export function createNote(
35
36
  const metadata = opts?.metadata ? JSON.stringify(opts.metadata) : "{}";
36
37
  const path = normalizePath(opts?.path);
37
38
 
39
+ // Empty-note invariant (#213): reject `content+path both absent`. Three
40
+ // legit shapes — content-only, path-only, both — only the empty+empty
41
+ // combo is the runaway-client signature that flooded a deployment with
42
+ // 7,453 pathless empty notes in one MCP burst. `content` only is a
43
+ // legitimate un-pathed jot; `path` only is a wikilink placeholder or
44
+ // `_schemas/*` config note.
45
+ if (!content.trim() && path === null) {
46
+ throw new EmptyNoteError();
47
+ }
48
+
38
49
  // `updated_at` is set to `created_at` on insert so a client whose optimistic
39
50
  // concurrency check falls back to `createdAt` on a never-updated note
40
51
  // (the common shape: `note.updatedAt ?? note.createdAt`) matches the stored
41
52
  // value. Hook-style writes with `skipUpdatedAt` preserve this; real user
42
53
  // edits bump it strictly upward, so `updated_at > created_at` still means
43
54
  // "user-touched since creation."
44
- db.prepare(
45
- `INSERT INTO notes (id, content, path, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
46
- ).run(id, content, path, metadata, createdAt, createdAt);
55
+ try {
56
+ db.prepare(
57
+ `INSERT INTO notes (id, content, path, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
58
+ ).run(id, content, path, metadata, createdAt, createdAt);
59
+ } catch (err) {
60
+ if (path !== null && isPathUniqueError(err)) {
61
+ throw new PathConflictError(path);
62
+ }
63
+ throw err;
64
+ }
47
65
 
48
66
  if (opts?.tags && opts.tags.length > 0) {
49
67
  tagNote(db, id, opts.tags);
@@ -108,11 +126,98 @@ export class ConflictError extends Error {
108
126
  }
109
127
  }
110
128
 
129
+ /**
130
+ * Thrown by `createNote` / `updateNote` when the requested path is already
131
+ * taken by another note. Surfaces as 409 at the HTTP layer so clients can
132
+ * distinguish "path taken — pick another" from a generic 500.
133
+ *
134
+ * Detected by catching SQLite's UNIQUE-constraint error on the
135
+ * `idx_notes_path_unique` partial index (schema v5+). Matches the tag
136
+ * "UNIQUE constraint failed: notes.path" rather than a numeric code so
137
+ * we keep working if bun:sqlite changes its error class hierarchy.
138
+ */
139
+ export class PathConflictError extends Error {
140
+ code = "PATH_CONFLICT" as const;
141
+ path: string;
142
+
143
+ constructor(path: string) {
144
+ super(`path_conflict: another note already uses path "${path}"`);
145
+ this.name = "PathConflictError";
146
+ this.path = path;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Per-call item cap on `createNote`/`updateNote` batch entry points
152
+ * (MCP `create-note` / `update-note` and HTTP `POST /api/notes`).
153
+ * Single source of truth — both transports import from here so the cap
154
+ * can never silently drift between them. See #213 for the runaway-client
155
+ * incident that motivated the cap (7,453 empty notes in one MCP burst).
156
+ */
157
+ export const MAX_BATCH_SIZE = 500;
158
+
159
+ /**
160
+ * Thrown by `createNote` / `updateNote` when the proposed note state has
161
+ * neither content nor path. The vault accepts un-pathed jots (content only)
162
+ * and path-only placeholders (wikilink stubs, `_schemas/*`), but a note
163
+ * with neither is the runaway-client signature flagged in #213 — one MCP
164
+ * burst flooded a deployment with 7,453 empty pathless rows. Surfaces as
165
+ * 400 at the HTTP layer.
166
+ */
167
+ export class EmptyNoteError extends Error {
168
+ code = "EMPTY_NOTE" as const;
169
+ note_id: string | null;
170
+ /**
171
+ * Zero-based position in a batch call when the empty entry is rejected via
172
+ * the transport-layer pre-validation pass (HTTP `POST /api/notes` or MCP
173
+ * `create-note` with `notes: [...]`). `null` for single-update rejections
174
+ * and for Store-level throws that don't know their batch context.
175
+ */
176
+ item_index: number | null;
177
+
178
+ constructor(noteId: string | null = null, itemIndex: number | null = null) {
179
+ super(
180
+ noteId
181
+ ? `empty_note: update would leave note "${noteId}" with neither content nor path`
182
+ : itemIndex !== null
183
+ ? `empty_note: a note must have either content or a path (item index ${itemIndex})`
184
+ : `empty_note: a note must have either content or a path`,
185
+ );
186
+ this.name = "EmptyNoteError";
187
+ this.note_id = noteId;
188
+ this.item_index = itemIndex;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Match bun:sqlite's UNIQUE-constraint error on the notes.path index. The
194
+ * error class is `SQLiteError` but matching on the message is sufficient
195
+ * here — the index name and column are stable parts of the schema, and
196
+ * bun:sqlite has carried this exact message text since 1.0.
197
+ */
198
+ function isPathUniqueError(err: unknown): boolean {
199
+ if (!(err instanceof Error)) return false;
200
+ return err.message.includes("UNIQUE constraint failed: notes.path");
201
+ }
202
+
111
203
  export function updateNote(
112
204
  db: Database,
113
205
  id: string,
114
206
  updates: {
115
207
  content?: string;
208
+ /**
209
+ * Atomic content append. Computed via SQL string concatenation
210
+ * (`content = content || ?`), so two concurrent appends never
211
+ * overwrite each other — the second simply lands after the first.
212
+ * Mutually exclusive with `content`.
213
+ */
214
+ append?: string;
215
+ /**
216
+ * Atomic content prepend. Same SQL-level guarantee as `append`.
217
+ * Mutually exclusive with `content`. May be combined with `append`
218
+ * in a single call (both contributions land).
219
+ */
220
+ prepend?: string;
116
221
  path?: string;
117
222
  metadata?: Record<string, unknown>;
118
223
  created_at?: string;
@@ -125,8 +230,45 @@ export function updateNote(
125
230
  if_updated_at?: string;
126
231
  },
127
232
  ): Note {
233
+ if (updates.content !== undefined && (updates.append !== undefined || updates.prepend !== undefined)) {
234
+ throw new Error(
235
+ "update-note: `content` is mutually exclusive with `append`/`prepend`. Pick full-replace or additive — not both in the same call.",
236
+ );
237
+ }
238
+
239
+ // Empty-note invariant (#213): when this update touches content or path,
240
+ // reject if the post-state would be empty content + null path. We only
241
+ // enforce on transitions that actually touch the relevant fields, so
242
+ // metadata-only or tag-only updates against legacy empty rows still pass.
243
+ // Hook-style writes (skipUpdatedAt) are exempted — they're machine-level
244
+ // marker writes that legitimately may run against any shape of row.
245
+ const touchesContent = updates.content !== undefined
246
+ || updates.append !== undefined
247
+ || updates.prepend !== undefined;
248
+ const touchesPath = updates.path !== undefined;
249
+ if ((touchesContent || touchesPath) && !updates.skipUpdatedAt) {
250
+ const current = getNote(db, id);
251
+ if (current) {
252
+ let finalContent: string;
253
+ if (updates.content !== undefined) {
254
+ finalContent = updates.content;
255
+ } else if (touchesContent) {
256
+ finalContent = (updates.prepend ?? "") + current.content + (updates.append ?? "");
257
+ } else {
258
+ finalContent = current.content;
259
+ }
260
+ const finalPath = touchesPath ? normalizePath(updates.path) : (current.path ?? null);
261
+ if (!finalContent.trim() && !finalPath) {
262
+ throw new EmptyNoteError(id);
263
+ }
264
+ }
265
+ // If `current` is null we fall through — existing code paths handle the
266
+ // missing-row case downstream (the conditional UPDATE returns 0 rows;
267
+ // OC throws ConflictError; non-OC returns silently).
268
+ }
269
+
128
270
  const sets: string[] = [];
129
- const values: unknown[] = [];
271
+ const values: (string | null)[] = [];
130
272
 
131
273
  // Hooks and other machine-level writers pass `skipUpdatedAt: true` so
132
274
  // their metadata markers don't look like user activity. See issue #44.
@@ -150,6 +292,34 @@ export function updateNote(
150
292
  sets.push("content = ?");
151
293
  values.push(updates.content);
152
294
  }
295
+ if (updates.append !== undefined || updates.prepend !== undefined) {
296
+ // Atomic concat at the SQL layer. SQLite's `||` operator on the
297
+ // existing `content` column means a concurrent reader-then-writer
298
+ // race window is impossible: each `UPDATE` evaluates `content`
299
+ // under the write lock, so two simultaneous appends both land
300
+ // (in some order) instead of one clobbering the other.
301
+ //
302
+ // Frontmatter-aware prepend (#203): if the note opens with a YAML
303
+ // frontmatter block (`---\n...\n---\n`), the prepend is injected
304
+ // *after* the closing `---\n` so parsers that expect frontmatter
305
+ // at byte 0 still find it. Detection uses `instr(content, '\n---\n')`
306
+ // — the closing fence is whatever `\n---\n` appears after the
307
+ // opening one. If no frontmatter is detected, prepend goes at
308
+ // byte 0 as before. Atomicity is preserved: the entire transform
309
+ // is one UPDATE expression evaluated under the write lock.
310
+ sets.push(
311
+ "content = CASE "
312
+ + "WHEN substr(content, 1, 4) = '---' || char(10) "
313
+ + "AND instr(content, char(10) || '---' || char(10)) > 0 "
314
+ + "THEN substr(content, 1, instr(content, char(10) || '---' || char(10)) + 4) || ? "
315
+ + "|| substr(content, instr(content, char(10) || '---' || char(10)) + 5) || ? "
316
+ + "ELSE ? || content || ? "
317
+ + "END",
318
+ );
319
+ const prependVal = updates.prepend ?? "";
320
+ const appendVal = updates.append ?? "";
321
+ values.push(prependVal, appendVal, prependVal, appendVal);
322
+ }
153
323
  if (updates.path !== undefined) {
154
324
  sets.push("path = ?");
155
325
  values.push(normalizePath(updates.path));
@@ -167,13 +337,15 @@ export function updateNote(
167
337
  // need to validate the precondition; a conditional UPDATE that sets
168
338
  // updated_at to itself does exactly that atomically — even a no-net-
169
339
  // change UPDATE takes the write lock in WAL mode, so it still serializes
170
- // with other writers and `.changes` reflects whether the WHERE matched.
340
+ // with other writers. `RETURNING id` reports the row only when WHERE
341
+ // matched — `.changes` is unreliable inside multi-statement transactions
342
+ // (vault#261).
171
343
  if (sets.length === 0) {
172
344
  if (updates.if_updated_at !== undefined) {
173
345
  const probe = db.prepare(
174
- "UPDATE notes SET updated_at = updated_at WHERE id = ? AND updated_at IS ?",
175
- ).run(id, updates.if_updated_at);
176
- if (probe.changes === 0) {
346
+ "UPDATE notes SET updated_at = updated_at WHERE id = ? AND updated_at IS ? RETURNING id",
347
+ ).get(id, updates.if_updated_at) as { id: string } | null;
348
+ if (probe === null) {
177
349
  throwConflictOrMissing(db, id, updates.if_updated_at);
178
350
  }
179
351
  }
@@ -187,9 +359,23 @@ export function updateNote(
187
359
  values.push(updates.if_updated_at);
188
360
  }
189
361
 
190
- const res = db.prepare(sql).run(...values);
362
+ let matched: { id: string } | null = null;
363
+ try {
364
+ if (updates.if_updated_at !== undefined) {
365
+ matched = db.prepare(`${sql} RETURNING id`).get(...values) as
366
+ | { id: string }
367
+ | null;
368
+ } else {
369
+ db.prepare(sql).run(...values);
370
+ }
371
+ } catch (err) {
372
+ if (updates.path !== undefined && isPathUniqueError(err)) {
373
+ throw new PathConflictError(normalizePath(updates.path) ?? updates.path);
374
+ }
375
+ throw err;
376
+ }
191
377
 
192
- if (updates.if_updated_at !== undefined && res.changes === 0) {
378
+ if (updates.if_updated_at !== undefined && matched === null) {
193
379
  throwConflictOrMissing(db, id, updates.if_updated_at);
194
380
  }
195
381
 
@@ -212,21 +398,38 @@ export function deleteNote(db: Database, id: string): void {
212
398
 
213
399
  export function queryNotes(db: Database, opts: QueryOpts): Note[] {
214
400
  const conditions: string[] = [];
215
- const params: unknown[] = [];
401
+ const params: SQLQueryBindings[] = [];
216
402
  const joins: string[] = [];
217
403
 
218
- // Include tags — "all" (default): must have ALL tags; "any": must have ANY tag
404
+ // Include tags — "all" (default): must have ALL tags; "any": must have ANY tag.
405
+ // The `_tagsExpanded` internal field carries per-input-tag descendant sets
406
+ // when the tag-hierarchy resolver (see core/src/tag-hierarchy.ts) has
407
+ // expanded the input — `tags: ["manual"]` becomes the set
408
+ // `{manual, voice, text, ...}` per declared `_tags/*` config notes. Falls
409
+ // back to `[opts.tags[i]]` (single-element set) when no expansion is set,
410
+ // preserving the original semantics.
219
411
  if (opts.tags && opts.tags.length > 0) {
412
+ const tagSets: string[][] = (opts as QueryOpts & { _tagsExpanded?: string[][] })._tagsExpanded
413
+ ?? opts.tags.map((t) => [t]);
220
414
  const match = opts.tagMatch ?? "all";
221
415
  if (match === "any") {
222
- const placeholders = opts.tags.map(() => "?").join(", ");
223
- joins.push(`JOIN note_tags nt_or ON nt_or.note_id = n.id AND nt_or.tag_name IN (${placeholders})`);
224
- params.push(...opts.tags);
416
+ // Flatten all expanded sets and dedupe — a note tagged with any one
417
+ // matches the input.
418
+ const flat = Array.from(new Set(tagSets.flat()));
419
+ if (flat.length > 0) {
420
+ const placeholders = flat.map(() => "?").join(", ");
421
+ joins.push(`JOIN note_tags nt_or ON nt_or.note_id = n.id AND nt_or.tag_name IN (${placeholders})`);
422
+ params.push(...flat);
423
+ }
225
424
  } else {
226
- for (let i = 0; i < opts.tags.length; i++) {
425
+ // "all": one JOIN per input tag, each accepting the input or any descendant.
426
+ for (let i = 0; i < tagSets.length; i++) {
427
+ const set = tagSets[i] ?? [];
428
+ if (set.length === 0) continue;
227
429
  const alias = `nt${i}`;
228
- joins.push(`JOIN note_tags ${alias} ON ${alias}.note_id = n.id AND ${alias}.tag_name = ?`);
229
- params.push(opts.tags[i]);
430
+ const placeholders = set.map(() => "?").join(", ");
431
+ joins.push(`JOIN note_tags ${alias} ON ${alias}.note_id = n.id AND ${alias}.tag_name IN (${placeholders})`);
432
+ params.push(...set);
230
433
  }
231
434
  }
232
435
  }
@@ -259,6 +462,20 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
259
462
  );
260
463
  }
261
464
 
465
+ // ID set filter — used by `near` to push neighborhood scoping into SQL so
466
+ // that LIMIT applies to the neighborhood, not the whole notes table.
467
+ if (opts.ids !== undefined) {
468
+ if (opts.ids.length === 0) {
469
+ // Caller asked for "in this empty set" — no rows match. Short-circuit
470
+ // with an always-false condition; building `IN ()` would be a SQL error.
471
+ conditions.push("0 = 1");
472
+ } else {
473
+ const placeholders = opts.ids.map(() => "?").join(", ");
474
+ conditions.push(`n.id IN (${placeholders})`);
475
+ params.push(...opts.ids);
476
+ }
477
+ }
478
+
262
479
  // Exact path match (case-insensitive)
263
480
  if (opts.path) {
264
481
  conditions.push("n.path = ? COLLATE NOCASE");
@@ -291,14 +508,61 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
291
508
  }
292
509
  }
293
510
 
294
- // Date range
295
- if (opts.dateFrom) {
296
- conditions.push("n.created_at >= ?");
297
- params.push(opts.dateFrom);
511
+ // Date range. Two accepted shapes:
512
+ // - Legacy `dateFrom` / `dateTo` — always filters on `n.created_at`
513
+ // (vault ingestion time).
514
+ // - Generalized `dateFilter: { field, from, to }` — filters on the
515
+ // named field. `created_at` (default) and `updated_at` map to the
516
+ // real columns on `notes`; any other field must be declared
517
+ // `indexed: true` so the SQL targets a real B-tree index. The two
518
+ // shapes are mutually exclusive — the combination would silently
519
+ // AND, which would be surprising.
520
+ //
521
+ // `updated_at` enables incremental-rebuild flows (vault#285 1.5): an
522
+ // SSG or syncer asks "what changed since my last build" via
523
+ // `dateFilter: { field: "updated_at", from: lastBuildISO }`. There's
524
+ // no B-tree on `updated_at` today; a sequential scan is acceptable up
525
+ // to ~tens of thousands of notes. Add an index if the scan ever shows
526
+ // up in a real workload.
527
+ const hasLegacyDate = opts.dateFrom !== undefined || opts.dateTo !== undefined;
528
+ const hasDateFilter = opts.dateFilter !== undefined;
529
+ if (hasLegacyDate && hasDateFilter) {
530
+ throw new QueryError(
531
+ `cannot combine top-level date_from/date_to with date_filter — pass one or the other`,
532
+ "INVALID_QUERY",
533
+ );
298
534
  }
299
- if (opts.dateTo) {
300
- conditions.push("n.created_at < ?");
301
- params.push(opts.dateTo);
535
+ if (hasDateFilter) {
536
+ const filter = opts.dateFilter!;
537
+ const field = filter.field ?? "created_at";
538
+ let column: string;
539
+ if (field === "created_at") {
540
+ column = "n.created_at";
541
+ } else if (field === "updated_at") {
542
+ column = "n.updated_at";
543
+ } else {
544
+ // Re-uses the same indexed-field gate as `metadata` operator queries
545
+ // and `orderBy` so the error message and contract are consistent.
546
+ requireIndexedField(db, field);
547
+ column = `"meta_${field}"`;
548
+ }
549
+ if (filter.from !== undefined) {
550
+ conditions.push(`${column} >= ?`);
551
+ params.push(filter.from);
552
+ }
553
+ if (filter.to !== undefined) {
554
+ conditions.push(`${column} < ?`);
555
+ params.push(filter.to);
556
+ }
557
+ } else if (hasLegacyDate) {
558
+ if (opts.dateFrom) {
559
+ conditions.push("n.created_at >= ?");
560
+ params.push(opts.dateFrom);
561
+ }
562
+ if (opts.dateTo) {
563
+ conditions.push("n.created_at < ?");
564
+ params.push(opts.dateTo);
565
+ }
302
566
  }
303
567
 
304
568
  const direction = opts.sort === "desc" ? "DESC" : "ASC";
@@ -434,40 +698,422 @@ export function deleteTag(db: Database, name: string): { deleted: boolean; notes
434
698
  // The UNIQUE PRIMARY KEY on tags.name means rename-to-existing is ambiguous:
435
699
  // do you drop the source, or retag-and-drop? Callers must pick — rename errors
436
700
  // out; mergeTags explicitly retags.
701
+ //
702
+ // Vault#240 + #247: rename is a transactional cascade across every surface
703
+ // where the old name is referenced. The shape `tag` → `tag/sub` paths
704
+ // recursively (sub-tags follow their root). Counts are returned per-surface
705
+ // so REST/MCP responses can report what changed without a re-scan.
706
+ export interface RenameTagSuccess {
707
+ /** note_tags rows repointed (cumulative across self + every sub-tag). */
708
+ renamed: number;
709
+ /** Sub-tag rows renamed alongside the root (excludes the root itself). */
710
+ sub_tags_renamed: number;
711
+ /** OTHER tags whose `parent_names` JSON array referenced any old name. */
712
+ parent_refs_updated: number;
713
+ /** Tokens whose `scoped_tags` JSON array referenced any old name. */
714
+ tokens_updated: number;
715
+ /** indexed_fields rows whose `declarer_tags` JSON array referenced any old name. */
716
+ indexed_field_declarers_updated: number;
717
+ /** Notes whose `content` had `#oldname[/...]` references rewritten. */
718
+ notes_rewritten: number;
719
+ /** `_tags/<oldname>...` notes whose `path` was rewritten for hygiene. */
720
+ paths_renamed: number;
721
+ }
722
+
437
723
  export type RenameTagResult =
438
- | { renamed: number }
724
+ | RenameTagSuccess
439
725
  | { error: "not_found" }
440
- | { error: "target_exists" };
726
+ | { error: "target_exists"; conflicting: string[] };
441
727
 
728
+ /**
729
+ * Cascading tag rename — closes vault#240 (full cascade) and vault#247
730
+ * (parent_names piece). When `task` becomes `todo`, the rename touches:
731
+ *
732
+ * 1. `tags` PK row (and sub-tag rows `task/...` → `todo/...`).
733
+ * 2. `note_tags.tag_name` FK references for every renamed name.
734
+ * 3. `tags.parent_names` JSON arrays in OTHER tag rows.
735
+ * 4. `tokens.scoped_tags` JSON arrays.
736
+ * 5. `indexed_fields.declarer_tags` JSON arrays.
737
+ * 6. Note body `content`: `#oldname` and `#oldname/...` references
738
+ * become `#newname` / `#newname/...`. `[[_tags/oldname]]`
739
+ * wikilinks rewrite to `[[_tags/newname]]`.
740
+ * 7. `_tags/<oldname>...` config-note paths (post-v14 these are inert
741
+ * historical breadcrumbs, but renaming for hygiene keeps the
742
+ * vault internally consistent).
743
+ *
744
+ * Atomicity: a single `BEGIN IMMEDIATE` transaction. Any failure rolls
745
+ * back the entire cascade — no half-applied state. Pre-flight collision
746
+ * check covers both the root rename and every sub-tag rename so a
747
+ * partway-through abort can't happen on a UNIQUE-constraint violation.
748
+ *
749
+ * Cache invalidation: parent_names and tag-set both change, so callers
750
+ * (the store wrapper) bust both `_tagHierarchy` and `_schemaConfig`
751
+ * after the cascade returns.
752
+ */
442
753
  export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
443
754
  if (oldName === newName) {
444
755
  const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
445
- return exists ? { renamed: 0 } : { error: "not_found" };
756
+ return exists
757
+ ? emptyCascadeResult()
758
+ : { error: "not_found" };
446
759
  }
447
760
 
448
761
  const oldExists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
449
762
  if (!oldExists) return { error: "not_found" };
450
763
 
451
- const newExists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(newName);
452
- if (newExists) return { error: "target_exists" };
764
+ // Discover the full set of names being renamed: the root plus every
765
+ // sub-tag whose name starts with `<oldName>/`. Each maps to a parallel
766
+ // entry under `<newName>/`. Sorted by length DESC so we update the
767
+ // deepest path first if any later step needs deterministic ordering
768
+ // (the SQL we run is order-independent, but it costs nothing here).
769
+ //
770
+ // `escapeLikePattern` neutralizes `%` and `_` inside the operator-
771
+ // supplied tag name so a tag literally named `task_` doesn't pull
772
+ // `taskX/sub` into the rename transaction (that would be a write the
773
+ // caller never asked for — far worse than a downstream false-positive
774
+ // candidate). `ESCAPE '\\'` is required for the escape to take effect.
775
+ const subRows = db
776
+ .prepare("SELECT name FROM tags WHERE name LIKE ? ESCAPE '\\' ORDER BY length(name) DESC")
777
+ .all(`${escapeLikePattern(oldName)}/%`) as { name: string }[];
778
+ const renames: { from: string; to: string }[] = [
779
+ { from: oldName, to: newName },
780
+ ...subRows.map((r) => ({ from: r.name, to: `${newName}${r.name.slice(oldName.length)}` })),
781
+ ];
782
+
783
+ // Pre-flight: if any new name already exists as a tag (and isn't itself
784
+ // about to be renamed away), abort with structured error. No rows
785
+ // mutated. The renamed-away set covers both `oldName` itself (which
786
+ // becomes `newName` — fine) and any sub-tag whose new path happens to
787
+ // collide with an existing sub-tag (uncommon but possible if the
788
+ // operator picks an awkward target).
789
+ const renamedAway = new Set(renames.map((r) => r.from));
790
+ const conflicting: string[] = [];
791
+ const existsStmt = db.prepare("SELECT 1 FROM tags WHERE name = ?");
792
+ for (const { to } of renames) {
793
+ if (renamedAway.has(to)) continue;
794
+ if (existsStmt.get(to)) conflicting.push(to);
795
+ }
796
+ if (conflicting.length > 0) {
797
+ return { error: "target_exists", conflicting };
798
+ }
453
799
 
454
- db.exec("BEGIN");
800
+ db.exec("BEGIN IMMEDIATE");
455
801
  try {
456
- // Order matters: the note_tags FK points at tags(name), and tag_schemas'
457
- // FK cascades on delete. Seed the new row, move the schema + note_tags
458
- // onto it, then drop the old row.
459
- db.prepare("INSERT INTO tags (name) VALUES (?)").run(newName);
460
- db.prepare("UPDATE tag_schemas SET tag_name = ? WHERE tag_name = ?").run(newName, oldName);
461
- const updated = db.prepare("UPDATE note_tags SET tag_name = ? WHERE tag_name = ?").run(newName, oldName);
462
- db.prepare("DELETE FROM tags WHERE name = ?").run(oldName);
802
+ let renamedNoteTags = 0;
803
+ let pathsRenamed = 0;
804
+
805
+ // ---- Tag-row rename pass.
806
+ //
807
+ // Order: insert new row (carrying identity), repoint note_tags, drop
808
+ // old row. Per-rename, mirroring the pre-cascade behavior. The
809
+ // note_tags FK on `tag_name` has no ON DELETE, so the delete must
810
+ // come AFTER the repoint.
811
+ const now = new Date().toISOString();
812
+ const readStmt = db.prepare(
813
+ "SELECT description, fields, relationships, parent_names, created_at FROM tags WHERE name = ?",
814
+ );
815
+ const insertStmt = db.prepare(
816
+ `INSERT INTO tags (name, description, fields, relationships, parent_names, created_at, updated_at)
817
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
818
+ );
819
+ const repointStmt = db.prepare(
820
+ "UPDATE note_tags SET tag_name = ? WHERE tag_name = ? RETURNING note_id",
821
+ );
822
+ const dropStmt = db.prepare("DELETE FROM tags WHERE name = ?");
823
+ for (const { from, to } of renames) {
824
+ const old = readStmt.get(from) as
825
+ | { description: string | null; fields: string | null; relationships: string | null; parent_names: string | null; created_at: string | null }
826
+ | undefined;
827
+ insertStmt.run(
828
+ to,
829
+ old?.description ?? null,
830
+ old?.fields ?? null,
831
+ old?.relationships ?? null,
832
+ old?.parent_names ?? null,
833
+ old?.created_at ?? now,
834
+ now,
835
+ );
836
+ const repointed = repointStmt.all(to, from) as { note_id: string }[];
837
+ renamedNoteTags += repointed.length;
838
+ dropStmt.run(from);
839
+ }
840
+
841
+ // ---- JSON-array cascade across parent_names / scoped_tags /
842
+ // declarer_tags. Same shape three times: cheap LIKE pre-filter, then
843
+ // per-row JSON.parse → array.map → JSON.stringify. Replacing on the
844
+ // parsed array (not the encoded string) is robust against escaping
845
+ // edge cases. The pre-filter narrows the per-row work to just the
846
+ // candidates that mention any of the renamed names.
847
+ //
848
+ // Each call site supplies its own column name for the filter — SQL
849
+ // doesn't expand `column LIKE (a OR b)` into a disjunction. We also
850
+ // escape LIKE wildcards (`%`, `_`) inside tag names and append
851
+ // `ESCAPE '\\'` to every clause so a tag literally named `task_`
852
+ // doesn't match `taskX` as a false-positive candidate.
853
+ const renameMap = new Map(renames.map((r) => [r.from, r.to]));
854
+ const remap = (s: string): string => renameMap.get(s) ?? s;
855
+ const likeClauseFor = (column: string): string =>
856
+ renames
857
+ .map((r) => `${column} LIKE '%"${escapeJsonLike(r.from)}"%' ESCAPE '\\'`)
858
+ .join(" OR ");
859
+
860
+ let parentRefsUpdated = 0;
861
+ {
862
+ const rows = db
863
+ .prepare(`SELECT name, parent_names FROM tags WHERE parent_names IS NOT NULL AND (${likeClauseFor("parent_names")})`)
864
+ .all() as { name: string; parent_names: string }[];
865
+ // We just renamed every old name; the rows we're updating are now
866
+ // keyed by the new name where applicable. The candidate clause
867
+ // matched `parent_names` containing any old name — those references
868
+ // are stale and need rewriting.
869
+ const updateStmt = db.prepare(
870
+ "UPDATE tags SET parent_names = ?, updated_at = ? WHERE name = ?",
871
+ );
872
+ for (const row of rows) {
873
+ const next = remapJsonArray(row.parent_names, remap);
874
+ if (next === null) continue;
875
+ updateStmt.run(next, now, row.name);
876
+ parentRefsUpdated++;
877
+ }
878
+ }
879
+
880
+ let tokensUpdated = 0;
881
+ if (hasTable(db, "tokens")) {
882
+ const rows = db
883
+ .prepare(`SELECT token_hash, scoped_tags FROM tokens WHERE scoped_tags IS NOT NULL AND (${likeClauseFor("scoped_tags")})`)
884
+ .all() as { token_hash: string; scoped_tags: string }[];
885
+ const updateStmt = db.prepare("UPDATE tokens SET scoped_tags = ? WHERE token_hash = ?");
886
+ for (const row of rows) {
887
+ const next = remapJsonArray(row.scoped_tags, remap);
888
+ if (next === null) continue;
889
+ updateStmt.run(next, row.token_hash);
890
+ tokensUpdated++;
891
+ }
892
+ }
893
+
894
+ let declarersUpdated = 0;
895
+ if (hasTable(db, "indexed_fields")) {
896
+ const rows = db
897
+ .prepare(`SELECT field, declarer_tags FROM indexed_fields WHERE declarer_tags IS NOT NULL AND (${likeClauseFor("declarer_tags")})`)
898
+ .all() as { field: string; declarer_tags: string }[];
899
+ const updateStmt = db.prepare("UPDATE indexed_fields SET declarer_tags = ? WHERE field = ?");
900
+ for (const row of rows) {
901
+ const next = remapJsonArray(row.declarer_tags, remap);
902
+ if (next === null) continue;
903
+ updateStmt.run(next, row.field);
904
+ declarersUpdated++;
905
+ }
906
+ }
907
+
908
+ // ---- Note body content: rewrite `#<oldname>` and `#<oldname>/...`
909
+ // references. Sub-tag rewrites cascade naturally — `task/work`
910
+ // appears in `renames` so a body that says `#task/work` rewrites
911
+ // directly to `#todo/work` without splitting into prefix-replace.
912
+ //
913
+ // ALSO `[[_tags/<oldname>...]]` wikilinks (post-v14 these are
914
+ // historical, but if any vault still carries them, keep them
915
+ // pointing at the right path).
916
+ let notesRewritten = 0;
917
+ {
918
+ // Each pair of LIKE clauses uses ESCAPE '\\' so the bound pattern
919
+ // can carry a literal `%` or `_` from a tag name without the LIKE
920
+ // engine treating them as wildcards. The middle of the bound
921
+ // string is `escapeLikePattern(from)`; the leading/trailing `%` we
922
+ // wrap in are still our actual wildcards.
923
+ const orClauses = renames
924
+ .map(() => "(content LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')")
925
+ .join(" OR ");
926
+ const params: string[] = [];
927
+ for (const { from } of renames) {
928
+ const safe = escapeLikePattern(from);
929
+ params.push(`%#${safe}%`, `%[[_tags/${safe}%`);
930
+ }
931
+ const candidates = db
932
+ .prepare(`SELECT id, content FROM notes WHERE content IS NOT NULL AND content != '' AND (${orClauses})`)
933
+ .all(...params) as { id: string; content: string }[];
934
+ const updateStmt = db.prepare("UPDATE notes SET content = ? WHERE id = ?");
935
+ for (const row of candidates) {
936
+ const next = rewriteNoteBody(row.content, renames);
937
+ if (next === row.content) continue;
938
+ updateStmt.run(next, row.id);
939
+ notesRewritten++;
940
+ }
941
+ }
942
+
943
+ // ---- `_tags/<oldname>...` config-note paths. Post-v14 these are
944
+ // inert (the resolver reads `tags.parent_names`, not the notes).
945
+ // Renaming the path keeps the vault internally consistent for any
946
+ // operator who still inspects them by hand.
947
+ {
948
+ const orClauses = renames.map(() => "path LIKE ? ESCAPE '\\'").join(" OR ");
949
+ const params = renames.map((r) => `_tags/${escapeLikePattern(r.from)}%`);
950
+ const candidates = db
951
+ .prepare(`SELECT id, path FROM notes WHERE path IS NOT NULL AND (${orClauses})`)
952
+ .all(...params) as { id: string; path: string }[];
953
+ const updateStmt = db.prepare("UPDATE notes SET path = ? WHERE id = ?");
954
+ for (const row of candidates) {
955
+ const next = rewriteTagConfigPath(row.path, renames);
956
+ if (next === row.path) continue;
957
+ updateStmt.run(next, row.id);
958
+ pathsRenamed++;
959
+ }
960
+ }
961
+
463
962
  db.exec("COMMIT");
464
- return { renamed: Number(updated.changes) };
963
+
964
+ const result: RenameTagSuccess = {
965
+ renamed: renamedNoteTags,
966
+ sub_tags_renamed: renames.length - 1,
967
+ parent_refs_updated: parentRefsUpdated,
968
+ tokens_updated: tokensUpdated,
969
+ indexed_field_declarers_updated: declarersUpdated,
970
+ notes_rewritten: notesRewritten,
971
+ paths_renamed: pathsRenamed,
972
+ };
973
+
974
+ // Audit log: single line so operators searching `[vault] tag rename`
975
+ // can correlate cascades after the fact. Includes the stats and the
976
+ // mapping for non-trivial sub-tag cases.
977
+ console.error(
978
+ `[vault] tag rename cascade: ${oldName} → ${newName}` +
979
+ (renames.length > 1 ? ` (+${renames.length - 1} sub-tags)` : "") +
980
+ ` — note_tags:${result.renamed} parent_refs:${result.parent_refs_updated} tokens:${result.tokens_updated} indexed:${result.indexed_field_declarers_updated} notes:${result.notes_rewritten} paths:${result.paths_renamed}`,
981
+ );
982
+
983
+ return result;
465
984
  } catch (err) {
466
985
  db.exec("ROLLBACK");
467
986
  throw err;
468
987
  }
469
988
  }
470
989
 
990
+ function emptyCascadeResult(): RenameTagSuccess {
991
+ return {
992
+ renamed: 0,
993
+ sub_tags_renamed: 0,
994
+ parent_refs_updated: 0,
995
+ tokens_updated: 0,
996
+ indexed_field_declarers_updated: 0,
997
+ notes_rewritten: 0,
998
+ paths_renamed: 0,
999
+ };
1000
+ }
1001
+
1002
+ /**
1003
+ * Re-encode a JSON-array column after applying `remap` to every entry,
1004
+ * dropping duplicates after remap. Returns the new JSON string, or null
1005
+ * if parsing failed / the array became empty (the caller decides whether
1006
+ * empty means "leave column" vs "set to NULL"; current callers leave the
1007
+ * column as the new array since the existing schema accepts empty JSON).
1008
+ */
1009
+ function remapJsonArray(raw: string, remap: (s: string) => string): string | null {
1010
+ let parsed: unknown;
1011
+ try { parsed = JSON.parse(raw); } catch { return null; }
1012
+ if (!Array.isArray(parsed)) return null;
1013
+ const seen = new Set<string>();
1014
+ const next: string[] = [];
1015
+ for (const v of parsed) {
1016
+ if (typeof v !== "string") continue;
1017
+ const mapped = remap(v);
1018
+ if (seen.has(mapped)) continue;
1019
+ seen.add(mapped);
1020
+ next.push(mapped);
1021
+ }
1022
+ return JSON.stringify(next);
1023
+ }
1024
+
1025
+ /**
1026
+ * Apply every rename to a note's body content. Walks the rename list
1027
+ * longest-first so `#task/work` rewrites cleanly before `#task` would
1028
+ * grab the same prefix. Word-boundary semantics: a tag reference is
1029
+ * `#name` followed by either end-of-string, whitespace, punctuation, or
1030
+ * `/`. We ignore matches inside fenced code blocks — those are typically
1031
+ * escaped examples and rewriting them silently changes documented
1032
+ * behavior. (We DO touch inline code spans; the trade-off is too noisy
1033
+ * to track precisely and the operator can audit via the rewrite count.)
1034
+ */
1035
+ function rewriteNoteBody(content: string, renames: { from: string; to: string }[]): string {
1036
+ // Sort longest-first so `task/work` is matched before `task`.
1037
+ const sorted = [...renames].sort((a, b) => b.from.length - a.from.length);
1038
+ let out = content;
1039
+ for (const { from, to } of sorted) {
1040
+ // `#tag` / `#tag/...` references. `(?<=^|[\s\p{P}])` would be ideal
1041
+ // but we use a simpler form: match at start of string, or after
1042
+ // whitespace, or after a character that isn't part of a tag run.
1043
+ // Tag references end at a whitespace, end-of-string, or any non-tag
1044
+ // character (we approximate with `[^a-zA-Z0-9/_-]`).
1045
+ const tagRe = new RegExp(
1046
+ `(^|[^a-zA-Z0-9/_#-])#${escapeRegex(from)}(?=$|[^a-zA-Z0-9/_-])`,
1047
+ "g",
1048
+ );
1049
+ out = out.replace(tagRe, `$1#${to}`);
1050
+ // `[[_tags/oldname]]` and `[[_tags/oldname#...]]` wikilink targets.
1051
+ const wikiRe = new RegExp(
1052
+ `\\[\\[_tags/${escapeRegex(from)}(?=[\\]|#])`,
1053
+ "g",
1054
+ );
1055
+ out = out.replace(wikiRe, `[[_tags/${to}`);
1056
+ }
1057
+ return out;
1058
+ }
1059
+
1060
+ /**
1061
+ * Apply every rename to a `_tags/<oldname>...` path.
1062
+ */
1063
+ function rewriteTagConfigPath(path: string, renames: { from: string; to: string }[]): string {
1064
+ // Longest-first so `_tags/task/work` matches before `_tags/task`.
1065
+ const sorted = [...renames].sort((a, b) => b.from.length - a.from.length);
1066
+ for (const { from, to } of sorted) {
1067
+ if (path === `_tags/${from}`) return `_tags/${to}`;
1068
+ if (path.startsWith(`_tags/${from}/`)) {
1069
+ return `_tags/${to}${path.slice(`_tags/${from}`.length)}`;
1070
+ }
1071
+ }
1072
+ return path;
1073
+ }
1074
+
1075
+ function hasTable(db: Database, name: string): boolean {
1076
+ const row = db
1077
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
1078
+ .get(name);
1079
+ return !!row;
1080
+ }
1081
+
1082
+ /**
1083
+ * Escape a tag name for inline interpolation into a SQL LIKE pattern.
1084
+ * Doubles `'` for SQL-string safety AND backslash-prefixes the LIKE
1085
+ * wildcards (`%`, `_`) so a tag literally named `task_` doesn't match
1086
+ * `taskX` as a false-positive candidate. The escape character `\` is
1087
+ * declared at each call site via `ESCAPE '\\'`.
1088
+ *
1089
+ * Order matters: escape `\` first so a tag containing a backslash gets
1090
+ * its backslash doubled before we add our own escape prefixes for the
1091
+ * wildcards.
1092
+ */
1093
+ function escapeJsonLike(s: string): string {
1094
+ return s
1095
+ .replace(/\\/g, "\\\\")
1096
+ .replace(/'/g, "''")
1097
+ .replace(/%/g, "\\%")
1098
+ .replace(/_/g, "\\_");
1099
+ }
1100
+
1101
+ /**
1102
+ * Escape a tag name destined for a parameterized LIKE binding. No SQL
1103
+ * quote escape (param-binding handles that); just the wildcard
1104
+ * neutralization. Pair with `LIKE ? ESCAPE '\\'`.
1105
+ */
1106
+ function escapeLikePattern(s: string): string {
1107
+ return s
1108
+ .replace(/\\/g, "\\\\")
1109
+ .replace(/%/g, "\\%")
1110
+ .replace(/_/g, "\\_");
1111
+ }
1112
+
1113
+ function escapeRegex(s: string): string {
1114
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1115
+ }
1116
+
471
1117
  export function mergeTags(
472
1118
  db: Database,
473
1119
  sources: string[],
@@ -500,8 +1146,9 @@ export function mergeTags(
500
1146
  const before = (countStmt.get(source) as { c: number }).c;
501
1147
  retagStmt.run(target, source);
502
1148
  deleteNoteTagsStmt.run(source);
503
- // tag_schemas has ON DELETE CASCADE from tags(name), so dropping the
504
- // tag row also drops its schema — which is what we want for a merge.
1149
+ // Dropping the tag row drops its identity (description, fields,
1150
+ // relationships, parent_names) along with it — which is what we want
1151
+ // for a merge: the source's identity is consumed by the target.
505
1152
  deleteTagStmt.run(source);
506
1153
  merged[source] = before;
507
1154
  }
@@ -615,6 +1262,9 @@ export function getVaultStats(
615
1262
  const tagCountRow = db.prepare("SELECT COUNT(DISTINCT tag_name) as c FROM note_tags").get() as { c: number };
616
1263
  const tagCount = tagCountRow.c;
617
1264
 
1265
+ const attachmentCountRow = db.prepare("SELECT COUNT(*) as c FROM attachments").get() as { c: number };
1266
+ const attachmentCount = attachmentCountRow.c;
1267
+
618
1268
  const linkCountRow = db.prepare("SELECT COUNT(*) as c FROM links").get() as { c: number };
619
1269
  const linkCount = linkCountRow.c;
620
1270
 
@@ -629,6 +1279,7 @@ export function getVaultStats(
629
1279
  notesByMonth: monthRows,
630
1280
  topTags: topTagRows,
631
1281
  tagCount,
1282
+ attachmentCount,
632
1283
  linkCount,
633
1284
  };
634
1285
  }