@remit/drizzle-service 0.0.99 → 0.0.101
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/batch.sqlite.test.ts +78 -0
- package/src/batch.ts +46 -0
- package/src/databases.ts +70 -0
- package/src/db.ts +10 -9
- package/src/generated-enum.test.ts +8 -0
- package/src/generated-id.test.ts +19 -0
- package/src/id.test.ts +0 -8
- package/src/id.ts +0 -6
- package/src/index.ts +16 -6
- package/src/repair/address-display-name.sqlite.test.ts +3 -5
- package/src/repair/address-display-name.ts +8 -3
- package/src/repair/junk-only-address.sqlite.test.ts +8 -36
- package/src/repair/stranded-sent-outbox.sqlite.test.ts +2 -9
- package/src/repos/account.conformance.sqlite.test.ts +1 -1
- package/src/repos/calendar-default.sqlite.test.ts +6 -6
- package/src/repos/calendar-put.sqlite.test.ts +40 -39
- package/src/repos/calendar-store.ts +273 -0
- package/src/repos/cascade-delete.sqlite.test.ts +7 -9
- package/src/repos/cascade-delete.test.ts +6 -13
- package/src/repos/cascade-delete.ts +127 -97
- package/src/repos/envelope.test.ts +1 -17
- package/src/repos/envelope.ts +83 -67
- package/src/repos/filter.test.ts +22 -2
- package/src/repos/filter.ts +1 -5
- package/src/repos/i4-account-config.test.ts +2 -2
- package/src/repos/i4-account-config.ts +5 -2
- package/src/repos/i4-account.test.ts +6 -2
- package/src/repos/i4-account.ts +12 -56
- package/src/repos/i4-address-junk-move.sqlite.test.ts +1 -1
- package/src/repos/i4-address-merge-flags.sqlite.test.ts +1 -2
- package/src/repos/i4-address.ts +58 -90
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +6 -2
- package/src/repos/i4-mailbox-special-use.ts +9 -6
- package/src/repos/i4-mailbox.sqlite.test.ts +12 -14
- package/src/repos/i4-mailbox.test.ts +17 -62
- package/src/repos/i4-mailbox.ts +121 -94
- package/src/repos/i4-organize-job-request.ts +0 -4
- package/src/repos/i4-outbox-attachment.ts +62 -33
- package/src/repos/mappers.ts +0 -16
- package/src/repos/message.sqlite.test.ts +32 -106
- package/src/repos/message.test.ts +6 -167
- package/src/repos/message.ts +216 -228
- package/src/repos/sync-warning.ts +76 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +5 -10
- package/src/repos/thread-message-field-terms.sqlite.test.ts +5 -1
- package/src/repos/thread-message.sqlite.test.ts +2 -1
- package/src/repos/thread-message.test.ts +17 -4
- package/src/repos/thread-message.ts +502 -285
- package/src/repos/thread-search-predicates.ts +24 -26
- package/src/schema/message-data.ts +4 -8
- package/src/schema/sync-warning.ts +3 -0
- package/src/schema-sets/app.ts +17 -0
- package/src/schema-sets/mail.ts +32 -0
- package/src/schema-sets/search.ts +5 -0
- package/src/schema-version-accounting.sqlite.test.ts +56 -43
- package/src/schema.ts +3 -4
- package/src/search/thread-message-search.sqlite.test.ts +131 -0
- package/src/search/thread-message-search.ts +271 -0
- package/src/sqlite-client.ts +5 -7
- package/src/sqlite-repositories.ts +75 -0
- package/src/test-db-sqlite.ts +5 -13
- package/src/test-shipped-sqlite-schema.ts +46 -45
- package/src/test-sqlite.ts +35 -11
- package/src/vps-migrations-drift.sqlite.test.ts +81 -15
- package/src/account-granted-scopes-migration.sqlite.test.ts +0 -66
- package/src/account-synced-services-migration.sqlite.test.ts +0 -56
- package/src/mailbox-path-dedupe.sqlite.test.ts +0 -462
- package/src/mailbox-sync-status-backfill.sqlite.test.ts +0 -118
- package/src/outbox-created-cleanup.sqlite.test.ts +0 -83
- package/src/repair/duplicate-mailbox-mail.ts +0 -98
- package/src/repair/thread-message-category-contract.ts +0 -505
- package/src/repair/thread-message-category.sqlite.test.ts +0 -100
- package/src/repair/thread-message-category.ts +0 -415
- package/src/repos/calendar-migrations.sqlite.test.ts +0 -345
- package/src/repos/calendar-unit-of-work.ts +0 -31
- package/src/repos/config-import-atomicity.sqlite.test.ts +0 -255
- package/src/repos/filter-anchor-transaction.test.ts +0 -136
- package/src/repos/filter-anchor-transaction.ts +0 -45
- package/src/repos/filter-anchor.test.ts +0 -131
- package/src/repos/filter-anchor.ts +0 -105
- package/src/repos/i4-mailbox-special-use.structure.test.ts +0 -27
- package/src/repos/message-placement-backfill.sqlite.test.ts +0 -190
- package/src/repos/search-index-shape.test.ts +0 -82
- package/src/repos/search-index-shape.ts +0 -56
- package/src/repos/serialized-writes.sqlite.test.ts +0 -199
- package/src/repos/unit-of-work.sqlite.test.ts +0 -138
- package/src/repos/unit-of-work.test.ts +0 -109
- package/src/repos/unit-of-work.ts +0 -31
- package/src/schema/outbox.ts +0 -46
- package/src/schema-full-sqlite.ts +0 -13
- package/src/tx.sqlite.test.ts +0 -83
- package/src/tx.ts +0 -233
package/src/repos/filter.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { after, before, describe, test } from "node:test";
|
|
3
3
|
import {
|
|
4
|
+
FilterClauseField,
|
|
4
5
|
FilterDisabledReason,
|
|
5
6
|
FilterScope,
|
|
6
7
|
FilterState,
|
|
@@ -9,6 +10,8 @@ import { NotFoundError } from "../error.js";
|
|
|
9
10
|
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
10
11
|
import { FilterRepo } from "./filter.js";
|
|
11
12
|
|
|
13
|
+
const CLAUSES = [{ field: FilterClauseField.Subject, value: "booking" }];
|
|
14
|
+
|
|
12
15
|
describe("FilterRepo", () => {
|
|
13
16
|
let db: TestDb;
|
|
14
17
|
let close: () => Promise<void>;
|
|
@@ -29,12 +32,12 @@ describe("FilterRepo", () => {
|
|
|
29
32
|
accountConfigId,
|
|
30
33
|
name: "Booking confirmations",
|
|
31
34
|
scope: FilterScope.Standing,
|
|
35
|
+
literalClauses: CLAUSES,
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
assert.equal(filter.state, FilterState.Active);
|
|
35
39
|
assert.equal(filter.disabledReason, FilterDisabledReason.None);
|
|
36
|
-
assert.
|
|
37
|
-
assert.equal(filter.literalClauses.length, 0);
|
|
40
|
+
assert.deepEqual(filter.literalClauses, CLAUSES);
|
|
38
41
|
assert.equal(filter.actionLabelId, "None");
|
|
39
42
|
assert.equal(filter.actionMailboxId, "None");
|
|
40
43
|
assert.ok(
|
|
@@ -55,6 +58,7 @@ describe("FilterRepo", () => {
|
|
|
55
58
|
accountConfigId,
|
|
56
59
|
name: "Invoices",
|
|
57
60
|
scope: FilterScope.Standing,
|
|
61
|
+
literalClauses: CLAUSES,
|
|
58
62
|
state: FilterState.Disabled,
|
|
59
63
|
disabledReason: FilterDisabledReason.AwaitingFolder,
|
|
60
64
|
});
|
|
@@ -88,6 +92,7 @@ describe("FilterRepo", () => {
|
|
|
88
92
|
accountConfigId,
|
|
89
93
|
name: "Until my trip",
|
|
90
94
|
scope: FilterScope.Temporary,
|
|
95
|
+
literalClauses: CLAUSES,
|
|
91
96
|
expiresAt,
|
|
92
97
|
ttl,
|
|
93
98
|
});
|
|
@@ -124,6 +129,7 @@ describe("FilterRepo", () => {
|
|
|
124
129
|
accountConfigId,
|
|
125
130
|
name: "Newsletter admin",
|
|
126
131
|
scope: FilterScope.Standing,
|
|
132
|
+
literalClauses: CLAUSES,
|
|
127
133
|
});
|
|
128
134
|
|
|
129
135
|
const renamed = await repo.update(accountConfigId, filter.filterId, {
|
|
@@ -149,6 +155,7 @@ describe("FilterRepo", () => {
|
|
|
149
155
|
accountConfigId,
|
|
150
156
|
name: "Move to archive",
|
|
151
157
|
scope: FilterScope.Standing,
|
|
158
|
+
literalClauses: CLAUSES,
|
|
152
159
|
});
|
|
153
160
|
|
|
154
161
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
@@ -173,6 +180,7 @@ describe("FilterRepo", () => {
|
|
|
173
180
|
accountConfigId,
|
|
174
181
|
name: "Trip out-of-office",
|
|
175
182
|
scope: FilterScope.Standing,
|
|
183
|
+
literalClauses: CLAUSES,
|
|
176
184
|
});
|
|
177
185
|
|
|
178
186
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
@@ -205,6 +213,7 @@ describe("FilterRepo", () => {
|
|
|
205
213
|
accountConfigId,
|
|
206
214
|
name: "Until my trip",
|
|
207
215
|
scope: FilterScope.Temporary,
|
|
216
|
+
literalClauses: CLAUSES,
|
|
208
217
|
expiresAt: initialExpiresAt,
|
|
209
218
|
ttl: Math.floor(new Date(initialExpiresAt).getTime() / 1000),
|
|
210
219
|
});
|
|
@@ -236,6 +245,7 @@ describe("FilterRepo", () => {
|
|
|
236
245
|
accountConfigId,
|
|
237
246
|
name: "Until my trip",
|
|
238
247
|
scope: FilterScope.Temporary,
|
|
248
|
+
literalClauses: CLAUSES,
|
|
239
249
|
expiresAt,
|
|
240
250
|
ttl: Math.floor(new Date(expiresAt).getTime() / 1000),
|
|
241
251
|
});
|
|
@@ -268,11 +278,13 @@ describe("FilterRepo", () => {
|
|
|
268
278
|
accountConfigId,
|
|
269
279
|
name: "Standing",
|
|
270
280
|
scope: FilterScope.Standing,
|
|
281
|
+
literalClauses: CLAUSES,
|
|
271
282
|
});
|
|
272
283
|
await repo.create({
|
|
273
284
|
accountConfigId: other,
|
|
274
285
|
name: "Foreign",
|
|
275
286
|
scope: FilterScope.Standing,
|
|
287
|
+
literalClauses: CLAUSES,
|
|
276
288
|
});
|
|
277
289
|
|
|
278
290
|
const all = await repo.listByAccountConfig(accountConfigId);
|
|
@@ -299,6 +311,7 @@ describe("FilterRepo", () => {
|
|
|
299
311
|
accountConfigId,
|
|
300
312
|
name: "Standing",
|
|
301
313
|
scope: FilterScope.Standing,
|
|
314
|
+
literalClauses: CLAUSES,
|
|
302
315
|
});
|
|
303
316
|
|
|
304
317
|
const result = await repo.refreshExpiry(filter);
|
|
@@ -312,6 +325,7 @@ describe("FilterRepo", () => {
|
|
|
312
325
|
accountConfigId,
|
|
313
326
|
name: "Until tomorrow",
|
|
314
327
|
scope: FilterScope.Temporary,
|
|
328
|
+
literalClauses: CLAUSES,
|
|
315
329
|
expiresAt: future,
|
|
316
330
|
ttl: Math.floor(new Date(future).getTime() / 1000),
|
|
317
331
|
});
|
|
@@ -327,6 +341,7 @@ describe("FilterRepo", () => {
|
|
|
327
341
|
accountConfigId,
|
|
328
342
|
name: "Until yesterday, awaiting its folder",
|
|
329
343
|
scope: FilterScope.Temporary,
|
|
344
|
+
literalClauses: CLAUSES,
|
|
330
345
|
expiresAt: past,
|
|
331
346
|
ttl: Math.floor(new Date(past).getTime() / 1000) + 172_800,
|
|
332
347
|
state: FilterState.Disabled,
|
|
@@ -345,6 +360,7 @@ describe("FilterRepo", () => {
|
|
|
345
360
|
accountConfigId,
|
|
346
361
|
name: "Until yesterday",
|
|
347
362
|
scope: FilterScope.Temporary,
|
|
363
|
+
literalClauses: CLAUSES,
|
|
348
364
|
expiresAt: past,
|
|
349
365
|
ttl: Math.floor(new Date(past).getTime() / 1000) + 172_800,
|
|
350
366
|
});
|
|
@@ -368,6 +384,7 @@ describe("FilterRepo", () => {
|
|
|
368
384
|
accountConfigId,
|
|
369
385
|
name: "To delete",
|
|
370
386
|
scope: FilterScope.Standing,
|
|
387
|
+
literalClauses: CLAUSES,
|
|
371
388
|
});
|
|
372
389
|
|
|
373
390
|
await repo.delete(accountConfigId, filter.filterId);
|
|
@@ -385,16 +402,19 @@ describe("FilterRepo", () => {
|
|
|
385
402
|
accountConfigId,
|
|
386
403
|
name: "One",
|
|
387
404
|
scope: FilterScope.Standing,
|
|
405
|
+
literalClauses: CLAUSES,
|
|
388
406
|
}),
|
|
389
407
|
repo.create({
|
|
390
408
|
accountConfigId,
|
|
391
409
|
name: "Two",
|
|
392
410
|
scope: FilterScope.Standing,
|
|
411
|
+
literalClauses: CLAUSES,
|
|
393
412
|
}),
|
|
394
413
|
repo.create({
|
|
395
414
|
accountConfigId,
|
|
396
415
|
name: "Three",
|
|
397
416
|
scope: FilterScope.Standing,
|
|
417
|
+
literalClauses: CLAUSES,
|
|
398
418
|
}),
|
|
399
419
|
]);
|
|
400
420
|
|
package/src/repos/filter.ts
CHANGED
|
@@ -21,7 +21,6 @@ import { filterTable } from "../schema.js";
|
|
|
21
21
|
type DB = Db<Record<string, unknown>>;
|
|
22
22
|
|
|
23
23
|
const RULE_ASSERTION_FIELDS = [
|
|
24
|
-
"hasAnchor",
|
|
25
24
|
"matchOperator",
|
|
26
25
|
"literalClauses",
|
|
27
26
|
"actionLabelId",
|
|
@@ -37,7 +36,6 @@ const RULE_ASSERTION_FIELDS = [
|
|
|
37
36
|
* narrower signal `selectMoveWinner` reads for exclusive-move precedence.
|
|
38
37
|
*/
|
|
39
38
|
const ACTION_ASSERTION_FIELDS = [
|
|
40
|
-
"hasAnchor",
|
|
41
39
|
"matchOperator",
|
|
42
40
|
"literalClauses",
|
|
43
41
|
"actionLabelId",
|
|
@@ -81,7 +79,6 @@ function rowToFilter(row: typeof filterTable.$inferSelect): FilterItem {
|
|
|
81
79
|
ttl: row.ttl ?? undefined,
|
|
82
80
|
state: row.state,
|
|
83
81
|
disabledReason: row.disabledReason,
|
|
84
|
-
hasAnchor: row.hasAnchor,
|
|
85
82
|
ruleChangedAt: row.ruleChangedAt,
|
|
86
83
|
actionChangedAt: row.actionChangedAt,
|
|
87
84
|
matchOperator: row.matchOperator,
|
|
@@ -115,11 +112,10 @@ export class FilterRepo implements IFilterRepository {
|
|
|
115
112
|
ttl: input.ttl ?? null,
|
|
116
113
|
state: input.state ?? FilterState.Active,
|
|
117
114
|
disabledReason: input.disabledReason ?? FilterDisabledReason.None,
|
|
118
|
-
hasAnchor: input.hasAnchor ?? false,
|
|
119
115
|
ruleChangedAt: createdRuleChangedAt,
|
|
120
116
|
actionChangedAt: createdRuleChangedAt,
|
|
121
117
|
matchOperator: input.matchOperator ?? FilterMatchOperator.And,
|
|
122
|
-
literalClauses: input.literalClauses
|
|
118
|
+
literalClauses: input.literalClauses,
|
|
123
119
|
actionLabelId: input.actionLabelId ?? "None",
|
|
124
120
|
actionMailboxId: input.actionMailboxId ?? "None",
|
|
125
121
|
createdAt: now,
|
|
@@ -12,7 +12,7 @@ describe("AccountConfigRepo", () => {
|
|
|
12
12
|
|
|
13
13
|
before(async () => {
|
|
14
14
|
({ db, close } = await createTestDb());
|
|
15
|
-
repo = new AccountConfigRepo(db as never);
|
|
15
|
+
repo = new AccountConfigRepo(db as never, db as never);
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
after(async () => {
|
|
@@ -58,7 +58,7 @@ describe("AccountConfigRepo", () => {
|
|
|
58
58
|
const userId = randomId();
|
|
59
59
|
const cfg = await repo.create({ userId });
|
|
60
60
|
|
|
61
|
-
const accountRepo = new AccountRepo(db as never);
|
|
61
|
+
const accountRepo = new AccountRepo(db as never, db as never);
|
|
62
62
|
const addrRepo = new AddressRepo(db as never);
|
|
63
63
|
|
|
64
64
|
await accountRepo.create({
|
|
@@ -37,7 +37,10 @@ function rowToAccountConfig(
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export class AccountConfigRepo implements IAccountConfigRepository {
|
|
40
|
-
constructor(
|
|
40
|
+
constructor(
|
|
41
|
+
private db: DB,
|
|
42
|
+
private mail: DB,
|
|
43
|
+
) {}
|
|
41
44
|
|
|
42
45
|
async create(input: CreateAccountConfigInput): Promise<AccountConfigItem> {
|
|
43
46
|
const now = Date.now();
|
|
@@ -179,7 +182,7 @@ export class AccountConfigRepo implements IAccountConfigRepository {
|
|
|
179
182
|
.select()
|
|
180
183
|
.from(accountTable)
|
|
181
184
|
.where(eq(accountTable.accountConfigId, accountConfigId)),
|
|
182
|
-
this.
|
|
185
|
+
this.mail
|
|
183
186
|
.select()
|
|
184
187
|
.from(addressTable)
|
|
185
188
|
.where(eq(addressTable.accountConfigId, accountConfigId)),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { SearchIndex } from "../search/thread-message-search.js";
|
|
3
4
|
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
4
5
|
import { AccountRepo } from "./i4-account.js";
|
|
5
6
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
@@ -26,7 +27,7 @@ describe("AccountRepo", () => {
|
|
|
26
27
|
|
|
27
28
|
before(async () => {
|
|
28
29
|
({ db, close } = await createTestDb());
|
|
29
|
-
repo = new AccountRepo(db as never);
|
|
30
|
+
repo = new AccountRepo(db as never, db as never);
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
after(async () => {
|
|
@@ -160,7 +161,10 @@ describe("AccountRepo", () => {
|
|
|
160
161
|
const accountConfigId = randomId();
|
|
161
162
|
const account = await repo.create(makeAccountInput(accountConfigId));
|
|
162
163
|
|
|
163
|
-
const mailboxRepo = new MailboxRepo(
|
|
164
|
+
const mailboxRepo = new MailboxRepo(
|
|
165
|
+
db as never,
|
|
166
|
+
new SearchIndex(db as never, db as never),
|
|
167
|
+
);
|
|
164
168
|
mailboxCreated(
|
|
165
169
|
await mailboxRepo.create({
|
|
166
170
|
accountId: account.accountId,
|
package/src/repos/i4-account.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
AccountSchedulerPage,
|
|
5
5
|
CreateAccountInput,
|
|
6
6
|
IAccountRepository,
|
|
7
|
+
RemovableAccountField,
|
|
7
8
|
ResultList,
|
|
8
9
|
UpdateAccountInput,
|
|
9
10
|
} from "@remit/data-ports";
|
|
@@ -70,7 +71,10 @@ export function rowToAccount(
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
export class AccountRepo implements IAccountRepository {
|
|
73
|
-
constructor(
|
|
74
|
+
constructor(
|
|
75
|
+
private db: DB,
|
|
76
|
+
private mail: DB,
|
|
77
|
+
) {}
|
|
74
78
|
|
|
75
79
|
async create(input: CreateAccountInput): Promise<AccountItem> {
|
|
76
80
|
const now = Date.now();
|
|
@@ -107,6 +111,7 @@ export class AccountRepo implements IAccountRepository {
|
|
|
107
111
|
syncPhase: input.syncPhase,
|
|
108
112
|
mailboxCountTotal: input.mailboxCountTotal,
|
|
109
113
|
mailboxCountSynced: input.mailboxCountSynced,
|
|
114
|
+
deletedAt: input.deletedAt,
|
|
110
115
|
createdAt: now,
|
|
111
116
|
updatedAt: now,
|
|
112
117
|
})
|
|
@@ -138,63 +143,14 @@ export class AccountRepo implements IAccountRepository {
|
|
|
138
143
|
async update(
|
|
139
144
|
accountId: string,
|
|
140
145
|
input: UpdateAccountInput,
|
|
141
|
-
remove?:
|
|
146
|
+
remove?: RemovableAccountField[],
|
|
142
147
|
): Promise<AccountItem> {
|
|
143
|
-
const now = Date.now();
|
|
144
148
|
const updates: Partial<typeof accountTable.$inferInsert> = {
|
|
145
|
-
|
|
149
|
+
...input,
|
|
150
|
+
lastError: clampLastError(input.lastError),
|
|
151
|
+
updatedAt: Date.now(),
|
|
146
152
|
};
|
|
147
|
-
|
|
148
|
-
if (input.username !== undefined) updates.username = input.username;
|
|
149
|
-
if (input.email !== undefined) updates.email = input.email;
|
|
150
|
-
if (input.authType !== undefined) updates.authType = input.authType;
|
|
151
|
-
if (input.syncedServices !== undefined)
|
|
152
|
-
updates.syncedServices = input.syncedServices;
|
|
153
|
-
if (input.grantedScopes !== undefined)
|
|
154
|
-
updates.grantedScopes = input.grantedScopes;
|
|
155
|
-
if (input.passwordHash !== undefined)
|
|
156
|
-
updates.passwordHash = input.passwordHash;
|
|
157
|
-
if (input.oauthRefreshTokenHash !== undefined)
|
|
158
|
-
updates.oauthRefreshTokenHash = input.oauthRefreshTokenHash;
|
|
159
|
-
if (input.oauthTokenUpdatedAt !== undefined)
|
|
160
|
-
updates.oauthTokenUpdatedAt = input.oauthTokenUpdatedAt;
|
|
161
|
-
if (input.imapHost !== undefined) updates.imapHost = input.imapHost;
|
|
162
|
-
if (input.imapPort !== undefined) updates.imapPort = input.imapPort;
|
|
163
|
-
if (input.imapTls !== undefined) updates.imapTls = input.imapTls;
|
|
164
|
-
if (input.imapStartTls !== undefined)
|
|
165
|
-
updates.imapStartTls = input.imapStartTls;
|
|
166
|
-
if (input.smtpEnabled !== undefined)
|
|
167
|
-
updates.smtpEnabled = input.smtpEnabled;
|
|
168
|
-
if (input.smtpHost !== undefined) updates.smtpHost = input.smtpHost;
|
|
169
|
-
if (input.smtpPort !== undefined) updates.smtpPort = input.smtpPort;
|
|
170
|
-
if (input.smtpTls !== undefined) updates.smtpTls = input.smtpTls;
|
|
171
|
-
if (input.smtpStartTls !== undefined)
|
|
172
|
-
updates.smtpStartTls = input.smtpStartTls;
|
|
173
|
-
if (input.smtpUsername !== undefined)
|
|
174
|
-
updates.smtpUsername = input.smtpUsername;
|
|
175
|
-
if (input.isActive !== undefined) updates.isActive = input.isActive;
|
|
176
|
-
if (input.connectionState !== undefined)
|
|
177
|
-
updates.connectionState = input.connectionState;
|
|
178
|
-
if (input.lastConnectedAt !== undefined)
|
|
179
|
-
updates.lastConnectedAt = input.lastConnectedAt;
|
|
180
|
-
if (input.lastSyncAt !== undefined) updates.lastSyncAt = input.lastSyncAt;
|
|
181
|
-
if (input.lastError !== undefined)
|
|
182
|
-
updates.lastError = clampLastError(input.lastError);
|
|
183
|
-
if (input.syncPhase !== undefined) updates.syncPhase = input.syncPhase;
|
|
184
|
-
if (input.mailboxCountTotal !== undefined)
|
|
185
|
-
updates.mailboxCountTotal = input.mailboxCountTotal;
|
|
186
|
-
if (input.mailboxCountSynced !== undefined)
|
|
187
|
-
updates.mailboxCountSynced = input.mailboxCountSynced;
|
|
188
|
-
|
|
189
|
-
// Handle field removal (set to null)
|
|
190
|
-
if (remove) {
|
|
191
|
-
for (const field of remove) {
|
|
192
|
-
if (field === "lastError") updates.lastError = null;
|
|
193
|
-
if (field === "lastConnectedAt") updates.lastConnectedAt = null;
|
|
194
|
-
if (field === "lastSyncAt") updates.lastSyncAt = null;
|
|
195
|
-
if (field === "syncPhase") updates.syncPhase = null;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
153
|
+
for (const field of remove ?? []) updates[field] = null;
|
|
198
154
|
|
|
199
155
|
const [row] = await this.db
|
|
200
156
|
.update(accountTable)
|
|
@@ -312,7 +268,7 @@ export class AccountRepo implements IAccountRepository {
|
|
|
312
268
|
.select()
|
|
313
269
|
.from(accountTable)
|
|
314
270
|
.where(eq(accountTable.accountId, accountId)),
|
|
315
|
-
this.
|
|
271
|
+
this.mail
|
|
316
272
|
.select()
|
|
317
273
|
.from(mailboxTable)
|
|
318
274
|
.where(eq(mailboxTable.accountId, accountId)),
|
|
@@ -124,7 +124,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
124
124
|
before(async () => {
|
|
125
125
|
({ db, sqlite, close } = await createTestDb());
|
|
126
126
|
repo = new AddressRepo(db as never);
|
|
127
|
-
specialUse = new MailboxSpecialUseRepo(db as never);
|
|
127
|
+
specialUse = new MailboxSpecialUseRepo(db as never, db as never);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
130
|
/**
|
|
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { after, before, describe, test } from "node:test";
|
|
3
3
|
import type { AddressFlags } from "@remit/data-ports";
|
|
4
4
|
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
5
|
-
import { serializeSqliteWrites } from "../tx.js";
|
|
6
5
|
import { AddressRepo } from "./i4-address.js";
|
|
7
6
|
|
|
8
7
|
const FLAG_KEYS = [
|
|
@@ -25,7 +24,7 @@ describe("concurrent flag merges on one address", () => {
|
|
|
25
24
|
|
|
26
25
|
before(async () => {
|
|
27
26
|
({ db, close } = await createTestDb());
|
|
28
|
-
repo = new AddressRepo(
|
|
27
|
+
repo = new AddressRepo(db as never);
|
|
29
28
|
});
|
|
30
29
|
|
|
31
30
|
after(async () => {
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -35,7 +35,6 @@ import {
|
|
|
35
35
|
} from "../repair/junk-only-address.js";
|
|
36
36
|
import { addressTable } from "../schema/i4-address.js";
|
|
37
37
|
import { envelopeAddressTable } from "../schema/message-data.js";
|
|
38
|
-
import { runInTransaction } from "../tx.js";
|
|
39
38
|
import {
|
|
40
39
|
addressCorrespondence,
|
|
41
40
|
addressListable,
|
|
@@ -53,29 +52,26 @@ type AddressUpdate = Partial<{
|
|
|
53
52
|
| SQL;
|
|
54
53
|
}>;
|
|
55
54
|
|
|
56
|
-
|
|
55
|
+
/** The stored flags as a JSON object: a row harvested before the column existed carries `''`. */
|
|
56
|
+
const storedFlags = sql<string>`coalesce(nullif(${addressTable.flags}, ''), '{}')`;
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
* The stored flags exactly as they sit in the column, so a merge can guard its
|
|
60
|
-
* write on the bytes it read rather than on a re-serialization of them.
|
|
61
|
-
*/
|
|
62
|
-
const storedFlagsSql = sql<string>`cast(${addressTable.flags} as text)`;
|
|
58
|
+
const flagPath = (key: string): string => `$.${key}`;
|
|
63
59
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
stored === "" ? {} : (JSON.parse(stored) as AddressFlags);
|
|
60
|
+
const withFlag = (flags: SQL, key: string, value: unknown): SQL =>
|
|
61
|
+
sql`json_set(${flags}, ${flagPath(key)}, json(${JSON.stringify(value)}))`;
|
|
67
62
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
63
|
+
const withoutFlag = (flags: SQL, key: string): SQL =>
|
|
64
|
+
sql`json_remove(${flags}, ${flagPath(key)})`;
|
|
65
|
+
|
|
66
|
+
const flagRaised = (flags: SQL, key: string): SQL =>
|
|
67
|
+
sql`json_extract(${flags}, ${`$.${key}.value`}) = 1`;
|
|
72
68
|
|
|
73
69
|
/**
|
|
74
70
|
* `blocked` and `neverSpam` are two directly contradictory statements about
|
|
75
71
|
* where a sender's mail belongs (issue #605), so a row asserting both is not a
|
|
76
72
|
* tie to break — it is a row that should not exist. This is the only place that
|
|
77
73
|
* sees both keys at once: `buildFlagsPatch` stays a pure per-key translation,
|
|
78
|
-
* and the merge
|
|
74
|
+
* and the merge expression is the one write that sees the whole map.
|
|
79
75
|
*
|
|
80
76
|
* The flag the patch just raised wins, so the instruction the user gave last is
|
|
81
77
|
* the one that stands. A patch raising both resolves to `blocked` — the same
|
|
@@ -89,20 +85,18 @@ type MergeAttempt =
|
|
|
89
85
|
* healed the next time either key is written.
|
|
90
86
|
*/
|
|
91
87
|
const dropContradictedPlacementFlag = (
|
|
92
|
-
|
|
88
|
+
flags: SQL,
|
|
93
89
|
patch: FlagsMergePatch,
|
|
94
|
-
):
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
return next;
|
|
90
|
+
): SQL => {
|
|
91
|
+
const kept =
|
|
92
|
+
patch.blocked?.value === true
|
|
93
|
+
? "blocked"
|
|
94
|
+
: patch.neverSpam?.value === true
|
|
95
|
+
? "neverSpam"
|
|
96
|
+
: undefined;
|
|
97
|
+
if (kept === undefined) return flags;
|
|
98
|
+
const dropped = kept === "blocked" ? "neverSpam" : "blocked";
|
|
99
|
+
return sql`case when ${flagRaised(flags, kept)} and ${flagRaised(flags, dropped)} then ${withoutFlag(flags, dropped)} else ${flags} end`;
|
|
106
100
|
};
|
|
107
101
|
|
|
108
102
|
/**
|
|
@@ -463,54 +457,28 @@ export class AddressRepo implements IAddressRepository {
|
|
|
463
457
|
}
|
|
464
458
|
|
|
465
459
|
/**
|
|
466
|
-
*
|
|
467
|
-
* the
|
|
468
|
-
*
|
|
469
|
-
* junk-only reconcile, which runs its raw statement outside the write queue;
|
|
470
|
-
* that write fails the guard and the merge folds again over the winner
|
|
471
|
-
* instead of overwriting it. The unit never throws, so a reconcile statement
|
|
472
|
-
* landing inside its savepoint is never rolled back with it.
|
|
460
|
+
* Fold a change into the stored flags in the one UPDATE that writes them:
|
|
461
|
+
* the change is a JSON expression over the column, so two concurrent merges
|
|
462
|
+
* each apply to what the other left and neither overwrites the other.
|
|
473
463
|
*/
|
|
474
464
|
private async mergeStoredFlags(
|
|
475
465
|
accountConfigId: string,
|
|
476
466
|
addressId: string,
|
|
477
|
-
|
|
467
|
+
flags: SQL,
|
|
478
468
|
extra: AddressUpdate = {},
|
|
479
469
|
): Promise<AddressItem> {
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if (!current) return { outcome: "missing" };
|
|
493
|
-
const [row] = await tx
|
|
494
|
-
.update(addressTable)
|
|
495
|
-
.set({
|
|
496
|
-
...extra,
|
|
497
|
-
flags: merge(parseStoredFlags(current.stored)) as never,
|
|
498
|
-
updatedAt: Date.now(),
|
|
499
|
-
})
|
|
500
|
-
.where(and(key, eq(storedFlagsSql, current.stored)))
|
|
501
|
-
.returning();
|
|
502
|
-
return row
|
|
503
|
-
? { outcome: "merged", address: rowToAddress(row) }
|
|
504
|
-
: { outcome: "contended" };
|
|
505
|
-
},
|
|
506
|
-
);
|
|
507
|
-
if (result.outcome === "merged") return result.address;
|
|
508
|
-
if (result.outcome === "missing")
|
|
509
|
-
throw new NotFoundError(`Address not found: ${addressId}`);
|
|
510
|
-
}
|
|
511
|
-
throw new Error(
|
|
512
|
-
`Address flags stayed contended after ${MERGE_FLAGS_ATTEMPTS} attempts: ${addressId}`,
|
|
513
|
-
);
|
|
470
|
+
const [row] = await this.db
|
|
471
|
+
.update(addressTable)
|
|
472
|
+
.set({ ...extra, flags: flags as never, updatedAt: Date.now() })
|
|
473
|
+
.where(
|
|
474
|
+
and(
|
|
475
|
+
eq(addressTable.accountConfigId, accountConfigId),
|
|
476
|
+
eq(addressTable.addressId, addressId),
|
|
477
|
+
),
|
|
478
|
+
)
|
|
479
|
+
.returning();
|
|
480
|
+
if (!row) throw new NotFoundError(`Address not found: ${addressId}`);
|
|
481
|
+
return rowToAddress(row);
|
|
514
482
|
}
|
|
515
483
|
|
|
516
484
|
async mergeFlags(
|
|
@@ -518,21 +486,16 @@ export class AddressRepo implements IAddressRepository {
|
|
|
518
486
|
addressId: string,
|
|
519
487
|
patch: FlagsMergePatch,
|
|
520
488
|
): Promise<AddressItem> {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
(next[key] as AddressFlags[keyof AddressFlags]) = value;
|
|
533
|
-
}
|
|
534
|
-
return dropContradictedPlacementFlag(next, patch);
|
|
535
|
-
});
|
|
489
|
+
const merged = Object.entries(patch).reduce<SQL>((flags, [key, value]) => {
|
|
490
|
+
if (value === undefined) return flags;
|
|
491
|
+
if (value === null) return withoutFlag(flags, key);
|
|
492
|
+
return withFlag(flags, key, value);
|
|
493
|
+
}, storedFlags);
|
|
494
|
+
return this.mergeStoredFlags(
|
|
495
|
+
accountConfigId,
|
|
496
|
+
addressId,
|
|
497
|
+
dropContradictedPlacementFlag(merged, patch),
|
|
498
|
+
);
|
|
536
499
|
}
|
|
537
500
|
|
|
538
501
|
async promoteWellknownByUser(
|
|
@@ -540,10 +503,15 @@ export class AddressRepo implements IAddressRepository {
|
|
|
540
503
|
addressId: string,
|
|
541
504
|
now: number,
|
|
542
505
|
): Promise<AddressItem> {
|
|
543
|
-
return this.mergeStoredFlags(
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
506
|
+
return this.mergeStoredFlags(
|
|
507
|
+
accountConfigId,
|
|
508
|
+
addressId,
|
|
509
|
+
withFlag(storedFlags, "wellknown", {
|
|
510
|
+
value: true,
|
|
511
|
+
setAt: now,
|
|
512
|
+
setBy: "user-junk-rescue",
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
547
515
|
}
|
|
548
516
|
|
|
549
517
|
async demoteSenderTrust(
|
|
@@ -554,7 +522,7 @@ export class AddressRepo implements IAddressRepository {
|
|
|
554
522
|
return this.mergeStoredFlags(
|
|
555
523
|
accountConfigId,
|
|
556
524
|
addressId,
|
|
557
|
-
(
|
|
525
|
+
withoutFlag(withoutFlag(storedFlags, "wellknown"), "vip"),
|
|
558
526
|
{ inboundCount: 0, replyCount: 0 },
|
|
559
527
|
);
|
|
560
528
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
mailboxSpecialUseTable,
|
|
13
13
|
mailboxTable,
|
|
14
14
|
} from "../schema.js";
|
|
15
|
+
import { SearchIndex } from "../search/thread-message-search.js";
|
|
15
16
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
16
17
|
import { AccountSettingRepo } from "./i4-account-setting.js";
|
|
17
18
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
@@ -56,8 +57,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
56
57
|
});
|
|
57
58
|
close = testDb.close;
|
|
58
59
|
db = testDb.db;
|
|
59
|
-
repo = new MailboxSpecialUseRepo(testDb.db as never);
|
|
60
|
-
mailboxes = new MailboxRepo(
|
|
60
|
+
repo = new MailboxSpecialUseRepo(testDb.db as never, testDb.db as never);
|
|
61
|
+
mailboxes = new MailboxRepo(
|
|
62
|
+
testDb.db as never,
|
|
63
|
+
new SearchIndex(testDb.db as never, testDb.db as never),
|
|
64
|
+
);
|
|
61
65
|
accountSettings = new AccountSettingRepo(testDb.db as never);
|
|
62
66
|
});
|
|
63
67
|
|
|
@@ -90,8 +90,11 @@ function rowToSpecialUse(
|
|
|
90
90
|
export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
91
91
|
private readonly accountSetting: AccountSettingRepo;
|
|
92
92
|
|
|
93
|
-
constructor(
|
|
94
|
-
|
|
93
|
+
constructor(
|
|
94
|
+
private app: DB,
|
|
95
|
+
private db: DB,
|
|
96
|
+
) {
|
|
97
|
+
this.accountSetting = new AccountSettingRepo(app);
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
async create(
|
|
@@ -199,7 +202,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
|
199
202
|
async resolveJunkRolesForConfig(
|
|
200
203
|
accountConfigId: string,
|
|
201
204
|
): Promise<JunkRoleMailboxes> {
|
|
202
|
-
const accounts = await this.
|
|
205
|
+
const accounts = await this.app
|
|
203
206
|
.select({ accountId: accountTable.accountId })
|
|
204
207
|
.from(accountTable)
|
|
205
208
|
.where(eq(accountTable.accountConfigId, accountConfigId));
|
|
@@ -213,7 +216,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
|
213
216
|
* run one pass over the address table instead of one per config.
|
|
214
217
|
*/
|
|
215
218
|
async resolveJunkRolesForInstance(): Promise<JunkRoleMailboxes> {
|
|
216
|
-
const accounts = await this.
|
|
219
|
+
const accounts = await this.app
|
|
217
220
|
.select({ accountId: accountTable.accountId })
|
|
218
221
|
.from(accountTable);
|
|
219
222
|
return this.resolveJunkRoles(accounts.map((row) => row.accountId));
|
|
@@ -260,7 +263,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
|
260
263
|
accountIds: readonly string[],
|
|
261
264
|
roles: readonly CanonicalMailboxRoleValue[],
|
|
262
265
|
): Promise<Map<string, string>> {
|
|
263
|
-
const accounts = await this.
|
|
266
|
+
const accounts = await this.app
|
|
264
267
|
.select({
|
|
265
268
|
accountId: accountTable.accountId,
|
|
266
269
|
accountConfigId: accountTable.accountConfigId,
|
|
@@ -363,7 +366,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
|
363
366
|
accountId: string,
|
|
364
367
|
role: CanonicalMailboxRoleValue,
|
|
365
368
|
): Promise<string | undefined> {
|
|
366
|
-
const [account] = await this.
|
|
369
|
+
const [account] = await this.app
|
|
367
370
|
.select({ accountConfigId: accountTable.accountConfigId })
|
|
368
371
|
.from(accountTable)
|
|
369
372
|
.where(eq(accountTable.accountId, accountId));
|