@remit/drizzle-service 0.0.10 → 0.0.12
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),
|
|
@@ -212,6 +212,76 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
|
|
|
212
212
|
);
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
+
test("listByThread defaults to oldest first, by the date the mail was sent", async () => {
|
|
216
|
+
const acct = "acct-conversation-dates";
|
|
217
|
+
const threadId = "t-conversation-dates";
|
|
218
|
+
const base = Date.now();
|
|
219
|
+
// The folder a message was delivered to decides its internalDate, so a
|
|
220
|
+
// reply synced from Sent can carry an earlier internalDate than the
|
|
221
|
+
// message it answers. sentDate is what the conversation is ordered by.
|
|
222
|
+
const turns = [
|
|
223
|
+
{ messageId: "m-question", sentDate: base, internalDate: base + 5000 },
|
|
224
|
+
{ messageId: "m-answer", sentDate: base + 1000, internalDate: base },
|
|
225
|
+
];
|
|
226
|
+
for (const turn of turns) {
|
|
227
|
+
await repo.create(
|
|
228
|
+
makeInput({
|
|
229
|
+
accountConfigId: acct,
|
|
230
|
+
threadId,
|
|
231
|
+
messageId: turn.messageId,
|
|
232
|
+
sentDate: turn.sentDate,
|
|
233
|
+
internalDate: turn.internalDate,
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const result = await repo.listByThread(threadId, acct, {
|
|
239
|
+
excludeDeleted: true,
|
|
240
|
+
});
|
|
241
|
+
assert.deepEqual(
|
|
242
|
+
result.items.map((item) => item.messageId),
|
|
243
|
+
["m-question", "m-answer"],
|
|
244
|
+
"the reply follows the message it answers",
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("listByThread paginates in sentDate order without skipping or repeating", async () => {
|
|
249
|
+
const acct = "acct-conversation-pages";
|
|
250
|
+
const threadId = "t-conversation-pages";
|
|
251
|
+
const base = Date.now();
|
|
252
|
+
for (let index = 0; index < 5; index++) {
|
|
253
|
+
await repo.create(
|
|
254
|
+
makeInput({
|
|
255
|
+
accountConfigId: acct,
|
|
256
|
+
threadId,
|
|
257
|
+
messageId: `m-page-${index}`,
|
|
258
|
+
sentDate: base + index * 1000,
|
|
259
|
+
internalDate: base,
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const seen: string[] = [];
|
|
265
|
+
let continuationToken: string | undefined;
|
|
266
|
+
do {
|
|
267
|
+
const page = await repo.listByThread(threadId, acct, {
|
|
268
|
+
limit: 2,
|
|
269
|
+
continuationToken,
|
|
270
|
+
excludeDeleted: true,
|
|
271
|
+
});
|
|
272
|
+
seen.push(...page.items.map((item) => item.messageId));
|
|
273
|
+
continuationToken = page.continuationToken;
|
|
274
|
+
} while (continuationToken);
|
|
275
|
+
|
|
276
|
+
assert.deepEqual(seen, [
|
|
277
|
+
"m-page-0",
|
|
278
|
+
"m-page-1",
|
|
279
|
+
"m-page-2",
|
|
280
|
+
"m-page-3",
|
|
281
|
+
"m-page-4",
|
|
282
|
+
]);
|
|
283
|
+
});
|
|
284
|
+
|
|
215
285
|
test("listByThread excludes soft-deleted messages but keeps the rest of the conversation", async () => {
|
|
216
286
|
const acct = "acct-conversation-deleted";
|
|
217
287
|
const threadId = "t-conversation-deleted";
|
|
@@ -56,7 +56,6 @@ export const clampThreadSearchLimit = (limit?: number): number => {
|
|
|
56
56
|
// ─── Cursor ──────────────────────────────────────────────────────────────────
|
|
57
57
|
|
|
58
58
|
type DateCursor = { s: number; id: string };
|
|
59
|
-
type ThreadCursor = { d: number; id: string };
|
|
60
59
|
type AccountCursor = { id: string };
|
|
61
60
|
|
|
62
61
|
function encodeDateCursor(sentDate: number, threadMessageId: string): string {
|
|
@@ -73,23 +72,6 @@ function decodeDateCursor(token: string): DateCursor | null {
|
|
|
73
72
|
}
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
function encodeThreadCursor(
|
|
77
|
-
internalDate: number,
|
|
78
|
-
threadMessageId: string,
|
|
79
|
-
): string {
|
|
80
|
-
return Buffer.from(
|
|
81
|
-
JSON.stringify({ d: internalDate, id: threadMessageId }),
|
|
82
|
-
).toString("base64");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function decodeThreadCursor(token: string): ThreadCursor | null {
|
|
86
|
-
try {
|
|
87
|
-
return JSON.parse(Buffer.from(token, "base64").toString()) as ThreadCursor;
|
|
88
|
-
} catch {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
75
|
function encodeAccountCursor(threadMessageId: string): string {
|
|
94
76
|
return Buffer.from(JSON.stringify({ id: threadMessageId })).toString(
|
|
95
77
|
"base64",
|
|
@@ -563,22 +545,22 @@ export class DrizzleThreadMessageRepository
|
|
|
563
545
|
): Promise<ResultList<ThreadMessageItem>> {
|
|
564
546
|
const order = options?.order ?? "asc";
|
|
565
547
|
const cursor = options?.continuationToken
|
|
566
|
-
?
|
|
548
|
+
? decodeDateCursor(options.continuationToken)
|
|
567
549
|
: null;
|
|
568
550
|
|
|
569
551
|
const cursorCond = cursor
|
|
570
552
|
? order === "asc"
|
|
571
553
|
? or(
|
|
572
|
-
gt(threadMessageTable.
|
|
554
|
+
gt(threadMessageTable.sentDate, cursor.s),
|
|
573
555
|
and(
|
|
574
|
-
eq(threadMessageTable.
|
|
556
|
+
eq(threadMessageTable.sentDate, cursor.s),
|
|
575
557
|
gt(threadMessageTable.threadMessageId, cursor.id),
|
|
576
558
|
),
|
|
577
559
|
)
|
|
578
560
|
: or(
|
|
579
|
-
lt(threadMessageTable.
|
|
561
|
+
lt(threadMessageTable.sentDate, cursor.s),
|
|
580
562
|
and(
|
|
581
|
-
eq(threadMessageTable.
|
|
563
|
+
eq(threadMessageTable.sentDate, cursor.s),
|
|
582
564
|
gt(threadMessageTable.threadMessageId, cursor.id),
|
|
583
565
|
),
|
|
584
566
|
)
|
|
@@ -599,8 +581,8 @@ export class DrizzleThreadMessageRepository
|
|
|
599
581
|
)
|
|
600
582
|
.orderBy(
|
|
601
583
|
order === "asc"
|
|
602
|
-
? asc(threadMessageTable.
|
|
603
|
-
: desc(threadMessageTable.
|
|
584
|
+
? asc(threadMessageTable.sentDate)
|
|
585
|
+
: desc(threadMessageTable.sentDate),
|
|
604
586
|
asc(threadMessageTable.threadMessageId),
|
|
605
587
|
)
|
|
606
588
|
.limit(options?.limit ?? 100);
|
|
@@ -612,7 +594,7 @@ export class DrizzleThreadMessageRepository
|
|
|
612
594
|
items: rows.map(toItem),
|
|
613
595
|
continuationToken:
|
|
614
596
|
hasMore && lastRow
|
|
615
|
-
?
|
|
597
|
+
? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
|
|
616
598
|
: undefined,
|
|
617
599
|
};
|
|
618
600
|
}
|