@remit/drizzle-service 0.0.82 → 0.0.84
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/mailbox-sync-status-backfill.sqlite.test.ts +118 -0
- package/src/repair/junk-only-address.sqlite.test.ts +7 -1
- package/src/repos/i4-mailbox.sqlite.test.ts +57 -21
- package/src/repos/i4-mailbox.test.ts +508 -13
- package/src/repos/i4-mailbox.ts +226 -24
- package/src/repos/search-index-shape.test.ts +82 -0
- package/src/repos/search-index-shape.ts +56 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +353 -0
- package/src/repos/thread-message.ts +29 -4
- package/src/repos/thread-search-predicates.ts +19 -1
- package/src/schema/i4-mailbox.ts +2 -0
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import {
|
|
5
|
+
applyMigration,
|
|
6
|
+
migrationJournal,
|
|
7
|
+
} from "./test-shipped-sqlite-schema.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `sync_status` becomes NOT NULL, and on a real install almost every row is
|
|
11
|
+
* NULL: the sweep's insert is the only create path that omitted it, and the
|
|
12
|
+
* sweep is what discovers a folder in the first place.
|
|
13
|
+
*
|
|
14
|
+
* drizzle-kit does not emit data migrations, and SQLite cannot alter
|
|
15
|
+
* nullability, so the schema change is a copy-and-swap whose `INSERT … SELECT`
|
|
16
|
+
* would violate the new constraint on every one of those rows. Because drizzle
|
|
17
|
+
* runs a folder's whole pending set inside one `BEGIN`/`COMMIT`, that failure
|
|
18
|
+
* rolls the entities set back and the migrate one-shot that gates all six
|
|
19
|
+
* services never completes.
|
|
20
|
+
*
|
|
21
|
+
* So the pair is proven here in the order the migrator runs it, against the
|
|
22
|
+
* data it will actually meet.
|
|
23
|
+
*/
|
|
24
|
+
const BACKFILL = "0026_mailbox_sync_status_backfill";
|
|
25
|
+
const SCHEMA_CHANGE = "0027_mailbox_sync_status_total";
|
|
26
|
+
|
|
27
|
+
const atPredecessor = (): Database.Database => {
|
|
28
|
+
const sqlite = new Database(":memory:");
|
|
29
|
+
for (const entry of migrationJournal()) {
|
|
30
|
+
if (entry.tag === BACKFILL) return sqlite;
|
|
31
|
+
applyMigration(sqlite, entry.tag);
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`${BACKFILL} is not in the journal`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const seed = (
|
|
37
|
+
sqlite: Database.Database,
|
|
38
|
+
rows: Array<{ mailboxId: string; syncStatus: string | null }>,
|
|
39
|
+
): void => {
|
|
40
|
+
const insert = sqlite.prepare(
|
|
41
|
+
`INSERT INTO mailbox (
|
|
42
|
+
mailbox_id, account_id, namespace_type, namespace_prefix,
|
|
43
|
+
hierarchy_delimiter, full_path, uid_validity, uid_next, highest_modseq,
|
|
44
|
+
message_count, unseen_count, deleted_count, total_size, last_sync_uid,
|
|
45
|
+
high_water_mark_uid, last_message_sync_at, parent_mailbox_id,
|
|
46
|
+
sync_status, cursor_state, created_at, updated_at
|
|
47
|
+
) VALUES (?, 'acct', 'personal', '', '/', ?, 1, 1, '0', 0, 0, 0, 0, 0, 0, 0,
|
|
48
|
+
'None', ?, 'normal', 0, 0)`,
|
|
49
|
+
);
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
insert.run(row.mailboxId, `Folder/${row.mailboxId}`, row.syncStatus);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
describe("mailbox sync_status becomes total", () => {
|
|
56
|
+
test("the backfill runs first, so the rebuild sees no NULLs", () => {
|
|
57
|
+
const sqlite = atPredecessor();
|
|
58
|
+
seed(sqlite, [
|
|
59
|
+
{ mailboxId: "swept-1", syncStatus: null },
|
|
60
|
+
{ mailboxId: "swept-2", syncStatus: null },
|
|
61
|
+
{ mailboxId: "created", syncStatus: "pending" },
|
|
62
|
+
{ mailboxId: "failed", syncStatus: "failed" },
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
applyMigration(sqlite, BACKFILL);
|
|
66
|
+
applyMigration(sqlite, SCHEMA_CHANGE);
|
|
67
|
+
|
|
68
|
+
const rows = sqlite
|
|
69
|
+
.prepare(
|
|
70
|
+
"SELECT mailbox_id, sync_status FROM mailbox ORDER BY mailbox_id",
|
|
71
|
+
)
|
|
72
|
+
.all() as Array<{ mailbox_id: string; sync_status: string }>;
|
|
73
|
+
|
|
74
|
+
assert.deepEqual(rows, [
|
|
75
|
+
{ mailbox_id: "created", sync_status: "pending" },
|
|
76
|
+
{ mailbox_id: "failed", sync_status: "failed" },
|
|
77
|
+
{ mailbox_id: "swept-1", sync_status: "synced" },
|
|
78
|
+
{ mailbox_id: "swept-2", sync_status: "synced" },
|
|
79
|
+
]);
|
|
80
|
+
sqlite.close();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the rebuild is refused without the backfill ahead of it", () => {
|
|
84
|
+
// The failure this ordering exists to prevent, made visible: without the
|
|
85
|
+
// backfill the copy-and-swap cannot insert a NULL into the new column.
|
|
86
|
+
const sqlite = atPredecessor();
|
|
87
|
+
seed(sqlite, [{ mailboxId: "swept-1", syncStatus: null }]);
|
|
88
|
+
|
|
89
|
+
assert.throws(
|
|
90
|
+
() => applyMigration(sqlite, SCHEMA_CHANGE),
|
|
91
|
+
/NOT NULL constraint failed/,
|
|
92
|
+
);
|
|
93
|
+
sqlite.close();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the rebuild keeps the account index and the new column", () => {
|
|
97
|
+
const sqlite = atPredecessor();
|
|
98
|
+
applyMigration(sqlite, BACKFILL);
|
|
99
|
+
applyMigration(sqlite, SCHEMA_CHANGE);
|
|
100
|
+
applyMigration(sqlite, "0028_mailbox_pending_path");
|
|
101
|
+
|
|
102
|
+
const columns = (
|
|
103
|
+
sqlite.prepare("PRAGMA table_info(mailbox)").all() as Array<{
|
|
104
|
+
name: string;
|
|
105
|
+
}>
|
|
106
|
+
).map((column) => column.name);
|
|
107
|
+
assert.equal(columns.includes("pending_path"), true);
|
|
108
|
+
assert.equal(columns.includes("old_path"), false);
|
|
109
|
+
|
|
110
|
+
const indexes = (
|
|
111
|
+
sqlite.prepare("PRAGMA index_list(mailbox)").all() as Array<{
|
|
112
|
+
name: string;
|
|
113
|
+
}>
|
|
114
|
+
).map((index) => index.name);
|
|
115
|
+
assert.equal(indexes.includes("mailbox_by_account_id"), true);
|
|
116
|
+
sqlite.close();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -7,7 +7,10 @@ import Database from "better-sqlite3";
|
|
|
7
7
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
8
8
|
import { AccountSettingRepo } from "../repos/i4-account-setting.js";
|
|
9
9
|
import { MailboxSpecialUseRepo } from "../repos/i4-mailbox-special-use.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
applyMigration,
|
|
12
|
+
shippedTableDdl,
|
|
13
|
+
} from "../test-shipped-sqlite-schema.js";
|
|
11
14
|
import {
|
|
12
15
|
type JunkOnlyRepairClient,
|
|
13
16
|
type JunkOnlyRepairMode,
|
|
@@ -197,6 +200,9 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
197
200
|
]) {
|
|
198
201
|
sqlite.exec(shippedTableDdl(DDL_TAG, table));
|
|
199
202
|
}
|
|
203
|
+
// `mailbox` is read through the repo, which selects every column the
|
|
204
|
+
// entity declares — including the rename target added after this DDL.
|
|
205
|
+
applyMigration(sqlite, "0028_mailbox_pending_path");
|
|
200
206
|
sqlite.exec(
|
|
201
207
|
readFileSync(
|
|
202
208
|
new URL(
|
|
@@ -68,31 +68,39 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
68
68
|
assert.equal(reread.highestModseq, "9007199254740993");
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
test("
|
|
72
|
-
// A renamed parent is set pending by the caller; its children's new paths
|
|
73
|
-
// are equally absent from the server until MAILBOX_RENAME lands, so they
|
|
74
|
-
// must be pending too — otherwise a reconcile in that window reaps the
|
|
75
|
-
// child as server-deleted.
|
|
71
|
+
test("a row written without a state reads back synced (D1)", async () => {
|
|
76
72
|
const accountId = randomUUID();
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
});
|
|
73
|
+
const created = await repo.create(
|
|
74
|
+
makeMailboxInput(accountId, "Discovered"),
|
|
75
|
+
);
|
|
76
|
+
assert.equal(created.syncStatus, MailboxSyncStatus.synced);
|
|
77
|
+
const fetched = await repo.get(accountId, created.mailboxId);
|
|
78
|
+
assert.equal(fetched.syncStatus, MailboxSyncStatus.synced);
|
|
79
|
+
});
|
|
85
80
|
|
|
86
|
-
|
|
81
|
+
test("pendingPath round-trips and clears", async () => {
|
|
82
|
+
const accountId = randomUUID();
|
|
83
|
+
const created = await repo.create(makeMailboxInput(accountId, "Archive"));
|
|
84
|
+
assert.equal(created.pendingPath, undefined);
|
|
87
85
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
const claimed = await repo.transition(accountId, created.mailboxId, {
|
|
87
|
+
from: [MailboxSyncStatus.synced],
|
|
88
|
+
to: MailboxSyncStatus.pending,
|
|
89
|
+
set: { pendingPath: "Records" },
|
|
90
|
+
});
|
|
91
|
+
assert.equal(claimed?.pendingPath, "Records");
|
|
92
|
+
assert.equal(
|
|
93
|
+
(await repo.get(accountId, created.mailboxId)).pendingPath,
|
|
94
|
+
"Records",
|
|
95
|
+
);
|
|
91
96
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
const settled = await repo.transition(accountId, created.mailboxId, {
|
|
98
|
+
from: [MailboxSyncStatus.pending],
|
|
99
|
+
to: MailboxSyncStatus.synced,
|
|
100
|
+
set: { fullPath: "Records", pendingPath: null },
|
|
101
|
+
});
|
|
102
|
+
assert.equal(settled?.pendingPath, undefined);
|
|
103
|
+
assert.equal(settled?.fullPath, "Records");
|
|
96
104
|
});
|
|
97
105
|
});
|
|
98
106
|
|
|
@@ -115,6 +123,9 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
115
123
|
const sqlite = new Database(":memory:");
|
|
116
124
|
sqlite.exec(shippedTableDdl("0000_happy_roland_deschain", "mailbox"));
|
|
117
125
|
applyMigration(sqlite, "0002_highest_modseq_text");
|
|
126
|
+
applyMigration(sqlite, "0026_mailbox_sync_status_backfill");
|
|
127
|
+
applyMigration(sqlite, "0027_mailbox_sync_status_total");
|
|
128
|
+
applyMigration(sqlite, "0028_mailbox_pending_path");
|
|
118
129
|
const db = drizzle(sqlite, { schema: { mailbox: mailboxTable } });
|
|
119
130
|
repo = new MailboxRepo(db as never);
|
|
120
131
|
close = async () => {
|
|
@@ -157,6 +168,31 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
157
168
|
assert.strictEqual(fetched.highestModseq, "900:149");
|
|
158
169
|
});
|
|
159
170
|
|
|
171
|
+
test("a row inserted without a state reads back synced", async () => {
|
|
172
|
+
const accountId = randomUUID();
|
|
173
|
+
const created = await repo.create(makeMailboxInput(accountId, "Notes"));
|
|
174
|
+
assert.equal(created.syncStatus, MailboxSyncStatus.synced);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("pendingPath round-trips and clears on the shipped shape", async () => {
|
|
178
|
+
const accountId = randomUUID();
|
|
179
|
+
const created = await repo.create(makeMailboxInput(accountId, "Receipts"));
|
|
180
|
+
|
|
181
|
+
const claimed = await repo.transition(accountId, created.mailboxId, {
|
|
182
|
+
from: [MailboxSyncStatus.synced],
|
|
183
|
+
to: MailboxSyncStatus.pending,
|
|
184
|
+
set: { pendingPath: "Invoices" },
|
|
185
|
+
});
|
|
186
|
+
assert.equal(claimed?.pendingPath, "Invoices");
|
|
187
|
+
|
|
188
|
+
const cleared = await repo.transition(accountId, created.mailboxId, {
|
|
189
|
+
from: [MailboxSyncStatus.pending],
|
|
190
|
+
to: MailboxSyncStatus.synced,
|
|
191
|
+
set: { pendingPath: null },
|
|
192
|
+
});
|
|
193
|
+
assert.equal(cleared?.pendingPath, undefined);
|
|
194
|
+
});
|
|
195
|
+
|
|
160
196
|
test("round-trips a cursor above 2^53 with its exact digits", async () => {
|
|
161
197
|
const accountId = randomUUID();
|
|
162
198
|
const modseq = "18446744073709551615";
|