@openparachute/vault 0.2.4 → 0.3.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 (102) hide show
  1. package/.claude/settings.local.json +2 -25
  2. package/CHANGELOG.md +64 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +591 -19
  6. package/core/src/hooks.ts +111 -3
  7. package/core/src/indexed-fields.test.ts +285 -0
  8. package/core/src/indexed-fields.ts +238 -0
  9. package/core/src/mcp.ts +127 -6
  10. package/core/src/notes.ts +153 -11
  11. package/core/src/query-operators.ts +174 -0
  12. package/core/src/schema.ts +69 -2
  13. package/core/src/store.ts +95 -1
  14. package/core/src/tag-schemas.ts +5 -0
  15. package/core/src/types.ts +28 -1
  16. package/docs/HTTP_API.md +105 -1
  17. package/docs/auth-model.md +340 -0
  18. package/package/package.json +32 -0
  19. package/package.json +2 -2
  20. package/src/auth.test.ts +83 -114
  21. package/src/auth.ts +68 -6
  22. package/src/backup-launchd.ts +1 -1
  23. package/src/backup.test.ts +1 -1
  24. package/src/backup.ts +18 -17
  25. package/src/bind.test.ts +28 -0
  26. package/src/bind.ts +19 -0
  27. package/src/cli.ts +228 -133
  28. package/src/config-triggers.test.ts +49 -0
  29. package/src/config.test.ts +317 -2
  30. package/src/config.ts +420 -40
  31. package/src/context.test.ts +136 -0
  32. package/src/context.ts +115 -0
  33. package/src/daemon.ts +17 -16
  34. package/src/doctor.test.ts +9 -7
  35. package/src/launchd.test.ts +1 -1
  36. package/src/launchd.ts +6 -6
  37. package/src/mcp-http.ts +75 -21
  38. package/src/mcp-install.test.ts +125 -0
  39. package/src/mcp-install.ts +60 -0
  40. package/src/mcp-tools.ts +34 -96
  41. package/src/module-config.ts +109 -0
  42. package/src/oauth.test.ts +345 -57
  43. package/src/oauth.ts +155 -35
  44. package/src/published.test.ts +2 -2
  45. package/src/routes.ts +209 -33
  46. package/src/routing.test.ts +817 -300
  47. package/src/routing.ts +204 -202
  48. package/src/scopes.test.ts +294 -0
  49. package/src/scopes.ts +253 -0
  50. package/src/scribe-env.test.ts +49 -0
  51. package/src/scribe-env.ts +33 -0
  52. package/src/server.ts +73 -9
  53. package/src/services-manifest.test.ts +140 -0
  54. package/src/services-manifest.ts +99 -0
  55. package/src/systemd.ts +3 -3
  56. package/src/token-store.ts +42 -9
  57. package/src/transcription-worker.test.ts +864 -0
  58. package/src/transcription-worker.ts +501 -0
  59. package/src/triggers.test.ts +191 -1
  60. package/src/triggers.ts +17 -2
  61. package/src/vault.test.ts +693 -77
  62. package/src/version.test.ts +1 -1
  63. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  64. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  65. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  66. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  67. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  68. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  69. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  70. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  71. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  72. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  73. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  74. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  75. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  76. package/religions-abrahamic-filter.png +0 -0
  77. package/religions-buddhism-v2.png +0 -0
  78. package/religions-buddhism.png +0 -0
  79. package/religions-final.png +0 -0
  80. package/religions-v1.png +0 -0
  81. package/religions-v2.png +0 -0
  82. package/religions-zen.png +0 -0
  83. package/web/README.md +0 -73
  84. package/web/bun.lock +0 -827
  85. package/web/eslint.config.js +0 -23
  86. package/web/index.html +0 -15
  87. package/web/package.json +0 -36
  88. package/web/public/favicon.svg +0 -1
  89. package/web/public/icons.svg +0 -24
  90. package/web/src/App.tsx +0 -149
  91. package/web/src/Graph.tsx +0 -200
  92. package/web/src/NoteView.tsx +0 -155
  93. package/web/src/Sidebar.tsx +0 -186
  94. package/web/src/api.ts +0 -21
  95. package/web/src/index.css +0 -50
  96. package/web/src/main.tsx +0 -10
  97. package/web/src/types.ts +0 -37
  98. package/web/src/utils.ts +0 -107
  99. package/web/tsconfig.app.json +0 -25
  100. package/web/tsconfig.json +0 -7
  101. package/web/tsconfig.node.json +0 -24
  102. package/web/vite.config.ts +0 -16
@@ -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 () => {
@@ -326,6 +550,204 @@ describe("queryNotes", async () => {
326
550
  const results = await store.queryNotes({ limit: 3 });
327
551
  expect(results).toHaveLength(3);
328
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
+ });
329
751
  });
330
752
 
331
753
  // ---- Search ----
@@ -445,6 +867,41 @@ describe("attachments", async () => {
445
867
  const attachments = await store.getAttachments(note.id);
446
868
  expect(attachments).toHaveLength(0);
447
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
+ });
448
905
  });
449
906
 
450
907
  // ---- MCP Tools ----
@@ -505,7 +962,7 @@ describe("MCP tools", async () => {
505
962
  const tools = generateMcpTools(store);
506
963
  const updateNote = tools.find((t) => t.name === "update-note")!;
507
964
  const newDate = "2025-03-01T00:00:00.000Z";
508
- 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;
509
966
  expect(result.createdAt).toBe(newDate);
510
967
  expect(result.content).toBe("Test");
511
968
  });
@@ -514,7 +971,7 @@ describe("MCP tools", async () => {
514
971
  const note = await store.createNote("Test", { metadata: { existing: "value" } });
515
972
  const tools = generateMcpTools(store);
516
973
  const updateNote = tools.find((t) => t.name === "update-note")!;
517
- 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;
518
975
  expect(result.metadata).toEqual({ existing: "value", importance: "high" });
519
976
  });
520
977
 
@@ -524,12 +981,12 @@ describe("MCP tools", async () => {
524
981
  const updateNote = tools.find((t) => t.name === "update-note")!;
525
982
 
526
983
  // Add tags
527
- await updateNote.execute({ id: note.id, tags: { add: ["pinned", "daily"] } });
984
+ await updateNote.execute({ id: note.id, tags: { add: ["pinned", "daily"] }, force: true });
528
985
  expect((await store.getNote(note.id))!.tags).toContain("pinned");
529
986
  expect((await store.getNote(note.id))!.tags).toContain("daily");
530
987
 
531
988
  // Remove tags
532
- await updateNote.execute({ id: note.id, tags: { remove: ["pinned"] } });
989
+ await updateNote.execute({ id: note.id, tags: { remove: ["pinned"] }, force: true });
533
990
  expect((await store.getNote(note.id))!.tags).not.toContain("pinned");
534
991
  expect((await store.getNote(note.id))!.tags).toContain("daily");
535
992
  });
@@ -541,11 +998,11 @@ describe("MCP tools", async () => {
541
998
  const updateNote = tools.find((t) => t.name === "update-note")!;
542
999
 
543
1000
  // Add link
544
- 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 });
545
1002
  expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(1);
546
1003
 
547
1004
  // Remove link
548
- 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 });
549
1006
  expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
550
1007
  });
551
1008
 
@@ -559,6 +1016,7 @@ describe("MCP tools", async () => {
559
1016
  const result = await updateNote.execute({
560
1017
  id: "source",
561
1018
  links: { remove: [{ target: "target", relationship: "wikilink" }] },
1019
+ force: true,
562
1020
  }) as any;
563
1021
  expect(result.content).toBe("See People/Alice for details");
564
1022
  });
@@ -570,8 +1028,8 @@ describe("MCP tools", async () => {
570
1028
  const updateNote = tools.find((t) => t.name === "update-note")!;
571
1029
  const result = await updateNote.execute({
572
1030
  notes: [
573
- { id: "a", content: "A updated" },
574
- { id: "b", tags: { add: ["pinned"] } },
1031
+ { id: "a", content: "A updated", force: true },
1032
+ { id: "b", tags: { add: ["pinned"] }, force: true },
575
1033
  ],
576
1034
  }) as any[];
577
1035
  expect(result).toHaveLength(2);
@@ -583,7 +1041,7 @@ describe("MCP tools", async () => {
583
1041
  await store.createNote("Test", { path: "Projects/README" });
584
1042
  const tools = generateMcpTools(store);
585
1043
  const updateNote = tools.find((t) => t.name === "update-note")!;
586
- 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;
587
1045
  expect(result.content).toBe("Updated");
588
1046
  });
589
1047
 
@@ -592,7 +1050,7 @@ describe("MCP tools", async () => {
592
1050
  const tools = generateMcpTools(store);
593
1051
  const updateNote = tools.find((t) => t.name === "update-note")!;
594
1052
 
595
- 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;
596
1054
  expect(first.content).toBe("Second");
597
1055
  expect(first.updatedAt).toBeTruthy();
598
1056
 
@@ -609,7 +1067,7 @@ describe("MCP tools", async () => {
609
1067
  const tools = generateMcpTools(store);
610
1068
  const updateNote = tools.find((t) => t.name === "update-note")!;
611
1069
 
612
- 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;
613
1071
 
614
1072
  // Simulate a stale client that has the pre-update timestamp (or something else).
615
1073
  const staleTimestamp = "2020-01-01T00:00:00.000Z";
@@ -635,9 +1093,11 @@ describe("MCP tools", async () => {
635
1093
  expect((await store.getNote(note.id))!.content).toBe("Second");
636
1094
  });
637
1095
 
638
- 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 () => {
639
1097
  const note = await store.createNote("First");
640
- 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);
641
1101
  const tools = generateMcpTools(store);
642
1102
  const updateNote = tools.find((t) => t.name === "update-note")!;
643
1103
 
@@ -653,7 +1113,60 @@ describe("MCP tools", async () => {
653
1113
  }
654
1114
  expect(err).toBeTruthy();
655
1115
  expect(err.code).toBe("CONFLICT");
656
- 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");
657
1170
  });
658
1171
 
659
1172
  it("update-note batch aborts on first conflict without touching subsequent items", async () => {
@@ -663,7 +1176,7 @@ describe("MCP tools", async () => {
663
1176
  const updateNote = tools.find((t) => t.name === "update-note")!;
664
1177
 
665
1178
  // Bump a's updated_at so any stale if_updated_at conflicts.
666
- 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;
667
1180
  expect(bumped.updatedAt).toBeTruthy();
668
1181
 
669
1182
  let err: any;
@@ -671,7 +1184,7 @@ describe("MCP tools", async () => {
671
1184
  await updateNote.execute({
672
1185
  notes: [
673
1186
  { id: "a", content: "A new", if_updated_at: "2020-01-01T00:00:00.000Z" },
674
- { id: "b", content: "B new" },
1187
+ { id: "b", content: "B new", force: true },
675
1188
  ],
676
1189
  });
677
1190
  } catch (e) {
@@ -698,7 +1211,7 @@ describe("MCP tools", async () => {
698
1211
  const updateNote = tools.find((t) => t.name === "update-note")!;
699
1212
 
700
1213
  // Establish a known updated_at the two callers both read.
701
- 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;
702
1215
  expect(seed.updatedAt).toBeTruthy();
703
1216
 
704
1217
  const results = await Promise.allSettled([
@@ -731,7 +1244,7 @@ describe("MCP tools", async () => {
731
1244
  const updateNote = tools.find((t) => t.name === "update-note")!;
732
1245
 
733
1246
  // Bump so a stale if_updated_at conflicts; and capture state after bump.
734
- 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 });
735
1248
  const preConflictLinks = await store.getLinks("source", { direction: "outbound" });
736
1249
  expect(preConflictLinks).toHaveLength(1);
737
1250
 
@@ -761,6 +1274,11 @@ describe("MCP tools", async () => {
761
1274
  const result = await query.execute({ id: note.id }) as any;
762
1275
  expect(result.content).toBe("Hello");
763
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);
764
1282
  });
765
1283
 
766
1284
  it("query-notes single note by path", async () => {
@@ -779,6 +1297,60 @@ describe("MCP tools", async () => {
779
1297
  expect(result).toHaveLength(1);
780
1298
  });
781
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
+
782
1354
  it("query-notes list defaults to no content (index mode)", async () => {
783
1355
  const content = "This is the note body.";
784
1356
  await store.createNote(content, { tags: ["daily"], path: "Notes/test", metadata: { status: "draft" } });