@openparachute/vault 0.3.1 → 0.4.0

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 (82) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +9 -5
  3. package/core/src/core.test.ts +2252 -7
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +801 -67
  6. package/core/src/note-schemas.ts +232 -0
  7. package/core/src/notes.ts +313 -35
  8. package/core/src/obsidian.ts +3 -3
  9. package/core/src/paths.ts +1 -1
  10. package/core/src/query-operators.ts +23 -7
  11. package/core/src/schema-defaults.ts +287 -0
  12. package/core/src/schema.ts +393 -9
  13. package/core/src/store.ts +248 -6
  14. package/core/src/tag-hierarchy.ts +137 -0
  15. package/core/src/tag-schemas.ts +242 -42
  16. package/core/src/types.ts +100 -6
  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 +231 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +144 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +384 -78
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +296 -0
  30. package/src/hub-jwt.ts +79 -0
  31. package/src/init-summary.test.ts +133 -0
  32. package/src/init-summary.ts +90 -0
  33. package/src/init.test.ts +216 -0
  34. package/src/mcp-http.ts +30 -28
  35. package/src/mcp-install.ts +1 -1
  36. package/src/mcp-tools.ts +294 -6
  37. package/src/module-config.ts +1 -1
  38. package/src/oauth.test.ts +345 -0
  39. package/src/oauth.ts +85 -14
  40. package/src/owner-auth.ts +57 -1
  41. package/src/prompt.ts +31 -14
  42. package/src/routes.ts +686 -58
  43. package/src/routing.test.ts +466 -1
  44. package/src/routing.ts +108 -24
  45. package/src/scopes.test.ts +66 -8
  46. package/src/scopes.ts +163 -37
  47. package/src/server.ts +24 -2
  48. package/src/services-manifest.test.ts +20 -0
  49. package/src/services-manifest.ts +9 -2
  50. package/src/stop-signal.test.ts +85 -0
  51. package/src/storage.test.ts +92 -0
  52. package/src/tag-scope.ts +118 -0
  53. package/src/token-store.test.ts +47 -0
  54. package/src/token-store.ts +128 -13
  55. package/src/tokens-routes.test.ts +720 -0
  56. package/src/tokens-routes.ts +392 -0
  57. package/src/transcription-worker.test.ts +5 -0
  58. package/src/triggers.ts +1 -1
  59. package/src/two-factor.ts +2 -2
  60. package/src/vault-create.test.ts +193 -0
  61. package/src/vault-name.test.ts +123 -0
  62. package/src/vault-name.ts +80 -0
  63. package/src/vault.test.ts +868 -3
  64. package/tsconfig.json +8 -1
  65. package/.claude/settings.local.json +0 -8
  66. package/.dockerignore +0 -8
  67. package/.env.example +0 -9
  68. package/CHANGELOG.md +0 -175
  69. package/CLAUDE.md +0 -125
  70. package/Caddyfile +0 -3
  71. package/Dockerfile +0 -22
  72. package/bun.lock +0 -219
  73. package/bunfig.toml +0 -2
  74. package/deploy/parachute-vault.service +0 -20
  75. package/docker-compose.yml +0 -50
  76. package/docs/HTTP_API.md +0 -434
  77. package/docs/auth-model.md +0 -340
  78. package/fly.toml +0 -24
  79. package/package/package.json +0 -32
  80. package/railway.json +0 -14
  81. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  82. 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,51 @@ 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) maps to `n.created_at`; any
516
+ // other field must be declared `indexed: true` so the SQL targets
517
+ // a real B-tree index. The two shapes are mutually exclusive — the
518
+ // combination would silently AND, which would be surprising.
519
+ const hasLegacyDate = opts.dateFrom !== undefined || opts.dateTo !== undefined;
520
+ const hasDateFilter = opts.dateFilter !== undefined;
521
+ if (hasLegacyDate && hasDateFilter) {
522
+ throw new QueryError(
523
+ `cannot combine top-level date_from/date_to with date_filter — pass one or the other`,
524
+ "INVALID_QUERY",
525
+ );
298
526
  }
299
- if (opts.dateTo) {
300
- conditions.push("n.created_at < ?");
301
- params.push(opts.dateTo);
527
+ if (hasDateFilter) {
528
+ const filter = opts.dateFilter!;
529
+ const field = filter.field ?? "created_at";
530
+ let column: string;
531
+ if (field === "created_at") {
532
+ column = "n.created_at";
533
+ } else {
534
+ // Re-uses the same indexed-field gate as `metadata` operator queries
535
+ // and `orderBy` so the error message and contract are consistent.
536
+ requireIndexedField(db, field);
537
+ column = `"meta_${field}"`;
538
+ }
539
+ if (filter.from !== undefined) {
540
+ conditions.push(`${column} >= ?`);
541
+ params.push(filter.from);
542
+ }
543
+ if (filter.to !== undefined) {
544
+ conditions.push(`${column} < ?`);
545
+ params.push(filter.to);
546
+ }
547
+ } else if (hasLegacyDate) {
548
+ if (opts.dateFrom) {
549
+ conditions.push("n.created_at >= ?");
550
+ params.push(opts.dateFrom);
551
+ }
552
+ if (opts.dateTo) {
553
+ conditions.push("n.created_at < ?");
554
+ params.push(opts.dateTo);
555
+ }
302
556
  }
303
557
 
304
558
  const direction = opts.sort === "desc" ? "DESC" : "ASC";
@@ -453,15 +707,34 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
453
707
 
454
708
  db.exec("BEGIN");
455
709
  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);
710
+ // Order matters: note_tags' FK points at tags(name). Copy the old row's
711
+ // identity columns onto a new row keyed by `newName`, repoint note_tags,
712
+ // then drop the old row. Description/fields/relationships/parent_names
713
+ // travel with the rename — they're tag-identity data.
714
+ const old = db.prepare(
715
+ "SELECT description, fields, relationships, parent_names, created_at FROM tags WHERE name = ?",
716
+ ).get(oldName) as
717
+ | { description: string | null; fields: string | null; relationships: string | null; parent_names: string | null; created_at: string | null }
718
+ | undefined;
719
+ const now = new Date().toISOString();
720
+ db.prepare(
721
+ `INSERT INTO tags (name, description, fields, relationships, parent_names, created_at, updated_at)
722
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
723
+ ).run(
724
+ newName,
725
+ old?.description ?? null,
726
+ old?.fields ?? null,
727
+ old?.relationships ?? null,
728
+ old?.parent_names ?? null,
729
+ old?.created_at ?? now,
730
+ now,
731
+ );
732
+ const renamed = db.prepare(
733
+ "UPDATE note_tags SET tag_name = ? WHERE tag_name = ? RETURNING note_id",
734
+ ).all(newName, oldName) as { note_id: string }[];
462
735
  db.prepare("DELETE FROM tags WHERE name = ?").run(oldName);
463
736
  db.exec("COMMIT");
464
- return { renamed: Number(updated.changes) };
737
+ return { renamed: renamed.length };
465
738
  } catch (err) {
466
739
  db.exec("ROLLBACK");
467
740
  throw err;
@@ -500,8 +773,9 @@ export function mergeTags(
500
773
  const before = (countStmt.get(source) as { c: number }).c;
501
774
  retagStmt.run(target, source);
502
775
  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.
776
+ // Dropping the tag row drops its identity (description, fields,
777
+ // relationships, parent_names) along with it — which is what we want
778
+ // for a merge: the source's identity is consumed by the target.
505
779
  deleteTagStmt.run(source);
506
780
  merged[source] = before;
507
781
  }
@@ -615,6 +889,9 @@ export function getVaultStats(
615
889
  const tagCountRow = db.prepare("SELECT COUNT(DISTINCT tag_name) as c FROM note_tags").get() as { c: number };
616
890
  const tagCount = tagCountRow.c;
617
891
 
892
+ const attachmentCountRow = db.prepare("SELECT COUNT(*) as c FROM attachments").get() as { c: number };
893
+ const attachmentCount = attachmentCountRow.c;
894
+
618
895
  const linkCountRow = db.prepare("SELECT COUNT(*) as c FROM links").get() as { c: number };
619
896
  const linkCount = linkCountRow.c;
620
897
 
@@ -629,6 +906,7 @@ export function getVaultStats(
629
906
  notesByMonth: monthRows,
630
907
  topTags: topTagRows,
631
908
  tagCount,
909
+ attachmentCount,
632
910
  linkCount,
633
911
  };
634
912
  }
@@ -82,8 +82,8 @@ export function parseFrontmatter(raw: string): {
82
82
  // Key: value pair — keys must be YAML-valid (word chars and hyphens, no spaces)
83
83
  const kvMatch = line.match(/^([\w][\w-]*):\s*(.*)/);
84
84
  if (kvMatch) {
85
- const key = kvMatch[1];
86
- const value = kvMatch[2].trim();
85
+ const key = kvMatch[1]!;
86
+ const value = kvMatch[2]!.trim();
87
87
 
88
88
  if (value === "[]") {
89
89
  frontmatter[key] = [];
@@ -143,7 +143,7 @@ export function extractInlineTags(content: string): string[] {
143
143
  const regex = /(?:^|\s)#([\w][\w/-]*[\w]|[\w])/gm;
144
144
  let match: RegExpExecArray | null;
145
145
  while ((match = regex.exec(stripped)) !== null) {
146
- tags.add(match[1].toLowerCase());
146
+ tags.add(match[1]!.toLowerCase());
147
147
  }
148
148
  return [...tags];
149
149
  }
package/core/src/paths.ts CHANGED
@@ -39,7 +39,7 @@ export function normalizePath(path: string | null | undefined): string | null {
39
39
  */
40
40
  export function pathTitle(path: string): string {
41
41
  const segments = path.split("/");
42
- return segments[segments.length - 1];
42
+ return segments[segments.length - 1]!;
43
43
  }
44
44
 
45
45
  /**
@@ -15,7 +15,7 @@
15
15
  * See `Parachute/Decisions/2026-04-19-metadata-indexing-via-tag-schemas`.
16
16
  */
17
17
 
18
- import { Database } from "bun:sqlite";
18
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
19
19
  import { getIndexedField, type IndexedField } from "./indexed-fields.js";
20
20
 
21
21
  export const SUPPORTED_OPS = [
@@ -68,6 +68,22 @@ function validateOperatorObject(field: string, obj: Record<string, unknown>): vo
68
68
  }
69
69
  }
70
70
 
71
+ function toBinding(field: string, op: string, value: unknown): SQLQueryBindings {
72
+ if (
73
+ value === null ||
74
+ typeof value === "string" ||
75
+ typeof value === "number" ||
76
+ typeof value === "boolean" ||
77
+ typeof value === "bigint"
78
+ ) {
79
+ return value;
80
+ }
81
+ throw new QueryError(
82
+ `operator "${op}" on metadata field "${field}" expects a primitive value (string, number, boolean, bigint, or null), got ${typeof value}`,
83
+ "INVALID_OPERATOR_VALUE",
84
+ );
85
+ }
86
+
71
87
  /**
72
88
  * Look up `field` in `indexed_fields` or throw a loud error suggesting the
73
89
  * caller declare it via `update-tag` with `indexed: true`.
@@ -91,14 +107,14 @@ export function requireIndexedField(db: Database, field: string): IndexedField {
91
107
  export function buildOperatorClause(
92
108
  field: string,
93
109
  opObj: Record<string, unknown>,
94
- ): { sql: string; params: unknown[] } {
110
+ ): { sql: string; params: SQLQueryBindings[] } {
95
111
  validateOperatorObject(field, opObj);
96
112
  // `field` came from indexed_fields (which validated it via FIELD_NAME_RE
97
113
  // when the declaration was recorded), so interpolating it into the column
98
114
  // name is safe.
99
115
  const col = `"meta_${field}"`;
100
116
  const parts: string[] = [];
101
- const params: unknown[] = [];
117
+ const params: SQLQueryBindings[] = [];
102
118
 
103
119
  for (const [op, value] of Object.entries(opObj)) {
104
120
  switch (op as QueryOp) {
@@ -107,7 +123,7 @@ export function buildOperatorClause(
107
123
  parts.push(`${col} IS NULL`);
108
124
  } else {
109
125
  parts.push(`${col} = ?`);
110
- params.push(value);
126
+ params.push(toBinding(field, op, value));
111
127
  }
112
128
  break;
113
129
  case "ne":
@@ -119,7 +135,7 @@ export function buildOperatorClause(
119
135
  // that has no value for the field would be silently excluded. Be
120
136
  // explicit: either the column is null, or the values differ.
121
137
  parts.push(`(${col} IS NULL OR ${col} <> ?)`);
122
- params.push(value);
138
+ params.push(toBinding(field, op, value));
123
139
  }
124
140
  break;
125
141
  case "gt":
@@ -128,7 +144,7 @@ export function buildOperatorClause(
128
144
  case "lte": {
129
145
  const sym = op === "gt" ? ">" : op === "gte" ? ">=" : op === "lt" ? "<" : "<=";
130
146
  parts.push(`${col} ${sym} ?`);
131
- params.push(value);
147
+ params.push(toBinding(field, op, value));
132
148
  break;
133
149
  }
134
150
  case "in":
@@ -152,7 +168,7 @@ export function buildOperatorClause(
152
168
  } else {
153
169
  parts.push(`(${col} IS NULL OR ${col} NOT IN (${placeholders}))`);
154
170
  }
155
- for (const v of value) params.push(v);
171
+ for (const v of value) params.push(toBinding(field, op, v));
156
172
  break;
157
173
  }
158
174
  case "exists":