@remit/drizzle-service 0.0.87 → 0.0.89
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/account-granted-scopes-migration.sqlite.test.ts +66 -0
- package/src/account-synced-services-migration.sqlite.test.ts +8 -1
- package/src/mailbox-path-dedupe.sqlite.test.ts +436 -0
- package/src/repair/duplicate-mailbox-mail.ts +98 -0
- package/src/repos/i4-account.test.ts +20 -17
- package/src/repos/i4-account.ts +4 -0
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +67 -38
- package/src/repos/i4-mailbox.sqlite.test.ts +84 -21
- package/src/repos/i4-mailbox.test.ts +92 -39
- package/src/repos/i4-mailbox.ts +14 -3
- package/src/repos/test-helpers.ts +8 -0
|
@@ -18,6 +18,7 @@ import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
|
18
18
|
import { runInTransaction } from "../tx.js";
|
|
19
19
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
20
20
|
import { DrizzleMessageRepository, deleteMessageSubtree } from "./message.js";
|
|
21
|
+
import { mailboxCreated } from "./test-helpers.js";
|
|
21
22
|
|
|
22
23
|
/** Every state a row can carry, for a transition that decides against none. */
|
|
23
24
|
const EVERY_STATE = [
|
|
@@ -63,7 +64,9 @@ describe("MailboxRepo", () => {
|
|
|
63
64
|
|
|
64
65
|
test("create and get", async () => {
|
|
65
66
|
const accountId = randomId();
|
|
66
|
-
const mailbox =
|
|
67
|
+
const mailbox = mailboxCreated(
|
|
68
|
+
await repo.create(makeMailboxInput(accountId)),
|
|
69
|
+
);
|
|
67
70
|
assert.ok(mailbox.mailboxId);
|
|
68
71
|
assert.equal(mailbox.fullPath, "INBOX");
|
|
69
72
|
|
|
@@ -76,10 +79,12 @@ describe("MailboxRepo", () => {
|
|
|
76
79
|
test("highestModseq round-trips a value above 2^53 without loss (reader#9)", async () => {
|
|
77
80
|
const accountId = randomId();
|
|
78
81
|
const modseq = "18446744073709551615";
|
|
79
|
-
const created =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
const created = mailboxCreated(
|
|
83
|
+
await repo.create({
|
|
84
|
+
...makeMailboxInput(accountId),
|
|
85
|
+
highestModseq: modseq,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
83
88
|
assert.equal(created.highestModseq, modseq);
|
|
84
89
|
|
|
85
90
|
const fetched = await repo.get(accountId, created.mailboxId);
|
|
@@ -98,8 +103,12 @@ describe("MailboxRepo", () => {
|
|
|
98
103
|
|
|
99
104
|
test("batchGet: WHERE id = ANY($1)", async () => {
|
|
100
105
|
const accountId = randomId();
|
|
101
|
-
const m1 =
|
|
102
|
-
|
|
106
|
+
const m1 = mailboxCreated(
|
|
107
|
+
await repo.create(makeMailboxInput(accountId, "INBOX")),
|
|
108
|
+
);
|
|
109
|
+
const m2 = mailboxCreated(
|
|
110
|
+
await repo.create(makeMailboxInput(accountId, "Sent")),
|
|
111
|
+
);
|
|
103
112
|
|
|
104
113
|
const results = await repo.get(accountId, [m1.mailboxId, m2.mailboxId]);
|
|
105
114
|
assert.equal(results.length, 2);
|
|
@@ -114,7 +123,9 @@ describe("MailboxRepo", () => {
|
|
|
114
123
|
|
|
115
124
|
test("findByPath finds existing mailbox", async () => {
|
|
116
125
|
const accountId = randomId();
|
|
117
|
-
|
|
126
|
+
mailboxCreated(
|
|
127
|
+
await repo.create(makeMailboxInput(accountId, "Work/Projects")),
|
|
128
|
+
);
|
|
118
129
|
|
|
119
130
|
const found = await repo.findByPath(accountId, "Work/Projects");
|
|
120
131
|
assert.ok(found);
|
|
@@ -145,9 +156,13 @@ describe("MailboxRepo", () => {
|
|
|
145
156
|
|
|
146
157
|
test("findByPathPrefix finds children", async () => {
|
|
147
158
|
const accountId = randomId();
|
|
148
|
-
await repo.create(makeMailboxInput(accountId, "Work"));
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
mailboxCreated(await repo.create(makeMailboxInput(accountId, "Work")));
|
|
160
|
+
mailboxCreated(
|
|
161
|
+
await repo.create(makeMailboxInput(accountId, "Work/Projects")),
|
|
162
|
+
);
|
|
163
|
+
mailboxCreated(
|
|
164
|
+
await repo.create(makeMailboxInput(accountId, "Work/Projects/Alpha")),
|
|
165
|
+
);
|
|
151
166
|
|
|
152
167
|
const children = await repo.findByPathPrefix(accountId, "Work");
|
|
153
168
|
assert.ok(children.some((m) => m.fullPath === "Work/Projects"));
|
|
@@ -161,8 +176,8 @@ describe("MailboxRepo", () => {
|
|
|
161
176
|
|
|
162
177
|
test("findByPathPrefix finds nothing in a flat namespace", async () => {
|
|
163
178
|
const accountId = randomId();
|
|
164
|
-
await repo.create(makeMailboxInput(accountId, "Work"));
|
|
165
|
-
await repo.create(makeMailboxInput(accountId, "Workshop"));
|
|
179
|
+
mailboxCreated(await repo.create(makeMailboxInput(accountId, "Work")));
|
|
180
|
+
mailboxCreated(await repo.create(makeMailboxInput(accountId, "Workshop")));
|
|
166
181
|
|
|
167
182
|
assert.deepEqual(await repo.findByPathPrefix(accountId, "Work", ""), []);
|
|
168
183
|
});
|
|
@@ -170,7 +185,9 @@ describe("MailboxRepo", () => {
|
|
|
170
185
|
test("cross-tenant: get refuses a foreign account", async () => {
|
|
171
186
|
const accountId = randomId();
|
|
172
187
|
const other = randomId();
|
|
173
|
-
const mailbox =
|
|
188
|
+
const mailbox = mailboxCreated(
|
|
189
|
+
await repo.create(makeMailboxInput(accountId)),
|
|
190
|
+
);
|
|
174
191
|
|
|
175
192
|
await assert.rejects(
|
|
176
193
|
() => repo.get(other, mailbox.mailboxId),
|
|
@@ -186,7 +203,9 @@ describe("MailboxRepo", () => {
|
|
|
186
203
|
test("cross-tenant: update refuses a foreign account and leaves the row unchanged", async () => {
|
|
187
204
|
const accountId = randomId();
|
|
188
205
|
const other = randomId();
|
|
189
|
-
const mailbox =
|
|
206
|
+
const mailbox = mailboxCreated(
|
|
207
|
+
await repo.create(makeMailboxInput(accountId)),
|
|
208
|
+
);
|
|
190
209
|
|
|
191
210
|
await assert.rejects(
|
|
192
211
|
() => repo.update(other, mailbox.mailboxId, { messageCount: 99 }),
|
|
@@ -201,7 +220,9 @@ describe("MailboxRepo", () => {
|
|
|
201
220
|
test("cross-tenant: delete is a no-op for a foreign account", async () => {
|
|
202
221
|
const accountId = randomId();
|
|
203
222
|
const other = randomId();
|
|
204
|
-
const mailbox =
|
|
223
|
+
const mailbox = mailboxCreated(
|
|
224
|
+
await repo.create(makeMailboxInput(accountId)),
|
|
225
|
+
);
|
|
205
226
|
|
|
206
227
|
await repo.delete(other, mailbox.mailboxId);
|
|
207
228
|
const still = await repo.get(accountId, mailbox.mailboxId);
|
|
@@ -213,8 +234,8 @@ describe("MailboxRepo", () => {
|
|
|
213
234
|
test("cross-tenant: deleteMany only removes ids owned by the tenant", async () => {
|
|
214
235
|
const accountA = randomId();
|
|
215
236
|
const accountB = randomId();
|
|
216
|
-
const a = await repo.create(makeMailboxInput(accountA));
|
|
217
|
-
const b = await repo.create(makeMailboxInput(accountB));
|
|
237
|
+
const a = mailboxCreated(await repo.create(makeMailboxInput(accountA)));
|
|
238
|
+
const b = mailboxCreated(await repo.create(makeMailboxInput(accountB)));
|
|
218
239
|
|
|
219
240
|
await repo.deleteMany(accountA, [a.mailboxId, b.mailboxId]);
|
|
220
241
|
|
|
@@ -231,7 +252,9 @@ describe("MailboxRepo", () => {
|
|
|
231
252
|
describe("transition — the conditional write (D3)", () => {
|
|
232
253
|
test("an accepted from-state applies the new state and the set fields", async () => {
|
|
233
254
|
const accountId = randomId();
|
|
234
|
-
const mailbox =
|
|
255
|
+
const mailbox = mailboxCreated(
|
|
256
|
+
await repo.create(makeMailboxInput(accountId, "Archive")),
|
|
257
|
+
);
|
|
235
258
|
|
|
236
259
|
const written = await repo.transition(accountId, mailbox.mailboxId, {
|
|
237
260
|
from: [MailboxSyncStatus.synced, MailboxSyncStatus.failed],
|
|
@@ -246,7 +269,9 @@ describe("MailboxRepo", () => {
|
|
|
246
269
|
|
|
247
270
|
test("a state outside `from` writes nothing and leaves the row byte-identical", async () => {
|
|
248
271
|
const accountId = randomId();
|
|
249
|
-
const mailbox =
|
|
272
|
+
const mailbox = mailboxCreated(
|
|
273
|
+
await repo.create(makeMailboxInput(accountId, "Work")),
|
|
274
|
+
);
|
|
250
275
|
const before = await repo.get(accountId, mailbox.mailboxId);
|
|
251
276
|
|
|
252
277
|
const lost = await repo.transition(accountId, mailbox.mailboxId, {
|
|
@@ -271,7 +296,9 @@ describe("MailboxRepo", () => {
|
|
|
271
296
|
|
|
272
297
|
test("wherePendingPath as a string matches only that target", async () => {
|
|
273
298
|
const accountId = randomId();
|
|
274
|
-
const mailbox =
|
|
299
|
+
const mailbox = mailboxCreated(
|
|
300
|
+
await repo.create(makeMailboxInput(accountId, "Bills")),
|
|
301
|
+
);
|
|
275
302
|
await repo.transition(accountId, mailbox.mailboxId, {
|
|
276
303
|
from: [MailboxSyncStatus.synced],
|
|
277
304
|
to: MailboxSyncStatus.pending,
|
|
@@ -302,7 +329,9 @@ describe("MailboxRepo", () => {
|
|
|
302
329
|
// `synced` with the rename target still on it, and the rename then
|
|
303
330
|
// never runs and is never marked failed.
|
|
304
331
|
const accountId = randomId();
|
|
305
|
-
const claimed =
|
|
332
|
+
const claimed = mailboxCreated(
|
|
333
|
+
await repo.create(makeMailboxInput(accountId, "Notes")),
|
|
334
|
+
);
|
|
306
335
|
await repo.transition(accountId, claimed.mailboxId, {
|
|
307
336
|
from: [MailboxSyncStatus.synced],
|
|
308
337
|
to: MailboxSyncStatus.pending,
|
|
@@ -320,10 +349,12 @@ describe("MailboxRepo", () => {
|
|
|
320
349
|
assert.equal(still.syncStatus, MailboxSyncStatus.pending);
|
|
321
350
|
assert.equal(still.pendingPath, "Journal");
|
|
322
351
|
|
|
323
|
-
const creating =
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
352
|
+
const creating = mailboxCreated(
|
|
353
|
+
await repo.create({
|
|
354
|
+
...makeMailboxInput(accountId, "Fresh"),
|
|
355
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
327
358
|
const settled = await repo.transition(accountId, creating.mailboxId, {
|
|
328
359
|
from: [MailboxSyncStatus.pending],
|
|
329
360
|
wherePendingPath: null,
|
|
@@ -334,7 +365,9 @@ describe("MailboxRepo", () => {
|
|
|
334
365
|
|
|
335
366
|
test("omitting wherePendingPath predicates on syncStatus alone", async () => {
|
|
336
367
|
const accountId = randomId();
|
|
337
|
-
const mailbox =
|
|
368
|
+
const mailbox = mailboxCreated(
|
|
369
|
+
await repo.create(makeMailboxInput(accountId, "Old")),
|
|
370
|
+
);
|
|
338
371
|
await repo.transition(accountId, mailbox.mailboxId, {
|
|
339
372
|
from: [MailboxSyncStatus.synced],
|
|
340
373
|
to: MailboxSyncStatus.pending,
|
|
@@ -355,7 +388,9 @@ describe("MailboxRepo", () => {
|
|
|
355
388
|
// rename target on it is what strands a folder whose rename then never
|
|
356
389
|
// runs and is never marked failed.
|
|
357
390
|
const accountId = randomId();
|
|
358
|
-
const mailbox =
|
|
391
|
+
const mailbox = mailboxCreated(
|
|
392
|
+
await repo.create(makeMailboxInput(accountId, "Trips")),
|
|
393
|
+
);
|
|
359
394
|
|
|
360
395
|
for (const to of EVERY_STATE) {
|
|
361
396
|
const written = await repo.transition(accountId, mailbox.mailboxId, {
|
|
@@ -380,7 +415,9 @@ describe("MailboxRepo", () => {
|
|
|
380
415
|
|
|
381
416
|
test("two overlapping transitions with the same from: exactly one wins", async () => {
|
|
382
417
|
const accountId = randomId();
|
|
383
|
-
const mailbox =
|
|
418
|
+
const mailbox = mailboxCreated(
|
|
419
|
+
await repo.create(makeMailboxInput(accountId, "Races")),
|
|
420
|
+
);
|
|
384
421
|
|
|
385
422
|
const outcomes = await Promise.all([
|
|
386
423
|
repo.transition(accountId, mailbox.mailboxId, {
|
|
@@ -398,7 +435,9 @@ describe("MailboxRepo", () => {
|
|
|
398
435
|
|
|
399
436
|
test("cross-tenant: a foreign account transitions nothing", async () => {
|
|
400
437
|
const accountId = randomId();
|
|
401
|
-
const mailbox =
|
|
438
|
+
const mailbox = mailboxCreated(
|
|
439
|
+
await repo.create(makeMailboxInput(accountId, "Private")),
|
|
440
|
+
);
|
|
402
441
|
|
|
403
442
|
assert.equal(
|
|
404
443
|
await repo.transition(randomId(), mailbox.mailboxId, {
|
|
@@ -416,10 +455,14 @@ describe("MailboxRepo", () => {
|
|
|
416
455
|
|
|
417
456
|
describe("transitionSubtree — the intent, all-or-nothing (D6)", () => {
|
|
418
457
|
const seedSubtree = async (accountId: string) => ({
|
|
419
|
-
parent:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
458
|
+
parent: mailboxCreated(
|
|
459
|
+
await repo.create(makeMailboxInput(accountId, "Work")),
|
|
460
|
+
),
|
|
461
|
+
child: mailboxCreated(
|
|
462
|
+
await repo.create(makeMailboxInput(accountId, "Work/Projects")),
|
|
463
|
+
),
|
|
464
|
+
grandchild: mailboxCreated(
|
|
465
|
+
await repo.create(makeMailboxInput(accountId, "Work/Projects/Alpha")),
|
|
423
466
|
),
|
|
424
467
|
});
|
|
425
468
|
|
|
@@ -568,8 +611,12 @@ describe("MailboxRepo", () => {
|
|
|
568
611
|
|
|
569
612
|
test("removes the folder, its mail and its own child rows, and nothing else's", async () => {
|
|
570
613
|
const accountId = randomId();
|
|
571
|
-
const doomed =
|
|
572
|
-
|
|
614
|
+
const doomed = mailboxCreated(
|
|
615
|
+
await repo.create(makeMailboxInput(accountId, "Receipts")),
|
|
616
|
+
);
|
|
617
|
+
const spared = mailboxCreated(
|
|
618
|
+
await repo.create(makeMailboxInput(accountId, "Keep")),
|
|
619
|
+
);
|
|
573
620
|
const doomedMessage = await seedMessage(doomed.mailboxId, 1);
|
|
574
621
|
const sparedMessage = await seedMessage(spared.mailboxId, 1);
|
|
575
622
|
await db.insert(mailboxSpecialUseTable).values({
|
|
@@ -636,7 +683,9 @@ describe("MailboxRepo", () => {
|
|
|
636
683
|
// delete is the outcome the design rules out. This is the test that
|
|
637
684
|
// stops a future refactor doing it.
|
|
638
685
|
const accountId = randomId();
|
|
639
|
-
const mailbox =
|
|
686
|
+
const mailbox = mailboxCreated(
|
|
687
|
+
await repo.create(makeMailboxInput(accountId, "Bound")),
|
|
688
|
+
);
|
|
640
689
|
const filterId = randomId();
|
|
641
690
|
await db.insert(filterTable).values({
|
|
642
691
|
filterId,
|
|
@@ -662,7 +711,9 @@ describe("MailboxRepo", () => {
|
|
|
662
711
|
|
|
663
712
|
test("re-running after a partial removal completes", async () => {
|
|
664
713
|
const accountId = randomId();
|
|
665
|
-
const mailbox =
|
|
714
|
+
const mailbox = mailboxCreated(
|
|
715
|
+
await repo.create(makeMailboxInput(accountId, "Resume")),
|
|
716
|
+
);
|
|
666
717
|
const first = await seedMessage(mailbox.mailboxId, 1);
|
|
667
718
|
await seedMessage(mailbox.mailboxId, 2);
|
|
668
719
|
|
|
@@ -691,7 +742,9 @@ describe("MailboxRepo", () => {
|
|
|
691
742
|
|
|
692
743
|
test("cross-tenant: a foreign account removes neither the row nor its mail", async () => {
|
|
693
744
|
const accountId = randomId();
|
|
694
|
-
const mailbox =
|
|
745
|
+
const mailbox = mailboxCreated(
|
|
746
|
+
await repo.create(makeMailboxInput(accountId, "Mine")),
|
|
747
|
+
);
|
|
695
748
|
await seedMessage(mailbox.mailboxId, 1);
|
|
696
749
|
|
|
697
750
|
await repo.deleteMailboxWithMail(randomId(), mailbox.mailboxId);
|
package/src/repos/i4-mailbox.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type {
|
|
3
3
|
CreateMailboxInput,
|
|
4
|
+
CreateMailboxResult,
|
|
4
5
|
IMailboxRepository,
|
|
5
6
|
MailboxItem,
|
|
6
7
|
MailboxStatePredicate,
|
|
@@ -128,7 +129,7 @@ export function rowToMailbox(
|
|
|
128
129
|
export class MailboxRepo implements IMailboxRepository {
|
|
129
130
|
constructor(private db: DB) {}
|
|
130
131
|
|
|
131
|
-
async create(input: CreateMailboxInput): Promise<
|
|
132
|
+
async create(input: CreateMailboxInput): Promise<CreateMailboxResult> {
|
|
132
133
|
const now = Date.now();
|
|
133
134
|
const [row] = await this.db
|
|
134
135
|
.insert(mailboxTable)
|
|
@@ -160,8 +161,12 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
160
161
|
createdAt: now,
|
|
161
162
|
updatedAt: now,
|
|
162
163
|
})
|
|
164
|
+
.onConflictDoNothing({
|
|
165
|
+
target: [mailboxTable.accountId, mailboxTable.fullPath],
|
|
166
|
+
})
|
|
163
167
|
.returning();
|
|
164
|
-
|
|
168
|
+
if (!row) return { outcome: "PathTaken" };
|
|
169
|
+
return { outcome: "Created", mailbox: rowToMailbox(row) };
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
async get(accountId: string, mailboxId: string): Promise<MailboxItem>;
|
|
@@ -444,7 +449,13 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
444
449
|
): Promise<MailboxItem> {
|
|
445
450
|
const existing = await this.findByPath(accountId, fullPath);
|
|
446
451
|
if (existing) return existing;
|
|
447
|
-
|
|
452
|
+
const created = await this.create({ accountId, fullPath, ...defaults });
|
|
453
|
+
if (created.outcome === "Created") return created.mailbox;
|
|
454
|
+
const winner = await this.findByPath(accountId, fullPath);
|
|
455
|
+
if (!winner) {
|
|
456
|
+
throw new NotFoundError(`Mailbox not found at path: ${fullPath}`);
|
|
457
|
+
}
|
|
458
|
+
return winner;
|
|
448
459
|
}
|
|
449
460
|
|
|
450
461
|
async findByPathPrefix(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CreateMailboxResult, MailboxItem } from "@remit/data-ports";
|
|
1
2
|
import type Database from "better-sqlite3";
|
|
2
3
|
import type { Db } from "../db.js";
|
|
3
4
|
import {
|
|
@@ -20,3 +21,10 @@ export async function createTestDb(): Promise<{
|
|
|
20
21
|
const { db, sqlite, close } = await createSqliteTestDb(messageDataSchema);
|
|
21
22
|
return { db, sqlite, stop: close };
|
|
22
23
|
}
|
|
24
|
+
|
|
25
|
+
export const mailboxCreated = (result: CreateMailboxResult): MailboxItem => {
|
|
26
|
+
if (result.outcome !== "Created") {
|
|
27
|
+
throw new Error("The account already has a folder at that path");
|
|
28
|
+
}
|
|
29
|
+
return result.mailbox;
|
|
30
|
+
};
|