@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/core.test.ts +348 -60
- package/core/src/mcp.ts +61 -32
- package/core/src/notes.ts +187 -81
- package/core/src/portable-md.test.ts +554 -1
- 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/cli.ts +17 -0
- package/src/import-daemon-busy.test.ts +109 -0
- package/src/published.test.ts +17 -0
- package/src/routes.ts +136 -48
- package/src/vault.test.ts +294 -32
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
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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
|
-
|
|
373
|
-
|
|
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
|