@remit/drizzle-service 0.0.7 → 0.0.9
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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { afterEach, beforeEach, describe, test } from "node:test";
|
|
4
|
+
import { MessageSystemFlag } from "@remit/domain-enums";
|
|
5
|
+
import type Database from "better-sqlite3";
|
|
6
|
+
import {
|
|
7
|
+
type MessageDataSchema,
|
|
8
|
+
messageDataSchema,
|
|
9
|
+
} from "../schema/message-data.js";
|
|
10
|
+
import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
|
|
11
|
+
import { DrizzleMessageFlagRepository } from "./message-flag.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The one-time `message_flag.flag_name` rename shipped for issue #64, run
|
|
15
|
+
* against a real database rather than a hand-copied twin: the SQL is read
|
|
16
|
+
* from the committed migration so this test fails if that file drifts.
|
|
17
|
+
*
|
|
18
|
+
* Before the generated-enum fix, `MessageSystemFlag.Seen` held `Seen` and
|
|
19
|
+
* every row landed under the unprefixed spelling. `hasFlag` is an exact
|
|
20
|
+
* string match, so once the corrected code queries `\Seen` those rows go
|
|
21
|
+
* invisible — which is a re-star on unstar, and a silent no-op on
|
|
22
|
+
* mark-as-unread.
|
|
23
|
+
*/
|
|
24
|
+
const MIGRATION_SQL = readFileSync(
|
|
25
|
+
new URL(
|
|
26
|
+
"../../../../deploy/vps/migrations-sqlite/entities/0002_system_flag_wire_format.sql",
|
|
27
|
+
import.meta.url,
|
|
28
|
+
),
|
|
29
|
+
"utf8",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const applyMigration = (sqlite: Database.Database): void => {
|
|
33
|
+
for (const statement of MIGRATION_SQL.split("--> statement-breakpoint")) {
|
|
34
|
+
sqlite.exec(statement);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const MESSAGE_ID = "00000000-0000-0000-6464-000000000001";
|
|
39
|
+
const OTHER_MESSAGE_ID = "00000000-0000-0000-6464-000000000002";
|
|
40
|
+
|
|
41
|
+
describe("message_flag wire-format migration (issue #64, sqlite)", () => {
|
|
42
|
+
let db: SqliteTestDb<MessageDataSchema>;
|
|
43
|
+
let sqlite: Database.Database;
|
|
44
|
+
let close: () => Promise<void>;
|
|
45
|
+
let repo: DrizzleMessageFlagRepository;
|
|
46
|
+
|
|
47
|
+
const insertLegacyRow = (messageId: string, flagName: string): void => {
|
|
48
|
+
sqlite
|
|
49
|
+
.prepare(
|
|
50
|
+
`INSERT INTO message_flag
|
|
51
|
+
(message_flag_id, message_id, flag_name, set_at, created_at, updated_at)
|
|
52
|
+
VALUES (?, ?, ?, 1000, 1000, 1000)`,
|
|
53
|
+
)
|
|
54
|
+
.run(`${messageId}:${flagName}`, messageId, flagName);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const flagNames = (messageId: string): string[] =>
|
|
58
|
+
(
|
|
59
|
+
sqlite
|
|
60
|
+
.prepare(
|
|
61
|
+
"SELECT flag_name FROM message_flag WHERE message_id = ? ORDER BY flag_name",
|
|
62
|
+
)
|
|
63
|
+
.all(messageId) as Array<{ flag_name: string }>
|
|
64
|
+
).map((r) => r.flag_name);
|
|
65
|
+
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
({ db, sqlite, close } = await createSqliteTestDb(messageDataSchema));
|
|
68
|
+
repo = new DrizzleMessageFlagRepository(
|
|
69
|
+
db as unknown as ConstructorParameters<
|
|
70
|
+
typeof DrizzleMessageFlagRepository
|
|
71
|
+
>[0],
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(async () => {
|
|
76
|
+
await close();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a row written under the old spelling is found by the corrected enum", async () => {
|
|
80
|
+
insertLegacyRow(MESSAGE_ID, "Flagged");
|
|
81
|
+
assert.equal(
|
|
82
|
+
await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Flagged),
|
|
83
|
+
false,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
applyMigration(sqlite);
|
|
87
|
+
|
|
88
|
+
assert.equal(
|
|
89
|
+
await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Flagged),
|
|
90
|
+
true,
|
|
91
|
+
);
|
|
92
|
+
assert.deepEqual(flagNames(MESSAGE_ID), ["\\Flagged"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("unstarring a migrated message removes the star instead of re-adding it", async () => {
|
|
96
|
+
insertLegacyRow(MESSAGE_ID, "Flagged");
|
|
97
|
+
applyMigration(sqlite);
|
|
98
|
+
|
|
99
|
+
// The toggleFlagged decision: hasFlag true => operation "remove".
|
|
100
|
+
const hadFlag = await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Flagged);
|
|
101
|
+
assert.equal(hadFlag, true, "pre-migration row must read as starred");
|
|
102
|
+
|
|
103
|
+
await repo.removeFlag(MESSAGE_ID, MessageSystemFlag.Flagged);
|
|
104
|
+
assert.equal(
|
|
105
|
+
await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Flagged),
|
|
106
|
+
false,
|
|
107
|
+
);
|
|
108
|
+
assert.deepEqual(flagNames(MESSAGE_ID), []);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("mark-as-unread on a migrated message clears the read state", async () => {
|
|
112
|
+
insertLegacyRow(MESSAGE_ID, "Seen");
|
|
113
|
+
applyMigration(sqlite);
|
|
114
|
+
|
|
115
|
+
assert.equal(await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Seen), true);
|
|
116
|
+
await repo.removeFlag(MESSAGE_ID, MessageSystemFlag.Seen);
|
|
117
|
+
assert.equal(await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Seen), false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("renames every RFC 9051 system flag", () => {
|
|
121
|
+
for (const name of ["Seen", "Answered", "Flagged", "Deleted", "Draft"]) {
|
|
122
|
+
insertLegacyRow(MESSAGE_ID, name);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
applyMigration(sqlite);
|
|
126
|
+
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
flagNames(MESSAGE_ID).sort(),
|
|
129
|
+
Object.values(MessageSystemFlag).slice().sort(),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("leaves keyword and custom flags untouched", () => {
|
|
134
|
+
insertLegacyRow(MESSAGE_ID, "$Forwarded");
|
|
135
|
+
insertLegacyRow(MESSAGE_ID, "$Junk");
|
|
136
|
+
insertLegacyRow(MESSAGE_ID, "project-invoices");
|
|
137
|
+
|
|
138
|
+
applyMigration(sqlite);
|
|
139
|
+
|
|
140
|
+
assert.deepEqual(flagNames(MESSAGE_ID), [
|
|
141
|
+
"$Forwarded",
|
|
142
|
+
"$Junk",
|
|
143
|
+
"project-invoices",
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("is idempotent — a second and third run change nothing", () => {
|
|
148
|
+
insertLegacyRow(MESSAGE_ID, "Seen");
|
|
149
|
+
insertLegacyRow(OTHER_MESSAGE_ID, "Flagged");
|
|
150
|
+
insertLegacyRow(OTHER_MESSAGE_ID, "$Forwarded");
|
|
151
|
+
|
|
152
|
+
applyMigration(sqlite);
|
|
153
|
+
const afterFirst = [
|
|
154
|
+
...flagNames(MESSAGE_ID),
|
|
155
|
+
...flagNames(OTHER_MESSAGE_ID),
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
applyMigration(sqlite);
|
|
159
|
+
applyMigration(sqlite);
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(
|
|
162
|
+
[...flagNames(MESSAGE_ID), ...flagNames(OTHER_MESSAGE_ID)],
|
|
163
|
+
afterFirst,
|
|
164
|
+
);
|
|
165
|
+
assert.deepEqual(afterFirst, ["\\Seen", "$Forwarded", "\\Flagged"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("collapses a message already carrying both spellings to one row", () => {
|
|
169
|
+
insertLegacyRow(MESSAGE_ID, "Seen");
|
|
170
|
+
insertLegacyRow(MESSAGE_ID, "\\Seen");
|
|
171
|
+
|
|
172
|
+
applyMigration(sqlite);
|
|
173
|
+
|
|
174
|
+
assert.deepEqual(flagNames(MESSAGE_ID), ["\\Seen"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("does not touch other messages' rows", () => {
|
|
178
|
+
insertLegacyRow(MESSAGE_ID, "Seen");
|
|
179
|
+
insertLegacyRow(OTHER_MESSAGE_ID, "Seen");
|
|
180
|
+
|
|
181
|
+
applyMigration(sqlite);
|
|
182
|
+
|
|
183
|
+
assert.deepEqual(flagNames(MESSAGE_ID), ["\\Seen"]);
|
|
184
|
+
assert.deepEqual(flagNames(OTHER_MESSAGE_ID), ["\\Seen"]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -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,
|