@openparachute/vault 0.6.4-rc.9 → 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/README.md +6 -4
- package/core/src/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- package/core/src/bare-tag-guard.test.ts +246 -0
- package/core/src/conformance.test.ts +112 -0
- package/core/src/conformance.ts +214 -0
- package/core/src/mcp.ts +313 -316
- package/core/src/notes.ts +73 -57
- 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 +79 -11
- package/core/src/tag-hierarchy.ts +26 -0
- package/core/src/txn.test.ts +161 -0
- package/core/src/txn.ts +78 -0
- package/core/src/types.ts +24 -0
- package/package.json +1 -1
- package/src/bare-tag-guard-routes.test.ts +72 -0
- package/src/cli.ts +1 -1
- package/src/first-boot-create.test.ts +155 -0
- package/src/routes.ts +198 -32
- package/src/routing.test.ts +125 -0
- package/src/routing.ts +10 -2
- package/src/self-register.test.ts +4 -4
- package/src/self-register.ts +1 -2
- package/src/server.ts +58 -38
- package/src/storage.test.ts +75 -8
- package/web/ui/dist/assets/{index-UlvD8KHr.css → index-C5XqQM-c.css} +1 -1
- package/web/ui/dist/assets/index-TJ-XN4OZ.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DwYo23aY.js +0 -61
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,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
type QueryHashInputs,
|
|
20
21
|
} from "./cursor.js";
|
|
21
22
|
import { getIndexedField, releaseField } from "./indexed-fields.js";
|
|
23
|
+
import { stripTagHash } from "./tag-hierarchy.js";
|
|
22
24
|
|
|
23
25
|
let idCounter = 0;
|
|
24
26
|
|
|
@@ -686,8 +688,14 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
686
688
|
// rides idx_note_tags_tag, produces each note id at most once, and lets
|
|
687
689
|
// the whole query drop DISTINCT. See the 2026-06-10 perf measurements.
|
|
688
690
|
if (opts.tags && opts.tags.length > 0) {
|
|
691
|
+
// Canonical-bare-tag guard (vault#XXX) backstop for direct-core callers
|
|
692
|
+
// that bypass BunSqliteStore.normalizeQueryTags (the store normalizes +
|
|
693
|
+
// hierarchy-expands before reaching here; this protects the raw noteOps
|
|
694
|
+
// entry point and tests). `_tagsExpanded`, when present, was already built
|
|
695
|
+
// from bare names by the store, so prefer it; otherwise strip the literal
|
|
696
|
+
// tags. No-op on already-bare input.
|
|
689
697
|
const tagSets: string[][] = (opts as QueryOpts & { _tagsExpanded?: string[][] })._tagsExpanded
|
|
690
|
-
?? opts.tags.map((t) => [t]);
|
|
698
|
+
?? opts.tags.map((t) => [stripTagHash(t)]);
|
|
691
699
|
const match = opts.tagMatch ?? "all";
|
|
692
700
|
if (match === "any") {
|
|
693
701
|
// Flatten all expanded sets and dedupe — a note tagged with any one
|
|
@@ -710,9 +718,11 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
710
718
|
}
|
|
711
719
|
}
|
|
712
720
|
|
|
713
|
-
// Exclude tags
|
|
721
|
+
// Exclude tags — bare-tag guard backstop (see tags block above).
|
|
714
722
|
if (opts.excludeTags && opts.excludeTags.length > 0) {
|
|
715
|
-
for (const
|
|
723
|
+
for (const rawTag of opts.excludeTags) {
|
|
724
|
+
const tag = stripTagHash(rawTag);
|
|
725
|
+
if (tag === "") continue;
|
|
716
726
|
conditions.push(`NOT EXISTS (SELECT 1 FROM note_tags ex WHERE ex.note_id = n.id AND ex.tag_name = ?)`);
|
|
717
727
|
params.push(tag);
|
|
718
728
|
}
|
|
@@ -1189,11 +1199,18 @@ export function searchNotes(
|
|
|
1189
1199
|
const limit = typeof opts?.limit === "number" ? opts.limit : 50;
|
|
1190
1200
|
|
|
1191
1201
|
if (opts?.tags && opts.tags.length > 0) {
|
|
1202
|
+
// Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
|
|
1203
|
+
const searchTags = opts.tags.map(stripTagHash).filter((t) => t !== "");
|
|
1204
|
+
if (searchTags.length === 0) {
|
|
1205
|
+
// All tag filters collapsed to empty — fall through to the untagged
|
|
1206
|
+
// search path below (no tag constraint).
|
|
1207
|
+
opts = { ...opts, tags: undefined };
|
|
1208
|
+
} else {
|
|
1192
1209
|
try {
|
|
1193
1210
|
// Tag membership as a semijoin — same rationale as queryNotes: a
|
|
1194
1211
|
// `JOIN note_tags` multiplies rows for multi-tagged notes and forced
|
|
1195
1212
|
// DISTINCT over full rows. The FTS join itself is 1:1 on rowid.
|
|
1196
|
-
const tagPlaceholders =
|
|
1213
|
+
const tagPlaceholders = searchTags.map(() => "?").join(", ");
|
|
1197
1214
|
const rows = db.prepare(`
|
|
1198
1215
|
SELECT n.* FROM notes n
|
|
1199
1216
|
JOIN notes_fts fts ON fts.rowid = n.rowid
|
|
@@ -1201,11 +1218,12 @@ export function searchNotes(
|
|
|
1201
1218
|
AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
|
|
1202
1219
|
ORDER BY rank
|
|
1203
1220
|
LIMIT ?
|
|
1204
|
-
`).all(query, ...
|
|
1221
|
+
`).all(query, ...searchTags, limit) as NoteRow[];
|
|
1205
1222
|
return notesWithTags(db, rows);
|
|
1206
1223
|
} catch {
|
|
1207
1224
|
return [];
|
|
1208
1225
|
}
|
|
1226
|
+
}
|
|
1209
1227
|
}
|
|
1210
1228
|
|
|
1211
1229
|
try {
|
|
@@ -1236,7 +1254,15 @@ export function tagNote(db: Database, noteId: string, tags: string[]): void {
|
|
|
1236
1254
|
const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
|
|
1237
1255
|
const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
|
|
1238
1256
|
|
|
1239
|
-
|
|
1257
|
+
// Canonical-bare-tag guard (vault#XXX): strip any leading `#` so the
|
|
1258
|
+
// `#`-decorated form a client may pass (the agent module stored
|
|
1259
|
+
// `#agent/message/inbound` verbatim) lands on the same bare row everyone
|
|
1260
|
+
// else queries. This is the single write chokepoint — createNote /
|
|
1261
|
+
// updateNote / batch / MCP add-tags / REST / import / transcript all funnel
|
|
1262
|
+
// through store.tagNote → here.
|
|
1263
|
+
for (const raw of tags) {
|
|
1264
|
+
const tag = stripTagHash(raw);
|
|
1265
|
+
if (tag === "") continue;
|
|
1240
1266
|
insertTag.run(tag);
|
|
1241
1267
|
insertNoteTag.run(noteId, tag);
|
|
1242
1268
|
}
|
|
@@ -1244,7 +1270,10 @@ export function tagNote(db: Database, noteId: string, tags: string[]): void {
|
|
|
1244
1270
|
|
|
1245
1271
|
export function untagNote(db: Database, noteId: string, tags: string[]): void {
|
|
1246
1272
|
const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
|
|
1247
|
-
|
|
1273
|
+
// Mirror tagNote's normalization so removing `#tag` deletes the bare row.
|
|
1274
|
+
for (const raw of tags) {
|
|
1275
|
+
const tag = stripTagHash(raw);
|
|
1276
|
+
if (tag === "") continue;
|
|
1248
1277
|
stmt.run(noteId, tag);
|
|
1249
1278
|
}
|
|
1250
1279
|
}
|
|
@@ -1359,6 +1388,11 @@ export type RenameTagResult =
|
|
|
1359
1388
|
* after the cascade returns.
|
|
1360
1389
|
*/
|
|
1361
1390
|
export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
|
|
1391
|
+
// Normalize the TARGET so a rename can never create a `#`-prefixed tag. The
|
|
1392
|
+
// SOURCE (`oldName`) is left LITERAL on purpose — it's the transitional escape
|
|
1393
|
+
// hatch that lets the `#legacy/*` → `legacy/*` data migration find the
|
|
1394
|
+
// `#`-prefixed rows. (Renaming TO a `#`-name is the thing we're preventing.)
|
|
1395
|
+
newName = stripTagHash(newName);
|
|
1362
1396
|
if (oldName === newName) {
|
|
1363
1397
|
const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
|
|
1364
1398
|
return exists
|
|
@@ -1405,8 +1439,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1405
1439
|
return { error: "target_exists", conflicting };
|
|
1406
1440
|
}
|
|
1407
1441
|
|
|
1408
|
-
db
|
|
1409
|
-
try {
|
|
1442
|
+
const result = transaction(db, (): RenameTagSuccess => {
|
|
1410
1443
|
let renamedNoteTags = 0;
|
|
1411
1444
|
let pathsRenamed = 0;
|
|
1412
1445
|
|
|
@@ -1567,9 +1600,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1567
1600
|
}
|
|
1568
1601
|
}
|
|
1569
1602
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
const result: RenameTagSuccess = {
|
|
1603
|
+
return {
|
|
1573
1604
|
renamed: renamedNoteTags,
|
|
1574
1605
|
sub_tags_renamed: renames.length - 1,
|
|
1575
1606
|
parent_refs_updated: parentRefsUpdated,
|
|
@@ -1578,21 +1609,18 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1578
1609
|
notes_rewritten: notesRewritten,
|
|
1579
1610
|
paths_renamed: pathsRenamed,
|
|
1580
1611
|
};
|
|
1612
|
+
});
|
|
1581
1613
|
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
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
|
+
);
|
|
1590
1622
|
|
|
1591
|
-
|
|
1592
|
-
} catch (err) {
|
|
1593
|
-
db.exec("ROLLBACK");
|
|
1594
|
-
throw err;
|
|
1595
|
-
}
|
|
1623
|
+
return result;
|
|
1596
1624
|
}
|
|
1597
1625
|
|
|
1598
1626
|
function emptyCascadeResult(): RenameTagSuccess {
|
|
@@ -1727,13 +1755,15 @@ export function mergeTags(
|
|
|
1727
1755
|
sources: string[],
|
|
1728
1756
|
target: string,
|
|
1729
1757
|
): { merged: Record<string, number>; target: string } {
|
|
1758
|
+
// Normalize the TARGET so a merge can never create a `#`-prefixed tag. SOURCES
|
|
1759
|
+
// stay LITERAL so `#legacy/*` rows can be merged away (same carve-out as rename).
|
|
1760
|
+
target = stripTagHash(target);
|
|
1730
1761
|
// Dedup + drop target-in-sources (self-merge is a no-op).
|
|
1731
1762
|
const uniqueSources = Array.from(new Set(sources)).filter((s) => s !== target);
|
|
1732
1763
|
|
|
1733
1764
|
const merged: Record<string, number> = {};
|
|
1734
1765
|
|
|
1735
|
-
db
|
|
1736
|
-
try {
|
|
1766
|
+
transaction(db, () => {
|
|
1737
1767
|
// Target might not exist yet. Seed it so INSERT OR IGNORE into note_tags
|
|
1738
1768
|
// can reference it; leave any existing schema on target untouched.
|
|
1739
1769
|
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(target);
|
|
@@ -1760,12 +1790,7 @@ export function mergeTags(
|
|
|
1760
1790
|
deleteTagStmt.run(source);
|
|
1761
1791
|
merged[source] = before;
|
|
1762
1792
|
}
|
|
1763
|
-
|
|
1764
|
-
db.exec("COMMIT");
|
|
1765
|
-
} catch (err) {
|
|
1766
|
-
db.exec("ROLLBACK");
|
|
1767
|
-
throw err;
|
|
1768
|
-
}
|
|
1793
|
+
});
|
|
1769
1794
|
|
|
1770
1795
|
return { merged, target };
|
|
1771
1796
|
}
|
|
@@ -1963,8 +1988,7 @@ export interface BulkNoteInput {
|
|
|
1963
1988
|
export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
|
|
1964
1989
|
const results: Note[] = [];
|
|
1965
1990
|
|
|
1966
|
-
db
|
|
1967
|
-
try {
|
|
1991
|
+
transaction(db, () => {
|
|
1968
1992
|
for (const input of inputs) {
|
|
1969
1993
|
results.push(
|
|
1970
1994
|
createNote(db, input.content, {
|
|
@@ -1979,11 +2003,7 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
|
|
|
1979
2003
|
}),
|
|
1980
2004
|
);
|
|
1981
2005
|
}
|
|
1982
|
-
|
|
1983
|
-
} catch (err) {
|
|
1984
|
-
db.exec("ROLLBACK");
|
|
1985
|
-
throw err;
|
|
1986
|
-
}
|
|
2006
|
+
});
|
|
1987
2007
|
|
|
1988
2008
|
return results;
|
|
1989
2009
|
}
|
|
@@ -1991,45 +2011,41 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
|
|
|
1991
2011
|
export function batchTag(db: Database, noteIds: string[], tags: string[]): number {
|
|
1992
2012
|
const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
|
|
1993
2013
|
const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
|
|
2014
|
+
// Canonical-bare-tag guard (vault#XXX) — batchTag has its own SQL (does NOT
|
|
2015
|
+
// funnel through tagNote), so it strips leading `#` independently.
|
|
2016
|
+
const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
|
|
1994
2017
|
let count = 0;
|
|
1995
2018
|
|
|
1996
|
-
db
|
|
1997
|
-
|
|
1998
|
-
for (const tag of tags) {
|
|
2019
|
+
transaction(db, () => {
|
|
2020
|
+
for (const tag of bareTags) {
|
|
1999
2021
|
insertTag.run(tag);
|
|
2000
2022
|
}
|
|
2001
2023
|
for (const noteId of noteIds) {
|
|
2002
|
-
for (const tag of
|
|
2024
|
+
for (const tag of bareTags) {
|
|
2003
2025
|
insertNoteTag.run(noteId, tag);
|
|
2004
2026
|
count++;
|
|
2005
2027
|
}
|
|
2006
2028
|
}
|
|
2007
|
-
|
|
2008
|
-
} catch (err) {
|
|
2009
|
-
db.exec("ROLLBACK");
|
|
2010
|
-
throw err;
|
|
2011
|
-
}
|
|
2029
|
+
});
|
|
2012
2030
|
|
|
2013
2031
|
return count;
|
|
2014
2032
|
}
|
|
2015
2033
|
|
|
2016
2034
|
export function batchUntag(db: Database, noteIds: string[], tags: string[]): number {
|
|
2017
2035
|
const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
|
|
2036
|
+
// Mirror batchTag's bare-tag normalization so removing `#tag` deletes the
|
|
2037
|
+
// bare row.
|
|
2038
|
+
const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
|
|
2018
2039
|
let count = 0;
|
|
2019
2040
|
|
|
2020
|
-
db
|
|
2021
|
-
try {
|
|
2041
|
+
transaction(db, () => {
|
|
2022
2042
|
for (const noteId of noteIds) {
|
|
2023
|
-
for (const tag of
|
|
2043
|
+
for (const tag of bareTags) {
|
|
2024
2044
|
stmt.run(noteId, tag);
|
|
2025
2045
|
count++;
|
|
2026
2046
|
}
|
|
2027
2047
|
}
|
|
2028
|
-
|
|
2029
|
-
} catch (err) {
|
|
2030
|
-
db.exec("ROLLBACK");
|
|
2031
|
-
throw err;
|
|
2032
|
-
}
|
|
2048
|
+
});
|
|
2033
2049
|
|
|
2034
2050
|
return count;
|
|
2035
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
|
+
});
|