@openparachute/vault 0.4.4-rc.12 → 0.4.5
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/core/src/core.test.ts +348 -60
- package/core/src/mcp.ts +61 -32
- package/core/src/notes.ts +187 -81
- package/core/src/portable-md.test.ts +554 -1
- package/core/src/portable-md.ts +508 -19
- package/core/src/schema.ts +61 -3
- package/core/src/store.ts +5 -4
- package/core/src/types.ts +27 -3
- package/core/src/wikilinks.ts +74 -14
- package/package.json +1 -1
- package/src/cli.ts +17 -0
- package/src/import-daemon-busy.test.ts +109 -0
- package/src/published.test.ts +17 -0
- package/src/routes.ts +136 -48
- package/src/vault.test.ts +294 -32
package/core/src/core.test.ts
CHANGED
|
@@ -189,15 +189,31 @@ describe("notes", async () => {
|
|
|
189
189
|
});
|
|
190
190
|
|
|
191
191
|
// -------------------------------------------------------------------------
|
|
192
|
-
// Empty
|
|
192
|
+
// Empty content is a valid state (vault#323)
|
|
193
193
|
// -------------------------------------------------------------------------
|
|
194
|
+
// Skeleton notes, drafts saved before content, organizing-only notes,
|
|
195
|
+
// capture-then-fill flows. The earlier #213 guard rejected `content +
|
|
196
|
+
// path both absent` — we no longer enforce it because real vaults
|
|
197
|
+
// legitimately carry such rows and the round-trip import has to accept
|
|
198
|
+
// them.
|
|
199
|
+
|
|
200
|
+
it("createNote accepts empty content with no path", async () => {
|
|
201
|
+
const n = await store.createNote("");
|
|
202
|
+
expect(n.content).toBe("");
|
|
203
|
+
expect(n.path).toBeUndefined();
|
|
204
|
+
});
|
|
194
205
|
|
|
195
|
-
it("createNote
|
|
196
|
-
await
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
it("createNote accepts whitespace-only content with no path", async () => {
|
|
207
|
+
const n = await store.createNote(" ");
|
|
208
|
+
expect(n.content).toBe(" ");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("createNote empty-content note is queryable + survives round-trip", async () => {
|
|
212
|
+
const created = await store.createNote("", { metadata: { kind: "skeleton" } });
|
|
213
|
+
const fetched = await store.getNote(created.id);
|
|
214
|
+
expect(fetched).not.toBeNull();
|
|
215
|
+
expect(fetched!.content).toBe("");
|
|
216
|
+
expect(fetched!.metadata).toMatchObject({ kind: "skeleton" });
|
|
201
217
|
});
|
|
202
218
|
|
|
203
219
|
it("createNote accepts content-only (un-pathed jot)", async () => {
|
|
@@ -212,18 +228,24 @@ describe("notes", async () => {
|
|
|
212
228
|
expect(n.path).toBe("wiki/placeholder");
|
|
213
229
|
});
|
|
214
230
|
|
|
215
|
-
it("updateNote
|
|
231
|
+
it("updateNote allows clearing both content and path", async () => {
|
|
216
232
|
const n = await store.createNote("body", { path: "p" });
|
|
217
|
-
await
|
|
218
|
-
|
|
219
|
-
|
|
233
|
+
const updated = await store.updateNote(n.id, {
|
|
234
|
+
content: "",
|
|
235
|
+
path: "",
|
|
236
|
+
if_updated_at: n.createdAt,
|
|
237
|
+
});
|
|
238
|
+
expect(updated.content).toBe("");
|
|
239
|
+
expect(updated.path).toBeUndefined();
|
|
220
240
|
});
|
|
221
241
|
|
|
222
|
-
it("updateNote
|
|
242
|
+
it("updateNote allows clearing content when path is already null", async () => {
|
|
223
243
|
const n = await store.createNote("body");
|
|
224
|
-
await
|
|
225
|
-
|
|
226
|
-
|
|
244
|
+
const updated = await store.updateNote(n.id, {
|
|
245
|
+
content: "",
|
|
246
|
+
if_updated_at: n.createdAt,
|
|
247
|
+
});
|
|
248
|
+
expect(updated.content).toBe("");
|
|
227
249
|
});
|
|
228
250
|
|
|
229
251
|
it("updateNote allows clearing content when path is set (or being set)", async () => {
|
|
@@ -300,6 +322,142 @@ describe("updated_at backfill on init", async () => {
|
|
|
300
322
|
});
|
|
301
323
|
});
|
|
302
324
|
|
|
325
|
+
// ---- Extension (vault#328 Phase 1: DB + Store) ----
|
|
326
|
+
//
|
|
327
|
+
// Three pinned behaviors:
|
|
328
|
+
// 1. Migrations and inserts default to "md" — every existing row keeps
|
|
329
|
+
// its meaning after the v17 → v18 ALTER TABLE.
|
|
330
|
+
// 2. Explicit extension on createNote persists end-to-end.
|
|
331
|
+
// 3. queryNotes filters by extension (single string + array shapes).
|
|
332
|
+
|
|
333
|
+
describe("notes.extension (vault#328)", async () => {
|
|
334
|
+
it("defaults to 'md' when not specified on createNote", async () => {
|
|
335
|
+
const note = await store.createNote("hello world");
|
|
336
|
+
expect(note.extension).toBe("md");
|
|
337
|
+
const fetched = await store.getNote(note.id);
|
|
338
|
+
expect(fetched!.extension).toBe("md");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("persists explicit extension on createNote", async () => {
|
|
342
|
+
const note = await store.createNote("month,income\n2026-01,12000", {
|
|
343
|
+
path: "Tabular/budget",
|
|
344
|
+
extension: "csv",
|
|
345
|
+
});
|
|
346
|
+
expect(note.extension).toBe("csv");
|
|
347
|
+
const fetched = await store.getNote(note.id);
|
|
348
|
+
expect(fetched!.extension).toBe("csv");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("backfills 'md' on existing rows after v17 → v18 migration", () => {
|
|
352
|
+
// Build a v17-shape vault by hand: create the notes table WITHOUT the
|
|
353
|
+
// `extension` column, insert a row, then run initSchema and assert
|
|
354
|
+
// the migration backfills "md".
|
|
355
|
+
const raw = new Database(":memory:");
|
|
356
|
+
raw.exec(`
|
|
357
|
+
CREATE TABLE notes (
|
|
358
|
+
id TEXT PRIMARY KEY,
|
|
359
|
+
content TEXT DEFAULT '',
|
|
360
|
+
path TEXT,
|
|
361
|
+
metadata TEXT DEFAULT '{}',
|
|
362
|
+
created_at TEXT NOT NULL,
|
|
363
|
+
updated_at TEXT
|
|
364
|
+
)
|
|
365
|
+
`);
|
|
366
|
+
raw.prepare(
|
|
367
|
+
"INSERT INTO notes (id, content, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
|
368
|
+
).run("legacy", "old content", "2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00.000Z");
|
|
369
|
+
|
|
370
|
+
initSchema(raw); // applies v18 ALTER TABLE
|
|
371
|
+
|
|
372
|
+
const row = raw.prepare("SELECT extension FROM notes WHERE id = ?").get("legacy") as {
|
|
373
|
+
extension: string;
|
|
374
|
+
};
|
|
375
|
+
expect(row.extension).toBe("md");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("updateNote changes extension on existing note", async () => {
|
|
379
|
+
const note = await store.createNote("hello", { path: "Foo" });
|
|
380
|
+
expect(note.extension).toBe("md");
|
|
381
|
+
const updated = await store.updateNote(note.id, { extension: "mdx" });
|
|
382
|
+
expect(updated.extension).toBe("mdx");
|
|
383
|
+
const fetched = await store.getNote(note.id);
|
|
384
|
+
expect(fetched!.extension).toBe("mdx");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("queryNotes filters by extension (single string)", async () => {
|
|
388
|
+
await store.createNote("md note A", { path: "a", extension: "md" });
|
|
389
|
+
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
390
|
+
await store.createNote("md note B", { path: "c" }); // default md
|
|
391
|
+
const csv = await store.queryNotes({ extension: "csv" });
|
|
392
|
+
expect(csv).toHaveLength(1);
|
|
393
|
+
expect(csv[0]!.path).toBe("b");
|
|
394
|
+
const md = await store.queryNotes({ extension: "md" });
|
|
395
|
+
expect(md).toHaveLength(2);
|
|
396
|
+
expect(md.map((n) => n.path).sort()).toEqual(["a", "c"]);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("queryNotes filters by extension (array — IN clause)", async () => {
|
|
400
|
+
await store.createNote("md note", { path: "a" });
|
|
401
|
+
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
402
|
+
await store.createNote("yaml note", { path: "c", extension: "yaml" });
|
|
403
|
+
await store.createNote("json note", { path: "d", extension: "json" });
|
|
404
|
+
const results = await store.queryNotes({ extension: ["csv", "yaml", "json"] });
|
|
405
|
+
expect(results).toHaveLength(3);
|
|
406
|
+
const paths = results.map((n) => n.path).sort();
|
|
407
|
+
expect(paths).toEqual(["b", "c", "d"]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("queryNotes extension filter is case-insensitive", async () => {
|
|
411
|
+
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
412
|
+
// Caller-supplied case shouldn't matter — stored as "csv", looked up as "CSV".
|
|
413
|
+
const results = await store.queryNotes({ extension: "CSV" });
|
|
414
|
+
expect(results).toHaveLength(1);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("updateNote extension-only collision throws PathConflictError (vault#329 F1)", async () => {
|
|
418
|
+
// Two notes share `Foo` differing only by extension — legal under
|
|
419
|
+
// v18's composite (path, extension) uniqueness. Flip the md note's
|
|
420
|
+
// extension to "csv": that would collide with the existing csv
|
|
421
|
+
// row. The catch in updateNote must surface PATH_CONFLICT (not a
|
|
422
|
+
// raw SQLiteError) since the composite index fires UNIQUE on
|
|
423
|
+
// extension-only updates just like it does on path-only updates.
|
|
424
|
+
const md = await store.createNote("# md note", { path: "Foo", id: "foo-md" });
|
|
425
|
+
await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
|
|
426
|
+
expect(
|
|
427
|
+
store.updateNote(md.id, { extension: "csv" }),
|
|
428
|
+
).rejects.toMatchObject({ code: "PATH_CONFLICT", path: "Foo" });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("getNoteByPath throws AmbiguousPathError when path matches multiple extensions (vault#330 S1)", async () => {
|
|
432
|
+
await store.createNote("# md", { path: "Foo", id: "foo-md" });
|
|
433
|
+
await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
|
|
434
|
+
expect(store.getNoteByPath("Foo")).rejects.toMatchObject({
|
|
435
|
+
code: "AMBIGUOUS_PATH",
|
|
436
|
+
path: "Foo",
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("getNoteByPath returns the right note when extension is passed (vault#330 S1)", async () => {
|
|
441
|
+
await store.createNote("# md", { path: "Foo", id: "foo-md" });
|
|
442
|
+
await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
|
|
443
|
+
const md = await store.getNoteByPath("Foo", "md");
|
|
444
|
+
const csv = await store.getNoteByPath("Foo", "csv");
|
|
445
|
+
expect(md!.id).toBe("foo-md");
|
|
446
|
+
expect(csv!.id).toBe("foo-csv");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("getNoteByPath returns single match unchanged when no ambiguity (vault#330 S1 back-compat)", async () => {
|
|
450
|
+
await store.createNote("# only", { path: "Foo", id: "only" });
|
|
451
|
+
const note = await store.getNoteByPath("Foo");
|
|
452
|
+
expect(note!.id).toBe("only");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("getNoteByPath returns null for unknown path (vault#330 S1 back-compat)", async () => {
|
|
456
|
+
const note = await store.getNoteByPath("DoesNotExist");
|
|
457
|
+
expect(note).toBeNull();
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
303
461
|
// ---- Tags ----
|
|
304
462
|
|
|
305
463
|
describe("tags", async () => {
|
|
@@ -1567,6 +1725,91 @@ describe("MCP tools", async () => {
|
|
|
1567
1725
|
expect(result[1].tags).toContain("doc");
|
|
1568
1726
|
});
|
|
1569
1727
|
|
|
1728
|
+
it("create-note accepts extension field (vault#328)", async () => {
|
|
1729
|
+
const tools = generateMcpTools(store);
|
|
1730
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
1731
|
+
const result = await createNote.execute({
|
|
1732
|
+
content: "month,income\n2026-01,12000",
|
|
1733
|
+
path: "Tabular/budget",
|
|
1734
|
+
extension: "csv",
|
|
1735
|
+
}) as any;
|
|
1736
|
+
expect(result.extension).toBe("csv");
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
it("create-note defaults extension to 'md' when omitted (vault#328)", async () => {
|
|
1740
|
+
const tools = generateMcpTools(store);
|
|
1741
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
1742
|
+
const result = await createNote.execute({ content: "plain markdown" }) as any;
|
|
1743
|
+
expect(result.extension).toBe("md");
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
it("create-note rejects invalid extension (uppercase, dot, reserved) (vault#328)", async () => {
|
|
1747
|
+
const tools = generateMcpTools(store);
|
|
1748
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
1749
|
+
// Uppercase
|
|
1750
|
+
expect(createNote.execute({ content: "x", extension: "CSV" })).rejects.toThrow(/invalid extension/);
|
|
1751
|
+
// Dot
|
|
1752
|
+
expect(createNote.execute({ content: "x", extension: "csv.bak" })).rejects.toThrow(/invalid extension/);
|
|
1753
|
+
// Slash
|
|
1754
|
+
expect(createNote.execute({ content: "x", extension: "foo/bar" })).rejects.toThrow(/invalid extension/);
|
|
1755
|
+
// Reserved "parachute" prefix (lowercase — the pattern check passes,
|
|
1756
|
+
// so the reserved-prefix guard is what fires).
|
|
1757
|
+
expect(createNote.execute({ content: "x", extension: "parachute" })).rejects.toThrow(/reserved/);
|
|
1758
|
+
expect(createNote.execute({ content: "x", extension: "parachutex" })).rejects.toThrow(/reserved/);
|
|
1759
|
+
// Too long (>16)
|
|
1760
|
+
expect(createNote.execute({ content: "x", extension: "a".repeat(17) })).rejects.toThrow(/invalid extension/);
|
|
1761
|
+
// Empty
|
|
1762
|
+
expect(createNote.execute({ content: "x", extension: "" })).rejects.toThrow(/non-empty/);
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
it("update-note changes extension (vault#328)", async () => {
|
|
1766
|
+
const note = await store.createNote("hi", { path: "Foo" });
|
|
1767
|
+
const tools = generateMcpTools(store);
|
|
1768
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1769
|
+
const result = await updateNote.execute({ id: note.id, extension: "mdx", force: true }) as any;
|
|
1770
|
+
expect(result.extension).toBe("mdx");
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
it("update-note validates extension on update branch (vault#328)", async () => {
|
|
1774
|
+
const note = await store.createNote("hi", { path: "Foo" });
|
|
1775
|
+
const tools = generateMcpTools(store);
|
|
1776
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1777
|
+
expect(
|
|
1778
|
+
updateNote.execute({ id: note.id, extension: "BAD", force: true }),
|
|
1779
|
+
).rejects.toThrow(/invalid extension/);
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
it("update-note if_missing=create honors extension (vault#328)", async () => {
|
|
1783
|
+
const tools = generateMcpTools(store);
|
|
1784
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1785
|
+
const result = await updateNote.execute({
|
|
1786
|
+
id: "Tabular/new-budget",
|
|
1787
|
+
content: "month,total\n2026-02,9000",
|
|
1788
|
+
extension: "csv",
|
|
1789
|
+
if_missing: "create",
|
|
1790
|
+
}) as any;
|
|
1791
|
+
expect(result.created).toBe(true);
|
|
1792
|
+
expect(result.extension).toBe("csv");
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
it("query-notes filters by extension (vault#328)", async () => {
|
|
1796
|
+
await store.createNote("md note", { path: "a" });
|
|
1797
|
+
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
1798
|
+
await store.createNote("yaml note", { path: "c", extension: "yaml" });
|
|
1799
|
+
const tools = generateMcpTools(store);
|
|
1800
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
1801
|
+
|
|
1802
|
+
// Single extension
|
|
1803
|
+
const csv = await queryNotes.execute({ extension: "csv", include_content: true }) as any[];
|
|
1804
|
+
expect(csv).toHaveLength(1);
|
|
1805
|
+
expect(csv[0].path).toBe("b");
|
|
1806
|
+
|
|
1807
|
+
// Array shape
|
|
1808
|
+
const both = await queryNotes.execute({ extension: ["csv", "yaml"], include_content: true }) as any[];
|
|
1809
|
+
expect(both).toHaveLength(2);
|
|
1810
|
+
expect(both.map((n) => n.path).sort()).toEqual(["b", "c"]);
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1570
1813
|
it("create-note with links resolves targets by path", async () => {
|
|
1571
1814
|
const tools = generateMcpTools(store);
|
|
1572
1815
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
@@ -2811,58 +3054,32 @@ describe("MCP tools", async () => {
|
|
|
2811
3054
|
expect(r2.map((n) => n.content).sort()).toEqual(["a"]);
|
|
2812
3055
|
});
|
|
2813
3056
|
|
|
2814
|
-
// ---- empty-note + batch-cap MCP
|
|
3057
|
+
// ---- empty-note acceptance (vault#323) + batch-cap MCP ----
|
|
2815
3058
|
|
|
2816
|
-
it("create-note
|
|
3059
|
+
it("create-note accepts bare empty content with no path", async () => {
|
|
2817
3060
|
const tools = generateMcpTools(store);
|
|
2818
3061
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
expect(
|
|
2826
|
-
});
|
|
2827
|
-
|
|
2828
|
-
it("create-note batch rejects when any entry is empty content + no path (atomic, with item_index)", async () => {
|
|
2829
|
-
const tools = generateMcpTools(store);
|
|
2830
|
-
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2831
|
-
const beforeCount = (await store.queryNotes({ search: "atomic-marker" })).length;
|
|
2832
|
-
let err: any;
|
|
2833
|
-
try {
|
|
2834
|
-
await createNote.execute({
|
|
2835
|
-
notes: [
|
|
2836
|
-
{ content: "atomic-marker first" },
|
|
2837
|
-
{ content: "" },
|
|
2838
|
-
],
|
|
2839
|
-
});
|
|
2840
|
-
} catch (e) {
|
|
2841
|
-
err = e;
|
|
2842
|
-
}
|
|
2843
|
-
expect(err?.code).toBe("EMPTY_NOTE");
|
|
2844
|
-
// The first item must NOT have been created — pre-validation rolls
|
|
2845
|
-
// the whole batch back atomically. Partial-create would leak prefixes
|
|
2846
|
-
// on every runaway-client burst (#213).
|
|
2847
|
-
const afterCount = (await store.queryNotes({ search: "atomic-marker" })).length;
|
|
2848
|
-
expect(afterCount).toBe(beforeCount);
|
|
2849
|
-
// Parity with HTTP route: MCP callers with multi-item batches need to
|
|
2850
|
-
// know which entry triggered the rejection. The bad entry is at index 1.
|
|
2851
|
-
expect(err.item_index).toBe(1);
|
|
3062
|
+
const result = await createNote.execute({ content: "" }) as any;
|
|
3063
|
+
expect(result).toBeTruthy();
|
|
3064
|
+
const note = Array.isArray(result) ? result[0] : result;
|
|
3065
|
+
expect(note.content).toBe("");
|
|
3066
|
+
const fetched = await store.getNote(note.id);
|
|
3067
|
+
expect(fetched).not.toBeNull();
|
|
3068
|
+
expect(fetched!.content).toBe("");
|
|
2852
3069
|
});
|
|
2853
3070
|
|
|
2854
|
-
it("create-note
|
|
3071
|
+
it("create-note batch with mixed empty + content entries succeeds end-to-end", async () => {
|
|
2855
3072
|
const tools = generateMcpTools(store);
|
|
2856
3073
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
expect(
|
|
3074
|
+
const result = await createNote.execute({
|
|
3075
|
+
notes: [
|
|
3076
|
+
{ content: "first" },
|
|
3077
|
+
{ content: "" },
|
|
3078
|
+
{ content: "third" },
|
|
3079
|
+
],
|
|
3080
|
+
}) as any[];
|
|
3081
|
+
expect(result).toHaveLength(3);
|
|
3082
|
+
expect(result.map((n) => n.content)).toEqual(["first", "", "third"]);
|
|
2866
3083
|
});
|
|
2867
3084
|
|
|
2868
3085
|
it("create-note batch over MAX_BATCH_SIZE rejects with BATCH_TOO_LARGE", async () => {
|
|
@@ -3910,6 +4127,77 @@ describe("update-note if_missing=create (vault#309)", async () => {
|
|
|
3910
4127
|
const all = await store.queryNotes({ limit: 100 });
|
|
3911
4128
|
expect(all.filter((n) => n.path === "Inbox/sync-target")).toHaveLength(1);
|
|
3912
4129
|
});
|
|
4130
|
+
|
|
4131
|
+
// vault#321 F3 — schema-conflict warning surfaces on the create
|
|
4132
|
+
// branch. The branch reuses `attachValidationStatus` so the
|
|
4133
|
+
// conflict-detection logic should fire, but pre-fold it wasn't
|
|
4134
|
+
// pinned. Two tags directly applied to the new note, each
|
|
4135
|
+
// declaring the same field with conflicting specs → schema_conflict
|
|
4136
|
+
// warning (the existing `resolveNoteSchemas` walk produces this for
|
|
4137
|
+
// both inheritance chains AND multi-tag direct application).
|
|
4138
|
+
it("schema-conflict warning surfaces on create branch (vault#321 F3)", async () => {
|
|
4139
|
+
await store.upsertTagSchema("kpi", {
|
|
4140
|
+
fields: { count: { type: "integer" } },
|
|
4141
|
+
});
|
|
4142
|
+
await store.upsertTagSchema("metric", {
|
|
4143
|
+
fields: { count: { type: "string" } }, // conflicting spec
|
|
4144
|
+
});
|
|
4145
|
+
const tools = generateMcpTools(store);
|
|
4146
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
4147
|
+
const result = await update.execute({
|
|
4148
|
+
id: "Inbox/conflicting-tags",
|
|
4149
|
+
content: "x",
|
|
4150
|
+
tags: ["kpi", "metric"],
|
|
4151
|
+
metadata: { count: 5 },
|
|
4152
|
+
if_missing: "create",
|
|
4153
|
+
}) as any;
|
|
4154
|
+
expect(result.created).toBe(true);
|
|
4155
|
+
const conflict = result.validation_status.warnings.find(
|
|
4156
|
+
(w: any) => w.reason === "schema_conflict",
|
|
4157
|
+
);
|
|
4158
|
+
expect(conflict).toBeDefined();
|
|
4159
|
+
expect(conflict.field).toBe("count");
|
|
4160
|
+
// First-tag-wins precedence (kpi → integer). The loser_schema
|
|
4161
|
+
// field names metric.
|
|
4162
|
+
expect(conflict.schema).toBe("kpi");
|
|
4163
|
+
expect(conflict.loser_schema).toBe("metric");
|
|
4164
|
+
});
|
|
4165
|
+
|
|
4166
|
+
// vault#321 F4 — links.add on the create branch is applied. The
|
|
4167
|
+
// implementation at mcp.ts:644-650 was present pre-fold; this
|
|
4168
|
+
// test pins it so a future regression breaking Gitcoin's
|
|
4169
|
+
// upsert-with-typed-links workflow goes red.
|
|
4170
|
+
it("links.add applied on create branch (vault#321 F4)", async () => {
|
|
4171
|
+
// Two pre-existing target notes the new source links to.
|
|
4172
|
+
await store.createNote("A", { id: "t-mcp-a-321", path: "Targets/A-mcp" });
|
|
4173
|
+
await store.createNote("B", { id: "t-mcp-b-321", path: "Targets/B-mcp" });
|
|
4174
|
+
|
|
4175
|
+
const tools = generateMcpTools(store);
|
|
4176
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
4177
|
+
const result = await update.execute({
|
|
4178
|
+
id: "Inbox/mcp-source-321",
|
|
4179
|
+
content: "source body",
|
|
4180
|
+
if_missing: "create",
|
|
4181
|
+
links: {
|
|
4182
|
+
add: [
|
|
4183
|
+
{ target: "t-mcp-a-321", relationship: "derived-from" },
|
|
4184
|
+
{ target: "Targets/B-mcp", relationship: "responds-to", metadata: { weight: 5 } },
|
|
4185
|
+
],
|
|
4186
|
+
},
|
|
4187
|
+
}) as any;
|
|
4188
|
+
expect(result.created).toBe(true);
|
|
4189
|
+
|
|
4190
|
+
const sourceId = result.id as string;
|
|
4191
|
+
const outboundLinks = await store.getLinks(sourceId, { direction: "outbound" });
|
|
4192
|
+
const derivedFrom = outboundLinks.find((l) => l.relationship === "derived-from");
|
|
4193
|
+
expect(derivedFrom).toBeDefined();
|
|
4194
|
+
expect(derivedFrom!.targetId).toBe("t-mcp-a-321");
|
|
4195
|
+
|
|
4196
|
+
const respondsTo = outboundLinks.find((l) => l.relationship === "responds-to");
|
|
4197
|
+
expect(respondsTo).toBeDefined();
|
|
4198
|
+
expect(respondsTo!.targetId).toBe("t-mcp-b-321");
|
|
4199
|
+
expect(respondsTo!.metadata).toEqual({ weight: 5 });
|
|
4200
|
+
});
|
|
3913
4201
|
});
|
|
3914
4202
|
|
|
3915
4203
|
// ---------------------------------------------------------------------------
|
package/core/src/mcp.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { Store, Note } from "./types.js";
|
|
3
3
|
import * as noteOps from "./notes.js";
|
|
4
|
-
import { filterMetadata, MAX_BATCH_SIZE } from "./notes.js";
|
|
4
|
+
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
5
5
|
import * as linkOps from "./links.js";
|
|
6
6
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
7
7
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -26,14 +26,33 @@ export interface McpToolDef {
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Resolve a note identifier — tries ID first, then case-insensitive
|
|
30
|
-
* Works everywhere a note reference is accepted.
|
|
29
|
+
* Resolve a note identifier — tries ID first, then case-insensitive
|
|
30
|
+
* path match. Works everywhere a note reference is accepted.
|
|
31
|
+
*
|
|
32
|
+
* Path-with-extension form (vault#330 S1): a trailing `.<ext>` matching
|
|
33
|
+
* the extension pattern (`/^[a-z0-9]{1,16}$/i`) is parsed as
|
|
34
|
+
* `(path, extension)` to disambiguate notes that share a path
|
|
35
|
+
* differing only by extension. Mirrors the wikilink ambiguity policy
|
|
36
|
+
* from vault#328.
|
|
37
|
+
*
|
|
38
|
+
* On ambiguous path with no extension hint, `getNoteByPath` throws
|
|
39
|
+
* `AmbiguousPathError` — `resolveNote` propagates it so MCP / REST
|
|
40
|
+
* handlers can surface a clear 4xx rather than picking arbitrarily.
|
|
31
41
|
*/
|
|
32
42
|
function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
33
43
|
// Try ID match first (fast, indexed)
|
|
34
44
|
const byId = noteOps.getNote(db, idOrPath);
|
|
35
45
|
if (byId) return byId;
|
|
36
|
-
//
|
|
46
|
+
// Path-with-extension form: `Tabular/budget.csv` → (path="Tabular/
|
|
47
|
+
// budget", extension="csv"). Only kicks in when the suffix looks
|
|
48
|
+
// like an extension AND a `(path, ext)` row exists. Fall through to
|
|
49
|
+
// the no-extension lookup if not (so `Recipe.v2` where `v2` isn't a
|
|
50
|
+
// real extension still finds Recipe.v2 by exact-path).
|
|
51
|
+
const extMatch = idOrPath.match(/^(.*)\.([a-z0-9]{1,16})$/i);
|
|
52
|
+
if (extMatch) {
|
|
53
|
+
const explicit = noteOps.getNoteByPath(db, extMatch[1]!, extMatch[2]!);
|
|
54
|
+
if (explicit) return explicit;
|
|
55
|
+
}
|
|
37
56
|
return noteOps.getNoteByPath(db, idOrPath);
|
|
38
57
|
}
|
|
39
58
|
|
|
@@ -133,6 +152,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
133
152
|
has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction)." },
|
|
134
153
|
path: { type: "string", description: "Exact path match (case-insensitive)" },
|
|
135
154
|
path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
|
|
155
|
+
extension: {
|
|
156
|
+
oneOf: [
|
|
157
|
+
{ type: "string" },
|
|
158
|
+
{ type: "array", items: { type: "string" } },
|
|
159
|
+
],
|
|
160
|
+
description: "Filter by file extension (vault#328). Pass a single extension (e.g. \"csv\") or an array (e.g. [\"csv\", \"yaml\", \"json\"]). Notes default to \"md\"; case-insensitive match.",
|
|
161
|
+
},
|
|
136
162
|
search: { type: "string", description: "Full-text search query" },
|
|
137
163
|
metadata: {
|
|
138
164
|
type: "object",
|
|
@@ -264,6 +290,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
264
290
|
hasLinks: params.has_links as boolean | undefined,
|
|
265
291
|
path: params.path as string | undefined,
|
|
266
292
|
pathPrefix: params.path_prefix as string | undefined,
|
|
293
|
+
extension: params.extension as string | string[] | undefined,
|
|
267
294
|
// Push the near-scope into the SQL WHERE so that LIMIT and ORDER
|
|
268
295
|
// BY apply to the neighborhood. Without this, queryNotes would
|
|
269
296
|
// fetch the first `limit` notes by created_at and then post-
|
|
@@ -339,6 +366,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
339
366
|
// Single note fields
|
|
340
367
|
content: { type: "string", description: "Note content (markdown). Wikilinks like [[Target]] auto-resolve." },
|
|
341
368
|
path: { type: "string", description: "Note path (e.g., 'Projects/README')" },
|
|
369
|
+
extension: { type: "string", description: "File extension (vault#328). Default \"md\". Use \"csv\"/\"yaml\"/\"json\"/\"mdx\"/etc. for non-markdown notes. Lowercase alphanumeric, 1–16 chars; no '.' or '/'. The \"parachute\" prefix is reserved." },
|
|
342
370
|
metadata: { type: "object", description: "Metadata fields" },
|
|
343
371
|
tags: { type: "array", items: { type: "string" }, description: "Tags to apply" },
|
|
344
372
|
links: {
|
|
@@ -362,6 +390,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
362
390
|
properties: {
|
|
363
391
|
content: { type: "string" },
|
|
364
392
|
path: { type: "string" },
|
|
393
|
+
extension: { type: "string", description: "File extension (vault#328). See top-level docs." },
|
|
365
394
|
metadata: { type: "object" },
|
|
366
395
|
tags: { type: "array", items: { type: "string" } },
|
|
367
396
|
links: { type: "array" },
|
|
@@ -381,43 +410,30 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
381
410
|
throw new BatchTooLargeError(items.length);
|
|
382
411
|
}
|
|
383
412
|
|
|
384
|
-
// Empty-note pre-validation (#213): make mixed batches atomic for
|
|
385
|
-
// the empty-note case. The Store will throw EmptyNoteError on the
|
|
386
|
-
// empty entry, but in a sequential batch loop the prefix would have
|
|
387
|
-
// already committed before we hit it. Pre-walk so the whole call
|
|
388
|
-
// either creates everything or nothing. The error carries
|
|
389
|
-
// `item_index` so MCP callers with multi-item batches can pinpoint
|
|
390
|
-
// the bad entry — parity with the HTTP route's response shape.
|
|
391
|
-
// TODO: tighten batch input type — `items[i] as any` mirrors the
|
|
392
|
-
// top-of-call cast at `params.notes as any[]`. A typed McpCreateNoteInput
|
|
393
|
-
// would let us drop both casts.
|
|
394
|
-
for (let i = 0; i < items.length; i++) {
|
|
395
|
-
const item = items[i] as any;
|
|
396
|
-
const content = ((item?.content as string | undefined) ?? "").toString();
|
|
397
|
-
const rawPath = item?.path;
|
|
398
|
-
const pathEmpty = rawPath === undefined || rawPath === null
|
|
399
|
-
|| (typeof rawPath === "string" && rawPath.trim() === "");
|
|
400
|
-
if (!content.trim() && pathEmpty) {
|
|
401
|
-
throw new noteOps.EmptyNoteError(null, batch ? i : null);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
413
|
const created: Note[] = [];
|
|
406
414
|
// Wrap multi-item batches in a SQLite transaction so a mid-batch
|
|
407
|
-
// failure rolls back every prior insert — see #236.
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
415
|
+
// failure rolls back every prior insert — see #236. This guards
|
|
416
|
+
// anything thrown from store.createNote / createLink (path
|
|
417
|
+
// conflict, etc.). Single-item calls skip the wrap to avoid
|
|
418
|
+
// colliding with concurrent callers on the shared bun:sqlite
|
|
419
|
+
// connection.
|
|
412
420
|
const batched = items.length > 1;
|
|
413
421
|
if (batched) db.exec("BEGIN");
|
|
414
422
|
try {
|
|
415
423
|
for (const item of items) {
|
|
424
|
+
// Validate extension up front (vault#328). Throwing here while
|
|
425
|
+
// we're inside the BEGIN block on a batch rolls back the
|
|
426
|
+
// transaction in the outer catch — the same behavior as a
|
|
427
|
+
// path conflict mid-batch.
|
|
428
|
+
const extension = item.extension !== undefined
|
|
429
|
+
? validateExtension(item.extension)
|
|
430
|
+
: undefined;
|
|
416
431
|
const note = await store.createNote(item.content as string ?? "", {
|
|
417
432
|
path: item.path as string | undefined,
|
|
418
433
|
tags: item.tags as string[] | undefined,
|
|
419
434
|
metadata: item.metadata as Record<string, unknown> | undefined,
|
|
420
435
|
created_at: item.created_at as string | undefined,
|
|
436
|
+
...(extension !== undefined ? { extension } : {}),
|
|
421
437
|
});
|
|
422
438
|
|
|
423
439
|
// Create explicit links (not wikilinks — those are automatic)
|
|
@@ -488,6 +504,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
488
504
|
description: "Find-and-replace one occurrence. Errors if `old_text` is not found or matches multiple locations. Mutually exclusive with `content` and `append`/`prepend`.",
|
|
489
505
|
},
|
|
490
506
|
path: { type: "string", description: "New path" },
|
|
507
|
+
extension: { type: "string", description: "Change the note's file extension (vault#328). Allowed but caller-owned — you're responsible for content validity if you switch a non-empty note's extension. Lowercase alphanumeric, 1–16 chars; \"parachute\" prefix reserved." },
|
|
491
508
|
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
|
|
492
509
|
created_at: { type: "string", description: "New created_at timestamp" },
|
|
493
510
|
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
|
|
@@ -552,6 +569,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
552
569
|
required: ["old_text", "new_text"],
|
|
553
570
|
},
|
|
554
571
|
path: { type: "string" },
|
|
572
|
+
extension: { type: "string", description: "Change the note's file extension (vault#328). See top-level docs." },
|
|
555
573
|
metadata: { type: "object" },
|
|
556
574
|
created_at: { type: "string" },
|
|
557
575
|
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
|
|
@@ -638,6 +656,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
638
656
|
// caller's intent.
|
|
639
657
|
const idLooksLikePath = idOrPath.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPath);
|
|
640
658
|
const explicitPath = typeof item.path === "string" ? item.path as string : undefined;
|
|
659
|
+
// Validate extension before reaching the Store — same
|
|
660
|
+
// contract as the create-note tool.
|
|
661
|
+
const createExt = item.extension !== undefined
|
|
662
|
+
? validateExtension(item.extension)
|
|
663
|
+
: undefined;
|
|
641
664
|
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
642
665
|
...(idLooksLikePath ? { path: explicitPath ?? idOrPath } : { id: idOrPath, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
643
666
|
...(item.tags && Array.isArray((item.tags as any).add)
|
|
@@ -647,6 +670,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
647
670
|
: {}),
|
|
648
671
|
...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
|
|
649
672
|
...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
|
|
673
|
+
...(createExt !== undefined ? { extension: createExt } : {}),
|
|
650
674
|
};
|
|
651
675
|
const content = (item.content as string | undefined) ?? "";
|
|
652
676
|
const created = await store.createNote(content, createOpts);
|
|
@@ -776,6 +800,9 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
776
800
|
if (item.prepend !== undefined) updates.prepend = item.prepend;
|
|
777
801
|
}
|
|
778
802
|
if (item.path !== undefined) updates.path = item.path;
|
|
803
|
+
if (item.extension !== undefined) {
|
|
804
|
+
updates.extension = validateExtension(item.extension);
|
|
805
|
+
}
|
|
779
806
|
if (item.metadata !== undefined) {
|
|
780
807
|
// Merge metadata (don't replace wholesale)
|
|
781
808
|
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
@@ -1254,8 +1281,10 @@ function normalizeTags(tag: unknown): string[] | undefined {
|
|
|
1254
1281
|
}
|
|
1255
1282
|
|
|
1256
1283
|
// Re-exported for backward compat; defined in notes.ts alongside the
|
|
1257
|
-
// conditional-UPDATE implementation that raises it.
|
|
1258
|
-
|
|
1284
|
+
// conditional-UPDATE implementation that raises it. AmbiguousPathError
|
|
1285
|
+
// joins the set (vault#331 N2) so external callers can `instanceof`
|
|
1286
|
+
// it without crossing module boundaries.
|
|
1287
|
+
export { ConflictError, PathConflictError, AmbiguousPathError, MAX_BATCH_SIZE } from "./notes.js";
|
|
1259
1288
|
|
|
1260
1289
|
/**
|
|
1261
1290
|
* Thrown by the `update-note` MCP tool (and the REST PATCH handler) when a
|