@remit/drizzle-service 0.0.3 → 0.0.4

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.3",
3
+ "version": "0.0.4",
4
4
  "description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -135,610 +135,589 @@ describe("clampThreadSearchLimit", () => {
135
135
  // These six scenarios mirror the canonical DynamoDB test suite in
136
136
  // packages/remit-electrodb-service/src/models/thread-message.test.ts.
137
137
 
138
- describe(
139
- "DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox",
140
- { skip: !RUN_INTEG },
141
- () => {
142
- let repo: DrizzleThreadMessageRepository;
143
- const cleanup: Array<() => Promise<void>> = [];
144
-
145
- before(async () => {
146
- await setupDb();
147
- repo = new DrizzleThreadMessageRepository(PG_URL);
148
- });
149
-
150
- after(async () => {
151
- for (const fn of cleanup.reverse()) {
152
- await fn();
153
- }
154
- await repo.close();
155
- });
138
+ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox", {
139
+ skip: !RUN_INTEG,
140
+ }, () => {
141
+ let repo: DrizzleThreadMessageRepository;
142
+ const cleanup: Array<() => Promise<void>> = [];
143
+
144
+ before(async () => {
145
+ await setupDb();
146
+ repo = new DrizzleThreadMessageRepository(PG_URL);
147
+ });
156
148
 
157
- async function seed(
158
- accountConfigId: string,
159
- mailboxId: string,
160
- rows: Array<Partial<CreateThreadMessageInput>>,
161
- ): Promise<void> {
162
- for (const overrides of rows) {
163
- const created = await repo.create(
164
- makeInput(accountConfigId, mailboxId, overrides),
165
- );
166
- cleanup.push(() =>
167
- repo.delete(accountConfigId, created.threadMessageId),
168
- );
169
- }
149
+ after(async () => {
150
+ for (const fn of cleanup.reverse()) {
151
+ await fn();
170
152
  }
153
+ await repo.close();
154
+ });
171
155
 
172
- // ── Scenario 1 ────────────────────────────────────────────────────────────
173
- // The unread boolean narrows results to matching rows only.
174
- test("unread filter returns only matching rows", async () => {
175
- const acct = uuid();
176
- const mbx = uuid();
177
- await seed(acct, mbx, [
178
- { isRead: false, subject: "unread one" },
179
- { isRead: false, subject: "unread two" },
180
- { isRead: true, subject: "read one" },
181
- ]);
182
-
183
- const unread = await repo.searchByMailboxWindow(
184
- acct,
185
- mbx,
186
- { unread: true },
187
- { excludeDeleted: true },
188
- );
189
- assert.equal(unread.items.length, 2);
190
- assert.ok(unread.items.every((r) => r.isRead === false));
191
-
192
- const read = await repo.searchByMailboxWindow(
193
- acct,
194
- mbx,
195
- { unread: false },
196
- { excludeDeleted: true },
197
- );
198
- assert.equal(read.items.length, 1);
199
- assert.equal(read.items[0].subject, "read one");
200
- });
201
-
202
- // ── Scenario 2 ────────────────────────────────────────────────────────────
203
- // subject filter matches a substring of the subject.
204
- test("subject filter matches a substring", async () => {
205
- const acct = uuid();
206
- const mbx = uuid();
207
- await seed(acct, mbx, [
208
- { subject: "invoice March" },
209
- { subject: "invoice April" },
210
- { subject: "newsletter" },
211
- ]);
212
-
213
- const result = await repo.searchByMailboxWindow(
214
- acct,
215
- mbx,
216
- { subject: "invoice" },
217
- { excludeDeleted: true },
218
- );
219
- assert.equal(result.items.length, 2);
220
- assert.ok(
221
- result.items.every((r) => r.subject?.includes("invoice") ?? false),
222
- );
223
- });
224
-
225
- // ── Scenario 3 ────────────────────────────────────────────────────────────
226
- // A full page (limit 2 over 5 rows) yields a cursor to resume from.
227
- test("a full page yields a continuation cursor", async () => {
228
- const acct = uuid();
229
- const mbx = uuid();
230
- const now = Date.now();
231
- await seed(
232
- acct,
233
- mbx,
234
- Array.from({ length: 5 }, (_, i) => ({
235
- subject: `row ${i}`,
236
- sentDate: now - i,
237
- internalDate: now - i,
238
- })),
239
- );
240
-
241
- const result = await repo.searchByMailboxWindow(
242
- acct,
243
- mbx,
244
- {},
245
- { excludeDeleted: true, limit: 2 },
246
- );
247
- assert.equal(result.items.length, 2);
248
- assert.ok(result.continuationToken, "full page must yield a cursor");
249
- });
250
-
251
- // ── Scenario 4 ────────────────────────────────────────────────────────────
252
- // Matching runs over the WHOLE mailbox via the trigram index, so an old
253
- // match well behind the recent rows is still found and paged (the DynamoDB
254
- // recent-window bound does not apply on Postgres — the improvement over #443
255
- // on the DDB path).
256
- test("an old match behind the recent rows is still found — matching is whole-mailbox, not window-bounded", async () => {
257
- const acct = uuid();
258
- const mbx = uuid();
259
- const now = Date.now();
260
- // The newest 2 rows do NOT match; the older 4 rows DO — searching "match"
261
- // (lowercased, as the client sends it) must reach all four.
262
- const rows = [
263
- { subject: "recent plain a", sentDate: now, internalDate: now },
264
- { subject: "recent plain b", sentDate: now - 1, internalDate: now - 1 },
265
- { subject: "old MATCH c", sentDate: now - 2, internalDate: now - 2 },
266
- { subject: "old MATCH d", sentDate: now - 3, internalDate: now - 3 },
267
- { subject: "old MATCH e", sentDate: now - 4, internalDate: now - 4 },
268
- { subject: "old MATCH f", sentDate: now - 5, internalDate: now - 5 },
269
- ];
270
- await seed(acct, mbx, rows);
271
-
272
- const page1 = await repo.searchByMailboxWindow(
273
- acct,
274
- mbx,
275
- { subject: "match" },
276
- { excludeDeleted: true, limit: 2 },
277
- );
278
- assert.deepEqual(
279
- page1.items.map((r) => r.subject),
280
- ["old MATCH c", "old MATCH d"],
281
- "first page holds the two newest matches, not the recent non-matches",
282
- );
283
- assert.ok(
284
- page1.continuationToken,
285
- "more matches remain — cursor expected",
286
- );
287
-
288
- const page2 = await repo.searchByMailboxWindow(
289
- acct,
290
- mbx,
291
- { subject: "match" },
292
- {
293
- excludeDeleted: true,
294
- limit: 2,
295
- continuationToken: page1.continuationToken,
296
- },
297
- );
298
- assert.deepEqual(
299
- page2.items.map((r) => r.subject),
300
- ["old MATCH e", "old MATCH f"],
301
- "paging reaches the oldest matches",
302
- );
303
-
304
- const count = await repo.countByMailbox(
305
- acct,
306
- mbx,
307
- { subject: "match" },
308
- { excludeDeleted: true, limit: 2 },
309
- );
310
- assert.equal(
311
- count,
312
- 2,
313
- "count is capped at the page limit when more match",
156
+ async function seed(
157
+ accountConfigId: string,
158
+ mailboxId: string,
159
+ rows: Array<Partial<CreateThreadMessageInput>>,
160
+ ): Promise<void> {
161
+ for (const overrides of rows) {
162
+ const created = await repo.create(
163
+ makeInput(accountConfigId, mailboxId, overrides),
314
164
  );
315
- });
165
+ cleanup.push(() => repo.delete(accountConfigId, created.threadMessageId));
166
+ }
167
+ }
168
+
169
+ // ── Scenario 1 ────────────────────────────────────────────────────────────
170
+ // The unread boolean narrows results to matching rows only.
171
+ test("unread filter returns only matching rows", async () => {
172
+ const acct = uuid();
173
+ const mbx = uuid();
174
+ await seed(acct, mbx, [
175
+ { isRead: false, subject: "unread one" },
176
+ { isRead: false, subject: "unread two" },
177
+ { isRead: true, subject: "read one" },
178
+ ]);
179
+
180
+ const unread = await repo.searchByMailboxWindow(
181
+ acct,
182
+ mbx,
183
+ { unread: true },
184
+ { excludeDeleted: true },
185
+ );
186
+ assert.equal(unread.items.length, 2);
187
+ assert.ok(unread.items.every((r) => r.isRead === false));
188
+
189
+ const read = await repo.searchByMailboxWindow(
190
+ acct,
191
+ mbx,
192
+ { unread: false },
193
+ { excludeDeleted: true },
194
+ );
195
+ assert.equal(read.items.length, 1);
196
+ assert.equal(read.items[0].subject, "read one");
197
+ });
316
198
 
317
- // ── Scenario 5 ────────────────────────────────────────────────────────────
318
- // desc default: the first page holds the newest matches; count equals the
319
- // page length when more rows match than the limit.
320
- test("desc page holds the newest matches and count equals items.length", async () => {
321
- const acct = uuid();
322
- const mbx = uuid();
323
- const now = Date.now();
324
- await seed(
325
- acct,
326
- mbx,
327
- Array.from({ length: 5 }, (_, i) => ({
328
- subject: `alpha ${i}`,
329
- sentDate: now - i,
330
- internalDate: now - i,
331
- })),
332
- );
199
+ // ── Scenario 2 ────────────────────────────────────────────────────────────
200
+ // subject filter matches a substring of the subject.
201
+ test("subject filter matches a substring", async () => {
202
+ const acct = uuid();
203
+ const mbx = uuid();
204
+ await seed(acct, mbx, [
205
+ { subject: "invoice March" },
206
+ { subject: "invoice April" },
207
+ { subject: "newsletter" },
208
+ ]);
209
+
210
+ const result = await repo.searchByMailboxWindow(
211
+ acct,
212
+ mbx,
213
+ { subject: "invoice" },
214
+ { excludeDeleted: true },
215
+ );
216
+ assert.equal(result.items.length, 2);
217
+ assert.ok(
218
+ result.items.every((r) => r.subject?.includes("invoice") ?? false),
219
+ );
220
+ });
333
221
 
334
- const window = await repo.searchByMailboxWindow(
335
- acct,
336
- mbx,
337
- { subject: "alpha" },
338
- { excludeDeleted: true, limit: 2 },
339
- );
340
- assert.equal(window.items.length, 2);
341
- const dates = window.items.map((r) => r.sentDate).sort((a, b) => b - a);
342
- assert.deepEqual(dates, [now, now - 1], "page holds the two newest rows");
343
-
344
- const count = await repo.countByMailbox(
345
- acct,
346
- mbx,
347
- { subject: "alpha" },
348
- { excludeDeleted: true, limit: 2 },
349
- );
350
- assert.equal(count, window.items.length, "count == items.length");
351
- assert.equal(count, 2);
352
- });
222
+ // ── Scenario 3 ────────────────────────────────────────────────────────────
223
+ // A full page (limit 2 over 5 rows) yields a cursor to resume from.
224
+ test("a full page yields a continuation cursor", async () => {
225
+ const acct = uuid();
226
+ const mbx = uuid();
227
+ const now = Date.now();
228
+ await seed(
229
+ acct,
230
+ mbx,
231
+ Array.from({ length: 5 }, (_, i) => ({
232
+ subject: `row ${i}`,
233
+ sentDate: now - i,
234
+ internalDate: now - i,
235
+ })),
236
+ );
237
+
238
+ const result = await repo.searchByMailboxWindow(
239
+ acct,
240
+ mbx,
241
+ {},
242
+ { excludeDeleted: true, limit: 2 },
243
+ );
244
+ assert.equal(result.items.length, 2);
245
+ assert.ok(result.continuationToken, "full page must yield a cursor");
246
+ });
353
247
 
354
- // ── Scenario 6 ────────────────────────────────────────────────────────────
355
- // order:asc returns the OLDEST matches first; count agrees with the page.
356
- test("order:asc returns the oldest matches first", async () => {
357
- const acct = uuid();
358
- const mbx = uuid();
359
- const now = Date.now();
360
- await seed(
361
- acct,
362
- mbx,
363
- Array.from({ length: 5 }, (_, i) => ({
364
- subject: `beta ${i}`,
365
- sentDate: now - i,
366
- internalDate: now - i,
367
- })),
368
- );
248
+ // ── Scenario 4 ────────────────────────────────────────────────────────────
249
+ // Matching runs over the WHOLE mailbox via the trigram index, so an old
250
+ // match well behind the recent rows is still found and paged (the DynamoDB
251
+ // recent-window bound does not apply on Postgres — the improvement over #443
252
+ // on the DDB path).
253
+ test("an old match behind the recent rows is still found — matching is whole-mailbox, not window-bounded", async () => {
254
+ const acct = uuid();
255
+ const mbx = uuid();
256
+ const now = Date.now();
257
+ // The newest 2 rows do NOT match; the older 4 rows DO — searching "match"
258
+ // (lowercased, as the client sends it) must reach all four.
259
+ const rows = [
260
+ { subject: "recent plain a", sentDate: now, internalDate: now },
261
+ { subject: "recent plain b", sentDate: now - 1, internalDate: now - 1 },
262
+ { subject: "old MATCH c", sentDate: now - 2, internalDate: now - 2 },
263
+ { subject: "old MATCH d", sentDate: now - 3, internalDate: now - 3 },
264
+ { subject: "old MATCH e", sentDate: now - 4, internalDate: now - 4 },
265
+ { subject: "old MATCH f", sentDate: now - 5, internalDate: now - 5 },
266
+ ];
267
+ await seed(acct, mbx, rows);
268
+
269
+ const page1 = await repo.searchByMailboxWindow(
270
+ acct,
271
+ mbx,
272
+ { subject: "match" },
273
+ { excludeDeleted: true, limit: 2 },
274
+ );
275
+ assert.deepEqual(
276
+ page1.items.map((r) => r.subject),
277
+ ["old MATCH c", "old MATCH d"],
278
+ "first page holds the two newest matches, not the recent non-matches",
279
+ );
280
+ assert.ok(page1.continuationToken, "more matches remain — cursor expected");
281
+
282
+ const page2 = await repo.searchByMailboxWindow(
283
+ acct,
284
+ mbx,
285
+ { subject: "match" },
286
+ {
287
+ excludeDeleted: true,
288
+ limit: 2,
289
+ continuationToken: page1.continuationToken,
290
+ },
291
+ );
292
+ assert.deepEqual(
293
+ page2.items.map((r) => r.subject),
294
+ ["old MATCH e", "old MATCH f"],
295
+ "paging reaches the oldest matches",
296
+ );
297
+
298
+ const count = await repo.countByMailbox(
299
+ acct,
300
+ mbx,
301
+ { subject: "match" },
302
+ { excludeDeleted: true, limit: 2 },
303
+ );
304
+ assert.equal(count, 2, "count is capped at the page limit when more match");
305
+ });
369
306
 
370
- const window = await repo.searchByMailboxWindow(
371
- acct,
372
- mbx,
373
- { subject: "beta" },
374
- { excludeDeleted: true, limit: 2, order: "asc" },
375
- );
376
- assert.equal(window.items.length, 2);
377
- const dates = window.items.map((r) => r.sentDate).sort((a, b) => a - b);
378
- assert.deepEqual(
379
- dates,
380
- [now - 4, now - 3],
381
- "asc page holds the two oldest rows",
382
- );
307
+ // ── Scenario 5 ────────────────────────────────────────────────────────────
308
+ // desc default: the first page holds the newest matches; count equals the
309
+ // page length when more rows match than the limit.
310
+ test("desc page holds the newest matches and count equals items.length", async () => {
311
+ const acct = uuid();
312
+ const mbx = uuid();
313
+ const now = Date.now();
314
+ await seed(
315
+ acct,
316
+ mbx,
317
+ Array.from({ length: 5 }, (_, i) => ({
318
+ subject: `alpha ${i}`,
319
+ sentDate: now - i,
320
+ internalDate: now - i,
321
+ })),
322
+ );
323
+
324
+ const window = await repo.searchByMailboxWindow(
325
+ acct,
326
+ mbx,
327
+ { subject: "alpha" },
328
+ { excludeDeleted: true, limit: 2 },
329
+ );
330
+ assert.equal(window.items.length, 2);
331
+ const dates = window.items.map((r) => r.sentDate).sort((a, b) => b - a);
332
+ assert.deepEqual(dates, [now, now - 1], "page holds the two newest rows");
333
+
334
+ const count = await repo.countByMailbox(
335
+ acct,
336
+ mbx,
337
+ { subject: "alpha" },
338
+ { excludeDeleted: true, limit: 2 },
339
+ );
340
+ assert.equal(count, window.items.length, "count == items.length");
341
+ assert.equal(count, 2);
342
+ });
383
343
 
384
- const count = await repo.countByMailbox(
385
- acct,
386
- mbx,
387
- { subject: "beta" },
388
- { excludeDeleted: true, limit: 2, order: "asc" },
389
- );
390
- assert.equal(count, window.items.length, "count == items.length for asc");
391
- assert.equal(count, 2);
392
- });
393
- },
394
- );
344
+ // ── Scenario 6 ────────────────────────────────────────────────────────────
345
+ // order:asc returns the OLDEST matches first; count agrees with the page.
346
+ test("order:asc returns the oldest matches first", async () => {
347
+ const acct = uuid();
348
+ const mbx = uuid();
349
+ const now = Date.now();
350
+ await seed(
351
+ acct,
352
+ mbx,
353
+ Array.from({ length: 5 }, (_, i) => ({
354
+ subject: `beta ${i}`,
355
+ sentDate: now - i,
356
+ internalDate: now - i,
357
+ })),
358
+ );
359
+
360
+ const window = await repo.searchByMailboxWindow(
361
+ acct,
362
+ mbx,
363
+ { subject: "beta" },
364
+ { excludeDeleted: true, limit: 2, order: "asc" },
365
+ );
366
+ assert.equal(window.items.length, 2);
367
+ const dates = window.items.map((r) => r.sentDate).sort((a, b) => a - b);
368
+ assert.deepEqual(
369
+ dates,
370
+ [now - 4, now - 3],
371
+ "asc page holds the two oldest rows",
372
+ );
373
+
374
+ const count = await repo.countByMailbox(
375
+ acct,
376
+ mbx,
377
+ { subject: "beta" },
378
+ { excludeDeleted: true, limit: 2, order: "asc" },
379
+ );
380
+ assert.equal(count, window.items.length, "count == items.length for asc");
381
+ assert.equal(count, 2);
382
+ });
383
+ });
395
384
 
396
385
  // ─── Native text-search semantics ─────────────────────────────────────────────
397
386
  // The type-ahead search box lowercases the query before sending it. These tests
398
387
  // pin the Postgres-native behaviour: case- and accent-insensitive substring
399
388
  // matching over the whole mailbox, scoped to the account/mailbox.
400
389
 
401
- describe(
402
- "DrizzleThreadMessageRepository — native text search",
403
- { skip: !RUN_INTEG },
404
- () => {
405
- let repo: DrizzleThreadMessageRepository;
406
- const cleanup: Array<() => Promise<void>> = [];
390
+ describe("DrizzleThreadMessageRepository — native text search", {
391
+ skip: !RUN_INTEG,
392
+ }, () => {
393
+ let repo: DrizzleThreadMessageRepository;
394
+ const cleanup: Array<() => Promise<void>> = [];
407
395
 
408
- before(async () => {
409
- await setupDb();
410
- repo = new DrizzleThreadMessageRepository(PG_URL);
411
- });
412
-
413
- after(async () => {
414
- for (const fn of cleanup.reverse()) {
415
- await fn();
416
- }
417
- await repo.close();
418
- });
396
+ before(async () => {
397
+ await setupDb();
398
+ repo = new DrizzleThreadMessageRepository(PG_URL);
399
+ });
419
400
 
420
- async function seed(
421
- accountConfigId: string,
422
- mailboxId: string,
423
- rows: Array<Partial<CreateThreadMessageInput>>,
424
- ): Promise<void> {
425
- for (const overrides of rows) {
426
- const created = await repo.create(
427
- makeInput(accountConfigId, mailboxId, overrides),
428
- );
429
- cleanup.push(() =>
430
- repo.delete(accountConfigId, created.threadMessageId),
431
- );
432
- }
401
+ after(async () => {
402
+ for (const fn of cleanup.reverse()) {
403
+ await fn();
433
404
  }
405
+ await repo.close();
406
+ });
434
407
 
435
- const subjectsOf = (r: { items: Array<{ subject?: string }> }) =>
436
- r.items.map((i) => i.subject).sort();
437
-
438
- test("subject match is case-insensitive (client lowercases the query)", async () => {
439
- const acct = uuid();
440
- const mbx = uuid();
441
- await seed(acct, mbx, [
442
- { subject: "Quarterly Invoice" },
443
- { subject: "invoice reminder" },
444
- { subject: "Newsletter" },
445
- ]);
446
-
447
- const res = await repo.searchByMailboxWindow(acct, mbx, {
448
- query: "invoice",
449
- });
450
- assert.deepEqual(subjectsOf(res), [
451
- "Quarterly Invoice",
452
- "invoice reminder",
453
- ]);
408
+ async function seed(
409
+ accountConfigId: string,
410
+ mailboxId: string,
411
+ rows: Array<Partial<CreateThreadMessageInput>>,
412
+ ): Promise<void> {
413
+ for (const overrides of rows) {
414
+ const created = await repo.create(
415
+ makeInput(accountConfigId, mailboxId, overrides),
416
+ );
417
+ cleanup.push(() => repo.delete(accountConfigId, created.threadMessageId));
418
+ }
419
+ }
420
+
421
+ const subjectsOf = (r: { items: Array<{ subject?: string }> }) =>
422
+ r.items.map((i) => i.subject).sort();
423
+
424
+ test("subject match is case-insensitive (client lowercases the query)", async () => {
425
+ const acct = uuid();
426
+ const mbx = uuid();
427
+ await seed(acct, mbx, [
428
+ { subject: "Quarterly Invoice" },
429
+ { subject: "invoice reminder" },
430
+ { subject: "Newsletter" },
431
+ ]);
432
+
433
+ const res = await repo.searchByMailboxWindow(acct, mbx, {
434
+ query: "invoice",
454
435
  });
436
+ assert.deepEqual(subjectsOf(res), [
437
+ "Quarterly Invoice",
438
+ "invoice reminder",
439
+ ]);
440
+ });
455
441
 
456
- test("sender name match is case-insensitive and accent-insensitive", async () => {
457
- const acct = uuid();
458
- const mbx = uuid();
459
- await seed(acct, mbx, [
460
- { fromName: "Angélique Müller", subject: "a" },
461
- { fromName: "Someone Else", subject: "b" },
462
- ]);
463
-
464
- const accented = await repo.searchByMailboxWindow(acct, mbx, {
465
- query: "angelique",
466
- });
467
- assert.deepEqual(
468
- subjectsOf(accented),
469
- ["a"],
470
- "accent-folded name matches",
471
- );
442
+ test("sender name match is case-insensitive and accent-insensitive", async () => {
443
+ const acct = uuid();
444
+ const mbx = uuid();
445
+ await seed(acct, mbx, [
446
+ { fromName: "Angélique Müller", subject: "a" },
447
+ { fromName: "Someone Else", subject: "b" },
448
+ ]);
472
449
 
473
- const upper = await repo.searchByMailboxWindow(acct, mbx, {
474
- query: "muller",
475
- });
476
- assert.deepEqual(subjectsOf(upper), ["a"], "case-folded name matches");
450
+ const accented = await repo.searchByMailboxWindow(acct, mbx, {
451
+ query: "angelique",
477
452
  });
453
+ assert.deepEqual(subjectsOf(accented), ["a"], "accent-folded name matches");
478
454
 
479
- test("from filter matches the email address as well as the display name", async () => {
480
- const acct = uuid();
481
- const mbx = uuid();
482
- await seed(acct, mbx, [
483
- { fromName: "Jane Doe", fromEmail: "jane@acme.example", subject: "a" },
484
- { fromName: "Bob", fromEmail: "bob@other.example", subject: "b" },
485
- ]);
486
-
487
- const byDomain = await repo.searchByMailboxWindow(acct, mbx, {
488
- from: "acme",
489
- });
490
- assert.deepEqual(subjectsOf(byDomain), ["a"], "matches the email domain");
455
+ const upper = await repo.searchByMailboxWindow(acct, mbx, {
456
+ query: "muller",
491
457
  });
458
+ assert.deepEqual(subjectsOf(upper), ["a"], "case-folded name matches");
459
+ });
492
460
 
493
- test("multi-word query ANDs tokens across the subject and from fields", async () => {
494
- const acct = uuid();
495
- const mbx = uuid();
496
- await seed(acct, mbx, [
497
- { fromName: "Emma Stone", subject: "Sleep schedule" },
498
- { fromName: "Emma Watson", subject: "Travel plans" },
499
- { subject: "Emma Sleep mattress order" },
500
- ]);
501
-
502
- // "emma sleep": each token must appear in subject OR from — row 1 (Emma in
503
- // from, Sleep in subject) and row 3 (both in subject) match; row 2 does not.
504
- const res = await repo.searchByMailboxWindow(acct, mbx, {
505
- query: "emma sleep",
506
- });
507
- assert.deepEqual(subjectsOf(res), [
508
- "Emma Sleep mattress order",
509
- "Sleep schedule",
510
- ]);
511
- });
461
+ test("from filter matches the email address as well as the display name", async () => {
462
+ const acct = uuid();
463
+ const mbx = uuid();
464
+ await seed(acct, mbx, [
465
+ { fromName: "Jane Doe", fromEmail: "jane@acme.example", subject: "a" },
466
+ { fromName: "Bob", fromEmail: "bob@other.example", subject: "b" },
467
+ ]);
512
468
 
513
- test("subject filter does not match text that only appears in the from fields", async () => {
514
- const acct = uuid();
515
- const mbx = uuid();
516
- await seed(acct, mbx, [
517
- { fromName: "Marketing", subject: "Weekly digest" },
518
- { subject: "Marketing update" },
519
- ]);
520
-
521
- const res = await repo.searchByMailboxWindow(acct, mbx, {
522
- subject: "marketing",
523
- });
524
- assert.deepEqual(subjectsOf(res), ["Marketing update"]);
469
+ const byDomain = await repo.searchByMailboxWindow(acct, mbx, {
470
+ from: "acme",
525
471
  });
472
+ assert.deepEqual(subjectsOf(byDomain), ["a"], "matches the email domain");
473
+ });
526
474
 
527
- test("results stay scoped to the account and mailbox", async () => {
528
- const acctA = uuid();
529
- const acctB = uuid();
530
- const mbx1 = uuid();
531
- const mbx2 = uuid();
532
- await seed(acctA, mbx1, [{ subject: "shared token here" }]);
533
- await seed(acctB, mbx1, [{ subject: "shared token elsewhere" }]);
534
- await seed(acctA, mbx2, [{ subject: "shared token other mailbox" }]);
535
-
536
- const res = await repo.searchByMailboxWindow(acctA, mbx1, {
537
- query: "shared",
538
- });
539
- assert.deepEqual(subjectsOf(res), ["shared token here"]);
475
+ test("multi-word query ANDs tokens across the subject and from fields", async () => {
476
+ const acct = uuid();
477
+ const mbx = uuid();
478
+ await seed(acct, mbx, [
479
+ { fromName: "Emma Stone", subject: "Sleep schedule" },
480
+ { fromName: "Emma Watson", subject: "Travel plans" },
481
+ { subject: "Emma Sleep mattress order" },
482
+ ]);
483
+
484
+ // "emma sleep": each token must appear in subject OR from — row 1 (Emma in
485
+ // from, Sleep in subject) and row 3 (both in subject) match; row 2 does not.
486
+ const res = await repo.searchByMailboxWindow(acct, mbx, {
487
+ query: "emma sleep",
540
488
  });
489
+ assert.deepEqual(subjectsOf(res), [
490
+ "Emma Sleep mattress order",
491
+ "Sleep schedule",
492
+ ]);
493
+ });
541
494
 
542
- test("LIKE metacharacters in the query match literally", async () => {
543
- const acct = uuid();
544
- const mbx = uuid();
545
- await seed(acct, mbx, [
546
- { subject: "50% off today" },
547
- { subject: "50 percent off" },
548
- ]);
495
+ test("subject filter does not match text that only appears in the from fields", async () => {
496
+ const acct = uuid();
497
+ const mbx = uuid();
498
+ await seed(acct, mbx, [
499
+ { fromName: "Marketing", subject: "Weekly digest" },
500
+ { subject: "Marketing update" },
501
+ ]);
549
502
 
550
- const res = await repo.searchByMailboxWindow(acct, mbx, { query: "50%" });
551
- assert.deepEqual(subjectsOf(res), ["50% off today"]);
503
+ const res = await repo.searchByMailboxWindow(acct, mbx, {
504
+ subject: "marketing",
552
505
  });
506
+ assert.deepEqual(subjectsOf(res), ["Marketing update"]);
507
+ });
553
508
 
554
- test("whole-mailbox recall: a match far behind the recent rows is found", async () => {
555
- const acct = uuid();
556
- const mbx = uuid();
557
- const now = Date.now();
558
- const rows = Array.from({ length: 40 }, (_, i) => ({
559
- subject: i === 0 ? "needle in the haystack" : `filler ${i}`,
560
- sentDate: now - i,
561
- internalDate: now - i,
562
- }));
563
- // Put the only match at the OLDEST position.
564
- rows[0].subject = "filler 0";
565
- rows[39].subject = "needle in the haystack";
566
- await seed(acct, mbx, rows);
567
-
568
- const res = await repo.searchByMailboxWindow(
569
- acct,
570
- mbx,
571
- { query: "needle" },
572
- { limit: 5 },
573
- );
574
- assert.deepEqual(subjectsOf(res), ["needle in the haystack"]);
509
+ test("results stay scoped to the account and mailbox", async () => {
510
+ const acctA = uuid();
511
+ const acctB = uuid();
512
+ const mbx1 = uuid();
513
+ const mbx2 = uuid();
514
+ await seed(acctA, mbx1, [{ subject: "shared token here" }]);
515
+ await seed(acctB, mbx1, [{ subject: "shared token elsewhere" }]);
516
+ await seed(acctA, mbx2, [{ subject: "shared token other mailbox" }]);
517
+
518
+ const res = await repo.searchByMailboxWindow(acctA, mbx1, {
519
+ query: "shared",
575
520
  });
576
- },
577
- );
521
+ assert.deepEqual(subjectsOf(res), ["shared token here"]);
522
+ });
578
523
 
579
- // ─── Smoke tests for the full interface ──────────────────────────────────────
524
+ test("LIKE metacharacters in the query match literally", async () => {
525
+ const acct = uuid();
526
+ const mbx = uuid();
527
+ await seed(acct, mbx, [
528
+ { subject: "50% off today" },
529
+ { subject: "50 percent off" },
530
+ ]);
580
531
 
581
- describe(
582
- "DrizzleThreadMessageRepository core CRUD",
583
- { skip: !RUN_INTEG },
584
- () => {
585
- let repo: DrizzleThreadMessageRepository;
586
- const cleanup: Array<() => Promise<void>> = [];
532
+ const res = await repo.searchByMailboxWindow(acct, mbx, { query: "50%" });
533
+ assert.deepEqual(subjectsOf(res), ["50% off today"]);
534
+ });
587
535
 
588
- before(async () => {
589
- await setupDb();
590
- repo = new DrizzleThreadMessageRepository(PG_URL);
591
- });
536
+ test("whole-mailbox recall: a match far behind the recent rows is found", async () => {
537
+ const acct = uuid();
538
+ const mbx = uuid();
539
+ const now = Date.now();
540
+ const rows = Array.from({ length: 40 }, (_, i) => ({
541
+ subject: i === 0 ? "needle in the haystack" : `filler ${i}`,
542
+ sentDate: now - i,
543
+ internalDate: now - i,
544
+ }));
545
+ // Put the only match at the OLDEST position.
546
+ rows[0].subject = "filler 0";
547
+ rows[39].subject = "needle in the haystack";
548
+ await seed(acct, mbx, rows);
549
+
550
+ const res = await repo.searchByMailboxWindow(
551
+ acct,
552
+ mbx,
553
+ { query: "needle" },
554
+ { limit: 5 },
555
+ );
556
+ assert.deepEqual(subjectsOf(res), ["needle in the haystack"]);
557
+ });
558
+ });
592
559
 
593
- after(async () => {
594
- for (const fn of cleanup.reverse()) {
595
- await fn();
596
- }
597
- await repo.close();
598
- });
560
+ // ─── Smoke tests for the full interface ──────────────────────────────────────
599
561
 
600
- test("create and get round-trip", async () => {
601
- const acct = uuid();
602
- const mbx = uuid();
603
- const created = await repo.create(
604
- makeInput(acct, mbx, { subject: "hello" }),
605
- );
606
- cleanup.push(() => repo.delete(acct, created.threadMessageId));
607
- assert.ok(created.threadMessageId);
608
- assert.equal(created.subject, "hello");
562
+ describe("DrizzleThreadMessageRepository core CRUD", {
563
+ skip: !RUN_INTEG,
564
+ }, () => {
565
+ let repo: DrizzleThreadMessageRepository;
566
+ const cleanup: Array<() => Promise<void>> = [];
609
567
 
610
- const fetched = await repo.get(acct, created.threadMessageId);
611
- assert.equal(fetched.threadMessageId, created.threadMessageId);
612
- });
568
+ before(async () => {
569
+ await setupDb();
570
+ repo = new DrizzleThreadMessageRepository(PG_URL);
571
+ });
613
572
 
614
- test("update persists", async () => {
615
- const acct = uuid();
616
- const mbx = uuid();
617
- const created = await repo.create(
618
- makeInput(acct, mbx, { subject: "original" }),
619
- );
620
- cleanup.push(() => repo.delete(acct, created.threadMessageId));
573
+ after(async () => {
574
+ for (const fn of cleanup.reverse()) {
575
+ await fn();
576
+ }
577
+ await repo.close();
578
+ });
621
579
 
622
- const updated = await repo.update(acct, created.threadMessageId, {
623
- subject: "updated",
624
- });
625
- assert.equal(updated.subject, "updated");
626
- });
580
+ test("create and get round-trip", async () => {
581
+ const acct = uuid();
582
+ const mbx = uuid();
583
+ const created = await repo.create(
584
+ makeInput(acct, mbx, { subject: "hello" }),
585
+ );
586
+ cleanup.push(() => repo.delete(acct, created.threadMessageId));
587
+ assert.ok(created.threadMessageId);
588
+ assert.equal(created.subject, "hello");
589
+
590
+ const fetched = await repo.get(acct, created.threadMessageId);
591
+ assert.equal(fetched.threadMessageId, created.threadMessageId);
592
+ });
627
593
 
628
- test("delete removes the row", async () => {
629
- const acct = uuid();
630
- const mbx = uuid();
631
- const created = await repo.create(makeInput(acct, mbx));
632
- await repo.delete(acct, created.threadMessageId);
633
- await assert.rejects(
634
- () => repo.get(acct, created.threadMessageId),
635
- /not found/i,
636
- );
594
+ test("update persists", async () => {
595
+ const acct = uuid();
596
+ const mbx = uuid();
597
+ const created = await repo.create(
598
+ makeInput(acct, mbx, { subject: "original" }),
599
+ );
600
+ cleanup.push(() => repo.delete(acct, created.threadMessageId));
601
+
602
+ const updated = await repo.update(acct, created.threadMessageId, {
603
+ subject: "updated",
637
604
  });
605
+ assert.equal(updated.subject, "updated");
606
+ });
638
607
 
639
- test("listByMailbox returns desc-ordered rows for the mailbox", async () => {
640
- const acct = uuid();
641
- const mbx = uuid();
642
- const now = Date.now();
643
- const a = await repo.create(
644
- makeInput(acct, mbx, {
645
- sentDate: now - 1000,
646
- internalDate: now - 1000,
647
- subject: "older",
648
- }),
649
- );
650
- cleanup.push(() => repo.delete(acct, a.threadMessageId));
651
- const b = await repo.create(
652
- makeInput(acct, mbx, {
653
- sentDate: now,
654
- internalDate: now,
655
- subject: "newer",
656
- }),
657
- );
658
- cleanup.push(() => repo.delete(acct, b.threadMessageId));
608
+ test("delete removes the row", async () => {
609
+ const acct = uuid();
610
+ const mbx = uuid();
611
+ const created = await repo.create(makeInput(acct, mbx));
612
+ await repo.delete(acct, created.threadMessageId);
613
+ await assert.rejects(
614
+ () => repo.get(acct, created.threadMessageId),
615
+ /not found/i,
616
+ );
617
+ });
659
618
 
660
- const result = await repo.listByMailbox(acct, mbx);
661
- assert.equal(result.items.length, 2);
662
- assert.equal(result.items[0].subject, "newer");
663
- assert.equal(result.items[1].subject, "older");
664
- });
619
+ test("listByMailbox returns desc-ordered rows for the mailbox", async () => {
620
+ const acct = uuid();
621
+ const mbx = uuid();
622
+ const now = Date.now();
623
+ const a = await repo.create(
624
+ makeInput(acct, mbx, {
625
+ sentDate: now - 1000,
626
+ internalDate: now - 1000,
627
+ subject: "older",
628
+ }),
629
+ );
630
+ cleanup.push(() => repo.delete(acct, a.threadMessageId));
631
+ const b = await repo.create(
632
+ makeInput(acct, mbx, {
633
+ sentDate: now,
634
+ internalDate: now,
635
+ subject: "newer",
636
+ }),
637
+ );
638
+ cleanup.push(() => repo.delete(acct, b.threadMessageId));
639
+
640
+ const result = await repo.listByMailbox(acct, mbx);
641
+ assert.equal(result.items.length, 2);
642
+ assert.equal(result.items[0].subject, "newer");
643
+ assert.equal(result.items[1].subject, "older");
644
+ });
665
645
 
666
- test("get with array returns all matched rows", async () => {
667
- const acct = uuid();
668
- const mbx = uuid();
669
- const a = await repo.create(makeInput(acct, mbx));
670
- cleanup.push(() => repo.delete(acct, a.threadMessageId));
671
- const b = await repo.create(makeInput(acct, mbx));
672
- cleanup.push(() => repo.delete(acct, b.threadMessageId));
646
+ test("get with array returns all matched rows", async () => {
647
+ const acct = uuid();
648
+ const mbx = uuid();
649
+ const a = await repo.create(makeInput(acct, mbx));
650
+ cleanup.push(() => repo.delete(acct, a.threadMessageId));
651
+ const b = await repo.create(makeInput(acct, mbx));
652
+ cleanup.push(() => repo.delete(acct, b.threadMessageId));
673
653
 
674
- const rows = await repo.get(acct, [a.threadMessageId, b.threadMessageId]);
675
- assert.equal(rows.length, 2);
676
- });
654
+ const rows = await repo.get(acct, [a.threadMessageId, b.threadMessageId]);
655
+ assert.equal(rows.length, 2);
656
+ });
677
657
 
678
- // ── Tenant scoping on the id-only lookups (issue #1193) ────────────────────
679
- // findByMessageId / findAllByMessageId / getByMessageId / countByThread carry
680
- // accountConfigId in the WHERE clause, so a row created under account A must
681
- // never resolve under account B's scope.
682
-
683
- test("findByMessageId is scoped to accountConfigId", async () => {
684
- const acct = uuid();
685
- const other = uuid();
686
- const mbx = uuid();
687
- const messageId = uuid();
688
- const created = await repo.create(makeInput(acct, mbx, { messageId }));
689
- cleanup.push(() => repo.delete(acct, created.threadMessageId));
690
-
691
- const own = await repo.findByMessageId(acct, messageId);
692
- assert.ok(own);
693
- assert.equal(own.threadMessageId, created.threadMessageId);
694
-
695
- const foreign = await repo.findByMessageId(other, messageId);
696
- assert.equal(foreign, null);
697
- });
658
+ // ── Tenant scoping on the id-only lookups (issue #1193) ────────────────────
659
+ // findByMessageId / findAllByMessageId / getByMessageId / countByThread carry
660
+ // accountConfigId in the WHERE clause, so a row created under account A must
661
+ // never resolve under account B's scope.
662
+
663
+ test("findByMessageId is scoped to accountConfigId", async () => {
664
+ const acct = uuid();
665
+ const other = uuid();
666
+ const mbx = uuid();
667
+ const messageId = uuid();
668
+ const created = await repo.create(makeInput(acct, mbx, { messageId }));
669
+ cleanup.push(() => repo.delete(acct, created.threadMessageId));
670
+
671
+ const own = await repo.findByMessageId(acct, messageId);
672
+ assert.ok(own);
673
+ assert.equal(own.threadMessageId, created.threadMessageId);
674
+
675
+ const foreign = await repo.findByMessageId(other, messageId);
676
+ assert.equal(foreign, null);
677
+ });
698
678
 
699
- test("findAllByMessageId is scoped to accountConfigId", async () => {
700
- const acct = uuid();
701
- const other = uuid();
702
- const messageId = uuid();
703
- // Same messageId across two mailboxes of the same account.
704
- const a = await repo.create(makeInput(acct, uuid(), { messageId }));
705
- cleanup.push(() => repo.delete(acct, a.threadMessageId));
706
- const b = await repo.create(makeInput(acct, uuid(), { messageId }));
707
- cleanup.push(() => repo.delete(acct, b.threadMessageId));
708
-
709
- const own = await repo.findAllByMessageId(acct, messageId);
710
- assert.equal(own.length, 2);
711
-
712
- const foreign = await repo.findAllByMessageId(other, messageId);
713
- assert.equal(foreign.length, 0);
714
- });
679
+ test("findAllByMessageId is scoped to accountConfigId", async () => {
680
+ const acct = uuid();
681
+ const other = uuid();
682
+ const messageId = uuid();
683
+ // Same messageId across two mailboxes of the same account.
684
+ const a = await repo.create(makeInput(acct, uuid(), { messageId }));
685
+ cleanup.push(() => repo.delete(acct, a.threadMessageId));
686
+ const b = await repo.create(makeInput(acct, uuid(), { messageId }));
687
+ cleanup.push(() => repo.delete(acct, b.threadMessageId));
688
+
689
+ const own = await repo.findAllByMessageId(acct, messageId);
690
+ assert.equal(own.length, 2);
691
+
692
+ const foreign = await repo.findAllByMessageId(other, messageId);
693
+ assert.equal(foreign.length, 0);
694
+ });
715
695
 
716
- test("getByMessageId throws for another account's messageId", async () => {
717
- const acct = uuid();
718
- const other = uuid();
719
- const mbx = uuid();
720
- const messageId = uuid();
721
- const created = await repo.create(makeInput(acct, mbx, { messageId }));
722
- cleanup.push(() => repo.delete(acct, created.threadMessageId));
696
+ test("getByMessageId throws for another account's messageId", async () => {
697
+ const acct = uuid();
698
+ const other = uuid();
699
+ const mbx = uuid();
700
+ const messageId = uuid();
701
+ const created = await repo.create(makeInput(acct, mbx, { messageId }));
702
+ cleanup.push(() => repo.delete(acct, created.threadMessageId));
723
703
 
724
- const own = await repo.getByMessageId(acct, messageId);
725
- assert.equal(own.threadMessageId, created.threadMessageId);
704
+ const own = await repo.getByMessageId(acct, messageId);
705
+ assert.equal(own.threadMessageId, created.threadMessageId);
726
706
 
727
- await assert.rejects(() => repo.getByMessageId(other, messageId));
728
- });
707
+ await assert.rejects(() => repo.getByMessageId(other, messageId));
708
+ });
729
709
 
730
- test("countByThread is scoped to accountConfigId", async () => {
731
- const acct = uuid();
732
- const other = uuid();
733
- const mbx = uuid();
734
- const threadId = uuid();
735
- for (let i = 0; i < 3; i++) {
736
- const row = await repo.create(makeInput(acct, mbx, { threadId }));
737
- cleanup.push(() => repo.delete(acct, row.threadMessageId));
738
- }
739
-
740
- assert.equal(await repo.countByThread(acct, threadId), 3);
741
- assert.equal(await repo.countByThread(other, threadId), 0);
742
- });
743
- },
744
- );
710
+ test("countByThread is scoped to accountConfigId", async () => {
711
+ const acct = uuid();
712
+ const other = uuid();
713
+ const mbx = uuid();
714
+ const threadId = uuid();
715
+ for (let i = 0; i < 3; i++) {
716
+ const row = await repo.create(makeInput(acct, mbx, { threadId }));
717
+ cleanup.push(() => repo.delete(acct, row.threadMessageId));
718
+ }
719
+
720
+ assert.equal(await repo.countByThread(acct, threadId), 3);
721
+ assert.equal(await repo.countByThread(other, threadId), 0);
722
+ });
723
+ });
@@ -1,7 +1,4 @@
1
- import type {
2
- IUnitOfWork,
3
- UnitOfWorkRepositories,
4
- } from "@remit/data-ports";
1
+ import type { IUnitOfWork, UnitOfWorkRepositories } from "@remit/data-ports";
5
2
  import type { Db } from "../db.js";
6
3
  import type { MessageDataSchema } from "../schema/message-data.js";
7
4
  import { runInTransaction } from "../tx.js";
@@ -16,19 +16,13 @@ const hasCheckScript = existsSync(
16
16
  // Fails when the committed VPS migrations (deploy/vps/migrations/*) no longer
17
17
  // produce the schema drizzle would generate from the entity + auth schemas.
18
18
  // See npm-scripts/check-vps-migrations.mjs for the mechanism.
19
- test(
20
- "committed VPS migrations match the drizzle schema",
21
- { skip: !hasCheckScript },
22
- () => {
23
- assert.doesNotThrow(() => {
24
- execFileSync(
25
- "node",
26
- ["npm-scripts/check-vps-migrations.mjs", "--check"],
27
- {
28
- cwd: repoRoot,
29
- stdio: "inherit",
30
- },
31
- );
32
- }, "committed VPS migrations are stale — run `npm run migrations:generate`");
33
- },
34
- );
19
+ test("committed VPS migrations match the drizzle schema", {
20
+ skip: !hasCheckScript,
21
+ }, () => {
22
+ assert.doesNotThrow(() => {
23
+ execFileSync("node", ["npm-scripts/check-vps-migrations.mjs", "--check"], {
24
+ cwd: repoRoot,
25
+ stdio: "inherit",
26
+ });
27
+ }, "committed VPS migrations are stale — run `npm run migrations:generate`");
28
+ });