@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.
- package/core/src/core.test.ts +221 -0
- package/core/src/mcp.ts +56 -6
- package/core/src/notes.ts +185 -15
- package/core/src/portable-md.test.ts +531 -0
- package/core/src/portable-md.ts +508 -19
- package/core/src/schema.ts +61 -3
- package/core/src/store.ts +5 -4
- package/core/src/types.ts +27 -3
- package/core/src/wikilinks.ts +74 -14
- package/package.json +1 -1
- package/src/published.test.ts +17 -0
- package/src/routes.ts +121 -3
- package/src/vault.test.ts +175 -0
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
-
|
|
309
|
-
|
|
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
|