@openparachute/vault 0.2.4 → 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 +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -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 +153 -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 +28 -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/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -16
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 () => {
|
|
@@ -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
|
|
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
|
-
|
|
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).
|
|
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" } });
|