@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.
@@ -189,15 +189,31 @@ describe("notes", async () => {
189
189
  });
190
190
 
191
191
  // -------------------------------------------------------------------------
192
- // Empty-note invariant at the Store boundary (#213)
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 rejects content+path both absent → EmptyNoteError", async () => {
196
- await expect(store.createNote("")).rejects.toMatchObject({ code: "EMPTY_NOTE" });
197
- await expect(store.createNote(" ")).rejects.toMatchObject({ code: "EMPTY_NOTE" });
198
- await expect(store.createNote("", { metadata: { x: 1 } })).rejects.toMatchObject({
199
- code: "EMPTY_NOTE",
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 rejects clearing both content and path → EmptyNoteError", async () => {
231
+ it("updateNote allows clearing both content and path", async () => {
216
232
  const n = await store.createNote("body", { path: "p" });
217
- await expect(
218
- store.updateNote(n.id, { content: "", path: "", if_updated_at: n.createdAt }),
219
- ).rejects.toMatchObject({ code: "EMPTY_NOTE", note_id: n.id });
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 rejects clearing content when path is already null", async () => {
242
+ it("updateNote allows clearing content when path is already null", async () => {
223
243
  const n = await store.createNote("body");
224
- await expect(
225
- store.updateNote(n.id, { content: "", if_updated_at: n.createdAt }),
226
- ).rejects.toMatchObject({ code: "EMPTY_NOTE" });
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 regressions (#213) ----
3057
+ // ---- empty-note acceptance (vault#323) + batch-cap MCP ----
2815
3058
 
2816
- it("create-note rejects bare empty content with no path (EMPTY_NOTE)", async () => {
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
- let err: any;
2820
- try {
2821
- await createNote.execute({ content: "" });
2822
- } catch (e) {
2823
- err = e;
2824
- }
2825
- expect(err?.code).toBe("EMPTY_NOTE");
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 single empty has null item_index (not a batch position)", async () => {
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
- let err: any;
2858
- try {
2859
- await createNote.execute({ content: "" });
2860
- } catch (e) {
2861
- err = e;
2862
- }
2863
- expect(err?.code).toBe("EMPTY_NOTE");
2864
- // Single-call (no `notes` array) — there's no batch position to report.
2865
- expect(err.item_index).toBeNull();
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 path match.
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
- // Fallback to path match
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. The pre-walk
408
- // above catches empty-note cases; this guards anything thrown from
409
- // store.createNote / createLink (path conflict, etc.). Single-item
410
- // calls skip the wrap to avoid colliding with concurrent callers
411
- // on the shared bun:sqlite connection.
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
- export { ConflictError, PathConflictError, EmptyNoteError, MAX_BATCH_SIZE } from "./notes.js";
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