@openparachute/vault 0.6.4 → 0.6.5-rc.1
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/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- package/core/src/mcp.ts +306 -314
- package/core/src/notes.ts +21 -47
- package/core/src/portable-md-batching.test.ts +67 -0
- package/core/src/portable-md-golden.test.ts +55 -0
- package/core/src/portable-md.ts +579 -435
- package/core/src/schema.ts +21 -43
- package/core/src/store.ts +14 -9
- package/core/src/txn.test.ts +161 -0
- package/core/src/txn.ts +78 -0
- package/core/src/types.ts +9 -0
- package/package.json +1 -1
- package/src/routes.ts +8 -7
package/core/src/notes.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
2
2
|
import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats } from "./types.js";
|
|
3
3
|
import { normalizePath } from "./paths.js";
|
|
4
|
+
import { transaction } from "./txn.js";
|
|
4
5
|
import {
|
|
5
6
|
buildOperatorClause,
|
|
6
7
|
isOperatorObject,
|
|
@@ -1438,8 +1439,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1438
1439
|
return { error: "target_exists", conflicting };
|
|
1439
1440
|
}
|
|
1440
1441
|
|
|
1441
|
-
db
|
|
1442
|
-
try {
|
|
1442
|
+
const result = transaction(db, (): RenameTagSuccess => {
|
|
1443
1443
|
let renamedNoteTags = 0;
|
|
1444
1444
|
let pathsRenamed = 0;
|
|
1445
1445
|
|
|
@@ -1600,9 +1600,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1600
1600
|
}
|
|
1601
1601
|
}
|
|
1602
1602
|
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
const result: RenameTagSuccess = {
|
|
1603
|
+
return {
|
|
1606
1604
|
renamed: renamedNoteTags,
|
|
1607
1605
|
sub_tags_renamed: renames.length - 1,
|
|
1608
1606
|
parent_refs_updated: parentRefsUpdated,
|
|
@@ -1611,21 +1609,18 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1611
1609
|
notes_rewritten: notesRewritten,
|
|
1612
1610
|
paths_renamed: pathsRenamed,
|
|
1613
1611
|
};
|
|
1612
|
+
});
|
|
1614
1613
|
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1614
|
+
// Audit log: single line so operators searching `[vault] tag rename`
|
|
1615
|
+
// can correlate cascades after the fact. Includes the stats and the
|
|
1616
|
+
// mapping for non-trivial sub-tag cases.
|
|
1617
|
+
console.error(
|
|
1618
|
+
`[vault] tag rename cascade: ${oldName} → ${newName}` +
|
|
1619
|
+
(renames.length > 1 ? ` (+${renames.length - 1} sub-tags)` : "") +
|
|
1620
|
+
` — note_tags:${result.renamed} parent_refs:${result.parent_refs_updated} tokens:${result.tokens_updated} indexed:${result.indexed_field_declarers_updated} notes:${result.notes_rewritten} paths:${result.paths_renamed}`,
|
|
1621
|
+
);
|
|
1623
1622
|
|
|
1624
|
-
|
|
1625
|
-
} catch (err) {
|
|
1626
|
-
db.exec("ROLLBACK");
|
|
1627
|
-
throw err;
|
|
1628
|
-
}
|
|
1623
|
+
return result;
|
|
1629
1624
|
}
|
|
1630
1625
|
|
|
1631
1626
|
function emptyCascadeResult(): RenameTagSuccess {
|
|
@@ -1768,8 +1763,7 @@ export function mergeTags(
|
|
|
1768
1763
|
|
|
1769
1764
|
const merged: Record<string, number> = {};
|
|
1770
1765
|
|
|
1771
|
-
db
|
|
1772
|
-
try {
|
|
1766
|
+
transaction(db, () => {
|
|
1773
1767
|
// Target might not exist yet. Seed it so INSERT OR IGNORE into note_tags
|
|
1774
1768
|
// can reference it; leave any existing schema on target untouched.
|
|
1775
1769
|
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(target);
|
|
@@ -1796,12 +1790,7 @@ export function mergeTags(
|
|
|
1796
1790
|
deleteTagStmt.run(source);
|
|
1797
1791
|
merged[source] = before;
|
|
1798
1792
|
}
|
|
1799
|
-
|
|
1800
|
-
db.exec("COMMIT");
|
|
1801
|
-
} catch (err) {
|
|
1802
|
-
db.exec("ROLLBACK");
|
|
1803
|
-
throw err;
|
|
1804
|
-
}
|
|
1793
|
+
});
|
|
1805
1794
|
|
|
1806
1795
|
return { merged, target };
|
|
1807
1796
|
}
|
|
@@ -1999,8 +1988,7 @@ export interface BulkNoteInput {
|
|
|
1999
1988
|
export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
|
|
2000
1989
|
const results: Note[] = [];
|
|
2001
1990
|
|
|
2002
|
-
db
|
|
2003
|
-
try {
|
|
1991
|
+
transaction(db, () => {
|
|
2004
1992
|
for (const input of inputs) {
|
|
2005
1993
|
results.push(
|
|
2006
1994
|
createNote(db, input.content, {
|
|
@@ -2015,11 +2003,7 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
|
|
|
2015
2003
|
}),
|
|
2016
2004
|
);
|
|
2017
2005
|
}
|
|
2018
|
-
|
|
2019
|
-
} catch (err) {
|
|
2020
|
-
db.exec("ROLLBACK");
|
|
2021
|
-
throw err;
|
|
2022
|
-
}
|
|
2006
|
+
});
|
|
2023
2007
|
|
|
2024
2008
|
return results;
|
|
2025
2009
|
}
|
|
@@ -2032,8 +2016,7 @@ export function batchTag(db: Database, noteIds: string[], tags: string[]): numbe
|
|
|
2032
2016
|
const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
|
|
2033
2017
|
let count = 0;
|
|
2034
2018
|
|
|
2035
|
-
db
|
|
2036
|
-
try {
|
|
2019
|
+
transaction(db, () => {
|
|
2037
2020
|
for (const tag of bareTags) {
|
|
2038
2021
|
insertTag.run(tag);
|
|
2039
2022
|
}
|
|
@@ -2043,11 +2026,7 @@ export function batchTag(db: Database, noteIds: string[], tags: string[]): numbe
|
|
|
2043
2026
|
count++;
|
|
2044
2027
|
}
|
|
2045
2028
|
}
|
|
2046
|
-
|
|
2047
|
-
} catch (err) {
|
|
2048
|
-
db.exec("ROLLBACK");
|
|
2049
|
-
throw err;
|
|
2050
|
-
}
|
|
2029
|
+
});
|
|
2051
2030
|
|
|
2052
2031
|
return count;
|
|
2053
2032
|
}
|
|
@@ -2059,19 +2038,14 @@ export function batchUntag(db: Database, noteIds: string[], tags: string[]): num
|
|
|
2059
2038
|
const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
|
|
2060
2039
|
let count = 0;
|
|
2061
2040
|
|
|
2062
|
-
db
|
|
2063
|
-
try {
|
|
2041
|
+
transaction(db, () => {
|
|
2064
2042
|
for (const noteId of noteIds) {
|
|
2065
2043
|
for (const tag of bareTags) {
|
|
2066
2044
|
stmt.run(noteId, tag);
|
|
2067
2045
|
count++;
|
|
2068
2046
|
}
|
|
2069
2047
|
}
|
|
2070
|
-
|
|
2071
|
-
} catch (err) {
|
|
2072
|
-
db.exec("ROLLBACK");
|
|
2073
|
-
throw err;
|
|
2074
|
-
}
|
|
2048
|
+
});
|
|
2075
2049
|
|
|
2076
2050
|
return count;
|
|
2077
2051
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batched-export coverage (Phase-1 streaming refactor). The export walk
|
|
3
|
+
* streams notes in windows of `EXPORT_BATCH_SIZE` (500) instead of one
|
|
4
|
+
* full-corpus query. These tests prove the windowing walks EVERY note across
|
|
5
|
+
* the batch boundaries — both a non-multiple remainder (1,203 = 500 + 500 +
|
|
6
|
+
* 203, so the final partial batch is exercised) and an exact multiple (1,000
|
|
7
|
+
* = 500 + 500 + a terminating empty query), with a sentinel note pinned in
|
|
8
|
+
* the final batch so an off-by-one that dropped the tail would fail loudly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import { existsSync, mkdtempSync, rmSync } from "fs";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
|
|
17
|
+
import { SqliteStore } from "./store.js";
|
|
18
|
+
import { exportVaultToDir } from "./portable-md.js";
|
|
19
|
+
|
|
20
|
+
/** Zero-padded so lexicographic id order == numeric order (the export walks
|
|
21
|
+
* `created_at ASC, id ASC`; fresh notes share a created_at so id breaks the
|
|
22
|
+
* tie — this makes the batch membership of each note deterministic). */
|
|
23
|
+
const pad = (i: number): string => String(i).padStart(5, "0");
|
|
24
|
+
|
|
25
|
+
async function seed(store: SqliteStore, n: number): Promise<void> {
|
|
26
|
+
const inputs = [];
|
|
27
|
+
for (let i = 0; i < n; i++) {
|
|
28
|
+
inputs.push({ content: `note ${i}`, id: `n${pad(i)}`, path: `notes/n${pad(i)}` });
|
|
29
|
+
}
|
|
30
|
+
await store.createNotes(inputs);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("exportVaultToDir — batched walk across boundaries", () => {
|
|
34
|
+
let store: SqliteStore;
|
|
35
|
+
let outDir: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
39
|
+
outDir = mkdtempSync(join(tmpdir(), "portable-batch-"));
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
try { rmSync(outDir, { recursive: true, force: true }); } catch {}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("walks all 1,203 notes (2 full batches + a 203-note remainder)", async () => {
|
|
46
|
+
await seed(store, 1203);
|
|
47
|
+
const stats = await exportVaultToDir(store, { outDir, exportedAt: "2026-07-02T00:00:00.000Z" });
|
|
48
|
+
|
|
49
|
+
expect(stats.notes).toBe(1203);
|
|
50
|
+
// First note, both interior batch boundaries, and the sentinel in the
|
|
51
|
+
// final partial batch must all be on disk.
|
|
52
|
+
expect(existsSync(join(outDir, "notes/n00000.md"))).toBe(true); // batch 1 head
|
|
53
|
+
expect(existsSync(join(outDir, "notes/n00500.md"))).toBe(true); // batch 2 head
|
|
54
|
+
expect(existsSync(join(outDir, "notes/n01000.md"))).toBe(true); // batch 3 head
|
|
55
|
+
expect(existsSync(join(outDir, "notes/n01202.md"))).toBe(true); // last partial-batch sentinel
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("walks all notes on an exact multiple of the batch size (1,000 = 2×500 + terminating empty query)", async () => {
|
|
59
|
+
await seed(store, 1000);
|
|
60
|
+
const stats = await exportVaultToDir(store, { outDir, exportedAt: "2026-07-02T00:00:00.000Z" });
|
|
61
|
+
|
|
62
|
+
expect(stats.notes).toBe(1000);
|
|
63
|
+
expect(existsSync(join(outDir, "notes/n00000.md"))).toBe(true);
|
|
64
|
+
expect(existsSync(join(outDir, "notes/n00500.md"))).toBe(true);
|
|
65
|
+
expect(existsSync(join(outDir, "notes/n00999.md"))).toBe(true); // last note of the final full batch
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden-bytes regression for the portable-md streaming export refactor
|
|
3
|
+
* (Phase-1 shared-core, vault cloud design §4).
|
|
4
|
+
*
|
|
5
|
+
* `core/src/__fixtures__/portable-export-golden.json` was captured from the
|
|
6
|
+
* PRE-refactor (full-corpus, sync-fs) exporter by
|
|
7
|
+
* `scripts/capture-portable-golden.ts`. This test builds the SAME
|
|
8
|
+
* deterministic vault and asserts the CURRENT exporter reproduces those
|
|
9
|
+
* bytes exactly — the streaming/source-sink rewrite must be byte-for-byte
|
|
10
|
+
* indistinguishable on the fs backend. A diff here is a real format drift,
|
|
11
|
+
* not test flake (both sides share `buildGoldenVault` + `serializeExportTree`
|
|
12
|
+
* from the fixture module, so a fixture-vs-test mismatch is impossible).
|
|
13
|
+
*
|
|
14
|
+
* If a format change is ever intentional, re-run the capture script against
|
|
15
|
+
* the new code, review the JSON diff, and commit both together.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from "bun:test";
|
|
19
|
+
import { Database } from "bun:sqlite";
|
|
20
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
21
|
+
import { tmpdir } from "os";
|
|
22
|
+
import { join } from "path";
|
|
23
|
+
|
|
24
|
+
import { SqliteStore } from "./store.js";
|
|
25
|
+
import { exportVaultToDir } from "./portable-md.js";
|
|
26
|
+
import {
|
|
27
|
+
buildGoldenVault,
|
|
28
|
+
serializeExportTree,
|
|
29
|
+
GOLDEN_EXPORTED_AT,
|
|
30
|
+
} from "./__fixtures__/golden-vault.js";
|
|
31
|
+
import goldenTree from "./__fixtures__/portable-export-golden.json";
|
|
32
|
+
|
|
33
|
+
describe("portable-md golden bytes — new exporter == old-exporter fixture", () => {
|
|
34
|
+
it("reproduces the captured export tree byte-for-byte", async () => {
|
|
35
|
+
const store = new SqliteStore(new Database(":memory:"));
|
|
36
|
+
await buildGoldenVault(store);
|
|
37
|
+
|
|
38
|
+
const outDir = mkdtempSync(join(tmpdir(), "portable-golden-test-"));
|
|
39
|
+
try {
|
|
40
|
+
await exportVaultToDir(store, {
|
|
41
|
+
outDir,
|
|
42
|
+
vaultName: "golden",
|
|
43
|
+
vaultDescription: "portable-md byte-stability fixture",
|
|
44
|
+
exportedAt: GOLDEN_EXPORTED_AT,
|
|
45
|
+
caseSensitiveOverride: true,
|
|
46
|
+
});
|
|
47
|
+
const tree = serializeExportTree(outDir);
|
|
48
|
+
// Compare as objects (per-file byte equality) — a keyed diff points
|
|
49
|
+
// straight at the drifting file if this ever fails.
|
|
50
|
+
expect(tree).toEqual(goldenTree as Record<string, string>);
|
|
51
|
+
} finally {
|
|
52
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|