@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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 treats LIKE metacharacters in a search term literally", async () => {
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 plain = await repo.createAddress(
247
- makeAddressInput(accountConfigId, "ab@x.com"),
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
- const result = await repo.listByAccountConfig({
314
+ await repo.updateAddress(accountConfigId, created.addressId, {
315
+ displayName: "Özcan Bakker",
316
+ });
317
+
318
+ const found = await repo.listByAccountConfig({
251
319
  accountConfigId,
252
- search: "a_@x.com",
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
- result.items.length,
256
- 0,
257
- "`_` must not act as a single-character wildcard",
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
- await repo.deleteManyAddresses(accountConfigId, [plain.addressId]);
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(
@@ -9,40 +9,105 @@ import type {
9
9
  ResultList,
10
10
  UpdateAddressInput,
11
11
  } from "@remit/data-ports";
12
- import { and, asc, eq, gt, inArray, or, sql } from "drizzle-orm";
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
- * Escape the LIKE metacharacters so a term containing `%` or `_` (both legal in
25
- * an email local part) matches literally instead of as a wildcard.
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 escapeLikeTerm = (term: string): string =>
28
- term.replace(/[\\%_]/g, (char) => `\\${char}`);
51
+ const compoundOfSql = (displayName: string): SQL<string> =>
52
+ sql<string>`trim(${displayName.toLowerCase()} || ' ' || ${addressTable.normalizedEmail})`;
29
53
 
30
54
  /**
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).
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 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
- );
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
- displayName: input.displayName ?? sql`${addressTable.displayName}`,
144
- updatedAt: now,
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: Partial<typeof addressTable.$inferInsert> = {
196
- updatedAt: now,
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 decoded = cursor ? decodeToken(cursor) : undefined;
488
- const after = decoded
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 ? addressSearchPredicate(search) : undefined,
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
- asc(addressTable.normalizedCompound),
515
- asc(addressTable.addressId),
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 items = rows.slice(0, limit).map(rowToAddress);
521
- const lastItem = items[items.length - 1];
586
+ const page = rows.slice(0, limit);
587
+ const lastRow = page[page.length - 1];
522
588
  return resultList(
523
- items,
589
+ page.map(rowToAddress),
524
590
  limit,
525
- hasMore && lastItem
591
+ hasMore && lastRow
526
592
  ? {
527
- normalizedCompound: lastItem.normalizedCompound,
528
- addressId: lastItem.addressId,
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
  );