@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
package/src/repos/i4-account.ts
CHANGED
|
@@ -40,6 +40,7 @@ export function rowToAccount(
|
|
|
40
40
|
email: row.email,
|
|
41
41
|
authType: row.authType as AccountItem["authType"],
|
|
42
42
|
syncedServices: row.syncedServices as AccountItem["syncedServices"],
|
|
43
|
+
grantedScopes: row.grantedScopes as AccountItem["grantedScopes"],
|
|
43
44
|
passwordHash: row.passwordHash ?? undefined,
|
|
44
45
|
oauthRefreshTokenHash: row.oauthRefreshTokenHash ?? undefined,
|
|
45
46
|
oauthTokenUpdatedAt: row.oauthTokenUpdatedAt ?? undefined,
|
|
@@ -83,6 +84,7 @@ export class AccountRepo implements IAccountRepository {
|
|
|
83
84
|
email: input.email,
|
|
84
85
|
authType: input.authType ?? "password",
|
|
85
86
|
syncedServices: input.syncedServices ?? [AccountService.Mail],
|
|
87
|
+
grantedScopes: input.grantedScopes ?? [],
|
|
86
88
|
passwordHash: input.passwordHash,
|
|
87
89
|
oauthRefreshTokenHash: input.oauthRefreshTokenHash,
|
|
88
90
|
oauthTokenUpdatedAt: input.oauthTokenUpdatedAt,
|
|
@@ -148,6 +150,8 @@ export class AccountRepo implements IAccountRepository {
|
|
|
148
150
|
if (input.authType !== undefined) updates.authType = input.authType;
|
|
149
151
|
if (input.syncedServices !== undefined)
|
|
150
152
|
updates.syncedServices = input.syncedServices;
|
|
153
|
+
if (input.grantedScopes !== undefined)
|
|
154
|
+
updates.grantedScopes = input.grantedScopes;
|
|
151
155
|
if (input.passwordHash !== undefined)
|
|
152
156
|
updates.passwordHash = input.passwordHash;
|
|
153
157
|
if (input.oauthRefreshTokenHash !== undefined)
|
|
@@ -16,6 +16,7 @@ import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
|
16
16
|
import { AccountSettingRepo } from "./i4-account-setting.js";
|
|
17
17
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
18
18
|
import { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
|
|
19
|
+
import { mailboxCreated } from "./test-helpers.js";
|
|
19
20
|
|
|
20
21
|
const makeMailboxInput = (
|
|
21
22
|
accountId: string,
|
|
@@ -103,11 +104,15 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
103
104
|
|
|
104
105
|
test("the appointment beats the folder the server flagged", async () => {
|
|
105
106
|
const { accountId, accountConfigId } = await makeAccount();
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
mailboxCreated(
|
|
108
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
109
|
+
);
|
|
110
|
+
const flagged = mailboxCreated(
|
|
111
|
+
await mailboxes.create(makeMailboxInput(accountId, "Junk")),
|
|
112
|
+
);
|
|
108
113
|
await repo.create(flagged.mailboxId, "Junk");
|
|
109
|
-
const chosen =
|
|
110
|
-
makeMailboxInput(accountId, "INBOX/Rubbish"),
|
|
114
|
+
const chosen = mailboxCreated(
|
|
115
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Rubbish")),
|
|
111
116
|
);
|
|
112
117
|
await appoint(
|
|
113
118
|
accountConfigId,
|
|
@@ -126,10 +131,14 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
126
131
|
// the user keeps mail in. `bin` is no longer a hint, and an appointment
|
|
127
132
|
// overrides the proposal either way.
|
|
128
133
|
const { accountId, accountConfigId } = await makeAccount();
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
mailboxCreated(
|
|
135
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
136
|
+
);
|
|
137
|
+
const bin = mailboxCreated(
|
|
138
|
+
await mailboxes.create(makeMailboxInput(accountId, "Bin")),
|
|
139
|
+
);
|
|
140
|
+
const gmailTrash = mailboxCreated(
|
|
141
|
+
await mailboxes.create(makeMailboxInput(accountId, "[Gmail]/Trash")),
|
|
133
142
|
);
|
|
134
143
|
|
|
135
144
|
assert.equal(
|
|
@@ -151,9 +160,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
151
160
|
|
|
152
161
|
test("an appointment on a mailbox that is gone falls back rather than resolving to nothing", async () => {
|
|
153
162
|
const { accountId, accountConfigId } = await makeAccount();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
mailboxCreated(
|
|
164
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
165
|
+
);
|
|
166
|
+
const archive = mailboxCreated(
|
|
167
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Archive")),
|
|
157
168
|
);
|
|
158
169
|
await appoint(
|
|
159
170
|
accountConfigId,
|
|
@@ -171,12 +182,14 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
171
182
|
// `Deleted` matches the name proposal, and emptying it would destroy mail
|
|
172
183
|
// the user never put in a trash folder (audit #841).
|
|
173
184
|
const { accountId, accountConfigId } = await makeAccount();
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
185
|
+
mailboxCreated(
|
|
186
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
187
|
+
);
|
|
188
|
+
const keepsakes = mailboxCreated(
|
|
189
|
+
await mailboxes.create(makeMailboxInput(accountId, "Deleted")),
|
|
177
190
|
);
|
|
178
|
-
const trash =
|
|
179
|
-
makeMailboxInput(accountId, "[Gmail]/Trash"),
|
|
191
|
+
const trash = mailboxCreated(
|
|
192
|
+
await mailboxes.create(makeMailboxInput(accountId, "[Gmail]/Trash")),
|
|
180
193
|
);
|
|
181
194
|
|
|
182
195
|
const proposal = await repo.resolveTrashRole(accountId);
|
|
@@ -201,9 +214,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
201
214
|
// flagged folder both hide that; the resolution names the id the user chose
|
|
202
215
|
// so a refusal can offer the repair (#887).
|
|
203
216
|
const { accountId, accountConfigId } = await makeAccount();
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
217
|
+
mailboxCreated(
|
|
218
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
219
|
+
);
|
|
220
|
+
const flagged = mailboxCreated(
|
|
221
|
+
await mailboxes.create(makeMailboxInput(accountId, "[Gmail]/Trash")),
|
|
207
222
|
);
|
|
208
223
|
await repo.create(flagged.mailboxId, "Trash");
|
|
209
224
|
const appointed = randomUUID();
|
|
@@ -237,9 +252,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
237
252
|
|
|
238
253
|
test("resolves an INBOX-nested Junk folder that advertises \\Junk", async () => {
|
|
239
254
|
const { accountId } = await makeAccount();
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
255
|
+
mailboxCreated(
|
|
256
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
257
|
+
);
|
|
258
|
+
const spam = mailboxCreated(
|
|
259
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Spam")),
|
|
243
260
|
);
|
|
244
261
|
await repo.create(spam.mailboxId, "Junk");
|
|
245
262
|
|
|
@@ -252,9 +269,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
252
269
|
// bare names, so `INBOX/Spam` resolved to nothing on a server that
|
|
253
270
|
// advertises no \Junk.
|
|
254
271
|
const { accountId } = await makeAccount();
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
272
|
+
mailboxCreated(
|
|
273
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
274
|
+
);
|
|
275
|
+
const spam = mailboxCreated(
|
|
276
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Spam")),
|
|
258
277
|
);
|
|
259
278
|
|
|
260
279
|
const found = await repo.findJunkMailbox(accountId);
|
|
@@ -266,9 +285,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
266
285
|
// whole-path rule, so `INBOX/Trash` resolved to nothing and a delete
|
|
267
286
|
// refused on an account that plainly has a Trash folder.
|
|
268
287
|
const { accountId } = await makeAccount();
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
288
|
+
mailboxCreated(
|
|
289
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
290
|
+
);
|
|
291
|
+
const trash = mailboxCreated(
|
|
292
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Trash")),
|
|
272
293
|
);
|
|
273
294
|
|
|
274
295
|
assert.equal(
|
|
@@ -286,9 +307,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
286
307
|
|
|
287
308
|
test("resolves an INBOX-nested Archive folder that advertises no special use", async () => {
|
|
288
309
|
const { accountId } = await makeAccount();
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
310
|
+
mailboxCreated(
|
|
311
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
312
|
+
);
|
|
313
|
+
const archive = mailboxCreated(
|
|
314
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Archive")),
|
|
292
315
|
);
|
|
293
316
|
|
|
294
317
|
assert.equal(
|
|
@@ -299,12 +322,14 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
299
322
|
|
|
300
323
|
test("splits the leaf on the account's own delimiter, not on a slash", async () => {
|
|
301
324
|
const { accountId } = await makeAccount();
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
makeMailboxInput(accountId, "INBOX.Trash", "."),
|
|
325
|
+
mailboxCreated(
|
|
326
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX", ".")),
|
|
305
327
|
);
|
|
306
|
-
const
|
|
307
|
-
makeMailboxInput(accountId, "INBOX.
|
|
328
|
+
const trash = mailboxCreated(
|
|
329
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX.Trash", ".")),
|
|
330
|
+
);
|
|
331
|
+
const archive = mailboxCreated(
|
|
332
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX.Archive", ".")),
|
|
308
333
|
);
|
|
309
334
|
|
|
310
335
|
assert.equal(
|
|
@@ -319,8 +344,12 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
319
344
|
|
|
320
345
|
test("answers null when the account has no Junk folder at all", async () => {
|
|
321
346
|
const { accountId } = await makeAccount();
|
|
322
|
-
|
|
323
|
-
|
|
347
|
+
mailboxCreated(
|
|
348
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
349
|
+
);
|
|
350
|
+
mailboxCreated(
|
|
351
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Work")),
|
|
352
|
+
);
|
|
324
353
|
|
|
325
354
|
assert.equal(await repo.findJunkMailbox(accountId), null);
|
|
326
355
|
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
shippedTableDdl,
|
|
12
12
|
} from "../test-shipped-sqlite-schema.js";
|
|
13
13
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
14
|
+
import { mailboxCreated } from "./test-helpers.js";
|
|
14
15
|
|
|
15
16
|
function makeMailboxInput(accountId: string, fullPath = "INBOX") {
|
|
16
17
|
return {
|
|
@@ -50,10 +51,12 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
50
51
|
const accountId = randomUUID();
|
|
51
52
|
const modseq = "18446744073709551615";
|
|
52
53
|
|
|
53
|
-
const created =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
const created = mailboxCreated(
|
|
55
|
+
await repo.create({
|
|
56
|
+
...makeMailboxInput(accountId),
|
|
57
|
+
highestModseq: modseq,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
57
60
|
assert.equal(created.highestModseq, modseq);
|
|
58
61
|
|
|
59
62
|
const fetched = await repo.get(accountId, created.mailboxId);
|
|
@@ -70,8 +73,8 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
70
73
|
|
|
71
74
|
test("a row written without a state reads back synced (D1)", async () => {
|
|
72
75
|
const accountId = randomUUID();
|
|
73
|
-
const created =
|
|
74
|
-
makeMailboxInput(accountId, "Discovered"),
|
|
76
|
+
const created = mailboxCreated(
|
|
77
|
+
await repo.create(makeMailboxInput(accountId, "Discovered")),
|
|
75
78
|
);
|
|
76
79
|
assert.equal(created.syncStatus, MailboxSyncStatus.synced);
|
|
77
80
|
const fetched = await repo.get(accountId, created.mailboxId);
|
|
@@ -80,7 +83,9 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
80
83
|
|
|
81
84
|
test("pendingPath round-trips and clears", async () => {
|
|
82
85
|
const accountId = randomUUID();
|
|
83
|
-
const created =
|
|
86
|
+
const created = mailboxCreated(
|
|
87
|
+
await repo.create(makeMailboxInput(accountId, "Archive")),
|
|
88
|
+
);
|
|
84
89
|
assert.equal(created.pendingPath, undefined);
|
|
85
90
|
|
|
86
91
|
const claimed = await repo.transition(accountId, created.mailboxId, {
|
|
@@ -102,6 +107,53 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
102
107
|
assert.equal(settled?.pendingPath, undefined);
|
|
103
108
|
assert.equal(settled?.fullPath, "Records");
|
|
104
109
|
});
|
|
110
|
+
|
|
111
|
+
test("a second row at a path the account holds is refused as PathTaken", async () => {
|
|
112
|
+
const accountId = randomUUID();
|
|
113
|
+
const first = mailboxCreated(
|
|
114
|
+
await repo.create(makeMailboxInput(accountId, "Notifications")),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const second = await repo.create(
|
|
118
|
+
makeMailboxInput(accountId, "Notifications"),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
assert.deepEqual(second, { outcome: "PathTaken" });
|
|
122
|
+
assert.equal(
|
|
123
|
+
(await repo.findByPath(accountId, "Notifications"))?.mailboxId,
|
|
124
|
+
first.mailboxId,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("another account may hold the same path", async () => {
|
|
129
|
+
const created = await repo.create(
|
|
130
|
+
makeMailboxInput(randomUUID(), "Notifications"),
|
|
131
|
+
);
|
|
132
|
+
assert.equal(created.outcome, "Created");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a write that moves a row onto a path another row holds is rejected", async () => {
|
|
136
|
+
const accountId = randomUUID();
|
|
137
|
+
mailboxCreated(
|
|
138
|
+
await repo.create(makeMailboxInput(accountId, "INBOX/Notifications")),
|
|
139
|
+
);
|
|
140
|
+
const recreated = mailboxCreated(
|
|
141
|
+
await repo.create({
|
|
142
|
+
...makeMailboxInput(accountId, "Notifications"),
|
|
143
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await assert.rejects(
|
|
148
|
+
repo.transition(accountId, recreated.mailboxId, {
|
|
149
|
+
from: [MailboxSyncStatus.pending],
|
|
150
|
+
wherePendingPath: null,
|
|
151
|
+
to: MailboxSyncStatus.synced,
|
|
152
|
+
set: { fullPath: "INBOX/Notifications" },
|
|
153
|
+
}),
|
|
154
|
+
/UNIQUE constraint failed: mailbox\.account_id, mailbox\.full_path/,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
105
157
|
});
|
|
106
158
|
|
|
107
159
|
/**
|
|
@@ -126,6 +178,7 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
126
178
|
applyMigration(sqlite, "0026_mailbox_sync_status_backfill");
|
|
127
179
|
applyMigration(sqlite, "0027_mailbox_sync_status_total");
|
|
128
180
|
applyMigration(sqlite, "0028_mailbox_pending_path");
|
|
181
|
+
applyMigration(sqlite, "0031_mailbox_path_unique");
|
|
129
182
|
const db = drizzle(sqlite, { schema: { mailbox: mailboxTable } });
|
|
130
183
|
repo = new MailboxRepo(db as never);
|
|
131
184
|
close = async () => {
|
|
@@ -146,10 +199,12 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
146
199
|
|
|
147
200
|
test("reads a plain-digit cursor back as a string", async () => {
|
|
148
201
|
const accountId = randomUUID();
|
|
149
|
-
const created =
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
202
|
+
const created = mailboxCreated(
|
|
203
|
+
await repo.create({
|
|
204
|
+
...makeMailboxInput(accountId),
|
|
205
|
+
highestModseq: "900",
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
153
208
|
|
|
154
209
|
assert.strictEqual(created.highestModseq, "900");
|
|
155
210
|
|
|
@@ -159,10 +214,12 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
159
214
|
|
|
160
215
|
test("keeps a resumable cursor intact", async () => {
|
|
161
216
|
const accountId = randomUUID();
|
|
162
|
-
const created =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
217
|
+
const created = mailboxCreated(
|
|
218
|
+
await repo.create({
|
|
219
|
+
...makeMailboxInput(accountId, "Archive"),
|
|
220
|
+
highestModseq: "900:149",
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
166
223
|
|
|
167
224
|
const fetched = await repo.get(accountId, created.mailboxId);
|
|
168
225
|
assert.strictEqual(fetched.highestModseq, "900:149");
|
|
@@ -170,13 +227,17 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
170
227
|
|
|
171
228
|
test("a row inserted without a state reads back synced", async () => {
|
|
172
229
|
const accountId = randomUUID();
|
|
173
|
-
const created =
|
|
230
|
+
const created = mailboxCreated(
|
|
231
|
+
await repo.create(makeMailboxInput(accountId, "Notes")),
|
|
232
|
+
);
|
|
174
233
|
assert.equal(created.syncStatus, MailboxSyncStatus.synced);
|
|
175
234
|
});
|
|
176
235
|
|
|
177
236
|
test("pendingPath round-trips and clears on the shipped shape", async () => {
|
|
178
237
|
const accountId = randomUUID();
|
|
179
|
-
const created =
|
|
238
|
+
const created = mailboxCreated(
|
|
239
|
+
await repo.create(makeMailboxInput(accountId, "Receipts")),
|
|
240
|
+
);
|
|
180
241
|
|
|
181
242
|
const claimed = await repo.transition(accountId, created.mailboxId, {
|
|
182
243
|
from: [MailboxSyncStatus.synced],
|
|
@@ -196,10 +257,12 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
196
257
|
test("round-trips a cursor above 2^53 with its exact digits", async () => {
|
|
197
258
|
const accountId = randomUUID();
|
|
198
259
|
const modseq = "18446744073709551615";
|
|
199
|
-
const created =
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
260
|
+
const created = mailboxCreated(
|
|
261
|
+
await repo.create({
|
|
262
|
+
...makeMailboxInput(accountId, "Sent"),
|
|
263
|
+
highestModseq: modseq,
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
203
266
|
|
|
204
267
|
assert.strictEqual(created.highestModseq, modseq);
|
|
205
268
|
const fetched = await repo.get(accountId, created.mailboxId);
|