@openparachute/vault 0.2.3 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/CHANGELOG.md +70 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +603 -19
  6. package/core/src/indexed-fields.test.ts +285 -0
  7. package/core/src/indexed-fields.ts +238 -0
  8. package/core/src/mcp.ts +127 -6
  9. package/core/src/notes.ts +157 -11
  10. package/core/src/query-operators.ts +174 -0
  11. package/core/src/schema.ts +69 -2
  12. package/core/src/store.ts +92 -0
  13. package/core/src/tag-schemas.ts +5 -0
  14. package/core/src/types.ts +29 -1
  15. package/docs/HTTP_API.md +105 -1
  16. package/package/package.json +32 -0
  17. package/package.json +2 -2
  18. package/src/auth.test.ts +83 -114
  19. package/src/auth.ts +68 -6
  20. package/src/backup-launchd.ts +1 -1
  21. package/src/backup.test.ts +1 -1
  22. package/src/backup.ts +18 -17
  23. package/src/cli.ts +179 -121
  24. package/src/config-triggers.test.ts +49 -0
  25. package/src/config.test.ts +317 -2
  26. package/src/config.ts +420 -40
  27. package/src/context.test.ts +136 -0
  28. package/src/context.ts +115 -0
  29. package/src/daemon.ts +17 -16
  30. package/src/doctor.test.ts +9 -7
  31. package/src/launchd.test.ts +1 -1
  32. package/src/launchd.ts +6 -6
  33. package/src/mcp-http.ts +75 -21
  34. package/src/mcp-install.test.ts +125 -0
  35. package/src/mcp-install.ts +60 -0
  36. package/src/mcp-tools.ts +34 -96
  37. package/src/module-config.ts +109 -0
  38. package/src/oauth.test.ts +345 -57
  39. package/src/oauth.ts +155 -35
  40. package/src/published.test.ts +2 -2
  41. package/src/routes.ts +209 -33
  42. package/src/routing.test.ts +817 -300
  43. package/src/routing.ts +204 -202
  44. package/src/scopes.test.ts +136 -0
  45. package/src/scopes.ts +105 -0
  46. package/src/scribe-env.test.ts +49 -0
  47. package/src/scribe-env.ts +33 -0
  48. package/src/server.ts +57 -5
  49. package/src/services-manifest.test.ts +140 -0
  50. package/src/services-manifest.ts +99 -0
  51. package/src/systemd.ts +3 -3
  52. package/src/token-store.ts +42 -9
  53. package/src/transcription-worker.test.ts +583 -0
  54. package/src/transcription-worker.ts +346 -0
  55. package/src/triggers.test.ts +191 -1
  56. package/src/triggers.ts +17 -2
  57. package/src/vault.test.ts +693 -77
  58. package/src/version.test.ts +1 -1
package/core/src/mcp.ts CHANGED
@@ -4,6 +4,8 @@ import * as noteOps from "./notes.js";
4
4
  import { filterMetadata } from "./notes.js";
5
5
  import * as linkOps from "./links.js";
6
6
  import * as tagSchemaOps from "./tag-schemas.js";
7
+ import type { TagFieldSchema } from "./tag-schemas.js";
8
+ import * as indexedFieldOps from "./indexed-fields.js";
7
9
  import {
8
10
  expandContent,
9
11
  DEFAULT_EXPAND_DEPTH,
@@ -101,10 +103,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
101
103
  },
102
104
  tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
103
105
  exclude_tags: { type: "array", items: { type: "string" }, description: "Exclude notes with these tags" },
106
+ has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
107
+ has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction)." },
104
108
  path: { type: "string", description: "Exact path match (case-insensitive)" },
105
109
  path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
106
110
  search: { type: "string", description: "Full-text search query" },
107
- metadata: { type: "object", description: "Filter by metadata values (exact match per key)" },
111
+ metadata: {
112
+ type: "object",
113
+ description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
114
+ },
115
+ order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
108
116
  date_from: { type: "string", description: "Start date (ISO, inclusive)" },
109
117
  date_to: { type: "string", description: "End date (ISO, exclusive)" },
110
118
  near: {
@@ -201,12 +209,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
201
209
  tags,
202
210
  tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
203
211
  excludeTags: params.exclude_tags as string[] | undefined,
212
+ hasTags: params.has_tags as boolean | undefined,
213
+ hasLinks: params.has_links as boolean | undefined,
204
214
  path: params.path as string | undefined,
205
215
  pathPrefix: params.path_prefix as string | undefined,
206
216
  metadata: params.metadata as Record<string, unknown> | undefined,
207
217
  dateFrom: params.date_from as string | undefined,
208
218
  dateTo: params.date_to as string | undefined,
209
219
  sort: params.sort as "asc" | "desc" | undefined,
220
+ orderBy: params.order_by as string | undefined,
210
221
  limit: (params.limit as number) ?? 50,
211
222
  offset: params.offset as number | undefined,
212
223
  });
@@ -348,7 +359,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
348
359
  - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
349
360
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
350
361
  - For batch: pass a \`notes\` array, each with an \`id\` field.
351
- - Optimistic concurrency: pass \`if_updated_at\` with the \`updated_at\` value you last read. The update is rejected with a conflict error if the note has changed since. Re-read the note, reconcile, and retry.`,
362
+ - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally.`,
352
363
  inputSchema: {
353
364
  type: "object",
354
365
  properties: {
@@ -357,7 +368,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
357
368
  path: { type: "string", description: "New path" },
358
369
  metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
359
370
  created_at: { type: "string", description: "New created_at timestamp" },
360
- if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since." },
371
+ if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set." },
372
+ force: { type: "boolean", description: "Override the required `if_updated_at` check and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe." },
361
373
  tags: {
362
374
  type: "object",
363
375
  properties: {
@@ -405,7 +417,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
405
417
  path: { type: "string" },
406
418
  metadata: { type: "object" },
407
419
  created_at: { type: "string" },
408
- if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since." },
420
+ if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item." },
421
+ force: { type: "boolean", description: "Override the required `if_updated_at` check for this item." },
409
422
  tags: { type: "object" },
410
423
  links: { type: "object" },
411
424
  },
@@ -423,6 +436,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
423
436
  for (const item of items) {
424
437
  const note = requireNote(db, item.id as string);
425
438
 
439
+ // --- Safety-by-default: refuse mutations without a precondition ---
440
+ // The caller must either echo the note's last-seen `updated_at`
441
+ // (`if_updated_at`) so the conditional UPDATE can catch lost
442
+ // writes, or explicitly opt out with `force: true`. This runs
443
+ // *before* any DB writes so a rejection leaves the note untouched.
444
+ if (item.if_updated_at === undefined && item.force !== true) {
445
+ throw new PreconditionRequiredError(note.id, note.path ?? null);
446
+ }
447
+
426
448
  // --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
427
449
  // We compute the cleaned content so we can do the core UPDATE first
428
450
  // (with if_updated_at atomically) before any link deletions. If the
@@ -585,6 +607,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
585
607
  type: { type: "string", description: "Field type: string, boolean, integer" },
586
608
  description: { type: "string" },
587
609
  enum: { type: "array", items: { type: "string" }, description: "Allowed values (first is default)" },
610
+ indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed." },
588
611
  },
589
612
  required: ["type"],
590
613
  },
@@ -595,11 +618,76 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
595
618
  execute: (params) => {
596
619
  const tag = params.tag as string;
597
620
  const existing = tagSchemaOps.getTagSchema(db, tag);
598
- const mergedFields = { ...existing?.fields, ...(params.fields as any) };
599
- return tagSchemaOps.upsertTagSchema(db, tag, {
621
+ const incomingFields = (params.fields as Record<string, TagFieldSchema> | undefined) ?? {};
622
+ const mergedFields: Record<string, TagFieldSchema> = {
623
+ ...(existing?.fields ?? {}),
624
+ ...incomingFields,
625
+ };
626
+
627
+ // Validate cross-tag consistency on fields being (re)declared in this
628
+ // call. `type` and `indexed` are global — all declarers must agree.
629
+ // `description` and `enum` are per-tag, so we don't compare them.
630
+ const otherSchemas = tagSchemaOps
631
+ .listTagSchemas(db)
632
+ .filter((s) => s.tag !== tag);
633
+ for (const [fieldName, spec] of Object.entries(incomingFields)) {
634
+ const incomingIndexed = spec.indexed === true;
635
+ for (const other of otherSchemas) {
636
+ const otherSpec = other.fields?.[fieldName];
637
+ if (!otherSpec) continue;
638
+ if (otherSpec.type !== spec.type) {
639
+ throw new Error(
640
+ `field "${fieldName}" type conflict: tag "${tag}" declares "${spec.type}"; tag "${other.tag}" declares "${otherSpec.type}". Types must agree across all declarers.`,
641
+ );
642
+ }
643
+ if ((otherSpec.indexed === true) !== incomingIndexed) {
644
+ throw new Error(
645
+ `field "${fieldName}" indexed-flag conflict: tag "${tag}" sets indexed=${incomingIndexed}; tag "${other.tag}" sets indexed=${otherSpec.indexed === true}. Must match across all declarers — change them atomically or not at all.`,
646
+ );
647
+ }
648
+ }
649
+ if (incomingIndexed) {
650
+ const mapped = indexedFieldOps.mapFieldType(spec.type);
651
+ if (!mapped) {
652
+ throw new Error(
653
+ `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
654
+ );
655
+ }
656
+ indexedFieldOps.validateFieldName(fieldName);
657
+ }
658
+ }
659
+
660
+ // Persist the schema first, then reconcile indexing lifecycle. An
661
+ // error here would leave the on-disk schema untouched, matching
662
+ // prior behavior.
663
+ const result = tagSchemaOps.upsertTagSchema(db, tag, {
600
664
  description: (params.description as string | undefined) ?? existing?.description,
601
665
  fields: Object.keys(mergedFields).length > 0 ? mergedFields : undefined,
602
666
  });
667
+
668
+ // Diff indexed state for this tag: what it indexed before vs. now.
669
+ const priorIndexed = new Set(
670
+ Object.entries(existing?.fields ?? {})
671
+ .filter(([, v]) => v.indexed === true)
672
+ .map(([k]) => k),
673
+ );
674
+ const nextIndexed = new Set(
675
+ Object.entries(mergedFields)
676
+ .filter(([, v]) => v.indexed === true)
677
+ .map(([k]) => k),
678
+ );
679
+ for (const fieldName of nextIndexed) {
680
+ const spec = mergedFields[fieldName]!;
681
+ const mapped = indexedFieldOps.mapFieldType(spec.type)!;
682
+ indexedFieldOps.declareField(db, fieldName, mapped, tag);
683
+ }
684
+ for (const fieldName of priorIndexed) {
685
+ if (!nextIndexed.has(fieldName)) {
686
+ indexedFieldOps.releaseField(db, fieldName, tag);
687
+ }
688
+ }
689
+
690
+ return result;
603
691
  },
604
692
  },
605
693
 
@@ -618,6 +706,17 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
618
706
  },
619
707
  execute: async (params) => {
620
708
  const tag = params.tag as string;
709
+ // Release any indexed fields this tag declared before the schema
710
+ // row disappears. releaseField drops the generated column + index
711
+ // when the declarer set empties.
712
+ const schema = tagSchemaOps.getTagSchema(db, tag);
713
+ if (schema?.fields) {
714
+ for (const [fieldName, spec] of Object.entries(schema.fields)) {
715
+ if (spec.indexed === true) {
716
+ indexedFieldOps.releaseField(db, fieldName, tag);
717
+ }
718
+ }
719
+ }
621
720
  // Delete schema first (FK cascade would handle it, but be explicit)
622
721
  tagSchemaOps.deleteTagSchema(db, tag);
623
722
  return await store.deleteTag(tag);
@@ -733,3 +832,25 @@ function normalizeTags(tag: unknown): string[] | undefined {
733
832
  // conditional-UPDATE implementation that raises it.
734
833
  export { ConflictError } from "./notes.js";
735
834
 
835
+ /**
836
+ * Thrown by the `update-note` MCP tool (and the REST PATCH handler) when a
837
+ * caller tries to mutate a note without either an `if_updated_at` token or
838
+ * an explicit `force: true` opt-out. The `if_updated_at` requirement is the
839
+ * safety-by-default posture — we'd rather refuse an ambiguous write than
840
+ * silently overwrite someone else's edit.
841
+ */
842
+ export class PreconditionRequiredError extends Error {
843
+ code = "PRECONDITION_REQUIRED" as const;
844
+ note_id: string;
845
+ note_path: string | null;
846
+
847
+ constructor(noteId: string, notePath: string | null) {
848
+ super(
849
+ `precondition required: update-note rejects an item without \`if_updated_at\` (read the note's updated_at and echo it) or \`force: true\` (explicit override). note="${noteId}"`,
850
+ );
851
+ this.name = "PreconditionRequiredError";
852
+ this.note_id = noteId;
853
+ this.note_path = notePath;
854
+ }
855
+ }
856
+
package/core/src/notes.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { Note, NoteIndex, QueryOpts, VaultStats } from "./types.js";
3
3
  import { normalizePath } from "./paths.js";
4
+ import {
5
+ buildOperatorClause,
6
+ isOperatorObject,
7
+ requireIndexedField,
8
+ } from "./query-operators.js";
4
9
 
5
10
  let idCounter = 0;
6
11
 
@@ -30,9 +35,15 @@ export function createNote(
30
35
  const metadata = opts?.metadata ? JSON.stringify(opts.metadata) : "{}";
31
36
  const path = normalizePath(opts?.path);
32
37
 
38
+ // `updated_at` is set to `created_at` on insert so a client whose optimistic
39
+ // concurrency check falls back to `createdAt` on a never-updated note
40
+ // (the common shape: `note.updatedAt ?? note.createdAt`) matches the stored
41
+ // value. Hook-style writes with `skipUpdatedAt` preserve this; real user
42
+ // edits bump it strictly upward, so `updated_at > created_at` still means
43
+ // "user-touched since creation."
33
44
  db.prepare(
34
- `INSERT INTO notes (id, content, path, metadata, created_at) VALUES (?, ?, ?, ?, ?)`,
35
- ).run(id, content, path, metadata, createdAt);
45
+ `INSERT INTO notes (id, content, path, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
46
+ ).run(id, content, path, metadata, createdAt, createdAt);
36
47
 
37
48
  if (opts?.tags && opts.tags.length > 0) {
38
49
  tagNote(db, id, opts.tags);
@@ -81,15 +92,17 @@ export function getNotes(db: Database, ids: string[]): Note[] {
81
92
  export class ConflictError extends Error {
82
93
  code = "CONFLICT" as const;
83
94
  note_id: string;
95
+ note_path: string | null;
84
96
  current_updated_at: string | null;
85
97
  expected_updated_at: string;
86
98
 
87
- constructor(noteId: string, current: string | null, expected: string) {
99
+ constructor(noteId: string, notePath: string | null, current: string | null, expected: string) {
88
100
  super(
89
101
  `conflict: note "${noteId}" has been modified (current updated_at=${current ?? "null"}, expected=${expected})`,
90
102
  );
91
103
  this.name = "ConflictError";
92
104
  this.note_id = noteId;
105
+ this.note_path = notePath;
93
106
  this.current_updated_at = current;
94
107
  this.expected_updated_at = expected;
95
108
  }
@@ -184,13 +197,13 @@ export function updateNote(
184
197
  }
185
198
 
186
199
  function throwConflictOrMissing(db: Database, id: string, expected: string): never {
187
- const row = db.prepare("SELECT updated_at FROM notes WHERE id = ?").get(id) as
188
- | { updated_at: string | null }
200
+ const row = db.prepare("SELECT updated_at, path FROM notes WHERE id = ?").get(id) as
201
+ | { updated_at: string | null; path: string | null }
189
202
  | undefined;
190
203
  if (!row) {
191
204
  throw new Error(`Note not found: "${id}"`);
192
205
  }
193
- throw new ConflictError(id, row.updated_at, expected);
206
+ throw new ConflictError(id, row.path, row.updated_at, expected);
194
207
  }
195
208
 
196
209
  export function deleteNote(db: Database, id: string): void {
@@ -226,6 +239,26 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
226
239
  }
227
240
  }
228
241
 
242
+ // Presence: has_tags. When specific tags were already supplied, skip —
243
+ // the existing join/condition already enforces that any result has tags.
244
+ const filterByTags = Boolean(opts.tags && opts.tags.length > 0);
245
+ if (opts.hasTags !== undefined && !filterByTags) {
246
+ conditions.push(
247
+ opts.hasTags
248
+ ? `EXISTS (SELECT 1 FROM note_tags ht WHERE ht.note_id = n.id)`
249
+ : `NOT EXISTS (SELECT 1 FROM note_tags ht WHERE ht.note_id = n.id)`,
250
+ );
251
+ }
252
+
253
+ // Presence: has_links (either direction counts).
254
+ if (opts.hasLinks !== undefined) {
255
+ conditions.push(
256
+ opts.hasLinks
257
+ ? `EXISTS (SELECT 1 FROM links hl WHERE hl.source_id = n.id OR hl.target_id = n.id)`
258
+ : `NOT EXISTS (SELECT 1 FROM links hl WHERE hl.source_id = n.id OR hl.target_id = n.id)`,
259
+ );
260
+ }
261
+
229
262
  // Exact path match (case-insensitive)
230
263
  if (opts.path) {
231
264
  conditions.push("n.path = ? COLLATE NOCASE");
@@ -238,11 +271,23 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
238
271
  params.push(opts.pathPrefix + "%");
239
272
  }
240
273
 
241
- // Metadata filters
274
+ // Metadata filters — operator objects route through the indexed generated
275
+ // column (fast, loud errors on non-indexed fields); primitives keep the
276
+ // existing JSON-scan exact-match behavior for backcompat.
242
277
  if (opts.metadata) {
243
278
  for (const [key, value] of Object.entries(opts.metadata)) {
244
- conditions.push(`json_extract(n.metadata, '$.' || ?) = ?`);
245
- params.push(key, typeof value === "string" ? value : JSON.stringify(value));
279
+ if (isOperatorObject(value)) {
280
+ requireIndexedField(db, key);
281
+ const { sql, params: opParams } = buildOperatorClause(
282
+ key,
283
+ value as Record<string, unknown>,
284
+ );
285
+ conditions.push(sql);
286
+ params.push(...opParams);
287
+ } else {
288
+ conditions.push(`json_extract(n.metadata, '$.' || ?) = ?`);
289
+ params.push(key, typeof value === "string" ? value : JSON.stringify(value));
290
+ }
246
291
  }
247
292
  }
248
293
 
@@ -256,7 +301,18 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
256
301
  params.push(opts.dateTo);
257
302
  }
258
303
 
259
- const orderBy = `n.created_at ${opts.sort === "desc" ? "DESC" : "ASC"}`;
304
+ const direction = opts.sort === "desc" ? "DESC" : "ASC";
305
+ let orderBy: string;
306
+ if (opts.orderBy) {
307
+ requireIndexedField(db, opts.orderBy);
308
+ // `orderBy` came from indexed_fields (validated on declaration), so
309
+ // the column name is safe to interpolate. Append created_at as a
310
+ // stable tiebreaker so two rows with the same indexed value have a
311
+ // deterministic order.
312
+ orderBy = `"meta_${opts.orderBy}" ${direction}, n.created_at ${direction}`;
313
+ } else {
314
+ orderBy = `n.created_at ${direction}`;
315
+ }
260
316
  const limit = typeof opts.limit === "number" ? opts.limit : 100;
261
317
  const offset = typeof opts.offset === "number" ? opts.offset : 0;
262
318
 
@@ -375,6 +431,90 @@ export function deleteTag(db: Database, name: string): { deleted: boolean; notes
375
431
  return { deleted: true, notes_untagged: notesUntagged };
376
432
  }
377
433
 
434
+ // The UNIQUE PRIMARY KEY on tags.name means rename-to-existing is ambiguous:
435
+ // do you drop the source, or retag-and-drop? Callers must pick — rename errors
436
+ // out; mergeTags explicitly retags.
437
+ export type RenameTagResult =
438
+ | { renamed: number }
439
+ | { error: "not_found" }
440
+ | { error: "target_exists" };
441
+
442
+ export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
443
+ if (oldName === newName) {
444
+ const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
445
+ return exists ? { renamed: 0 } : { error: "not_found" };
446
+ }
447
+
448
+ const oldExists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
449
+ if (!oldExists) return { error: "not_found" };
450
+
451
+ const newExists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(newName);
452
+ if (newExists) return { error: "target_exists" };
453
+
454
+ db.exec("BEGIN");
455
+ 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);
463
+ db.exec("COMMIT");
464
+ return { renamed: Number(updated.changes) };
465
+ } catch (err) {
466
+ db.exec("ROLLBACK");
467
+ throw err;
468
+ }
469
+ }
470
+
471
+ export function mergeTags(
472
+ db: Database,
473
+ sources: string[],
474
+ target: string,
475
+ ): { merged: Record<string, number>; target: string } {
476
+ // Dedup + drop target-in-sources (self-merge is a no-op).
477
+ const uniqueSources = Array.from(new Set(sources)).filter((s) => s !== target);
478
+
479
+ const merged: Record<string, number> = {};
480
+
481
+ db.exec("BEGIN");
482
+ try {
483
+ // Target might not exist yet. Seed it so INSERT OR IGNORE into note_tags
484
+ // can reference it; leave any existing schema on target untouched.
485
+ db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(target);
486
+
487
+ const retagStmt = db.prepare(
488
+ "INSERT OR IGNORE INTO note_tags (note_id, tag_name) SELECT note_id, ? FROM note_tags WHERE tag_name = ?",
489
+ );
490
+ const deleteNoteTagsStmt = db.prepare("DELETE FROM note_tags WHERE tag_name = ?");
491
+ const deleteTagStmt = db.prepare("DELETE FROM tags WHERE name = ?");
492
+ const countStmt = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?");
493
+
494
+ for (const source of uniqueSources) {
495
+ const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(source);
496
+ if (!exists) {
497
+ merged[source] = 0;
498
+ continue;
499
+ }
500
+ const before = (countStmt.get(source) as { c: number }).c;
501
+ retagStmt.run(target, source);
502
+ 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.
505
+ deleteTagStmt.run(source);
506
+ merged[source] = before;
507
+ }
508
+
509
+ db.exec("COMMIT");
510
+ } catch (err) {
511
+ db.exec("ROLLBACK");
512
+ throw err;
513
+ }
514
+
515
+ return { merged, target };
516
+ }
517
+
378
518
  // ---- Lean note index shape ----
379
519
 
380
520
  /** Max code points in a NoteIndex preview. */
@@ -475,6 +615,9 @@ export function getVaultStats(
475
615
  const tagCountRow = db.prepare("SELECT COUNT(DISTINCT tag_name) as c FROM note_tags").get() as { c: number };
476
616
  const tagCount = tagCountRow.c;
477
617
 
618
+ const linkCountRow = db.prepare("SELECT COUNT(*) as c FROM links").get() as { c: number };
619
+ const linkCount = linkCountRow.c;
620
+
478
621
  return {
479
622
  totalNotes,
480
623
  earliestNote: earliestRow
@@ -486,6 +629,7 @@ export function getVaultStats(
486
629
  notesByMonth: monthRows,
487
630
  topTags: topTagRows,
488
631
  tagCount,
632
+ linkCount,
489
633
  };
490
634
  }
491
635
 
@@ -593,6 +737,8 @@ function rowToNote(row: NoteRow): Note {
593
737
  path: row.path ?? undefined,
594
738
  metadata,
595
739
  createdAt: row.created_at,
596
- updatedAt: row.updated_at ?? undefined,
740
+ // Legacy notes (pre-#70) may have NULL updated_at. Fall back to created_at
741
+ // so the optimistic-concurrency contract always has a real token to echo.
742
+ updatedAt: row.updated_at ?? row.created_at,
597
743
  };
598
744
  }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Metadata operator objects for `query-notes`.
3
+ *
4
+ * A metadata value that is a plain object whose keys are all drawn from
5
+ * {@link SUPPORTED_OPS} is treated as an operator query. Otherwise the value
6
+ * falls through to the existing exact-match behavior (primitive → JSON
7
+ * stringify compare).
8
+ *
9
+ * Operator queries route through the generated columns maintained by
10
+ * `indexed-fields`: `meta_<field>` on `notes`, backed by a B-tree index. The
11
+ * field must be declared `indexed: true` in some tag schema; otherwise we
12
+ * refuse with an actionable error (no silent fallback to a JSON scan, per the
13
+ * decision doc).
14
+ *
15
+ * See `Parachute/Decisions/2026-04-19-metadata-indexing-via-tag-schemas`.
16
+ */
17
+
18
+ import { Database } from "bun:sqlite";
19
+ import { getIndexedField, type IndexedField } from "./indexed-fields.js";
20
+
21
+ export const SUPPORTED_OPS = [
22
+ "eq",
23
+ "ne",
24
+ "gt",
25
+ "gte",
26
+ "lt",
27
+ "lte",
28
+ "in",
29
+ "not_in",
30
+ "exists",
31
+ ] as const;
32
+
33
+ export type QueryOp = (typeof SUPPORTED_OPS)[number];
34
+
35
+ const OPS_SET: ReadonlySet<string> = new Set<string>(SUPPORTED_OPS);
36
+
37
+ export class QueryError extends Error {
38
+ override name = "QueryError";
39
+ code: string;
40
+ constructor(message: string, code = "INVALID_QUERY") {
41
+ super(message);
42
+ this.code = code;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Returns true when `value` is a non-array, non-null, non-empty plain object.
48
+ * The presence of *any* plain-object value commits the caller to operator-
49
+ * parsing: a misspelled operator like `{ bogus: 5 }` is a loud error rather
50
+ * than a silent fallback to JSON-blob exact-match. Nested-object exact-match
51
+ * on the JSON blob was never a meaningful use case.
52
+ */
53
+ export function isOperatorObject(value: unknown): value is Record<string, unknown> {
54
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
55
+ return false;
56
+ }
57
+ return Object.keys(value as object).length > 0;
58
+ }
59
+
60
+ function validateOperatorObject(field: string, obj: Record<string, unknown>): void {
61
+ for (const key of Object.keys(obj)) {
62
+ if (!OPS_SET.has(key)) {
63
+ throw new QueryError(
64
+ `unknown operator "${key}" on metadata field "${field}". Supported: ${SUPPORTED_OPS.join(", ")}.`,
65
+ "UNKNOWN_OPERATOR",
66
+ );
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Look up `field` in `indexed_fields` or throw a loud error suggesting the
73
+ * caller declare it via `update-tag` with `indexed: true`.
74
+ */
75
+ export function requireIndexedField(db: Database, field: string): IndexedField {
76
+ const row = getIndexedField(db, field);
77
+ if (!row) {
78
+ throw new QueryError(
79
+ `metadata field "${field}" is not indexed. To use operator queries or order_by on this field, declare it via update-tag with indexed: true.`,
80
+ "FIELD_NOT_INDEXED",
81
+ );
82
+ }
83
+ return row;
84
+ }
85
+
86
+ /**
87
+ * Build a SQL fragment + bound params for an operator object on an indexed
88
+ * metadata field. Each operator maps to a single AND clause; an object like
89
+ * `{ gt: 5, lt: 10 }` composes as `meta_<field> > 5 AND meta_<field> < 10`.
90
+ */
91
+ export function buildOperatorClause(
92
+ field: string,
93
+ opObj: Record<string, unknown>,
94
+ ): { sql: string; params: unknown[] } {
95
+ validateOperatorObject(field, opObj);
96
+ // `field` came from indexed_fields (which validated it via FIELD_NAME_RE
97
+ // when the declaration was recorded), so interpolating it into the column
98
+ // name is safe.
99
+ const col = `"meta_${field}"`;
100
+ const parts: string[] = [];
101
+ const params: unknown[] = [];
102
+
103
+ for (const [op, value] of Object.entries(opObj)) {
104
+ switch (op as QueryOp) {
105
+ case "eq":
106
+ if (value === null) {
107
+ parts.push(`${col} IS NULL`);
108
+ } else {
109
+ parts.push(`${col} = ?`);
110
+ params.push(value);
111
+ }
112
+ break;
113
+ case "ne":
114
+ if (value === null) {
115
+ parts.push(`${col} IS NOT NULL`);
116
+ } else {
117
+ // Preserve "field is set AND not equal" semantics — SQLite's `<>`
118
+ // returns NULL (not true) when the LHS is NULL, so a notes row
119
+ // that has no value for the field would be silently excluded. Be
120
+ // explicit: either the column is null, or the values differ.
121
+ parts.push(`(${col} IS NULL OR ${col} <> ?)`);
122
+ params.push(value);
123
+ }
124
+ break;
125
+ case "gt":
126
+ case "gte":
127
+ case "lt":
128
+ case "lte": {
129
+ const sym = op === "gt" ? ">" : op === "gte" ? ">=" : op === "lt" ? "<" : "<=";
130
+ parts.push(`${col} ${sym} ?`);
131
+ params.push(value);
132
+ break;
133
+ }
134
+ case "in":
135
+ case "not_in": {
136
+ if (!Array.isArray(value)) {
137
+ throw new QueryError(
138
+ `operator "${op}" on metadata field "${field}" expects an array`,
139
+ "INVALID_OPERATOR_VALUE",
140
+ );
141
+ }
142
+ if (value.length === 0) {
143
+ // Empty IN is a contradiction; empty NOT IN is a no-op. Emit SQL
144
+ // that matches these semantics without running a parameterless
145
+ // `IN ()` (which is a syntax error in SQLite).
146
+ parts.push(op === "in" ? "0" : "1");
147
+ break;
148
+ }
149
+ const placeholders = value.map(() => "?").join(", ");
150
+ if (op === "in") {
151
+ parts.push(`${col} IN (${placeholders})`);
152
+ } else {
153
+ parts.push(`(${col} IS NULL OR ${col} NOT IN (${placeholders}))`);
154
+ }
155
+ for (const v of value) params.push(v);
156
+ break;
157
+ }
158
+ case "exists":
159
+ if (typeof value !== "boolean") {
160
+ throw new QueryError(
161
+ `operator "exists" on metadata field "${field}" expects a boolean`,
162
+ "INVALID_OPERATOR_VALUE",
163
+ );
164
+ }
165
+ parts.push(value ? `${col} IS NOT NULL` : `${col} IS NULL`);
166
+ break;
167
+ }
168
+ }
169
+
170
+ return {
171
+ sql: parts.length === 1 ? parts[0]! : `(${parts.join(" AND ")})`,
172
+ params,
173
+ };
174
+ }