@openparachute/vault 0.7.9-rc.2 → 0.7.9-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/core.test.ts +546 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/link-count.test.ts +29 -0
- package/core/src/links.ts +43 -0
- package/core/src/mcp-manifest.ts +10 -6
- package/core/src/mcp.ts +129 -44
- package/core/src/notes.ts +51 -2
- package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
- package/core/src/schema.ts +28 -1
- package/core/src/store.ts +180 -57
- package/core/src/txn.test.ts +33 -1
- package/core/src/txn.ts +80 -7
- package/core/src/types.ts +11 -0
- package/core/src/vault-projection.ts +8 -1
- package/core/src/wikilinks.ts +873 -44
- package/package.json +2 -2
- package/src/aggregate-routes.test.ts +76 -0
- package/src/config.ts +8 -2
- package/src/mcp-http.test.ts +51 -1
- package/src/mcp-tools.ts +106 -5
- package/src/mirror-routes.test.ts +47 -0
- package/src/release-plan.test.ts +90 -1
- package/src/routes.ts +280 -66
- package/src/routing.test.ts +67 -0
- package/src/tag-scope-query-tag.test.ts +374 -0
- package/src/tag-scope.ts +116 -0
- package/src/test-support/vault-714-find-path.json +58 -0
- package/src/test-support/vault-714-graph.json +27 -0
- package/src/test-support/vault-714-has_links.json +96 -0
- package/src/test-support/vault-714-include_broken_links.json +118 -0
- package/src/test-support/vault-714-include_link_count.json +348 -0
- package/src/test-support/vault-714-include_links.json +86 -0
- package/src/test-support/vault-714-near.json +78 -0
- package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
- package/src/vault.test.ts +928 -1
- package/src/write-warnings-scope.test.ts +344 -0
- package/src/ws-server.ts +16 -3
- package/src/ws-subscribe.test.ts +76 -2
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration v27 → v28: fold the lazy unresolved_wikilinks relationship-column
|
|
3
|
+
* self-heal into the versioned chain (vault#567 item 2).
|
|
4
|
+
*
|
|
5
|
+
* The interesting path is an EXISTING vault whose `unresolved_wikilinks`
|
|
6
|
+
* table still has the pre-#555 2-column PK — not a fresh vault (those
|
|
7
|
+
* create the 3-column table lazily, or never create it). Gating is the
|
|
8
|
+
* load-bearing claim: a vault that never queued a dangling link must NOT
|
|
9
|
+
* grow the table on open.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import { initSchema, SCHEMA_VERSION } from "./schema.js";
|
|
14
|
+
import { SqliteStore } from "./store.js";
|
|
15
|
+
import { ensureRelationshipColumn } from "./wikilinks.js";
|
|
16
|
+
|
|
17
|
+
function hasTable(db: Database, name: string): boolean {
|
|
18
|
+
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function columnNames(db: Database, table: string): string[] {
|
|
22
|
+
return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("SCHEMA_VERSION v28", () => {
|
|
26
|
+
it("bumped SCHEMA_VERSION to at least 28 (unresolved_wikilinks heal is versioned)", () => {
|
|
27
|
+
expect(SCHEMA_VERSION).toBeGreaterThanOrEqual(28);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("migrateToV28 — does NOT create unresolved_wikilinks on a vault that never needed it", () => {
|
|
32
|
+
it("a fresh vault (no dangling links) still has no unresolved_wikilinks table after initSchema", () => {
|
|
33
|
+
const db = new Database(":memory:");
|
|
34
|
+
const store = new SqliteStore(db);
|
|
35
|
+
expect(store).toBeDefined();
|
|
36
|
+
expect(hasTable(db, "unresolved_wikilinks")).toBe(false);
|
|
37
|
+
expect(
|
|
38
|
+
(db.prepare("SELECT MAX(version) AS v FROM schema_version").get() as { v: number }).v,
|
|
39
|
+
).toBe(SCHEMA_VERSION);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("migrateToV28 — heals a pre-#555 2-column table at boot", () => {
|
|
44
|
+
let db: Database;
|
|
45
|
+
let store: SqliteStore;
|
|
46
|
+
let sourceId: string;
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
db = new Database(":memory:");
|
|
50
|
+
store = new SqliteStore(db);
|
|
51
|
+
const src = await store.createNote("plain body, no wikilinks", { path: "src-note" });
|
|
52
|
+
await store.createNote("plain target", { path: "Target A" });
|
|
53
|
+
sourceId = src.id;
|
|
54
|
+
db.exec(`
|
|
55
|
+
CREATE TABLE unresolved_wikilinks (
|
|
56
|
+
source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
|
57
|
+
target_path TEXT NOT NULL COLLATE NOCASE,
|
|
58
|
+
PRIMARY KEY (source_id, target_path)
|
|
59
|
+
)
|
|
60
|
+
`);
|
|
61
|
+
db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)").run(
|
|
62
|
+
src.id,
|
|
63
|
+
"Target B",
|
|
64
|
+
);
|
|
65
|
+
db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)").run(
|
|
66
|
+
src.id,
|
|
67
|
+
"Target A",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("initSchema rebuilds the 3-column PK and backfills relationship='wikilink'", () => {
|
|
72
|
+
expect(columnNames(db, "unresolved_wikilinks")).not.toContain("relationship");
|
|
73
|
+
initSchema(db);
|
|
74
|
+
expect(columnNames(db, "unresolved_wikilinks")).toContain("relationship");
|
|
75
|
+
expect(hasTable(db, "unresolved_wikilinks_pre_v555")).toBe(false);
|
|
76
|
+
const rows = db
|
|
77
|
+
.prepare("SELECT source_id, target_path, relationship FROM unresolved_wikilinks ORDER BY target_path")
|
|
78
|
+
.all() as { source_id: string; target_path: string; relationship: string }[];
|
|
79
|
+
expect(rows).toHaveLength(2);
|
|
80
|
+
expect(rows.every((r) => r.relationship === "wikilink")).toBe(true);
|
|
81
|
+
expect(rows.every((r) => r.source_id === sourceId)).toBe(true);
|
|
82
|
+
expect(
|
|
83
|
+
(db.prepare("SELECT MAX(version) AS v FROM schema_version").get() as { v: number }).v,
|
|
84
|
+
).toBe(SCHEMA_VERSION);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is idempotent — a second initSchema neither throws nor duplicates rows", () => {
|
|
88
|
+
initSchema(db);
|
|
89
|
+
initSchema(db);
|
|
90
|
+
const count = (db.prepare("SELECT COUNT(*) AS c FROM unresolved_wikilinks").get() as { c: number }).c;
|
|
91
|
+
expect(count).toBe(2);
|
|
92
|
+
expect(columnNames(db, "unresolved_wikilinks")).toContain("relationship");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("a 3-column table is a no-op (no rewrite)", () => {
|
|
96
|
+
initSchema(db); // first pass heals
|
|
97
|
+
const before = db.prepare("SELECT * FROM unresolved_wikilinks ORDER BY target_path").all();
|
|
98
|
+
ensureRelationshipColumn(db);
|
|
99
|
+
initSchema(db);
|
|
100
|
+
const after = db.prepare("SELECT * FROM unresolved_wikilinks ORDER BY target_path").all();
|
|
101
|
+
expect(after).toEqual(before);
|
|
102
|
+
});
|
|
103
|
+
});
|
package/core/src/schema.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { rebuildIndexes, listIndexedFields } from "./indexed-fields.js";
|
|
|
4
4
|
import { findMixedTypeIndexedFieldNotes } from "./doctor.js";
|
|
5
5
|
import { transaction } from "./txn.js";
|
|
6
6
|
import { timestampToMs } from "./cursor.js";
|
|
7
|
+
import { ensureRelationshipColumn } from "./wikilinks.js";
|
|
7
8
|
|
|
8
|
-
export const SCHEMA_VERSION =
|
|
9
|
+
export const SCHEMA_VERSION = 28;
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Deterministic last-resort epoch for a note whose `updated_at` AND
|
|
@@ -621,6 +622,13 @@ export function initSchema(db: Database): void {
|
|
|
621
622
|
// already reads as "needs embedding." See vault semantic-search MVP plan.
|
|
622
623
|
migrateToV27(db);
|
|
623
624
|
|
|
625
|
+
// Migrate v27 → v28: fold the lazy unresolved_wikilinks `relationship`
|
|
626
|
+
// column self-heal into the versioned chain (vault#567 item 2). Gated:
|
|
627
|
+
// no table → no-op; 3-column table → no-op; only a pre-#555 2-column
|
|
628
|
+
// table is rebuilt. Does not create the table on vaults that never
|
|
629
|
+
// queued an unresolved link, and does not rewrite notes.
|
|
630
|
+
migrateToV28(db);
|
|
631
|
+
|
|
624
632
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
625
633
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
626
634
|
rebuildIndexes(db);
|
|
@@ -1729,6 +1737,25 @@ function migrateToV27(db: Database): void {
|
|
|
1729
1737
|
});
|
|
1730
1738
|
}
|
|
1731
1739
|
|
|
1740
|
+
/**
|
|
1741
|
+
* Migrate v27 → v28: version the unresolved_wikilinks relationship-column
|
|
1742
|
+
* self-heal that used to run lazily on first touch (vault#567 item 2).
|
|
1743
|
+
*
|
|
1744
|
+
* COST: this does NOT rewrite every vault on open. `ensureRelationshipColumn`
|
|
1745
|
+
* gates on PRAGMA table_info:
|
|
1746
|
+
* - table missing (most vaults — lazy creation, never queued a dangling
|
|
1747
|
+
* link) → return immediately, no CREATE;
|
|
1748
|
+
* - `relationship` already present (post-#555 / fresh 3-column table) →
|
|
1749
|
+
* return immediately;
|
|
1750
|
+
* - pre-#555 2-column table only → one atomic 4-statement rebuild of
|
|
1751
|
+
* `unresolved_wikilinks` (typically tiny; not a notes rewrite).
|
|
1752
|
+
* Subsequent opens are the same PRAGMA no-op. The per-touch lazy call in
|
|
1753
|
+
* `wikilinks.ts` stays as a no-op safety net after this version.
|
|
1754
|
+
*/
|
|
1755
|
+
function migrateToV28(db: Database): void {
|
|
1756
|
+
ensureRelationshipColumn(db);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1732
1759
|
function hasTable(db: Database, name: string): boolean {
|
|
1733
1760
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
1734
1761
|
return !!row;
|
package/core/src/store.ts
CHANGED
|
@@ -12,16 +12,25 @@ import {
|
|
|
12
12
|
} from "./indexed-fields.js";
|
|
13
13
|
import {
|
|
14
14
|
syncWikilinks,
|
|
15
|
+
parseWikilinks,
|
|
16
|
+
resolveWikilinkDetailed,
|
|
17
|
+
rewriteWikilinkTargets,
|
|
18
|
+
wikilinkPathForm,
|
|
19
|
+
wikilinkRenameCandidates,
|
|
15
20
|
resolveUnresolvedWikilinks,
|
|
16
21
|
resolveOrQueueLink,
|
|
17
22
|
clearQueuedLink,
|
|
18
23
|
clearQueuedLinkTarget,
|
|
24
|
+
clearAmbiguousLink,
|
|
25
|
+
clearAmbiguousLinkTarget,
|
|
26
|
+
refreshAmbiguousLinks,
|
|
27
|
+
noteResolutionKeys,
|
|
28
|
+
pathResolutionKeys,
|
|
19
29
|
requeueInboundWikilinksForDelete,
|
|
20
30
|
} from "./wikilinks.js";
|
|
21
31
|
import { chunkForInClause } from "./sql-in.js";
|
|
22
|
-
import { pathTitle } from "./paths.js";
|
|
23
32
|
import { timestampToMs } from "./cursor.js";
|
|
24
|
-
import { transaction } from "./txn.js";
|
|
33
|
+
import { afterCommit, transaction } from "./txn.js";
|
|
25
34
|
import { HookRegistry } from "./hooks.js";
|
|
26
35
|
import {
|
|
27
36
|
loadTagHierarchy,
|
|
@@ -257,6 +266,9 @@ export class BunSqliteStore implements Store {
|
|
|
257
266
|
// resolved target.
|
|
258
267
|
linkOps.deleteLinksBySourceRelationship(this.db, note.id, fieldName);
|
|
259
268
|
clearQueuedLink(this.db, note.id, fieldName);
|
|
269
|
+
// vault#581 twin: a recorded ambiguity for the OLD value must go too,
|
|
270
|
+
// or the field would keep reporting a collision it no longer has.
|
|
271
|
+
clearAmbiguousLink(this.db, note.id, fieldName);
|
|
260
272
|
|
|
261
273
|
if (typeof nextValue === "string" && nextValue.trim() !== "") {
|
|
262
274
|
// Resolves now, or queues a lazy forward-ref on a miss — same
|
|
@@ -374,7 +386,10 @@ export class BunSqliteStore implements Store {
|
|
|
374
386
|
// still-present-but-unresolved element stays queued (re-queued
|
|
375
387
|
// idempotently above).
|
|
376
388
|
for (const value of priorSet) {
|
|
377
|
-
if (!nextSet.has(value))
|
|
389
|
+
if (!nextSet.has(value)) {
|
|
390
|
+
clearQueuedLinkTarget(this.db, noteId, fieldName, value);
|
|
391
|
+
clearAmbiguousLinkTarget(this.db, noteId, fieldName, value); // vault#581 twin
|
|
392
|
+
}
|
|
378
393
|
}
|
|
379
394
|
}
|
|
380
395
|
|
|
@@ -519,6 +534,10 @@ export class BunSqliteStore implements Store {
|
|
|
519
534
|
|
|
520
535
|
if (note.path) {
|
|
521
536
|
resolveUnresolvedWikilinks(this.db, note.path, note.id);
|
|
537
|
+
// vault#581 — this note becoming a NEW candidate can only make an
|
|
538
|
+
// existing ambiguity wider, but the recorded `candidate_count` has to
|
|
539
|
+
// stay honest. Bounded to rows whose target could name this note.
|
|
540
|
+
refreshAmbiguousLinks(this.db, noteResolutionKeys(note));
|
|
522
541
|
}
|
|
523
542
|
|
|
524
543
|
// Reference-field auto-link (vault#typed-reference-field) — no prior
|
|
@@ -601,71 +620,173 @@ export class BunSqliteStore implements Store {
|
|
|
601
620
|
}
|
|
602
621
|
}
|
|
603
622
|
|
|
604
|
-
|
|
623
|
+
// vault#708 — the rename cascade has to know which brackets resolved to
|
|
624
|
+
// THIS note, and resolution is only observable BEFORE the path moves.
|
|
625
|
+
// So the plan (source note -> the exact bracket texts that pointed here)
|
|
626
|
+
// is computed against the pre-write index; `cascadeRename` applies it
|
|
627
|
+
// after, when the new path is known.
|
|
628
|
+
const cascadePlan = oldPath && updates.path !== undefined && oldPath !== updates.path
|
|
629
|
+
? this.planCascadeRename(id, oldPath)
|
|
630
|
+
: undefined;
|
|
631
|
+
|
|
632
|
+
// Keep the note-row write and every derived index/cascade write in one
|
|
633
|
+
// atomic unit. Any later failure must leave the note and its indexes at
|
|
634
|
+
// the pre-update state rather than committing only the first SQL write.
|
|
635
|
+
const note = this.transaction(() => {
|
|
636
|
+
const note = noteOps.updateNote(this.db, id, updates);
|
|
637
|
+
|
|
638
|
+
// Wikilink sync runs against the *resulting* content. For append/prepend
|
|
639
|
+
// we don't have the new value pre-write — read it back off the returned
|
|
640
|
+
// note so a `[[Foo]]` introduced via append still creates the link.
|
|
641
|
+
if (updates.content !== undefined || updates.append !== undefined || updates.prepend !== undefined) {
|
|
642
|
+
syncWikilinks(this.db, id, note.content);
|
|
643
|
+
}
|
|
605
644
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
645
|
+
if (updates.path !== undefined && note.path) {
|
|
646
|
+
if (cascadePlan && oldPath && oldPath !== note.path) {
|
|
647
|
+
this.cascadeRename(cascadePlan, note, oldPath);
|
|
648
|
+
}
|
|
649
|
+
resolveUnresolvedWikilinks(this.db, note.path, id);
|
|
650
|
+
// vault#581 — a rename is one of the two ways an ambiguity stops being
|
|
651
|
+
// ambiguous (the other is a delete). Sweep the OLD path's keys as well
|
|
652
|
+
// as the new ones: it's the target the collision was recorded under.
|
|
653
|
+
refreshAmbiguousLinks(this.db, [
|
|
654
|
+
...pathResolutionKeys(oldPath, note.extension),
|
|
655
|
+
...noteResolutionKeys(note),
|
|
656
|
+
]);
|
|
657
|
+
}
|
|
612
658
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
659
|
+
// Reference-field auto-link sync (vault#typed-reference-field). Only
|
|
660
|
+
// when this call actually touched `metadata` — see the read above for
|
|
661
|
+
// why a content/tags/path-only update is skipped.
|
|
662
|
+
if (updates.metadata !== undefined) {
|
|
663
|
+
this.syncReferenceFieldLinks(note, priorMetadataForRefs);
|
|
616
664
|
}
|
|
617
|
-
resolveUnresolvedWikilinks(this.db, note.path, id);
|
|
618
|
-
}
|
|
619
665
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
// why a content/tags/path-only update is skipped.
|
|
623
|
-
if (updates.metadata !== undefined) {
|
|
624
|
-
this.syncReferenceFieldLinks(note, priorMetadataForRefs);
|
|
625
|
-
}
|
|
666
|
+
return note;
|
|
667
|
+
});
|
|
626
668
|
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
669
|
+
// Dispatch and invalidate only once the whole surrounding transaction
|
|
670
|
+
// stack commits. In a multi-item transactionAsync batch the inner Store
|
|
671
|
+
// transaction is a SAVEPOINT, so "after the inner closure" is not yet
|
|
672
|
+
// post-commit; afterCommit carries these through to the outer boundary.
|
|
673
|
+
afterCommit(this.db, () => {
|
|
674
|
+
this.invalidateConfigCachesForPath(note.path, oldPath);
|
|
675
|
+
this.hooks.dispatch("updated", note, this);
|
|
676
|
+
});
|
|
634
677
|
|
|
635
678
|
return note;
|
|
636
679
|
}
|
|
637
680
|
|
|
638
681
|
/**
|
|
639
|
-
*
|
|
640
|
-
*
|
|
682
|
+
* Plan the rename cascade (vault#708) — for a note about to move off
|
|
683
|
+
* `oldPath`, the exact `[[bracket]]` texts in each source note that
|
|
684
|
+
* RESOLVED TO THIS NOTE, keyed by source id.
|
|
685
|
+
*
|
|
686
|
+
* MUST run before the path write: resolution is a property of the index,
|
|
687
|
+
* and once the row moves, `[[Rename]]` no longer means what it meant.
|
|
688
|
+
*
|
|
689
|
+
* The source set comes from the `links` rows pointing at this note
|
|
690
|
+
* (`relationship = 'wikilink'`) — an index-driven prefilter that already
|
|
691
|
+
* excludes the two classes the old basename-matching cascade corrupted:
|
|
692
|
+
* - AMBIGUOUS brackets (`[[Rename]]` with `keep/Rename` AND `move/Rename`
|
|
693
|
+
* present) never get a `links` row at all — they live in
|
|
694
|
+
* `ambiguous_wikilinks` (vault#581/#707) and are healed by
|
|
695
|
+
* `refreshAmbiguousLinks` after the rename, not by a text rewrite.
|
|
696
|
+
* - brackets that resolved to a DIFFERENT same-basename note, whose
|
|
697
|
+
* `links` row points elsewhere.
|
|
698
|
+
* The note itself is added to the set because a self-referencing bracket
|
|
699
|
+
* is deliberately never given a `links` row (`syncWikilinks` skips
|
|
700
|
+
* self-links) yet the old cascade rewrote it — parity.
|
|
701
|
+
*
|
|
702
|
+
* `links` rows only say "this note links here", not WHICH bracket did it
|
|
703
|
+
* (`syncWikilinks` dedupes by resolved target id and stores no bracket
|
|
704
|
+
* text), so every candidate source is re-parsed and each bracket
|
|
705
|
+
* re-resolved. That is also what keeps a source's OTHER same-named
|
|
706
|
+
* brackets untouched.
|
|
641
707
|
*/
|
|
642
|
-
private
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
708
|
+
private planCascadeRename(id: string, oldPath: string): Map<string, string[]> {
|
|
709
|
+
const rows = this.db.prepare(
|
|
710
|
+
"SELECT source_id FROM links WHERE target_id = ? AND relationship = 'wikilink'",
|
|
711
|
+
).all(id) as { source_id: string }[];
|
|
712
|
+
const sourceIds = new Set<string>(rows.map((r) => r.source_id));
|
|
713
|
+
sourceIds.add(id);
|
|
714
|
+
|
|
715
|
+
const plan = new Map<string, string[]>();
|
|
716
|
+
for (const sourceId of sourceIds) {
|
|
717
|
+
const row = this.db.prepare("SELECT content FROM notes WHERE id = ?")
|
|
718
|
+
.get(sourceId) as { content: string } | null;
|
|
719
|
+
if (!row?.content) continue;
|
|
720
|
+
const targets: string[] = [];
|
|
721
|
+
const seen = new Set<string>();
|
|
722
|
+
for (const wl of parseWikilinks(row.content)) {
|
|
723
|
+
const key = wl.target.toLowerCase();
|
|
724
|
+
if (seen.has(key)) continue;
|
|
725
|
+
seen.add(key);
|
|
726
|
+
// Only path-derived forms of the old path can be invalidated by a
|
|
727
|
+
// repath; an H1-title-fallback bracket keeps resolving and must not
|
|
728
|
+
// be touched.
|
|
729
|
+
if (!wikilinkPathForm(wl.target, oldPath)) continue;
|
|
730
|
+
const detail = resolveWikilinkDetailed(this.db, wl.target);
|
|
731
|
+
if (detail.resolved && detail.note_id === id) targets.push(wl.target);
|
|
732
|
+
}
|
|
733
|
+
if (targets.length > 0) plan.set(sourceId, targets);
|
|
734
|
+
}
|
|
735
|
+
return plan;
|
|
736
|
+
}
|
|
658
737
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
738
|
+
/**
|
|
739
|
+
* Apply a {@link planCascadeRename} plan after the path write: rewrite only
|
|
740
|
+
* the brackets that resolved to the renamed note, preserving each one's
|
|
741
|
+
* shape (basename stays a basename, full path stays a full path, an
|
|
742
|
+
* explicit `.ext` keeps its `.ext`). A basename widens to the full path
|
|
743
|
+
* only when the NEW basename would no longer resolve back to this note —
|
|
744
|
+
* i.e. the move created a fresh collision.
|
|
745
|
+
*
|
|
746
|
+
* Each rewritten source is re-parsed via `syncWikilinks`, so `links`,
|
|
747
|
+
* `unresolved_wikilinks` and `ambiguous_wikilinks` stay consistent with
|
|
748
|
+
* the new text.
|
|
749
|
+
*/
|
|
750
|
+
private cascadeRename(plan: Map<string, string[]>, note: Note, oldPath: string): void {
|
|
751
|
+
if (plan.size === 0 || !note.path) return;
|
|
752
|
+
const newPath = note.path;
|
|
753
|
+
|
|
754
|
+
// One resolution per distinct bracket text, shared across sources.
|
|
755
|
+
const replacement = new Map<string, string>();
|
|
756
|
+
|
|
757
|
+
for (const [sourceId, targets] of plan) {
|
|
758
|
+
const row = this.db.prepare("SELECT content FROM notes WHERE id = ?")
|
|
759
|
+
.get(sourceId) as { content: string } | null;
|
|
760
|
+
if (!row?.content) continue;
|
|
761
|
+
|
|
762
|
+
const mapping = new Map<string, string>();
|
|
763
|
+
for (const target of targets) {
|
|
764
|
+
const key = target.toLowerCase();
|
|
765
|
+
let next = replacement.get(key);
|
|
766
|
+
if (next === undefined) {
|
|
767
|
+
const form = wikilinkPathForm(target, oldPath);
|
|
768
|
+
if (!form) continue;
|
|
769
|
+
const candidates = wikilinkRenameCandidates(form, newPath, note.extension);
|
|
770
|
+
// First candidate that actually resolves back to this note wins;
|
|
771
|
+
// if none does (e.g. every shape is now ambiguous), keep the
|
|
772
|
+
// preferred shape rather than inventing a third one.
|
|
773
|
+
next = candidates.find((c) => {
|
|
774
|
+
const detail = resolveWikilinkDetailed(this.db, c);
|
|
775
|
+
return detail.resolved && detail.note_id === note.id;
|
|
776
|
+
}) ?? candidates[0]!;
|
|
777
|
+
replacement.set(key, next);
|
|
778
|
+
}
|
|
779
|
+
if (next !== target) mapping.set(key, next);
|
|
664
780
|
}
|
|
781
|
+
if (mapping.size === 0) continue;
|
|
665
782
|
|
|
783
|
+
const updated = rewriteWikilinkTargets(
|
|
784
|
+
row.content,
|
|
785
|
+
(target) => mapping.get(target.toLowerCase()) ?? null,
|
|
786
|
+
);
|
|
666
787
|
if (updated !== row.content) {
|
|
667
|
-
noteOps.updateNote(this.db,
|
|
668
|
-
syncWikilinks(this.db,
|
|
788
|
+
noteOps.updateNote(this.db, sourceId, { content: updated });
|
|
789
|
+
syncWikilinks(this.db, sourceId, updated);
|
|
669
790
|
}
|
|
670
791
|
}
|
|
671
792
|
}
|
|
@@ -706,7 +827,13 @@ export class BunSqliteStore implements Store {
|
|
|
706
827
|
// comment for why this must run pre-delete and what it deliberately
|
|
707
828
|
// excludes (typed `links`, not just wikilinks).
|
|
708
829
|
requeueInboundWikilinksForDelete(this.db, id);
|
|
830
|
+
// vault#581 — the deleted note's resolution keys, captured BEFORE the row
|
|
831
|
+
// goes away and swept AFTER, so a `[[Dup]]` that was ambiguous only
|
|
832
|
+
// because of THIS note resolves (or, if it was the last candidate,
|
|
833
|
+
// demotes to an ordinary broken link).
|
|
834
|
+
const ambiguityKeys = noteResolutionKeys(existing);
|
|
709
835
|
noteOps.deleteNote(this.db, id);
|
|
836
|
+
refreshAmbiguousLinks(this.db, ambiguityKeys);
|
|
710
837
|
if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
|
|
711
838
|
// Dispatch even when `existing` was null — the caller asked for a
|
|
712
839
|
// deletion, and downstream consumers (e.g. the mirror) reconcile via
|
|
@@ -1601,7 +1728,3 @@ export class BunSqliteStore implements Store {
|
|
|
1601
1728
|
export const SqliteStore = BunSqliteStore;
|
|
1602
1729
|
/** @deprecated Renamed to `BunSqliteStore`. */
|
|
1603
1730
|
export type SqliteStore = BunSqliteStore;
|
|
1604
|
-
|
|
1605
|
-
function escapeRegex(s: string): string {
|
|
1606
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1607
|
-
}
|
package/core/src/txn.test.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { describe, it, expect } from "bun:test";
|
|
14
14
|
import { Database } from "bun:sqlite";
|
|
15
|
-
import { transaction, transactionAsync, type TxnCapableDb } from "./txn.js";
|
|
15
|
+
import { afterCommit, transaction, transactionAsync, type TxnCapableDb } from "./txn.js";
|
|
16
16
|
|
|
17
17
|
/** A fake `TxnCapableDb` that records exec calls and can be told to throw on
|
|
18
18
|
* COMMIT and/or ROLLBACK — lets us drive the failure branches exactly. */
|
|
@@ -261,6 +261,38 @@ describe("transactionAsync", () => {
|
|
|
261
261
|
});
|
|
262
262
|
|
|
263
263
|
describe("transaction — re-entrancy via SAVEPOINTs (vault#589)", () => {
|
|
264
|
+
it("does not attempt rollback when an after-commit callback throws", () => {
|
|
265
|
+
const { db, calls } = fakeDb();
|
|
266
|
+
expect(() => transaction(db, () => {
|
|
267
|
+
afterCommit(db, () => { throw new Error("post-commit boom"); });
|
|
268
|
+
})).toThrow("post-commit boom");
|
|
269
|
+
expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT"]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("runs nested after-commit work only after the outer commit", async () => {
|
|
273
|
+
const db = freshDb();
|
|
274
|
+
const calls: string[] = [];
|
|
275
|
+
await transactionAsync(db, async () => {
|
|
276
|
+
transaction(db, () => {
|
|
277
|
+
afterCommit(db, () => calls.push("after commit"));
|
|
278
|
+
});
|
|
279
|
+
expect(calls).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
expect(calls).toEqual(["after commit"]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("discards nested after-commit work when the outer transaction rolls back", async () => {
|
|
285
|
+
const db = freshDb();
|
|
286
|
+
const calls: string[] = [];
|
|
287
|
+
await expect(transactionAsync(db, async () => {
|
|
288
|
+
transaction(db, () => {
|
|
289
|
+
afterCommit(db, () => calls.push("after commit"));
|
|
290
|
+
});
|
|
291
|
+
throw new Error("outer boom");
|
|
292
|
+
})).rejects.toThrow("outer boom");
|
|
293
|
+
expect(calls).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
|
|
264
296
|
it("a nested sync transaction uses SAVEPOINT/RELEASE, not a second BEGIN", () => {
|
|
265
297
|
const { db, calls } = fakeDb();
|
|
266
298
|
const out = transaction(db, () => {
|
package/core/src/txn.ts
CHANGED
|
@@ -76,6 +76,49 @@ export interface TxnCapableDb {
|
|
|
76
76
|
*/
|
|
77
77
|
const txnDepth = new WeakMap<TxnCapableDb, number>();
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Post-commit callbacks, stacked per transaction frame. A nested transaction
|
|
81
|
+
* merges its callbacks into its parent when its SAVEPOINT is released; only
|
|
82
|
+
* the outermost successful commit runs them. A rollback discards the frame.
|
|
83
|
+
*/
|
|
84
|
+
const afterCommitFrames = new WeakMap<TxnCapableDb, Array<Array<() => void>>>();
|
|
85
|
+
|
|
86
|
+
function enterAfterCommitFrame(db: TxnCapableDb): void {
|
|
87
|
+
const frames = afterCommitFrames.get(db) ?? [];
|
|
88
|
+
frames.push([]);
|
|
89
|
+
afterCommitFrames.set(db, frames);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function commitAfterCommitFrame(db: TxnCapableDb): void {
|
|
93
|
+
const frames = afterCommitFrames.get(db);
|
|
94
|
+
const callbacks = frames?.pop() ?? [];
|
|
95
|
+
const parent = frames?.at(-1);
|
|
96
|
+
if (parent) {
|
|
97
|
+
parent.push(...callbacks);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
afterCommitFrames.delete(db);
|
|
101
|
+
for (const callback of callbacks) callback();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function rollbackAfterCommitFrame(db: TxnCapableDb): void {
|
|
105
|
+
const frames = afterCommitFrames.get(db);
|
|
106
|
+
frames?.pop();
|
|
107
|
+
if (!frames || frames.length === 0) afterCommitFrames.delete(db);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run a callback only after the surrounding transaction stack commits. When
|
|
112
|
+
* called outside a transaction it runs immediately. This keeps hooks and
|
|
113
|
+
* cache invalidation honest for synchronous store writes nested inside an
|
|
114
|
+
* outer {@link transactionAsync} batch.
|
|
115
|
+
*/
|
|
116
|
+
export function afterCommit(db: TxnCapableDb, callback: () => void): void {
|
|
117
|
+
const frame = afterCommitFrames.get(db)?.at(-1);
|
|
118
|
+
if (frame) frame.push(callback);
|
|
119
|
+
else callback();
|
|
120
|
+
}
|
|
121
|
+
|
|
79
122
|
/** One entry on the nesting stack: whether this frame owns the outer
|
|
80
123
|
* `BEGIN … COMMIT`/`ROLLBACK` (outermost) or a `SAVEPOINT` (nested), plus the
|
|
81
124
|
* depth to restore on exit. */
|
|
@@ -153,18 +196,38 @@ function rollbackTxn(db: TxnCapableDb, frame: TxnFrame): void {
|
|
|
153
196
|
* below unchanged.
|
|
154
197
|
*/
|
|
155
198
|
export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
|
|
199
|
+
enterAfterCommitFrame(db);
|
|
156
200
|
if (typeof db.transactionSync === "function") {
|
|
157
|
-
|
|
201
|
+
let result: T;
|
|
202
|
+
try {
|
|
203
|
+
result = db.transactionSync(fn);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
rollbackAfterCommitFrame(db);
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
commitAfterCommitFrame(db);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
let frame: TxnFrame;
|
|
212
|
+
try {
|
|
213
|
+
frame = enterTxn(db);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
rollbackAfterCommitFrame(db);
|
|
216
|
+
throw err;
|
|
158
217
|
}
|
|
159
|
-
|
|
218
|
+
let result: T;
|
|
160
219
|
try {
|
|
161
|
-
|
|
220
|
+
result = fn();
|
|
162
221
|
commitTxn(db, frame);
|
|
163
|
-
return result;
|
|
164
222
|
} catch (err) {
|
|
223
|
+
rollbackAfterCommitFrame(db);
|
|
165
224
|
rollbackTxn(db, frame);
|
|
166
225
|
throw err;
|
|
167
226
|
}
|
|
227
|
+
// The SQL transaction is already resolved. A callback error must propagate
|
|
228
|
+
// without attempting a misleading ROLLBACK after COMMIT.
|
|
229
|
+
commitAfterCommitFrame(db);
|
|
230
|
+
return result;
|
|
168
231
|
}
|
|
169
232
|
|
|
170
233
|
/**
|
|
@@ -188,13 +251,23 @@ export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
|
|
|
188
251
|
* than a second `BEGIN`. See the file header.
|
|
189
252
|
*/
|
|
190
253
|
export async function transactionAsync<T>(db: TxnCapableDb, fn: () => Promise<T>): Promise<T> {
|
|
191
|
-
|
|
254
|
+
enterAfterCommitFrame(db);
|
|
255
|
+
let frame: TxnFrame;
|
|
256
|
+
try {
|
|
257
|
+
frame = enterTxn(db);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
rollbackAfterCommitFrame(db);
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
let result: T;
|
|
192
263
|
try {
|
|
193
|
-
|
|
264
|
+
result = await fn();
|
|
194
265
|
commitTxn(db, frame);
|
|
195
|
-
return result;
|
|
196
266
|
} catch (err) {
|
|
267
|
+
rollbackAfterCommitFrame(db);
|
|
197
268
|
rollbackTxn(db, frame);
|
|
198
269
|
throw err;
|
|
199
270
|
}
|
|
271
|
+
commitAfterCommitFrame(db);
|
|
272
|
+
return result;
|
|
200
273
|
}
|
package/core/src/types.ts
CHANGED
|
@@ -188,6 +188,17 @@ export interface QueryOpts {
|
|
|
188
188
|
* had a broken link) — `true` matches nothing, `false` is a no-op.
|
|
189
189
|
*/
|
|
190
190
|
hasBrokenLinks?: boolean;
|
|
191
|
+
/**
|
|
192
|
+
* Presence filter on the `ambiguous_wikilinks` table (vault#581):
|
|
193
|
+
* `true` → only notes with at least one AMBIGUOUS outbound link (a
|
|
194
|
+
* `[[wikilink]]` or structured `links` target that matched ≥2 notes, so
|
|
195
|
+
* no link was created and none was guessed at); `false` → only notes with
|
|
196
|
+
* none. Disjoint from `hasBrokenLinks`: a dangling target matched NOTHING,
|
|
197
|
+
* an ambiguous one matched too much. Safe on a vault where the
|
|
198
|
+
* `ambiguous_wikilinks` table has never been created (no link has ever
|
|
199
|
+
* been ambiguous) — `true` matches nothing, `false` is a no-op.
|
|
200
|
+
*/
|
|
201
|
+
hasAmbiguousLinks?: boolean;
|
|
191
202
|
path?: string; // exact path match (case-insensitive)
|
|
192
203
|
pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
|
|
193
204
|
/**
|
|
@@ -358,7 +358,14 @@ export function projectionToMarkdown(args: {
|
|
|
358
358
|
|
|
359
359
|
const lines: string[] = [];
|
|
360
360
|
lines.push(`You are connected to Parachute Vault "${vaultName}".`);
|
|
361
|
-
|
|
361
|
+
// vault#669 / cloud#87 recovery: a non-string description (poisoned
|
|
362
|
+
// via an unguarded write door) used to throw here (`description.trim
|
|
363
|
+
// is not a function`) and take down MCP `initialize` — the first
|
|
364
|
+
// frame, so the connected AI never reached a tool that could repair
|
|
365
|
+
// it. Skip a non-string the same way we skip null/empty: render the
|
|
366
|
+
// base brief. The write doors now reject this shape; this is the
|
|
367
|
+
// already-poisoned reconnect path.
|
|
368
|
+
if (typeof description === "string" && description.trim().length > 0) {
|
|
362
369
|
lines.push("");
|
|
363
370
|
lines.push(description.trim());
|
|
364
371
|
}
|