@openparachute/vault 0.4.4-rc.14 → 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.
@@ -322,6 +322,142 @@ describe("updated_at backfill on init", async () => {
322
322
  });
323
323
  });
324
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
+
325
461
  // ---- Tags ----
326
462
 
327
463
  describe("tags", async () => {
@@ -1589,6 +1725,91 @@ describe("MCP tools", async () => {
1589
1725
  expect(result[1].tags).toContain("doc");
1590
1726
  });
1591
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
+
1592
1813
  it("create-note with links resolves targets by path", async () => {
1593
1814
  const tools = generateMcpTools(store);
1594
1815
  const createNote = tools.find((t) => t.name === "create-note")!;
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" },
@@ -392,11 +421,19 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
392
421
  if (batched) db.exec("BEGIN");
393
422
  try {
394
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;
395
431
  const note = await store.createNote(item.content as string ?? "", {
396
432
  path: item.path as string | undefined,
397
433
  tags: item.tags as string[] | undefined,
398
434
  metadata: item.metadata as Record<string, unknown> | undefined,
399
435
  created_at: item.created_at as string | undefined,
436
+ ...(extension !== undefined ? { extension } : {}),
400
437
  });
401
438
 
402
439
  // Create explicit links (not wikilinks — those are automatic)
@@ -467,6 +504,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
467
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`.",
468
505
  },
469
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." },
470
508
  metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
471
509
  created_at: { type: "string", description: "New created_at timestamp" },
472
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." },
@@ -531,6 +569,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
531
569
  required: ["old_text", "new_text"],
532
570
  },
533
571
  path: { type: "string" },
572
+ extension: { type: "string", description: "Change the note's file extension (vault#328). See top-level docs." },
534
573
  metadata: { type: "object" },
535
574
  created_at: { type: "string" },
536
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." },
@@ -617,6 +656,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
617
656
  // caller's intent.
618
657
  const idLooksLikePath = idOrPath.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPath);
619
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;
620
664
  const createOpts: Parameters<Store["createNote"]>[1] = {
621
665
  ...(idLooksLikePath ? { path: explicitPath ?? idOrPath } : { id: idOrPath, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
622
666
  ...(item.tags && Array.isArray((item.tags as any).add)
@@ -626,6 +670,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
626
670
  : {}),
627
671
  ...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
628
672
  ...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
673
+ ...(createExt !== undefined ? { extension: createExt } : {}),
629
674
  };
630
675
  const content = (item.content as string | undefined) ?? "";
631
676
  const created = await store.createNote(content, createOpts);
@@ -755,6 +800,9 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
755
800
  if (item.prepend !== undefined) updates.prepend = item.prepend;
756
801
  }
757
802
  if (item.path !== undefined) updates.path = item.path;
803
+ if (item.extension !== undefined) {
804
+ updates.extension = validateExtension(item.extension);
805
+ }
758
806
  if (item.metadata !== undefined) {
759
807
  // Merge metadata (don't replace wholesale)
760
808
  const existing = (note.metadata as Record<string, unknown>) ?? {};
@@ -1233,8 +1281,10 @@ function normalizeTags(tag: unknown): string[] | undefined {
1233
1281
  }
1234
1282
 
1235
1283
  // Re-exported for backward compat; defined in notes.ts alongside the
1236
- // conditional-UPDATE implementation that raises it.
1237
- export { ConflictError, PathConflictError, 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";
1238
1288
 
1239
1289
  /**
1240
1290
  * Thrown by the `update-note` MCP tool (and the REST PATCH handler) when a