@remit/drizzle-service 0.0.84 → 0.0.86
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-synced-services-migration.sqlite.test.ts +49 -0
- package/src/repos/account.conformance.sqlite.test.ts +23 -0
- package/src/repos/cascade-delete.sqlite.test.ts +1 -0
- package/src/repos/cascade-delete.test.ts +1 -0
- package/src/repos/i4-account.ts +5 -1
- package/src/repos/i4-address-junk-move.sqlite.test.ts +2 -2
- package/src/repos/i4-address-merge-flags.sqlite.test.ts +131 -0
- package/src/repos/i4-address.ts +36 -1
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { AccountService } from "@remit/domain-enums";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
6
|
+
import { AccountRepo } from "./repos/i4-account.js";
|
|
7
|
+
import { accountTable } from "./schema.js";
|
|
8
|
+
import {
|
|
9
|
+
applyMigration,
|
|
10
|
+
migrationJournal,
|
|
11
|
+
} from "./test-shipped-sqlite-schema.js";
|
|
12
|
+
|
|
13
|
+
const MIGRATION = "0029_account_synced_services";
|
|
14
|
+
|
|
15
|
+
const atPredecessor = (): Database.Database => {
|
|
16
|
+
const sqlite = new Database(":memory:");
|
|
17
|
+
for (const entry of migrationJournal()) {
|
|
18
|
+
if (entry.tag === MIGRATION) return sqlite;
|
|
19
|
+
applyMigration(sqlite, entry.tag);
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`${MIGRATION} is not in the journal`);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("an account written before the service selection existed", () => {
|
|
25
|
+
test("comes out of the migration syncing mail only", async () => {
|
|
26
|
+
const sqlite = atPredecessor();
|
|
27
|
+
sqlite
|
|
28
|
+
.prepare(
|
|
29
|
+
`INSERT INTO account (
|
|
30
|
+
account_id, account_config_id, username, email, auth_type,
|
|
31
|
+
imap_host, imap_port, imap_tls, imap_start_tls, smtp_port,
|
|
32
|
+
is_active, connection_state, created_at, updated_at
|
|
33
|
+
) VALUES ('existing', 'config', 'person', 'person@example.com',
|
|
34
|
+
'password', 'imap.example.com', 993, 1, 0, 587, 1,
|
|
35
|
+
'not_authenticated', 0, 0)`,
|
|
36
|
+
)
|
|
37
|
+
.run();
|
|
38
|
+
|
|
39
|
+
applyMigration(sqlite, MIGRATION);
|
|
40
|
+
|
|
41
|
+
const repo = new AccountRepo(
|
|
42
|
+
drizzle(sqlite, { schema: { accounts: accountTable } }) as never,
|
|
43
|
+
);
|
|
44
|
+
assert.deepEqual((await repo.get("existing")).syncedServices, [
|
|
45
|
+
AccountService.Mail,
|
|
46
|
+
]);
|
|
47
|
+
sqlite.close();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { accountRepositoryConformance } from "@remit/data-ports/conformance";
|
|
2
|
+
import { NotFoundError } from "../error.js";
|
|
3
|
+
import { randomId } from "../id.js";
|
|
4
|
+
import { accountTable } from "../schema.js";
|
|
5
|
+
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
6
|
+
import { AccountRepo } from "./i4-account.js";
|
|
7
|
+
|
|
8
|
+
let close: (() => Promise<void>) | undefined;
|
|
9
|
+
|
|
10
|
+
accountRepositoryConformance({
|
|
11
|
+
async createRepository() {
|
|
12
|
+
const { db, close: closeDb } = await createSqliteTestDb({
|
|
13
|
+
accounts: accountTable,
|
|
14
|
+
});
|
|
15
|
+
close = closeDb;
|
|
16
|
+
return new AccountRepo(db as never);
|
|
17
|
+
},
|
|
18
|
+
teardown: async () => {
|
|
19
|
+
await close?.();
|
|
20
|
+
},
|
|
21
|
+
makeId: () => randomId(),
|
|
22
|
+
isNotFoundError: (error) => error instanceof NotFoundError,
|
|
23
|
+
});
|
package/src/repos/i4-account.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
ResultList,
|
|
8
8
|
UpdateAccountInput,
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
|
-
import { SyncPhase } from "@remit/domain-enums";
|
|
10
|
+
import { AccountService, SyncPhase } from "@remit/domain-enums";
|
|
11
11
|
import { and, asc, eq, gt, inArray, or, sql } from "drizzle-orm";
|
|
12
12
|
import type { Db } from "../db.js";
|
|
13
13
|
import { NotFoundError } from "../error.js";
|
|
@@ -39,6 +39,7 @@ export function rowToAccount(
|
|
|
39
39
|
username: row.username,
|
|
40
40
|
email: row.email,
|
|
41
41
|
authType: row.authType as AccountItem["authType"],
|
|
42
|
+
syncedServices: row.syncedServices as AccountItem["syncedServices"],
|
|
42
43
|
passwordHash: row.passwordHash ?? undefined,
|
|
43
44
|
oauthRefreshTokenHash: row.oauthRefreshTokenHash ?? undefined,
|
|
44
45
|
oauthTokenUpdatedAt: row.oauthTokenUpdatedAt ?? undefined,
|
|
@@ -81,6 +82,7 @@ export class AccountRepo implements IAccountRepository {
|
|
|
81
82
|
username: input.username,
|
|
82
83
|
email: input.email,
|
|
83
84
|
authType: input.authType ?? "password",
|
|
85
|
+
syncedServices: input.syncedServices ?? [AccountService.Mail],
|
|
84
86
|
passwordHash: input.passwordHash,
|
|
85
87
|
oauthRefreshTokenHash: input.oauthRefreshTokenHash,
|
|
86
88
|
oauthTokenUpdatedAt: input.oauthTokenUpdatedAt,
|
|
@@ -144,6 +146,8 @@ export class AccountRepo implements IAccountRepository {
|
|
|
144
146
|
if (input.username !== undefined) updates.username = input.username;
|
|
145
147
|
if (input.email !== undefined) updates.email = input.email;
|
|
146
148
|
if (input.authType !== undefined) updates.authType = input.authType;
|
|
149
|
+
if (input.syncedServices !== undefined)
|
|
150
|
+
updates.syncedServices = input.syncedServices;
|
|
147
151
|
if (input.passwordHash !== undefined)
|
|
148
152
|
updates.passwordHash = input.passwordHash;
|
|
149
153
|
if (input.oauthRefreshTokenHash !== undefined)
|
|
@@ -20,9 +20,9 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
20
20
|
`INSERT OR IGNORE INTO account (
|
|
21
21
|
account_id, account_config_id, username, email, imap_host,
|
|
22
22
|
imap_port, imap_tls, imap_start_tls, smtp_port, is_active,
|
|
23
|
-
connection_state, created_at, updated_at
|
|
23
|
+
connection_state, synced_services, created_at, updated_at
|
|
24
24
|
) VALUES (?, ?, 'user', 'user@example.com', 'imap.example.com',
|
|
25
|
-
993, 1, 0, 465, 1, 'disconnected', 0, 0)`,
|
|
25
|
+
993, 1, 0, 465, 1, 'disconnected', '["Mail"]', 0, 0)`,
|
|
26
26
|
)
|
|
27
27
|
.run(accountId, CONFIG);
|
|
28
28
|
};
|
|
@@ -75,6 +75,137 @@ describe("concurrent flag merges on one address", () => {
|
|
|
75
75
|
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
test("marking a blocked sender never-spam drops the block", async () => {
|
|
79
|
+
const addr = await address();
|
|
80
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
81
|
+
blocked: { value: true, setAt: 1 },
|
|
82
|
+
muted: { value: true, setAt: 1 },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
86
|
+
neverSpam: { value: true, setAt: 2 },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
90
|
+
assert.equal(merged.flags?.neverSpam?.value, true);
|
|
91
|
+
assert.equal(merged.flags?.blocked, undefined);
|
|
92
|
+
assert.equal(
|
|
93
|
+
merged.flags?.muted?.value,
|
|
94
|
+
true,
|
|
95
|
+
"a flag on another axis is untouched",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("blocking a never-spam sender drops the grant", async () => {
|
|
102
|
+
const addr = await address();
|
|
103
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
104
|
+
neverSpam: { value: true, setAt: 1 },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
108
|
+
blocked: { value: true, setAt: 2 },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
112
|
+
assert.equal(merged.flags?.blocked?.value, true);
|
|
113
|
+
assert.equal(merged.flags?.neverSpam, undefined);
|
|
114
|
+
|
|
115
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("a never-spam grant turned off leaves a later block alone", async () => {
|
|
119
|
+
const addr = await address();
|
|
120
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
121
|
+
neverSpam: { value: false, setAt: 1 },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
125
|
+
blocked: { value: true, setAt: 2 },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
129
|
+
assert.equal(merged.flags?.blocked?.value, true);
|
|
130
|
+
assert.equal(
|
|
131
|
+
merged.flags?.neverSpam?.value,
|
|
132
|
+
false,
|
|
133
|
+
"an explicit false is not a contradiction and keeps its audit trail",
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// A row written before the invariant existed still carries both. Healing it
|
|
140
|
+
// while patching an unrelated key would revoke a placement instruction the
|
|
141
|
+
// user never touched, so the write leaves it and the read arbitrates.
|
|
142
|
+
test("a patch about another flag leaves a legacy both-set row alone", async () => {
|
|
143
|
+
const accountConfigId = randomId();
|
|
144
|
+
const addr = await repo.createAddress({
|
|
145
|
+
addressId: randomId(),
|
|
146
|
+
accountConfigId,
|
|
147
|
+
localPart: "legacy",
|
|
148
|
+
domain: "example.com",
|
|
149
|
+
normalizedEmail: "legacy@example.com",
|
|
150
|
+
normalizedCompound: "legacy@example.com:legacy",
|
|
151
|
+
flags: {
|
|
152
|
+
blocked: { value: true, setAt: 1 },
|
|
153
|
+
neverSpam: { value: true, setAt: 2 },
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
158
|
+
muted: { value: true, setAt: 3 },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
162
|
+
assert.equal(merged.flags?.blocked?.value, true);
|
|
163
|
+
assert.equal(merged.flags?.neverSpam?.value, true);
|
|
164
|
+
assert.equal(merged.flags?.muted?.value, true);
|
|
165
|
+
|
|
166
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("writing either placement flag heals a legacy both-set row", async () => {
|
|
170
|
+
const accountConfigId = randomId();
|
|
171
|
+
const addr = await repo.createAddress({
|
|
172
|
+
addressId: randomId(),
|
|
173
|
+
accountConfigId,
|
|
174
|
+
localPart: "legacy",
|
|
175
|
+
domain: "example.com",
|
|
176
|
+
normalizedEmail: "legacy2@example.com",
|
|
177
|
+
normalizedCompound: "legacy2@example.com:legacy",
|
|
178
|
+
flags: {
|
|
179
|
+
blocked: { value: true, setAt: 1 },
|
|
180
|
+
neverSpam: { value: true, setAt: 2 },
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
185
|
+
neverSpam: { value: true, setAt: 3 },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
189
|
+
assert.equal(merged.flags?.neverSpam?.value, true);
|
|
190
|
+
assert.equal(merged.flags?.blocked, undefined);
|
|
191
|
+
|
|
192
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("one patch naming both contradictory flags settles on the block", async () => {
|
|
196
|
+
const addr = await address();
|
|
197
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
198
|
+
blocked: { value: true, setAt: 1 },
|
|
199
|
+
neverSpam: { value: true, setAt: 1 },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
203
|
+
assert.equal(merged.flags?.blocked?.value, true);
|
|
204
|
+
assert.equal(merged.flags?.neverSpam, undefined);
|
|
205
|
+
|
|
206
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
207
|
+
});
|
|
208
|
+
|
|
78
209
|
test("a concurrent merge does not resurrect a deleted flag", async () => {
|
|
79
210
|
const addr = await address();
|
|
80
211
|
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -70,6 +70,41 @@ type MergeAttempt =
|
|
|
70
70
|
| { outcome: "missing" }
|
|
71
71
|
| { outcome: "contended" };
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* `blocked` and `neverSpam` are two directly contradictory statements about
|
|
75
|
+
* where a sender's mail belongs (issue #605), so a row asserting both is not a
|
|
76
|
+
* tie to break — it is a row that should not exist. This is the only place that
|
|
77
|
+
* sees both keys at once: `buildFlagsPatch` stays a pure per-key translation,
|
|
78
|
+
* and the merge fold is the read-modify-write of the whole map.
|
|
79
|
+
*
|
|
80
|
+
* The flag the patch just raised wins, so the instruction the user gave last is
|
|
81
|
+
* the one that stands. A patch raising both resolves to `blocked` — the same
|
|
82
|
+
* direction the `blocked`/`vip` same-second tie already breaks in.
|
|
83
|
+
*
|
|
84
|
+
* Only a patch that raises one of the two is allowed to drop the other. A row
|
|
85
|
+
* that somehow already carries both survives a patch about anything else
|
|
86
|
+
* untouched: dropping a placement instruction as a side effect of writing
|
|
87
|
+
* `muted` would be a silent revocation the user never asked for. Such a row is
|
|
88
|
+
* resolved on read — `resolveSenderPlacement` reads it as `Blocked` — and
|
|
89
|
+
* healed the next time either key is written.
|
|
90
|
+
*/
|
|
91
|
+
const dropContradictedPlacementFlag = (
|
|
92
|
+
next: AddressFlags,
|
|
93
|
+
patch: FlagsMergePatch,
|
|
94
|
+
): AddressFlags => {
|
|
95
|
+
if (next.blocked?.value !== true || next.neverSpam?.value !== true)
|
|
96
|
+
return next;
|
|
97
|
+
if (patch.blocked?.value === true) {
|
|
98
|
+
const { neverSpam: _neverSpam, ...rest } = next;
|
|
99
|
+
return rest;
|
|
100
|
+
}
|
|
101
|
+
if (patch.neverSpam?.value === true) {
|
|
102
|
+
const { blocked: _blocked, ...rest } = next;
|
|
103
|
+
return rest;
|
|
104
|
+
}
|
|
105
|
+
return next;
|
|
106
|
+
};
|
|
107
|
+
|
|
73
108
|
/**
|
|
74
109
|
* The stored `"<display name> <email>"` compound, folded in JavaScript exactly
|
|
75
110
|
* as message sync folds it — SQL `lower()` stops at ASCII, and the search reads
|
|
@@ -496,7 +531,7 @@ export class AddressRepo implements IAddressRepository {
|
|
|
496
531
|
}
|
|
497
532
|
(next[key] as AddressFlags[keyof AddressFlags]) = value;
|
|
498
533
|
}
|
|
499
|
-
return next;
|
|
534
|
+
return dropContradictedPlacementFlag(next, patch);
|
|
500
535
|
});
|
|
501
536
|
}
|
|
502
537
|
|