@openparachute/vault 0.6.5-rc.1 → 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/txn.test.ts +68 -0
- package/core/src/txn.ts +31 -4
- package/package.json +1 -1
package/core/src/txn.test.ts
CHANGED
|
@@ -103,6 +103,74 @@ describe("transaction (sync)", () => {
|
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
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
|
+
|
|
106
174
|
describe("transactionAsync", () => {
|
|
107
175
|
it("commits and returns the callback's resolved value", async () => {
|
|
108
176
|
const db = freshDb();
|
package/core/src/txn.ts
CHANGED
|
@@ -23,18 +23,38 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
/** The minimal DB surface the transaction seam needs — kept structural (no
|
|
26
|
-
* `bun:sqlite` import) so a non-bun backend satisfies it too.
|
|
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. */
|
|
27
34
|
export interface TxnCapableDb {
|
|
28
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;
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
/**
|
|
32
42
|
* Run `fn` inside a single write transaction, committing its result or
|
|
33
|
-
* rolling back on throw. Synchronous — the callback must not await (
|
|
34
|
-
* `
|
|
35
|
-
*
|
|
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.
|
|
36
53
|
*/
|
|
37
54
|
export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
|
|
55
|
+
if (typeof db.transactionSync === "function") {
|
|
56
|
+
return db.transactionSync(fn);
|
|
57
|
+
}
|
|
38
58
|
db.exec("BEGIN IMMEDIATE");
|
|
39
59
|
try {
|
|
40
60
|
const result = fn();
|
|
@@ -60,6 +80,13 @@ export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
|
|
|
60
80
|
* same shape as the pre-seam `if (batched) db.exec("BEGIN")` blocks in
|
|
61
81
|
* mcp.ts / routes.ts. A DO backend can't map this onto `transactionSync`
|
|
62
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.
|
|
63
90
|
*/
|
|
64
91
|
export async function transactionAsync<T>(db: TxnCapableDb, fn: () => Promise<T>): Promise<T> {
|
|
65
92
|
db.exec("BEGIN IMMEDIATE");
|