@remit/drizzle-service 0.0.9 → 0.0.11
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
|
@@ -209,6 +209,57 @@ describe("AddressRepo", () => {
|
|
|
209
209
|
await repo.deleteManyAddresses(accountConfigId, created);
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
test("listByAccountConfig resolves a search by exact email even when the sender has a display name", async () => {
|
|
213
|
+
const accountConfigId = randomId();
|
|
214
|
+
const created = await repo.createAddress({
|
|
215
|
+
...makeAddressInput(accountConfigId, "support@npmjs.com"),
|
|
216
|
+
displayName: "npm support",
|
|
217
|
+
// How message-sync writes it: display name first, then the email.
|
|
218
|
+
normalizedCompound: "npm support support@npmjs.com",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const byEmail = await repo.listByAccountConfig({
|
|
222
|
+
accountConfigId,
|
|
223
|
+
search: "support@npmjs.com",
|
|
224
|
+
});
|
|
225
|
+
assert.deepEqual(
|
|
226
|
+
byEmail.items.map((a) => a.addressId),
|
|
227
|
+
[created.addressId],
|
|
228
|
+
"an exact-address lookup must resolve the row",
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const byDisplayName = await repo.listByAccountConfig({
|
|
232
|
+
accountConfigId,
|
|
233
|
+
search: "npm",
|
|
234
|
+
});
|
|
235
|
+
assert.deepEqual(
|
|
236
|
+
byDisplayName.items.map((a) => a.addressId),
|
|
237
|
+
[created.addressId],
|
|
238
|
+
"display-name search keeps working",
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("listByAccountConfig treats LIKE metacharacters in a search term literally", async () => {
|
|
245
|
+
const accountConfigId = randomId();
|
|
246
|
+
const plain = await repo.createAddress(
|
|
247
|
+
makeAddressInput(accountConfigId, "ab@x.com"),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const result = await repo.listByAccountConfig({
|
|
251
|
+
accountConfigId,
|
|
252
|
+
search: "a_@x.com",
|
|
253
|
+
});
|
|
254
|
+
assert.equal(
|
|
255
|
+
result.items.length,
|
|
256
|
+
0,
|
|
257
|
+
"`_` must not act as a single-character wildcard",
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
await repo.deleteManyAddresses(accountConfigId, [plain.addressId]);
|
|
261
|
+
});
|
|
262
|
+
|
|
212
263
|
test("cross-tenant: getAddress refuses a foreign accountConfig", async () => {
|
|
213
264
|
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
214
265
|
const other = randomId();
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -20,6 +20,31 @@ import { shouldPromoteWellknown } from "./i4-address-wellknown.js";
|
|
|
20
20
|
|
|
21
21
|
type DB = Db<Record<string, unknown>>;
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Escape the LIKE metacharacters so a term containing `%` or `_` (both legal in
|
|
25
|
+
* an email local part) matches literally instead of as a wildcard.
|
|
26
|
+
*/
|
|
27
|
+
const escapeLikeTerm = (term: string): string =>
|
|
28
|
+
term.replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Match an address search term as a prefix of either the display-name compound
|
|
32
|
+
* or the normalized email.
|
|
33
|
+
*
|
|
34
|
+
* `normalizedCompound` is stored as `"<display name> <email>"`, so a prefix
|
|
35
|
+
* match on it only ever answers display-name queries — an exact-address lookup
|
|
36
|
+
* such as `support@npmjs.com` can never match a row whose sender has a display
|
|
37
|
+
* name. Matching `normalizedEmail` in the same predicate is what makes address
|
|
38
|
+
* resolution by email work at all (issue #51).
|
|
39
|
+
*/
|
|
40
|
+
const addressSearchPredicate = (term: string) => {
|
|
41
|
+
const pattern = `${escapeLikeTerm(term)}%`;
|
|
42
|
+
return or(
|
|
43
|
+
sql`${addressTable.normalizedCompound} LIKE ${pattern} ESCAPE '\\'`,
|
|
44
|
+
sql`${addressTable.normalizedEmail} LIKE ${pattern} ESCAPE '\\'`,
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
23
48
|
export function rowToAddress(
|
|
24
49
|
row: typeof addressTable.$inferSelect,
|
|
25
50
|
): AddressItem {
|
|
@@ -454,11 +479,11 @@ export class AddressRepo implements IAddressRepository {
|
|
|
454
479
|
|
|
455
480
|
async listByAccountConfig(input: {
|
|
456
481
|
accountConfigId: string;
|
|
457
|
-
|
|
482
|
+
search?: string;
|
|
458
483
|
cursor?: string;
|
|
459
484
|
limit?: number;
|
|
460
485
|
}): Promise<ResultList<AddressItem>> {
|
|
461
|
-
const { accountConfigId,
|
|
486
|
+
const { accountConfigId, search, cursor, limit = 100 } = input;
|
|
462
487
|
const decoded = cursor ? decodeToken(cursor) : undefined;
|
|
463
488
|
const after = decoded
|
|
464
489
|
? {
|
|
@@ -473,9 +498,7 @@ export class AddressRepo implements IAddressRepository {
|
|
|
473
498
|
.where(
|
|
474
499
|
and(
|
|
475
500
|
eq(addressTable.accountConfigId, accountConfigId),
|
|
476
|
-
|
|
477
|
-
? sql`${addressTable.normalizedCompound} LIKE ${`${normalizedCompound}%`}`
|
|
478
|
-
: undefined,
|
|
501
|
+
search ? addressSearchPredicate(search) : undefined,
|
|
479
502
|
after
|
|
480
503
|
? or(
|
|
481
504
|
gt(addressTable.normalizedCompound, after.normalizedCompound),
|
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { after, before, describe, test } from "node:test";
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
4
7
|
import { mailboxTable } from "../schema.js";
|
|
5
8
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
6
9
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
7
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The `mailbox` DDL as it actually ships, read from the committed migration
|
|
13
|
+
* rather than pushed from the drizzle table objects.
|
|
14
|
+
*
|
|
15
|
+
* The two disagree: the table object declares `highest_modseq` as text, the
|
|
16
|
+
* shipped migration still declares it `integer` (reader#73). Every other
|
|
17
|
+
* SQLite test in this package runs against the pushed shape, so none of them
|
|
18
|
+
* has ever exercised the one deployments run on — and SQLite hands a column
|
|
19
|
+
* with numeric affinity back as a number regardless of what the schema says.
|
|
20
|
+
* Reading the committed file keeps this test honest as the migration changes.
|
|
21
|
+
*/
|
|
22
|
+
const shippedMailboxDdl = (): string => {
|
|
23
|
+
const sql = readFileSync(
|
|
24
|
+
new URL(
|
|
25
|
+
"../../../../deploy/vps/migrations-sqlite/entities/0000_happy_roland_deschain.sql",
|
|
26
|
+
import.meta.url,
|
|
27
|
+
),
|
|
28
|
+
"utf8",
|
|
29
|
+
);
|
|
30
|
+
const match = sql.match(/CREATE TABLE `mailbox` \([\s\S]*?\n\);/);
|
|
31
|
+
if (!match)
|
|
32
|
+
throw new Error("mailbox DDL not found in the committed migration");
|
|
33
|
+
return match[0];
|
|
34
|
+
};
|
|
35
|
+
|
|
8
36
|
function makeMailboxInput(accountId: string, fullPath = "INBOX") {
|
|
9
37
|
return {
|
|
10
38
|
accountId,
|
|
@@ -61,3 +89,51 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
61
89
|
assert.equal(reread.highestModseq, "9007199254740993");
|
|
62
90
|
});
|
|
63
91
|
});
|
|
92
|
+
|
|
93
|
+
describe("MailboxRepo (sqlite, shipped column shape)", () => {
|
|
94
|
+
let close: () => Promise<void>;
|
|
95
|
+
let repo: MailboxRepo;
|
|
96
|
+
|
|
97
|
+
before(async () => {
|
|
98
|
+
const sqlite = new Database(":memory:");
|
|
99
|
+
sqlite.exec(shippedMailboxDdl());
|
|
100
|
+
const db = drizzle(sqlite, { schema: { mailbox: mailboxTable } });
|
|
101
|
+
repo = new MailboxRepo(db as never);
|
|
102
|
+
close = async () => {
|
|
103
|
+
sqlite.close();
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
after(async () => {
|
|
108
|
+
await close();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("reads the sync cursor back as a string, whatever the column stores", async () => {
|
|
112
|
+
// A plain-digit cursor lands in a column with numeric affinity and comes
|
|
113
|
+
// back a number. Callers compare the value they wrote against the value
|
|
114
|
+
// they read — `"900" === 900` is false — so an unnormalised read makes a
|
|
115
|
+
// stalled cursor undetectable.
|
|
116
|
+
const accountId = randomUUID();
|
|
117
|
+
const created = await repo.create({
|
|
118
|
+
...makeMailboxInput(accountId),
|
|
119
|
+
highestModseq: "900",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
assert.strictEqual(created.highestModseq, "900");
|
|
123
|
+
|
|
124
|
+
const fetched = await repo.get(accountId, created.mailboxId);
|
|
125
|
+
assert.strictEqual(fetched.highestModseq, "900");
|
|
126
|
+
assert.strictEqual(fetched.highestModseq === "900", true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("keeps a resumable cursor intact through the same column", async () => {
|
|
130
|
+
const accountId = randomUUID();
|
|
131
|
+
const created = await repo.create({
|
|
132
|
+
...makeMailboxInput(accountId, "Archive"),
|
|
133
|
+
highestModseq: "900:149",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const fetched = await repo.get(accountId, created.mailboxId);
|
|
137
|
+
assert.strictEqual(fetched.highestModseq, "900:149");
|
|
138
|
+
});
|
|
139
|
+
});
|
package/src/repos/i4-mailbox.ts
CHANGED
|
@@ -33,7 +33,15 @@ export function rowToMailbox(
|
|
|
33
33
|
fullPath: row.fullPath,
|
|
34
34
|
uidValidity: row.uidValidity,
|
|
35
35
|
uidNext: row.uidNext,
|
|
36
|
-
|
|
36
|
+
// SQLite returns a column with numeric affinity as a number whatever the
|
|
37
|
+
// schema declares, and the shipped self-host migration still declares
|
|
38
|
+
// this one `integer` (reader#73). A cursor read back as a number is not
|
|
39
|
+
// merely awkward to parse: `"900" === 900` is false, so code comparing
|
|
40
|
+
// the value it just wrote against the value it read would conclude
|
|
41
|
+
// nothing had changed — which is how a stalled cursor goes unreported.
|
|
42
|
+
// Normalising here means every consumer sees the declared type instead
|
|
43
|
+
// of each one guarding separately.
|
|
44
|
+
highestModseq: String(row.highestModseq),
|
|
37
45
|
messageCount: row.messageCount,
|
|
38
46
|
unseenCount: row.unseenCount,
|
|
39
47
|
deletedCount: row.deletedCount,
|