@remit/drizzle-service 0.0.99 → 0.0.101

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.
Files changed (93) hide show
  1. package/package.json +1 -1
  2. package/src/batch.sqlite.test.ts +78 -0
  3. package/src/batch.ts +46 -0
  4. package/src/databases.ts +70 -0
  5. package/src/db.ts +10 -9
  6. package/src/generated-enum.test.ts +8 -0
  7. package/src/generated-id.test.ts +19 -0
  8. package/src/id.test.ts +0 -8
  9. package/src/id.ts +0 -6
  10. package/src/index.ts +16 -6
  11. package/src/repair/address-display-name.sqlite.test.ts +3 -5
  12. package/src/repair/address-display-name.ts +8 -3
  13. package/src/repair/junk-only-address.sqlite.test.ts +8 -36
  14. package/src/repair/stranded-sent-outbox.sqlite.test.ts +2 -9
  15. package/src/repos/account.conformance.sqlite.test.ts +1 -1
  16. package/src/repos/calendar-default.sqlite.test.ts +6 -6
  17. package/src/repos/calendar-put.sqlite.test.ts +40 -39
  18. package/src/repos/calendar-store.ts +273 -0
  19. package/src/repos/cascade-delete.sqlite.test.ts +7 -9
  20. package/src/repos/cascade-delete.test.ts +6 -13
  21. package/src/repos/cascade-delete.ts +127 -97
  22. package/src/repos/envelope.test.ts +1 -17
  23. package/src/repos/envelope.ts +83 -67
  24. package/src/repos/filter.test.ts +22 -2
  25. package/src/repos/filter.ts +1 -5
  26. package/src/repos/i4-account-config.test.ts +2 -2
  27. package/src/repos/i4-account-config.ts +5 -2
  28. package/src/repos/i4-account.test.ts +6 -2
  29. package/src/repos/i4-account.ts +12 -56
  30. package/src/repos/i4-address-junk-move.sqlite.test.ts +1 -1
  31. package/src/repos/i4-address-merge-flags.sqlite.test.ts +1 -2
  32. package/src/repos/i4-address.ts +58 -90
  33. package/src/repos/i4-mailbox-special-use.sqlite.test.ts +6 -2
  34. package/src/repos/i4-mailbox-special-use.ts +9 -6
  35. package/src/repos/i4-mailbox.sqlite.test.ts +12 -14
  36. package/src/repos/i4-mailbox.test.ts +17 -62
  37. package/src/repos/i4-mailbox.ts +121 -94
  38. package/src/repos/i4-organize-job-request.ts +0 -4
  39. package/src/repos/i4-outbox-attachment.ts +62 -33
  40. package/src/repos/mappers.ts +0 -16
  41. package/src/repos/message.sqlite.test.ts +32 -106
  42. package/src/repos/message.test.ts +6 -167
  43. package/src/repos/message.ts +216 -228
  44. package/src/repos/sync-warning.ts +76 -0
  45. package/src/repos/thread-message-body-muted.sqlite.test.ts +5 -10
  46. package/src/repos/thread-message-field-terms.sqlite.test.ts +5 -1
  47. package/src/repos/thread-message.sqlite.test.ts +2 -1
  48. package/src/repos/thread-message.test.ts +17 -4
  49. package/src/repos/thread-message.ts +502 -285
  50. package/src/repos/thread-search-predicates.ts +24 -26
  51. package/src/schema/message-data.ts +4 -8
  52. package/src/schema/sync-warning.ts +3 -0
  53. package/src/schema-sets/app.ts +17 -0
  54. package/src/schema-sets/mail.ts +32 -0
  55. package/src/schema-sets/search.ts +5 -0
  56. package/src/schema-version-accounting.sqlite.test.ts +56 -43
  57. package/src/schema.ts +3 -4
  58. package/src/search/thread-message-search.sqlite.test.ts +131 -0
  59. package/src/search/thread-message-search.ts +271 -0
  60. package/src/sqlite-client.ts +5 -7
  61. package/src/sqlite-repositories.ts +75 -0
  62. package/src/test-db-sqlite.ts +5 -13
  63. package/src/test-shipped-sqlite-schema.ts +46 -45
  64. package/src/test-sqlite.ts +35 -11
  65. package/src/vps-migrations-drift.sqlite.test.ts +81 -15
  66. package/src/account-granted-scopes-migration.sqlite.test.ts +0 -66
  67. package/src/account-synced-services-migration.sqlite.test.ts +0 -56
  68. package/src/mailbox-path-dedupe.sqlite.test.ts +0 -462
  69. package/src/mailbox-sync-status-backfill.sqlite.test.ts +0 -118
  70. package/src/outbox-created-cleanup.sqlite.test.ts +0 -83
  71. package/src/repair/duplicate-mailbox-mail.ts +0 -98
  72. package/src/repair/thread-message-category-contract.ts +0 -505
  73. package/src/repair/thread-message-category.sqlite.test.ts +0 -100
  74. package/src/repair/thread-message-category.ts +0 -415
  75. package/src/repos/calendar-migrations.sqlite.test.ts +0 -345
  76. package/src/repos/calendar-unit-of-work.ts +0 -31
  77. package/src/repos/config-import-atomicity.sqlite.test.ts +0 -255
  78. package/src/repos/filter-anchor-transaction.test.ts +0 -136
  79. package/src/repos/filter-anchor-transaction.ts +0 -45
  80. package/src/repos/filter-anchor.test.ts +0 -131
  81. package/src/repos/filter-anchor.ts +0 -105
  82. package/src/repos/i4-mailbox-special-use.structure.test.ts +0 -27
  83. package/src/repos/message-placement-backfill.sqlite.test.ts +0 -190
  84. package/src/repos/search-index-shape.test.ts +0 -82
  85. package/src/repos/search-index-shape.ts +0 -56
  86. package/src/repos/serialized-writes.sqlite.test.ts +0 -199
  87. package/src/repos/unit-of-work.sqlite.test.ts +0 -138
  88. package/src/repos/unit-of-work.test.ts +0 -109
  89. package/src/repos/unit-of-work.ts +0 -31
  90. package/src/schema/outbox.ts +0 -46
  91. package/src/schema-full-sqlite.ts +0 -13
  92. package/src/tx.sqlite.test.ts +0 -83
  93. package/src/tx.ts +0 -233
@@ -13,20 +13,27 @@ import {
13
13
  asc,
14
14
  desc,
15
15
  eq,
16
- getTableColumns,
16
+ exists,
17
17
  gt,
18
18
  inArray,
19
19
  lt,
20
+ not,
20
21
  or,
21
22
  type SQL,
23
+ type SQLWrapper,
22
24
  sql,
23
25
  } from "drizzle-orm";
26
+ import { runBatch } from "../batch.js";
24
27
  import type { Db } from "../db.js";
25
28
  import { NotFoundError } from "../error.js";
26
29
  import { deterministicBase36Id } from "../id.js";
27
30
  import { decodeToken } from "../pagination.js";
28
31
  import { addressTable } from "../schema/i4-address.js";
29
32
  import { threadMessageTable } from "../schema/thread-message.js";
33
+ import type {
34
+ SearchIndex,
35
+ SearchPosition,
36
+ } from "../search/thread-message-search.js";
30
37
  import {
31
38
  bodyMatch,
32
39
  fromMatch,
@@ -174,51 +181,64 @@ function toItem(row: Row): ThreadMessageItem {
174
181
  const mutedSender = (): SQL =>
175
182
  sql`exists (select 1 from ${addressTable} where ${addressTable.accountConfigId} = ${threadMessageTable.accountConfigId} and ${addressTable.normalizedEmail} = lower(coalesce(${threadMessageTable.fromEmail}, '')) and json_extract(coalesce(nullif(${addressTable.flags}, ''), '{}'), '$.muted.value') = 1)`;
176
183
 
184
+ // The columns the search index is built from; an update that sets none of them
185
+ // leaves the index as it is.
186
+ const touchesSearchIndex = (input: UpdateThreadMessageInput): boolean =>
187
+ input.mailboxId !== undefined ||
188
+ input.sentDate !== undefined ||
189
+ input.listId !== undefined ||
190
+ input.subject !== undefined ||
191
+ input.fromName !== undefined ||
192
+ input.fromEmail !== undefined ||
193
+ input.snippet !== undefined;
194
+
177
195
  const queryTerms = (query: string | undefined): string[] =>
178
196
  query ? query.split(/\s+/).filter(Boolean) : [];
179
197
 
180
- // Translate SearchOptions into SQL conditions: subject/from/query as indexed
181
- // text predicates, muted as a subquery over the sender's address, the rest as
182
- // plain column equalities. A multi-word `query`
183
- // matches rows where every token appears in the subject, the from fields or the
184
- // body preview (AND across tokens, OR across fields) — the same shape as the
185
- // DynamoDB model.
186
- function buildSearchConditions(search: SearchOptions): SQL[] {
187
- const conditions: SQL[] = [];
188
-
189
- if (search.subject) conditions.push(subjectMatch(search.subject));
190
- if (search.from) conditions.push(fromMatch(search.from));
191
-
192
- if (search.query) {
193
- for (const token of queryTerms(search.query)) {
194
- conditions.push(
195
- sql`(${subjectMatch(token)} or ${fromMatch(token)} or ${bodyMatch(token)})`,
196
- );
197
- }
198
+ // The two halves of a search: the text condition, over the search index, and
199
+ // the conditions over the thread message row itself.
200
+ interface SearchConditions {
201
+ text: SQL | undefined;
202
+ row: SQL[];
203
+ }
204
+
205
+ // Translate SearchOptions into conditions: subject/from/query as indexed text
206
+ // predicates, muted as a subquery over the sender's address, the rest as plain
207
+ // column equalities. A multi-word `query` matches rows where every token
208
+ // appears in the subject, the from fields or the body preview (AND across
209
+ // tokens, OR across fields) — the same shape as the DynamoDB model.
210
+ function buildSearchConditions(search: SearchOptions): SearchConditions {
211
+ const text: SQL[] = [];
212
+ if (search.subject) text.push(subjectMatch(search.subject));
213
+ if (search.from) text.push(fromMatch(search.from));
214
+ for (const token of queryTerms(search.query)) {
215
+ text.push(
216
+ sql`(${subjectMatch(token)} or ${fromMatch(token)} or ${bodyMatch(token)})`,
217
+ );
198
218
  }
199
219
 
220
+ const row: SQL[] = [];
200
221
  if (search.muted !== undefined) {
201
- conditions.push(search.muted ? mutedSender() : sql`not ${mutedSender()}`);
222
+ row.push(search.muted ? mutedSender() : sql`not ${mutedSender()}`);
202
223
  }
203
-
204
224
  if (search.unread !== undefined) {
205
- conditions.push(eq(threadMessageTable.isRead, !search.unread));
225
+ row.push(eq(threadMessageTable.isRead, !search.unread));
206
226
  }
207
227
  if (search.starred !== undefined) {
208
- conditions.push(eq(threadMessageTable.hasStars, search.starred));
228
+ row.push(eq(threadMessageTable.hasStars, search.starred));
209
229
  }
210
230
  if (search.attachments !== undefined) {
211
- conditions.push(eq(threadMessageTable.hasAttachment, search.attachments));
231
+ row.push(eq(threadMessageTable.hasAttachment, search.attachments));
212
232
  }
213
233
  // An equality over a column on the row, so it sits inside the keyset window
214
234
  // rather than filtering what the window returned: a page is a full page of
215
235
  // matches however rare the category is. Served by
216
236
  // tm_by_mailbox_category_date.
217
237
  if (search.category?.length) {
218
- conditions.push(inArray(threadMessageTable.category, search.category));
238
+ row.push(inArray(threadMessageTable.category, search.category));
219
239
  }
220
240
 
221
- return conditions;
241
+ return { text: text.length > 0 ? and(...text) : undefined, row };
222
242
  }
223
243
 
224
244
  const fieldTermCondition = (term: ThreadMessageFieldTerm): SQL => {
@@ -245,6 +265,41 @@ function buildFieldTermCondition(
245
265
  return operator === "or" ? or(...conditions) : and(...conditions);
246
266
  }
247
267
 
268
+ const CANDIDATES_PER_READ = 500;
269
+
270
+ /**
271
+ * Where a count over a text search stops. Without text a count is one
272
+ * aggregate; with text it walks the matches, and a number past this is not
273
+ * worth walking further for.
274
+ */
275
+ export const TEXT_SEARCH_COUNT_CAP = 1000;
276
+
277
+ // A list of ids bound as one JSON parameter, however long it is.
278
+ const inJson = (column: SQLWrapper, values: readonly string[]): SQL =>
279
+ sql`${column} in (select value from json_each(${JSON.stringify(values)}))`;
280
+
281
+ interface PageQuery {
282
+ accountConfigId: string;
283
+ mailboxIds?: readonly string[];
284
+ text: SQL | undefined;
285
+ rank?: SQL<number>;
286
+ row: Array<SQL | undefined>;
287
+ order: "asc" | "desc";
288
+ after: SearchPosition | null;
289
+ limit: number;
290
+ }
291
+
292
+ type RankedRow = Row & { rank: number };
293
+
294
+ const positionOf = (cursor: DateCursor | RankedDateCursor | null) =>
295
+ cursor
296
+ ? {
297
+ rank: "r" in cursor ? cursor.r : 0,
298
+ sentDate: cursor.s,
299
+ threadMessageId: cursor.id,
300
+ }
301
+ : null;
302
+
248
303
  // Keyset cursor over (sent_date, thread_message_id). `desc` walks newest→oldest,
249
304
  // `asc` oldest→newest; the id tiebreak keeps paging stable across equal dates.
250
305
  function sentDateCursorCond(
@@ -265,27 +320,186 @@ function sentDateCursorCond(
265
320
  );
266
321
  }
267
322
 
268
- function rankedDateCursorCond(
269
- order: "asc" | "desc",
270
- rank: SQL<number>,
271
- cursor: RankedDateCursor | null,
272
- ): SQL | undefined {
273
- if (!cursor) return undefined;
274
- return or(
275
- lt(rank, cursor.r),
276
- and(eq(rank, cursor.r), sentDateCursorCond(order, cursor)),
277
- );
278
- }
279
-
280
323
  // ─── Repository ──────────────────────────────────────────────────────────────
281
324
 
282
325
  export class DrizzleThreadMessageRepository
283
326
  implements IThreadMessageRepository
284
327
  {
285
- private db: Db<Record<string, unknown>>;
328
+ constructor(
329
+ private readonly db: Db<Record<string, unknown>>,
330
+ private readonly search: SearchIndex,
331
+ ) {}
286
332
 
287
- constructor(db: Db<Record<string, unknown>>) {
288
- this.db = db;
333
+ /**
334
+ * One page of rows in date order (ranked first when asked). Without a text
335
+ * condition it is one query over the mail database. With one, the search
336
+ * index finds, scopes and orders the matches and hands over a page of ids at
337
+ * a time; the mail database keeps the ones its own conditions accept, until
338
+ * the page is full or the matches run out.
339
+ */
340
+ private async page(query: PageQuery): Promise<RankedRow[]> {
341
+ if (!query.text) {
342
+ const rows = await this.db
343
+ .select()
344
+ .from(threadMessageTable)
345
+ .where(
346
+ and(
347
+ eq(threadMessageTable.accountConfigId, query.accountConfigId),
348
+ query.mailboxIds
349
+ ? inArray(threadMessageTable.mailboxId, [...query.mailboxIds])
350
+ : undefined,
351
+ ...query.row,
352
+ sentDateCursorCond(
353
+ query.order,
354
+ query.after && {
355
+ s: query.after.sentDate,
356
+ id: query.after.threadMessageId,
357
+ },
358
+ ),
359
+ ),
360
+ )
361
+ .orderBy(
362
+ query.order === "desc"
363
+ ? desc(threadMessageTable.sentDate)
364
+ : asc(threadMessageTable.sentDate),
365
+ asc(threadMessageTable.threadMessageId),
366
+ )
367
+ .limit(query.limit);
368
+ return rows.map((row) => ({ ...row, rank: 0 }));
369
+ }
370
+
371
+ const page: RankedRow[] = [];
372
+ await this.eachMatch({ ...query, text: query.text }, (rows) => {
373
+ page.push(...rows.slice(0, query.limit - page.length));
374
+ return page.length < query.limit;
375
+ });
376
+ return page;
377
+ }
378
+
379
+ /**
380
+ * Walk every match of a text query in order, a page of candidates at a
381
+ * time, handing each page's accepted rows to `visit` until it returns false.
382
+ */
383
+ /**
384
+ * The mail rows behind one page of candidates, kept to the query's folder
385
+ * scope and row conditions: a candidate the index still holds for a row
386
+ * that has since moved folders, or gone, does not come back.
387
+ */
388
+ private matchedRows(
389
+ query: Omit<PageQuery, "limit" | "text" | "after" | "order">,
390
+ ids: readonly string[],
391
+ ) {
392
+ return and(
393
+ eq(threadMessageTable.accountConfigId, query.accountConfigId),
394
+ inJson(threadMessageTable.threadMessageId, ids),
395
+ query.mailboxIds
396
+ ? inJson(threadMessageTable.mailboxId, query.mailboxIds)
397
+ : undefined,
398
+ ...query.row,
399
+ );
400
+ }
401
+
402
+ /**
403
+ * Walk every match of a text query in order, a page of candidates at a
404
+ * time, handing each page's accepted rows to `visit` until it returns false.
405
+ */
406
+ private async eachMatch(
407
+ query: Omit<PageQuery, "limit" | "text"> & { text: SQL },
408
+ visit: (rows: RankedRow[]) => boolean,
409
+ ): Promise<void> {
410
+ let after = query.after;
411
+ for (;;) {
412
+ const candidates = await this.search.candidates({
413
+ accountConfigId: query.accountConfigId,
414
+ mailboxIds: query.mailboxIds,
415
+ where: query.text,
416
+ rank: query.rank,
417
+ order: query.order,
418
+ after,
419
+ limit: CANDIDATES_PER_READ,
420
+ });
421
+ const last = candidates.at(-1);
422
+ if (!last) return;
423
+ const rows = await this.db
424
+ .select()
425
+ .from(threadMessageTable)
426
+ .where(
427
+ this.matchedRows(
428
+ query,
429
+ candidates.map((candidate) => candidate.threadMessageId),
430
+ ),
431
+ );
432
+ const byId = new Map(rows.map((row) => [row.threadMessageId, row]));
433
+ const accepted = candidates.flatMap((candidate) => {
434
+ const row = byId.get(candidate.threadMessageId);
435
+ return row ? [{ ...row, rank: candidate.rank }] : [];
436
+ });
437
+ if (!visit(accepted)) return;
438
+ if (candidates.length < CANDIDATES_PER_READ) return;
439
+ after = last;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * How many rows a search accepts, or how many conversations they belong to.
445
+ * Without a text condition it is one COUNT; with one it walks the matches,
446
+ * reading only their thread ids, and stops at {@link TEXT_SEARCH_COUNT_CAP}.
447
+ */
448
+ private async countMatches(
449
+ query: Omit<PageQuery, "limit" | "after" | "order">,
450
+ unit: "rows" | "threads",
451
+ ): Promise<number> {
452
+ if (!query.text) {
453
+ const [{ count }] = await this.db
454
+ .select({
455
+ count:
456
+ unit === "threads"
457
+ ? sql<number>`cast(count(distinct ${threadMessageTable.threadId}) as int)`
458
+ : sql<number>`cast(count(*) as int)`,
459
+ })
460
+ .from(threadMessageTable)
461
+ .where(
462
+ and(
463
+ eq(threadMessageTable.accountConfigId, query.accountConfigId),
464
+ query.mailboxIds
465
+ ? inArray(threadMessageTable.mailboxId, [...query.mailboxIds])
466
+ : undefined,
467
+ ...query.row,
468
+ ),
469
+ );
470
+ return count;
471
+ }
472
+ const threads = new Set<string>();
473
+ let rows = 0;
474
+ let after: SearchPosition | null = null;
475
+ for (;;) {
476
+ const candidates = await this.search.candidates({
477
+ accountConfigId: query.accountConfigId,
478
+ mailboxIds: query.mailboxIds,
479
+ where: query.text,
480
+ order: "desc",
481
+ after,
482
+ limit: CANDIDATES_PER_READ,
483
+ });
484
+ const last = candidates.at(-1);
485
+ if (!last) break;
486
+ const matched = await this.db
487
+ .select({ threadId: threadMessageTable.threadId })
488
+ .from(threadMessageTable)
489
+ .where(
490
+ this.matchedRows(
491
+ query,
492
+ candidates.map((candidate) => candidate.threadMessageId),
493
+ ),
494
+ );
495
+ rows += matched.length;
496
+ for (const row of matched) threads.add(row.threadId);
497
+ const counted = unit === "threads" ? threads.size : rows;
498
+ if (counted >= TEXT_SEARCH_COUNT_CAP) return TEXT_SEARCH_COUNT_CAP;
499
+ if (candidates.length < CANDIDATES_PER_READ) break;
500
+ after = last;
501
+ }
502
+ return unit === "threads" ? threads.size : rows;
289
503
  }
290
504
 
291
505
  async close(): Promise<void> {
@@ -332,7 +546,10 @@ export class DrizzleThreadMessageRepository
332
546
  .values(row)
333
547
  .onConflictDoNothing()
334
548
  .returning();
335
- if (inserted) return toItem(inserted);
549
+ if (inserted) {
550
+ await this.search.sync([threadMessageId]);
551
+ return toItem(inserted);
552
+ }
336
553
 
337
554
  const [existing] = await this.db
338
555
  .select()
@@ -431,6 +648,7 @@ export class DrizzleThreadMessageRepository
431
648
  )
432
649
  .returning();
433
650
  if (!row) throw new NotFoundError("ThreadMessage not found");
651
+ if (touchesSearchIndex(input)) await this.search.sync([threadMessageId]);
434
652
  return toItem(row);
435
653
  }
436
654
 
@@ -446,6 +664,7 @@ export class DrizzleThreadMessageRepository
446
664
  eq(threadMessageTable.threadMessageId, threadMessageId),
447
665
  ),
448
666
  );
667
+ await this.search.sync([threadMessageId]);
449
668
  }
450
669
 
451
670
  async deleteMany(
@@ -462,6 +681,7 @@ export class DrizzleThreadMessageRepository
462
681
  inArray(threadMessageTable.threadMessageId, ids),
463
682
  ),
464
683
  );
684
+ await this.search.sync(ids);
465
685
  }
466
686
 
467
687
  async listByAccount(
@@ -509,56 +729,31 @@ export class DrizzleThreadMessageRepository
509
729
  search?: SearchOptions;
510
730
  },
511
731
  ): Promise<ResultList<ThreadMessageItem>> {
512
- const order = options?.order ?? "desc";
513
- const cursor = options?.continuationToken
514
- ? decodeDateCursor(options.continuationToken)
515
- : null;
516
-
517
- const cursorCond = cursor
518
- ? order === "desc"
519
- ? or(
520
- lt(threadMessageTable.sentDate, cursor.s),
521
- and(
522
- eq(threadMessageTable.sentDate, cursor.s),
523
- gt(threadMessageTable.threadMessageId, cursor.id),
524
- ),
525
- )
526
- : or(
527
- gt(threadMessageTable.sentDate, cursor.s),
528
- and(
529
- eq(threadMessageTable.sentDate, cursor.s),
530
- gt(threadMessageTable.threadMessageId, cursor.id),
531
- ),
532
- )
533
- : undefined;
534
-
535
- const mailboxCond = options?.inboxMailboxIds?.size
536
- ? inArray(threadMessageTable.mailboxId, [...options.inboxMailboxIds])
537
- : undefined;
538
-
539
- const rows = await this.db
540
- .select()
541
- .from(threadMessageTable)
542
- .where(
543
- and(
544
- eq(threadMessageTable.accountConfigId, accountConfigId),
545
- mailboxCond,
546
- options?.excludeDeleted
547
- ? eq(threadMessageTable.isDeleted, false)
548
- : undefined,
549
- ...(options?.search ? buildSearchConditions(options.search) : []),
550
- cursorCond,
551
- ),
552
- )
553
- .orderBy(
554
- order === "desc"
555
- ? desc(threadMessageTable.sentDate)
556
- : asc(threadMessageTable.sentDate),
557
- asc(threadMessageTable.threadMessageId),
558
- )
559
- .limit(options?.limit ?? 100);
560
-
561
- const lastRow = rows[rows.length - 1];
732
+ const search = options?.search
733
+ ? buildSearchConditions(options.search)
734
+ : { text: undefined, row: [] };
735
+ const rows = await this.page({
736
+ accountConfigId,
737
+ mailboxIds: options?.inboxMailboxIds?.size
738
+ ? [...options.inboxMailboxIds]
739
+ : undefined,
740
+ text: search.text,
741
+ row: [
742
+ options?.excludeDeleted
743
+ ? eq(threadMessageTable.isDeleted, false)
744
+ : undefined,
745
+ ...search.row,
746
+ ],
747
+ order: options?.order ?? "desc",
748
+ after: positionOf(
749
+ options?.continuationToken
750
+ ? decodeDateCursor(options.continuationToken)
751
+ : null,
752
+ ),
753
+ limit: options?.limit ?? 100,
754
+ });
755
+
756
+ const lastRow = rows.at(-1);
562
757
  const hasMore =
563
758
  options?.limit !== undefined && rows.length === options.limit;
564
759
  return {
@@ -588,57 +783,40 @@ export class DrizzleThreadMessageRepository
588
783
  excludeDeleted?: boolean;
589
784
  },
590
785
  ): Promise<ResultList<ThreadMessageItem>> {
591
- const order = options?.order ?? "desc";
592
786
  const limit = clampThreadSearchLimit(options?.limit);
593
787
  const cursor = options?.continuationToken
594
788
  ? decodeSearchCursor(options.continuationToken)
595
789
  : null;
596
790
  const ranked = cursor?.v !== 1;
597
-
598
- const mailboxCond = options?.mailboxIds?.size
599
- ? inArray(threadMessageTable.mailboxId, [...options.mailboxIds])
600
- : undefined;
601
-
602
- const senderRank = ranked
603
- ? senderMatchRank(queryTerms(search.query))
604
- : sql<number>`0`;
605
-
606
- const rows = await this.db
607
- .select({
608
- ...getTableColumns(threadMessageTable),
609
- senderRank,
610
- })
611
- .from(threadMessageTable)
612
- .where(
613
- and(
614
- eq(threadMessageTable.accountConfigId, accountConfigId),
615
- mailboxCond,
616
- options?.excludeDeleted
617
- ? eq(threadMessageTable.isDeleted, false)
618
- : undefined,
619
- ...buildSearchConditions(search),
620
- cursor?.v === 1
621
- ? sentDateCursorCond(order, cursor)
622
- : rankedDateCursorCond(order, senderRank, cursor),
623
- ),
624
- )
625
- .orderBy(
626
- ...(ranked ? [desc(senderRank)] : []),
627
- order === "desc"
628
- ? desc(threadMessageTable.sentDate)
629
- : asc(threadMessageTable.sentDate),
630
- asc(threadMessageTable.threadMessageId),
631
- )
632
- .limit(limit);
633
-
634
- const lastRow = rows[rows.length - 1];
791
+ const terms = queryTerms(search.query);
792
+ const conditions = buildSearchConditions(search);
793
+
794
+ const rows = await this.page({
795
+ accountConfigId,
796
+ mailboxIds: options?.mailboxIds?.size
797
+ ? [...options.mailboxIds]
798
+ : undefined,
799
+ text: conditions.text,
800
+ rank: ranked && terms.length > 0 ? senderMatchRank(terms) : undefined,
801
+ row: [
802
+ options?.excludeDeleted
803
+ ? eq(threadMessageTable.isDeleted, false)
804
+ : undefined,
805
+ ...conditions.row,
806
+ ],
807
+ order: options?.order ?? "desc",
808
+ after: positionOf(cursor),
809
+ limit,
810
+ });
811
+
812
+ const lastRow = rows.at(-1);
635
813
  return {
636
814
  items: rows.map(toItem),
637
815
  continuationToken:
638
816
  rows.length === limit && lastRow
639
817
  ? ranked
640
818
  ? encodeRankedDateCursor(
641
- lastRow.senderRank,
819
+ lastRow.rank,
642
820
  lastRow.sentDate,
643
821
  lastRow.threadMessageId,
644
822
  )
@@ -667,42 +845,33 @@ export class DrizzleThreadMessageRepository
667
845
  mailboxIds?: readonly string[];
668
846
  },
669
847
  ): Promise<ResultList<ThreadMessageItem>> {
670
- const order = options?.order ?? "desc";
671
848
  const limit = clampThreadSearchLimit(options?.limit);
672
- const cursor = options?.continuationToken
673
- ? decodeDateCursor(options.continuationToken)
674
- : null;
675
-
676
- const rows = await this.db
677
- .select()
678
- .from(threadMessageTable)
679
- .where(
680
- and(
681
- eq(threadMessageTable.accountConfigId, accountConfigId),
682
- options?.excludeDeleted
683
- ? eq(threadMessageTable.isDeleted, false)
684
- : undefined,
685
- options?.mailboxIds
686
- ? inArray(threadMessageTable.mailboxId, [...options.mailboxIds])
687
- : undefined,
688
- buildFieldTermCondition(terms, options?.operator ?? "and"),
689
- sentDateCursorCond(order, cursor),
690
- ),
691
- )
692
- .orderBy(
693
- order === "desc"
694
- ? desc(threadMessageTable.sentDate)
695
- : asc(threadMessageTable.sentDate),
696
- asc(threadMessageTable.threadMessageId),
697
- )
698
- .limit(limit);
849
+ const rows = await this.page({
850
+ accountConfigId,
851
+ mailboxIds: options?.mailboxIds,
852
+ text: buildFieldTermCondition(terms, options?.operator ?? "and"),
853
+ row: [
854
+ options?.excludeDeleted
855
+ ? eq(threadMessageTable.isDeleted, false)
856
+ : undefined,
857
+ ],
858
+ order: options?.order ?? "desc",
859
+ after: positionOf(
860
+ options?.continuationToken
861
+ ? decodeDateCursor(options.continuationToken)
862
+ : null,
863
+ ),
864
+ limit,
865
+ });
699
866
 
700
- const lastRow = rows[rows.length - 1];
701
867
  return {
702
868
  items: rows.map(toItem),
703
869
  continuationToken:
704
- rows.length === limit && lastRow
705
- ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
870
+ rows.length === limit && rows.at(-1)
871
+ ? encodeDateCursor(
872
+ rows[rows.length - 1].sentDate,
873
+ rows[rows.length - 1].threadMessageId,
874
+ )
706
875
  : undefined,
707
876
  };
708
877
  }
@@ -729,26 +898,23 @@ export class DrizzleThreadMessageRepository
729
898
  excludeDeleted?: boolean;
730
899
  },
731
900
  ): Promise<number> {
732
- const mailboxCond = options?.mailboxIds?.size
733
- ? inArray(threadMessageTable.mailboxId, [...options.mailboxIds])
734
- : undefined;
735
-
736
- const [{ count }] = await this.db
737
- .select({
738
- count: sql<number>`cast(count(distinct ${threadMessageTable.threadId}) as int)`,
739
- })
740
- .from(threadMessageTable)
741
- .where(
742
- and(
743
- eq(threadMessageTable.accountConfigId, accountConfigId),
744
- mailboxCond,
901
+ const conditions = buildSearchConditions(search);
902
+ return this.countMatches(
903
+ {
904
+ accountConfigId,
905
+ mailboxIds: options?.mailboxIds?.size
906
+ ? [...options.mailboxIds]
907
+ : undefined,
908
+ text: conditions.text,
909
+ row: [
745
910
  options?.excludeDeleted
746
911
  ? eq(threadMessageTable.isDeleted, false)
747
912
  : undefined,
748
- ...buildSearchConditions(search),
749
- ),
750
- );
751
- return count;
913
+ ...conditions.row,
914
+ ],
915
+ },
916
+ "threads",
917
+ );
752
918
  }
753
919
 
754
920
  async listByStarred(
@@ -762,48 +928,42 @@ export class DrizzleThreadMessageRepository
762
928
  search?: SearchOptions;
763
929
  },
764
930
  ): Promise<ResultList<ThreadMessageItem>> {
765
- const order = options?.order ?? "desc";
766
931
  // Default before the has-more check, or an absent limit silently truncates
767
932
  // at the implicit cap with no continuation token.
768
933
  const limit = options?.limit ?? DEFAULT_LIST_LIMIT;
769
- const cursor = options?.continuationToken
770
- ? decodeDateCursor(options.continuationToken)
771
- : null;
772
-
773
- const mailboxCond = options?.mailboxIds?.size
774
- ? inArray(threadMessageTable.mailboxId, [...options.mailboxIds])
775
- : undefined;
776
-
777
- const rows = await this.db
778
- .select()
779
- .from(threadMessageTable)
780
- .where(
781
- and(
782
- eq(threadMessageTable.accountConfigId, accountConfigId),
783
- eq(threadMessageTable.hasStars, true),
784
- mailboxCond,
785
- options?.excludeDeleted
786
- ? eq(threadMessageTable.isDeleted, false)
787
- : undefined,
788
- ...(options?.search ? buildSearchConditions(options.search) : []),
789
- sentDateCursorCond(order, cursor),
790
- ),
791
- )
792
- .orderBy(
793
- order === "desc"
794
- ? desc(threadMessageTable.sentDate)
795
- : asc(threadMessageTable.sentDate),
796
- asc(threadMessageTable.threadMessageId),
797
- )
798
- .limit(limit);
934
+ const search = options?.search
935
+ ? buildSearchConditions(options.search)
936
+ : { text: undefined, row: [] };
937
+ const rows = await this.page({
938
+ accountConfigId,
939
+ mailboxIds: options?.mailboxIds?.size
940
+ ? [...options.mailboxIds]
941
+ : undefined,
942
+ text: search.text,
943
+ row: [
944
+ eq(threadMessageTable.hasStars, true),
945
+ options?.excludeDeleted
946
+ ? eq(threadMessageTable.isDeleted, false)
947
+ : undefined,
948
+ ...search.row,
949
+ ],
950
+ order: options?.order ?? "desc",
951
+ after: positionOf(
952
+ options?.continuationToken
953
+ ? decodeDateCursor(options.continuationToken)
954
+ : null,
955
+ ),
956
+ limit,
957
+ });
799
958
 
800
- const lastRow = rows[rows.length - 1];
801
- const hasMore = rows.length === limit;
802
959
  return {
803
960
  items: rows.map(toItem),
804
961
  continuationToken:
805
- hasMore && lastRow
806
- ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
962
+ rows.length === limit && rows.at(-1)
963
+ ? encodeDateCursor(
964
+ rows[rows.length - 1].sentDate,
965
+ rows[rows.length - 1].threadMessageId,
966
+ )
807
967
  : undefined,
808
968
  };
809
969
  }
@@ -874,6 +1034,71 @@ export class DrizzleThreadMessageRepository
874
1034
  };
875
1035
  }
876
1036
 
1037
+ async rekeyThread(
1038
+ accountConfigId: string,
1039
+ fromThreadId: string,
1040
+ toThreadId: string,
1041
+ ): Promise<{ moved: number; dropped: number }> {
1042
+ if (fromThreadId === toThreadId) return { moved: 0, dropped: 0 };
1043
+ const rows = await this.db
1044
+ .select({
1045
+ threadMessageId: threadMessageTable.threadMessageId,
1046
+ messageId: threadMessageTable.messageId,
1047
+ })
1048
+ .from(threadMessageTable)
1049
+ .where(
1050
+ and(
1051
+ eq(threadMessageTable.accountConfigId, accountConfigId),
1052
+ eq(threadMessageTable.threadId, fromThreadId),
1053
+ ),
1054
+ );
1055
+ if (rows.length === 0) return { moved: 0, dropped: 0 };
1056
+
1057
+ // Per row: drop it when the target row already exists, otherwise move it
1058
+ // onto the target id. The delete runs first, so the two statements never
1059
+ // both apply to one row, and a rerun finds only rows a failed run left.
1060
+ const items = rows.flatMap((row) => {
1061
+ const target = deriveThreadMessageId(toThreadId, row.messageId);
1062
+ const targetStored = exists(
1063
+ this.db
1064
+ .select({ threadMessageId: threadMessageTable.threadMessageId })
1065
+ .from(threadMessageTable)
1066
+ .where(eq(threadMessageTable.threadMessageId, target)),
1067
+ );
1068
+ const source = and(
1069
+ eq(threadMessageTable.threadMessageId, row.threadMessageId),
1070
+ eq(threadMessageTable.threadId, fromThreadId),
1071
+ );
1072
+ return [
1073
+ this.db
1074
+ .delete(threadMessageTable)
1075
+ .where(and(source, targetStored))
1076
+ .returning({ threadMessageId: threadMessageTable.threadMessageId }),
1077
+ this.db
1078
+ .update(threadMessageTable)
1079
+ .set({ threadId: toThreadId, threadMessageId: target })
1080
+ .where(and(source, not(targetStored)))
1081
+ .returning({ threadMessageId: threadMessageTable.threadMessageId }),
1082
+ ];
1083
+ });
1084
+ const results = await runBatch(this.db, items);
1085
+ await this.search.sync(
1086
+ rows.flatMap((row) => [
1087
+ row.threadMessageId,
1088
+ deriveThreadMessageId(toThreadId, row.messageId),
1089
+ ]),
1090
+ );
1091
+ const count = (parity: number): number =>
1092
+ results.reduce<number>(
1093
+ (total, result, index) =>
1094
+ index % 2 === parity && Array.isArray(result)
1095
+ ? total + result.length
1096
+ : total,
1097
+ 0,
1098
+ );
1099
+ return { moved: count(1), dropped: count(0) };
1100
+ }
1101
+
877
1102
  async findByMessageId(
878
1103
  accountConfigId: string,
879
1104
  messageId: string,
@@ -992,40 +1217,35 @@ export class DrizzleThreadMessageRepository
992
1217
  excludeDeleted?: boolean;
993
1218
  },
994
1219
  ): Promise<ResultList<ThreadMessageItem>> {
995
- const order = options?.order ?? "desc";
996
1220
  const limit = options?.count ?? 100;
997
- const cursor = options?.continuationToken
998
- ? decodeDateCursor(options.continuationToken)
999
- : null;
1000
-
1001
- const rows = await this.db
1002
- .select()
1003
- .from(threadMessageTable)
1004
- .where(
1005
- and(
1006
- eq(threadMessageTable.accountConfigId, accountConfigId),
1007
- eq(threadMessageTable.mailboxId, mailboxId),
1008
- options?.excludeDeleted
1009
- ? eq(threadMessageTable.isDeleted, false)
1010
- : undefined,
1011
- ...buildSearchConditions(search),
1012
- sentDateCursorCond(order, cursor),
1013
- ),
1014
- )
1015
- .orderBy(
1016
- order === "desc"
1017
- ? desc(threadMessageTable.sentDate)
1018
- : asc(threadMessageTable.sentDate),
1019
- asc(threadMessageTable.threadMessageId),
1020
- )
1021
- .limit(limit);
1221
+ const conditions = buildSearchConditions(search);
1222
+ const rows = await this.page({
1223
+ accountConfigId,
1224
+ mailboxIds: [mailboxId],
1225
+ text: conditions.text,
1226
+ row: [
1227
+ options?.excludeDeleted
1228
+ ? eq(threadMessageTable.isDeleted, false)
1229
+ : undefined,
1230
+ ...conditions.row,
1231
+ ],
1232
+ order: options?.order ?? "desc",
1233
+ after: positionOf(
1234
+ options?.continuationToken
1235
+ ? decodeDateCursor(options.continuationToken)
1236
+ : null,
1237
+ ),
1238
+ limit,
1239
+ });
1022
1240
 
1023
- const lastRow = rows[rows.length - 1];
1024
1241
  return {
1025
1242
  items: rows.map(toItem),
1026
1243
  continuationToken:
1027
- rows.length === limit && lastRow
1028
- ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
1244
+ rows.length === limit && rows.at(-1)
1245
+ ? encodeDateCursor(
1246
+ rows[rows.length - 1].sentDate,
1247
+ rows[rows.length - 1].threadMessageId,
1248
+ )
1029
1249
  : undefined,
1030
1250
  };
1031
1251
  }
@@ -1048,45 +1268,40 @@ export class DrizzleThreadMessageRepository
1048
1268
  options?: {
1049
1269
  order?: "asc" | "desc";
1050
1270
  limit?: number;
1051
- continuationToken?: string;
1052
1271
  attributes?: string[];
1272
+ continuationToken?: string;
1053
1273
  excludeDeleted?: boolean;
1054
1274
  },
1055
1275
  ): Promise<ResultList<ThreadMessageItem>> {
1056
- const order = options?.order ?? "desc";
1057
1276
  const limit = clampThreadSearchLimit(options?.limit);
1058
- const cursor = options?.continuationToken
1059
- ? decodeDateCursor(options.continuationToken)
1060
- : null;
1061
-
1062
- const rows = await this.db
1063
- .select()
1064
- .from(threadMessageTable)
1065
- .where(
1066
- and(
1067
- eq(threadMessageTable.accountConfigId, accountConfigId),
1068
- eq(threadMessageTable.mailboxId, mailboxId),
1069
- options?.excludeDeleted
1070
- ? eq(threadMessageTable.isDeleted, false)
1071
- : undefined,
1072
- ...buildSearchConditions(search),
1073
- sentDateCursorCond(order, cursor),
1074
- ),
1075
- )
1076
- .orderBy(
1077
- order === "desc"
1078
- ? desc(threadMessageTable.sentDate)
1079
- : asc(threadMessageTable.sentDate),
1080
- asc(threadMessageTable.threadMessageId),
1081
- )
1082
- .limit(limit);
1277
+ const conditions = buildSearchConditions(search);
1278
+ const rows = await this.page({
1279
+ accountConfigId,
1280
+ mailboxIds: [mailboxId],
1281
+ text: conditions.text,
1282
+ row: [
1283
+ options?.excludeDeleted
1284
+ ? eq(threadMessageTable.isDeleted, false)
1285
+ : undefined,
1286
+ ...conditions.row,
1287
+ ],
1288
+ order: options?.order ?? "desc",
1289
+ after: positionOf(
1290
+ options?.continuationToken
1291
+ ? decodeDateCursor(options.continuationToken)
1292
+ : null,
1293
+ ),
1294
+ limit,
1295
+ });
1083
1296
 
1084
- const lastRow = rows[rows.length - 1];
1085
1297
  return {
1086
1298
  items: rows.map(toItem),
1087
1299
  continuationToken:
1088
- rows.length === limit && lastRow
1089
- ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
1300
+ rows.length === limit && rows.at(-1)
1301
+ ? encodeDateCursor(
1302
+ rows[rows.length - 1].sentDate,
1303
+ rows[rows.length - 1].threadMessageId,
1304
+ )
1090
1305
  : undefined,
1091
1306
  };
1092
1307
  }
@@ -1105,20 +1320,21 @@ export class DrizzleThreadMessageRepository
1105
1320
  order?: "asc" | "desc";
1106
1321
  },
1107
1322
  ): Promise<number> {
1108
- const [{ count }] = await this.db
1109
- .select({ count: sql<number>`cast(count(*) as int)` })
1110
- .from(threadMessageTable)
1111
- .where(
1112
- and(
1113
- eq(threadMessageTable.accountConfigId, accountConfigId),
1114
- eq(threadMessageTable.mailboxId, mailboxId),
1323
+ const conditions = buildSearchConditions(search);
1324
+ return this.countMatches(
1325
+ {
1326
+ accountConfigId,
1327
+ mailboxIds: [mailboxId],
1328
+ text: conditions.text,
1329
+ row: [
1115
1330
  options?.excludeDeleted
1116
1331
  ? eq(threadMessageTable.isDeleted, false)
1117
1332
  : undefined,
1118
- ...buildSearchConditions(search),
1119
- ),
1120
- );
1121
- return count;
1333
+ ...conditions.row,
1334
+ ],
1335
+ },
1336
+ "rows",
1337
+ );
1122
1338
  }
1123
1339
 
1124
1340
  async listAllByAccount(
@@ -1136,6 +1352,7 @@ export class DrizzleThreadMessageRepository
1136
1352
  .delete(threadMessageTable)
1137
1353
  .where(eq(threadMessageTable.accountConfigId, accountConfigId))
1138
1354
  .returning({ id: threadMessageTable.threadMessageId });
1355
+ await this.search.removeAccount(accountConfigId);
1139
1356
  return result.length;
1140
1357
  }
1141
1358
  }