@openparachute/vault 0.4.4-rc.14 → 0.4.6-rc.3

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/notes.ts CHANGED
@@ -29,12 +29,16 @@ export function generateId(): string {
29
29
  export function createNote(
30
30
  db: Database,
31
31
  content: string,
32
- opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string },
32
+ opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string },
33
33
  ): Note {
34
34
  const id = opts?.id ?? generateId();
35
35
  const createdAt = opts?.created_at ?? new Date().toISOString();
36
36
  const metadata = opts?.metadata ? JSON.stringify(opts.metadata) : "{}";
37
37
  const path = normalizePath(opts?.path);
38
+ // `extension` defaults to "md" so existing callers see no change.
39
+ // Validation happens at the API surface (MCP/REST) — the Store accepts
40
+ // whatever the caller passed; importer paths trust the export's shape.
41
+ const extension = opts?.extension ?? "md";
38
42
 
39
43
  // Empty content is a valid state (vault#323): skeleton notes, drafts
40
44
  // saved before content, organizing-only notes, capture-then-fill flows.
@@ -50,8 +54,8 @@ export function createNote(
50
54
  // "user-touched since creation."
51
55
  try {
52
56
  db.prepare(
53
- `INSERT INTO notes (id, content, path, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
54
- ).run(id, content, path, metadata, createdAt, createdAt);
57
+ `INSERT INTO notes (id, content, path, metadata, created_at, updated_at, extension) VALUES (?, ?, ?, ?, ?, ?, ?)`,
58
+ ).run(id, content, path, metadata, createdAt, createdAt, extension);
55
59
  } catch (err) {
56
60
  if (path !== null && isPathUniqueError(err)) {
57
61
  throw new PathConflictError(path);
@@ -75,13 +79,49 @@ export function getNote(db: Database, id: string): Note | null {
75
79
  return note;
76
80
  }
77
81
 
78
- export function getNoteByPath(db: Database, path: string): Note | null {
79
- const row = db.prepare("SELECT * FROM notes WHERE path = ?").get(path) as NoteRow | undefined;
80
- if (!row) return null;
82
+ /**
83
+ * Look up a note by `path`. When `extension` is provided, the lookup
84
+ * matches the `(path, extension)` tuple — exactly one row, since v18's
85
+ * composite uniqueness index makes that combo unique. When `extension`
86
+ * is omitted:
87
+ *
88
+ * - 0 matches → return null (back-compat).
89
+ * - 1 match → return it (back-compat).
90
+ * - >1 match → throw `AmbiguousPathError` (vault#330 S1). Caller
91
+ * must pass `extension` explicitly to disambiguate. Mirrors the
92
+ * wikilink ambiguity policy from vault#328 edge case 3 —
93
+ * path-as-key lookup is "(path, extension) tuple" everywhere.
94
+ *
95
+ * Path match is case-insensitive (`COLLATE NOCASE`) — matches the v5
96
+ * uniqueness contract and how wikilinks resolve.
97
+ */
98
+ export function getNoteByPath(db: Database, path: string, extension?: string): Note | null {
99
+ if (extension !== undefined) {
100
+ const row = db.prepare(
101
+ "SELECT * FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
102
+ ).get(path, extension.toLowerCase()) as NoteRow | undefined;
103
+ if (!row) return null;
104
+ const note = rowToNote(row);
105
+ note.tags = getNoteTags(db, note.id);
106
+ return note;
107
+ }
81
108
 
82
- const note = rowToNote(row);
83
- note.tags = getNoteTags(db, note.id);
84
- return note;
109
+ // No extension given. The composite-unique index lets two rows share
110
+ // a path differing only by extension, so we have to look at all matches
111
+ // before returning.
112
+ const rows = db.prepare(
113
+ "SELECT * FROM notes WHERE path = ? COLLATE NOCASE",
114
+ ).all(path) as NoteRow[];
115
+ if (rows.length === 0) return null;
116
+ if (rows.length === 1) {
117
+ const note = rowToNote(rows[0]!);
118
+ note.tags = getNoteTags(db, note.id);
119
+ return note;
120
+ }
121
+ throw new AmbiguousPathError(
122
+ path,
123
+ rows.map((r) => ({ id: r.id, extension: r.extension ?? "md" })),
124
+ );
85
125
  }
86
126
 
87
127
  export function getNotes(db: Database, ids: string[]): Note[] {
@@ -143,6 +183,31 @@ export class PathConflictError extends Error {
143
183
  }
144
184
  }
145
185
 
186
+ /**
187
+ * Thrown by `getNoteByPath` when the caller looked up a path that has
188
+ * multiple notes (post-vault#328: same path, different extensions),
189
+ * without specifying which extension to disambiguate. Aligns the
190
+ * path-as-key lookup contract with the wikilink ambiguity policy
191
+ * (vault#328 edge case 3): refuse-and-require-explicit-extension.
192
+ *
193
+ * Surfaces structured fields so callers (MCP `resolveNote`, REST
194
+ * path-as-id handlers) can convert to a clear 409 / 4xx with the list
195
+ * of candidate extensions. See vault#330 S1.
196
+ */
197
+ export class AmbiguousPathError extends Error {
198
+ code = "AMBIGUOUS_PATH" as const;
199
+ path: string;
200
+ candidates: { id: string; extension: string }[];
201
+
202
+ constructor(path: string, candidates: { id: string; extension: string }[]) {
203
+ const list = candidates.map((c) => c.extension).join(", ");
204
+ super(`ambiguous_path: "${path}" matches ${candidates.length} notes (extensions: ${list}); pass \`extension\` to disambiguate`);
205
+ this.name = "AmbiguousPathError";
206
+ this.path = path;
207
+ this.candidates = candidates;
208
+ }
209
+ }
210
+
146
211
  /**
147
212
  * Per-call item cap on `createNote`/`updateNote` batch entry points
148
213
  * (MCP `create-note` / `update-note` and HTTP `POST /api/notes`).
@@ -153,10 +218,72 @@ export class PathConflictError extends Error {
153
218
  export const MAX_BATCH_SIZE = 500;
154
219
 
155
220
  /**
156
- * Match bun:sqlite's UNIQUE-constraint error on the notes.path index. The
157
- * error class is `SQLiteError` but matching on the message is sufficient
158
- * herethe index name and column are stable parts of the schema, and
159
- * bun:sqlite has carried this exact message text since 1.0.
221
+ * Validate a caller-supplied file extension (vault#328). Rules:
222
+ * 1. Non-empty, lowercase alphanumeric only.
223
+ * 2. Length 1–16 long enough for "markdown" etc., short enough to
224
+ * bound on-disk filename length.
225
+ * 3. No dot/slash/uppercase — those would create path-encoding
226
+ * ambiguity or collide with filesystem separators.
227
+ * 4. Reserved: anything matching /^parachute/i is refused because the
228
+ * `.parachute/` sidecar dir convention owns that namespace; a note
229
+ * with extension `parachute` would write to `<path>.parachute`
230
+ * which is ambiguous with a directory entry.
231
+ *
232
+ * Throws `ExtensionValidationError` on failure. Both MCP and REST
233
+ * surfaces import this so the contract can never drift between them.
234
+ */
235
+ export const EXTENSION_PATTERN = /^[a-z0-9]{1,16}$/;
236
+
237
+ export class ExtensionValidationError extends Error {
238
+ code = "INVALID_EXTENSION" as const;
239
+ extension: string;
240
+ reason: string;
241
+
242
+ constructor(extension: string, reason: string) {
243
+ super(`invalid extension "${extension}": ${reason}`);
244
+ this.name = "ExtensionValidationError";
245
+ this.extension = extension;
246
+ this.reason = reason;
247
+ }
248
+ }
249
+
250
+ export function validateExtension(extension: unknown): string {
251
+ if (typeof extension !== "string") {
252
+ throw new ExtensionValidationError(
253
+ String(extension),
254
+ `must be a string (got ${typeof extension})`,
255
+ );
256
+ }
257
+ if (extension.length === 0) {
258
+ throw new ExtensionValidationError(
259
+ extension,
260
+ "must be non-empty; omit the field entirely to default to 'md'",
261
+ );
262
+ }
263
+ if (!EXTENSION_PATTERN.test(extension)) {
264
+ throw new ExtensionValidationError(
265
+ extension,
266
+ `must match ${EXTENSION_PATTERN.source} (lowercase alphanumeric, 1–16 chars; no '.', '/', or uppercase)`,
267
+ );
268
+ }
269
+ // Reserved namespace: anything that starts with "parachute" collides
270
+ // with the .parachute/ sidecar directory convention. The pattern check
271
+ // above already enforces lowercase, so a literal prefix match is exact.
272
+ if (extension.startsWith("parachute")) {
273
+ throw new ExtensionValidationError(
274
+ extension,
275
+ "the 'parachute' prefix is reserved for the .parachute/ sidecar dir",
276
+ );
277
+ }
278
+ return extension;
279
+ }
280
+
281
+ /**
282
+ * Match bun:sqlite's UNIQUE-constraint error on the notes path index.
283
+ * Post-vault#328 the unique index is composite `(path, extension)`, so
284
+ * the message text is "UNIQUE constraint failed: notes.path,
285
+ * notes.extension". Pre-v18 (legacy `(path)` index) emitted just
286
+ * "notes.path". Match on the common prefix to cover both.
160
287
  */
161
288
  function isPathUniqueError(err: unknown): boolean {
162
289
  if (!(err instanceof Error)) return false;
@@ -182,6 +309,7 @@ export function updateNote(
182
309
  */
183
310
  prepend?: string;
184
311
  path?: string;
312
+ extension?: string;
185
313
  metadata?: Record<string, unknown>;
186
314
  created_at?: string;
187
315
  skipUpdatedAt?: boolean;
@@ -260,6 +388,14 @@ export function updateNote(
260
388
  sets.push("path = ?");
261
389
  values.push(normalizePath(updates.path));
262
390
  }
391
+ if (updates.extension !== undefined) {
392
+ // Allowed but documented as caller-owned (vault#328 edge case 1):
393
+ // the Store accepts whatever the API surface validated, including
394
+ // changing extension on a non-empty note. The caller is responsible
395
+ // for content validity post-change.
396
+ sets.push("extension = ?");
397
+ values.push(updates.extension);
398
+ }
263
399
  if (updates.metadata !== undefined) {
264
400
  sets.push("metadata = ?");
265
401
  values.push(JSON.stringify(updates.metadata));
@@ -305,8 +441,14 @@ export function updateNote(
305
441
  db.prepare(sql).run(...values);
306
442
  }
307
443
  } catch (err) {
308
- if (updates.path !== undefined && isPathUniqueError(err)) {
309
- throw new PathConflictError(normalizePath(updates.path) ?? updates.path);
444
+ // Post-vault#328 the unique index is composite (path, extension), so
445
+ // an extension-only update can also trip UNIQUE — widen the catch to
446
+ // surface those as structured PATH_CONFLICT instead of a raw 500.
447
+ if (isPathUniqueError(err)) {
448
+ const conflictPath = updates.path !== undefined
449
+ ? (normalizePath(updates.path) ?? updates.path)
450
+ : ((db.prepare("SELECT path FROM notes WHERE id = ?").get(id) as { path: string | null } | undefined)?.path ?? "<unknown>");
451
+ throw new PathConflictError(conflictPath);
310
452
  }
311
453
  throw err;
312
454
  }
@@ -424,6 +566,26 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
424
566
  params.push(opts.pathPrefix + "%");
425
567
  }
426
568
 
569
+ // Extension filter (vault#328). Single string → exact match; array → IN
570
+ // clause. Compared lower-case so a caller passing "CSV" still hits rows
571
+ // stored as "csv". An empty array is a no-op (no filter applied) rather
572
+ // than a "no rows match" short-circuit — matches the spirit of the
573
+ // existing `tags: []` behavior.
574
+ if (opts.extension !== undefined) {
575
+ const exts = Array.isArray(opts.extension) ? opts.extension : [opts.extension];
576
+ const cleaned = exts
577
+ .filter((e): e is string => typeof e === "string" && e.length > 0)
578
+ .map((e) => e.toLowerCase());
579
+ if (cleaned.length === 1) {
580
+ conditions.push("LOWER(n.extension) = ?");
581
+ params.push(cleaned[0]!);
582
+ } else if (cleaned.length > 1) {
583
+ const placeholders = cleaned.map(() => "?").join(", ");
584
+ conditions.push(`LOWER(n.extension) IN (${placeholders})`);
585
+ params.push(...cleaned);
586
+ }
587
+ }
588
+
427
589
  // Metadata filters — operator objects route through the indexed generated
428
590
  // column (fast, loud errors on non-indexed fields); primitives keep the
429
591
  // existing JSON-scan exact-match behavior for backcompat.
@@ -1122,6 +1284,7 @@ export function toNoteIndex(note: Note): NoteIndex {
1122
1284
  return {
1123
1285
  id: note.id,
1124
1286
  path: note.path,
1287
+ extension: note.extension,
1125
1288
  createdAt: note.createdAt,
1126
1289
  updatedAt: note.updatedAt,
1127
1290
  tags: note.tags,
@@ -1229,6 +1392,7 @@ export interface BulkNoteInput {
1229
1392
  tags?: string[];
1230
1393
  metadata?: Record<string, unknown>;
1231
1394
  created_at?: string;
1395
+ extension?: string;
1232
1396
  }
1233
1397
 
1234
1398
  export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
@@ -1244,6 +1408,7 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
1244
1408
  tags: input.tags,
1245
1409
  metadata: input.metadata,
1246
1410
  created_at: input.created_at,
1411
+ extension: input.extension,
1247
1412
  }),
1248
1413
  );
1249
1414
  }
@@ -1311,6 +1476,7 @@ interface NoteRow {
1311
1476
  metadata: string | null;
1312
1477
  created_at: string;
1313
1478
  updated_at: string | null;
1479
+ extension: string | null;
1314
1480
  }
1315
1481
 
1316
1482
  function rowToNote(row: NoteRow): Note {
@@ -1322,6 +1488,10 @@ function rowToNote(row: NoteRow): Note {
1322
1488
  id: row.id,
1323
1489
  content: row.content,
1324
1490
  path: row.path ?? undefined,
1491
+ // `extension` is NOT NULL DEFAULT 'md' in v18+, but rows under a v17
1492
+ // migration window might briefly read as NULL. Fall back to "md" so
1493
+ // callers never see a missing extension.
1494
+ extension: row.extension ?? "md",
1325
1495
  metadata,
1326
1496
  createdAt: row.created_at,
1327
1497
  // Legacy notes (pre-#70) may have NULL updated_at. Fall back to created_at