@openparachute/vault 0.4.4-rc.12 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/src/notes.ts CHANGED
@@ -29,22 +29,22 @@ 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
- // Empty-note invariant (#213): reject `content+path both absent`. Three
40
- // legit shapes content-only, path-only, both — only the empty+empty
41
- // combo is the runaway-client signature that flooded a deployment with
42
- // 7,453 pathless empty notes in one MCP burst. `content` only is a
43
- // legitimate un-pathed jot; `path` only is a wikilink placeholder or
44
- // `_schemas/*` config note.
45
- if (!content.trim() && path === null) {
46
- throw new EmptyNoteError();
47
- }
43
+ // Empty content is a valid state (vault#323): skeleton notes, drafts
44
+ // saved before content, organizing-only notes, capture-then-fill flows.
45
+ // The earlier #213 guard rejected `content + path both absent`; we no
46
+ // longer enforce it because real vaults legitimately carry such rows
47
+ // and the round-trip import has to accept them.
48
48
 
49
49
  // `updated_at` is set to `created_at` on insert so a client whose optimistic
50
50
  // concurrency check falls back to `createdAt` on a never-updated note
@@ -54,8 +54,8 @@ export function createNote(
54
54
  // "user-touched since creation."
55
55
  try {
56
56
  db.prepare(
57
- `INSERT INTO notes (id, content, path, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
58
- ).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);
59
59
  } catch (err) {
60
60
  if (path !== null && isPathUniqueError(err)) {
61
61
  throw new PathConflictError(path);
@@ -79,13 +79,49 @@ export function getNote(db: Database, id: string): Note | null {
79
79
  return note;
80
80
  }
81
81
 
82
- export function getNoteByPath(db: Database, path: string): Note | null {
83
- const row = db.prepare("SELECT * FROM notes WHERE path = ?").get(path) as NoteRow | undefined;
84
- 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
+ }
85
108
 
86
- const note = rowToNote(row);
87
- note.tags = getNoteTags(db, note.id);
88
- 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
+ );
89
125
  }
90
126
 
91
127
  export function getNotes(db: Database, ids: string[]): Note[] {
@@ -147,6 +183,31 @@ export class PathConflictError extends Error {
147
183
  }
148
184
  }
149
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
+
150
211
  /**
151
212
  * Per-call item cap on `createNote`/`updateNote` batch entry points
152
213
  * (MCP `create-note` / `update-note` and HTTP `POST /api/notes`).
@@ -157,43 +218,72 @@ export class PathConflictError extends Error {
157
218
  export const MAX_BATCH_SIZE = 500;
158
219
 
159
220
  /**
160
- * Thrown by `createNote` / `updateNote` when the proposed note state has
161
- * neither content nor path. The vault accepts un-pathed jots (content only)
162
- * and path-only placeholders (wikilink stubs, `_schemas/*`), but a note
163
- * with neither is the runaway-client signature flagged in #213 — one MCP
164
- * burst flooded a deployment with 7,453 empty pathless rows. Surfaces as
165
- * 400 at the HTTP layer.
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.
166
234
  */
167
- export class EmptyNoteError extends Error {
168
- code = "EMPTY_NOTE" as const;
169
- note_id: string | null;
170
- /**
171
- * Zero-based position in a batch call when the empty entry is rejected via
172
- * the transport-layer pre-validation pass (HTTP `POST /api/notes` or MCP
173
- * `create-note` with `notes: [...]`). `null` for single-update rejections
174
- * and for Store-level throws that don't know their batch context.
175
- */
176
- item_index: number | null;
177
-
178
- constructor(noteId: string | null = null, itemIndex: number | null = null) {
179
- super(
180
- noteId
181
- ? `empty_note: update would leave note "${noteId}" with neither content nor path`
182
- : itemIndex !== null
183
- ? `empty_note: a note must have either content or a path (item index ${itemIndex})`
184
- : `empty_note: a note must have either content or a path`,
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",
185
276
  );
186
- this.name = "EmptyNoteError";
187
- this.note_id = noteId;
188
- this.item_index = itemIndex;
189
277
  }
278
+ return extension;
190
279
  }
191
280
 
192
281
  /**
193
- * Match bun:sqlite's UNIQUE-constraint error on the notes.path index. The
194
- * error class is `SQLiteError` but matching on the message is sufficient
195
- * here — the index name and column are stable parts of the schema, and
196
- * bun:sqlite has carried this exact message text since 1.0.
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.
197
287
  */
198
288
  function isPathUniqueError(err: unknown): boolean {
199
289
  if (!(err instanceof Error)) return false;
@@ -219,6 +309,7 @@ export function updateNote(
219
309
  */
220
310
  prepend?: string;
221
311
  path?: string;
312
+ extension?: string;
222
313
  metadata?: Record<string, unknown>;
223
314
  created_at?: string;
224
315
  skipUpdatedAt?: boolean;
@@ -236,36 +327,9 @@ export function updateNote(
236
327
  );
237
328
  }
238
329
 
239
- // Empty-note invariant (#213): when this update touches content or path,
240
- // reject if the post-state would be empty content + null path. We only
241
- // enforce on transitions that actually touch the relevant fields, so
242
- // metadata-only or tag-only updates against legacy empty rows still pass.
243
- // Hook-style writes (skipUpdatedAt) are exempted — they're machine-level
244
- // marker writes that legitimately may run against any shape of row.
245
- const touchesContent = updates.content !== undefined
246
- || updates.append !== undefined
247
- || updates.prepend !== undefined;
248
- const touchesPath = updates.path !== undefined;
249
- if ((touchesContent || touchesPath) && !updates.skipUpdatedAt) {
250
- const current = getNote(db, id);
251
- if (current) {
252
- let finalContent: string;
253
- if (updates.content !== undefined) {
254
- finalContent = updates.content;
255
- } else if (touchesContent) {
256
- finalContent = (updates.prepend ?? "") + current.content + (updates.append ?? "");
257
- } else {
258
- finalContent = current.content;
259
- }
260
- const finalPath = touchesPath ? normalizePath(updates.path) : (current.path ?? null);
261
- if (!finalContent.trim() && !finalPath) {
262
- throw new EmptyNoteError(id);
263
- }
264
- }
265
- // If `current` is null we fall through — existing code paths handle the
266
- // missing-row case downstream (the conditional UPDATE returns 0 rows;
267
- // OC throws ConflictError; non-OC returns silently).
268
- }
330
+ // Empty content is a valid state (vault#323) see createNote. The
331
+ // matching guard that used to reject updates clearing both content
332
+ // and path has been removed.
269
333
 
270
334
  const sets: string[] = [];
271
335
  const values: (string | null)[] = [];
@@ -324,6 +388,14 @@ export function updateNote(
324
388
  sets.push("path = ?");
325
389
  values.push(normalizePath(updates.path));
326
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
+ }
327
399
  if (updates.metadata !== undefined) {
328
400
  sets.push("metadata = ?");
329
401
  values.push(JSON.stringify(updates.metadata));
@@ -369,8 +441,14 @@ export function updateNote(
369
441
  db.prepare(sql).run(...values);
370
442
  }
371
443
  } catch (err) {
372
- if (updates.path !== undefined && isPathUniqueError(err)) {
373
- 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);
374
452
  }
375
453
  throw err;
376
454
  }
@@ -488,6 +566,26 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
488
566
  params.push(opts.pathPrefix + "%");
489
567
  }
490
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
+
491
589
  // Metadata filters — operator objects route through the indexed generated
492
590
  // column (fast, loud errors on non-indexed fields); primitives keep the
493
591
  // existing JSON-scan exact-match behavior for backcompat.
@@ -1186,6 +1284,7 @@ export function toNoteIndex(note: Note): NoteIndex {
1186
1284
  return {
1187
1285
  id: note.id,
1188
1286
  path: note.path,
1287
+ extension: note.extension,
1189
1288
  createdAt: note.createdAt,
1190
1289
  updatedAt: note.updatedAt,
1191
1290
  tags: note.tags,
@@ -1293,6 +1392,7 @@ export interface BulkNoteInput {
1293
1392
  tags?: string[];
1294
1393
  metadata?: Record<string, unknown>;
1295
1394
  created_at?: string;
1395
+ extension?: string;
1296
1396
  }
1297
1397
 
1298
1398
  export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
@@ -1308,6 +1408,7 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
1308
1408
  tags: input.tags,
1309
1409
  metadata: input.metadata,
1310
1410
  created_at: input.created_at,
1411
+ extension: input.extension,
1311
1412
  }),
1312
1413
  );
1313
1414
  }
@@ -1375,6 +1476,7 @@ interface NoteRow {
1375
1476
  metadata: string | null;
1376
1477
  created_at: string;
1377
1478
  updated_at: string | null;
1479
+ extension: string | null;
1378
1480
  }
1379
1481
 
1380
1482
  function rowToNote(row: NoteRow): Note {
@@ -1386,6 +1488,10 @@ function rowToNote(row: NoteRow): Note {
1386
1488
  id: row.id,
1387
1489
  content: row.content,
1388
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",
1389
1495
  metadata,
1390
1496
  createdAt: row.created_at,
1391
1497
  // Legacy notes (pre-#70) may have NULL updated_at. Fall back to created_at