@remit/drizzle-service 0.0.8 → 0.0.10
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
CHANGED
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { after, before, describe, test } from "node:test";
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
4
7
|
import { mailboxTable } from "../schema.js";
|
|
5
8
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
6
9
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
7
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The `mailbox` DDL as it actually ships, read from the committed migration
|
|
13
|
+
* rather than pushed from the drizzle table objects.
|
|
14
|
+
*
|
|
15
|
+
* The two disagree: the table object declares `highest_modseq` as text, the
|
|
16
|
+
* shipped migration still declares it `integer` (reader#73). Every other
|
|
17
|
+
* SQLite test in this package runs against the pushed shape, so none of them
|
|
18
|
+
* has ever exercised the one deployments run on — and SQLite hands a column
|
|
19
|
+
* with numeric affinity back as a number regardless of what the schema says.
|
|
20
|
+
* Reading the committed file keeps this test honest as the migration changes.
|
|
21
|
+
*/
|
|
22
|
+
const shippedMailboxDdl = (): string => {
|
|
23
|
+
const sql = readFileSync(
|
|
24
|
+
new URL(
|
|
25
|
+
"../../../../deploy/vps/migrations-sqlite/entities/0000_happy_roland_deschain.sql",
|
|
26
|
+
import.meta.url,
|
|
27
|
+
),
|
|
28
|
+
"utf8",
|
|
29
|
+
);
|
|
30
|
+
const match = sql.match(/CREATE TABLE `mailbox` \([\s\S]*?\n\);/);
|
|
31
|
+
if (!match)
|
|
32
|
+
throw new Error("mailbox DDL not found in the committed migration");
|
|
33
|
+
return match[0];
|
|
34
|
+
};
|
|
35
|
+
|
|
8
36
|
function makeMailboxInput(accountId: string, fullPath = "INBOX") {
|
|
9
37
|
return {
|
|
10
38
|
accountId,
|
|
@@ -61,3 +89,51 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
61
89
|
assert.equal(reread.highestModseq, "9007199254740993");
|
|
62
90
|
});
|
|
63
91
|
});
|
|
92
|
+
|
|
93
|
+
describe("MailboxRepo (sqlite, shipped column shape)", () => {
|
|
94
|
+
let close: () => Promise<void>;
|
|
95
|
+
let repo: MailboxRepo;
|
|
96
|
+
|
|
97
|
+
before(async () => {
|
|
98
|
+
const sqlite = new Database(":memory:");
|
|
99
|
+
sqlite.exec(shippedMailboxDdl());
|
|
100
|
+
const db = drizzle(sqlite, { schema: { mailbox: mailboxTable } });
|
|
101
|
+
repo = new MailboxRepo(db as never);
|
|
102
|
+
close = async () => {
|
|
103
|
+
sqlite.close();
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
after(async () => {
|
|
108
|
+
await close();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("reads the sync cursor back as a string, whatever the column stores", async () => {
|
|
112
|
+
// A plain-digit cursor lands in a column with numeric affinity and comes
|
|
113
|
+
// back a number. Callers compare the value they wrote against the value
|
|
114
|
+
// they read — `"900" === 900` is false — so an unnormalised read makes a
|
|
115
|
+
// stalled cursor undetectable.
|
|
116
|
+
const accountId = randomUUID();
|
|
117
|
+
const created = await repo.create({
|
|
118
|
+
...makeMailboxInput(accountId),
|
|
119
|
+
highestModseq: "900",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
assert.strictEqual(created.highestModseq, "900");
|
|
123
|
+
|
|
124
|
+
const fetched = await repo.get(accountId, created.mailboxId);
|
|
125
|
+
assert.strictEqual(fetched.highestModseq, "900");
|
|
126
|
+
assert.strictEqual(fetched.highestModseq === "900", true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("keeps a resumable cursor intact through the same column", async () => {
|
|
130
|
+
const accountId = randomUUID();
|
|
131
|
+
const created = await repo.create({
|
|
132
|
+
...makeMailboxInput(accountId, "Archive"),
|
|
133
|
+
highestModseq: "900:149",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const fetched = await repo.get(accountId, created.mailboxId);
|
|
137
|
+
assert.strictEqual(fetched.highestModseq, "900:149");
|
|
138
|
+
});
|
|
139
|
+
});
|
package/src/repos/i4-mailbox.ts
CHANGED
|
@@ -33,7 +33,15 @@ export function rowToMailbox(
|
|
|
33
33
|
fullPath: row.fullPath,
|
|
34
34
|
uidValidity: row.uidValidity,
|
|
35
35
|
uidNext: row.uidNext,
|
|
36
|
-
|
|
36
|
+
// SQLite returns a column with numeric affinity as a number whatever the
|
|
37
|
+
// schema declares, and the shipped self-host migration still declares
|
|
38
|
+
// this one `integer` (reader#73). A cursor read back as a number is not
|
|
39
|
+
// merely awkward to parse: `"900" === 900` is false, so code comparing
|
|
40
|
+
// the value it just wrote against the value it read would conclude
|
|
41
|
+
// nothing had changed — which is how a stalled cursor goes unreported.
|
|
42
|
+
// Normalising here means every consumer sees the declared type instead
|
|
43
|
+
// of each one guarding separately.
|
|
44
|
+
highestModseq: String(row.highestModseq),
|
|
37
45
|
messageCount: row.messageCount,
|
|
38
46
|
unseenCount: row.unseenCount,
|
|
39
47
|
deletedCount: row.deletedCount,
|
|
@@ -164,6 +164,113 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
|
|
|
164
164
|
assert.ok(n >= 1);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
test("listByThread returns inbox and sent messages interleaved in order", async () => {
|
|
168
|
+
const acct = "acct-conversation";
|
|
169
|
+
const threadId = "t-conversation";
|
|
170
|
+
const inbox = "mbx-inbox";
|
|
171
|
+
const sent = "mbx-sent";
|
|
172
|
+
const base = Date.now();
|
|
173
|
+
const turns = [
|
|
174
|
+
{ mailboxId: inbox, subject: "Databricks pricing", at: base },
|
|
175
|
+
{ mailboxId: sent, subject: "Re: Databricks pricing", at: base + 1000 },
|
|
176
|
+
{ mailboxId: inbox, subject: "Re: Databricks pricing", at: base + 2000 },
|
|
177
|
+
{ mailboxId: sent, subject: "Re: Databricks pricing", at: base + 3000 },
|
|
178
|
+
];
|
|
179
|
+
for (const [index, turn] of turns.entries()) {
|
|
180
|
+
await repo.create(
|
|
181
|
+
makeInput({
|
|
182
|
+
accountConfigId: acct,
|
|
183
|
+
threadId,
|
|
184
|
+
messageId: `m-conversation-${index}`,
|
|
185
|
+
mailboxId: turn.mailboxId,
|
|
186
|
+
subject: turn.subject,
|
|
187
|
+
referenceOrder: index,
|
|
188
|
+
internalDate: turn.at,
|
|
189
|
+
sentDate: turn.at,
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const ascending = await repo.listByThread(threadId, acct, {
|
|
195
|
+
order: "asc",
|
|
196
|
+
excludeDeleted: true,
|
|
197
|
+
});
|
|
198
|
+
assert.deepEqual(
|
|
199
|
+
ascending.items.map((item) => item.mailboxId),
|
|
200
|
+
[inbox, sent, inbox, sent],
|
|
201
|
+
"the conversation carries both received and sent messages, oldest first",
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const descending = await repo.listByThread(threadId, acct, {
|
|
205
|
+
order: "desc",
|
|
206
|
+
excludeDeleted: true,
|
|
207
|
+
});
|
|
208
|
+
assert.deepEqual(
|
|
209
|
+
descending.items.map((item) => item.mailboxId),
|
|
210
|
+
[sent, inbox, sent, inbox],
|
|
211
|
+
"reversing the order reverses the conversation",
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("listByThread excludes soft-deleted messages but keeps the rest of the conversation", async () => {
|
|
216
|
+
const acct = "acct-conversation-deleted";
|
|
217
|
+
const threadId = "t-conversation-deleted";
|
|
218
|
+
const base = Date.now();
|
|
219
|
+
const kept = await repo.create(
|
|
220
|
+
makeInput({
|
|
221
|
+
accountConfigId: acct,
|
|
222
|
+
threadId,
|
|
223
|
+
messageId: "m-kept",
|
|
224
|
+
mailboxId: "mbx-sent",
|
|
225
|
+
internalDate: base,
|
|
226
|
+
sentDate: base,
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
await repo.create(
|
|
230
|
+
makeInput({
|
|
231
|
+
accountConfigId: acct,
|
|
232
|
+
threadId,
|
|
233
|
+
messageId: "m-trashed",
|
|
234
|
+
mailboxId: "mbx-trash",
|
|
235
|
+
isDeleted: true,
|
|
236
|
+
internalDate: base + 1000,
|
|
237
|
+
sentDate: base + 1000,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const result = await repo.listByThread(threadId, acct, {
|
|
242
|
+
excludeDeleted: true,
|
|
243
|
+
});
|
|
244
|
+
assert.deepEqual(
|
|
245
|
+
result.items.map((item) => item.threadMessageId),
|
|
246
|
+
[kept.threadMessageId],
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("listByThread scopes to the account config", async () => {
|
|
251
|
+
const threadId = "t-shared-id";
|
|
252
|
+
await repo.create(
|
|
253
|
+
makeInput({
|
|
254
|
+
accountConfigId: "acct-mine",
|
|
255
|
+
threadId,
|
|
256
|
+
messageId: "m-mine",
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
await repo.create(
|
|
260
|
+
makeInput({
|
|
261
|
+
accountConfigId: "acct-theirs",
|
|
262
|
+
threadId,
|
|
263
|
+
messageId: "m-theirs",
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const mine = await repo.listByThread(threadId, "acct-mine");
|
|
268
|
+
assert.deepEqual(
|
|
269
|
+
mine.items.map((item) => item.messageId),
|
|
270
|
+
["m-mine"],
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
167
274
|
test("listByDate paginates with a stable keyset cursor", async () => {
|
|
168
275
|
const acct = "acct-page";
|
|
169
276
|
const base = Date.now();
|
|
@@ -558,7 +558,6 @@ export class DrizzleThreadMessageRepository
|
|
|
558
558
|
order?: "asc" | "desc";
|
|
559
559
|
limit?: number;
|
|
560
560
|
continuationToken?: string;
|
|
561
|
-
mailboxId?: string;
|
|
562
561
|
excludeDeleted?: boolean;
|
|
563
562
|
},
|
|
564
563
|
): Promise<ResultList<ThreadMessageItem>> {
|
|
@@ -592,9 +591,6 @@ export class DrizzleThreadMessageRepository
|
|
|
592
591
|
and(
|
|
593
592
|
eq(threadMessageTable.threadId, threadId),
|
|
594
593
|
eq(threadMessageTable.accountConfigId, accountConfigId),
|
|
595
|
-
options?.mailboxId
|
|
596
|
-
? eq(threadMessageTable.mailboxId, options.mailboxId)
|
|
597
|
-
: undefined,
|
|
598
594
|
options?.excludeDeleted
|
|
599
595
|
? eq(threadMessageTable.isDeleted, false)
|
|
600
596
|
: undefined,
|