@remit/drizzle-service 0.0.70 → 0.0.72
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/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/repos/calendar-feed-token.conformance.sqlite.test.ts +23 -0
- package/src/repos/calendar-feed-token.ts +97 -0
- package/src/repos/calendar-unit-of-work.ts +2 -0
- package/src/repos/i4-address.ts +35 -0
- package/src/repos/i4-outbox-message.test.ts +53 -0
- package/src/repos/i4-outbox-message.ts +10 -1
- package/src/schema.ts +1 -0
- package/src/tx.sqlite.test.ts +83 -0
- package/src/tx.ts +35 -4
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { CreateFailedConflictError, NotFoundError } from "./error.js";
|
|
2
2
|
export { CalendarCollectionRepo } from "./repos/calendar-collection.js";
|
|
3
3
|
export { CalendarEventIndexRepo } from "./repos/calendar-event-index.js";
|
|
4
|
+
export { CalendarFeedTokenRepo } from "./repos/calendar-feed-token.js";
|
|
4
5
|
export { CalendarObjectRepo } from "./repos/calendar-object.js";
|
|
5
6
|
export { CalendarSuggestionRepo } from "./repos/calendar-suggestion.js";
|
|
6
7
|
export { DrizzleCalendarUnitOfWork } from "./repos/calendar-unit-of-work.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { calendarFeedTokenRepositoryConformance } from "@remit/data-ports/conformance";
|
|
2
|
+
import { NotFoundError } from "../error.js";
|
|
3
|
+
import { randomId } from "../id.js";
|
|
4
|
+
import { calendarFeedTokenTable } from "../schema.js";
|
|
5
|
+
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
6
|
+
import { CalendarFeedTokenRepo } from "./calendar-feed-token.js";
|
|
7
|
+
|
|
8
|
+
let close: (() => Promise<void>) | undefined;
|
|
9
|
+
|
|
10
|
+
calendarFeedTokenRepositoryConformance({
|
|
11
|
+
async createRepository() {
|
|
12
|
+
const { db, close: closeDb } = await createSqliteTestDb({
|
|
13
|
+
calendarFeedTokens: calendarFeedTokenTable,
|
|
14
|
+
});
|
|
15
|
+
close = closeDb;
|
|
16
|
+
return new CalendarFeedTokenRepo(db as never);
|
|
17
|
+
},
|
|
18
|
+
teardown: async () => {
|
|
19
|
+
await close?.();
|
|
20
|
+
},
|
|
21
|
+
makeId: () => randomId(),
|
|
22
|
+
isNotFoundError: (error) => error instanceof NotFoundError,
|
|
23
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CalendarFeedTokenItem,
|
|
3
|
+
ICalendarFeedTokenRepository,
|
|
4
|
+
PutCalendarFeedTokenInput,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import { deriveCalendarFeedTokenId } from "@remit/data-ports/id";
|
|
7
|
+
import { and, eq } from "drizzle-orm";
|
|
8
|
+
import type { Db } from "../db.js";
|
|
9
|
+
import { calendarFeedTokenTable } from "../schema.js";
|
|
10
|
+
|
|
11
|
+
type DB = Db<Record<string, unknown>>;
|
|
12
|
+
|
|
13
|
+
function rowToFeedToken(
|
|
14
|
+
row: typeof calendarFeedTokenTable.$inferSelect,
|
|
15
|
+
): CalendarFeedTokenItem {
|
|
16
|
+
return {
|
|
17
|
+
feedTokenId: row.feedTokenId,
|
|
18
|
+
accountConfigId: row.accountConfigId,
|
|
19
|
+
calendarId: row.calendarId,
|
|
20
|
+
tokenHash: row.tokenHash,
|
|
21
|
+
createdAt: row.createdAt,
|
|
22
|
+
rotatedAt: row.rotatedAt,
|
|
23
|
+
updatedAt: row.updatedAt,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class CalendarFeedTokenRepo implements ICalendarFeedTokenRepository {
|
|
28
|
+
constructor(private db: DB) {}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One statement, so a rotation can never be observed as two live addresses.
|
|
32
|
+
* The conflict target is the derived primary key, which is what makes "one
|
|
33
|
+
* active token per calendar" a property of the schema rather than of this
|
|
34
|
+
* method: `createdAt` is left where it was and `rotatedAt` takes the clock,
|
|
35
|
+
* so the row itself says whether this address is the calendar's first.
|
|
36
|
+
*/
|
|
37
|
+
async put(input: PutCalendarFeedTokenInput): Promise<CalendarFeedTokenItem> {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const [row] = await this.db
|
|
40
|
+
.insert(calendarFeedTokenTable)
|
|
41
|
+
.values({
|
|
42
|
+
feedTokenId: deriveCalendarFeedTokenId(input.calendarId),
|
|
43
|
+
accountConfigId: input.accountConfigId,
|
|
44
|
+
calendarId: input.calendarId,
|
|
45
|
+
tokenHash: input.tokenHash,
|
|
46
|
+
createdAt: now,
|
|
47
|
+
rotatedAt: 0,
|
|
48
|
+
updatedAt: now,
|
|
49
|
+
})
|
|
50
|
+
.onConflictDoUpdate({
|
|
51
|
+
target: [
|
|
52
|
+
calendarFeedTokenTable.accountConfigId,
|
|
53
|
+
calendarFeedTokenTable.feedTokenId,
|
|
54
|
+
],
|
|
55
|
+
set: { tokenHash: input.tokenHash, rotatedAt: now, updatedAt: now },
|
|
56
|
+
})
|
|
57
|
+
.returning();
|
|
58
|
+
return rowToFeedToken(row);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async findByCalendar(
|
|
62
|
+
accountConfigId: string,
|
|
63
|
+
calendarId: string,
|
|
64
|
+
): Promise<CalendarFeedTokenItem | null> {
|
|
65
|
+
const [row] = await this.db
|
|
66
|
+
.select()
|
|
67
|
+
.from(calendarFeedTokenTable)
|
|
68
|
+
.where(
|
|
69
|
+
and(
|
|
70
|
+
eq(calendarFeedTokenTable.accountConfigId, accountConfigId),
|
|
71
|
+
eq(calendarFeedTokenTable.calendarId, calendarId),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
return row ? rowToFeedToken(row) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async findByTokenHash(
|
|
78
|
+
tokenHash: string,
|
|
79
|
+
): Promise<CalendarFeedTokenItem | null> {
|
|
80
|
+
const [row] = await this.db
|
|
81
|
+
.select()
|
|
82
|
+
.from(calendarFeedTokenTable)
|
|
83
|
+
.where(eq(calendarFeedTokenTable.tokenHash, tokenHash));
|
|
84
|
+
return row ? rowToFeedToken(row) : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async delete(accountConfigId: string, calendarId: string): Promise<void> {
|
|
88
|
+
await this.db
|
|
89
|
+
.delete(calendarFeedTokenTable)
|
|
90
|
+
.where(
|
|
91
|
+
and(
|
|
92
|
+
eq(calendarFeedTokenTable.accountConfigId, accountConfigId),
|
|
93
|
+
eq(calendarFeedTokenTable.calendarId, calendarId),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -6,6 +6,7 @@ import type { Db } from "../db.js";
|
|
|
6
6
|
import { runInTransaction } from "../tx.js";
|
|
7
7
|
import { CalendarCollectionRepo } from "./calendar-collection.js";
|
|
8
8
|
import { CalendarEventIndexRepo } from "./calendar-event-index.js";
|
|
9
|
+
import { CalendarFeedTokenRepo } from "./calendar-feed-token.js";
|
|
9
10
|
import { CalendarObjectRepo } from "./calendar-object.js";
|
|
10
11
|
import { CalendarSuggestionRepo } from "./calendar-suggestion.js";
|
|
11
12
|
|
|
@@ -23,6 +24,7 @@ export class DrizzleCalendarUnitOfWork implements ICalendarUnitOfWork {
|
|
|
23
24
|
calendarObject: new CalendarObjectRepo(tx as never),
|
|
24
25
|
calendarEventIndex: new CalendarEventIndexRepo(tx as never),
|
|
25
26
|
calendarSuggestion: new CalendarSuggestionRepo(tx as never),
|
|
27
|
+
calendarFeedToken: new CalendarFeedTokenRepo(tx as never),
|
|
26
28
|
}),
|
|
27
29
|
);
|
|
28
30
|
}
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
desc,
|
|
19
19
|
eq,
|
|
20
20
|
getTableColumns,
|
|
21
|
+
gt,
|
|
21
22
|
inArray,
|
|
22
23
|
type SQL,
|
|
23
24
|
sql,
|
|
@@ -748,6 +749,40 @@ export class AddressRepo implements IAddressRepository {
|
|
|
748
749
|
);
|
|
749
750
|
}
|
|
750
751
|
|
|
752
|
+
async listAllByAccountConfigPage(input: {
|
|
753
|
+
accountConfigId: string;
|
|
754
|
+
cursor?: string;
|
|
755
|
+
limit?: number;
|
|
756
|
+
}): Promise<ResultList<AddressItem>> {
|
|
757
|
+
const { accountConfigId, cursor, limit = 100 } = input;
|
|
758
|
+
const afterAddressId = cursor
|
|
759
|
+
? (decodeToken(cursor).addressId as string)
|
|
760
|
+
: undefined;
|
|
761
|
+
|
|
762
|
+
const rows = await this.db
|
|
763
|
+
.select()
|
|
764
|
+
.from(addressTable)
|
|
765
|
+
.where(
|
|
766
|
+
and(
|
|
767
|
+
eq(addressTable.accountConfigId, accountConfigId),
|
|
768
|
+
afterAddressId
|
|
769
|
+
? gt(addressTable.addressId, afterAddressId)
|
|
770
|
+
: undefined,
|
|
771
|
+
),
|
|
772
|
+
)
|
|
773
|
+
.orderBy(asc(addressTable.addressId))
|
|
774
|
+
.limit(limit + 1);
|
|
775
|
+
|
|
776
|
+
const hasMore = rows.length > limit;
|
|
777
|
+
const items = rows.slice(0, limit).map(rowToAddress);
|
|
778
|
+
const lastItem = items[items.length - 1];
|
|
779
|
+
return resultList(
|
|
780
|
+
items,
|
|
781
|
+
limit,
|
|
782
|
+
hasMore && lastItem ? { addressId: lastItem.addressId } : undefined,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
751
786
|
async createEnvelopeAddress(
|
|
752
787
|
input: CreateEnvelopeAddressInput,
|
|
753
788
|
): Promise<EnvelopeAddressItem> {
|
|
@@ -224,6 +224,59 @@ describe("OutboxMessageRepo", () => {
|
|
|
224
224
|
await repo.deleteMany(accountConfigId, created);
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
+
test("listByAccounts pages one ordering over every account (#1013)", async () => {
|
|
228
|
+
const accountConfigId = randomId();
|
|
229
|
+
const first = randomId();
|
|
230
|
+
const second = randomId();
|
|
231
|
+
const created: string[] = [];
|
|
232
|
+
const byAccount = new Map<string, string[]>([
|
|
233
|
+
[first, []],
|
|
234
|
+
[second, []],
|
|
235
|
+
]);
|
|
236
|
+
for (let i = 0; i < 6; i++) {
|
|
237
|
+
const accountId = i % 2 === 0 ? first : second;
|
|
238
|
+
const msg = await repo.create(
|
|
239
|
+
makeOutboxInput(accountId, accountConfigId),
|
|
240
|
+
);
|
|
241
|
+
created.push(msg.outboxMessageId);
|
|
242
|
+
byAccount.get(accountId)?.push(msg.outboxMessageId);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const wholeList = await repo.listByAccounts([first, second]);
|
|
246
|
+
const wholeIds = wholeList.items.map((m) => m.outboxMessageId);
|
|
247
|
+
for (const ids of byAccount.values()) {
|
|
248
|
+
for (const id of ids) {
|
|
249
|
+
assert.ok(wholeIds.includes(id), "every account's rows come back");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const seen: string[] = [];
|
|
254
|
+
let continuationToken: string | undefined;
|
|
255
|
+
let pages = 0;
|
|
256
|
+
do {
|
|
257
|
+
const page = await repo.listByAccounts([first, second], {
|
|
258
|
+
limit: 2,
|
|
259
|
+
continuationToken,
|
|
260
|
+
});
|
|
261
|
+
seen.push(...page.items.map((m) => m.outboxMessageId));
|
|
262
|
+
continuationToken = page.continuationToken;
|
|
263
|
+
pages++;
|
|
264
|
+
assert.ok(pages < 10, "pagination must terminate");
|
|
265
|
+
} while (continuationToken);
|
|
266
|
+
|
|
267
|
+
assert.equal(seen.length, 6, "every row returned exactly once");
|
|
268
|
+
assert.equal(new Set(seen).size, 6, "no duplicates across pages");
|
|
269
|
+
assert.deepEqual([...seen].sort(), [...created].sort(), "no gaps");
|
|
270
|
+
|
|
271
|
+
await repo.deleteMany(accountConfigId, created);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("listByAccounts answers no accounts with no rows", async () => {
|
|
275
|
+
const list = await repo.listByAccounts([]);
|
|
276
|
+
assert.deepEqual(list.items, []);
|
|
277
|
+
assert.equal(list.continuationToken, undefined);
|
|
278
|
+
});
|
|
279
|
+
|
|
227
280
|
test("listQueued returns only queued messages", async () => {
|
|
228
281
|
const accountId = randomId();
|
|
229
282
|
const accountConfigId = randomId();
|
|
@@ -272,8 +272,17 @@ export class OutboxMessageRepo implements IOutboxMessageRepository {
|
|
|
272
272
|
async listByAccount(
|
|
273
273
|
accountId: string,
|
|
274
274
|
options?: { limit?: number; continuationToken?: string },
|
|
275
|
+
): Promise<ResultList<OutboxMessageItem>> {
|
|
276
|
+
return this.listByAccounts([accountId], options);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async listByAccounts(
|
|
280
|
+
accountIds: string[],
|
|
281
|
+
options?: { limit?: number; continuationToken?: string },
|
|
275
282
|
): Promise<ResultList<OutboxMessageItem>> {
|
|
276
283
|
const limit = options?.limit ?? 100;
|
|
284
|
+
if (accountIds.length === 0) return resultList([], limit);
|
|
285
|
+
|
|
277
286
|
const cursor = options?.continuationToken
|
|
278
287
|
? decodeToken(options.continuationToken)
|
|
279
288
|
: undefined;
|
|
@@ -289,7 +298,7 @@ export class OutboxMessageRepo implements IOutboxMessageRepository {
|
|
|
289
298
|
.from(outboxMessageTable)
|
|
290
299
|
.where(
|
|
291
300
|
and(
|
|
292
|
-
|
|
301
|
+
inArray(outboxMessageTable.accountId, accountIds),
|
|
293
302
|
after
|
|
294
303
|
? or(
|
|
295
304
|
lt(outboxMessageTable.createdAt, after.createdAt),
|
package/src/schema.ts
CHANGED
|
@@ -11,6 +11,7 @@ import * as entities from "@remit/drizzle-sqlite-schema";
|
|
|
11
11
|
export const calendarTable = entities.calendarCollections;
|
|
12
12
|
export const calendarObjectTable = entities.calendarObjects;
|
|
13
13
|
export const calendarEventIndexTable = entities.calendarEventIndexes;
|
|
14
|
+
export const calendarFeedTokenTable = entities.calendarFeedTokens;
|
|
14
15
|
export const calendarSuggestionTable = entities.calendarSuggestions;
|
|
15
16
|
export const filterAnchorTable = entities.filterAnchors;
|
|
16
17
|
export const filterTable = entities.filters;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, before, describe, test } from "node:test";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import { sql } from "drizzle-orm";
|
|
8
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
9
|
+
import type { Db } from "./db.js";
|
|
10
|
+
import { runInTransaction } from "./tx.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A write set that reads before it writes, against the file four processes
|
|
14
|
+
* share (RFC 036 D3).
|
|
15
|
+
*
|
|
16
|
+
* SQLite gives a deferred transaction its write lock at its first write, and by
|
|
17
|
+
* then a unit that read first is holding a snapshot. Another process committing
|
|
18
|
+
* in that gap makes the upgrade impossible, and SQLite refuses it at once —
|
|
19
|
+
* `busy_timeout` waits for a lock, not for a snapshot that is already stale. The
|
|
20
|
+
* unit then dies with "database is locked" however long that timeout is, which
|
|
21
|
+
* is what answered a calendar write 500 while the imap-worker was syncing.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const PRAGMAS = [
|
|
25
|
+
"journal_mode = WAL",
|
|
26
|
+
"busy_timeout = 5000",
|
|
27
|
+
"synchronous = NORMAL",
|
|
28
|
+
"foreign_keys = ON",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
let directory: string;
|
|
32
|
+
let sqlite: Database.Database;
|
|
33
|
+
let other: Database.Database;
|
|
34
|
+
let db: Db<Record<string, never>>;
|
|
35
|
+
|
|
36
|
+
before(() => {
|
|
37
|
+
directory = mkdtempSync(join(tmpdir(), "remit-tx-"));
|
|
38
|
+
const path = join(directory, "remit.db");
|
|
39
|
+
|
|
40
|
+
sqlite = new Database(path);
|
|
41
|
+
for (const pragma of PRAGMAS) sqlite.pragma(pragma);
|
|
42
|
+
sqlite.exec("CREATE TABLE row (id INTEGER PRIMARY KEY, v TEXT NOT NULL)");
|
|
43
|
+
sqlite.exec("INSERT INTO row (id, v) VALUES (1, 'read'), (2, 'other')");
|
|
44
|
+
|
|
45
|
+
// The other writer, as a second connection. It refuses instead of waiting,
|
|
46
|
+
// so the run finishes in its own time whichever of the two holds the lock.
|
|
47
|
+
other = new Database(path);
|
|
48
|
+
other.pragma("busy_timeout = 0");
|
|
49
|
+
|
|
50
|
+
db = drizzle(sqlite) as unknown as Db<Record<string, never>>;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
after(() => {
|
|
54
|
+
other.close();
|
|
55
|
+
sqlite.close();
|
|
56
|
+
rmSync(directory, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("a top-level sqlite unit that reads before it writes", () => {
|
|
60
|
+
test("commits through another connection's write landing between the two", async () => {
|
|
61
|
+
const outcome = await runInTransaction(db, async (tx) => {
|
|
62
|
+
await tx.get(sql`SELECT v FROM row WHERE id = 1`);
|
|
63
|
+
|
|
64
|
+
// The other process, arriving in the gap an async write set leaves
|
|
65
|
+
// open. Whether its own write lands is not the claim here — only that
|
|
66
|
+
// this unit can still finish the one it came to make.
|
|
67
|
+
try {
|
|
68
|
+
other.prepare("UPDATE row SET v = 'moved' WHERE id = 2").run();
|
|
69
|
+
} catch {
|
|
70
|
+
// Refused because this unit holds the write lock, which is the
|
|
71
|
+
// outcome that keeps the unit below alive.
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await tx.run(sql`UPDATE row SET v = 'written' WHERE id = 1`);
|
|
75
|
+
return "committed";
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.equal(outcome, "committed");
|
|
79
|
+
assert.deepEqual(sqlite.prepare("SELECT v FROM row WHERE id = 1").get(), {
|
|
80
|
+
v: "written",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/tx.ts
CHANGED
|
@@ -7,9 +7,19 @@ import type { Db } from "./db.js";
|
|
|
7
7
|
//
|
|
8
8
|
// better-sqlite3's native transaction runner rejects a callback that returns a
|
|
9
9
|
// promise, and the repos' write sets are async, so drizzle's own
|
|
10
|
-
// `db.transaction()` cannot be used.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// `db.transaction()` cannot be used. A top-level unit is bracketed with
|
|
11
|
+
// `BEGIN IMMEDIATE` / `COMMIT` instead, and a nested one with a SAVEPOINT.
|
|
12
|
+
//
|
|
13
|
+
// IMMEDIATE, not the deferred transaction a bare `BEGIN` or `SAVEPOINT` opens.
|
|
14
|
+
// A deferred transaction takes its write lock at its first write, and a unit
|
|
15
|
+
// that reads before it writes (every unit that checks a row before updating it)
|
|
16
|
+
// has by then taken a read snapshot. If another process commits in between,
|
|
17
|
+
// SQLite refuses the upgrade with SQLITE_BUSY_SNAPSHOT — and that refusal is
|
|
18
|
+
// immediate, because `busy_timeout` cannot wait out a snapshot that is already
|
|
19
|
+
// stale. Four processes write this file (RFC 036 D3), so the gap is real: it
|
|
20
|
+
// answered a calendar write 500 while the imap-worker was syncing. Taking the
|
|
21
|
+
// write lock at BEGIN closes it — an IMMEDIATE that meets another writer waits
|
|
22
|
+
// out the `busy_timeout` and then proceeds, which is what that timeout is for.
|
|
13
23
|
//
|
|
14
24
|
// All writers on this backend share one better-sqlite3 connection (RFC 036 D3),
|
|
15
25
|
// and every query runs synchronously, but an async callback still yields the
|
|
@@ -61,6 +71,27 @@ async function runSqliteSavepoint<TSchema extends Record<string, unknown>, T>(
|
|
|
61
71
|
}
|
|
62
72
|
}
|
|
63
73
|
|
|
74
|
+
async function runSqliteImmediate<TSchema extends Record<string, unknown>, T>(
|
|
75
|
+
db: Db<TSchema>,
|
|
76
|
+
fn: (tx: Db<TSchema>) => Promise<T>,
|
|
77
|
+
): Promise<T> {
|
|
78
|
+
const runner = db as unknown as { run: (query: SQL) => unknown };
|
|
79
|
+
runner.run(sql.raw("BEGIN IMMEDIATE"));
|
|
80
|
+
try {
|
|
81
|
+
const result = await fn(db);
|
|
82
|
+
runner.run(sql.raw("COMMIT"));
|
|
83
|
+
return result;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
try {
|
|
86
|
+
runner.run(sql.raw("ROLLBACK"));
|
|
87
|
+
} catch {
|
|
88
|
+
// A failed rollback must not mask the error that caused it; surface
|
|
89
|
+
// the original below.
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
64
95
|
export async function runInTransaction<
|
|
65
96
|
TSchema extends Record<string, unknown>,
|
|
66
97
|
T,
|
|
@@ -72,7 +103,7 @@ export async function runInTransaction<
|
|
|
72
103
|
}
|
|
73
104
|
|
|
74
105
|
return serializeSqlite(() =>
|
|
75
|
-
inSqliteTx.run(true, () =>
|
|
106
|
+
inSqliteTx.run(true, () => runSqliteImmediate(db, fn)),
|
|
76
107
|
);
|
|
77
108
|
}
|
|
78
109
|
|