@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.
@@ -2,17 +2,25 @@ import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
3
  import { rebuildIndexes } from "./indexed-fields.js";
4
4
 
5
- export const SCHEMA_VERSION = 17;
5
+ export const SCHEMA_VERSION = 18;
6
6
 
7
7
  export const SCHEMA_SQL = `
8
- -- Notes: the universal record
8
+ -- Notes: the universal record.
9
+ --
10
+ -- extension (v18, vault#328) carries the file suffix the note should
11
+ -- exhibit when serialized to disk — "md" by default, "csv"/"yaml"/"json"/
12
+ -- "mdx"/etc. for non-markdown notes. Stored extension-less in the path
13
+ -- column; on-disk uniqueness key is (path, extension). See
14
+ -- core/src/portable-md.ts:supportsInlineFrontmatter for the
15
+ -- frontmatter-vs-sidecar split.
9
16
  CREATE TABLE IF NOT EXISTS notes (
10
17
  id TEXT PRIMARY KEY,
11
18
  content TEXT DEFAULT '',
12
19
  path TEXT,
13
20
  metadata TEXT DEFAULT '{}',
14
21
  created_at TEXT NOT NULL,
15
- updated_at TEXT
22
+ updated_at TEXT,
23
+ extension TEXT NOT NULL DEFAULT 'md'
16
24
  );
17
25
 
18
26
  -- Tags: first-class identity carrying schema, hierarchy, and typed-link
@@ -266,6 +274,11 @@ export function initSchema(db: Database): void {
266
274
  // as `tags.fields` if needed. See vault#267.
267
275
  migrateToV17(db);
268
276
 
277
+ // Migrate v17 → v18: add `notes.extension TEXT NOT NULL DEFAULT 'md'`.
278
+ // Backward-compat by construction — every existing row defaults to "md"
279
+ // (markdown), unchanged in meaning. See vault#328.
280
+ migrateToV18(db);
281
+
269
282
  // Rebuild any generated columns + indexes declared in indexed_fields.
270
283
  // No-op for a fresh vault; idempotent on existing vaults.
271
284
  rebuildIndexes(db);
@@ -793,6 +806,51 @@ function migrateToV17(db: Database): void {
793
806
  }
794
807
  }
795
808
 
809
+ /**
810
+ * Migrate v17 → v18: add `notes.extension TEXT NOT NULL DEFAULT 'md'`
811
+ * (vault#328) AND widen the path-uniqueness index from `(path)` to
812
+ * `(path, extension)` so two notes can share a path differing only by
813
+ * extension (`Recipes/pasta` with both .md and .csv variants).
814
+ *
815
+ * Backward-compat by construction — every existing row defaults to "md",
816
+ * so the new composite-index uniqueness collapses to the v5
817
+ * "(path WHERE NOT NULL) is unique" behavior on existing data. Wrapped
818
+ * in BEGIN IMMEDIATE / COMMIT / ROLLBACK per the v14+ pattern.
819
+ */
820
+ function migrateToV18(db: Database): void {
821
+ if (!hasTable(db, "notes")) return;
822
+
823
+ // Two responsibilities (column add + index swap) — both idempotent
824
+ // individually. Wrap in one transaction so an upgrading v17 vault
825
+ // ends up at exactly v17 or v18, never partial.
826
+ const needsColumn = !hasColumn(db, "notes", "extension");
827
+ const indexes = db.prepare(
828
+ "SELECT name FROM sqlite_master WHERE type='index' AND name IN ('idx_notes_path_unique', 'idx_notes_path_ext_unique')",
829
+ ).all() as { name: string }[];
830
+ const hasOldUnique = indexes.some((r) => r.name === "idx_notes_path_unique");
831
+ const hasNewUnique = indexes.some((r) => r.name === "idx_notes_path_ext_unique");
832
+ if (!needsColumn && hasNewUnique && !hasOldUnique) return;
833
+
834
+ db.exec("BEGIN IMMEDIATE");
835
+ try {
836
+ if (needsColumn) {
837
+ db.exec("ALTER TABLE notes ADD COLUMN extension TEXT NOT NULL DEFAULT 'md'");
838
+ }
839
+ if (hasOldUnique) {
840
+ db.exec("DROP INDEX idx_notes_path_unique");
841
+ }
842
+ if (!hasNewUnique) {
843
+ db.exec(
844
+ "CREATE UNIQUE INDEX idx_notes_path_ext_unique ON notes(path, extension) WHERE path IS NOT NULL",
845
+ );
846
+ }
847
+ db.exec("COMMIT");
848
+ } catch (err) {
849
+ db.exec("ROLLBACK");
850
+ throw err;
851
+ }
852
+ }
853
+
796
854
  function hasTable(db: Database, name: string): boolean {
797
855
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
798
856
  return !!row;
package/core/src/store.ts CHANGED
@@ -92,7 +92,7 @@ export class BunSqliteStore implements Store {
92
92
 
93
93
  // ---- Notes ----
94
94
 
95
- async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
95
+ async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
96
96
  const note = noteOps.createNote(this.db, content, opts);
97
97
 
98
98
  if (content) {
@@ -113,8 +113,8 @@ export class BunSqliteStore implements Store {
113
113
  return noteOps.getNote(this.db, id);
114
114
  }
115
115
 
116
- async getNoteByPath(path: string): Promise<Note | null> {
117
- return noteOps.getNoteByPath(this.db, path);
116
+ async getNoteByPath(path: string, extension?: string): Promise<Note | null> {
117
+ return noteOps.getNoteByPath(this.db, path, extension);
118
118
  }
119
119
 
120
120
  async getNotes(ids: string[]): Promise<Note[]> {
@@ -128,6 +128,7 @@ export class BunSqliteStore implements Store {
128
128
  append?: string;
129
129
  prepend?: string;
130
130
  path?: string;
131
+ extension?: string;
131
132
  metadata?: Record<string, unknown>;
132
133
  created_at?: string;
133
134
  skipUpdatedAt?: boolean;
@@ -498,7 +499,7 @@ export class BunSqliteStore implements Store {
498
499
  * `syncAllWikilinks`, so adding the cache rebuild there is the natural
499
500
  * place.)
500
501
  */
501
- async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
502
+ async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
502
503
  return noteOps.createNote(this.db, content, opts);
503
504
  }
504
505
 
package/core/src/types.ts CHANGED
@@ -10,6 +10,14 @@ export interface Note {
10
10
  id: string;
11
11
  content: string;
12
12
  path?: string;
13
+ /**
14
+ * File extension (sans dot). Defaults to `"md"`. Controls the
15
+ * serialized file suffix on export — `.md`/`.mdx` carry frontmatter
16
+ * inline; `.csv`/`.yaml`/`.json`/etc. carry their metadata in a
17
+ * sidecar at `.parachute/notes-meta/<id>.yaml`. See vault#328 +
18
+ * `core/src/portable-md.ts:supportsInlineFrontmatter`.
19
+ */
20
+ extension?: string;
13
21
  metadata?: Record<string, unknown>;
14
22
  createdAt: string; // ISO-8601
15
23
  updatedAt?: string;
@@ -64,6 +72,13 @@ export interface QueryOpts {
64
72
  hasLinks?: boolean;
65
73
  path?: string; // exact path match (case-insensitive)
66
74
  pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
75
+ /**
76
+ * Filter by file extension. Pass a single extension (e.g. `"csv"`) or
77
+ * an array (e.g. `["csv", "yaml", "json"]`). Extension is compared
78
+ * lower-case. Notes default to `"md"` so `extension: "md"` matches
79
+ * the existing markdown corpus. See vault#328.
80
+ */
81
+ extension?: string | string[];
67
82
  // Restrict results to a specific set of note IDs. The MCP `near` query uses
68
83
  // this to push graph-neighborhood scoping into the SQL WHERE clause so that
69
84
  // LIMIT and ORDER BY apply to the filtered set, not the whole notes table.
@@ -107,6 +122,7 @@ export interface QueryOpts {
107
122
  export interface NoteSummary {
108
123
  id: string;
109
124
  path?: string;
125
+ extension?: string;
110
126
  metadata?: Record<string, unknown>;
111
127
  createdAt: string;
112
128
  updatedAt?: string;
@@ -120,6 +136,7 @@ export interface NoteSummary {
120
136
  export interface NoteIndex {
121
137
  id: string;
122
138
  path?: string;
139
+ extension?: string;
123
140
  createdAt: string;
124
141
  updatedAt?: string;
125
142
  tags?: string[];
@@ -138,11 +155,18 @@ export interface HydratedLink extends Link {
138
155
 
139
156
  export interface Store {
140
157
  // Notes
141
- createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note>;
158
+ createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note>;
142
159
  getNote(id: string): Promise<Note | null>;
143
- getNoteByPath(path: string): Promise<Note | null>;
160
+ /**
161
+ * Look up a note by path. Pass `extension` to disambiguate when
162
+ * multiple notes share a path differing only by extension (post-
163
+ * vault#328). When omitted and >1 row matches, throws
164
+ * `AmbiguousPathError` instead of silently picking one. See
165
+ * vault#330 S1.
166
+ */
167
+ getNoteByPath(path: string, extension?: string): Promise<Note | null>;
144
168
  getNotes(ids: string[]): Promise<Note[]>;
145
- updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
169
+ updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
146
170
  /**
147
171
  * Set a note's `created_at` and `updated_at` explicitly. Import-only:
148
172
  * used by the portable-md round-trip path to restore timestamps from
@@ -110,19 +110,57 @@ function stripCode(content: string): string {
110
110
  * Resolve a wikilink target to a note ID.
111
111
  *
112
112
  * Resolution order:
113
- * 1. Exact path match (case-insensitive)
114
- * 2. Basename match target matches the last segment of a path
115
- * (e.g., "README" matches "Projects/Parachute/README")
116
- * Only if there's exactly one match (ambiguous = unresolved)
113
+ * 1. **Explicit-extension target**: `[[Foo.csv]]` matches the note with
114
+ * `path: "Foo"` AND `extension: "csv"` (vault#328). Lets a reader
115
+ * disambiguate when multiple notes share a path differing only by
116
+ * extension. The trailing `.<ext>` must match a known
117
+ * alphanumeric pattern (1–16 chars) — otherwise the dot is treated
118
+ * as part of the path string and falls through to the existing
119
+ * rules.
120
+ * 2. Exact path match (case-insensitive) — multiple matches resolve to
121
+ * a single note when only ONE extension is in play; if `Foo.md` and
122
+ * `Foo.csv` both exist, the link refuses to resolve and the caller
123
+ * sees an unresolved-wikilink entry (vault#328 ambiguity policy).
124
+ * 3. Basename match — target matches the last segment of a path
125
+ * (e.g., "README" matches "Projects/Parachute/README"). Same
126
+ * cross-extension ambiguity policy as #2.
117
127
  */
118
128
  export function resolveWikilink(db: Database, target: string): string | null {
119
- // 1. Exact match (case-insensitive)
129
+ // 1. Explicit extension form: `[[path.ext]]` where `.ext` is a
130
+ // pattern-matching tail (lowercase alphanumeric, 1–16 chars).
131
+ // Mirrors EXTENSION_PATTERN in core/src/notes.ts so the wikilink
132
+ // parser and the API surface share the same notion of "this looks
133
+ // like an extension."
134
+ const extMatch = target.match(/^(.*)\.([a-z0-9]{1,16})$/i);
135
+ if (extMatch) {
136
+ const pathPart = extMatch[1]!;
137
+ const extPart = extMatch[2]!.toLowerCase();
138
+ const explicit = db.prepare(
139
+ "SELECT id FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
140
+ ).get(pathPart, extPart) as { id: string } | undefined;
141
+ if (explicit) return explicit.id;
142
+ // No match for explicit (path, ext) — fall through to the looser
143
+ // rules so a literal note named `Recipe.v2` (where `v2` isn't an
144
+ // extension on a real note) can still resolve under exact-path
145
+ // match.
146
+ }
147
+
148
+ // 2. Exact match (case-insensitive). When multiple notes share a path
149
+ // (post-vault#328 this happens when `Foo.md` and `Foo.csv` both
150
+ // exist, since path is stored extension-less), refuse to resolve.
120
151
  const exact = db.prepare(
121
152
  "SELECT id FROM notes WHERE path = ? COLLATE NOCASE",
122
- ).get(target) as { id: string } | undefined;
123
- if (exact) return exact.id;
153
+ ).all(target) as { id: string }[];
154
+ if (exact.length === 1) return exact[0]!.id;
155
+ if (exact.length > 1) {
156
+ // Ambiguous — refuse-and-require-explicit-extension policy
157
+ // (vault#328). Returning null routes through the existing
158
+ // unresolved-wikilinks workflow, so the reader (or the indexer)
159
+ // sees the conflict.
160
+ return null;
161
+ }
124
162
 
125
- // 2. Basename match — last path segment equals target
163
+ // 3. Basename match — last path segment equals target.
126
164
  // e.g., target "README" matches path "Projects/Parachute/README"
127
165
  const basename = db.prepare(`
128
166
  SELECT id FROM notes
@@ -150,17 +188,39 @@ export interface WikilinkResolution {
150
188
 
151
189
  /**
152
190
  * Resolve a wikilink target with full detail — single match, ambiguous, or unresolved.
191
+ * Mirrors `resolveWikilink`'s resolution order, including the explicit-
192
+ * extension form `[[Foo.csv]]` introduced by vault#328.
153
193
  */
154
194
  export function resolveWikilinkDetailed(db: Database, target: string): WikilinkResolution {
155
- // 1. Exact match (case-insensitive)
195
+ // 1. Explicit-extension form: `[[path.ext]]`.
196
+ const extMatch = target.match(/^(.*)\.([a-z0-9]{1,16})$/i);
197
+ if (extMatch) {
198
+ const pathPart = extMatch[1]!;
199
+ const extPart = extMatch[2]!.toLowerCase();
200
+ const explicit = db.prepare(
201
+ "SELECT id, path FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
202
+ ).get(pathPart, extPart) as { id: string; path: string } | undefined;
203
+ if (explicit) {
204
+ return { resolved: true, note_id: explicit.id, path: explicit.path, candidates: [] };
205
+ }
206
+ }
207
+
208
+ // 2. Exact path match (case-insensitive). Multiple matches → ambiguous.
156
209
  const exact = db.prepare(
157
- "SELECT id, path FROM notes WHERE path = ? COLLATE NOCASE",
158
- ).get(target) as { id: string; path: string } | undefined;
159
- if (exact) {
160
- return { resolved: true, note_id: exact.id, path: exact.path, candidates: [] };
210
+ "SELECT id, path, extension FROM notes WHERE path = ? COLLATE NOCASE",
211
+ ).all(target) as { id: string; path: string; extension: string }[];
212
+ if (exact.length === 1) {
213
+ return { resolved: true, note_id: exact[0]!.id, path: exact[0]!.path, candidates: [] };
214
+ }
215
+ if (exact.length > 1) {
216
+ return {
217
+ resolved: false,
218
+ ambiguous: true,
219
+ candidates: exact.map((r) => ({ note_id: r.id, path: r.path })),
220
+ };
161
221
  }
162
222
 
163
- // 2. Basename match
223
+ // 3. Basename match
164
224
  const basename = db.prepare(`
165
225
  SELECT id, path FROM notes
166
226
  WHERE path IS NOT NULL
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.4-rc.12",
3
+ "version": "0.4.5",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -2558,6 +2558,23 @@ async function cmdImport(args: string[]) {
2558
2558
  process.exit(1);
2559
2559
  }
2560
2560
 
2561
+ // Daemon-busy guard (vault#323): the import opens its own bun:sqlite
2562
+ // connection, but a running daemon already holds the writer lock. The
2563
+ // first createNote then trips SQLITE_BUSY mid-stream and leaves a
2564
+ // partially-replayed vault. Refuse with a clear error rather than
2565
+ // attempt-and-fail. WAL/concurrent-writer is a separate follow-up.
2566
+ const globalConfig = readGlobalConfig();
2567
+ const port = globalConfig.port || DEFAULT_PORT;
2568
+ const health = await checkHealth(port);
2569
+ if (health.status === "healthy" || health.status === "unhealthy") {
2570
+ console.error(
2571
+ `error: vault daemon is running on port ${port}; stop it first with:\n` +
2572
+ ` parachute stop vault\n` +
2573
+ `the import requires exclusive write access to the SQLite database.`,
2574
+ );
2575
+ process.exit(1);
2576
+ }
2577
+
2561
2578
  // Autodetect: portable-md export → `.parachute/vault.yaml` present.
2562
2579
  const isPortableMd = existsSync(join(fullPath, ".parachute", "vault.yaml"));
2563
2580
 
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Integration test for vault#323: `parachute-vault import` refuses to run
3
+ * when a daemon is bound to the configured port. The import opens its
4
+ * own bun:sqlite connection; concurrent writers would otherwise trip
5
+ * SQLITE_BUSY mid-replay and leave a partially-imported vault.
6
+ *
7
+ * Pre-flights checkHealth(port) before any DB write; "healthy" or
8
+ * "unhealthy" (port bound, any HTTP response) means the daemon owns the
9
+ * writer lock, so we exit 1 with a clear error pointing at
10
+ * `parachute stop vault`.
11
+ */
12
+
13
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
14
+ import { resolve } from "path";
15
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
16
+ import { tmpdir } from "os";
17
+ import { join } from "path";
18
+
19
+ const CLI = resolve(import.meta.dir, "cli.ts");
20
+
21
+ async function runCli(
22
+ args: string[],
23
+ env: Record<string, string>,
24
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
25
+ // Async spawn — the daemon-busy probe inside the CLI fetches the test
26
+ // process's stub server, so the parent event loop must keep servicing
27
+ // requests while the child runs. Bun.spawnSync blocks the event loop
28
+ // and the in-test server can't answer, which makes every probe time
29
+ // out into the "not listening" branch.
30
+ const proc = Bun.spawn({
31
+ cmd: ["bun", CLI, ...args],
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ env: { ...process.env, ...env },
35
+ });
36
+ const [stdout, stderr, exitCode] = await Promise.all([
37
+ new Response(proc.stdout).text(),
38
+ new Response(proc.stderr).text(),
39
+ proc.exited,
40
+ ]);
41
+ return { exitCode: exitCode ?? -1, stdout, stderr };
42
+ }
43
+
44
+ let home: string;
45
+ let srcDir: string;
46
+
47
+ beforeEach(() => {
48
+ home = mkdtempSync(join(tmpdir(), "import-daemon-busy-"));
49
+ srcDir = mkdtempSync(join(tmpdir(), "import-daemon-busy-src-"));
50
+ // Minimal portable-md export shape so autodetect picks the portable path.
51
+ mkdirSync(join(srcDir, ".parachute"), { recursive: true });
52
+ writeFileSync(
53
+ join(srcDir, ".parachute", "vault.yaml"),
54
+ "name: default\nexport_format_version: 1\n",
55
+ );
56
+ // Minimal vault tree so the "vault not found" guard doesn't short-circuit.
57
+ // PARACHUTE_HOME → <root>/vault/{config.yaml, data/<name>/vault.yaml}.
58
+ mkdirSync(join(home, "vault", "data", "default"), { recursive: true });
59
+ writeFileSync(
60
+ join(home, "vault", "data", "default", "vault.yaml"),
61
+ "name: default\n",
62
+ );
63
+ });
64
+
65
+ afterEach(() => {
66
+ rmSync(home, { recursive: true, force: true });
67
+ rmSync(srcDir, { recursive: true, force: true });
68
+ });
69
+
70
+ describe("import daemon-busy guard (vault#323)", () => {
71
+ test("refuses with clear error + exit 1 when a server is bound to the configured port", async () => {
72
+ // Stand up a stub server that responds on /health so checkHealth
73
+ // returns either `healthy` or `unhealthy` — both signal that the
74
+ // port is owned by something. Either case triggers the guard.
75
+ // Bind to 127.0.0.1 explicitly so the subprocess's checkHealth
76
+ // (which probes 127.0.0.1:<port>) hits the same interface.
77
+ const server = Bun.serve({
78
+ port: 0,
79
+ hostname: "127.0.0.1",
80
+ fetch: () => new Response(JSON.stringify({ status: "ok" })),
81
+ });
82
+ try {
83
+ mkdirSync(join(home, "vault"), { recursive: true });
84
+ writeFileSync(join(home, "vault", "config.yaml"), `port: ${server.port}\n`);
85
+ const res = await runCli(
86
+ ["import", srcDir, "--vault", "default", "--yes"],
87
+ { PARACHUTE_HOME: home },
88
+ );
89
+ expect(res.exitCode).toBe(1);
90
+ expect(res.stderr).toMatch(/vault daemon is running on port/);
91
+ expect(res.stderr).toMatch(/parachute stop vault/);
92
+ } finally {
93
+ server.stop(true);
94
+ }
95
+ });
96
+
97
+ test("proceeds past the guard when nothing is listening on the configured port", async () => {
98
+ // Pick a port nothing's bound to. The daemon-busy message must NOT
99
+ // surface — that's the regression we're pinning.
100
+ const freePort = 1; // privileged, nothing should be listening here from this process
101
+ mkdirSync(join(home, "vault"), { recursive: true });
102
+ writeFileSync(join(home, "vault", "config.yaml"), `port: ${freePort}\n`);
103
+ const res = await runCli(
104
+ ["import", srcDir, "--vault", "default", "--yes"],
105
+ { PARACHUTE_HOME: home },
106
+ );
107
+ expect(res.stderr).not.toMatch(/vault daemon is running on port/);
108
+ });
109
+ });
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
2
3
  import { handleViewNote } from "./routes.ts";
4
+ import { SqliteStore } from "../core/src/store.ts";
3
5
 
4
6
  // Redirect URL builder — mirrors the logic in server.ts
5
7
  function buildRedirectUrl(reqUrl: string, noteId: string, prefix = ""): string {
@@ -211,4 +213,19 @@ describe("handleViewNote", async () => {
211
213
  const resp = await handleViewNote(store, "n1", { publishedTag: "public" });
212
214
  expect(resp.status).toBe(404);
213
215
  });
216
+
217
+ it("returns 409 ambiguous_path when bare path resolves to multiple notes (vault#331 N1)", async () => {
218
+ // Real SqliteStore so the v18 composite (path, extension) uniqueness
219
+ // index lets two notes coexist at the same path with different
220
+ // extensions — the scenario AmbiguousPathError is built for.
221
+ const store = new SqliteStore(new Database(":memory:"));
222
+ await store.createNote("# md", { id: "vn-md", path: "Foo", tags: ["publish"] });
223
+ await store.createNote("a,b\n1,2", { id: "vn-csv", path: "Foo", extension: "csv", tags: ["publish"] });
224
+ const resp = await handleViewNote(store, "Foo", { authenticated: true });
225
+ expect(resp.status).toBe(409);
226
+ const body = await resp.json() as any;
227
+ expect(body.error_type).toBe("ambiguous_path");
228
+ expect(body.path).toBe("Foo");
229
+ expect(body.candidates).toHaveLength(2);
230
+ });
214
231
  });