@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/schema.ts
CHANGED
|
@@ -2,17 +2,25 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
3
|
import { rebuildIndexes } from "./indexed-fields.js";
|
|
4
4
|
|
|
5
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 18;
|
|
6
6
|
|
|
7
7
|
export const SCHEMA_SQL = `
|
|
8
|
-
-- Notes: the universal record
|
|
8
|
+
-- Notes: the universal record.
|
|
9
|
+
--
|
|
10
|
+
-- extension (v18, vault#328) carries the file suffix the note should
|
|
11
|
+
-- exhibit when serialized to disk — "md" by default, "csv"/"yaml"/"json"/
|
|
12
|
+
-- "mdx"/etc. for non-markdown notes. Stored extension-less in the path
|
|
13
|
+
-- column; on-disk uniqueness key is (path, extension). See
|
|
14
|
+
-- core/src/portable-md.ts:supportsInlineFrontmatter for the
|
|
15
|
+
-- frontmatter-vs-sidecar split.
|
|
9
16
|
CREATE TABLE IF NOT EXISTS notes (
|
|
10
17
|
id TEXT PRIMARY KEY,
|
|
11
18
|
content TEXT DEFAULT '',
|
|
12
19
|
path TEXT,
|
|
13
20
|
metadata TEXT DEFAULT '{}',
|
|
14
21
|
created_at TEXT NOT NULL,
|
|
15
|
-
updated_at TEXT
|
|
22
|
+
updated_at TEXT,
|
|
23
|
+
extension TEXT NOT NULL DEFAULT 'md'
|
|
16
24
|
);
|
|
17
25
|
|
|
18
26
|
-- Tags: first-class identity carrying schema, hierarchy, and typed-link
|
|
@@ -266,6 +274,11 @@ export function initSchema(db: Database): void {
|
|
|
266
274
|
// as `tags.fields` if needed. See vault#267.
|
|
267
275
|
migrateToV17(db);
|
|
268
276
|
|
|
277
|
+
// Migrate v17 → v18: add `notes.extension TEXT NOT NULL DEFAULT 'md'`.
|
|
278
|
+
// Backward-compat by construction — every existing row defaults to "md"
|
|
279
|
+
// (markdown), unchanged in meaning. See vault#328.
|
|
280
|
+
migrateToV18(db);
|
|
281
|
+
|
|
269
282
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
270
283
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
271
284
|
rebuildIndexes(db);
|
|
@@ -793,6 +806,51 @@ function migrateToV17(db: Database): void {
|
|
|
793
806
|
}
|
|
794
807
|
}
|
|
795
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Migrate v17 → v18: add `notes.extension TEXT NOT NULL DEFAULT 'md'`
|
|
811
|
+
* (vault#328) AND widen the path-uniqueness index from `(path)` to
|
|
812
|
+
* `(path, extension)` so two notes can share a path differing only by
|
|
813
|
+
* extension (`Recipes/pasta` with both .md and .csv variants).
|
|
814
|
+
*
|
|
815
|
+
* Backward-compat by construction — every existing row defaults to "md",
|
|
816
|
+
* so the new composite-index uniqueness collapses to the v5
|
|
817
|
+
* "(path WHERE NOT NULL) is unique" behavior on existing data. Wrapped
|
|
818
|
+
* in BEGIN IMMEDIATE / COMMIT / ROLLBACK per the v14+ pattern.
|
|
819
|
+
*/
|
|
820
|
+
function migrateToV18(db: Database): void {
|
|
821
|
+
if (!hasTable(db, "notes")) return;
|
|
822
|
+
|
|
823
|
+
// Two responsibilities (column add + index swap) — both idempotent
|
|
824
|
+
// individually. Wrap in one transaction so an upgrading v17 vault
|
|
825
|
+
// ends up at exactly v17 or v18, never partial.
|
|
826
|
+
const needsColumn = !hasColumn(db, "notes", "extension");
|
|
827
|
+
const indexes = db.prepare(
|
|
828
|
+
"SELECT name FROM sqlite_master WHERE type='index' AND name IN ('idx_notes_path_unique', 'idx_notes_path_ext_unique')",
|
|
829
|
+
).all() as { name: string }[];
|
|
830
|
+
const hasOldUnique = indexes.some((r) => r.name === "idx_notes_path_unique");
|
|
831
|
+
const hasNewUnique = indexes.some((r) => r.name === "idx_notes_path_ext_unique");
|
|
832
|
+
if (!needsColumn && hasNewUnique && !hasOldUnique) return;
|
|
833
|
+
|
|
834
|
+
db.exec("BEGIN IMMEDIATE");
|
|
835
|
+
try {
|
|
836
|
+
if (needsColumn) {
|
|
837
|
+
db.exec("ALTER TABLE notes ADD COLUMN extension TEXT NOT NULL DEFAULT 'md'");
|
|
838
|
+
}
|
|
839
|
+
if (hasOldUnique) {
|
|
840
|
+
db.exec("DROP INDEX idx_notes_path_unique");
|
|
841
|
+
}
|
|
842
|
+
if (!hasNewUnique) {
|
|
843
|
+
db.exec(
|
|
844
|
+
"CREATE UNIQUE INDEX idx_notes_path_ext_unique ON notes(path, extension) WHERE path IS NOT NULL",
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
db.exec("COMMIT");
|
|
848
|
+
} catch (err) {
|
|
849
|
+
db.exec("ROLLBACK");
|
|
850
|
+
throw err;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
796
854
|
function hasTable(db: Database, name: string): boolean {
|
|
797
855
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
798
856
|
return !!row;
|
package/core/src/store.ts
CHANGED
|
@@ -92,7 +92,7 @@ export class BunSqliteStore implements Store {
|
|
|
92
92
|
|
|
93
93
|
// ---- Notes ----
|
|
94
94
|
|
|
95
|
-
async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
|
|
95
|
+
async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
|
|
96
96
|
const note = noteOps.createNote(this.db, content, opts);
|
|
97
97
|
|
|
98
98
|
if (content) {
|
|
@@ -113,8 +113,8 @@ export class BunSqliteStore implements Store {
|
|
|
113
113
|
return noteOps.getNote(this.db, id);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
async getNoteByPath(path: string): Promise<Note | null> {
|
|
117
|
-
return noteOps.getNoteByPath(this.db, path);
|
|
116
|
+
async getNoteByPath(path: string, extension?: string): Promise<Note | null> {
|
|
117
|
+
return noteOps.getNoteByPath(this.db, path, extension);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
async getNotes(ids: string[]): Promise<Note[]> {
|
|
@@ -128,6 +128,7 @@ export class BunSqliteStore implements Store {
|
|
|
128
128
|
append?: string;
|
|
129
129
|
prepend?: string;
|
|
130
130
|
path?: string;
|
|
131
|
+
extension?: string;
|
|
131
132
|
metadata?: Record<string, unknown>;
|
|
132
133
|
created_at?: string;
|
|
133
134
|
skipUpdatedAt?: boolean;
|
|
@@ -498,7 +499,7 @@ export class BunSqliteStore implements Store {
|
|
|
498
499
|
* `syncAllWikilinks`, so adding the cache rebuild there is the natural
|
|
499
500
|
* place.)
|
|
500
501
|
*/
|
|
501
|
-
async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
|
|
502
|
+
async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
|
|
502
503
|
return noteOps.createNote(this.db, content, opts);
|
|
503
504
|
}
|
|
504
505
|
|
package/core/src/types.ts
CHANGED
|
@@ -10,6 +10,14 @@ export interface Note {
|
|
|
10
10
|
id: string;
|
|
11
11
|
content: string;
|
|
12
12
|
path?: string;
|
|
13
|
+
/**
|
|
14
|
+
* File extension (sans dot). Defaults to `"md"`. Controls the
|
|
15
|
+
* serialized file suffix on export — `.md`/`.mdx` carry frontmatter
|
|
16
|
+
* inline; `.csv`/`.yaml`/`.json`/etc. carry their metadata in a
|
|
17
|
+
* sidecar at `.parachute/notes-meta/<id>.yaml`. See vault#328 +
|
|
18
|
+
* `core/src/portable-md.ts:supportsInlineFrontmatter`.
|
|
19
|
+
*/
|
|
20
|
+
extension?: string;
|
|
13
21
|
metadata?: Record<string, unknown>;
|
|
14
22
|
createdAt: string; // ISO-8601
|
|
15
23
|
updatedAt?: string;
|
|
@@ -64,6 +72,13 @@ export interface QueryOpts {
|
|
|
64
72
|
hasLinks?: boolean;
|
|
65
73
|
path?: string; // exact path match (case-insensitive)
|
|
66
74
|
pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
|
|
75
|
+
/**
|
|
76
|
+
* Filter by file extension. Pass a single extension (e.g. `"csv"`) or
|
|
77
|
+
* an array (e.g. `["csv", "yaml", "json"]`). Extension is compared
|
|
78
|
+
* lower-case. Notes default to `"md"` so `extension: "md"` matches
|
|
79
|
+
* the existing markdown corpus. See vault#328.
|
|
80
|
+
*/
|
|
81
|
+
extension?: string | string[];
|
|
67
82
|
// Restrict results to a specific set of note IDs. The MCP `near` query uses
|
|
68
83
|
// this to push graph-neighborhood scoping into the SQL WHERE clause so that
|
|
69
84
|
// LIMIT and ORDER BY apply to the filtered set, not the whole notes table.
|
|
@@ -107,6 +122,7 @@ export interface QueryOpts {
|
|
|
107
122
|
export interface NoteSummary {
|
|
108
123
|
id: string;
|
|
109
124
|
path?: string;
|
|
125
|
+
extension?: string;
|
|
110
126
|
metadata?: Record<string, unknown>;
|
|
111
127
|
createdAt: string;
|
|
112
128
|
updatedAt?: string;
|
|
@@ -120,6 +136,7 @@ export interface NoteSummary {
|
|
|
120
136
|
export interface NoteIndex {
|
|
121
137
|
id: string;
|
|
122
138
|
path?: string;
|
|
139
|
+
extension?: string;
|
|
123
140
|
createdAt: string;
|
|
124
141
|
updatedAt?: string;
|
|
125
142
|
tags?: string[];
|
|
@@ -138,11 +155,18 @@ export interface HydratedLink extends Link {
|
|
|
138
155
|
|
|
139
156
|
export interface Store {
|
|
140
157
|
// Notes
|
|
141
|
-
createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note>;
|
|
158
|
+
createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note>;
|
|
142
159
|
getNote(id: string): Promise<Note | null>;
|
|
143
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Look up a note by path. Pass `extension` to disambiguate when
|
|
162
|
+
* multiple notes share a path differing only by extension (post-
|
|
163
|
+
* vault#328). When omitted and >1 row matches, throws
|
|
164
|
+
* `AmbiguousPathError` instead of silently picking one. See
|
|
165
|
+
* vault#330 S1.
|
|
166
|
+
*/
|
|
167
|
+
getNoteByPath(path: string, extension?: string): Promise<Note | null>;
|
|
144
168
|
getNotes(ids: string[]): Promise<Note[]>;
|
|
145
|
-
updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
|
|
169
|
+
updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
|
|
146
170
|
/**
|
|
147
171
|
* Set a note's `created_at` and `updated_at` explicitly. Import-only:
|
|
148
172
|
* used by the portable-md round-trip path to restore timestamps from
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -110,19 +110,57 @@ function stripCode(content: string): string {
|
|
|
110
110
|
* Resolve a wikilink target to a note ID.
|
|
111
111
|
*
|
|
112
112
|
* Resolution order:
|
|
113
|
-
* 1.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
113
|
+
* 1. **Explicit-extension target**: `[[Foo.csv]]` matches the note with
|
|
114
|
+
* `path: "Foo"` AND `extension: "csv"` (vault#328). Lets a reader
|
|
115
|
+
* disambiguate when multiple notes share a path differing only by
|
|
116
|
+
* extension. The trailing `.<ext>` must match a known
|
|
117
|
+
* alphanumeric pattern (1–16 chars) — otherwise the dot is treated
|
|
118
|
+
* as part of the path string and falls through to the existing
|
|
119
|
+
* rules.
|
|
120
|
+
* 2. Exact path match (case-insensitive) — multiple matches resolve to
|
|
121
|
+
* a single note when only ONE extension is in play; if `Foo.md` and
|
|
122
|
+
* `Foo.csv` both exist, the link refuses to resolve and the caller
|
|
123
|
+
* sees an unresolved-wikilink entry (vault#328 ambiguity policy).
|
|
124
|
+
* 3. Basename match — target matches the last segment of a path
|
|
125
|
+
* (e.g., "README" matches "Projects/Parachute/README"). Same
|
|
126
|
+
* cross-extension ambiguity policy as #2.
|
|
117
127
|
*/
|
|
118
128
|
export function resolveWikilink(db: Database, target: string): string | null {
|
|
119
|
-
// 1.
|
|
129
|
+
// 1. Explicit extension form: `[[path.ext]]` where `.ext` is a
|
|
130
|
+
// pattern-matching tail (lowercase alphanumeric, 1–16 chars).
|
|
131
|
+
// Mirrors EXTENSION_PATTERN in core/src/notes.ts so the wikilink
|
|
132
|
+
// parser and the API surface share the same notion of "this looks
|
|
133
|
+
// like an extension."
|
|
134
|
+
const extMatch = target.match(/^(.*)\.([a-z0-9]{1,16})$/i);
|
|
135
|
+
if (extMatch) {
|
|
136
|
+
const pathPart = extMatch[1]!;
|
|
137
|
+
const extPart = extMatch[2]!.toLowerCase();
|
|
138
|
+
const explicit = db.prepare(
|
|
139
|
+
"SELECT id FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
|
|
140
|
+
).get(pathPart, extPart) as { id: string } | undefined;
|
|
141
|
+
if (explicit) return explicit.id;
|
|
142
|
+
// No match for explicit (path, ext) — fall through to the looser
|
|
143
|
+
// rules so a literal note named `Recipe.v2` (where `v2` isn't an
|
|
144
|
+
// extension on a real note) can still resolve under exact-path
|
|
145
|
+
// match.
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 2. Exact match (case-insensitive). When multiple notes share a path
|
|
149
|
+
// (post-vault#328 this happens when `Foo.md` and `Foo.csv` both
|
|
150
|
+
// exist, since path is stored extension-less), refuse to resolve.
|
|
120
151
|
const exact = db.prepare(
|
|
121
152
|
"SELECT id FROM notes WHERE path = ? COLLATE NOCASE",
|
|
122
|
-
).
|
|
123
|
-
if (exact) return exact
|
|
153
|
+
).all(target) as { id: string }[];
|
|
154
|
+
if (exact.length === 1) return exact[0]!.id;
|
|
155
|
+
if (exact.length > 1) {
|
|
156
|
+
// Ambiguous — refuse-and-require-explicit-extension policy
|
|
157
|
+
// (vault#328). Returning null routes through the existing
|
|
158
|
+
// unresolved-wikilinks workflow, so the reader (or the indexer)
|
|
159
|
+
// sees the conflict.
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
124
162
|
|
|
125
|
-
//
|
|
163
|
+
// 3. Basename match — last path segment equals target.
|
|
126
164
|
// e.g., target "README" matches path "Projects/Parachute/README"
|
|
127
165
|
const basename = db.prepare(`
|
|
128
166
|
SELECT id FROM notes
|
|
@@ -150,17 +188,39 @@ export interface WikilinkResolution {
|
|
|
150
188
|
|
|
151
189
|
/**
|
|
152
190
|
* Resolve a wikilink target with full detail — single match, ambiguous, or unresolved.
|
|
191
|
+
* Mirrors `resolveWikilink`'s resolution order, including the explicit-
|
|
192
|
+
* extension form `[[Foo.csv]]` introduced by vault#328.
|
|
153
193
|
*/
|
|
154
194
|
export function resolveWikilinkDetailed(db: Database, target: string): WikilinkResolution {
|
|
155
|
-
// 1.
|
|
195
|
+
// 1. Explicit-extension form: `[[path.ext]]`.
|
|
196
|
+
const extMatch = target.match(/^(.*)\.([a-z0-9]{1,16})$/i);
|
|
197
|
+
if (extMatch) {
|
|
198
|
+
const pathPart = extMatch[1]!;
|
|
199
|
+
const extPart = extMatch[2]!.toLowerCase();
|
|
200
|
+
const explicit = db.prepare(
|
|
201
|
+
"SELECT id, path FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
|
|
202
|
+
).get(pathPart, extPart) as { id: string; path: string } | undefined;
|
|
203
|
+
if (explicit) {
|
|
204
|
+
return { resolved: true, note_id: explicit.id, path: explicit.path, candidates: [] };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 2. Exact path match (case-insensitive). Multiple matches → ambiguous.
|
|
156
209
|
const exact = db.prepare(
|
|
157
|
-
"SELECT id, path FROM notes WHERE path = ? COLLATE NOCASE",
|
|
158
|
-
).
|
|
159
|
-
if (exact) {
|
|
160
|
-
return { resolved: true, note_id: exact
|
|
210
|
+
"SELECT id, path, extension FROM notes WHERE path = ? COLLATE NOCASE",
|
|
211
|
+
).all(target) as { id: string; path: string; extension: string }[];
|
|
212
|
+
if (exact.length === 1) {
|
|
213
|
+
return { resolved: true, note_id: exact[0]!.id, path: exact[0]!.path, candidates: [] };
|
|
214
|
+
}
|
|
215
|
+
if (exact.length > 1) {
|
|
216
|
+
return {
|
|
217
|
+
resolved: false,
|
|
218
|
+
ambiguous: true,
|
|
219
|
+
candidates: exact.map((r) => ({ note_id: r.id, path: r.path })),
|
|
220
|
+
};
|
|
161
221
|
}
|
|
162
222
|
|
|
163
|
-
//
|
|
223
|
+
// 3. Basename match
|
|
164
224
|
const basename = db.prepare(`
|
|
165
225
|
SELECT id, path FROM notes
|
|
166
226
|
WHERE path IS NOT NULL
|
package/package.json
CHANGED
package/src/published.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
2
3
|
import { handleViewNote } from "./routes.ts";
|
|
4
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
3
5
|
|
|
4
6
|
// Redirect URL builder — mirrors the logic in server.ts
|
|
5
7
|
function buildRedirectUrl(reqUrl: string, noteId: string, prefix = ""): string {
|
|
@@ -211,4 +213,19 @@ describe("handleViewNote", async () => {
|
|
|
211
213
|
const resp = await handleViewNote(store, "n1", { publishedTag: "public" });
|
|
212
214
|
expect(resp.status).toBe(404);
|
|
213
215
|
});
|
|
216
|
+
|
|
217
|
+
it("returns 409 ambiguous_path when bare path resolves to multiple notes (vault#331 N1)", async () => {
|
|
218
|
+
// Real SqliteStore so the v18 composite (path, extension) uniqueness
|
|
219
|
+
// index lets two notes coexist at the same path with different
|
|
220
|
+
// extensions — the scenario AmbiguousPathError is built for.
|
|
221
|
+
const store = new SqliteStore(new Database(":memory:"));
|
|
222
|
+
await store.createNote("# md", { id: "vn-md", path: "Foo", tags: ["publish"] });
|
|
223
|
+
await store.createNote("a,b\n1,2", { id: "vn-csv", path: "Foo", extension: "csv", tags: ["publish"] });
|
|
224
|
+
const resp = await handleViewNote(store, "Foo", { authenticated: true });
|
|
225
|
+
expect(resp.status).toBe(409);
|
|
226
|
+
const body = await resp.json() as any;
|
|
227
|
+
expect(body.error_type).toBe("ambiguous_path");
|
|
228
|
+
expect(body.path).toBe("Foo");
|
|
229
|
+
expect(body.candidates).toHaveLength(2);
|
|
230
|
+
});
|
|
214
231
|
});
|
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).
|
|
@@ -660,12 +732,19 @@ export async function handleNotes(
|
|
|
660
732
|
if (batched) db.exec("BEGIN");
|
|
661
733
|
try {
|
|
662
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;
|
|
663
741
|
const note = await store.createNote(item.content ?? "", {
|
|
664
742
|
id: item.id,
|
|
665
743
|
path: item.path,
|
|
666
744
|
tags: item.tags,
|
|
667
745
|
metadata: item.metadata,
|
|
668
746
|
created_at: item.createdAt ?? item.created_at,
|
|
747
|
+
...(extension !== undefined ? { extension } : {}),
|
|
669
748
|
});
|
|
670
749
|
|
|
671
750
|
// Create explicit links
|
|
@@ -688,6 +767,12 @@ export async function handleNotes(
|
|
|
688
767
|
409,
|
|
689
768
|
);
|
|
690
769
|
}
|
|
770
|
+
if (e && e.code === "INVALID_EXTENSION") {
|
|
771
|
+
return json(
|
|
772
|
+
{ error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
|
|
773
|
+
400,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
691
776
|
throw e;
|
|
692
777
|
}
|
|
693
778
|
|
|
@@ -845,12 +930,17 @@ export async function handleNotes(
|
|
|
845
930
|
}
|
|
846
931
|
const idLooksLikePath = idOrPathStr.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPathStr);
|
|
847
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;
|
|
848
937
|
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
849
938
|
...(idLooksLikePath ? { path: explicitPath ?? idOrPathStr } : { id: idOrPathStr, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
850
939
|
...(tagsArr.length > 0 ? { tags: tagsArr } : {}),
|
|
851
940
|
...(body.metadata !== undefined ? { metadata: body.metadata as Record<string, unknown> } : {}),
|
|
852
941
|
...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
|
|
853
942
|
...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
|
|
943
|
+
...(createExt !== undefined ? { extension: createExt } : {}),
|
|
854
944
|
};
|
|
855
945
|
const content = (body.content as string | undefined) ?? "";
|
|
856
946
|
const created = await store.createNote(content, createOpts);
|
|
@@ -1019,6 +1109,11 @@ export async function handleNotes(
|
|
|
1019
1109
|
if (body.prepend !== undefined) updates.prepend = body.prepend;
|
|
1020
1110
|
}
|
|
1021
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
|
+
}
|
|
1022
1117
|
if (body.metadata !== undefined) {
|
|
1023
1118
|
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
1024
1119
|
updates.metadata = { ...existing, ...body.metadata };
|
|
@@ -1108,6 +1203,12 @@ export async function handleNotes(
|
|
|
1108
1203
|
409,
|
|
1109
1204
|
);
|
|
1110
1205
|
}
|
|
1206
|
+
if (e && e.code === "INVALID_EXTENSION") {
|
|
1207
|
+
return json(
|
|
1208
|
+
{ error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
|
|
1209
|
+
400,
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1111
1212
|
throw e;
|
|
1112
1213
|
}
|
|
1113
1214
|
}
|
|
@@ -1448,6 +1549,11 @@ export async function handleFindPath(
|
|
|
1448
1549
|
return json(result);
|
|
1449
1550
|
} catch (e: any) {
|
|
1450
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;
|
|
1451
1557
|
throw e;
|
|
1452
1558
|
}
|
|
1453
1559
|
}
|
|
@@ -1640,7 +1746,19 @@ export async function handleViewNote(
|
|
|
1640
1746
|
options: { authenticated?: boolean; publishedTag?: string } = {},
|
|
1641
1747
|
): Promise<Response> {
|
|
1642
1748
|
const { authenticated = false, publishedTag = "publish" } = options;
|
|
1643
|
-
|
|
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
|
+
}
|
|
1644
1762
|
if (!note) {
|
|
1645
1763
|
return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain" } });
|
|
1646
1764
|
}
|