@remit/drizzle-service 0.0.99 → 0.0.100

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 +45 -0
  4. package/src/databases.ts +70 -0
  5. package/src/db.ts +1 -4
  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
@@ -1,4 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import type {
3
2
  CreateMessageInput,
4
3
  IMessageRepository,
@@ -6,8 +5,20 @@ import type {
6
5
  MessageItem,
7
6
  PlacementPredicate,
8
7
  } from "@remit/data-ports";
9
- import { and, asc, eq, gt, inArray, isNull, or, type SQL } from "drizzle-orm";
10
-
8
+ import {
9
+ and,
10
+ asc,
11
+ eq,
12
+ exists,
13
+ gt,
14
+ inArray,
15
+ isNotNull,
16
+ isNull,
17
+ lt,
18
+ or,
19
+ type SQL,
20
+ } from "drizzle-orm";
21
+ import { runBatch, type SqliteBatchItem } from "../batch.js";
11
22
  import type { Db } from "../db.js";
12
23
  import {
13
24
  CreateFailedConflictError,
@@ -20,22 +31,21 @@ import {
20
31
  } from "../id.js";
21
32
  import { decodeToken, resultList } from "../pagination.js";
22
33
  import {
23
- bodyPartContentTable,
24
34
  bodyPartParameterTable,
25
35
  bodyPartStorageTable,
26
36
  bodyPartTable,
37
+ bodyStructureRefetchTable,
27
38
  envelopeAddressTable,
28
39
  envelopeTable,
29
40
  type MessageDataSchema,
30
41
  messageFlagTable,
31
42
  messageReferenceTable,
32
43
  messageTable,
33
- outboxTable,
34
44
  rawMessageStorageTable,
35
45
  } from "../schema/message-data.js";
36
- import { runInTransaction } from "../tx.js";
46
+ import { threadMessageTable } from "../schema/thread-message.js";
47
+ import type { SearchIndex } from "../search/thread-message-search.js";
37
48
  import {
38
- toBodyPartContentItem,
39
49
  toBodyPartItem,
40
50
  toBodyPartParameterItem,
41
51
  toBodyPartStorageItem,
@@ -47,13 +57,6 @@ import {
47
57
 
48
58
  type DB = Db<MessageDataSchema>;
49
59
 
50
- const REEMBED_BATCH_SIZE = 500;
51
-
52
- export interface ReembedRequest {
53
- queued: number;
54
- alreadyQueued: number;
55
- }
56
-
57
60
  function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
58
61
  return {
59
62
  messageId: row.messageId,
@@ -74,9 +77,6 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
74
77
  movedByRemit: row.movedByRemit,
75
78
  createdAt: row.createdAt,
76
79
  updatedAt: row.updatedAt,
77
- ...(row.messageIdHeader !== null
78
- ? { messageIdHeader: row.messageIdHeader }
79
- : {}),
80
80
  ...(row.bodyStorageKey !== null
81
81
  ? { bodyStorageKey: row.bodyStorageKey }
82
82
  : {}),
@@ -111,71 +111,56 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
111
111
  };
112
112
  }
113
113
 
114
- /**
115
- * Emitted into the transactional outbox when a message's rows are deleted, so
116
- * the search-index worker relays a search-index REMOVE and the vectors are
117
- * dropped.
118
- */
119
- export const MESSAGE_REMOVED_EVENT = "message.removed" as const;
120
-
121
- export type SubtreeDb = Pick<Db<Record<string, unknown>>, "delete" | "insert">;
114
+ export type SubtreeDb = Pick<Db<Record<string, unknown>>, "delete">;
122
115
 
123
116
  /**
124
- * Delete a message and its whole per-message subtree by message id — the nine
125
- * child tables, the message row, and its prior CDC outbox rows — then append one
126
- * `message.removed` outbox row per id so search-index cleanup rides the same
127
- * transactional outbox as body-sync and move. Runs inside a caller-supplied
128
- * transaction so the row delete and the removal event commit atomically.
117
+ * The statements that delete a message and its whole per-message subtree by
118
+ * message id: the child tables, then the message row. The caller runs them as
119
+ * one batch so the subtree goes atomically; the last statement returns the
120
+ * message ids it removed. `guard` rides every statement, so
121
+ * a guard that reads the message row holds until the last statement removes it.
129
122
  */
130
- export async function deleteMessageSubtree(
131
- tx: SubtreeDb,
123
+ export function deleteMessageSubtree(
124
+ db: SubtreeDb,
132
125
  messageIds: string[],
133
- ): Promise<void> {
134
- if (messageIds.length === 0) return;
135
-
136
- await tx
137
- .delete(messageFlagTable)
138
- .where(inArray(messageFlagTable.messageId, messageIds));
139
- await tx
140
- .delete(messageReferenceTable)
141
- .where(inArray(messageReferenceTable.messageId, messageIds));
142
- await tx
143
- .delete(envelopeAddressTable)
144
- .where(inArray(envelopeAddressTable.messageId, messageIds));
145
- await tx
146
- .delete(bodyPartParameterTable)
147
- .where(inArray(bodyPartParameterTable.messageId, messageIds));
148
- await tx
149
- .delete(bodyPartStorageTable)
150
- .where(inArray(bodyPartStorageTable.messageId, messageIds));
151
- await tx
152
- .delete(bodyPartContentTable)
153
- .where(inArray(bodyPartContentTable.messageId, messageIds));
154
- await tx
155
- .delete(rawMessageStorageTable)
156
- .where(inArray(rawMessageStorageTable.messageId, messageIds));
157
- await tx
158
- .delete(bodyPartTable)
159
- .where(inArray(bodyPartTable.messageId, messageIds));
160
- await tx
161
- .delete(envelopeTable)
162
- .where(inArray(envelopeTable.messageId, messageIds));
163
- await tx
164
- .delete(messageTable)
165
- .where(inArray(messageTable.messageId, messageIds));
166
-
167
- await tx
168
- .delete(outboxTable)
169
- .where(inArray(outboxTable.messageId, messageIds));
170
- await tx.insert(outboxTable).values(
171
- messageIds.map((messageId) => ({
172
- id: randomUUID(),
173
- messageId,
174
- event: MESSAGE_REMOVED_EVENT,
175
- payload: { messageId },
176
- createdAt: new Date(),
177
- })),
178
- );
126
+ guard?: SQL,
127
+ ): SqliteBatchItem[] {
128
+ if (messageIds.length === 0) return [];
129
+ return [
130
+ db
131
+ .delete(messageFlagTable)
132
+ .where(and(inArray(messageFlagTable.messageId, messageIds), guard)),
133
+ db
134
+ .delete(messageReferenceTable)
135
+ .where(and(inArray(messageReferenceTable.messageId, messageIds), guard)),
136
+ db
137
+ .delete(envelopeAddressTable)
138
+ .where(and(inArray(envelopeAddressTable.messageId, messageIds), guard)),
139
+ db
140
+ .delete(bodyPartParameterTable)
141
+ .where(and(inArray(bodyPartParameterTable.messageId, messageIds), guard)),
142
+ db
143
+ .delete(bodyPartStorageTable)
144
+ .where(and(inArray(bodyPartStorageTable.messageId, messageIds), guard)),
145
+ db
146
+ .delete(rawMessageStorageTable)
147
+ .where(and(inArray(rawMessageStorageTable.messageId, messageIds), guard)),
148
+ db
149
+ .delete(bodyPartTable)
150
+ .where(and(inArray(bodyPartTable.messageId, messageIds), guard)),
151
+ db
152
+ .delete(bodyStructureRefetchTable)
153
+ .where(
154
+ and(inArray(bodyStructureRefetchTable.messageId, messageIds), guard),
155
+ ),
156
+ db
157
+ .delete(envelopeTable)
158
+ .where(and(inArray(envelopeTable.messageId, messageIds), guard)),
159
+ db
160
+ .delete(messageTable)
161
+ .where(and(inArray(messageTable.messageId, messageIds), guard))
162
+ .returning({ messageId: messageTable.messageId }),
163
+ ];
179
164
  }
180
165
 
181
166
  /**
@@ -207,11 +192,14 @@ const placementTerms = (expected: PlacementPredicate): SQL[] => {
207
192
  };
208
193
 
209
194
  export class DrizzleMessageRepository implements IMessageRepository {
210
- constructor(private db: DB) {}
195
+ constructor(
196
+ private db: DB,
197
+ private search: SearchIndex,
198
+ ) {}
211
199
 
212
- async create(input: CreateMessageInput): Promise<MessageItem> {
200
+ private rowOf(input: CreateMessageInput) {
213
201
  const now = Date.now();
214
- const row = {
202
+ return {
215
203
  messageId: input.messageId,
216
204
  mailboxId: input.mailboxId,
217
205
  uid: input.uid,
@@ -229,7 +217,6 @@ export class DrizzleMessageRepository implements IMessageRepository {
229
217
  input.authenticityVerdict ?? ("NotEvaluated" as const),
230
218
  hasListUnsubscribe: input.hasListUnsubscribe ?? false,
231
219
  movedByRemit: input.movedByRemit ?? false,
232
- messageIdHeader: input.messageIdHeader ?? null,
233
220
  bodyStorageKey: input.bodyStorageKey ?? null,
234
221
  originalMailboxId: input.originalMailboxId ?? null,
235
222
  originalUid: input.originalUid ?? null,
@@ -243,14 +230,13 @@ export class DrizzleMessageRepository implements IMessageRepository {
243
230
  createdAt: now,
244
231
  updatedAt: now,
245
232
  };
233
+ }
234
+
235
+ async create(input: CreateMessageInput): Promise<MessageItem> {
236
+ const row = this.rowOf(input);
246
237
 
247
238
  // Faithful to ElectroDB message.create: a duplicate messageId raises a
248
239
  // unique-constraint violation, surfaced as the domain conflict error.
249
- //
250
- // No outbox event. A freshly created message has neither a body nor a
251
- // threadMessage yet, so there is nothing to index — the search-index
252
- // relay never drained a creation event, and the rows accumulated forever
253
- // (reader#1063). Body-sync appends the event once there is content.
254
240
  try {
255
241
  await this.db.insert(messageTable).values(row);
256
242
  } catch (error) {
@@ -270,17 +256,13 @@ export class DrizzleMessageRepository implements IMessageRepository {
270
256
  async upsertWithStatus(
271
257
  input: CreateMessageInput,
272
258
  ): Promise<{ item: MessageItem; created: boolean }> {
273
- const existing = await this.db
274
- .select()
275
- .from(messageTable)
276
- .where(eq(messageTable.messageId, input.messageId));
277
-
278
- if (existing.length > 0) {
279
- return { item: toMessageItem(existing[0]), created: false };
280
- }
281
-
282
- const item = await this.create(input);
283
- return { item, created: true };
259
+ const [inserted] = await this.db
260
+ .insert(messageTable)
261
+ .values(this.rowOf(input))
262
+ .onConflictDoNothing({ target: messageTable.messageId })
263
+ .returning();
264
+ if (inserted) return { item: toMessageItem(inserted), created: true };
265
+ return { item: await this.get(input.messageId), created: false };
284
266
  }
285
267
 
286
268
  async get(messageId: string): Promise<MessageItem>;
@@ -318,7 +300,6 @@ export class DrizzleMessageRepository implements IMessageRepository {
318
300
  bodyPartParameters,
319
301
  rawMessageStorages,
320
302
  bodyPartStorages,
321
- bodyPartContents,
322
303
  ] = await Promise.all([
323
304
  this.db
324
305
  .select()
@@ -356,10 +337,6 @@ export class DrizzleMessageRepository implements IMessageRepository {
356
337
  .select()
357
338
  .from(bodyPartStorageTable)
358
339
  .where(eq(bodyPartStorageTable.messageId, messageId)),
359
- this.db
360
- .select()
361
- .from(bodyPartContentTable)
362
- .where(eq(bodyPartContentTable.messageId, messageId)),
363
340
  ]);
364
341
 
365
342
  if (messages.length === 0) {
@@ -383,7 +360,6 @@ export class DrizzleMessageRepository implements IMessageRepository {
383
360
  bodyPartParameter: bodyPartParameters.map(toBodyPartParameterItem),
384
361
  rawMessageStorage: rawMessageStorages.map(toRawMessageStorageItem),
385
362
  bodyPartStorage: bodyPartStorages.map(toBodyPartStorageItem),
386
- bodyPartContent: bodyPartContents.map(toBodyPartContentItem),
387
363
  };
388
364
  }
389
365
 
@@ -427,42 +403,16 @@ export class DrizzleMessageRepository implements IMessageRepository {
427
403
  ...(input.placementDecidedAt !== undefined
428
404
  ? { placementDecidedAt: input.placementDecidedAt }
429
405
  : {}),
430
- ...(input.messageIdHeader !== undefined
431
- ? { messageIdHeader: input.messageIdHeader }
432
- : {}),
433
406
  ...(input.spamReport !== undefined
434
407
  ? { spamReport: input.spamReport }
435
408
  : {}),
436
409
  updatedAt: now,
437
410
  };
438
411
 
439
- // A non-empty bodyStorageKey means body-sync just persisted the parsed
440
- // body, so the message now has embeddable content and its threadMessage
441
- // exists. Append a search-index event in the same transaction as the write
442
- // (transactional outbox) — the search-index worker relays it to SQS and
443
- // embeds.
444
- // The outbox is append-only: the worker's content-hash gate makes a
445
- // redundant pass near-free.
446
- const bodySynced =
447
- typeof input.bodyStorageKey === "string" &&
448
- input.bodyStorageKey.length > 0;
449
-
450
- await runInTransaction(this.db, async (tx) => {
451
- await tx
452
- .update(messageTable)
453
- .set(setValues)
454
- .where(eq(messageTable.messageId, messageId));
455
-
456
- if (bodySynced) {
457
- await tx.insert(outboxTable).values({
458
- id: randomUUID(),
459
- messageId,
460
- event: "message.body_synced",
461
- payload: { messageId },
462
- createdAt: new Date(),
463
- });
464
- }
465
- });
412
+ await this.db
413
+ .update(messageTable)
414
+ .set(setValues)
415
+ .where(eq(messageTable.messageId, messageId));
466
416
  return this.get(messageId);
467
417
  }
468
418
 
@@ -496,28 +446,35 @@ export class DrizzleMessageRepository implements IMessageRepository {
496
446
  messageId: string,
497
447
  expected: PlacementPredicate,
498
448
  ): Promise<boolean> {
499
- return runInTransaction(this.db, async (tx) => {
500
- const claimed = await tx
501
- .update(messageTable)
502
- .set({ updatedAt: Date.now() })
449
+ const placed = exists(
450
+ this.db
451
+ .select({ messageId: messageTable.messageId })
452
+ .from(messageTable)
503
453
  .where(
504
454
  and(
505
455
  eq(messageTable.messageId, messageId),
506
456
  ...placementTerms(expected),
507
457
  ),
508
- )
509
- .returning({ messageId: messageTable.messageId });
510
- if (claimed.length === 0) return false;
511
- await deleteMessageSubtree(tx as unknown as SubtreeDb, [messageId]);
512
- return true;
513
- });
458
+ ),
459
+ );
460
+ const listings = await this.db
461
+ .select({ threadMessageId: threadMessageTable.threadMessageId })
462
+ .from(threadMessageTable)
463
+ .where(eq(threadMessageTable.messageId, messageId));
464
+ const results = await runBatch(this.db, [
465
+ this.db
466
+ .delete(threadMessageTable)
467
+ .where(and(eq(threadMessageTable.messageId, messageId), placed)),
468
+ ...deleteMessageSubtree(this.db, [messageId], placed),
469
+ ]);
470
+ await this.search.sync(listings.map((row) => row.threadMessageId));
471
+ const removed = results.at(-1);
472
+ return Array.isArray(removed) && removed.length > 0;
514
473
  }
515
474
 
516
475
  async deleteMany(messageIds: string[]): Promise<void> {
517
476
  if (messageIds.length === 0) return;
518
- await runInTransaction(this.db, (tx) =>
519
- deleteMessageSubtree(tx as unknown as SubtreeDb, messageIds),
520
- );
477
+ await runBatch(this.db, deleteMessageSubtree(this.db, messageIds));
521
478
  }
522
479
 
523
480
  async listByMailbox(
@@ -575,6 +532,41 @@ export class DrizzleMessageRepository implements IMessageRepository {
575
532
  return rows.map(toMessageItem);
576
533
  }
577
534
 
535
+ async listStrandedPlacements(
536
+ mailboxIds: readonly string[],
537
+ untouchedSince: number,
538
+ ): ReturnType<IMessageRepository["listStrandedPlacements"]> {
539
+ if (mailboxIds.length === 0) return [];
540
+ const ids = [...mailboxIds];
541
+ const rows = await this.db
542
+ .select()
543
+ .from(messageTable)
544
+ .where(
545
+ and(
546
+ lt(messageTable.updatedAt, untouchedSince),
547
+ or(
548
+ and(
549
+ eq(messageTable.status, "moving"),
550
+ inArray(messageTable.originalMailboxId, ids),
551
+ isNotNull(messageTable.originalUid),
552
+ ),
553
+ and(
554
+ eq(messageTable.status, "deleting"),
555
+ inArray(messageTable.mailboxId, ids),
556
+ ),
557
+ and(
558
+ eq(messageTable.status, "moving"),
559
+ eq(messageTable.uid, 0),
560
+ isNull(messageTable.originalUid),
561
+ inArray(messageTable.mailboxId, ids),
562
+ ),
563
+ ),
564
+ ),
565
+ )
566
+ .orderBy(asc(messageTable.updatedAt), asc(messageTable.messageId));
567
+ return rows.map(toMessageItem);
568
+ }
569
+
578
570
  async transitionPlacement(
579
571
  messageId: string,
580
572
  expected: PlacementPredicate,
@@ -608,17 +600,68 @@ export class DrizzleMessageRepository implements IMessageRepository {
608
600
  return toMessageItem(rows[0]);
609
601
  }
610
602
 
603
+ async repointIfPlacement(
604
+ messageId: string,
605
+ expected: PlacementPredicate,
606
+ to: { mailboxId: string; uid: number; isDeleted: boolean },
607
+ ): Promise<boolean> {
608
+ const now = Date.now();
609
+ const landed = exists(
610
+ this.db
611
+ .select({ messageId: messageTable.messageId })
612
+ .from(messageTable)
613
+ .where(
614
+ and(
615
+ eq(messageTable.messageId, messageId),
616
+ eq(messageTable.mailboxId, to.mailboxId),
617
+ eq(messageTable.uid, to.uid),
618
+ ),
619
+ ),
620
+ );
621
+ const listings = await this.db
622
+ .select({ threadMessageId: threadMessageTable.threadMessageId })
623
+ .from(threadMessageTable)
624
+ .where(eq(threadMessageTable.messageId, messageId));
625
+ const [moved] = await runBatch(this.db, [
626
+ this.db
627
+ .update(messageTable)
628
+ .set({
629
+ uid: to.uid,
630
+ mailboxId: to.mailboxId,
631
+ originalUid: null,
632
+ status: "active",
633
+ syncStatus: "synced",
634
+ abandonedMutation: "none",
635
+ updatedAt: now,
636
+ })
637
+ .where(
638
+ and(
639
+ eq(messageTable.messageId, messageId),
640
+ ...placementTerms(expected),
641
+ ),
642
+ )
643
+ .returning({ messageId: messageTable.messageId }),
644
+ this.db
645
+ .update(threadMessageTable)
646
+ .set({
647
+ mailboxId: to.mailboxId,
648
+ uid: to.uid,
649
+ isDeleted: to.isDeleted,
650
+ updatedAt: now,
651
+ })
652
+ .where(and(eq(threadMessageTable.messageId, messageId), landed)),
653
+ ] as const);
654
+ await this.search.sync(listings.map((row) => row.threadMessageId));
655
+ return moved.length > 0;
656
+ }
657
+
611
658
  async updateUid(
612
659
  messageId: string,
613
660
  newUid: number,
614
661
  newMailboxId: string,
615
662
  ): ReturnType<IMessageRepository["updateUid"]> {
616
663
  // updateUid settles a move against confirmed IMAP state (the destination
617
- // mailbox and its COPYUID). The message's search vectors still carry the
618
- // old mailbox in their metadata and their body is unchanged, so a normal
619
- // re-index would skip them on content hash. Enqueue a move re-index event
620
- // in the same transaction as the update; the search-index worker drains it
621
- // with force, refreshing the stored mailbox metadata.
664
+ // mailbox and its COPYUID).
622
665
  //
623
666
  // `originalUid` goes with the settle. It exists to say the row's `uid`
624
667
  // was recorded under `originalMailboxId`, and that stops being true here;
@@ -626,82 +669,27 @@ export class DrizzleMessageRepository implements IMessageRepository {
626
669
  // in-flight one whenever the destination's counter happens to hand back
627
670
  // the source's number, which on a young account is most of them (#1217).
628
671
  // `originalMailboxId` stays: Undo restores the message to it.
629
- const rows = await runInTransaction(this.db, async (tx) => {
630
- const updated = await tx
631
- .update(messageTable)
632
- .set({
633
- uid: newUid,
634
- mailboxId: newMailboxId,
635
- originalUid: null,
636
- status: "active",
637
- syncStatus: "synced",
638
- // The settle clears the epitaph as well as the pair. `syncStatus`
639
- // alone already gates every reader of it, so this is tidiness
640
- // rather than correctness — but a row that has just settled has
641
- // nothing it gave up on, and leaving a value there invites a
642
- // reader that forgets the gate.
643
- abandonedMutation: "none",
644
- updatedAt: Date.now(),
645
- })
646
- .where(eq(messageTable.messageId, messageId))
647
- .returning();
648
- if (updated.length === 0) {
649
- throw new NotFoundError(`Message not found: ${messageId}`);
650
- }
651
-
652
- await tx.insert(outboxTable).values({
653
- id: randomUUID(),
654
- messageId,
655
- event: "message.moved",
656
- payload: { messageId },
657
- createdAt: new Date(),
658
- });
659
- return updated;
660
- });
661
- return toMessageItem(rows[0]);
662
- }
663
-
664
- async requestReembed(messageIds: string[]): Promise<ReembedRequest> {
665
- const request: ReembedRequest = { queued: 0, alreadyQueued: 0 };
666
- for (
667
- let start = 0;
668
- start < messageIds.length;
669
- start += REEMBED_BATCH_SIZE
670
- ) {
671
- const batch = messageIds.slice(start, start + REEMBED_BATCH_SIZE);
672
- await runInTransaction(this.db, async (tx) => {
673
- const present = await tx
674
- .select({ messageId: messageTable.messageId })
675
- .from(messageTable)
676
- .where(inArray(messageTable.messageId, batch));
677
- const pending = await tx
678
- .selectDistinct({ messageId: outboxTable.messageId })
679
- .from(outboxTable)
680
- .where(
681
- and(
682
- inArray(outboxTable.messageId, batch),
683
- eq(outboxTable.event, "message.moved"),
684
- isNull(outboxTable.processedAt),
685
- ),
686
- );
687
- const pendingIds = new Set(pending.map(({ messageId }) => messageId));
688
- const toQueue = present.filter(
689
- ({ messageId }) => !pendingIds.has(messageId),
690
- );
691
- request.alreadyQueued += present.length - toQueue.length;
692
- request.queued += toQueue.length;
693
- if (toQueue.length === 0) return;
694
- await tx.insert(outboxTable).values(
695
- toQueue.map(({ messageId }) => ({
696
- id: randomUUID(),
697
- messageId,
698
- event: "message.moved" as const,
699
- payload: { messageId },
700
- createdAt: new Date(),
701
- })),
702
- );
703
- });
672
+ const rows = await this.db
673
+ .update(messageTable)
674
+ .set({
675
+ uid: newUid,
676
+ mailboxId: newMailboxId,
677
+ originalUid: null,
678
+ status: "active",
679
+ syncStatus: "synced",
680
+ // The settle clears the epitaph as well as the pair. `syncStatus`
681
+ // alone already gates every reader of it, so this is tidiness
682
+ // rather than correctness — but a row that has just settled has
683
+ // nothing it gave up on, and leaving a value there invites a
684
+ // reader that forgets the gate.
685
+ abandonedMutation: "none",
686
+ updatedAt: Date.now(),
687
+ })
688
+ .where(eq(messageTable.messageId, messageId))
689
+ .returning();
690
+ if (rows.length === 0) {
691
+ throw new NotFoundError(`Message not found: ${messageId}`);
704
692
  }
705
- return request;
693
+ return toMessageItem(rows[0]);
706
694
  }
707
695
  }
@@ -0,0 +1,76 @@
1
+ import type {
2
+ ISyncWarningRepository,
3
+ SurfacedSyncWarnings,
4
+ SyncWarningItem,
5
+ SyncWarningKey,
6
+ } from "@remit/data-ports";
7
+ import { deriveSyncWarningId } from "@remit/data-ports/id";
8
+ import { and, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
9
+ import type { Db } from "../db.js";
10
+ import { syncWarningTable } from "../schema/sync-warning.js";
11
+
12
+ type DB = Db<Record<string, unknown>>;
13
+
14
+ export class SyncWarningRepo implements ISyncWarningRepository {
15
+ constructor(private db: DB) {}
16
+
17
+ listByMailboxId = async (mailboxId: string): Promise<SyncWarningItem[]> =>
18
+ this.db
19
+ .select()
20
+ .from(syncWarningTable)
21
+ .where(eq(syncWarningTable.mailboxId, mailboxId));
22
+
23
+ listSurfacedByAccountConfigId = async (
24
+ accountConfigId: string,
25
+ minAttempts: number,
26
+ limit: number,
27
+ ): Promise<SurfacedSyncWarnings> => {
28
+ const surfaced = and(
29
+ eq(syncWarningTable.accountConfigId, accountConfigId),
30
+ gte(syncWarningTable.attempts, minAttempts),
31
+ );
32
+ const items = await this.db
33
+ .select()
34
+ .from(syncWarningTable)
35
+ .where(surfaced)
36
+ .orderBy(desc(syncWarningTable.createdAt))
37
+ .limit(limit);
38
+ const [{ total }] = await this.db
39
+ .select({ total: count() })
40
+ .from(syncWarningTable)
41
+ .where(surfaced);
42
+ return { items, total };
43
+ };
44
+
45
+ recordFailures = async (keys: SyncWarningKey[]): Promise<void> => {
46
+ if (keys.length === 0) return;
47
+ const now = Date.now();
48
+ await this.db
49
+ .insert(syncWarningTable)
50
+ .values(
51
+ keys.map((key) => ({
52
+ syncWarningId: deriveSyncWarningId(
53
+ key.accountId,
54
+ key.mailboxId,
55
+ key.uidValidity,
56
+ key.uid,
57
+ key.stage,
58
+ ),
59
+ ...key,
60
+ attempts: 1,
61
+ createdAt: now,
62
+ })),
63
+ )
64
+ .onConflictDoUpdate({
65
+ target: syncWarningTable.syncWarningId,
66
+ set: { attempts: sql`${syncWarningTable.attempts} + 1` },
67
+ });
68
+ };
69
+
70
+ deleteMany = async (syncWarningIds: string[]): Promise<void> => {
71
+ if (syncWarningIds.length === 0) return;
72
+ await this.db
73
+ .delete(syncWarningTable)
74
+ .where(inArray(syncWarningTable.syncWarningId, syncWarningIds));
75
+ };
76
+ }