@openparachute/vault 0.6.4 → 0.6.5-rc.2

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.
@@ -1,6 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
3
  import { rebuildIndexes } from "./indexed-fields.js";
4
+ import { transaction } from "./txn.js";
4
5
 
5
6
  export const SCHEMA_VERSION = 23;
6
7
 
@@ -680,8 +681,7 @@ function migrateToV13(db: Database): void {
680
681
  function migrateToV14(db: Database): void {
681
682
  if (!hasTable(db, "tags")) return;
682
683
 
683
- db.exec("BEGIN IMMEDIATE");
684
- try {
684
+ const { copiedSchemas, copiedHierarchy } = transaction(db, () => {
685
685
  // 1. ALTER TABLE — additive, idempotent.
686
686
  const cols: [string, string][] = [
687
687
  ["description", "TEXT"],
@@ -762,16 +762,13 @@ function migrateToV14(db: Database): void {
762
762
  db.exec("DROP TABLE tag_schemas");
763
763
  }
764
764
 
765
- db.exec("COMMIT");
765
+ return { copiedSchemas, copiedHierarchy };
766
+ });
766
767
 
767
- if (copiedSchemas > 0 || copiedHierarchy > 0) {
768
- console.log(
769
- `[vault] migrated to schema v14: copied ${copiedSchemas} tag_schemas + ${copiedHierarchy} _tags/* hierarchies onto tags rows`,
770
- );
771
- }
772
- } catch (err) {
773
- db.exec("ROLLBACK");
774
- throw err;
768
+ if (copiedSchemas > 0 || copiedHierarchy > 0) {
769
+ console.log(
770
+ `[vault] migrated to schema v14: copied ${copiedSchemas} tag_schemas + ${copiedHierarchy} _tags/* hierarchies onto tags rows`,
771
+ );
775
772
  }
776
773
  }
777
774
 
@@ -806,8 +803,7 @@ function migrateToV15(db: Database): void {
806
803
  ).get()) !== null;
807
804
  if (hasSchemas || hasMappings) return;
808
805
 
809
- db.exec("BEGIN IMMEDIATE");
810
- try {
806
+ const { copiedSchemas, copiedMappings } = transaction(db, () => {
811
807
  const now = new Date().toISOString();
812
808
  let copiedSchemas = 0;
813
809
  let copiedMappings = 0;
@@ -884,16 +880,13 @@ function migrateToV15(db: Database): void {
884
880
  }
885
881
  }
886
882
 
887
- db.exec("COMMIT");
883
+ return { copiedSchemas, copiedMappings };
884
+ });
888
885
 
889
- if (copiedSchemas > 0 || copiedMappings > 0) {
890
- console.log(
891
- `[vault] migrated to schema v15: copied ${copiedSchemas} _schemas/* + ${copiedMappings} _schema_defaults mappings into note_schemas/schema_mappings`,
892
- );
893
- }
894
- } catch (err) {
895
- db.exec("ROLLBACK");
896
- throw err;
886
+ if (copiedSchemas > 0 || copiedMappings > 0) {
887
+ console.log(
888
+ `[vault] migrated to schema v15: copied ${copiedSchemas} _schemas/* + ${copiedMappings} _schema_defaults mappings into note_schemas/schema_mappings`,
889
+ );
897
890
  }
898
891
  }
899
892
 
@@ -922,15 +915,10 @@ function migrateToV16(db: Database): void {
922
915
  // ALTER block so a fresh vault — where the column exists but the index
923
916
  // doesn't — still gets it.
924
917
  if (!hasColumn(db, "tokens", "vault_name")) {
925
- db.exec("BEGIN IMMEDIATE");
926
- try {
918
+ transaction(db, () => {
927
919
  db.exec("ALTER TABLE tokens ADD COLUMN vault_name TEXT");
928
920
  db.exec("CREATE INDEX IF NOT EXISTS idx_tokens_vault_name ON tokens(vault_name)");
929
- db.exec("COMMIT");
930
- } catch (err) {
931
- db.exec("ROLLBACK");
932
- throw err;
933
- }
921
+ });
934
922
  return;
935
923
  }
936
924
 
@@ -980,8 +968,7 @@ function migrateToV17(db: Database): void {
980
968
  ).all() as { schema_name: string; match_kind: string; match_value: string }[];
981
969
  }
982
970
 
983
- db.exec("BEGIN IMMEDIATE");
984
- try {
971
+ transaction(db, () => {
985
972
  // Drop the index first — the index references the table; SQLite would
986
973
  // tear it down on DROP TABLE but the explicit DROP keeps the order
987
974
  // obvious if a future migration reads from sqlite_master mid-flight.
@@ -993,11 +980,7 @@ function migrateToV17(db: Database): void {
993
980
  if (hasNoteSchemas) {
994
981
  db.exec("DROP TABLE note_schemas");
995
982
  }
996
- db.exec("COMMIT");
997
- } catch (err) {
998
- db.exec("ROLLBACK");
999
- throw err;
1000
- }
983
+ });
1001
984
 
1002
985
  if (droppedSchemas.length > 0 || droppedMappings.length > 0) {
1003
986
  const schemaNames = droppedSchemas.map((s) => s.name).join(", ");
@@ -1052,8 +1035,7 @@ function migrateToV18(db: Database): void {
1052
1035
  const hasNewUnique = indexes.some((r) => r.name === "idx_notes_path_ext_unique");
1053
1036
  if (!needsColumn && hasNewUnique && !hasOldUnique) return;
1054
1037
 
1055
- db.exec("BEGIN IMMEDIATE");
1056
- try {
1038
+ transaction(db, () => {
1057
1039
  if (needsColumn) {
1058
1040
  db.exec("ALTER TABLE notes ADD COLUMN extension TEXT NOT NULL DEFAULT 'md'");
1059
1041
  }
@@ -1065,11 +1047,7 @@ function migrateToV18(db: Database): void {
1065
1047
  "CREATE UNIQUE INDEX idx_notes_path_ext_unique ON notes(path, extension) WHERE path IS NOT NULL",
1066
1048
  );
1067
1049
  }
1068
- db.exec("COMMIT");
1069
- } catch (err) {
1070
- db.exec("ROLLBACK");
1071
- throw err;
1072
- }
1050
+ });
1073
1051
  }
1074
1052
 
1075
1053
  /**
package/core/src/store.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  } from "./indexed-fields.js";
13
13
  import { syncWikilinks, resolveUnresolvedWikilinks } from "./wikilinks.js";
14
14
  import { pathTitle } from "./paths.js";
15
+ import { transaction } from "./txn.js";
15
16
  import { HookRegistry } from "./hooks.js";
16
17
  import {
17
18
  loadTagHierarchy,
@@ -55,6 +56,15 @@ export class BunSqliteStore implements Store {
55
56
  this.hooks = opts?.hooks ?? new HookRegistry();
56
57
  }
57
58
 
59
+ /**
60
+ * The transaction seam (see core/src/txn.ts). bun backs it with
61
+ * `BEGIN IMMEDIATE … COMMIT`; a DO-backed Store overrides this with
62
+ * `ctx.storage.transactionSync`. Synchronous — `fn` must not await.
63
+ */
64
+ transaction<T>(fn: () => T): T {
65
+ return transaction(this.db, fn);
66
+ }
67
+
58
68
  /**
59
69
  * Lazy accessor for the `_tags/*` config-note hierarchy. First call after
60
70
  * boot or after an invalidation does the scan; subsequent calls hit the
@@ -716,10 +726,8 @@ export class BunSqliteStore implements Store {
716
726
  // mismatch only detectable once the existing declarer set is consulted),
717
727
  // the whole write rolls back — the schema never ends up claiming an index
718
728
  // that doesn't exist. vault#478 transactional fix.
719
- let result: tagSchemaOps.TagRecord;
720
- this.db.exec("BEGIN IMMEDIATE");
721
- try {
722
- result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
729
+ const result = this.transaction(() => {
730
+ const record = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
723
731
 
724
732
  if (patch.fields !== undefined) {
725
733
  for (const fieldName of nextIndexed) {
@@ -734,11 +742,8 @@ export class BunSqliteStore implements Store {
734
742
  }
735
743
  }
736
744
  }
737
- this.db.exec("COMMIT");
738
- } catch (err) {
739
- this.db.exec("ROLLBACK");
740
- throw err;
741
- }
745
+ return record;
746
+ });
742
747
 
743
748
  if (patch.parent_names !== undefined) {
744
749
  // parent_names drives both query expansion (tag hierarchy) AND, post
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Contract tests for the transaction seam (core/src/txn.ts).
3
+ *
4
+ * Pins the behavior every migrated call site now depends on: commit returns
5
+ * the callback's value, a throw rolls back and re-throws the ORIGINAL error,
6
+ * and a ROLLBACK that itself fails (the "COMMIT already resolved the txn"
7
+ * case) is swallowed so the original error still propagates. The swallow /
8
+ * ordering cases use a structural fake `TxnCapableDb` so we can force a
9
+ * COMMIT/ROLLBACK failure deterministically; the happy + real-rollback cases
10
+ * run against a live bun:sqlite connection.
11
+ */
12
+
13
+ import { describe, it, expect } from "bun:test";
14
+ import { Database } from "bun:sqlite";
15
+ import { transaction, transactionAsync, type TxnCapableDb } from "./txn.js";
16
+
17
+ /** A fake `TxnCapableDb` that records exec calls and can be told to throw on
18
+ * COMMIT and/or ROLLBACK — lets us drive the failure branches exactly. */
19
+ function fakeDb(opts: { commitThrows?: Error; rollbackThrows?: Error } = {}): {
20
+ db: TxnCapableDb;
21
+ calls: string[];
22
+ } {
23
+ const calls: string[] = [];
24
+ const db: TxnCapableDb = {
25
+ exec(sql: string): void {
26
+ calls.push(sql);
27
+ if (sql === "COMMIT" && opts.commitThrows) throw opts.commitThrows;
28
+ if (sql === "ROLLBACK" && opts.rollbackThrows) throw opts.rollbackThrows;
29
+ },
30
+ };
31
+ return { db, calls };
32
+ }
33
+
34
+ function freshDb(): Database {
35
+ const db = new Database(":memory:");
36
+ db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)");
37
+ return db;
38
+ }
39
+
40
+ describe("transaction (sync)", () => {
41
+ it("commits and returns the callback's value", () => {
42
+ const db = freshDb();
43
+ const result = transaction(db, () => {
44
+ db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
45
+ return { count: 1 };
46
+ });
47
+ expect(result).toEqual({ count: 1 });
48
+ // Write is durable after commit.
49
+ expect((db.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(1);
50
+ });
51
+
52
+ it("wraps in BEGIN IMMEDIATE … COMMIT", () => {
53
+ const { db, calls } = fakeDb();
54
+ transaction(db, () => "ok");
55
+ expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT"]);
56
+ });
57
+
58
+ it("rolls back and re-throws the ORIGINAL error when the callback throws", () => {
59
+ const db = freshDb();
60
+ const sentinel = new Error("boom");
61
+ expect(() =>
62
+ transaction(db, () => {
63
+ db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
64
+ throw sentinel;
65
+ }),
66
+ ).toThrow(sentinel);
67
+ // The inner INSERT was rolled back.
68
+ expect((db.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(0);
69
+ });
70
+
71
+ it("issues ROLLBACK (not COMMIT) on a callback throw", () => {
72
+ const { db, calls } = fakeDb();
73
+ expect(() => transaction(db, () => { throw new Error("x"); })).toThrow("x");
74
+ expect(calls).toEqual(["BEGIN IMMEDIATE", "ROLLBACK"]);
75
+ });
76
+
77
+ it("swallows a ROLLBACK failure after a COMMIT throw and propagates the ORIGINAL (commit) error", () => {
78
+ const commitErr = new Error("commit failed");
79
+ const rollbackErr = new Error("no transaction is active");
80
+ const { db, calls } = fakeDb({ commitThrows: commitErr, rollbackThrows: rollbackErr });
81
+ let thrown: unknown;
82
+ try {
83
+ transaction(db, () => "value");
84
+ } catch (e) {
85
+ thrown = e;
86
+ }
87
+ // The COMMIT error is the original that propagates; the ROLLBACK error is swallowed.
88
+ expect(thrown).toBe(commitErr);
89
+ expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT", "ROLLBACK"]);
90
+ });
91
+
92
+ it("preserves the callback error even when ROLLBACK also fails", () => {
93
+ const callbackErr = new Error("callback boom");
94
+ const rollbackErr = new Error("rollback boom");
95
+ const { db } = fakeDb({ rollbackThrows: rollbackErr });
96
+ let thrown: unknown;
97
+ try {
98
+ transaction(db, () => { throw callbackErr; });
99
+ } catch (e) {
100
+ thrown = e;
101
+ }
102
+ expect(thrown).toBe(callbackErr);
103
+ });
104
+ });
105
+
106
+ describe("transaction — native transactionSync preference (DO backend)", () => {
107
+ /** A db that exposes a native `transactionSync` (standing in for a Durable
108
+ * Object shim delegating to `ctx.storage.transactionSync`). Here it's backed
109
+ * by bun's own `.transaction()` so rollback-on-throw is real, while the raw
110
+ * BEGIN/COMMIT it issues internally never flow through our `exec` — letting
111
+ * us assert the seam issued no explicit transaction SQL. */
112
+ function nativeDb(): { db: TxnCapableDb; execs: string[]; bun: Database } {
113
+ const bun = freshDb();
114
+ const execs: string[] = [];
115
+ const db: TxnCapableDb = {
116
+ exec(sql: string): void {
117
+ execs.push(sql);
118
+ bun.exec(sql);
119
+ },
120
+ transactionSync<T>(fn: () => T): T {
121
+ return bun.transaction(fn)();
122
+ },
123
+ };
124
+ return { db, execs, bun };
125
+ }
126
+
127
+ it("prefers transactionSync over BEGIN IMMEDIATE — delegates once, issues no raw txn SQL", () => {
128
+ const calls: string[] = [];
129
+ const state = { txnCalls: 0 };
130
+ const db: TxnCapableDb = {
131
+ exec(sql: string): void { calls.push(sql); },
132
+ transactionSync<T>(fn: () => T): T { state.txnCalls++; return fn(); },
133
+ };
134
+ const result = transaction(db, () => { db.exec("WORK"); return "ok"; });
135
+ expect(result).toBe("ok");
136
+ expect(state.txnCalls).toBe(1);
137
+ // Only the inner write — never BEGIN IMMEDIATE / COMMIT / ROLLBACK.
138
+ expect(calls).toEqual(["WORK"]);
139
+ });
140
+
141
+ it("commits via native transactionSync and returns the callback's value", () => {
142
+ const { db, bun } = nativeDb();
143
+ const result = transaction(db, () => {
144
+ db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
145
+ return 42;
146
+ });
147
+ expect(result).toBe(42);
148
+ expect((bun.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(1);
149
+ });
150
+
151
+ it("rolls back through the native transactionSync on a throw, re-throwing the ORIGINAL error", () => {
152
+ const { db, execs, bun } = nativeDb();
153
+ const sentinel = new Error("native boom");
154
+ expect(() =>
155
+ transaction(db, () => {
156
+ db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
157
+ throw sentinel;
158
+ }),
159
+ ).toThrow(sentinel);
160
+ // Real rollback — the write did not persist.
161
+ expect((bun.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(0);
162
+ // The seam issued NO raw transaction SQL of its own (the native primitive
163
+ // owns BEGIN/COMMIT/ROLLBACK internally); only the inner INSERT flowed through.
164
+ expect(execs).toEqual(["INSERT INTO t (id, v) VALUES (1, 'a')"]);
165
+ });
166
+
167
+ it("bun path unchanged when the db has no transactionSync (BEGIN IMMEDIATE … COMMIT)", () => {
168
+ const { db, calls } = fakeDb(); // no transactionSync
169
+ transaction(db, () => "ok");
170
+ expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT"]);
171
+ });
172
+ });
173
+
174
+ describe("transactionAsync", () => {
175
+ it("commits and returns the callback's resolved value", async () => {
176
+ const db = freshDb();
177
+ const result = await transactionAsync(db, async () => {
178
+ db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
179
+ return 7;
180
+ });
181
+ expect(result).toBe(7);
182
+ expect((db.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(1);
183
+ });
184
+
185
+ it("wraps in BEGIN IMMEDIATE … COMMIT", async () => {
186
+ const { db, calls } = fakeDb();
187
+ await transactionAsync(db, async () => "ok");
188
+ expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT"]);
189
+ });
190
+
191
+ it("rolls back and re-throws the ORIGINAL error when the callback rejects", async () => {
192
+ const db = freshDb();
193
+ const sentinel = new Error("async boom");
194
+ await expect(
195
+ transactionAsync(db, async () => {
196
+ db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
197
+ throw sentinel;
198
+ }),
199
+ ).rejects.toBe(sentinel);
200
+ expect((db.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(0);
201
+ });
202
+
203
+ it("swallows a ROLLBACK failure after a COMMIT throw and propagates the ORIGINAL (commit) error", async () => {
204
+ const commitErr = new Error("commit failed");
205
+ const rollbackErr = new Error("no transaction is active");
206
+ const { db, calls } = fakeDb({ commitThrows: commitErr, rollbackThrows: rollbackErr });
207
+ let thrown: unknown;
208
+ try {
209
+ await transactionAsync(db, async () => "value");
210
+ } catch (e) {
211
+ thrown = e;
212
+ }
213
+ expect(thrown).toBe(commitErr);
214
+ expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT", "ROLLBACK"]);
215
+ });
216
+
217
+ it("preserves the callback error even when ROLLBACK also fails", async () => {
218
+ const callbackErr = new Error("callback boom");
219
+ const rollbackErr = new Error("rollback boom");
220
+ const { db } = fakeDb({ rollbackThrows: rollbackErr });
221
+ let thrown: unknown;
222
+ try {
223
+ await transactionAsync(db, async () => { throw callbackErr; });
224
+ } catch (e) {
225
+ thrown = e;
226
+ }
227
+ expect(thrown).toBe(callbackErr);
228
+ });
229
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The transaction seam (Phase-1 shared-core refactor, vault cloud design §4).
3
+ *
4
+ * Core code must never emit raw `BEGIN`/`COMMIT`/`ROLLBACK` — that's the one
5
+ * SQLite construct DO's `sql.exec` blocks, so it can't be shimmed like the
6
+ * rest of the `Database` surface. Instead every atomic block routes through
7
+ * `transaction` / `transactionAsync` here. The bun backend implements them
8
+ * as `BEGIN IMMEDIATE … COMMIT` with rollback-on-throw; a future Durable-
9
+ * Object backend implements the same contract with `ctx.storage.transactionSync`
10
+ * (and `Store.transaction`, see types.ts, is the object-level entry point).
11
+ *
12
+ * `BEGIN IMMEDIATE` (write lock up front) matches the pre-seam behavior of
13
+ * the 13 core blocks this replaced — several used a bare `BEGIN`, but every
14
+ * one of them only ever wrote, so acquiring the write lock eagerly is a
15
+ * strict improvement (no lazy busy-upgrade) and never a semantic change.
16
+ *
17
+ * **Nesting is unsupported**, matching the pre-seam code exactly: SQLite
18
+ * throws "cannot start a transaction within a transaction" on a nested
19
+ * `BEGIN`, and none of the migrated call sites nest (a batch wraps individual
20
+ * `createNote`s, none of which open their own transaction). If a nested
21
+ * caller ever appears, the fix is SAVEPOINT-based re-entrancy *in the bun
22
+ * implementation here* — the call sites stay untouched.
23
+ */
24
+
25
+ /** The minimal DB surface the transaction seam needs — kept structural (no
26
+ * `bun:sqlite` import) so a non-bun backend satisfies it too.
27
+ *
28
+ * `transactionSync` is OPTIONAL: a backend whose engine blocks explicit
29
+ * `BEGIN`/`COMMIT` (Cloudflare Durable Objects — `sql.exec` throws on them)
30
+ * exposes a native synchronous transaction primitive instead, and
31
+ * {@link transaction} delegates to it. bun's `Database` has no such method, so
32
+ * it takes the `BEGIN IMMEDIATE` path — the check is a duck-type, never a
33
+ * behavior change for the bun backend. */
34
+ export interface TxnCapableDb {
35
+ exec(sql: string): void;
36
+ /** Native commit-on-return / rollback-on-throw transaction, when the backend
37
+ * provides one (e.g. a DO shim delegating to `ctx.storage.transactionSync`). */
38
+ transactionSync?<T>(fn: () => T): T;
39
+ }
40
+
41
+ /**
42
+ * Run `fn` inside a single write transaction, committing its result or
43
+ * rolling back on throw. Synchronous — the callback must not await (see
44
+ * `transactionAsync` for the batch paths that legitimately await the async
45
+ * Store facade).
46
+ *
47
+ * When the backing db exposes a native `transactionSync` (a backend whose
48
+ * engine blocks raw `BEGIN`/`COMMIT` — Durable Objects), we delegate to it: a
49
+ * real transaction with the same commit-on-return / rollback-on-throw contract,
50
+ * without ever issuing the explicit transaction SQL that backend rejects. bun's
51
+ * `Database` has no `transactionSync`, so it takes the `BEGIN IMMEDIATE` path
52
+ * below unchanged.
53
+ */
54
+ export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
55
+ if (typeof db.transactionSync === "function") {
56
+ return db.transactionSync(fn);
57
+ }
58
+ db.exec("BEGIN IMMEDIATE");
59
+ try {
60
+ const result = fn();
61
+ db.exec("COMMIT");
62
+ return result;
63
+ } catch (err) {
64
+ // Best-effort rollback — if the COMMIT itself threw the transaction is
65
+ // already resolved and ROLLBACK would throw "no transaction is active";
66
+ // swallow that so the original error is the one that propagates.
67
+ try {
68
+ db.exec("ROLLBACK");
69
+ } catch {
70
+ // no active transaction to roll back
71
+ }
72
+ throw err;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Async sibling of {@link transaction} for the batch executors, which run
78
+ * their body through the async `Store` facade (`await store.createNote(...)`
79
+ * etc.). The transaction spans the awaits on the shared connection — the
80
+ * same shape as the pre-seam `if (batched) db.exec("BEGIN")` blocks in
81
+ * mcp.ts / routes.ts. A DO backend can't map this onto `transactionSync`
82
+ * (which forbids awaiting); porting the batch path is a Phase-2 concern.
83
+ *
84
+ * Deliberately NOT given the `db.transactionSync` fast-path that {@link transaction}
85
+ * has: `transactionSync` runs its callback synchronously and forbids awaiting,
86
+ * so it cannot wrap an `async` body that awaits the Store facade between writes.
87
+ * A DO backend that needs an atomic async batch has to reshape the batch as a
88
+ * synchronous unit (or use an async DO transaction primitive) — out of scope
89
+ * for this seam; the raw `BEGIN IMMEDIATE` path stays the single implementation.
90
+ */
91
+ export async function transactionAsync<T>(db: TxnCapableDb, fn: () => Promise<T>): Promise<T> {
92
+ db.exec("BEGIN IMMEDIATE");
93
+ try {
94
+ const result = await fn();
95
+ db.exec("COMMIT");
96
+ return result;
97
+ } catch (err) {
98
+ try {
99
+ db.exec("ROLLBACK");
100
+ } catch {
101
+ // no active transaction to roll back
102
+ }
103
+ throw err;
104
+ }
105
+ }
package/core/src/types.ts CHANGED
@@ -263,6 +263,15 @@ export interface Store {
263
263
  */
264
264
  readonly db: Database;
265
265
 
266
+ /**
267
+ * Run `fn` inside a single atomic write transaction (commit on return,
268
+ * rollback on throw). The transaction seam (see core/src/txn.ts): the
269
+ * bun backend implements it as `BEGIN IMMEDIATE … COMMIT`, a future
270
+ * Durable-Object backend as `ctx.storage.transactionSync`. Synchronous —
271
+ * `fn` must not await. Nesting is unsupported (matches raw-SQLite BEGIN).
272
+ */
273
+ transaction<T>(fn: () => T): T;
274
+
266
275
  // Notes. `actor` / `via` carry write-attribution (vault#298) — the
267
276
  // principal + interface stamped onto created_by/created_via (and mirrored
268
277
  // into the last_updated_* pair on create). Omitted → attribution NULL.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.4",
3
+ "version": "0.6.5-rc.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/routes.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
16
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
17
+ import { transactionAsync } from "../core/src/txn.ts";
17
18
  import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
18
19
  import {
19
20
  parseContentRange,
@@ -1143,17 +1144,16 @@ async function handleNotesInner(
1143
1144
  // don't collide with concurrent single-item callers on the shared
1144
1145
  // bun:sqlite connection.
1145
1146
  const batched = items.length > 1;
1146
- if (batched) db.exec("BEGIN");
1147
- try {
1147
+ const runBatch = async (): Promise<void> => {
1148
1148
  for (const item of items) {
1149
1149
  // Validate extension before reaching the Store (vault#328).
1150
- // Thrown inside the BEGIN blockouter catch rolls the batch
1151
- // back, same shape as the path-conflict path.
1150
+ // Thrown inside the batch transaction — rolls the batch back,
1151
+ // same shape as the path-conflict path.
1152
1152
  const extension = item.extension !== undefined
1153
1153
  ? validateExtension(item.extension)
1154
1154
  : undefined;
1155
1155
  // Strict-schema gate (vault#299) — reject before any write so a
1156
- // mid-batch violation rolls back via the outer BEGIN/ROLLBACK.
1156
+ // mid-batch violation rolls back the batch transaction.
1157
1157
  gateStrictWrite(store, writeCtx, {
1158
1158
  path: item.path,
1159
1159
  tags: item.tags,
@@ -1181,9 +1181,10 @@ async function handleNotesInner(
1181
1181
 
1182
1182
  created.push((await store.getNote(note.id)) ?? note);
1183
1183
  }
1184
- if (batched) db.exec("COMMIT");
1184
+ };
1185
+ try {
1186
+ await (batched ? transactionAsync(db, runBatch) : runBatch());
1185
1187
  } catch (e: any) {
1186
- if (batched) db.exec("ROLLBACK");
1187
1188
  // Duck-type for module-boundary robustness (matches the PATCH branch).
1188
1189
  if (e && e.code === "PATH_CONFLICT") {
1189
1190
  return json(