@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/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.exec("BEGIN IMMEDIATE");
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
- db.exec("COMMIT");
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
- // Audit log: single line so operators searching `[vault] tag rename`
1616
- // can correlate cascades after the fact. Includes the stats and the
1617
- // mapping for non-trivial sub-tag cases.
1618
- console.error(
1619
- `[vault] tag rename cascade: ${oldName} → ${newName}` +
1620
- (renames.length > 1 ? ` (+${renames.length - 1} sub-tags)` : "") +
1621
- ` — 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}`,
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
- return result;
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.exec("BEGIN");
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.exec("BEGIN");
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
- db.exec("COMMIT");
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.exec("BEGIN");
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
- db.exec("COMMIT");
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.exec("BEGIN");
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
- db.exec("COMMIT");
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
+ });