@remit/drizzle-service 0.0.39 → 0.0.40
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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { or, type SQL, sql } from "drizzle-orm";
|
|
2
|
+
import { addressTable } from "../schema/i4-address.js";
|
|
3
|
+
|
|
4
|
+
// The address search seam. A term is matched as a substring of the display name,
|
|
5
|
+
// the local part, the domain and the whole address, and the order it comes back
|
|
6
|
+
// in is decided by which of those matched and where (#704).
|
|
7
|
+
|
|
8
|
+
const escapeLike = (term: string): string => term.replace(/[\\%_]/g, "\\$&");
|
|
9
|
+
|
|
10
|
+
const SEARCH_COLUMNS = [
|
|
11
|
+
sql`lower(coalesce(${addressTable.displayName}, ''))`,
|
|
12
|
+
sql`${addressTable.localPart}`,
|
|
13
|
+
sql`${addressTable.domain}`,
|
|
14
|
+
sql`${addressTable.normalizedEmail}`,
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* SQL `lower()` and `like` both fold ASCII only, so a column read through them
|
|
19
|
+
* can never meet a term folded by JavaScript: `Öz` would miss `Özcan Bakker`.
|
|
20
|
+
* `normalizedCompound` is written already folded by JavaScript, on the same
|
|
21
|
+
* rules as the term, so matching it raw is what keeps a name outside ASCII
|
|
22
|
+
* findable. It carries no column identity — the name and the address are
|
|
23
|
+
* concatenated in it — so a row reached only this way scores no rank and sorts
|
|
24
|
+
* last, which is where a fallback belongs.
|
|
25
|
+
*/
|
|
26
|
+
const FOLDED_FALLBACK = sql`${addressTable.normalizedCompound}`;
|
|
27
|
+
|
|
28
|
+
const like = (column: SQL, pattern: string): SQL =>
|
|
29
|
+
sql`${column} like ${pattern} escape '\\'`;
|
|
30
|
+
|
|
31
|
+
const patterns = (term: string) => {
|
|
32
|
+
const escaped = escapeLike(term.toLowerCase());
|
|
33
|
+
return { leading: `${escaped}%`, anywhere: `%${escaped}%` };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const addressSearchMatch = (term: string): SQL => {
|
|
37
|
+
const { anywhere } = patterns(term);
|
|
38
|
+
const matched = or(
|
|
39
|
+
...[...SEARCH_COLUMNS, FOLDED_FALLBACK].map((column) =>
|
|
40
|
+
like(column, anywhere),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
if (matched === undefined) throw new Error("no address column to search");
|
|
44
|
+
return matched;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where the term hit, as one number: every match at the start of a column
|
|
49
|
+
* outranks every match in the middle of one, and within each the display name
|
|
50
|
+
* outranks the local part, the domain and the whole address. A mid-string match
|
|
51
|
+
* still comes back — this only decides the order.
|
|
52
|
+
*/
|
|
53
|
+
export const addressMatchRank = (term: string | undefined): SQL<number> => {
|
|
54
|
+
// Not the bare literal `0`: SQLite reads an integer literal in ORDER BY as a
|
|
55
|
+
// column index and rejects it as out of range.
|
|
56
|
+
if (!term) return sql<number>`cast(0 as integer)`;
|
|
57
|
+
const { leading, anywhere } = patterns(term);
|
|
58
|
+
const arms = [
|
|
59
|
+
...SEARCH_COLUMNS.map((column) => like(column, leading)),
|
|
60
|
+
...SEARCH_COLUMNS.map((column) => like(column, anywhere)),
|
|
61
|
+
].map(
|
|
62
|
+
(condition, index) =>
|
|
63
|
+
sql`when ${condition} then ${SEARCH_COLUMNS.length * 2 - index}`,
|
|
64
|
+
);
|
|
65
|
+
return sql<number>`case ${sql.join(arms, sql` `)} else 0 end`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// `json_extract` raises on text that is not JSON, and this runs on every row in
|
|
69
|
+
// the account's scan — one unparseable `flags` value would take the whole
|
|
70
|
+
// account's autocomplete down rather than its own row.
|
|
71
|
+
const flagValue = (name: string): SQL<number> =>
|
|
72
|
+
sql<number>`coalesce(json_extract(coalesce(nullif(${addressTable.flags}, ''), '{}'), ${`$.${name}.value`}), 0)`;
|
|
73
|
+
|
|
74
|
+
/** The account's own standing for the sender: a VIP first, then one it trusts. */
|
|
75
|
+
export const addressPreference = (): SQL<number> =>
|
|
76
|
+
sql<number>`(2 * ${flagValue("vip")} + ${flagValue("trusted")})`;
|
|
77
|
+
|
|
78
|
+
export const addressCorrespondence = (): SQL<number> =>
|
|
79
|
+
sql<number>`(${addressTable.replyCount} + ${addressTable.inboundCount} + ${addressTable.outboundCount})`;
|
|
80
|
+
|
|
81
|
+
export const addressRecency = (): SQL<number> =>
|
|
82
|
+
sql<number>`max(${addressTable.lastInboundAt}, ${addressTable.lastReplyAt}, coalesce(${addressTable.lastOutboundAt}, 0))`;
|
|
@@ -241,23 +241,417 @@ describe("AddressRepo", () => {
|
|
|
241
241
|
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
-
test("listByAccountConfig
|
|
244
|
+
test("listByAccountConfig matches any word of the display name, the local part and the domain (#704)", async () => {
|
|
245
245
|
const accountConfigId = randomId();
|
|
246
|
-
const
|
|
247
|
-
makeAddressInput(accountConfigId, "
|
|
246
|
+
const created = await repo.createAddress({
|
|
247
|
+
...makeAddressInput(accountConfigId, "amsterdam@pocahondas.nl"),
|
|
248
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
249
|
+
normalizedCompound:
|
|
250
|
+
"pocahondas locatie amsterdam amsterdam@pocahondas.nl",
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
for (const term of [
|
|
254
|
+
"po",
|
|
255
|
+
"pocahondas",
|
|
256
|
+
"locatie",
|
|
257
|
+
"amsterdam",
|
|
258
|
+
"pocahondas.nl",
|
|
259
|
+
"@pocahondas.nl",
|
|
260
|
+
"amsterdam@pocahondas.nl",
|
|
261
|
+
]) {
|
|
262
|
+
const found = await repo.listByAccountConfig({
|
|
263
|
+
accountConfigId,
|
|
264
|
+
search: term,
|
|
265
|
+
});
|
|
266
|
+
assert.deepEqual(
|
|
267
|
+
found.items.map((a) => a.addressId),
|
|
268
|
+
[created.addressId],
|
|
269
|
+
`"${term}" must resolve the address`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("listByAccountConfig finds a display name whose first letter is not ASCII", async () => {
|
|
277
|
+
const accountConfigId = randomId();
|
|
278
|
+
// Written the way message sync writes it: the compound folded in JavaScript.
|
|
279
|
+
const names = [
|
|
280
|
+
["Özcan Bakker", "o.bakker@kliniek.nl", ["Öz", "öz", "özcan", "zcan"]],
|
|
281
|
+
["Émile Zola", "emile@zola.test", ["Ém", "ém", "émile", "zola"]],
|
|
282
|
+
["Ángela Ruiz", "angela@ruiz.test", ["Án", "án", "ángela", "ngela"]],
|
|
283
|
+
] as const;
|
|
284
|
+
|
|
285
|
+
for (const [displayName, email, terms] of names) {
|
|
286
|
+
const created = await repo.createAddress({
|
|
287
|
+
...makeAddressInput(accountConfigId, email),
|
|
288
|
+
displayName,
|
|
289
|
+
normalizedCompound: `${displayName.toLowerCase()} ${email}`,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
for (const term of terms) {
|
|
293
|
+
const found = await repo.listByAccountConfig({
|
|
294
|
+
accountConfigId,
|
|
295
|
+
search: term,
|
|
296
|
+
});
|
|
297
|
+
assert.deepEqual(
|
|
298
|
+
found.items.map((a) => a.addressId),
|
|
299
|
+
[created.addressId],
|
|
300
|
+
`"${term}" must resolve ${displayName}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("updateAddress keeps the compound findable when a name is not ASCII", async () => {
|
|
309
|
+
const accountConfigId = randomId();
|
|
310
|
+
const created = await repo.createAddress(
|
|
311
|
+
makeAddressInput(accountConfigId, "o.bakker@kliniek.nl"),
|
|
248
312
|
);
|
|
249
313
|
|
|
250
|
-
|
|
314
|
+
await repo.updateAddress(accountConfigId, created.addressId, {
|
|
315
|
+
displayName: "Özcan Bakker",
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const found = await repo.listByAccountConfig({
|
|
251
319
|
accountConfigId,
|
|
252
|
-
search: "
|
|
320
|
+
search: "Öz",
|
|
321
|
+
});
|
|
322
|
+
assert.deepEqual(
|
|
323
|
+
found.items.map((a) => a.addressId),
|
|
324
|
+
[created.addressId],
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("listByAccountConfig offers a two-letter name match ahead of a louder domain match (#704)", async () => {
|
|
331
|
+
const accountConfigId = randomId();
|
|
332
|
+
const colin = await repo.createAddress({
|
|
333
|
+
...makeAddressInput(accountConfigId, "colin@personal.com"),
|
|
334
|
+
displayName: "Colin Baker",
|
|
335
|
+
inboundCount: 4,
|
|
336
|
+
});
|
|
337
|
+
const newsletter = await repo.createAddress({
|
|
338
|
+
...makeAddressInput(accountConfigId, "info@acme.com"),
|
|
339
|
+
displayName: "Acme Newsletter",
|
|
340
|
+
inboundCount: 500,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const byInitials = await repo.listByAccountConfig({
|
|
344
|
+
accountConfigId,
|
|
345
|
+
search: "co",
|
|
346
|
+
limit: 1,
|
|
347
|
+
});
|
|
348
|
+
assert.deepEqual(
|
|
349
|
+
byInitials.items.map((a) => a.addressId),
|
|
350
|
+
[colin.addressId],
|
|
351
|
+
"volume must not take the only suggestion slot from a name match",
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const shop = await repo.createAddress({
|
|
355
|
+
...makeAddressInput(accountConfigId, "hello@corner.test"),
|
|
356
|
+
displayName: "Corner Shop",
|
|
357
|
+
});
|
|
358
|
+
const loud = await repo.createAddress({
|
|
359
|
+
...makeAddressInput(accountConfigId, "news@corner.test"),
|
|
360
|
+
displayName: "Loud List",
|
|
361
|
+
inboundCount: 500,
|
|
253
362
|
});
|
|
363
|
+
const byName = await repo.listByAccountConfig({
|
|
364
|
+
accountConfigId,
|
|
365
|
+
search: "corner",
|
|
366
|
+
});
|
|
367
|
+
assert.deepEqual(
|
|
368
|
+
byName.items.map((a) => a.addressId),
|
|
369
|
+
[shop.addressId, loud.addressId],
|
|
370
|
+
"a name match outranks a shared domain",
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
374
|
+
colin.addressId,
|
|
375
|
+
newsletter.addressId,
|
|
376
|
+
shop.addressId,
|
|
377
|
+
loud.addressId,
|
|
378
|
+
]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("cross-tenant: a search never reaches another account's addresses", async () => {
|
|
382
|
+
const mine = randomId();
|
|
383
|
+
const theirs = randomId();
|
|
384
|
+
const ours = await repo.createAddress({
|
|
385
|
+
...makeAddressInput(mine, "amsterdam@pocahondas.nl"),
|
|
386
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
387
|
+
});
|
|
388
|
+
const foreign = await repo.createAddress({
|
|
389
|
+
...makeAddressInput(theirs, "amsterdam@pocahondas.nl"),
|
|
390
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
for (const term of ["po", "amsterdam", "pocahondas.nl"]) {
|
|
394
|
+
const found = await repo.listByAccountConfig({
|
|
395
|
+
accountConfigId: mine,
|
|
396
|
+
search: term,
|
|
397
|
+
});
|
|
398
|
+
assert.deepEqual(
|
|
399
|
+
found.items.map((a) => a.addressId),
|
|
400
|
+
[ours.addressId],
|
|
401
|
+
`"${term}" must stay inside the caller's account`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
await repo.deleteManyAddresses(mine, [ours.addressId]);
|
|
406
|
+
await repo.deleteManyAddresses(theirs, [foreign.addressId]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("listByAccountConfig puts a match at the start of a name above one in the middle", async () => {
|
|
410
|
+
const accountConfigId = randomId();
|
|
411
|
+
const leading = await repo.createAddress({
|
|
412
|
+
...makeAddressInput(accountConfigId, "amsterdam@pocahondas.nl"),
|
|
413
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
414
|
+
});
|
|
415
|
+
const middle = await repo.createAddress({
|
|
416
|
+
...makeAddressInput(accountConfigId, "hello@example.test"),
|
|
417
|
+
displayName: "Studio Pocahondas",
|
|
418
|
+
inboundCount: 500,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const found = await repo.listByAccountConfig({
|
|
422
|
+
accountConfigId,
|
|
423
|
+
search: "po",
|
|
424
|
+
});
|
|
425
|
+
assert.deepEqual(
|
|
426
|
+
found.items.map((a) => a.addressId),
|
|
427
|
+
[leading.addressId, middle.addressId],
|
|
428
|
+
"a mid-name match is returned, but below the one the term starts",
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
432
|
+
leading.addressId,
|
|
433
|
+
middle.addressId,
|
|
434
|
+
]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("listByAccountConfig leads a tier with the account's own VIP", async () => {
|
|
438
|
+
const accountConfigId = randomId();
|
|
439
|
+
const vip = await repo.createAddress({
|
|
440
|
+
...makeAddressInput(accountConfigId, "one@vips.test"),
|
|
441
|
+
displayName: "Sales Alice",
|
|
442
|
+
flags: { vip: { value: true, setAt: 1 } },
|
|
443
|
+
});
|
|
444
|
+
const trusted = await repo.createAddress({
|
|
445
|
+
...makeAddressInput(accountConfigId, "two@vips.test"),
|
|
446
|
+
displayName: "Sales Bob",
|
|
447
|
+
flags: { trusted: { value: true, setAt: 1 } },
|
|
448
|
+
inboundCount: 40,
|
|
449
|
+
});
|
|
450
|
+
const stranger = await repo.createAddress({
|
|
451
|
+
...makeAddressInput(accountConfigId, "three@vips.test"),
|
|
452
|
+
displayName: "Sales Carol",
|
|
453
|
+
inboundCount: 900,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const found = await repo.listByAccountConfig({
|
|
457
|
+
accountConfigId,
|
|
458
|
+
search: "sales",
|
|
459
|
+
});
|
|
460
|
+
assert.deepEqual(
|
|
461
|
+
found.items.map((a) => a.addressId),
|
|
462
|
+
[vip.addressId, trusted.addressId, stranger.addressId],
|
|
463
|
+
"standing decides ahead of volume",
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
467
|
+
vip.addressId,
|
|
468
|
+
trusted.addressId,
|
|
469
|
+
stranger.addressId,
|
|
470
|
+
]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("listByAccountConfig finds an address whose display name arrived after the first sighting", async () => {
|
|
474
|
+
const accountConfigId = randomId();
|
|
475
|
+
// How message-sync writes a first sighting with no display name, then a
|
|
476
|
+
// later one that carries it.
|
|
477
|
+
const nameless = {
|
|
478
|
+
...makeAddressInput(accountConfigId, "amsterdam@pocahondas.nl"),
|
|
479
|
+
displayName: "",
|
|
480
|
+
normalizedCompound: "amsterdam@pocahondas.nl",
|
|
481
|
+
};
|
|
482
|
+
await repo.upsertAddress(nameless);
|
|
483
|
+
const created = await repo.upsertAddress({
|
|
484
|
+
...nameless,
|
|
485
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
486
|
+
normalizedCompound:
|
|
487
|
+
"pocahondas locatie amsterdam amsterdam@pocahondas.nl",
|
|
488
|
+
});
|
|
489
|
+
assert.equal(created.displayName, "Pocahondas locatie amsterdam");
|
|
490
|
+
|
|
491
|
+
for (const term of ["po", "locatie", "pocahondas"]) {
|
|
492
|
+
const found = await repo.listByAccountConfig({
|
|
493
|
+
accountConfigId,
|
|
494
|
+
search: term,
|
|
495
|
+
});
|
|
496
|
+
assert.deepEqual(
|
|
497
|
+
found.items.map((a) => a.addressId),
|
|
498
|
+
[created.addressId],
|
|
499
|
+
`"${term}" must resolve the address`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("upsertAddress keeps a known display name when a later sighting carries none", async () => {
|
|
507
|
+
const accountConfigId = randomId();
|
|
508
|
+
const named = {
|
|
509
|
+
...makeAddressInput(accountConfigId, "amsterdam@pocahondas.nl"),
|
|
510
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
511
|
+
normalizedCompound:
|
|
512
|
+
"pocahondas locatie amsterdam amsterdam@pocahondas.nl",
|
|
513
|
+
};
|
|
514
|
+
await repo.upsertAddress(named);
|
|
515
|
+
const after = await repo.upsertAddress({
|
|
516
|
+
...named,
|
|
517
|
+
displayName: "",
|
|
518
|
+
normalizedCompound: "amsterdam@pocahondas.nl",
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
assert.equal(after.displayName, "Pocahondas locatie amsterdam");
|
|
254
522
|
assert.equal(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
523
|
+
after.normalizedCompound,
|
|
524
|
+
"pocahondas locatie amsterdam amsterdam@pocahondas.nl",
|
|
525
|
+
);
|
|
526
|
+
const found = await repo.listByAccountConfig({
|
|
527
|
+
accountConfigId,
|
|
528
|
+
search: "locatie",
|
|
529
|
+
});
|
|
530
|
+
assert.deepEqual(
|
|
531
|
+
found.items.map((a) => a.addressId),
|
|
532
|
+
[named.addressId],
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
await repo.deleteManyAddresses(accountConfigId, [named.addressId]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("listByAccountConfig ranks an address the account writes to above an alphabetically earlier stranger (#704)", async () => {
|
|
539
|
+
const accountConfigId = randomId();
|
|
540
|
+
const stranger = await repo.createAddress({
|
|
541
|
+
...makeAddressInput(accountConfigId, "aaron@example.com"),
|
|
542
|
+
displayName: "Aaron Stranger",
|
|
543
|
+
normalizedCompound: "aaron stranger aaron@example.com",
|
|
544
|
+
});
|
|
545
|
+
const frequent = await repo.createAddress({
|
|
546
|
+
...makeAddressInput(accountConfigId, "zoe@example.com"),
|
|
547
|
+
displayName: "Zoe Frequent",
|
|
548
|
+
normalizedCompound: "zoe frequent zoe@example.com",
|
|
549
|
+
});
|
|
550
|
+
// Written to, never heard back from — the compose field's most useful
|
|
551
|
+
// suggestion and the one a received-mail-only score would bury.
|
|
552
|
+
const now = Date.now();
|
|
553
|
+
await repo.incrementOutboundCount(accountConfigId, frequent.addressId, now);
|
|
554
|
+
await repo.incrementOutboundCount(accountConfigId, frequent.addressId, now);
|
|
555
|
+
|
|
556
|
+
const ranked = await repo.listByAccountConfig({
|
|
557
|
+
accountConfigId,
|
|
558
|
+
search: "example.com",
|
|
559
|
+
});
|
|
560
|
+
assert.deepEqual(
|
|
561
|
+
ranked.items.map((a) => a.normalizedEmail),
|
|
562
|
+
["zoe@example.com", "aaron@example.com"],
|
|
563
|
+
"correspondence outranks the alphabet",
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
const cut = await repo.listByAccountConfig({
|
|
567
|
+
accountConfigId,
|
|
568
|
+
search: "example.com",
|
|
569
|
+
limit: 1,
|
|
570
|
+
});
|
|
571
|
+
assert.deepEqual(
|
|
572
|
+
cut.items.map((a) => a.normalizedEmail),
|
|
573
|
+
["zoe@example.com"],
|
|
574
|
+
"a short suggestion list keeps the address worth suggesting",
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
578
|
+
stranger.addressId,
|
|
579
|
+
frequent.addressId,
|
|
580
|
+
]);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("listByAccountConfig pages a ranked search without dupes or gaps", async () => {
|
|
584
|
+
const accountConfigId = randomId();
|
|
585
|
+
const created: string[] = [];
|
|
586
|
+
for (const [index, name] of ["a", "b", "c", "d", "e"].entries()) {
|
|
587
|
+
const addr = await repo.createAddress({
|
|
588
|
+
...makeAddressInput(accountConfigId, `${name}@ranked.test`),
|
|
589
|
+
displayName: name,
|
|
590
|
+
normalizedCompound: `${name} ${name}@ranked.test`,
|
|
591
|
+
inboundCount: index % 2,
|
|
592
|
+
});
|
|
593
|
+
created.push(addr.addressId);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const seen: string[] = [];
|
|
597
|
+
let cursor: string | undefined;
|
|
598
|
+
let pages = 0;
|
|
599
|
+
do {
|
|
600
|
+
const page = await repo.listByAccountConfig({
|
|
601
|
+
accountConfigId,
|
|
602
|
+
search: "ranked.test",
|
|
603
|
+
limit: 2,
|
|
604
|
+
cursor,
|
|
605
|
+
});
|
|
606
|
+
seen.push(...page.items.map((a) => a.addressId));
|
|
607
|
+
cursor = page.continuationToken;
|
|
608
|
+
pages++;
|
|
609
|
+
assert.ok(pages < 10, "pagination must terminate");
|
|
610
|
+
} while (cursor);
|
|
611
|
+
|
|
612
|
+
assert.equal(new Set(seen).size, 5, "every row returned exactly once");
|
|
613
|
+
assert.deepEqual([...seen].sort(), [...created].sort(), "no gaps");
|
|
614
|
+
|
|
615
|
+
await repo.deleteManyAddresses(accountConfigId, created);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("listByAccountConfig treats LIKE metacharacters in a search term literally", async () => {
|
|
619
|
+
const accountConfigId = randomId();
|
|
620
|
+
const plain = await repo.createAddress(
|
|
621
|
+
makeAddressInput(accountConfigId, "ab@x.com"),
|
|
622
|
+
);
|
|
623
|
+
const literal = await repo.createAddress(
|
|
624
|
+
makeAddressInput(accountConfigId, "a%b_c@x.com"),
|
|
258
625
|
);
|
|
259
626
|
|
|
260
|
-
|
|
627
|
+
for (const term of ["a_@x.com", "a%x", "a%b_c@y"]) {
|
|
628
|
+
const found = await repo.listByAccountConfig({
|
|
629
|
+
accountConfigId,
|
|
630
|
+
search: term,
|
|
631
|
+
});
|
|
632
|
+
assert.deepEqual(
|
|
633
|
+
found.items.map((a) => a.addressId),
|
|
634
|
+
[],
|
|
635
|
+
`"${term}" must match no wildcard`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
for (const term of ["a%b", "b_c", "a%b_c@x.com"]) {
|
|
640
|
+
const found = await repo.listByAccountConfig({
|
|
641
|
+
accountConfigId,
|
|
642
|
+
search: term,
|
|
643
|
+
});
|
|
644
|
+
assert.deepEqual(
|
|
645
|
+
found.items.map((a) => a.addressId),
|
|
646
|
+
[literal.addressId],
|
|
647
|
+
`"${term}" must match the metacharacter literally`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
652
|
+
plain.addressId,
|
|
653
|
+
literal.addressId,
|
|
654
|
+
]);
|
|
261
655
|
});
|
|
262
656
|
|
|
263
657
|
test("cross-tenant: getAddress refuses a foreign accountConfig", async () => {
|
|
@@ -412,6 +806,12 @@ describe("AddressRepo", () => {
|
|
|
412
806
|
["an unparseable", "not-a-cursor"],
|
|
413
807
|
["a bare number", Buffer.from("123").toString("base64url")],
|
|
414
808
|
["a JSON array", Buffer.from("[1,2]").toString("base64url")],
|
|
809
|
+
[
|
|
810
|
+
"a token without a ranking key",
|
|
811
|
+
Buffer.from(
|
|
812
|
+
JSON.stringify({ normalizedCompound: "a", addressId: "b" }),
|
|
813
|
+
).toString("base64url"),
|
|
814
|
+
],
|
|
415
815
|
] as const) {
|
|
416
816
|
test(`${label} cursor is rejected as a 400`, async () => {
|
|
417
817
|
await assert.rejects(
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -9,40 +9,105 @@ import type {
|
|
|
9
9
|
ResultList,
|
|
10
10
|
UpdateAddressInput,
|
|
11
11
|
} from "@remit/data-ports";
|
|
12
|
-
import {
|
|
12
|
+
import { BadRequestError } from "@remit/data-ports/errors";
|
|
13
|
+
import {
|
|
14
|
+
and,
|
|
15
|
+
asc,
|
|
16
|
+
desc,
|
|
17
|
+
eq,
|
|
18
|
+
getTableColumns,
|
|
19
|
+
inArray,
|
|
20
|
+
type SQL,
|
|
21
|
+
sql,
|
|
22
|
+
} from "drizzle-orm";
|
|
13
23
|
import type { Db } from "../db.js";
|
|
14
24
|
import { NotFoundError } from "../error.js";
|
|
15
25
|
import { envelopeAddressId as deriveEnvelopeAddressId } from "../id.js";
|
|
16
26
|
import { decodeToken, resultList } from "../pagination.js";
|
|
17
27
|
import { addressTable } from "../schema/i4-address.js";
|
|
18
28
|
import { envelopeAddressTable } from "../schema/message-data.js";
|
|
29
|
+
import {
|
|
30
|
+
addressCorrespondence,
|
|
31
|
+
addressMatchRank,
|
|
32
|
+
addressPreference,
|
|
33
|
+
addressRecency,
|
|
34
|
+
addressSearchMatch,
|
|
35
|
+
} from "./address-search-predicates.js";
|
|
19
36
|
import { shouldPromoteWellknown } from "./i4-address-wellknown.js";
|
|
20
37
|
|
|
21
38
|
type DB = Db<Record<string, unknown>>;
|
|
22
39
|
|
|
40
|
+
type AddressUpdate = Partial<{
|
|
41
|
+
[K in keyof typeof addressTable.$inferInsert]:
|
|
42
|
+
| (typeof addressTable.$inferInsert)[K]
|
|
43
|
+
| SQL;
|
|
44
|
+
}>;
|
|
45
|
+
|
|
23
46
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
47
|
+
* The stored `"<display name> <email>"` compound, folded in JavaScript exactly
|
|
48
|
+
* as message sync folds it — SQL `lower()` stops at ASCII, and the search reads
|
|
49
|
+
* this column expecting a full fold.
|
|
26
50
|
*/
|
|
27
|
-
const
|
|
28
|
-
|
|
51
|
+
const compoundOfSql = (displayName: string): SQL<string> =>
|
|
52
|
+
sql<string>`trim(${displayName.toLowerCase()} || ' ' || ${addressTable.normalizedEmail})`;
|
|
29
53
|
|
|
30
54
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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).
|
|
55
|
+
* The order a suggestion list comes back in: where the term hit, then the
|
|
56
|
+
* account's own standing for the sender, then how much it corresponds with it,
|
|
57
|
+
* then how recently — and the stored compound and the id last, so the order is
|
|
58
|
+
* total and a page boundary is a position rather than a guess.
|
|
39
59
|
*/
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
const searchOrder = (search: string | undefined) =>
|
|
61
|
+
[
|
|
62
|
+
{ key: "rank", expr: addressMatchRank(search), direction: "desc" },
|
|
63
|
+
{ key: "preference", expr: addressPreference(), direction: "desc" },
|
|
64
|
+
{ key: "correspondence", expr: addressCorrespondence(), direction: "desc" },
|
|
65
|
+
{ key: "recency", expr: addressRecency(), direction: "desc" },
|
|
66
|
+
{
|
|
67
|
+
key: "normalizedCompound",
|
|
68
|
+
expr: sql`${addressTable.normalizedCompound}`,
|
|
69
|
+
direction: "asc",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "addressId",
|
|
73
|
+
expr: sql`${addressTable.addressId}`,
|
|
74
|
+
direction: "asc",
|
|
75
|
+
},
|
|
76
|
+
] as const;
|
|
77
|
+
|
|
78
|
+
type SearchOrder = ReturnType<typeof searchOrder>;
|
|
79
|
+
type CursorPosition = Record<SearchOrder[number]["key"], number | string>;
|
|
80
|
+
|
|
81
|
+
const decodeAddressCursor = (
|
|
82
|
+
cursor: string,
|
|
83
|
+
order: SearchOrder,
|
|
84
|
+
): CursorPosition => {
|
|
85
|
+
const decoded = decodeToken(cursor);
|
|
86
|
+
const position = {} as Record<string, number | string>;
|
|
87
|
+
for (const { key } of order) {
|
|
88
|
+
const value = decoded[key];
|
|
89
|
+
if (typeof value !== "number" && typeof value !== "string") {
|
|
90
|
+
throw new BadRequestError("Invalid continuationToken");
|
|
91
|
+
}
|
|
92
|
+
position[key] = value;
|
|
93
|
+
}
|
|
94
|
+
return position as CursorPosition;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resume after a position in that order: strictly past the first key, or equal
|
|
99
|
+
* on it and strictly past the rest.
|
|
100
|
+
*/
|
|
101
|
+
const after = (order: SearchOrder, position: CursorPosition): SQL => {
|
|
102
|
+
const step = (index: number): SQL => {
|
|
103
|
+
const { key, expr, direction } = order[index];
|
|
104
|
+
const value = position[key];
|
|
105
|
+
const past =
|
|
106
|
+
direction === "desc" ? sql`${expr} < ${value}` : sql`${expr} > ${value}`;
|
|
107
|
+
if (index === order.length - 1) return past;
|
|
108
|
+
return sql`(${past} or (${expr} = ${value} and ${step(index + 1)}))`;
|
|
109
|
+
};
|
|
110
|
+
return step(0);
|
|
46
111
|
};
|
|
47
112
|
|
|
48
113
|
export function rowToAddress(
|
|
@@ -115,6 +180,10 @@ export class AddressRepo implements IAddressRepository {
|
|
|
115
180
|
return rowToAddress(row);
|
|
116
181
|
}
|
|
117
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Message sync sends `""` for a bare address, so a sighting carrying no
|
|
185
|
+
* display name must leave the stored one alone rather than erase it.
|
|
186
|
+
*/
|
|
118
187
|
async upsertAddress(input: CreateAddressInput): Promise<AddressItem> {
|
|
119
188
|
const now = Date.now();
|
|
120
189
|
const [row] = await this.db
|
|
@@ -139,10 +208,13 @@ export class AddressRepo implements IAddressRepository {
|
|
|
139
208
|
})
|
|
140
209
|
.onConflictDoUpdate({
|
|
141
210
|
target: addressTable.addressId,
|
|
142
|
-
set:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
211
|
+
set: input.displayName
|
|
212
|
+
? {
|
|
213
|
+
displayName: input.displayName,
|
|
214
|
+
normalizedCompound: input.normalizedCompound,
|
|
215
|
+
updatedAt: now,
|
|
216
|
+
}
|
|
217
|
+
: { updatedAt: now },
|
|
146
218
|
})
|
|
147
219
|
.returning();
|
|
148
220
|
return rowToAddress(row);
|
|
@@ -192,11 +264,11 @@ export class AddressRepo implements IAddressRepository {
|
|
|
192
264
|
input: UpdateAddressInput,
|
|
193
265
|
): Promise<AddressItem> {
|
|
194
266
|
const now = Date.now();
|
|
195
|
-
const updates:
|
|
196
|
-
|
|
197
|
-
};
|
|
198
|
-
if (input.displayName !== undefined)
|
|
267
|
+
const updates: AddressUpdate = { updatedAt: now };
|
|
268
|
+
if (input.displayName !== undefined) {
|
|
199
269
|
updates.displayName = input.displayName;
|
|
270
|
+
updates.normalizedCompound = compoundOfSql(input.displayName);
|
|
271
|
+
}
|
|
200
272
|
if (input.flags !== undefined) updates.flags = input.flags as never;
|
|
201
273
|
if (input.inboundCount !== undefined)
|
|
202
274
|
updates.inboundCount = input.inboundCount;
|
|
@@ -484,48 +556,46 @@ export class AddressRepo implements IAddressRepository {
|
|
|
484
556
|
limit?: number;
|
|
485
557
|
}): Promise<ResultList<AddressItem>> {
|
|
486
558
|
const { accountConfigId, search, cursor, limit = 100 } = input;
|
|
487
|
-
const
|
|
488
|
-
const
|
|
489
|
-
? {
|
|
490
|
-
normalizedCompound: decoded.normalizedCompound as string,
|
|
491
|
-
addressId: decoded.addressId as string,
|
|
492
|
-
}
|
|
493
|
-
: undefined;
|
|
559
|
+
const order = searchOrder(search);
|
|
560
|
+
const position = cursor ? decodeAddressCursor(cursor, order) : undefined;
|
|
494
561
|
|
|
495
562
|
const rows = await this.db
|
|
496
|
-
.select(
|
|
563
|
+
.select({
|
|
564
|
+
...getTableColumns(addressTable),
|
|
565
|
+
rank: order[0].expr,
|
|
566
|
+
preference: order[1].expr,
|
|
567
|
+
correspondence: order[2].expr,
|
|
568
|
+
recency: order[3].expr,
|
|
569
|
+
})
|
|
497
570
|
.from(addressTable)
|
|
498
571
|
.where(
|
|
499
572
|
and(
|
|
500
573
|
eq(addressTable.accountConfigId, accountConfigId),
|
|
501
|
-
search ?
|
|
502
|
-
after
|
|
503
|
-
? or(
|
|
504
|
-
gt(addressTable.normalizedCompound, after.normalizedCompound),
|
|
505
|
-
and(
|
|
506
|
-
eq(addressTable.normalizedCompound, after.normalizedCompound),
|
|
507
|
-
gt(addressTable.addressId, after.addressId),
|
|
508
|
-
),
|
|
509
|
-
)
|
|
510
|
-
: undefined,
|
|
574
|
+
search ? addressSearchMatch(search) : undefined,
|
|
575
|
+
position ? after(order, position) : undefined,
|
|
511
576
|
),
|
|
512
577
|
)
|
|
513
578
|
.orderBy(
|
|
514
|
-
|
|
515
|
-
|
|
579
|
+
...order.map(({ expr, direction }) =>
|
|
580
|
+
direction === "desc" ? desc(expr) : asc(expr),
|
|
581
|
+
),
|
|
516
582
|
)
|
|
517
583
|
.limit(limit + 1);
|
|
518
584
|
|
|
519
585
|
const hasMore = rows.length > limit;
|
|
520
|
-
const
|
|
521
|
-
const
|
|
586
|
+
const page = rows.slice(0, limit);
|
|
587
|
+
const lastRow = page[page.length - 1];
|
|
522
588
|
return resultList(
|
|
523
|
-
|
|
589
|
+
page.map(rowToAddress),
|
|
524
590
|
limit,
|
|
525
|
-
hasMore &&
|
|
591
|
+
hasMore && lastRow
|
|
526
592
|
? {
|
|
527
|
-
|
|
528
|
-
|
|
593
|
+
rank: lastRow.rank,
|
|
594
|
+
preference: lastRow.preference,
|
|
595
|
+
correspondence: lastRow.correspondence,
|
|
596
|
+
recency: lastRow.recency,
|
|
597
|
+
normalizedCompound: lastRow.normalizedCompound,
|
|
598
|
+
addressId: lastRow.addressId,
|
|
529
599
|
}
|
|
530
600
|
: undefined,
|
|
531
601
|
);
|