@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
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "bun:test";
2
2
  import { Database } from "bun:sqlite";
3
3
  import { SqliteStore } from "./store.js";
4
4
  import { generateMcpTools } from "./mcp.js";
5
+ import { initSchema } from "./schema.js";
5
6
 
6
7
  let store: SqliteStore;
7
8
  let db: Database;
@@ -68,7 +69,11 @@ describe("notes", async () => {
68
69
  const updated = await store.updateNote(note.id, { created_at: newDate });
69
70
  expect(updated.createdAt).toBe(newDate);
70
71
  expect(updated.content).toBe("Test"); // content unchanged
71
- expect(updated.updatedAt).not.toBe(note.updatedAt); // updated_at bumped
72
+ // updated_at is bumped to "now" by the update path. Can't strictly
73
+ // differ from note.updatedAt (same-ms collision possible) but must be
74
+ // monotonically non-decreasing from the prior value.
75
+ expect(updated.updatedAt).toBeTruthy();
76
+ expect(updated.updatedAt! >= note.updatedAt!).toBe(true);
72
77
  });
73
78
 
74
79
  it("updates metadata and created_at together", async () => {
@@ -87,6 +92,36 @@ describe("notes", async () => {
87
92
  expect(updated.createdAt).toBe(note.createdAt);
88
93
  });
89
94
 
95
+ it("sets updatedAt === createdAt on insert", async () => {
96
+ const note = await store.createNote("Fresh");
97
+ expect(note.updatedAt).toBe(note.createdAt);
98
+ const fetched = (await store.getNote(note.id))!;
99
+ expect(fetched.updatedAt).toBe(fetched.createdAt);
100
+ });
101
+
102
+ it("create-insert updatedAt respects an explicit created_at", async () => {
103
+ const note = await store.createNote("Imported", {
104
+ created_at: "2024-02-14T09:30:00.000Z",
105
+ });
106
+ expect(note.createdAt).toBe("2024-02-14T09:30:00.000Z");
107
+ expect(note.updatedAt).toBe("2024-02-14T09:30:00.000Z");
108
+ });
109
+
110
+ it("fresh note: if_updated_at with createdAt as the token succeeds", async () => {
111
+ // Regression guard: clients that pass `updatedAt ?? createdAt` as the
112
+ // OC token used to hit a CONFLICT on the very first edit because stored
113
+ // `updated_at` was NULL. Insert-time backfill removes that class of
114
+ // spurious conflict.
115
+ const note = await store.createNote("First");
116
+ const updated = await store.updateNote(note.id, {
117
+ content: "Second",
118
+ if_updated_at: note.createdAt,
119
+ });
120
+ expect(updated.content).toBe("Second");
121
+ expect(updated.updatedAt).toBeTruthy();
122
+ expect(updated.updatedAt).not.toBe(note.createdAt);
123
+ });
124
+
90
125
  it("deletes a note", async () => {
91
126
  const note = await store.createNote("Delete me");
92
127
  await store.deleteNote(note.id);
@@ -103,6 +138,57 @@ describe("notes", async () => {
103
138
  });
104
139
  });
105
140
 
141
+ // ---- Backfill migration: legacy rows with NULL updated_at ----
142
+
143
+ describe("updated_at backfill on init", async () => {
144
+ it("backfills updated_at = created_at for pre-existing NULL rows", () => {
145
+ const raw = new Database(":memory:");
146
+ initSchema(raw); // create tables
147
+
148
+ // Simulate a legacy row (pre-fix insert path left updated_at NULL).
149
+ raw.prepare(
150
+ "INSERT INTO notes (id, content, created_at, updated_at) VALUES (?, ?, ?, ?)",
151
+ ).run("legacy", "old", "2024-01-01T00:00:00.000Z", null);
152
+ const before = raw.prepare("SELECT updated_at FROM notes WHERE id = ?").get("legacy") as {
153
+ updated_at: string | null;
154
+ };
155
+ expect(before.updated_at).toBeNull();
156
+
157
+ // Re-run init: migration should backfill without touching the row otherwise.
158
+ initSchema(raw);
159
+ const after = raw.prepare("SELECT created_at, updated_at FROM notes WHERE id = ?").get(
160
+ "legacy",
161
+ ) as { created_at: string; updated_at: string };
162
+ expect(after.updated_at).toBe(after.created_at);
163
+ expect(after.created_at).toBe("2024-01-01T00:00:00.000Z");
164
+ });
165
+
166
+ it("leaves rows whose updated_at is already set untouched", () => {
167
+ const raw = new Database(":memory:");
168
+ initSchema(raw);
169
+
170
+ raw.prepare(
171
+ "INSERT INTO notes (id, content, created_at, updated_at) VALUES (?, ?, ?, ?)",
172
+ ).run("edited", "content", "2024-01-01T00:00:00.000Z", "2024-06-15T12:00:00.000Z");
173
+
174
+ initSchema(raw); // migration is idempotent
175
+
176
+ const row = raw.prepare("SELECT created_at, updated_at FROM notes WHERE id = ?").get(
177
+ "edited",
178
+ ) as { created_at: string; updated_at: string };
179
+ expect(row.created_at).toBe("2024-01-01T00:00:00.000Z");
180
+ expect(row.updated_at).toBe("2024-06-15T12:00:00.000Z");
181
+ });
182
+
183
+ it("is a no-op for a fresh vault with zero notes", () => {
184
+ const raw = new Database(":memory:");
185
+ initSchema(raw);
186
+ initSchema(raw);
187
+ const count = raw.prepare("SELECT COUNT(*) as c FROM notes").get() as { c: number };
188
+ expect(count.c).toBe(0);
189
+ });
190
+ });
191
+
106
192
  // ---- Tags ----
107
193
 
108
194
  describe("tags", async () => {
@@ -152,6 +238,144 @@ describe("tags", async () => {
152
238
  });
153
239
  });
154
240
 
241
+ // ---- Tag rename + merge ----
242
+
243
+ describe("renameTag", async () => {
244
+ it("retags every note and drops the old tag", async () => {
245
+ const n1 = await store.createNote("A", { tags: ["voice"] });
246
+ const n2 = await store.createNote("B", { tags: ["voice", "keeper"] });
247
+
248
+ const result = await store.renameTag("voice", "memo");
249
+ expect(result).toEqual({ renamed: 2 });
250
+
251
+ expect((await store.getNote(n1.id))!.tags).toEqual(["memo"]);
252
+ expect((await store.getNote(n2.id))!.tags?.sort()).toEqual(["keeper", "memo"]);
253
+ const tags = await store.listTags();
254
+ expect(tags.some((t) => t.name === "voice")).toBe(false);
255
+ expect(tags.find((t) => t.name === "memo")!.count).toBe(2);
256
+ });
257
+
258
+ it("carries the schema row onto the new tag name", async () => {
259
+ await store.createNote("A", { tags: ["voice"] });
260
+ await store.upsertTagSchema("voice", {
261
+ description: "Voice memos",
262
+ fields: { transcribed: { type: "boolean" } },
263
+ });
264
+
265
+ await store.renameTag("voice", "memo");
266
+
267
+ expect(await store.getTagSchema("voice")).toBeNull();
268
+ const schema = await store.getTagSchema("memo");
269
+ expect(schema?.description).toBe("Voice memos");
270
+ expect(schema?.fields?.transcribed.type).toBe("boolean");
271
+ });
272
+
273
+ it("renames an unused tag (zero notes)", async () => {
274
+ await store.createNote("A", { tags: ["doomed"] });
275
+ await store.untagNote((await store.queryNotes({}))[0].id, ["doomed"]);
276
+
277
+ const result = await store.renameTag("doomed", "archived");
278
+ expect(result).toEqual({ renamed: 0 });
279
+ const tags = await store.listTags();
280
+ expect(tags.some((t) => t.name === "doomed")).toBe(false);
281
+ expect(tags.some((t) => t.name === "archived")).toBe(true);
282
+ });
283
+
284
+ it("returns target_exists without mutating when new_name already in use", async () => {
285
+ await store.createNote("A", { tags: ["old"] });
286
+ await store.createNote("B", { tags: ["new"] });
287
+
288
+ const result = await store.renameTag("old", "new");
289
+ expect(result).toEqual({ error: "target_exists" });
290
+
291
+ // No bleed — both tags still present with their original counts.
292
+ const tags = await store.listTags();
293
+ expect(tags.find((t) => t.name === "old")!.count).toBe(1);
294
+ expect(tags.find((t) => t.name === "new")!.count).toBe(1);
295
+ });
296
+
297
+ it("returns not_found when source tag does not exist", async () => {
298
+ const result = await store.renameTag("nope", "something");
299
+ expect(result).toEqual({ error: "not_found" });
300
+ });
301
+
302
+ it("same-name rename is a no-op on an existing tag", async () => {
303
+ await store.createNote("A", { tags: ["voice"] });
304
+ const result = await store.renameTag("voice", "voice");
305
+ expect(result).toEqual({ renamed: 0 });
306
+ expect((await store.listTags()).find((t) => t.name === "voice")!.count).toBe(1);
307
+ });
308
+ });
309
+
310
+ describe("mergeTags", async () => {
311
+ it("retags every note from every source onto target and drops sources", async () => {
312
+ const n1 = await store.createNote("A", { tags: ["v1"] });
313
+ const n2 = await store.createNote("B", { tags: ["v2"] });
314
+ const n3 = await store.createNote("C", { tags: ["v1", "v2"] });
315
+
316
+ const result = await store.mergeTags(["v1", "v2"], "voice");
317
+ expect(result.target).toBe("voice");
318
+ expect(result.merged).toEqual({ v1: 2, v2: 2 });
319
+
320
+ expect((await store.getNote(n1.id))!.tags).toEqual(["voice"]);
321
+ expect((await store.getNote(n2.id))!.tags).toEqual(["voice"]);
322
+ expect((await store.getNote(n3.id))!.tags).toEqual(["voice"]);
323
+ const tags = await store.listTags();
324
+ expect(tags.some((t) => t.name === "v1")).toBe(false);
325
+ expect(tags.some((t) => t.name === "v2")).toBe(false);
326
+ expect(tags.find((t) => t.name === "voice")!.count).toBe(3);
327
+ });
328
+
329
+ it("creates target if it does not exist", async () => {
330
+ await store.createNote("A", { tags: ["old"] });
331
+ const result = await store.mergeTags(["old"], "brand-new");
332
+ expect(result).toEqual({ merged: { old: 1 }, target: "brand-new" });
333
+ expect((await store.listTags()).find((t) => t.name === "brand-new")!.count).toBe(1);
334
+ });
335
+
336
+ it("leaves target's schema intact; drops sources' schemas", async () => {
337
+ await store.createNote("A", { tags: ["v1"] });
338
+ await store.createNote("B", { tags: ["voice"] });
339
+ await store.upsertTagSchema("v1", { description: "legacy" });
340
+ await store.upsertTagSchema("voice", { description: "the keeper" });
341
+
342
+ await store.mergeTags(["v1"], "voice");
343
+
344
+ expect(await store.getTagSchema("v1")).toBeNull();
345
+ expect((await store.getTagSchema("voice"))!.description).toBe("the keeper");
346
+ });
347
+
348
+ it("dedups duplicate sources in the request", async () => {
349
+ await store.createNote("A", { tags: ["v1"] });
350
+ const result = await store.mergeTags(["v1", "v1"], "voice");
351
+ // A duplicated source counts once — not twice.
352
+ expect(result.merged).toEqual({ v1: 1 });
353
+ });
354
+
355
+ it("silently skips target when it appears in sources", async () => {
356
+ await store.createNote("A", { tags: ["v1", "voice"] });
357
+ const result = await store.mergeTags(["v1", "voice"], "voice");
358
+ // voice is target; it should drop out of sources, not be deleted.
359
+ expect(result.merged).toEqual({ v1: 1 });
360
+ expect((await store.listTags()).some((t) => t.name === "voice")).toBe(true);
361
+ });
362
+
363
+ it("records 0 for sources that do not exist", async () => {
364
+ await store.createNote("A", { tags: ["real"] });
365
+ const result = await store.mergeTags(["real", "ghost"], "voice");
366
+ expect(result.merged).toEqual({ real: 1, ghost: 0 });
367
+ });
368
+
369
+ it("is idempotent on notes that already have the target tag", async () => {
370
+ // Both source and target tags present on the same note. Merge must not
371
+ // blow up on the INSERT OR IGNORE into note_tags.
372
+ const note = await store.createNote("A", { tags: ["v1", "voice"] });
373
+ const result = await store.mergeTags(["v1"], "voice");
374
+ expect(result.merged).toEqual({ v1: 1 });
375
+ expect((await store.getNote(note.id))!.tags).toEqual(["voice"]);
376
+ });
377
+ });
378
+
155
379
  // ---- Vault Stats ----
156
380
 
157
381
  describe("vault stats", async () => {
@@ -163,6 +387,7 @@ describe("vault stats", async () => {
163
387
  expect(stats.notesByMonth).toEqual([]);
164
388
  expect(stats.topTags).toEqual([]);
165
389
  expect(stats.tagCount).toBe(0);
390
+ expect(stats.linkCount).toBe(0);
166
391
  });
167
392
 
168
393
  it("counts total notes and tagCount", async () => {
@@ -231,6 +456,17 @@ describe("vault stats", async () => {
231
456
  expect(stats).toHaveProperty("notesByMonth");
232
457
  expect(stats).toHaveProperty("topTags");
233
458
  expect(stats).toHaveProperty("tagCount");
459
+ expect(stats).toHaveProperty("linkCount");
460
+ });
461
+
462
+ it("counts resolved wikilinks in linkCount", async () => {
463
+ await store.createNote("Target A", { path: "alpha" });
464
+ await store.createNote("Target B", { path: "beta" });
465
+ await store.createNote("Refs both [[alpha]] and [[beta]]", { path: "hub" });
466
+ await store.createNote("Refs alpha only [[alpha]]", { path: "solo" });
467
+
468
+ const stats = await store.getVaultStats();
469
+ expect(stats.linkCount).toBe(3);
234
470
  });
235
471
 
236
472
  it("getVaultStats returns correct stats", async () => {
@@ -314,6 +550,204 @@ describe("queryNotes", async () => {
314
550
  const results = await store.queryNotes({ limit: 3 });
315
551
  expect(results).toHaveLength(3);
316
552
  });
553
+
554
+ it("has_tags=false returns only untagged notes", async () => {
555
+ await store.createNote("Tagged", { tags: ["daily"] });
556
+ await store.createNote("Plain");
557
+
558
+ const results = await store.queryNotes({ hasTags: false });
559
+ expect(results.map((n) => n.content).sort()).toEqual(["Plain"]);
560
+ });
561
+
562
+ it("has_tags=true returns only tagged notes", async () => {
563
+ await store.createNote("Tagged", { tags: ["daily"] });
564
+ await store.createNote("Plain");
565
+
566
+ const results = await store.queryNotes({ hasTags: true });
567
+ expect(results.map((n) => n.content).sort()).toEqual(["Tagged"]);
568
+ });
569
+
570
+ it("has_tags is ignored when `tags` is also provided (tag filter wins)", async () => {
571
+ await store.createNote("A", { tags: ["daily"] });
572
+ await store.createNote("B");
573
+
574
+ // tags:["daily"] already constrains to tagged notes; has_tags is a no-op.
575
+ const truthy = await store.queryNotes({ tags: ["daily"], hasTags: true });
576
+ expect(truthy.map((n) => n.content)).toEqual(["A"]);
577
+
578
+ // `has_tags: false` would contradict `tags` — but tag filter wins, so "A" still returns.
579
+ const falsy = await store.queryNotes({ tags: ["daily"], hasTags: false });
580
+ expect(falsy.map((n) => n.content)).toEqual(["A"]);
581
+ });
582
+
583
+ it("has_links=false returns orphaned notes (no inbound or outbound links)", async () => {
584
+ const a = await store.createNote("A", { id: "ha" });
585
+ const b = await store.createNote("B", { id: "hb" });
586
+ await store.createNote("Orphan", { id: "ho" });
587
+ await store.createLink(a.id, b.id, "mentions");
588
+
589
+ const orphans = await store.queryNotes({ hasLinks: false });
590
+ expect(orphans.map((n) => n.content).sort()).toEqual(["Orphan"]);
591
+ });
592
+
593
+ it("has_links=true returns notes with any link (inbound or outbound)", async () => {
594
+ const a = await store.createNote("Source", { id: "la" });
595
+ const b = await store.createNote("Target", { id: "lb" });
596
+ await store.createNote("Orphan", { id: "lo" });
597
+ await store.createLink(a.id, b.id, "mentions");
598
+
599
+ // Both Source (outbound) and Target (inbound) should appear.
600
+ const linked = await store.queryNotes({ hasLinks: true });
601
+ expect(linked.map((n) => n.content).sort()).toEqual(["Source", "Target"]);
602
+ });
603
+
604
+ it("composes has_tags + has_links (untagged and orphaned)", async () => {
605
+ const a = await store.createNote("Tagged+linked", { tags: ["x"], id: "ca" });
606
+ const b = await store.createNote("Plain+linked", { id: "cb" });
607
+ await store.createNote("Tagged+orphan", { tags: ["x"], id: "cc" });
608
+ await store.createNote("Plain+orphan", { id: "cd" });
609
+ await store.createLink(a.id, b.id, "mentions");
610
+
611
+ const loners = await store.queryNotes({ hasTags: false, hasLinks: false });
612
+ expect(loners.map((n) => n.content)).toEqual(["Plain+orphan"]);
613
+ });
614
+
615
+ it("has_tags=false composes with exclude_tags as a no-op (untagged notes have no tags to exclude)", async () => {
616
+ await store.createNote("Tagged", { tags: ["archived"] });
617
+ await store.createNote("Plain");
618
+
619
+ const results = await store.queryNotes({ hasTags: false, excludeTags: ["archived"] });
620
+ expect(results.map((n) => n.content)).toEqual(["Plain"]);
621
+ });
622
+
623
+ // ---- Operator objects + order_by on indexed metadata fields ----
624
+
625
+ describe("metadata operators + order_by", () => {
626
+ async function seedIndexedPriorities() {
627
+ const { declareField } = await import("./indexed-fields.js");
628
+ declareField(db, "priority", "INTEGER", "project");
629
+ declareField(db, "status", "TEXT", "project");
630
+ }
631
+
632
+ it("eq operator on indexed field matches primitive exactly", async () => {
633
+ await seedIndexedPriorities();
634
+ await store.createNote("high", { metadata: { priority: 5 } });
635
+ await store.createNote("low", { metadata: { priority: 1 } });
636
+
637
+ const results = await store.queryNotes({ metadata: { priority: { eq: 5 } } });
638
+ expect(results.map((n) => n.content)).toEqual(["high"]);
639
+ });
640
+
641
+ it("ne operator returns non-matching rows AND rows without the field", async () => {
642
+ await seedIndexedPriorities();
643
+ await store.createNote("has-1", { metadata: { priority: 1 } });
644
+ await store.createNote("has-2", { metadata: { priority: 2 } });
645
+ await store.createNote("missing"); // no priority at all
646
+
647
+ const results = await store.queryNotes({ metadata: { priority: { ne: 1 } } });
648
+ expect(results.map((n) => n.content).sort()).toEqual(["has-2", "missing"]);
649
+ });
650
+
651
+ it("gt / gte / lt / lte compose into range queries on one field", async () => {
652
+ await seedIndexedPriorities();
653
+ for (const p of [1, 2, 3, 4, 5]) {
654
+ await store.createNote(`p${p}`, { metadata: { priority: p } });
655
+ }
656
+ const range = await store.queryNotes({ metadata: { priority: { gte: 2, lt: 5 } } });
657
+ expect(range.map((n) => n.content).sort()).toEqual(["p2", "p3", "p4"]);
658
+ });
659
+
660
+ it("in and not_in take arrays; empty in returns no rows, empty not_in returns all", async () => {
661
+ await seedIndexedPriorities();
662
+ await store.createNote("a", { metadata: { status: "active" } });
663
+ await store.createNote("b", { metadata: { status: "exploring" } });
664
+ await store.createNote("c", { metadata: { status: "done" } });
665
+
666
+ const inResult = await store.queryNotes({ metadata: { status: { in: ["active", "exploring"] } } });
667
+ expect(inResult.map((n) => n.content).sort()).toEqual(["a", "b"]);
668
+
669
+ const notInResult = await store.queryNotes({ metadata: { status: { not_in: ["done"] } } });
670
+ // "done" excluded; rows with status=null (none here) would also pass.
671
+ expect(notInResult.map((n) => n.content).sort()).toEqual(["a", "b"]);
672
+
673
+ const emptyIn = await store.queryNotes({ metadata: { status: { in: [] } } });
674
+ expect(emptyIn).toHaveLength(0);
675
+ });
676
+
677
+ it("exists: true / false distinguishes present vs absent field", async () => {
678
+ await seedIndexedPriorities();
679
+ await store.createNote("has", { metadata: { priority: 3 } });
680
+ await store.createNote("missing");
681
+
682
+ const has = await store.queryNotes({ metadata: { priority: { exists: true } } });
683
+ expect(has.map((n) => n.content)).toEqual(["has"]);
684
+
685
+ const missing = await store.queryNotes({ metadata: { priority: { exists: false } } });
686
+ expect(missing.map((n) => n.content)).toEqual(["missing"]);
687
+ });
688
+
689
+ it("order_by sorts by the indexed field; sort='desc' reverses direction", async () => {
690
+ await seedIndexedPriorities();
691
+ await store.createNote("p3", { metadata: { priority: 3 } });
692
+ await store.createNote("p1", { metadata: { priority: 1 } });
693
+ await store.createNote("p2", { metadata: { priority: 2 } });
694
+
695
+ const asc = await store.queryNotes({ orderBy: "priority" });
696
+ expect(asc.map((n) => n.content)).toEqual(["p1", "p2", "p3"]);
697
+
698
+ const desc = await store.queryNotes({ orderBy: "priority", sort: "desc" });
699
+ expect(desc.map((n) => n.content)).toEqual(["p3", "p2", "p1"]);
700
+ });
701
+
702
+ it("operator objects compose with tag and exclude_tags filters", async () => {
703
+ await seedIndexedPriorities();
704
+ await store.createNote("p5-project", { tags: ["project"], metadata: { priority: 5 } });
705
+ await store.createNote("p3-project", { tags: ["project"], metadata: { priority: 3 } });
706
+ await store.createNote("p5-other", { tags: ["other"], metadata: { priority: 5 } });
707
+
708
+ const results = await store.queryNotes({
709
+ tags: ["project"],
710
+ metadata: { priority: { gte: 4 } },
711
+ });
712
+ expect(results.map((n) => n.content)).toEqual(["p5-project"]);
713
+ });
714
+
715
+ it("primitive metadata values keep working (backcompat, scan JSON)", async () => {
716
+ // Note: priority is NOT declared indexed here — primitive match still
717
+ // goes through json_extract and doesn't require an index.
718
+ await store.createNote("match", { metadata: { kind: "draft" } });
719
+ await store.createNote("other", { metadata: { kind: "final" } });
720
+
721
+ const results = await store.queryNotes({ metadata: { kind: "draft" } });
722
+ expect(results.map((n) => n.content)).toEqual(["match"]);
723
+ });
724
+
725
+ it("operator on a non-indexed field throws FIELD_NOT_INDEXED", async () => {
726
+ await store.createNote("x", { metadata: { foo: "bar" } });
727
+ expect(
728
+ store.queryNotes({ metadata: { foo: { eq: "bar" } } }),
729
+ ).rejects.toThrow(/not indexed/);
730
+ });
731
+
732
+ it("order_by on a non-indexed field throws FIELD_NOT_INDEXED", async () => {
733
+ await store.createNote("x", { metadata: { foo: 1 } });
734
+ expect(store.queryNotes({ orderBy: "foo" })).rejects.toThrow(/not indexed/);
735
+ });
736
+
737
+ it("unknown operator throws UNKNOWN_OPERATOR with supported-op list", async () => {
738
+ await seedIndexedPriorities();
739
+ expect(
740
+ store.queryNotes({ metadata: { priority: { bogus: 5 } as any } }),
741
+ ).rejects.toThrow(/unknown operator "bogus"/);
742
+ });
743
+
744
+ it("in/not_in without an array value throws INVALID_OPERATOR_VALUE", async () => {
745
+ await seedIndexedPriorities();
746
+ expect(
747
+ store.queryNotes({ metadata: { priority: { in: 5 } as any } }),
748
+ ).rejects.toThrow(/expects an array/);
749
+ });
750
+ });
317
751
  });
318
752
 
319
753
  // ---- Search ----
@@ -433,6 +867,41 @@ describe("attachments", async () => {
433
867
  const attachments = await store.getAttachments(note.id);
434
868
  expect(attachments).toHaveLength(0);
435
869
  });
870
+
871
+ it("deleteAttachment removes row and reports orphaned path", async () => {
872
+ const note = await store.createNote("Has attachment");
873
+ const att = await store.addAttachment(note.id, "2026-04-18/pic.png", "image/png");
874
+
875
+ const result = await store.deleteAttachment(note.id, att.id);
876
+ expect(result).toEqual({ deleted: true, path: "2026-04-18/pic.png", orphaned: true });
877
+ expect(await store.getAttachments(note.id)).toHaveLength(0);
878
+ });
879
+
880
+ it("deleteAttachment returns deleted:false for nonexistent id", async () => {
881
+ const note = await store.createNote("x");
882
+ const result = await store.deleteAttachment(note.id, "does-not-exist");
883
+ expect(result).toEqual({ deleted: false, path: null, orphaned: false });
884
+ });
885
+
886
+ it("deleteAttachment is scoped to noteId (cross-note attempt is a no-op)", async () => {
887
+ const a = await store.createNote("A");
888
+ const b = await store.createNote("B");
889
+ const attA = await store.addAttachment(a.id, "files/a.png", "image/png");
890
+
891
+ const result = await store.deleteAttachment(b.id, attA.id);
892
+ expect(result.deleted).toBe(false);
893
+ expect(await store.getAttachments(a.id)).toHaveLength(1);
894
+ });
895
+
896
+ it("deleteAttachment reports orphaned:false when a sibling attachment shares the path", async () => {
897
+ const a = await store.createNote("A");
898
+ const b = await store.createNote("B");
899
+ const attA = await store.addAttachment(a.id, "shared/pic.png", "image/png");
900
+ await store.addAttachment(b.id, "shared/pic.png", "image/png");
901
+
902
+ const result = await store.deleteAttachment(a.id, attA.id);
903
+ expect(result).toEqual({ deleted: true, path: "shared/pic.png", orphaned: false });
904
+ });
436
905
  });
437
906
 
438
907
  // ---- MCP Tools ----
@@ -493,7 +962,7 @@ describe("MCP tools", async () => {
493
962
  const tools = generateMcpTools(store);
494
963
  const updateNote = tools.find((t) => t.name === "update-note")!;
495
964
  const newDate = "2025-03-01T00:00:00.000Z";
496
- const result = await updateNote.execute({ id: note.id, created_at: newDate }) as any;
965
+ const result = await updateNote.execute({ id: note.id, created_at: newDate, force: true }) as any;
497
966
  expect(result.createdAt).toBe(newDate);
498
967
  expect(result.content).toBe("Test");
499
968
  });
@@ -502,7 +971,7 @@ describe("MCP tools", async () => {
502
971
  const note = await store.createNote("Test", { metadata: { existing: "value" } });
503
972
  const tools = generateMcpTools(store);
504
973
  const updateNote = tools.find((t) => t.name === "update-note")!;
505
- const result = await updateNote.execute({ id: note.id, metadata: { importance: "high" } }) as any;
974
+ const result = await updateNote.execute({ id: note.id, metadata: { importance: "high" }, force: true }) as any;
506
975
  expect(result.metadata).toEqual({ existing: "value", importance: "high" });
507
976
  });
508
977
 
@@ -512,12 +981,12 @@ describe("MCP tools", async () => {
512
981
  const updateNote = tools.find((t) => t.name === "update-note")!;
513
982
 
514
983
  // Add tags
515
- await updateNote.execute({ id: note.id, tags: { add: ["pinned", "daily"] } });
984
+ await updateNote.execute({ id: note.id, tags: { add: ["pinned", "daily"] }, force: true });
516
985
  expect((await store.getNote(note.id))!.tags).toContain("pinned");
517
986
  expect((await store.getNote(note.id))!.tags).toContain("daily");
518
987
 
519
988
  // Remove tags
520
- await updateNote.execute({ id: note.id, tags: { remove: ["pinned"] } });
989
+ await updateNote.execute({ id: note.id, tags: { remove: ["pinned"] }, force: true });
521
990
  expect((await store.getNote(note.id))!.tags).not.toContain("pinned");
522
991
  expect((await store.getNote(note.id))!.tags).toContain("daily");
523
992
  });
@@ -529,11 +998,11 @@ describe("MCP tools", async () => {
529
998
  const updateNote = tools.find((t) => t.name === "update-note")!;
530
999
 
531
1000
  // Add link
532
- await updateNote.execute({ id: "a", links: { add: [{ target: "b", relationship: "mentions" }] } });
1001
+ await updateNote.execute({ id: "a", links: { add: [{ target: "b", relationship: "mentions" }] }, force: true });
533
1002
  expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(1);
534
1003
 
535
1004
  // Remove link
536
- await updateNote.execute({ id: "a", links: { remove: [{ target: "b", relationship: "mentions" }] } });
1005
+ await updateNote.execute({ id: "a", links: { remove: [{ target: "b", relationship: "mentions" }] }, force: true });
537
1006
  expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
538
1007
  });
539
1008
 
@@ -547,6 +1016,7 @@ describe("MCP tools", async () => {
547
1016
  const result = await updateNote.execute({
548
1017
  id: "source",
549
1018
  links: { remove: [{ target: "target", relationship: "wikilink" }] },
1019
+ force: true,
550
1020
  }) as any;
551
1021
  expect(result.content).toBe("See People/Alice for details");
552
1022
  });
@@ -558,8 +1028,8 @@ describe("MCP tools", async () => {
558
1028
  const updateNote = tools.find((t) => t.name === "update-note")!;
559
1029
  const result = await updateNote.execute({
560
1030
  notes: [
561
- { id: "a", content: "A updated" },
562
- { id: "b", tags: { add: ["pinned"] } },
1031
+ { id: "a", content: "A updated", force: true },
1032
+ { id: "b", tags: { add: ["pinned"] }, force: true },
563
1033
  ],
564
1034
  }) as any[];
565
1035
  expect(result).toHaveLength(2);
@@ -571,7 +1041,7 @@ describe("MCP tools", async () => {
571
1041
  await store.createNote("Test", { path: "Projects/README" });
572
1042
  const tools = generateMcpTools(store);
573
1043
  const updateNote = tools.find((t) => t.name === "update-note")!;
574
- const result = await updateNote.execute({ id: "Projects/README", content: "Updated" }) as any;
1044
+ const result = await updateNote.execute({ id: "Projects/README", content: "Updated", force: true }) as any;
575
1045
  expect(result.content).toBe("Updated");
576
1046
  });
577
1047
 
@@ -580,7 +1050,7 @@ describe("MCP tools", async () => {
580
1050
  const tools = generateMcpTools(store);
581
1051
  const updateNote = tools.find((t) => t.name === "update-note")!;
582
1052
 
583
- const first = await updateNote.execute({ id: note.id, content: "Second" }) as any;
1053
+ const first = await updateNote.execute({ id: note.id, content: "Second", force: true }) as any;
584
1054
  expect(first.content).toBe("Second");
585
1055
  expect(first.updatedAt).toBeTruthy();
586
1056
 
@@ -597,7 +1067,7 @@ describe("MCP tools", async () => {
597
1067
  const tools = generateMcpTools(store);
598
1068
  const updateNote = tools.find((t) => t.name === "update-note")!;
599
1069
 
600
- const after = await updateNote.execute({ id: note.id, content: "Second" }) as any;
1070
+ const after = await updateNote.execute({ id: note.id, content: "Second", force: true }) as any;
601
1071
 
602
1072
  // Simulate a stale client that has the pre-update timestamp (or something else).
603
1073
  const staleTimestamp = "2020-01-01T00:00:00.000Z";
@@ -623,9 +1093,11 @@ describe("MCP tools", async () => {
623
1093
  expect((await store.getNote(note.id))!.content).toBe("Second");
624
1094
  });
625
1095
 
626
- it("update-note if_updated_at conflicts for a never-updated note when caller expects a value", async () => {
1096
+ it("update-note if_updated_at conflicts when the caller's timestamp doesn't match", async () => {
627
1097
  const note = await store.createNote("First");
628
- expect(note.updatedAt).toBeUndefined();
1098
+ // A fresh note has updatedAt === createdAt. Sending a
1099
+ // mismatching timestamp must still be rejected as a conflict.
1100
+ expect(note.updatedAt).toBe(note.createdAt);
629
1101
  const tools = generateMcpTools(store);
630
1102
  const updateNote = tools.find((t) => t.name === "update-note")!;
631
1103
 
@@ -641,7 +1113,60 @@ describe("MCP tools", async () => {
641
1113
  }
642
1114
  expect(err).toBeTruthy();
643
1115
  expect(err.code).toBe("CONFLICT");
644
- expect(err.current_updated_at).toBeNull();
1116
+ expect(err.current_updated_at).toBe(note.createdAt);
1117
+ });
1118
+
1119
+ it("create-note returns updatedAt equal to createdAt on fresh notes", async () => {
1120
+ const tools = generateMcpTools(store);
1121
+ const createNote = tools.find((t) => t.name === "create-note")!;
1122
+ const result = await createNote.execute({ content: "Hello" }) as any;
1123
+ expect(result.updatedAt).toBeTruthy();
1124
+ expect(result.updatedAt).toBe(result.createdAt);
1125
+ });
1126
+
1127
+ it("update-note requires if_updated_at or force (precondition-required)", async () => {
1128
+ const note = await store.createNote("Test", { path: "Inbox/x" });
1129
+ const tools = generateMcpTools(store);
1130
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1131
+
1132
+ let err: any;
1133
+ try {
1134
+ await updateNote.execute({ id: note.id, content: "changed" });
1135
+ } catch (e) {
1136
+ err = e;
1137
+ }
1138
+ expect(err?.code).toBe("PRECONDITION_REQUIRED");
1139
+ expect(err.note_id).toBe(note.id);
1140
+ expect(err.note_path).toBe("Inbox/x");
1141
+ expect((await store.getNote(note.id))!.content).toBe("Test");
1142
+ });
1143
+
1144
+ it("update-note force:true bypasses precondition and mutates unconditionally", async () => {
1145
+ const note = await store.createNote("First");
1146
+ const tools = generateMcpTools(store);
1147
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1148
+ const result = await updateNote.execute({ id: note.id, content: "Second", force: true }) as any;
1149
+ expect(result.content).toBe("Second");
1150
+ });
1151
+
1152
+ it("update-note conflict error surfaces note_path", async () => {
1153
+ const note = await store.createNote("First", { path: "Inbox/y" });
1154
+ const tools = generateMcpTools(store);
1155
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1156
+ await updateNote.execute({ id: note.id, content: "Second", force: true });
1157
+
1158
+ let err: any;
1159
+ try {
1160
+ await updateNote.execute({
1161
+ id: note.id,
1162
+ content: "Third",
1163
+ if_updated_at: "2020-01-01T00:00:00.000Z",
1164
+ });
1165
+ } catch (e) {
1166
+ err = e;
1167
+ }
1168
+ expect(err?.code).toBe("CONFLICT");
1169
+ expect(err.note_path).toBe("Inbox/y");
645
1170
  });
646
1171
 
647
1172
  it("update-note batch aborts on first conflict without touching subsequent items", async () => {
@@ -651,7 +1176,7 @@ describe("MCP tools", async () => {
651
1176
  const updateNote = tools.find((t) => t.name === "update-note")!;
652
1177
 
653
1178
  // Bump a's updated_at so any stale if_updated_at conflicts.
654
- const bumped = await updateNote.execute({ id: "a", content: "A bumped" }) as any;
1179
+ const bumped = await updateNote.execute({ id: "a", content: "A bumped", force: true }) as any;
655
1180
  expect(bumped.updatedAt).toBeTruthy();
656
1181
 
657
1182
  let err: any;
@@ -659,7 +1184,7 @@ describe("MCP tools", async () => {
659
1184
  await updateNote.execute({
660
1185
  notes: [
661
1186
  { id: "a", content: "A new", if_updated_at: "2020-01-01T00:00:00.000Z" },
662
- { id: "b", content: "B new" },
1187
+ { id: "b", content: "B new", force: true },
663
1188
  ],
664
1189
  });
665
1190
  } catch (e) {
@@ -686,7 +1211,7 @@ describe("MCP tools", async () => {
686
1211
  const updateNote = tools.find((t) => t.name === "update-note")!;
687
1212
 
688
1213
  // Establish a known updated_at the two callers both read.
689
- const seed = await updateNote.execute({ id: note.id, content: "seed-v1" }) as any;
1214
+ const seed = await updateNote.execute({ id: note.id, content: "seed-v1", force: true }) as any;
690
1215
  expect(seed.updatedAt).toBeTruthy();
691
1216
 
692
1217
  const results = await Promise.allSettled([
@@ -719,7 +1244,7 @@ describe("MCP tools", async () => {
719
1244
  const updateNote = tools.find((t) => t.name === "update-note")!;
720
1245
 
721
1246
  // Bump so a stale if_updated_at conflicts; and capture state after bump.
722
- await updateNote.execute({ id: "source", content: "See [[People/Alice]] for details" });
1247
+ await updateNote.execute({ id: "source", content: "See [[People/Alice]] for details", force: true });
723
1248
  const preConflictLinks = await store.getLinks("source", { direction: "outbound" });
724
1249
  expect(preConflictLinks).toHaveLength(1);
725
1250
 
@@ -749,6 +1274,11 @@ describe("MCP tools", async () => {
749
1274
  const result = await query.execute({ id: note.id }) as any;
750
1275
  expect(result.content).toBe("Hello");
751
1276
  expect(result.path).toBe("test/note");
1277
+ // updatedAt is the optimistic-concurrency token. Callers can't arm a
1278
+ // followup update without it, so it must always come back from a
1279
+ // single-note fetch.
1280
+ expect(result.updatedAt).toBeTruthy();
1281
+ expect(result.updatedAt).toBe(note.updatedAt);
752
1282
  });
753
1283
 
754
1284
  it("query-notes single note by path", async () => {
@@ -767,6 +1297,60 @@ describe("MCP tools", async () => {
767
1297
  expect(result).toHaveLength(1);
768
1298
  });
769
1299
 
1300
+ it("query-notes has_tags=false surfaces untagged notes", async () => {
1301
+ await store.createNote("Tagged", { tags: ["daily"] });
1302
+ await store.createNote("Plain");
1303
+ const tools = generateMcpTools(store);
1304
+ const query = tools.find((t) => t.name === "query-notes")!;
1305
+ const result = await query.execute({ has_tags: false, include_content: true }) as any[];
1306
+ expect(result.map((n) => n.content)).toEqual(["Plain"]);
1307
+ });
1308
+
1309
+ it("query-notes has_links=false surfaces orphaned notes", async () => {
1310
+ const a = await store.createNote("Source", { id: "mq-a" });
1311
+ const b = await store.createNote("Target", { id: "mq-b" });
1312
+ await store.createNote("Orphan", { id: "mq-o" });
1313
+ await store.createLink(a.id, b.id, "mentions");
1314
+
1315
+ const tools = generateMcpTools(store);
1316
+ const query = tools.find((t) => t.name === "query-notes")!;
1317
+ const result = await query.execute({ has_links: false, include_content: true }) as any[];
1318
+ expect(result.map((n) => n.content)).toEqual(["Orphan"]);
1319
+ });
1320
+
1321
+ it("query-notes metadata operator query routes through the indexed column", async () => {
1322
+ const { declareField } = await import("./indexed-fields.js");
1323
+ declareField(db, "priority", "INTEGER", "project");
1324
+ await store.createNote("high", { metadata: { priority: 5 } });
1325
+ await store.createNote("mid", { metadata: { priority: 3 } });
1326
+ await store.createNote("low", { metadata: { priority: 1 } });
1327
+
1328
+ const tools = generateMcpTools(store);
1329
+ const query = tools.find((t) => t.name === "query-notes")!;
1330
+ const result = await query.execute({
1331
+ metadata: { priority: { gte: 3 } },
1332
+ include_content: true,
1333
+ }) as any[];
1334
+ expect(result.map((n) => n.content).sort()).toEqual(["high", "mid"]);
1335
+ });
1336
+
1337
+ it("query-notes order_by + sort=desc surfaces highest-priority first", async () => {
1338
+ const { declareField } = await import("./indexed-fields.js");
1339
+ declareField(db, "priority", "INTEGER", "project");
1340
+ await store.createNote("p2", { metadata: { priority: 2 } });
1341
+ await store.createNote("p5", { metadata: { priority: 5 } });
1342
+ await store.createNote("p1", { metadata: { priority: 1 } });
1343
+
1344
+ const tools = generateMcpTools(store);
1345
+ const query = tools.find((t) => t.name === "query-notes")!;
1346
+ const result = await query.execute({
1347
+ order_by: "priority",
1348
+ sort: "desc",
1349
+ include_content: true,
1350
+ }) as any[];
1351
+ expect(result.map((n) => n.content)).toEqual(["p5", "p2", "p1"]);
1352
+ });
1353
+
770
1354
  it("query-notes list defaults to no content (index mode)", async () => {
771
1355
  const content = "This is the note body.";
772
1356
  await store.createNote(content, { tags: ["daily"], path: "Notes/test", metadata: { status: "draft" } });