@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.
- package/.claude/settings.local.json +8 -0
- package/CHANGELOG.md +70 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +603 -19
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +157 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +92 -0
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +29 -1
- package/docs/HTTP_API.md +105 -1
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/cli.ts +179 -121
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +136 -0
- package/src/scopes.ts +105 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +57 -5
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +583 -0
- package/src/transcription-worker.ts +346 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
package/core/src/core.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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).
|
|
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" } });
|