@remit/drizzle-service 0.0.99 → 0.0.101
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/batch.sqlite.test.ts +78 -0
- package/src/batch.ts +46 -0
- package/src/databases.ts +70 -0
- package/src/db.ts +10 -9
- package/src/generated-enum.test.ts +8 -0
- package/src/generated-id.test.ts +19 -0
- package/src/id.test.ts +0 -8
- package/src/id.ts +0 -6
- package/src/index.ts +16 -6
- package/src/repair/address-display-name.sqlite.test.ts +3 -5
- package/src/repair/address-display-name.ts +8 -3
- package/src/repair/junk-only-address.sqlite.test.ts +8 -36
- package/src/repair/stranded-sent-outbox.sqlite.test.ts +2 -9
- package/src/repos/account.conformance.sqlite.test.ts +1 -1
- package/src/repos/calendar-default.sqlite.test.ts +6 -6
- package/src/repos/calendar-put.sqlite.test.ts +40 -39
- package/src/repos/calendar-store.ts +273 -0
- package/src/repos/cascade-delete.sqlite.test.ts +7 -9
- package/src/repos/cascade-delete.test.ts +6 -13
- package/src/repos/cascade-delete.ts +127 -97
- package/src/repos/envelope.test.ts +1 -17
- package/src/repos/envelope.ts +83 -67
- package/src/repos/filter.test.ts +22 -2
- package/src/repos/filter.ts +1 -5
- package/src/repos/i4-account-config.test.ts +2 -2
- package/src/repos/i4-account-config.ts +5 -2
- package/src/repos/i4-account.test.ts +6 -2
- package/src/repos/i4-account.ts +12 -56
- package/src/repos/i4-address-junk-move.sqlite.test.ts +1 -1
- package/src/repos/i4-address-merge-flags.sqlite.test.ts +1 -2
- package/src/repos/i4-address.ts +58 -90
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +6 -2
- package/src/repos/i4-mailbox-special-use.ts +9 -6
- package/src/repos/i4-mailbox.sqlite.test.ts +12 -14
- package/src/repos/i4-mailbox.test.ts +17 -62
- package/src/repos/i4-mailbox.ts +121 -94
- package/src/repos/i4-organize-job-request.ts +0 -4
- package/src/repos/i4-outbox-attachment.ts +62 -33
- package/src/repos/mappers.ts +0 -16
- package/src/repos/message.sqlite.test.ts +32 -106
- package/src/repos/message.test.ts +6 -167
- package/src/repos/message.ts +216 -228
- package/src/repos/sync-warning.ts +76 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +5 -10
- package/src/repos/thread-message-field-terms.sqlite.test.ts +5 -1
- package/src/repos/thread-message.sqlite.test.ts +2 -1
- package/src/repos/thread-message.test.ts +17 -4
- package/src/repos/thread-message.ts +502 -285
- package/src/repos/thread-search-predicates.ts +24 -26
- package/src/schema/message-data.ts +4 -8
- package/src/schema/sync-warning.ts +3 -0
- package/src/schema-sets/app.ts +17 -0
- package/src/schema-sets/mail.ts +32 -0
- package/src/schema-sets/search.ts +5 -0
- package/src/schema-version-accounting.sqlite.test.ts +56 -43
- package/src/schema.ts +3 -4
- package/src/search/thread-message-search.sqlite.test.ts +131 -0
- package/src/search/thread-message-search.ts +271 -0
- package/src/sqlite-client.ts +5 -7
- package/src/sqlite-repositories.ts +75 -0
- package/src/test-db-sqlite.ts +5 -13
- package/src/test-shipped-sqlite-schema.ts +46 -45
- package/src/test-sqlite.ts +35 -11
- package/src/vps-migrations-drift.sqlite.test.ts +81 -15
- package/src/account-granted-scopes-migration.sqlite.test.ts +0 -66
- package/src/account-synced-services-migration.sqlite.test.ts +0 -56
- package/src/mailbox-path-dedupe.sqlite.test.ts +0 -462
- package/src/mailbox-sync-status-backfill.sqlite.test.ts +0 -118
- package/src/outbox-created-cleanup.sqlite.test.ts +0 -83
- package/src/repair/duplicate-mailbox-mail.ts +0 -98
- package/src/repair/thread-message-category-contract.ts +0 -505
- package/src/repair/thread-message-category.sqlite.test.ts +0 -100
- package/src/repair/thread-message-category.ts +0 -415
- package/src/repos/calendar-migrations.sqlite.test.ts +0 -345
- package/src/repos/calendar-unit-of-work.ts +0 -31
- package/src/repos/config-import-atomicity.sqlite.test.ts +0 -255
- package/src/repos/filter-anchor-transaction.test.ts +0 -136
- package/src/repos/filter-anchor-transaction.ts +0 -45
- package/src/repos/filter-anchor.test.ts +0 -131
- package/src/repos/filter-anchor.ts +0 -105
- package/src/repos/i4-mailbox-special-use.structure.test.ts +0 -27
- package/src/repos/message-placement-backfill.sqlite.test.ts +0 -190
- package/src/repos/search-index-shape.test.ts +0 -82
- package/src/repos/search-index-shape.ts +0 -56
- package/src/repos/serialized-writes.sqlite.test.ts +0 -199
- package/src/repos/unit-of-work.sqlite.test.ts +0 -138
- package/src/repos/unit-of-work.test.ts +0 -109
- package/src/repos/unit-of-work.ts +0 -31
- package/src/schema/outbox.ts +0 -46
- package/src/schema-full-sqlite.ts +0 -13
- package/src/tx.sqlite.test.ts +0 -83
- package/src/tx.ts +0 -233
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The guard that keeps an instance installed by an earlier build from running a
|
|
3
|
-
* predicate its index cannot answer.
|
|
4
|
-
*
|
|
5
|
-
* `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table, so
|
|
6
|
-
* adding an indexed column changes nothing on a database that already has the
|
|
7
|
-
* old one — and a MATCH naming that column raises rather than missing. The
|
|
8
|
-
* migrator compares the shipped shape with the installed one and rebuilds.
|
|
9
|
-
*/
|
|
10
|
-
import assert from "node:assert/strict";
|
|
11
|
-
import { readFileSync } from "node:fs";
|
|
12
|
-
import { describe, test } from "node:test";
|
|
13
|
-
import {
|
|
14
|
-
searchIndexColumns,
|
|
15
|
-
searchIndexShapeIsCurrent,
|
|
16
|
-
} from "./search-index-shape.js";
|
|
17
|
-
|
|
18
|
-
const shipped = readFileSync(
|
|
19
|
-
new URL("../../../../npm-scripts/sqlite-search-index.sql", import.meta.url),
|
|
20
|
-
"utf8",
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
const installed = (columns: string[]): string =>
|
|
24
|
-
`CREATE VIRTUAL TABLE thread_message_fts USING fts5(${columns.join(", ")}, content='thread_message', content_rowid='rowid', tokenize='trigram remove_diacritics 1')`;
|
|
25
|
-
|
|
26
|
-
describe("searchIndexColumns", () => {
|
|
27
|
-
test("reads the indexed columns and leaves the options out", () => {
|
|
28
|
-
assert.deepEqual(searchIndexColumns(installed(["subject", "sender"])), [
|
|
29
|
-
"subject",
|
|
30
|
-
"sender",
|
|
31
|
-
]);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("reads the shipped index, triggers and all", () => {
|
|
35
|
-
assert.deepEqual(searchIndexColumns(shipped), [
|
|
36
|
-
"subject",
|
|
37
|
-
"sender",
|
|
38
|
-
"body",
|
|
39
|
-
]);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("a table that is not an fts5 index has no columns", () => {
|
|
43
|
-
assert.deepEqual(searchIndexColumns("CREATE TABLE t (a, b)"), []);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe("searchIndexShapeIsCurrent", () => {
|
|
48
|
-
test("the shipped index matches itself", () => {
|
|
49
|
-
assert.equal(searchIndexShapeIsCurrent(shipped, shipped), true);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
// The upgrade this exists for: an instance carrying the two-column index the
|
|
53
|
-
// previous build installed must be rebuilt, or every search raises.
|
|
54
|
-
test("an index missing an indexed column is stale", () => {
|
|
55
|
-
assert.equal(
|
|
56
|
-
searchIndexShapeIsCurrent(installed(["subject", "sender"]), shipped),
|
|
57
|
-
false,
|
|
58
|
-
);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("an index carrying every shipped column is current", () => {
|
|
62
|
-
assert.equal(
|
|
63
|
-
searchIndexShapeIsCurrent(
|
|
64
|
-
installed(["subject", "sender", "body"]),
|
|
65
|
-
shipped,
|
|
66
|
-
),
|
|
67
|
-
true,
|
|
68
|
-
);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// An extra column indexes text nothing reads. It costs space and matches
|
|
72
|
-
// nothing wrong, so it is not a reason to drop and re-tokenize the table.
|
|
73
|
-
test("an index carrying more than the shipped columns is current", () => {
|
|
74
|
-
assert.equal(
|
|
75
|
-
searchIndexShapeIsCurrent(
|
|
76
|
-
installed(["subject", "sender", "body", "labels"]),
|
|
77
|
-
shipped,
|
|
78
|
-
),
|
|
79
|
-
true,
|
|
80
|
-
);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Whether an installed FTS5 search index carries the columns the shipped one
|
|
3
|
-
* declares.
|
|
4
|
-
*
|
|
5
|
-
* `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against a table that already
|
|
6
|
-
* exists, so a database installed by an earlier build keeps that build's
|
|
7
|
-
* columns for good. A predicate naming a column that build never indexed does
|
|
8
|
-
* not degrade — SQLite raises, and every search on that instance fails — so the
|
|
9
|
-
* migrator compares the two shapes and rebuilds where they differ.
|
|
10
|
-
*
|
|
11
|
-
* Both sides are read from DDL rather than from a hand-kept list: the shipped
|
|
12
|
-
* side is the committed `.sql`, the installed side is what `sqlite_master`
|
|
13
|
-
* holds, and neither can drift from what is actually there.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** The text between the parentheses of a `USING fts5(...)` clause. */
|
|
17
|
-
const fts5Arguments = (ddl: string): string => {
|
|
18
|
-
const clause = ddl.search(/using\s+fts5\s*\(/i);
|
|
19
|
-
if (clause === -1) return "";
|
|
20
|
-
const open = ddl.indexOf("(", clause);
|
|
21
|
-
let depth = 0;
|
|
22
|
-
for (let index = open; index < ddl.length; index++) {
|
|
23
|
-
const char = ddl[index];
|
|
24
|
-
if (char === "(") depth++;
|
|
25
|
-
if (char !== ")") continue;
|
|
26
|
-
depth--;
|
|
27
|
-
if (depth === 0) return ddl.slice(open + 1, index);
|
|
28
|
-
}
|
|
29
|
-
return "";
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The indexed column names an FTS5 table declares. An argument carrying an `=`
|
|
34
|
-
* is an option (`content=`, `tokenize=`), not a column, and anything quoted or
|
|
35
|
-
* otherwise unusual is left out rather than guessed at — a name this cannot
|
|
36
|
-
* read is a name the comparison must not claim is missing.
|
|
37
|
-
*/
|
|
38
|
-
export const searchIndexColumns = (ddl: string): string[] =>
|
|
39
|
-
fts5Arguments(ddl)
|
|
40
|
-
.split(",")
|
|
41
|
-
.map((argument) => argument.trim())
|
|
42
|
-
.filter((argument) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(argument));
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Whether `installed` indexes every column `shipped` does. A superset passes:
|
|
46
|
-
* an extra column indexes text no predicate reads, which costs space and
|
|
47
|
-
* matches nothing wrong.
|
|
48
|
-
*/
|
|
49
|
-
export const searchIndexShapeIsCurrent = (
|
|
50
|
-
installed: string,
|
|
51
|
-
shipped: string,
|
|
52
|
-
): boolean => {
|
|
53
|
-
const columns = new Set(searchIndexColumns(installed));
|
|
54
|
-
const expected = searchIndexColumns(shipped);
|
|
55
|
-
return expected.length > 0 && expected.every((column) => columns.has(column));
|
|
56
|
-
};
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { after, before, describe, test } from "node:test";
|
|
3
|
-
import { eq } from "drizzle-orm";
|
|
4
|
-
import type { Db } from "../db.js";
|
|
5
|
-
import {
|
|
6
|
-
type MessageDataSchema,
|
|
7
|
-
messageDataSchema,
|
|
8
|
-
outboxTable,
|
|
9
|
-
} from "../schema/message-data.js";
|
|
10
|
-
import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
|
|
11
|
-
import { runInTransaction, serializeSqliteWrites } from "../tx.js";
|
|
12
|
-
|
|
13
|
-
// The RFC 036 D3 hard requirement: every write on the shared better-sqlite3
|
|
14
|
-
// connection routes through the wave-1 serialization, so a plain single
|
|
15
|
-
// statement can never land inside another unit's open SAVEPOINT (the
|
|
16
|
-
// uncommitted-read / rollback hazard named on PR #1310). The db handle repos
|
|
17
|
-
// hold is `serializeSqliteWrites(db)` — unserialized access is impossible by
|
|
18
|
-
// construction. The `outbox` table stands in for any write target here (few
|
|
19
|
-
// required columns, already dialect-cast for the repos).
|
|
20
|
-
|
|
21
|
-
const row = (id: string) => ({
|
|
22
|
-
id,
|
|
23
|
-
messageId: `msg-${id}`,
|
|
24
|
-
event: "message.body_synced" as const,
|
|
25
|
-
payload: { messageId: `msg-${id}` },
|
|
26
|
-
createdAt: new Date(),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const nextTick = (): Promise<void> =>
|
|
30
|
-
new Promise((resolve) => setImmediate(resolve));
|
|
31
|
-
|
|
32
|
-
describe("serialized SQLite writes (RFC 036 D3)", () => {
|
|
33
|
-
let raw: SqliteTestDb<MessageDataSchema>;
|
|
34
|
-
let db: Db<MessageDataSchema>;
|
|
35
|
-
let close: () => Promise<void>;
|
|
36
|
-
|
|
37
|
-
before(async () => {
|
|
38
|
-
({ db: raw, close } =
|
|
39
|
-
await createSqliteTestDb<MessageDataSchema>(messageDataSchema));
|
|
40
|
-
db = serializeSqliteWrites(raw);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
after(async () => {
|
|
44
|
-
await close();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
const byId = (id: string) =>
|
|
48
|
-
raw.select().from(outboxTable).where(eq(outboxTable.id, id));
|
|
49
|
-
|
|
50
|
-
test("a single-statement write does not interleave into an open transaction", async () => {
|
|
51
|
-
const order: string[] = [];
|
|
52
|
-
let release: () => void = () => {};
|
|
53
|
-
const barrier = new Promise<void>((resolve) => {
|
|
54
|
-
release = resolve;
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
// A top-level transaction: insert TX_ROW, park on a barrier (yielding the
|
|
58
|
-
// event loop with the SAVEPOINT open), then throw so the whole unit rolls
|
|
59
|
-
// back.
|
|
60
|
-
const txDone = runInTransaction(db, async (tx) => {
|
|
61
|
-
await tx.insert(outboxTable).values(row("tx-row"));
|
|
62
|
-
order.push("tx-insert");
|
|
63
|
-
await barrier;
|
|
64
|
-
order.push("tx-throw");
|
|
65
|
-
throw new Error("rollback");
|
|
66
|
-
}).catch(() => {
|
|
67
|
-
order.push("tx-rolledback");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// Let the transaction reach the barrier before the single write is issued.
|
|
71
|
-
await nextTick();
|
|
72
|
-
await nextTick();
|
|
73
|
-
|
|
74
|
-
const singleWrite = db
|
|
75
|
-
.insert(outboxTable)
|
|
76
|
-
.values(row("single-row"))
|
|
77
|
-
.then(() => {
|
|
78
|
-
order.push("single-committed");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// While the transaction holds the write queue, the single write must not
|
|
82
|
-
// have executed — it is queued behind the open unit, not interleaved into
|
|
83
|
-
// its savepoint.
|
|
84
|
-
await nextTick();
|
|
85
|
-
await nextTick();
|
|
86
|
-
assert.equal(
|
|
87
|
-
order.includes("single-committed"),
|
|
88
|
-
false,
|
|
89
|
-
"single write ran while a top-level transaction was open",
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
release();
|
|
93
|
-
await txDone;
|
|
94
|
-
await singleWrite;
|
|
95
|
-
|
|
96
|
-
assert.deepEqual(order, [
|
|
97
|
-
"tx-insert",
|
|
98
|
-
"tx-throw",
|
|
99
|
-
"tx-rolledback",
|
|
100
|
-
"single-committed",
|
|
101
|
-
]);
|
|
102
|
-
|
|
103
|
-
// The transaction rolled back; the single write committed independently —
|
|
104
|
-
// it was never part of the rolled-back savepoint.
|
|
105
|
-
assert.equal(
|
|
106
|
-
(await byId("tx-row")).length,
|
|
107
|
-
0,
|
|
108
|
-
"transaction must roll back",
|
|
109
|
-
);
|
|
110
|
-
assert.equal(
|
|
111
|
-
(await byId("single-row")).length,
|
|
112
|
-
1,
|
|
113
|
-
"single write must commit, not be swallowed by the rolled-back transaction",
|
|
114
|
-
);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test("concurrent single-statement writes all commit", async () => {
|
|
118
|
-
const ids = Array.from({ length: 20 }, (_, i) => `w-${i}`);
|
|
119
|
-
await Promise.all(ids.map((id) => db.insert(outboxTable).values(row(id))));
|
|
120
|
-
for (const id of ids) {
|
|
121
|
-
assert.equal((await byId(id)).length, 1, `${id} must be committed`);
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("the explicit .run() executor is also serialized, not just await", async () => {
|
|
126
|
-
// A write executed via `.run()` (not `await`) must still go through the
|
|
127
|
-
// queue — the hardened wrapper intercepts every terminal executor.
|
|
128
|
-
const builder = db
|
|
129
|
-
.insert(outboxTable)
|
|
130
|
-
.values(row("run-row")) as unknown as {
|
|
131
|
-
run: () => Promise<unknown>;
|
|
132
|
-
};
|
|
133
|
-
await builder.run();
|
|
134
|
-
assert.equal((await byId("run-row")).length, 1, "run() committed the row");
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("the .values() executor is serialized, not run inline", async () => {
|
|
138
|
-
// `values` doubles as the insert chain method and (on a runnable with
|
|
139
|
-
// `.returning()`) a terminal executor. The executor form must queue like
|
|
140
|
-
// every other terminal — issued while a transaction is parked, it must
|
|
141
|
-
// wait for the unit to finish instead of interleaving into its savepoint.
|
|
142
|
-
const order: string[] = [];
|
|
143
|
-
let release: () => void = () => {};
|
|
144
|
-
const barrier = new Promise<void>((resolve) => {
|
|
145
|
-
release = resolve;
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
const txDone = runInTransaction(db, async (tx) => {
|
|
149
|
-
await tx.insert(outboxTable).values(row("values-tx-row"));
|
|
150
|
-
order.push("tx-insert");
|
|
151
|
-
await barrier;
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
await nextTick();
|
|
155
|
-
await nextTick();
|
|
156
|
-
|
|
157
|
-
const runnable = db
|
|
158
|
-
.insert(outboxTable)
|
|
159
|
-
.values(row("values-row"))
|
|
160
|
-
.returning() as unknown as { values: () => Promise<unknown[]> };
|
|
161
|
-
const executorWrite = runnable.values().then(() => {
|
|
162
|
-
order.push("values-committed");
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
await nextTick();
|
|
166
|
-
await nextTick();
|
|
167
|
-
assert.equal(
|
|
168
|
-
order.includes("values-committed"),
|
|
169
|
-
false,
|
|
170
|
-
".values() executor ran while a top-level transaction was open",
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
release();
|
|
174
|
-
await txDone;
|
|
175
|
-
await executorWrite;
|
|
176
|
-
assert.deepEqual(order, ["tx-insert", "values-committed"]);
|
|
177
|
-
assert.equal((await byId("values-row")).length, 1);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test("writes inside a transaction still commit atomically", async () => {
|
|
181
|
-
await runInTransaction(db, async (tx) => {
|
|
182
|
-
await tx.insert(outboxTable).values(row("a"));
|
|
183
|
-
await tx
|
|
184
|
-
.update(outboxTable)
|
|
185
|
-
.set({ event: "message.moved" })
|
|
186
|
-
.where(eq(outboxTable.id, "a"));
|
|
187
|
-
});
|
|
188
|
-
const [committed] = await byId("a");
|
|
189
|
-
assert.equal(committed?.event, "message.moved");
|
|
190
|
-
|
|
191
|
-
await assert.rejects(() =>
|
|
192
|
-
runInTransaction(db, async (tx) => {
|
|
193
|
-
await tx.insert(outboxTable).values(row("b"));
|
|
194
|
-
throw new Error("boom");
|
|
195
|
-
}),
|
|
196
|
-
);
|
|
197
|
-
assert.equal((await byId("b")).length, 0, "transaction rollback drops b");
|
|
198
|
-
});
|
|
199
|
-
});
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { after, before, describe, test } from "node:test";
|
|
3
|
-
import { eq } from "drizzle-orm";
|
|
4
|
-
import {
|
|
5
|
-
envelopeId as deriveEnvelopeId,
|
|
6
|
-
rootBodyPartId as deriveRootBodyPartId,
|
|
7
|
-
} from "../id.js";
|
|
8
|
-
import { addressTable } from "../schema/i4-address.js";
|
|
9
|
-
import {
|
|
10
|
-
type MessageDataSchema,
|
|
11
|
-
messageDataSchema,
|
|
12
|
-
messageTable,
|
|
13
|
-
} from "../schema/message-data.js";
|
|
14
|
-
import { threadMessageTable } from "../schema/thread-message.js";
|
|
15
|
-
import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
|
|
16
|
-
import { DrizzleUnitOfWork } from "./unit-of-work.js";
|
|
17
|
-
|
|
18
|
-
// The unit-of-work runs its write set inside one SAVEPOINT on sqlite (RFC 036
|
|
19
|
-
// D1) — and each repo's own `create` opens a nested SAVEPOINT, so this also
|
|
20
|
-
// covers savepoint nesting. Commit persists the whole set; a throw rolls all of
|
|
21
|
-
// it back, message and thread-message together.
|
|
22
|
-
|
|
23
|
-
const SCHEMA = {
|
|
24
|
-
...messageDataSchema,
|
|
25
|
-
threadMessage: threadMessageTable,
|
|
26
|
-
address: addressTable,
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
describe("DrizzleUnitOfWork (sqlite)", () => {
|
|
30
|
-
let db: SqliteTestDb<MessageDataSchema>;
|
|
31
|
-
let close: () => Promise<void>;
|
|
32
|
-
let uow: DrizzleUnitOfWork;
|
|
33
|
-
|
|
34
|
-
before(async () => {
|
|
35
|
-
({ db, close } = await createSqliteTestDb<MessageDataSchema>(SCHEMA));
|
|
36
|
-
uow = new DrizzleUnitOfWork(db);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
after(async () => {
|
|
40
|
-
await close();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const messageInput = (id: string) => ({
|
|
44
|
-
messageId: id,
|
|
45
|
-
mailboxId: "00000000-0000-0000-3333-000000000002",
|
|
46
|
-
uid: 7,
|
|
47
|
-
sequenceNumber: 1,
|
|
48
|
-
rfc822Size: 512,
|
|
49
|
-
internalDate: 1700000000000,
|
|
50
|
-
envelopeId: deriveEnvelopeId(id),
|
|
51
|
-
rootBodyPartId: deriveRootBodyPartId(id),
|
|
52
|
-
status: "active" as const,
|
|
53
|
-
syncStatus: "synced" as const,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const threadInput = (id: string) => ({
|
|
57
|
-
accountConfigId: "acct-1",
|
|
58
|
-
threadId: `thread-${id}`,
|
|
59
|
-
messageId: id,
|
|
60
|
-
mailboxId: "mbx-1",
|
|
61
|
-
uid: 7,
|
|
62
|
-
referenceOrder: 0,
|
|
63
|
-
internalDate: 1700000000000,
|
|
64
|
-
sentDate: 1700000000000,
|
|
65
|
-
isRead: false,
|
|
66
|
-
isDeleted: false,
|
|
67
|
-
hasAttachment: false,
|
|
68
|
-
hasStars: false,
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("commits message + thread-message together", async () => {
|
|
72
|
-
const id = "00000000-0000-0000-3333-000000000010";
|
|
73
|
-
await uow.transaction(async (repos) => {
|
|
74
|
-
await repos.message.create(messageInput(id));
|
|
75
|
-
await repos.threadMessage.create(threadInput(id));
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const msg = await db
|
|
79
|
-
.select()
|
|
80
|
-
.from(messageTable)
|
|
81
|
-
.where(eq(messageTable.messageId, id));
|
|
82
|
-
const thread = await db
|
|
83
|
-
.select()
|
|
84
|
-
.from(threadMessageTable)
|
|
85
|
-
.where(eq(threadMessageTable.messageId, id));
|
|
86
|
-
assert.equal(msg.length, 1);
|
|
87
|
-
assert.equal(thread.length, 1);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("a throw rolls the whole set back", async () => {
|
|
91
|
-
const id = "00000000-0000-0000-3333-000000000011";
|
|
92
|
-
await assert.rejects(() =>
|
|
93
|
-
uow.transaction(async (repos) => {
|
|
94
|
-
await repos.message.create(messageInput(id));
|
|
95
|
-
await repos.threadMessage.create(threadInput(id));
|
|
96
|
-
throw new Error("boom");
|
|
97
|
-
}),
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
const msg = await db
|
|
101
|
-
.select()
|
|
102
|
-
.from(messageTable)
|
|
103
|
-
.where(eq(messageTable.messageId, id));
|
|
104
|
-
const thread = await db
|
|
105
|
-
.select()
|
|
106
|
-
.from(threadMessageTable)
|
|
107
|
-
.where(eq(threadMessageTable.messageId, id));
|
|
108
|
-
assert.equal(msg.length, 0, "message insert must roll back");
|
|
109
|
-
assert.equal(thread.length, 0, "thread-message insert must roll back");
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("concurrent transactions serialize without corrupting savepoints", async () => {
|
|
113
|
-
const ids = Array.from(
|
|
114
|
-
{ length: 12 },
|
|
115
|
-
(_, i) =>
|
|
116
|
-
`00000000-0000-0000-3333-0000000001${i.toString().padStart(2, "0")}`,
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
// The failure this guards: shared-connection savepoint interleaving when
|
|
120
|
-
// callers run under concurrency (message-sync's pMap). All must commit.
|
|
121
|
-
await Promise.all(
|
|
122
|
-
ids.map((id) =>
|
|
123
|
-
uow.transaction(async (repos) => {
|
|
124
|
-
await repos.message.create(messageInput(id));
|
|
125
|
-
await repos.threadMessage.create(threadInput(id));
|
|
126
|
-
}),
|
|
127
|
-
),
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
for (const id of ids) {
|
|
131
|
-
const msg = await db
|
|
132
|
-
.select()
|
|
133
|
-
.from(messageTable)
|
|
134
|
-
.where(eq(messageTable.messageId, id));
|
|
135
|
-
assert.equal(msg.length, 1, `message ${id} must be committed`);
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
});
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { after, before, describe, test } from "node:test";
|
|
3
|
-
import { eq } from "drizzle-orm";
|
|
4
|
-
import {
|
|
5
|
-
envelopeId as deriveEnvelopeId,
|
|
6
|
-
rootBodyPartId as deriveRootBodyPartId,
|
|
7
|
-
} from "../id.js";
|
|
8
|
-
import {
|
|
9
|
-
envelopeTable,
|
|
10
|
-
messageTable,
|
|
11
|
-
outboxTable,
|
|
12
|
-
} from "../schema/message-data.js";
|
|
13
|
-
import { createTestDb, type TestDb } from "./test-helpers.js";
|
|
14
|
-
import { DrizzleUnitOfWork } from "./unit-of-work.js";
|
|
15
|
-
|
|
16
|
-
describe("DrizzleUnitOfWork", () => {
|
|
17
|
-
let db: TestDb;
|
|
18
|
-
let stop: () => Promise<void>;
|
|
19
|
-
let unitOfWork: DrizzleUnitOfWork;
|
|
20
|
-
|
|
21
|
-
const MESSAGE_ID = "00000000-0000-0000-2222-000000000001";
|
|
22
|
-
const MAILBOX_ID = "00000000-0000-0000-2222-000000000002";
|
|
23
|
-
const NOW = 1700000000000;
|
|
24
|
-
|
|
25
|
-
const writeEnvelopeMessageAndBody = async (
|
|
26
|
-
repos: Parameters<Parameters<DrizzleUnitOfWork["transaction"]>[0]>[0],
|
|
27
|
-
) => {
|
|
28
|
-
await repos.envelope.upsertEnvelope({
|
|
29
|
-
envelopeId: deriveEnvelopeId(MESSAGE_ID),
|
|
30
|
-
messageId: MESSAGE_ID,
|
|
31
|
-
dateValue: NOW,
|
|
32
|
-
dateRaw: "Tue, 14 Nov 2023 22:13:20 +0000",
|
|
33
|
-
subject: "hello",
|
|
34
|
-
messageIdValue: "<hello@example.com>",
|
|
35
|
-
});
|
|
36
|
-
await repos.message.upsertWithStatus({
|
|
37
|
-
messageId: MESSAGE_ID,
|
|
38
|
-
mailboxId: MAILBOX_ID,
|
|
39
|
-
uid: 7,
|
|
40
|
-
sequenceNumber: 1,
|
|
41
|
-
rfc822Size: 1024,
|
|
42
|
-
internalDate: NOW,
|
|
43
|
-
envelopeId: deriveEnvelopeId(MESSAGE_ID),
|
|
44
|
-
rootBodyPartId: deriveRootBodyPartId(MESSAGE_ID),
|
|
45
|
-
});
|
|
46
|
-
// Body-sync appends the transactional-outbox row; the create emits none.
|
|
47
|
-
await repos.message.update(MESSAGE_ID, {
|
|
48
|
-
bodyStorageKey: "body/hello.json",
|
|
49
|
-
});
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const rows = async () => {
|
|
53
|
-
const envelopes = await db
|
|
54
|
-
.select()
|
|
55
|
-
.from(envelopeTable)
|
|
56
|
-
.where(eq(envelopeTable.messageId, MESSAGE_ID));
|
|
57
|
-
const messages = await db
|
|
58
|
-
.select()
|
|
59
|
-
.from(messageTable)
|
|
60
|
-
.where(eq(messageTable.messageId, MESSAGE_ID));
|
|
61
|
-
const outbox = await db
|
|
62
|
-
.select()
|
|
63
|
-
.from(outboxTable)
|
|
64
|
-
.where(eq(outboxTable.messageId, MESSAGE_ID));
|
|
65
|
-
return { envelopes, messages, outbox };
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
before(async () => {
|
|
69
|
-
({ db, stop } = await createTestDb());
|
|
70
|
-
unitOfWork = new DrizzleUnitOfWork(db);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
after(async () => {
|
|
74
|
-
await stop();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("a mid-save throw rolls back the whole write set, outbox included", async () => {
|
|
78
|
-
await assert.rejects(
|
|
79
|
-
() =>
|
|
80
|
-
unitOfWork.transaction(async (repos) => {
|
|
81
|
-
await writeEnvelopeMessageAndBody(repos);
|
|
82
|
-
// A later write in the set fails after the envelope, message and its
|
|
83
|
-
// transactional-outbox row have already been written.
|
|
84
|
-
throw new Error("thread write failed");
|
|
85
|
-
}),
|
|
86
|
-
/thread write failed/,
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
const { envelopes, messages, outbox } = await rows();
|
|
90
|
-
assert.equal(envelopes.length, 0, "envelope must be rolled back");
|
|
91
|
-
assert.equal(messages.length, 0, "message must be rolled back");
|
|
92
|
-
assert.equal(
|
|
93
|
-
outbox.length,
|
|
94
|
-
0,
|
|
95
|
-
"outbox row must be rolled back with the message",
|
|
96
|
-
);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("a successful transaction commits the data rows and the outbox row", async () => {
|
|
100
|
-
await unitOfWork.transaction(writeEnvelopeMessageAndBody);
|
|
101
|
-
|
|
102
|
-
const { envelopes, messages, outbox } = await rows();
|
|
103
|
-
assert.equal(envelopes.length, 1);
|
|
104
|
-
assert.equal(messages.length, 1);
|
|
105
|
-
assert.equal(outbox.length, 1);
|
|
106
|
-
assert.equal(outbox[0].event, "message.body_synced");
|
|
107
|
-
assert.deepStrictEqual(outbox[0].payload, { messageId: MESSAGE_ID });
|
|
108
|
-
});
|
|
109
|
-
});
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { IUnitOfWork, UnitOfWorkRepositories } from "@remit/data-ports";
|
|
2
|
-
import type { Db } from "../db.js";
|
|
3
|
-
import type { MessageDataSchema } from "../schema/message-data.js";
|
|
4
|
-
import { runInTransaction } from "../tx.js";
|
|
5
|
-
import { DrizzleEnvelopeRepository } from "./envelope.js";
|
|
6
|
-
import { AddressRepo } from "./i4-address.js";
|
|
7
|
-
import { DrizzleMessageRepository } from "./message.js";
|
|
8
|
-
import { DrizzleThreadMessageRepository } from "./thread-message.js";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Runs a write set inside a single transaction. The repositories handed
|
|
12
|
-
* to the callback are bound to that transaction, so the data rows and the
|
|
13
|
-
* transactional-outbox rows the message write appends commit atomically — a
|
|
14
|
-
* throw anywhere rolls the whole set back, outbox included.
|
|
15
|
-
*/
|
|
16
|
-
export class DrizzleUnitOfWork implements IUnitOfWork {
|
|
17
|
-
constructor(private db: Db<MessageDataSchema>) {}
|
|
18
|
-
|
|
19
|
-
transaction<T>(
|
|
20
|
-
fn: (repos: UnitOfWorkRepositories) => Promise<T>,
|
|
21
|
-
): Promise<T> {
|
|
22
|
-
return runInTransaction(this.db, (tx) =>
|
|
23
|
-
fn({
|
|
24
|
-
message: new DrizzleMessageRepository(tx),
|
|
25
|
-
envelope: new DrizzleEnvelopeRepository(tx),
|
|
26
|
-
address: new AddressRepo(tx),
|
|
27
|
-
threadMessage: new DrizzleThreadMessageRepository(tx),
|
|
28
|
-
}),
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
}
|
package/src/schema/outbox.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { sql } from "drizzle-orm";
|
|
2
|
-
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Transactional outbox. It has no TypeSpec entity — it is infrastructure for
|
|
6
|
-
* the search-index worker (append a row per body change / move, drain by id,
|
|
7
|
-
* mark `processed_at`). The partial index selects unprocessed rows for the
|
|
8
|
-
* short-cadence poll (RFC 036 D2).
|
|
9
|
-
*
|
|
10
|
-
* Exported raw for committed-migration generation (schema-full-sqlite) as well
|
|
11
|
-
* as for the repos and the drain logic.
|
|
12
|
-
*/
|
|
13
|
-
/**
|
|
14
|
-
* The event vocabulary a producer may append. Every kind here is drained by the
|
|
15
|
-
* search-index relay (`DRAIN_EVENTS`); a kind nothing drains accumulates
|
|
16
|
-
* undrained rows forever, which is what `message.created` did (reader#1063).
|
|
17
|
-
* The column carries the union so a new producer cannot invent a kind without
|
|
18
|
-
* adding it here, where the drain-coverage test picks it up.
|
|
19
|
-
*/
|
|
20
|
-
export const OUTBOX_EVENTS = [
|
|
21
|
-
"message.body_synced",
|
|
22
|
-
"message.moved",
|
|
23
|
-
"message.removed",
|
|
24
|
-
] as const;
|
|
25
|
-
|
|
26
|
-
export type OutboxEvent = (typeof OUTBOX_EVENTS)[number];
|
|
27
|
-
|
|
28
|
-
export const outboxTable = sqliteTable(
|
|
29
|
-
"outbox",
|
|
30
|
-
{
|
|
31
|
-
id: text("id").primaryKey(),
|
|
32
|
-
messageId: text("message_id").notNull(),
|
|
33
|
-
event: text("event", { enum: OUTBOX_EVENTS }).notNull(),
|
|
34
|
-
payload: text("payload", { mode: "json" }).notNull(),
|
|
35
|
-
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
36
|
-
.$defaultFn(() => new Date())
|
|
37
|
-
.notNull(),
|
|
38
|
-
processedAt: integer("processed_at", { mode: "number" }),
|
|
39
|
-
},
|
|
40
|
-
(t) => [
|
|
41
|
-
index("outbox_message_id_idx").on(t.messageId),
|
|
42
|
-
index("outbox_unprocessed_idx")
|
|
43
|
-
.on(t.createdAt)
|
|
44
|
-
.where(sql`${t.processedAt} IS NULL`),
|
|
45
|
-
],
|
|
46
|
-
);
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
// Complete SQLite drizzle schema for committed-migration GENERATION only
|
|
2
|
-
// (RFC 036 D5) — consumed by packages/migrate/drizzle.entities.sqlite.config.ts
|
|
3
|
-
// and the drift guard (npm-scripts/check-vps-migrations.mjs), never at runtime.
|
|
4
|
-
//
|
|
5
|
-
// It pulls the generated entity package in wholesale, so a new TypeSpec entity
|
|
6
|
-
// flows into the committed migration with nothing to hand-maintain. The only
|
|
7
|
-
// addition is the `outbox` infra table, which has no entity.
|
|
8
|
-
//
|
|
9
|
-
// schema.ts stays the app/dev surface (single `*Table` alias per table, what
|
|
10
|
-
// the repos and `pushSchema` need); this file exposes canonical names, so it
|
|
11
|
-
// must not be fed to `pushSchema` alongside schema.ts (duplicate index names).
|
|
12
|
-
export * from "@remit/drizzle-sqlite-schema";
|
|
13
|
-
export { outboxTable } from "./schema/outbox.js";
|