@openparachute/vault 0.7.2-rc.3 → 0.7.2-rc.4
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/aggregate.test.ts +23 -0
- package/core/src/contract-taxonomy.test.ts +22 -0
- package/core/src/query-operators.ts +56 -0
- package/core/src/store.ts +8 -0
- package/core/src/tag-hierarchy.ts +13 -0
- package/core/src/wikilinks.test.ts +175 -0
- package/core/src/wikilinks.ts +151 -10
- package/package.json +1 -1
- package/src/routes.ts +143 -5
- package/src/vault.test.ts +139 -1
|
@@ -214,6 +214,29 @@ describe("aggregateNotes — errors", () => {
|
|
|
214
214
|
}
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
+
// LB — a `type: "number"` (float) field can NEVER be `indexed: true`
|
|
218
|
+
// (only integer/boolean are indexable numeric shapes), so it legitimately
|
|
219
|
+
// never appears in `indexed_fields`. Before the fix, the FIELD_NOT_INDEXED
|
|
220
|
+
// thrown here carried the SAME generic "declare it via update-tag with
|
|
221
|
+
// indexed: true" hint every other unindexed field gets — advice that's
|
|
222
|
+
// impossible to satisfy for `number` specifically, dead-ending an agent
|
|
223
|
+
// that tries it and hits the SEPARATE "unsupported type ... for indexing"
|
|
224
|
+
// error. The hint must instead name the real fix: store as an integer.
|
|
225
|
+
it("FIELD_NOT_INDEXED on a type: \"number\" sum field carries an actionable hint, not the impossible generic \"declare indexed: true\" advice", async () => {
|
|
226
|
+
await store.upsertTagRecord("expense", { fields: { amount: { type: "number" } } });
|
|
227
|
+
await store.createNote("a", { tags: ["expense"], metadata: { amount: 9.99 } });
|
|
228
|
+
try {
|
|
229
|
+
aggregateNotes(db, { aggregate: { group_by: "tag", op: "sum", field: "amount" } });
|
|
230
|
+
throw new Error("expected throw");
|
|
231
|
+
} catch (e: any) {
|
|
232
|
+
expect(e.code).toBe("FIELD_NOT_INDEXED");
|
|
233
|
+
expect(e.message).not.toContain("declare it via update-tag with indexed: true");
|
|
234
|
+
const guidance = `${e.message} ${e.hint ?? ""}`.toLowerCase();
|
|
235
|
+
expect(guidance).toContain("integer");
|
|
236
|
+
expect(guidance).toMatch(/float|number|decimal/);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
217
240
|
it("throws INVALID_QUERY when the sum field is indexed but not numeric (TEXT-backed)", async () => {
|
|
218
241
|
await store.upsertTagRecord("task", {
|
|
219
242
|
fields: {
|
|
@@ -295,6 +295,28 @@ describe("contract: taxonomy — #552 (flipped from test.todo)", () => {
|
|
|
295
295
|
expect(cleanReport.summary).toContain("clean");
|
|
296
296
|
});
|
|
297
297
|
|
|
298
|
+
// LB — a DIRECT self-parent (`parent_names: [tag]` on tag itself) is a
|
|
299
|
+
// degenerate one-node cycle: `getTagDescendants` starts its traversal
|
|
300
|
+
// result at `{tag}`, and the moment it walks the tag's own self-edge it
|
|
301
|
+
// finds `tag` already `result.has(child)` and skips re-expanding it — so
|
|
302
|
+
// `descendants` never grows past size 1. `findHierarchyCycles`'s original
|
|
303
|
+
// gate (`descendants.has(tag) && descendants.size > 1`) requires size>1,
|
|
304
|
+
// so this exact case reported clean despite being a real cycle. Only
|
|
305
|
+
// reachable via guard-bypassing data (direct DB write / import) since
|
|
306
|
+
// `upsertTagRecord` rejects a self-parent at write time (see the "bare
|
|
307
|
+
// self-parent: also caught" assertion above, which exercises the WRITE
|
|
308
|
+
// guard — this test exercises the READ-side doctor scan on data that
|
|
309
|
+
// skipped that guard).
|
|
310
|
+
it("doctor flags a bare self-parent cycle (X declares parent_names: [X]), which the size>1 gate alone misses", async () => {
|
|
311
|
+
await store.upsertTagRecord("self-cyc", {});
|
|
312
|
+
db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?").run(JSON.stringify(["self-cyc"]), "self-cyc");
|
|
313
|
+
|
|
314
|
+
const report = await store.doctor();
|
|
315
|
+
const finding = report.findings.find((f) => f.type === "parent_names_cycle" && f.subject === "self-cyc");
|
|
316
|
+
expect(finding).toBeDefined();
|
|
317
|
+
expect(finding?.severity).toBe("error");
|
|
318
|
+
});
|
|
319
|
+
|
|
298
320
|
it("dead_tag_metadata_reference does not false-positive on a schema-declared enum field whose values coincide with an unrelated live tag (vault#570)", async () => {
|
|
299
321
|
// "archived" is a real, unrelated live tag — coincidentally sharing a
|
|
300
322
|
// name with one of "status"'s declared enum values. Pre-fix, this ONE
|
|
@@ -101,13 +101,69 @@ function toBinding(field: string, op: string, value: unknown): SQLQueryBindings
|
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Best-effort lookup of a metadata field's declared `type` across every tag
|
|
106
|
+
* schema — used ONLY to sharpen the {@link requireIndexedField} error hint
|
|
107
|
+
* below when the field was never indexed. Scans `tags.fields` directly
|
|
108
|
+
* rather than going through `indexed_fields` (which by construction has no
|
|
109
|
+
* row for a type that can never BE indexed, e.g. `"number"`) or a
|
|
110
|
+
* per-note schema resolution (scoped to one note's tags, not "any tag
|
|
111
|
+
* anywhere"). Only reached on the FIELD_NOT_INDEXED error path — a full
|
|
112
|
+
* table scan here is fine since it runs once per rejected call, never on a
|
|
113
|
+
* hot path. Returns the type from the FIRST tag found declaring `field`
|
|
114
|
+
* (a cross-tag type conflict on the same field name is its own separate
|
|
115
|
+
* validation elsewhere, not this function's job); `undefined` when no tag
|
|
116
|
+
* declares this field name, or its `fields` JSON doesn't parse.
|
|
117
|
+
*/
|
|
118
|
+
function findDeclaredFieldType(db: Database, field: string): string | undefined {
|
|
119
|
+
const rows = db.prepare(
|
|
120
|
+
`SELECT fields FROM tags WHERE fields IS NOT NULL`,
|
|
121
|
+
).all() as { fields: string | null }[];
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
if (!row.fields) continue;
|
|
124
|
+
try {
|
|
125
|
+
const parsed: unknown = JSON.parse(row.fields);
|
|
126
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
127
|
+
const spec = (parsed as Record<string, unknown>)[field];
|
|
128
|
+
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) continue;
|
|
129
|
+
const type = (spec as Record<string, unknown>).type;
|
|
130
|
+
if (typeof type === "string") return type;
|
|
131
|
+
} catch {
|
|
132
|
+
// Malformed `fields` JSON on some other tag — not this lookup's job
|
|
133
|
+
// to validate; skip and keep scanning.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
104
139
|
/**
|
|
105
140
|
* Look up `field` in `indexed_fields` or throw a loud error suggesting the
|
|
106
141
|
* caller declare it via `update-tag` with `indexed: true`.
|
|
142
|
+
*
|
|
143
|
+
* That generic advice is IMPOSSIBLE to satisfy for a field whose schema
|
|
144
|
+
* declares `type: "number"` (a float) — only `integer`/`boolean` are
|
|
145
|
+
* indexable numeric shapes (see `indexed-fields.ts`'s `TYPE_MAP`), so
|
|
146
|
+
* `update-tag` with `indexed: true` on a `number` field itself throws
|
|
147
|
+
* "unsupported type ... for indexing". An agent that only sees the generic
|
|
148
|
+
* hint has no escape from that loop (LB — round-4 bug hunt). When the
|
|
149
|
+
* declared type is `"number"`, swap in a hint that names the actual fix:
|
|
150
|
+
* store the value as an integer (e.g. cents instead of dollars) and index
|
|
151
|
+
* THAT field instead.
|
|
107
152
|
*/
|
|
108
153
|
export function requireIndexedField(db: Database, field: string): IndexedField {
|
|
109
154
|
const row = getIndexedField(db, field);
|
|
110
155
|
if (!row) {
|
|
156
|
+
if (findDeclaredFieldType(db, field) === "number") {
|
|
157
|
+
throw new QueryError(
|
|
158
|
+
`metadata field "${field}" is not indexed, and can never be: it's declared type: "number" (a float), and only integer/boolean fields are indexable numeric types. "declare indexed: true" will not work on this field.`,
|
|
159
|
+
"FIELD_NOT_INDEXED",
|
|
160
|
+
{
|
|
161
|
+
error_type: "field_not_indexed",
|
|
162
|
+
field,
|
|
163
|
+
hint: `"${field}" is a float ("number") field — float fields can't be server-side indexed, filtered with operators, used in order_by, or summed via aggregate. Store the value as an integer instead (e.g. cents rather than dollars) and declare indexed: true on THAT field to enable those.`,
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
}
|
|
111
167
|
throw new QueryError(
|
|
112
168
|
`metadata field "${field}" is not indexed. To use operator queries or order_by on this field, declare it via update-tag with indexed: true.`,
|
|
113
169
|
"FIELD_NOT_INDEXED",
|
package/core/src/store.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
resolveUnresolvedWikilinks,
|
|
16
16
|
resolveOrQueueLink,
|
|
17
17
|
clearQueuedLink,
|
|
18
|
+
requeueInboundWikilinksForDelete,
|
|
18
19
|
} from "./wikilinks.js";
|
|
19
20
|
import { pathTitle } from "./paths.js";
|
|
20
21
|
import { timestampToMs } from "./cursor.js";
|
|
@@ -382,6 +383,13 @@ export class BunSqliteStore implements Store {
|
|
|
382
383
|
// by design, hooks subscribing to "deleted" receive a DeletedNoteRef,
|
|
383
384
|
// not a Note.
|
|
384
385
|
const existing = noteOps.getNote(this.db, id);
|
|
386
|
+
// LB6: re-queue inbound wikilink edges BEFORE the FK cascade drops the
|
|
387
|
+
// `links` rows, so recreating a note at this path/title auto-heals the
|
|
388
|
+
// edge instead of leaving every referencing note's `[[link]]` dead until
|
|
389
|
+
// it's individually re-saved. See requeueInboundWikilinksForDelete's doc
|
|
390
|
+
// comment for why this must run pre-delete and what it deliberately
|
|
391
|
+
// excludes (typed `links`, not just wikilinks).
|
|
392
|
+
requeueInboundWikilinksForDelete(this.db, id);
|
|
385
393
|
noteOps.deleteNote(this.db, id);
|
|
386
394
|
if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
|
|
387
395
|
// Dispatch even when `existing` was null — the caller asked for a
|
|
@@ -372,6 +372,19 @@ export function computeExpandedTagCounts(
|
|
|
372
372
|
export function findHierarchyCycles(h: TagHierarchy): string[] {
|
|
373
373
|
const cycles: string[] = [];
|
|
374
374
|
for (const tag of h.childrenOf.keys()) {
|
|
375
|
+
// Direct self-edge (`parent_names` lists the tag itself) — checked
|
|
376
|
+
// separately from the descendant-set gate below because
|
|
377
|
+
// `getTagDescendants` can't see it: its traversal seeds `result` with
|
|
378
|
+
// `{tag}` and then skips re-expanding any child already present in
|
|
379
|
+
// `result` (the visited-set that makes runtime traversal cycle-safe) —
|
|
380
|
+
// so a bare `X -> X` edge never grows `descendants` past size 1, and
|
|
381
|
+
// `descendants.size > 1` alone silently reports it clean. Only
|
|
382
|
+
// reachable via guard-bypassing data (a direct DB write, or an import
|
|
383
|
+
// that skips `upsertTagRecord`'s write-time cycle guard).
|
|
384
|
+
if (h.childrenOf.get(tag)?.has(tag)) {
|
|
385
|
+
cycles.push(tag);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
375
388
|
const descendants = getTagDescendants(h, tag);
|
|
376
389
|
if (descendants.has(tag) && descendants.size > 1) {
|
|
377
390
|
// tag reaches itself through a non-trivial path
|
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
listUnresolvedWikilinks,
|
|
11
11
|
getContentWikilinkWarnings,
|
|
12
12
|
resolveOrQueueLink,
|
|
13
|
+
requeueInboundWikilinksForDelete,
|
|
14
|
+
getUnresolvedLinksForNote,
|
|
13
15
|
} from "./wikilinks.js";
|
|
16
|
+
import { findPath } from "./links.js";
|
|
14
17
|
|
|
15
18
|
let store: SqliteStore;
|
|
16
19
|
let db: Database;
|
|
@@ -480,6 +483,178 @@ describe("path-based resolution", async () => {
|
|
|
480
483
|
});
|
|
481
484
|
});
|
|
482
485
|
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// requeueInboundWikilinksForDelete (LB6) — a deleted note's inbound wikilink
|
|
488
|
+
// edges must re-queue so recreating a note at the same path/title heals
|
|
489
|
+
// them, instead of staying dead until the SOURCE note is individually
|
|
490
|
+
// re-saved.
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
describe("delete → recreate re-resolves inbound wikilinks (LB6)", () => {
|
|
494
|
+
it("A [[Foo]] survives Foo's delete-then-recreate without touching A", async () => {
|
|
495
|
+
const a = await store.createNote("Link to [[Foo]]", { path: "A" });
|
|
496
|
+
|
|
497
|
+
// Foo doesn't exist yet — the link is queued, not yet live.
|
|
498
|
+
expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
|
|
499
|
+
const pendingBefore = db.prepare(
|
|
500
|
+
"SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?",
|
|
501
|
+
).all(a.id) as { target_path: string }[];
|
|
502
|
+
expect(pendingBefore.map((r) => r.target_path)).toEqual(["Foo"]);
|
|
503
|
+
|
|
504
|
+
// Create Foo — the wikilink resolves and the edge + backlink exist.
|
|
505
|
+
const foo1 = await store.createNote("First Foo", { path: "Foo" });
|
|
506
|
+
let links = await store.getLinks(a.id, { direction: "outbound" });
|
|
507
|
+
expect(links).toHaveLength(1);
|
|
508
|
+
expect(links[0]!.targetId).toBe(foo1.id);
|
|
509
|
+
expect(findPath(db, a.id, foo1.id)).not.toBeNull();
|
|
510
|
+
const backlinks = await store.getLinks(foo1.id, { direction: "inbound" });
|
|
511
|
+
expect(backlinks.some((l) => l.sourceId === a.id)).toBe(true);
|
|
512
|
+
|
|
513
|
+
// Delete Foo. Without the LB6 fix, `unresolved_wikilinks` stays empty —
|
|
514
|
+
// nothing pending — even though A's `[[Foo]]` text is untouched.
|
|
515
|
+
await store.deleteNote(foo1.id);
|
|
516
|
+
expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
|
|
517
|
+
const pendingAfterDelete = db.prepare(
|
|
518
|
+
"SELECT target_path, relationship FROM unresolved_wikilinks WHERE source_id = ?",
|
|
519
|
+
).all(a.id) as { target_path: string; relationship: string }[];
|
|
520
|
+
expect(pendingAfterDelete).toEqual([{ target_path: "Foo", relationship: "wikilink" }]);
|
|
521
|
+
|
|
522
|
+
// Recreate Foo (new note, new id — same path). A was never re-saved.
|
|
523
|
+
const foo2 = await store.createNote("Second Foo", { path: "Foo" });
|
|
524
|
+
|
|
525
|
+
// The A -> Foo edge is back, pointed at the NEW Foo, and find-path sees it.
|
|
526
|
+
links = await store.getLinks(a.id, { direction: "outbound" });
|
|
527
|
+
expect(links).toHaveLength(1);
|
|
528
|
+
expect(links[0]!.targetId).toBe(foo2.id);
|
|
529
|
+
expect(links[0]!.relationship).toBe("wikilink");
|
|
530
|
+
expect(findPath(db, a.id, foo2.id)).not.toBeNull();
|
|
531
|
+
|
|
532
|
+
// A's content was never touched.
|
|
533
|
+
const reread = await store.getNote(a.id);
|
|
534
|
+
expect(reread!.content).toBe("Link to [[Foo]]");
|
|
535
|
+
|
|
536
|
+
// The pending row is consumed on resolution.
|
|
537
|
+
const pendingAfterRecreate = db.prepare(
|
|
538
|
+
"SELECT * FROM unresolved_wikilinks WHERE source_id = ?",
|
|
539
|
+
).all(a.id);
|
|
540
|
+
expect(pendingAfterRecreate).toHaveLength(0);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("only re-queues wikilink-relationship inbound edges, not explicit typed links", async () => {
|
|
544
|
+
const source = await store.createNote("A", { id: "src", path: "Src" });
|
|
545
|
+
const target = await store.createNote("B", { id: "tgt", path: "Tgt" });
|
|
546
|
+
await store.createLink("src", "tgt", "related-to"); // hand-authored typed link, not a wikilink
|
|
547
|
+
|
|
548
|
+
requeueInboundWikilinksForDelete(db, target.id);
|
|
549
|
+
|
|
550
|
+
// getUnresolvedLinksForNote tolerates the table not existing at all
|
|
551
|
+
// (no wikilink was ever parsed in this test) — a raw SELECT would throw.
|
|
552
|
+
expect(getUnresolvedLinksForNote(db, source.id)).toHaveLength(0);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("re-queues via basename resolution, not just exact path, and recovers on recreate at the same path", async () => {
|
|
556
|
+
// Target lives at a nested path; source links via the bare basename —
|
|
557
|
+
// resolved through resolveWikilink's basename fallback (rule #3), not
|
|
558
|
+
// an exact path match.
|
|
559
|
+
const target = await store.createNote("# Weekly Review\n\nBody.", { path: "Projects/Weekly Review" });
|
|
560
|
+
const source = await store.createNote("See [[Weekly Review]]", { path: "A" });
|
|
561
|
+
expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(1);
|
|
562
|
+
|
|
563
|
+
await store.deleteNote(target.id);
|
|
564
|
+
|
|
565
|
+
const pending = db.prepare(
|
|
566
|
+
"SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?",
|
|
567
|
+
).all(source.id) as { target_path: string }[];
|
|
568
|
+
// The re-queued key is the RAW bracket text ("Weekly Review"), not the
|
|
569
|
+
// deleted note's full path — that's what the basename-fallback lazy
|
|
570
|
+
// resolver (`resolveUnresolvedWikilinks`'s `? LIKE '%/' || target_path`
|
|
571
|
+
// suffix match) actually matches on.
|
|
572
|
+
expect(pending.map((r) => r.target_path)).toEqual(["Weekly Review"]);
|
|
573
|
+
|
|
574
|
+
// Recreate at the SAME nested path — the suffix match backfills it.
|
|
575
|
+
const target2 = await store.createNote("# Weekly Review v2\n\nBody.", { path: "Projects/Weekly Review" });
|
|
576
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
577
|
+
expect(links).toHaveLength(1);
|
|
578
|
+
expect(links[0]!.targetId).toBe(target2.id);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// BLOCKER (Fable review) — the delete→recreate re-heal must cover links
|
|
582
|
+
// that resolved via the H1-TITLE fallback and the explicit-EXTENSION form,
|
|
583
|
+
// not just path/basename. The re-queued row's target string is the raw
|
|
584
|
+
// bracket text (e.g. "John Doe" / "budget.csv"), which the OLD sweep
|
|
585
|
+
// matched against the new note by PATH TEXT ONLY — so a title- or
|
|
586
|
+
// extension-resolved link stayed queued (0 links rebuilt) forever. The
|
|
587
|
+
// sweep now runs each candidate through the SAME resolver write-time uses,
|
|
588
|
+
// so deferred resolution finally matches write-time on all four legs.
|
|
589
|
+
it("re-heals a TITLE-fallback link AND an EXTENSION-form link on delete→recreate at the same path+H1", async () => {
|
|
590
|
+
// A resolves to `people/jdoe` only via its H1 title "John Doe".
|
|
591
|
+
// B resolves to `budget` (ext csv) only via the explicit [[budget.csv]]
|
|
592
|
+
// form (the extension leg is exact-path, so `budget` lives at path
|
|
593
|
+
// "budget", disambiguating it from a same-named `.md` note).
|
|
594
|
+
const person = await store.createNote("# John Doe\n\nBio.", { path: "people/jdoe" });
|
|
595
|
+
const budget = await store.createNote("month,total\n2026-01,9000", { path: "budget", extension: "csv" });
|
|
596
|
+
const a = await store.createNote("met [[John Doe]] today", { path: "A" });
|
|
597
|
+
const b = await store.createNote("see [[budget.csv]]", { path: "B" });
|
|
598
|
+
|
|
599
|
+
// Both links live at write time (title fallback + extension form).
|
|
600
|
+
expect((await store.getLinks(a.id, { direction: "outbound" }))[0]?.targetId).toBe(person.id);
|
|
601
|
+
expect((await store.getLinks(b.id, { direction: "outbound" }))[0]?.targetId).toBe(budget.id);
|
|
602
|
+
|
|
603
|
+
await store.deleteNote(person.id);
|
|
604
|
+
await store.deleteNote(budget.id);
|
|
605
|
+
|
|
606
|
+
// Both re-queued with their raw bracket text — NOT the deleted notes' paths.
|
|
607
|
+
const pendingA = db.prepare("SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?").all(a.id) as { target_path: string }[];
|
|
608
|
+
const pendingB = db.prepare("SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?").all(b.id) as { target_path: string }[];
|
|
609
|
+
expect(pendingA.map((r) => r.target_path)).toEqual(["John Doe"]);
|
|
610
|
+
expect(pendingB.map((r) => r.target_path)).toEqual(["budget.csv"]);
|
|
611
|
+
expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
|
|
612
|
+
expect(await store.getLinks(b.id, { direction: "outbound" })).toHaveLength(0);
|
|
613
|
+
|
|
614
|
+
// Recreate each at the SAME path + H1 (new note, new id). Neither A nor B
|
|
615
|
+
// is re-saved — only the deferred sweep can rebuild these edges.
|
|
616
|
+
const person2 = await store.createNote("# John Doe\n\nBio v2.", { path: "people/jdoe" });
|
|
617
|
+
const budget2 = await store.createNote("month,total\n2026-02,9500", { path: "budget", extension: "csv" });
|
|
618
|
+
|
|
619
|
+
// BOTH re-heal (RED before the sweep completion — title/ext legs missing).
|
|
620
|
+
const aLinks = await store.getLinks(a.id, { direction: "outbound" });
|
|
621
|
+
const bLinks = await store.getLinks(b.id, { direction: "outbound" });
|
|
622
|
+
expect(aLinks).toHaveLength(1);
|
|
623
|
+
expect(aLinks[0]!.targetId).toBe(person2.id);
|
|
624
|
+
expect(findPath(db, a.id, person2.id)).not.toBeNull();
|
|
625
|
+
expect(bLinks).toHaveLength(1);
|
|
626
|
+
expect(bLinks[0]!.targetId).toBe(budget2.id);
|
|
627
|
+
|
|
628
|
+
// Pending rows consumed on resolution.
|
|
629
|
+
expect(getUnresolvedLinksForNote(db, a.id)).toHaveLength(0);
|
|
630
|
+
expect(getUnresolvedLinksForNote(db, b.id)).toHaveLength(0);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// The completed sweep must NOT mis-resolve an AMBIGUOUS target — matching
|
|
634
|
+
// write-time's "don't guess" contract. A pending [[John Doe]] is swept when
|
|
635
|
+
// a same-titled note is created; if TWO notes already share that H1 by the
|
|
636
|
+
// time the sweep verifies, the resolver returns ambiguous and the row stays
|
|
637
|
+
// queued rather than the old path-only sweep's "link to whoever showed up."
|
|
638
|
+
// (Reached via a pathless first note — the sweep is path-gated, so it never
|
|
639
|
+
// fired to consume the row when only ONE John Doe existed.)
|
|
640
|
+
it("does not re-heal a title link when the target is ambiguous (two notes share the H1)", async () => {
|
|
641
|
+
const a = await store.createNote("met [[John Doe]]", { path: "A" });
|
|
642
|
+
expect(getUnresolvedLinksForNote(db, a.id).map((l) => l.target)).toEqual(["John Doe"]); // queued, no John Doe yet
|
|
643
|
+
|
|
644
|
+
// A PATHLESS note carrying the H1 — createNote's sweep is path-gated, so
|
|
645
|
+
// it does NOT fire here; A's pending row survives with one John Doe extant.
|
|
646
|
+
await store.createNote("# John Doe\n\nfirst.");
|
|
647
|
+
expect(getUnresolvedLinksForNote(db, a.id).map((l) => l.target)).toEqual(["John Doe"]); // still queued
|
|
648
|
+
|
|
649
|
+
// A SECOND note with the same H1, WITH a path → the sweep fires. Now two
|
|
650
|
+
// notes carry "John Doe" → resolveWikilinkDetailed is ambiguous → the row
|
|
651
|
+
// is left queued, nothing linked.
|
|
652
|
+
await store.createNote("# John Doe\n\nsecond.", { path: "people/jd" });
|
|
653
|
+
expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
|
|
654
|
+
expect(getUnresolvedLinksForNote(db, a.id).map((l) => l.target)).toEqual(["John Doe"]);
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
483
658
|
// ---------------------------------------------------------------------------
|
|
484
659
|
// unresolved_wikilinks relationship-column migration — atomicity (vault#555
|
|
485
660
|
// wire+generalist must-fix; W7's migrateToV25-interruption lesson applied).
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { Note } from "./types.js";
|
|
3
3
|
import * as linkOps from "./links.js";
|
|
4
|
-
import { getNote, findNotesByTitle } from "./notes.js";
|
|
4
|
+
import { getNote, findNotesByTitle, extractH1Title } from "./notes.js";
|
|
5
5
|
import { chunkForInClause } from "./sql-in.js";
|
|
6
6
|
import { transaction } from "./txn.js";
|
|
7
7
|
import type { QueryWarning } from "./query-warnings.js";
|
|
@@ -619,11 +619,32 @@ function syncUnresolvedWikilinks(
|
|
|
619
619
|
|
|
620
620
|
/**
|
|
621
621
|
* Try to resolve pending wikilinks AND pending structured-link forward-refs
|
|
622
|
-
* (vault#555) that point to a given
|
|
622
|
+
* (vault#555) that point to a given note. Called when a note is created or
|
|
623
623
|
* its path changes. Each pending row materializes with ITS OWN relationship
|
|
624
624
|
* (a structured link queued via {@link queueUnresolvedLink} backfills with
|
|
625
625
|
* the caller's original relationship, not "wikilink").
|
|
626
626
|
*
|
|
627
|
+
* Deferred resolution now covers all four legs of
|
|
628
|
+
* {@link resolveWikilinkDetailed}: exact path, basename, H1 title, and the
|
|
629
|
+
* explicit `path.ext` form. (Caveat: the candidate pre-filter below matches the
|
|
630
|
+
* title/ext legs via SQL `COLLATE NOCASE`, which case-folds ASCII only — a
|
|
631
|
+
* non-ASCII title differing from the target only in letter case is missed at
|
|
632
|
+
* the candidate stage and re-heals on the source's next save instead. Rare;
|
|
633
|
+
* tracked as a follow-up.) Before this, the sweep matched a pending row to
|
|
634
|
+
* the new note by PATH TEXT ONLY (`target_path = path OR path LIKE
|
|
635
|
+
* '%/'||target_path`) — so a `[[John Doe]]` that resolved at write time via
|
|
636
|
+
* the H1-title fallback (its note's displayed title differs from its path,
|
|
637
|
+
* e.g. `people/jdoe`), or a `[[Foo.csv]]` that resolved via the extension
|
|
638
|
+
* leg, silently never re-healed on a delete→recreate (the exact LB6 gap) and
|
|
639
|
+
* more broadly never backfilled when the target note was created AFTER the
|
|
640
|
+
* referencing note. Each candidate pending row is now VERIFIED through
|
|
641
|
+
* `resolveWikilinkDetailed` against the current DB — a row is healed only
|
|
642
|
+
* when its target string actually resolves to THIS note. An AMBIGUOUS target
|
|
643
|
+
* (≥2 notes now share the path/title) resolves to neither and stays queued,
|
|
644
|
+
* identical to write-time's "don't guess" contract — this also closes the
|
|
645
|
+
* pre-existing asymmetry where the path-only sweep would link an ambiguous
|
|
646
|
+
* `[[Foo]]` to whichever colliding note happened to be created.
|
|
647
|
+
*
|
|
627
648
|
* Returns the number of links resolved.
|
|
628
649
|
*/
|
|
629
650
|
export function resolveUnresolvedWikilinks(
|
|
@@ -632,13 +653,32 @@ export function resolveUnresolvedWikilinks(
|
|
|
632
653
|
noteId: string,
|
|
633
654
|
): number {
|
|
634
655
|
ensureRelationshipColumn(db);
|
|
635
|
-
|
|
656
|
+
|
|
657
|
+
// The newly-created / newly-repathed note supplies the two resolution keys
|
|
658
|
+
// the pending row's `target_path` (a bare path/basename string) can't carry
|
|
659
|
+
// on its own: the note's H1 title (title-fallback leg) and its extension
|
|
660
|
+
// (explicit `path.ext` leg). `getNote` is one indexed PK lookup; the title
|
|
661
|
+
// is parsed from content in JS (no separate query).
|
|
662
|
+
const note = getNote(db, noteId);
|
|
663
|
+
const h1Title = note?.content ? extractH1Title(note.content) : null;
|
|
664
|
+
const pathDotExt = note?.extension ? `${notePath}.${note.extension}` : null;
|
|
665
|
+
|
|
666
|
+
let rows: { source_id: string; target_path: string; relationship: string }[];
|
|
636
667
|
try {
|
|
668
|
+
// Candidate pre-filter: every pending row whose `target_path` COULD
|
|
669
|
+
// resolve to this note under any resolveWikilinkDetailed leg — exact
|
|
670
|
+
// path, basename (target is the last path segment), H1 title, or the
|
|
671
|
+
// `path.ext` form. A `null` bind (no H1 heading / no extension) makes its
|
|
672
|
+
// clause never match (`target_path = NULL` is NULL, i.e. falsy in SQL).
|
|
673
|
+
// The verify step below is what enforces correctness; this clause only
|
|
674
|
+
// BOUNDS how many rows reach the (title-fallback-scanning) resolver.
|
|
637
675
|
rows = db.prepare(`
|
|
638
|
-
SELECT source_id, relationship FROM unresolved_wikilinks
|
|
676
|
+
SELECT source_id, target_path, relationship FROM unresolved_wikilinks
|
|
639
677
|
WHERE target_path = ? COLLATE NOCASE
|
|
640
678
|
OR ? LIKE '%/' || target_path
|
|
641
|
-
|
|
679
|
+
OR target_path = ? COLLATE NOCASE
|
|
680
|
+
OR target_path = ? COLLATE NOCASE
|
|
681
|
+
`).all(notePath, notePath, h1Title, pathDotExt) as typeof rows;
|
|
642
682
|
} catch {
|
|
643
683
|
return 0; // Table doesn't exist
|
|
644
684
|
}
|
|
@@ -649,16 +689,24 @@ export function resolveUnresolvedWikilinks(
|
|
|
649
689
|
for (const row of rows) {
|
|
650
690
|
if (row.source_id === noteId) continue; // Skip self-links
|
|
651
691
|
|
|
692
|
+
// Verify against the SAME resolver write-time uses, so deferred
|
|
693
|
+
// resolution can't diverge from it: heal a pending row ONLY when its
|
|
694
|
+
// target string actually resolves to THIS note now. A miss or an
|
|
695
|
+
// ambiguous result leaves the row queued (surfaced as a visible broken
|
|
696
|
+
// link, and re-tried on the next matching note create).
|
|
697
|
+
const detail = resolveWikilinkDetailed(db, row.target_path);
|
|
698
|
+
if (!detail.resolved || detail.note_id !== noteId) continue;
|
|
699
|
+
|
|
652
700
|
const relationship = row.relationship || WIKILINK_REL;
|
|
653
701
|
linkOps.createLink(db, row.source_id, noteId, relationship);
|
|
654
702
|
resolved++;
|
|
655
703
|
|
|
656
|
-
// Remove
|
|
657
|
-
//
|
|
658
|
-
//
|
|
704
|
+
// Remove exactly this pending row — a source may have BOTH a wikilink and
|
|
705
|
+
// a structured-link forward-ref pending against the same target_path
|
|
706
|
+
// (distinct relationships), so scope the delete to all three PK columns.
|
|
659
707
|
db.prepare(
|
|
660
|
-
"DELETE FROM unresolved_wikilinks WHERE source_id = ? AND
|
|
661
|
-
).run(row.source_id,
|
|
708
|
+
"DELETE FROM unresolved_wikilinks WHERE source_id = ? AND target_path = ? AND relationship = ?",
|
|
709
|
+
).run(row.source_id, row.target_path, relationship);
|
|
662
710
|
}
|
|
663
711
|
|
|
664
712
|
return resolved;
|
|
@@ -840,3 +888,96 @@ export function getContentWikilinkWarnings(
|
|
|
840
888
|
|
|
841
889
|
return warnings;
|
|
842
890
|
}
|
|
891
|
+
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
// Delete-time re-queue (LB6) — a deleted note's INBOUND wikilink edges must
|
|
894
|
+
// come back to life if a note is later recreated at the same path/title.
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Before a note is deleted, re-queue every INBOUND `wikilink`-relationship
|
|
899
|
+
* edge pointing at it into `unresolved_wikilinks`, so that recreating a note
|
|
900
|
+
* matching the original `[[target]]` text (same path, basename, H1 title, or
|
|
901
|
+
* `path.ext` form — anything {@link resolveWikilinkDetailed} would have
|
|
902
|
+
* matched) auto-heals the edge via {@link resolveUnresolvedWikilinks} — which
|
|
903
|
+
* now verifies each pending row through that same resolver — exactly as if
|
|
904
|
+
* the link had never resolved in the first place.
|
|
905
|
+
*
|
|
906
|
+
* Without this, `deleteNote`'s `DELETE FROM notes` cascades the `links` row
|
|
907
|
+
* away (FK `ON DELETE CASCADE`) but leaves `unresolved_wikilinks` untouched —
|
|
908
|
+
* a note recreated at the deleted note's path never gets linked back to,
|
|
909
|
+
* because nothing is pending resolution for it. Only a re-save of the
|
|
910
|
+
* SOURCE note (which reparses its own content from scratch) would recover
|
|
911
|
+
* it; a fresh `[[Foo]]` reference elsewhere would work, but the ORIGINAL
|
|
912
|
+
* source note's edge stayed dead despite its unchanged `[[Foo]]` text.
|
|
913
|
+
*
|
|
914
|
+
* MUST be called BEFORE the note row is deleted — it needs both the `links`
|
|
915
|
+
* row (about to cascade away) and, per source, the CURRENT content (to
|
|
916
|
+
* recover the raw `[[target]]` text the resolver actually matched on: a
|
|
917
|
+
* resolved link's row retains the target NOTE id, not the original bracket
|
|
918
|
+
* text, and title-fallback / basename resolution mean that text can differ
|
|
919
|
+
* from the deleted note's own path).
|
|
920
|
+
*
|
|
921
|
+
* Scope: only `relationship = "wikilink"` inbound edges are re-queued.
|
|
922
|
+
* Explicit typed `links` (structured `links: [{target, relationship}]`
|
|
923
|
+
* entries, vault#555) are left alone — those are hand-authored associations
|
|
924
|
+
* the caller owns, not content-derived, so silently resurrecting them as a
|
|
925
|
+
* forward-ref on an unrelated future note would be surprising. A source
|
|
926
|
+
* that links to itself is never queued (wikilinks never create self-links,
|
|
927
|
+
* so no such inbound row can exist) and is skipped defensively anyway.
|
|
928
|
+
*/
|
|
929
|
+
export function requeueInboundWikilinksForDelete(db: Database, noteId: string): void {
|
|
930
|
+
let inbound: { source_id: string }[];
|
|
931
|
+
try {
|
|
932
|
+
inbound = db.prepare(
|
|
933
|
+
"SELECT DISTINCT source_id FROM links WHERE target_id = ? AND relationship = ?",
|
|
934
|
+
).all(noteId, WIKILINK_REL) as { source_id: string }[];
|
|
935
|
+
} catch {
|
|
936
|
+
return; // links table missing (shouldn't happen post-schema-init, but never block a delete on this)
|
|
937
|
+
}
|
|
938
|
+
if (inbound.length === 0) return;
|
|
939
|
+
|
|
940
|
+
// Cheap string pre-filter (perf): a wikilink can only have resolved to the
|
|
941
|
+
// note being deleted if its raw target text equals one of THIS note's own
|
|
942
|
+
// resolution keys — its path, basename, H1 title, or `path.ext` form (the
|
|
943
|
+
// four legs {@link resolveWikilinkDetailed} matches on; every leg produces a
|
|
944
|
+
// target string equal to one of these, so the key set is a complete
|
|
945
|
+
// necessary-condition superset). Computed ONCE, then each source wikilink's
|
|
946
|
+
// target is gated on set membership BEFORE the expensive resolver call
|
|
947
|
+
// (whose title-fallback leg scans every note's content). Without this, a hub
|
|
948
|
+
// note with hundreds of inbound sources would fire hundreds of full-vault
|
|
949
|
+
// scans in one delete. The resolver still CONFIRMS each survivor — the
|
|
950
|
+
// pre-filter narrows, it doesn't decide.
|
|
951
|
+
const deleted = getNote(db, noteId);
|
|
952
|
+
const keys = new Set<string>();
|
|
953
|
+
const addKey = (s: string | null | undefined): void => {
|
|
954
|
+
const k = s?.trim().toLowerCase();
|
|
955
|
+
if (k) keys.add(k);
|
|
956
|
+
};
|
|
957
|
+
if (deleted?.path) {
|
|
958
|
+
addKey(deleted.path);
|
|
959
|
+
const slash = deleted.path.lastIndexOf("/");
|
|
960
|
+
addKey(slash >= 0 ? deleted.path.slice(slash + 1) : deleted.path); // basename
|
|
961
|
+
if (deleted.extension) addKey(`${deleted.path}.${deleted.extension}`);
|
|
962
|
+
}
|
|
963
|
+
if (deleted?.content) addKey(extractH1Title(deleted.content));
|
|
964
|
+
if (keys.size === 0) return; // no key any wikilink could have matched on
|
|
965
|
+
|
|
966
|
+
for (const { source_id } of inbound) {
|
|
967
|
+
if (source_id === noteId) continue; // defensive — wikilinks never self-link
|
|
968
|
+
|
|
969
|
+
const source = getNote(db, source_id);
|
|
970
|
+
if (!source || !source.content) continue;
|
|
971
|
+
|
|
972
|
+
const targetMap = dedupeWikilinkTargets(parseWikilinks(source.content));
|
|
973
|
+
for (const [, wl] of targetMap) {
|
|
974
|
+
// Skip the resolver unless this target text could match the deleted
|
|
975
|
+
// note by one of its resolution keys (necessary-condition pre-filter).
|
|
976
|
+
if (!keys.has(wl.target.trim().toLowerCase())) continue;
|
|
977
|
+
const detail = resolveWikilinkDetailed(db, wl.target);
|
|
978
|
+
if (detail.resolved && detail.note_id === noteId) {
|
|
979
|
+
queueUnresolvedLink(db, source_id, wl.target, WIKILINK_REL);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
package/package.json
CHANGED
package/src/routes.ts
CHANGED
|
@@ -178,6 +178,105 @@ function jsonWithWarnings(data: unknown, warnings: QueryWarning[], status = 200)
|
|
|
178
178
|
return res;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Sane upper bound on a mutating-route JSON request body (LB7c). Without
|
|
183
|
+
* this, a giant field (most commonly a note's `content`) sails straight
|
|
184
|
+
* through `req.json()` and into wikilink parsing + the store write with no
|
|
185
|
+
* gate at all — reproduced at 50MB `content` -> 201 Created, RSS 92MB ->
|
|
186
|
+
* 663MB (memory-DoS via a single request). 10MB is generous for
|
|
187
|
+
* hand-authored or transcribed note content (multiple hours of dense
|
|
188
|
+
* transcript) while bounding the failure mode. Mirrors `/upload`'s
|
|
189
|
+
* `MAX_UPLOAD_BYTES` file-size gate for the JSON-body transport.
|
|
190
|
+
*/
|
|
191
|
+
export const MAX_JSON_BODY_BYTES = 10 * 1024 * 1024;
|
|
192
|
+
|
|
193
|
+
function payloadTooLargeResponse(bytes: number): Response {
|
|
194
|
+
return json(
|
|
195
|
+
{
|
|
196
|
+
error: `Request body too large (${(bytes / 1024 / 1024).toFixed(1)}MB). Max: ${Math.round(MAX_JSON_BODY_BYTES / 1024 / 1024)}MB`,
|
|
197
|
+
error_type: "payload_too_large",
|
|
198
|
+
limit: MAX_JSON_BODY_BYTES,
|
|
199
|
+
got: bytes,
|
|
200
|
+
},
|
|
201
|
+
413,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Single choke point for every mutating route that reads a JSON request
|
|
207
|
+
* body (LB7 — door asymmetry fix). Three failure modes, all returning a
|
|
208
|
+
* clean 400/413 instead of falling through to server.ts's generic
|
|
209
|
+
* `{ error: "Internal server error" }` 500-with-no-`error_type` (LB7a), or
|
|
210
|
+
* silently succeeding on a wrong-shape body (LB7b — `null` used to throw a
|
|
211
|
+
* TypeError deep in the handler; `42`/`[]` used to sail through property
|
|
212
|
+
* access as `undefined` and create a blank note/no-op update):
|
|
213
|
+
*
|
|
214
|
+
* 1. **Oversize** — `Content-Length` over {@link MAX_JSON_BODY_BYTES}
|
|
215
|
+
* rejects BEFORE `req.json()` even buffers/parses the body (the cheap
|
|
216
|
+
* path). When the header is absent or understates the true size
|
|
217
|
+
* (chunked transfer, a lying client), the parsed body's own
|
|
218
|
+
* JSON-serialized size is checked as a backstop — one extra parse, but
|
|
219
|
+
* still cheaper than letting the oversized value reach the store and
|
|
220
|
+
* get re-processed (wikilink scan, indexing, etc.).
|
|
221
|
+
* 2. **Malformed JSON** — `req.json()` rejects; caught here rather than
|
|
222
|
+
* propagating to the top-level handler.
|
|
223
|
+
* 3. **Wrong shape** — valid JSON, but not a plain object (`null`, an
|
|
224
|
+
* array, or a bare primitive). Gated by `requireObject` (default
|
|
225
|
+
* `true`); every current caller wants this — the parameter exists so a
|
|
226
|
+
* future non-object-bodied route doesn't have to bypass the helper
|
|
227
|
+
* entirely to opt out.
|
|
228
|
+
*
|
|
229
|
+
* Callers: check `.ok` and return `.response` on failure; `.body` is the
|
|
230
|
+
* parsed JSON value (still `any` — this validates SHAPE, not the specific
|
|
231
|
+
* fields a given route requires, which callers keep checking themselves).
|
|
232
|
+
*/
|
|
233
|
+
async function parseJsonBody(
|
|
234
|
+
req: Request,
|
|
235
|
+
opts?: { requireObject?: boolean },
|
|
236
|
+
): Promise<{ ok: true; body: any } | { ok: false; response: Response }> {
|
|
237
|
+
const contentLengthHeader = req.headers.get("content-length");
|
|
238
|
+
if (contentLengthHeader) {
|
|
239
|
+
const declaredBytes = Number(contentLengthHeader);
|
|
240
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_JSON_BODY_BYTES) {
|
|
241
|
+
return { ok: false, response: payloadTooLargeResponse(declaredBytes) };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const INVALID_JSON = Symbol("invalid-json");
|
|
246
|
+
const parsed = await req.json().catch(() => INVALID_JSON);
|
|
247
|
+
if (parsed === INVALID_JSON) {
|
|
248
|
+
return { ok: false, response: json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!contentLengthHeader) {
|
|
252
|
+
const approxBytes = Buffer.byteLength(JSON.stringify(parsed) ?? "", "utf8");
|
|
253
|
+
if (approxBytes > MAX_JSON_BODY_BYTES) {
|
|
254
|
+
return { ok: false, response: payloadTooLargeResponse(approxBytes) };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const requireObject = opts?.requireObject ?? true;
|
|
259
|
+
if (requireObject && (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))) {
|
|
260
|
+
// The body PARSED as JSON but is the wrong SHAPE (a primitive/array/null,
|
|
261
|
+
// not an object) — a `invalid_request` per the taxonomy (parallel to
|
|
262
|
+
// `/tags/merge`'s `sources`/`target` shape errors). `invalid_json` is
|
|
263
|
+
// reserved for a genuine parse failure (the branch above).
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
response: json(
|
|
267
|
+
{
|
|
268
|
+
error: "Request body must be a JSON object",
|
|
269
|
+
error_type: "invalid_request",
|
|
270
|
+
hint: `expected a JSON object body — got ${parsed === null ? "null" : Array.isArray(parsed) ? "an array" : typeof parsed}`,
|
|
271
|
+
},
|
|
272
|
+
400,
|
|
273
|
+
),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { ok: true, body: parsed };
|
|
278
|
+
}
|
|
279
|
+
|
|
181
280
|
/**
|
|
182
281
|
* REST-only `removed_param` warning (vault#550) — the flat `date_field` /
|
|
183
282
|
* `date_from` / `date_to` query-string params were removed at 0.6.4
|
|
@@ -1647,8 +1746,39 @@ async function handleNotesInner(
|
|
|
1647
1746
|
|
|
1648
1747
|
// POST /notes — create (single or batch)
|
|
1649
1748
|
if (method === "POST") {
|
|
1650
|
-
const
|
|
1749
|
+
const parsedBody = await parseJsonBody(req);
|
|
1750
|
+
if (!parsedBody.ok) return parsedBody.response;
|
|
1751
|
+
const body = parsedBody.body as any;
|
|
1752
|
+
// `notes` (when present) must actually be an array — otherwise
|
|
1753
|
+
// `items` below iterates whatever `body.notes` happens to be (e.g. a
|
|
1754
|
+
// STRING iterates as one-character items), silently creating a batch
|
|
1755
|
+
// of blank notes (LB7b).
|
|
1756
|
+
if (body.notes !== undefined && !Array.isArray(body.notes)) {
|
|
1757
|
+
return json(
|
|
1758
|
+
{
|
|
1759
|
+
error: "notes must be an array",
|
|
1760
|
+
error_type: "invalid_request",
|
|
1761
|
+
field: "notes",
|
|
1762
|
+
hint: 'pass { "notes": [...] } for a batch, or a single note object with no `notes` key',
|
|
1763
|
+
},
|
|
1764
|
+
400,
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1651
1767
|
const items: any[] = body.notes ?? [body];
|
|
1768
|
+
// Every batch item must itself be a plain object — a primitive/array
|
|
1769
|
+
// entry would otherwise read as an all-`undefined` note (LB7b).
|
|
1770
|
+
for (const item of items) {
|
|
1771
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
1772
|
+
return json(
|
|
1773
|
+
{
|
|
1774
|
+
error: "each note must be a JSON object",
|
|
1775
|
+
error_type: "invalid_request",
|
|
1776
|
+
hint: 'each entry in "notes" (or the top-level body, for a single-note POST) must be an object, e.g. {"content": "..."}',
|
|
1777
|
+
},
|
|
1778
|
+
400,
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1652
1782
|
|
|
1653
1783
|
// Batch cap (#213): refuse oversized batches before doing any work. 500
|
|
1654
1784
|
// is the cap (Benjamin's number) — tighter blast radius than 1000 for
|
|
@@ -2052,7 +2182,9 @@ async function handleNotesInner(
|
|
|
2052
2182
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
2053
2183
|
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2054
2184
|
}
|
|
2055
|
-
const
|
|
2185
|
+
const parsedBody = await parseJsonBody(req);
|
|
2186
|
+
if (!parsedBody.ok) return parsedBody.response;
|
|
2187
|
+
const body = parsedBody.body as { path: string; mimeType: string; transcribe?: boolean };
|
|
2056
2188
|
if (!body.path || !body.mimeType) {
|
|
2057
2189
|
return json(
|
|
2058
2190
|
{ error: "path and mimeType are required", error_type: "missing_required_field", hint: "pass both `path` and `mimeType`" },
|
|
@@ -2235,7 +2367,9 @@ async function handleNotesInner(
|
|
|
2235
2367
|
// Body is parsed up front so the `if_missing: "create"` branch
|
|
2236
2368
|
// (vault#309) can fire when the note doesn't exist. Pre-#309
|
|
2237
2369
|
// shape parsed the body only after the not-found check.
|
|
2238
|
-
const
|
|
2370
|
+
const parsedBody = await parseJsonBody(req);
|
|
2371
|
+
if (!parsedBody.ok) return parsedBody.response;
|
|
2372
|
+
const body = parsedBody.body as any;
|
|
2239
2373
|
const note = await resolveNote(store, idOrPath);
|
|
2240
2374
|
if (!note) {
|
|
2241
2375
|
// vault#309 — `if_missing: "create"` turns this PATCH into a
|
|
@@ -3100,7 +3234,9 @@ export async function handleTags(
|
|
|
3100
3234
|
if (tagScope.allowed && !tagScope.allowed.has(putTagName)) {
|
|
3101
3235
|
return tagScopeForbidden(tagScope.raw ?? []);
|
|
3102
3236
|
}
|
|
3103
|
-
const
|
|
3237
|
+
const parsedBody = await parseJsonBody(req);
|
|
3238
|
+
if (!parsedBody.ok) return parsedBody.response;
|
|
3239
|
+
const body = parsedBody.body as {
|
|
3104
3240
|
description?: string | null;
|
|
3105
3241
|
fields?: Record<string, unknown> | null;
|
|
3106
3242
|
relationships?: Record<string, unknown> | null;
|
|
@@ -3514,7 +3650,9 @@ export async function handleVault(
|
|
|
3514
3650
|
}
|
|
3515
3651
|
|
|
3516
3652
|
if (req.method === "PATCH") {
|
|
3517
|
-
const
|
|
3653
|
+
const parsedBody = await parseJsonBody(req);
|
|
3654
|
+
if (!parsedBody.ok) return parsedBody.response;
|
|
3655
|
+
const body = parsedBody.body as {
|
|
3518
3656
|
description?: string;
|
|
3519
3657
|
config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
|
|
3520
3658
|
};
|
package/src/vault.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { BunStore } from "./vault-store.ts";
|
|
|
11
11
|
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
12
12
|
import { getLinksHydrated } from "../core/src/links.ts";
|
|
13
13
|
import { buildVaultProjection } from "../core/src/vault-projection.ts";
|
|
14
|
-
import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks } from "./routes.ts";
|
|
14
|
+
import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks, MAX_JSON_BODY_BYTES } from "./routes.ts";
|
|
15
15
|
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
16
16
|
import type { TagScopeCtx } from "./routes.ts";
|
|
17
17
|
import { extractApiKey } from "./auth.ts";
|
|
@@ -4198,6 +4198,144 @@ describe("HTTP /notes", async () => {
|
|
|
4198
4198
|
});
|
|
4199
4199
|
});
|
|
4200
4200
|
|
|
4201
|
+
// ---------------------------------------------------------------------------
|
|
4202
|
+
// REST transport hardening — malformed/wrong-shape/oversize JSON bodies
|
|
4203
|
+
// (LB7). Before the fix: malformed JSON on POST /notes, POST
|
|
4204
|
+
// /notes/:id/attachments, and PUT /tags/:name threw past the handler into
|
|
4205
|
+
// server.ts's generic top-level catch — a 500 with no `error_type` (LB7a),
|
|
4206
|
+
// unlike the already-hardened /tags/merge and /tags/:name/rename routes,
|
|
4207
|
+
// which returned a clean 400 `invalid_json`. A syntactically-valid but
|
|
4208
|
+
// wrong-shape body (`null`, `42`, `[]`) either threw a TypeError deep in the
|
|
4209
|
+
// handler or sailed through property access as `undefined`, silently
|
|
4210
|
+
// creating a blank note / no-op update (LB7b). And nothing capped request
|
|
4211
|
+
// body size, so an oversized `content` field reached the store unbounded
|
|
4212
|
+
// (LB7c). Every assertion here MUST fail without the fix (parseJsonBody).
|
|
4213
|
+
// ---------------------------------------------------------------------------
|
|
4214
|
+
describe("REST transport hardening — malformed/wrong-shape/oversize JSON bodies (LB7)", async () => {
|
|
4215
|
+
/** Raw (non-JSON.stringify'd) request body — for malformed-JSON cases mkReq can't express. */
|
|
4216
|
+
function mkRawReq(method: string, path: string, rawBody: string): Request {
|
|
4217
|
+
return new Request(`${BASE}${path}`, {
|
|
4218
|
+
method,
|
|
4219
|
+
body: rawBody,
|
|
4220
|
+
headers: { "Content-Type": "application/json" },
|
|
4221
|
+
});
|
|
4222
|
+
}
|
|
4223
|
+
|
|
4224
|
+
describe("malformed JSON -> clean 400 invalid_json, not a 500 (LB7a)", () => {
|
|
4225
|
+
test("POST /notes", async () => {
|
|
4226
|
+
const res = await handleNotes(mkRawReq("POST", "/notes", "{not valid json"), store, "");
|
|
4227
|
+
expect(res.status).toBe(400);
|
|
4228
|
+
const body = await res.json() as any;
|
|
4229
|
+
expect(body.error_type).toBe("invalid_json");
|
|
4230
|
+
});
|
|
4231
|
+
|
|
4232
|
+
test("POST /notes/:id/attachments", async () => {
|
|
4233
|
+
await store.createNote("x", { id: "att-malformed" });
|
|
4234
|
+
const res = await handleNotes(
|
|
4235
|
+
mkRawReq("POST", "/notes/att-malformed/attachments", "{not valid json"),
|
|
4236
|
+
store,
|
|
4237
|
+
"/att-malformed/attachments",
|
|
4238
|
+
);
|
|
4239
|
+
expect(res.status).toBe(400);
|
|
4240
|
+
const body = await res.json() as any;
|
|
4241
|
+
expect(body.error_type).toBe("invalid_json");
|
|
4242
|
+
});
|
|
4243
|
+
|
|
4244
|
+
test("PATCH /notes/:idOrPath", async () => {
|
|
4245
|
+
await store.createNote("x", { id: "patch-malformed" });
|
|
4246
|
+
const res = await handleNotes(
|
|
4247
|
+
mkRawReq("PATCH", "/notes/patch-malformed", "{not valid json"),
|
|
4248
|
+
store,
|
|
4249
|
+
"/patch-malformed",
|
|
4250
|
+
);
|
|
4251
|
+
expect(res.status).toBe(400);
|
|
4252
|
+
const body = await res.json() as any;
|
|
4253
|
+
expect(body.error_type).toBe("invalid_json");
|
|
4254
|
+
});
|
|
4255
|
+
|
|
4256
|
+
test("PUT /tags/:name — parity with the already-hardened /tags/merge shape", async () => {
|
|
4257
|
+
const res = await handleTags(mkRawReq("PUT", "/tags/malformed", "{not valid json"), store, "/malformed");
|
|
4258
|
+
expect(res.status).toBe(400);
|
|
4259
|
+
const body = await res.json() as any;
|
|
4260
|
+
expect(body.error_type).toBe("invalid_json");
|
|
4261
|
+
});
|
|
4262
|
+
|
|
4263
|
+
test("PATCH /vault-info", async () => {
|
|
4264
|
+
const cfg = { name: "default" } as { name: string };
|
|
4265
|
+
const res = await handleVault(mkRawReq("PATCH", "/vault", "{not valid json"), store, cfg as any);
|
|
4266
|
+
expect(res.status).toBe(400);
|
|
4267
|
+
const body = await res.json() as any;
|
|
4268
|
+
expect(body.error_type).toBe("invalid_json");
|
|
4269
|
+
});
|
|
4270
|
+
});
|
|
4271
|
+
|
|
4272
|
+
// Wrong-SHAPE bodies parse fine as JSON but aren't the object a route
|
|
4273
|
+
// expects — the taxonomy calls that `invalid_request` (parallel to
|
|
4274
|
+
// /tags/merge's sources/target shape errors), NOT `invalid_json` (which is
|
|
4275
|
+
// reserved for a genuine parse failure, asserted in the LB7a block above).
|
|
4276
|
+
describe("wrong-shape (but syntactically valid) JSON body -> 400 invalid_request, not 500 or a silent blank write (LB7b)", () => {
|
|
4277
|
+
test("POST /notes with a `null` body -> 400 invalid_request, not a 500 TypeError", async () => {
|
|
4278
|
+
const res = await handleNotes(mkReq("POST", "/notes", null), store, "");
|
|
4279
|
+
expect(res.status).toBe(400);
|
|
4280
|
+
const body = await res.json() as any;
|
|
4281
|
+
expect(body.error_type).toBe("invalid_request");
|
|
4282
|
+
});
|
|
4283
|
+
|
|
4284
|
+
test("POST /notes with a bare number body -> 400 invalid_request, not a silently-created blank note", async () => {
|
|
4285
|
+
const res = await handleNotes(mkReq("POST", "/notes", 42), store, "");
|
|
4286
|
+
expect(res.status).toBe(400);
|
|
4287
|
+
const body = await res.json() as any;
|
|
4288
|
+
expect(body.error_type).toBe("invalid_request");
|
|
4289
|
+
const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
|
|
4290
|
+
expect(after).toHaveLength(0); // no blank note landed
|
|
4291
|
+
});
|
|
4292
|
+
|
|
4293
|
+
test("POST /notes with a bare array body -> 400 invalid_request, not a silently-created blank note", async () => {
|
|
4294
|
+
const res = await handleNotes(mkReq("POST", "/notes", []), store, "");
|
|
4295
|
+
expect(res.status).toBe(400);
|
|
4296
|
+
const body = await res.json() as any;
|
|
4297
|
+
expect(body.error_type).toBe("invalid_request");
|
|
4298
|
+
const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
|
|
4299
|
+
expect(after).toHaveLength(0);
|
|
4300
|
+
});
|
|
4301
|
+
|
|
4302
|
+
test("POST /notes with notes: \"not-an-array\" -> 400 invalid_request, not per-character blank notes", async () => {
|
|
4303
|
+
const res = await handleNotes(mkReq("POST", "/notes", { notes: "oops" }), store, "");
|
|
4304
|
+
expect(res.status).toBe(400);
|
|
4305
|
+
const body = await res.json() as any;
|
|
4306
|
+
expect(body.error_type).toBe("invalid_request");
|
|
4307
|
+
expect(body.field).toBe("notes");
|
|
4308
|
+
const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
|
|
4309
|
+
expect(after).toHaveLength(0);
|
|
4310
|
+
});
|
|
4311
|
+
|
|
4312
|
+
test("POST /notes with a non-object item inside notes[] -> 400 invalid_request", async () => {
|
|
4313
|
+
const res = await handleNotes(
|
|
4314
|
+
mkReq("POST", "/notes", { notes: [{ content: "ok", path: "ok-one" }, 42] }),
|
|
4315
|
+
store,
|
|
4316
|
+
"",
|
|
4317
|
+
);
|
|
4318
|
+
expect(res.status).toBe(400);
|
|
4319
|
+
const body = await res.json() as any;
|
|
4320
|
+
expect(body.error_type).toBe("invalid_request");
|
|
4321
|
+
const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
|
|
4322
|
+
expect(after).toHaveLength(0); // the whole batch is rejected, not a partial write
|
|
4323
|
+
});
|
|
4324
|
+
});
|
|
4325
|
+
|
|
4326
|
+
describe("oversize request body -> 413, not an unbounded write (LB7c)", () => {
|
|
4327
|
+
test("POST /notes with an oversized content field is rejected", async () => {
|
|
4328
|
+
const huge = "x".repeat(MAX_JSON_BODY_BYTES + 1024);
|
|
4329
|
+
const res = await handleNotes(mkReq("POST", "/notes", { content: huge, path: "huge-note" }), store, "");
|
|
4330
|
+
expect(res.status).toBe(413);
|
|
4331
|
+
const body = await res.json() as any;
|
|
4332
|
+
expect(body.error_type).toBe("payload_too_large");
|
|
4333
|
+
const stored = await store.getNoteByPath("huge-note");
|
|
4334
|
+
expect(stored).toBeNull();
|
|
4335
|
+
});
|
|
4336
|
+
});
|
|
4337
|
+
});
|
|
4338
|
+
|
|
4201
4339
|
// ---------------------------------------------------------------------------
|
|
4202
4340
|
// REST tag-scope confidentiality (security review). expand_links must not
|
|
4203
4341
|
// inline out-of-scope wikilinked content; include_links must not hydrate
|