@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/src/routes.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Store, Note } from "../core/src/types.ts";
|
|
15
15
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
16
|
-
import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
|
|
16
|
+
import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
17
17
|
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
18
18
|
import * as linkOps from "../core/src/links.ts";
|
|
19
19
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
@@ -75,6 +75,25 @@ function parseQueryList(url: URL, key: string): string[] | undefined {
|
|
|
75
75
|
return val ? val.split(",") : undefined;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Parse the extension query parameter (vault#328). Two accepted shapes:
|
|
80
|
+
* - `?extension=csv` (single value → string)
|
|
81
|
+
* - `?extension=csv&extension=yaml` OR `?extension=csv,yaml`
|
|
82
|
+
* (repeated or comma-list → array)
|
|
83
|
+
* Returns undefined when absent so the queryNotes filter is skipped.
|
|
84
|
+
* Validation lives at the engine layer — bad strings result in zero
|
|
85
|
+
* matches rather than 400, mirroring how `path` works.
|
|
86
|
+
*/
|
|
87
|
+
function parseExtensionFilter(url: URL): string | string[] | undefined {
|
|
88
|
+
const all = url.searchParams.getAll("extension");
|
|
89
|
+
if (all.length === 0) return undefined;
|
|
90
|
+
// Flatten comma-lists inside each param.
|
|
91
|
+
const flat = all.flatMap((v) => v.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
92
|
+
if (flat.length === 0) return undefined;
|
|
93
|
+
if (flat.length === 1) return flat[0]!;
|
|
94
|
+
return flat;
|
|
95
|
+
}
|
|
96
|
+
|
|
78
97
|
function parseInt10(val: string | null): number | undefined {
|
|
79
98
|
if (!val) return undefined;
|
|
80
99
|
const n = parseInt(val, 10);
|
|
@@ -373,11 +392,21 @@ function parseExpandParams(
|
|
|
373
392
|
|
|
374
393
|
|
|
375
394
|
/**
|
|
376
|
-
* Resolve a note by ID or path. Tries ID first, then case-insensitive
|
|
395
|
+
* Resolve a note by ID or path. Tries ID first, then case-insensitive
|
|
396
|
+
* path. A trailing `.<ext>` matching the extension pattern is parsed
|
|
397
|
+
* as `(path, extension)` to disambiguate notes sharing a path
|
|
398
|
+
* differing only by extension (vault#330 S1). When the path is
|
|
399
|
+
* ambiguous and no extension hint is supplied, `getNoteByPath` throws
|
|
400
|
+
* `AmbiguousPathError` — REST handlers catch it and return 409.
|
|
377
401
|
*/
|
|
378
402
|
async function resolveNote(store: Store, idOrPath: string): Promise<Note | null> {
|
|
379
403
|
const byId = await store.getNote(idOrPath);
|
|
380
404
|
if (byId) return byId;
|
|
405
|
+
const extMatch = idOrPath.match(/^(.*)\.([a-z0-9]{1,16})$/i);
|
|
406
|
+
if (extMatch) {
|
|
407
|
+
const explicit = await store.getNoteByPath(extMatch[1]!, extMatch[2]!);
|
|
408
|
+
if (explicit) return explicit;
|
|
409
|
+
}
|
|
381
410
|
return await store.getNoteByPath(idOrPath);
|
|
382
411
|
}
|
|
383
412
|
|
|
@@ -398,12 +427,49 @@ class NotFoundError extends Error {
|
|
|
398
427
|
// Notes — GET/POST/PATCH/DELETE /api/notes[/:idOrPath]
|
|
399
428
|
// ---------------------------------------------------------------------------
|
|
400
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Convert a thrown `AmbiguousPathError` (vault#330 S1) into a structured
|
|
432
|
+
* 409 JSON response. Shared by every handler that calls `resolveNote`
|
|
433
|
+
* with a user-supplied path — handleNotes, handleFindPath,
|
|
434
|
+
* handleViewNote. Returns null when the error isn't an
|
|
435
|
+
* AmbiguousPathError so the caller can re-throw / fall through.
|
|
436
|
+
*/
|
|
437
|
+
function ambiguousPathResponse(e: any): Response | null {
|
|
438
|
+
if (!e || e.code !== "AMBIGUOUS_PATH") return null;
|
|
439
|
+
return json(
|
|
440
|
+
{
|
|
441
|
+
error_type: "ambiguous_path",
|
|
442
|
+
error: "ambiguous_path",
|
|
443
|
+
path: e.path,
|
|
444
|
+
candidates: e.candidates,
|
|
445
|
+
message: e.message,
|
|
446
|
+
},
|
|
447
|
+
409,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
401
451
|
export async function handleNotes(
|
|
402
452
|
req: Request,
|
|
403
453
|
store: Store,
|
|
404
454
|
subpath: string,
|
|
405
455
|
vault?: string,
|
|
406
456
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
457
|
+
): Promise<Response> {
|
|
458
|
+
try {
|
|
459
|
+
return await handleNotesInner(req, store, subpath, vault, tagScope);
|
|
460
|
+
} catch (e: any) {
|
|
461
|
+
const ambig = ambiguousPathResponse(e);
|
|
462
|
+
if (ambig) return ambig;
|
|
463
|
+
throw e;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function handleNotesInner(
|
|
468
|
+
req: Request,
|
|
469
|
+
store: Store,
|
|
470
|
+
subpath: string,
|
|
471
|
+
vault?: string,
|
|
472
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
407
473
|
): Promise<Response> {
|
|
408
474
|
const url = new URL(req.url);
|
|
409
475
|
const method = req.method;
|
|
@@ -508,6 +574,12 @@ export async function handleNotes(
|
|
|
508
574
|
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
509
575
|
path: parseQuery(url, "path") ?? undefined,
|
|
510
576
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
577
|
+
// Extension filter (vault#328). Accepts repeated `extension=`
|
|
578
|
+
// params for the array form: `?extension=csv&extension=yaml`.
|
|
579
|
+
// `parseQueryList` already returns undefined when no params
|
|
580
|
+
// are present, so the filter is silently skipped on a plain
|
|
581
|
+
// GET without the extension query.
|
|
582
|
+
extension: parseExtensionFilter(url),
|
|
511
583
|
metadata: bracket.metadata,
|
|
512
584
|
// Date-range precedence chain (highest to lowest):
|
|
513
585
|
// 1. Bracket-style `meta[created_at][gte]=…` (canonical).
|
|
@@ -636,35 +708,10 @@ export async function handleNotes(
|
|
|
636
708
|
);
|
|
637
709
|
}
|
|
638
710
|
|
|
639
|
-
// Empty-note pre-validation (#213): walk the batch first and reject the
|
|
640
|
-
// whole request if any item would be content+path empty. This makes
|
|
641
|
-
// mixed batches atomic for the empty-note case — no caller gets a
|
|
642
|
-
// half-applied batch where the prefix landed and the empty entry
|
|
643
|
-
// surfaced the 400. Mirrors the Store-level invariant exactly.
|
|
644
|
-
for (let i = 0; i < items.length; i++) {
|
|
645
|
-
const item = items[i];
|
|
646
|
-
const content = (item?.content ?? "").toString();
|
|
647
|
-
const rawPath = item?.path;
|
|
648
|
-
const pathEmpty = rawPath === undefined || rawPath === null
|
|
649
|
-
|| (typeof rawPath === "string" && rawPath.trim() === "");
|
|
650
|
-
if (!content.trim() && pathEmpty) {
|
|
651
|
-
return json(
|
|
652
|
-
{
|
|
653
|
-
error_type: "empty_note",
|
|
654
|
-
error: "EmptyNoteError",
|
|
655
|
-
message: `empty_note: a note must have either content or a path (item index ${i})`,
|
|
656
|
-
item_index: i,
|
|
657
|
-
},
|
|
658
|
-
400,
|
|
659
|
-
);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
711
|
// Tag-scope pre-validation: every new note in the batch must carry at
|
|
664
|
-
// least one tag inside the token's allowlist.
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
// batch with an in-scope prefix.
|
|
712
|
+
// least one tag inside the token's allowlist. Reject the whole request
|
|
713
|
+
// before any DB write so a tag-scoped token can't accidentally land a
|
|
714
|
+
// partial batch with an in-scope prefix.
|
|
668
715
|
if (tagScope.allowed) {
|
|
669
716
|
for (let i = 0; i < items.length; i++) {
|
|
670
717
|
if (!tagsWithinScope(items[i]?.tags, tagScope.allowed, tagScope.raw)) {
|
|
@@ -685,12 +732,19 @@ export async function handleNotes(
|
|
|
685
732
|
if (batched) db.exec("BEGIN");
|
|
686
733
|
try {
|
|
687
734
|
for (const item of items) {
|
|
735
|
+
// Validate extension before reaching the Store (vault#328).
|
|
736
|
+
// Thrown inside the BEGIN block — outer catch rolls the batch
|
|
737
|
+
// back, same shape as the path-conflict path.
|
|
738
|
+
const extension = item.extension !== undefined
|
|
739
|
+
? validateExtension(item.extension)
|
|
740
|
+
: undefined;
|
|
688
741
|
const note = await store.createNote(item.content ?? "", {
|
|
689
742
|
id: item.id,
|
|
690
743
|
path: item.path,
|
|
691
744
|
tags: item.tags,
|
|
692
745
|
metadata: item.metadata,
|
|
693
746
|
created_at: item.createdAt ?? item.created_at,
|
|
747
|
+
...(extension !== undefined ? { extension } : {}),
|
|
694
748
|
});
|
|
695
749
|
|
|
696
750
|
// Create explicit links
|
|
@@ -713,14 +767,9 @@ export async function handleNotes(
|
|
|
713
767
|
409,
|
|
714
768
|
);
|
|
715
769
|
}
|
|
716
|
-
if (e && e.code === "
|
|
770
|
+
if (e && e.code === "INVALID_EXTENSION") {
|
|
717
771
|
return json(
|
|
718
|
-
{
|
|
719
|
-
error_type: "empty_note",
|
|
720
|
-
error: "EmptyNoteError",
|
|
721
|
-
message: e.message,
|
|
722
|
-
item_index: e.item_index ?? null,
|
|
723
|
-
},
|
|
772
|
+
{ error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
|
|
724
773
|
400,
|
|
725
774
|
);
|
|
726
775
|
}
|
|
@@ -881,18 +930,43 @@ export async function handleNotes(
|
|
|
881
930
|
}
|
|
882
931
|
const idLooksLikePath = idOrPathStr.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPathStr);
|
|
883
932
|
const explicitPath = typeof body.path === "string" ? body.path as string : undefined;
|
|
933
|
+
// Validate extension before reaching the Store (vault#328).
|
|
934
|
+
const createExt = body.extension !== undefined
|
|
935
|
+
? validateExtension(body.extension)
|
|
936
|
+
: undefined;
|
|
884
937
|
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
885
938
|
...(idLooksLikePath ? { path: explicitPath ?? idOrPathStr } : { id: idOrPathStr, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
886
939
|
...(tagsArr.length > 0 ? { tags: tagsArr } : {}),
|
|
887
940
|
...(body.metadata !== undefined ? { metadata: body.metadata as Record<string, unknown> } : {}),
|
|
888
941
|
...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
|
|
889
942
|
...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
|
|
943
|
+
...(createExt !== undefined ? { extension: createExt } : {}),
|
|
890
944
|
};
|
|
891
945
|
const content = (body.content as string | undefined) ?? "";
|
|
892
946
|
const created = await store.createNote(content, createOpts);
|
|
893
947
|
if (tagsArr.length > 0) {
|
|
894
948
|
await applySchemaDefaults(store, db, [created.id], tagsArr);
|
|
895
949
|
}
|
|
950
|
+
// vault#321 F2 — apply `links.add` on the create branch.
|
|
951
|
+
// MCP's create-on-missing branch already did this
|
|
952
|
+
// (`core/src/mcp.ts` if_missing=create block); the REST side
|
|
953
|
+
// was missing it, producing a cross-surface inconsistency
|
|
954
|
+
// operators (Gitcoin's drift sync) would trip on. Mirror the
|
|
955
|
+
// MCP recipe exactly:
|
|
956
|
+
// - `links.add` IS applied — drift sync can declare typed
|
|
957
|
+
// links at upsert time and have them materialize.
|
|
958
|
+
// - `links.remove` is ignored (nothing to remove on a
|
|
959
|
+
// fresh note).
|
|
960
|
+
// - Missing target notes skip silently (mirrors MCP).
|
|
961
|
+
const linksAdd = (body.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
962
|
+
if (linksAdd) {
|
|
963
|
+
for (const link of linksAdd) {
|
|
964
|
+
const target = await resolveNote(store, link.target);
|
|
965
|
+
if (target) {
|
|
966
|
+
await store.createLink(created.id, target.id, link.relationship, link.metadata);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
896
970
|
const final = await store.getNote(created.id);
|
|
897
971
|
if (!final) return json({ error: "Note disappeared" }, 500);
|
|
898
972
|
const validated = attachValidationStatus(store, db, final);
|
|
@@ -1035,6 +1109,11 @@ export async function handleNotes(
|
|
|
1035
1109
|
if (body.prepend !== undefined) updates.prepend = body.prepend;
|
|
1036
1110
|
}
|
|
1037
1111
|
if (body.path !== undefined) updates.path = body.path;
|
|
1112
|
+
if (body.extension !== undefined) {
|
|
1113
|
+
// Validate up front (vault#328). Throws ExtensionValidationError
|
|
1114
|
+
// which the outer catch converts to a 400.
|
|
1115
|
+
updates.extension = validateExtension(body.extension);
|
|
1116
|
+
}
|
|
1038
1117
|
if (body.metadata !== undefined) {
|
|
1039
1118
|
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
1040
1119
|
updates.metadata = { ...existing, ...body.metadata };
|
|
@@ -1124,17 +1203,9 @@ export async function handleNotes(
|
|
|
1124
1203
|
409,
|
|
1125
1204
|
);
|
|
1126
1205
|
}
|
|
1127
|
-
|
|
1128
|
-
// would clear both content AND path. Surface as 400 so callers can fix
|
|
1129
|
-
// the request without retrying.
|
|
1130
|
-
if (e && e.code === "EMPTY_NOTE") {
|
|
1206
|
+
if (e && e.code === "INVALID_EXTENSION") {
|
|
1131
1207
|
return json(
|
|
1132
|
-
{
|
|
1133
|
-
error_type: "empty_note",
|
|
1134
|
-
error: "EmptyNoteError",
|
|
1135
|
-
message: e.message,
|
|
1136
|
-
note_id: e.note_id ?? null,
|
|
1137
|
-
},
|
|
1208
|
+
{ error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
|
|
1138
1209
|
400,
|
|
1139
1210
|
);
|
|
1140
1211
|
}
|
|
@@ -1478,6 +1549,11 @@ export async function handleFindPath(
|
|
|
1478
1549
|
return json(result);
|
|
1479
1550
|
} catch (e: any) {
|
|
1480
1551
|
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
1552
|
+
// vault#331 N1 — surface AmbiguousPathError from resolveNote as 409
|
|
1553
|
+
// mirroring the handleNotes path. Without this, an ambiguous source/
|
|
1554
|
+
// target path on /api/find-path bubbled to a server-level 500.
|
|
1555
|
+
const ambig = ambiguousPathResponse(e);
|
|
1556
|
+
if (ambig) return ambig;
|
|
1481
1557
|
throw e;
|
|
1482
1558
|
}
|
|
1483
1559
|
}
|
|
@@ -1670,7 +1746,19 @@ export async function handleViewNote(
|
|
|
1670
1746
|
options: { authenticated?: boolean; publishedTag?: string } = {},
|
|
1671
1747
|
): Promise<Response> {
|
|
1672
1748
|
const { authenticated = false, publishedTag = "publish" } = options;
|
|
1673
|
-
|
|
1749
|
+
let note: Note | null;
|
|
1750
|
+
try {
|
|
1751
|
+
note = await resolveNote(store, idOrPath);
|
|
1752
|
+
} catch (e: any) {
|
|
1753
|
+
// vault#331 N1 — surface AmbiguousPathError as 409. The HTML view
|
|
1754
|
+
// route doesn't otherwise return JSON, but the structured body is
|
|
1755
|
+
// the right shape for the API contract; a human reader hitting
|
|
1756
|
+
// this URL gets the JSON inline (rare — the bare path form is
|
|
1757
|
+
// mostly an API consumer's mistake).
|
|
1758
|
+
const ambig = ambiguousPathResponse(e);
|
|
1759
|
+
if (ambig) return ambig;
|
|
1760
|
+
throw e;
|
|
1761
|
+
}
|
|
1674
1762
|
if (!note) {
|
|
1675
1763
|
return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain" } });
|
|
1676
1764
|
}
|