@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.
- 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 +229 -0
- package/core/src/txn.ts +105 -0
- package/core/src/types.ts +9 -0
- package/package.json +1 -1
- package/src/routes.ts +8 -7
package/core/src/schema.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
765
|
+
return { copiedSchemas, copiedHierarchy };
|
|
766
|
+
});
|
|
766
767
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
|
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
|
-
|
|
883
|
+
return { copiedSchemas, copiedMappings };
|
|
884
|
+
});
|
|
888
885
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
720
|
-
|
|
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
|
-
|
|
738
|
-
}
|
|
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
|
+
});
|
package/core/src/txn.ts
ADDED
|
@@ -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
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
|
-
|
|
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
|
|
1151
|
-
//
|
|
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
|
|
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
|
-
|
|
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(
|