@remit/drizzle-service 0.0.1

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 (82) hide show
  1. package/drizzle.config.ts +15 -0
  2. package/package.json +52 -0
  3. package/src/db.ts +13 -0
  4. package/src/dialect.ts +13 -0
  5. package/src/error.ts +37 -0
  6. package/src/id.ts +50 -0
  7. package/src/index.ts +62 -0
  8. package/src/pagination.ts +29 -0
  9. package/src/repos/cascade-delete.sqlite.test.ts +159 -0
  10. package/src/repos/cascade-delete.test.ts +423 -0
  11. package/src/repos/cascade-delete.ts +219 -0
  12. package/src/repos/envelope.sqlite.test.ts +94 -0
  13. package/src/repos/envelope.test.ts +225 -0
  14. package/src/repos/envelope.ts +342 -0
  15. package/src/repos/filter-anchor.test.ts +131 -0
  16. package/src/repos/filter-anchor.ts +105 -0
  17. package/src/repos/filter.test.ts +279 -0
  18. package/src/repos/filter.ts +253 -0
  19. package/src/repos/i4-account-config.test.ts +105 -0
  20. package/src/repos/i4-account-config.ts +257 -0
  21. package/src/repos/i4-account-export-request.ts +141 -0
  22. package/src/repos/i4-account-setting.test.ts +94 -0
  23. package/src/repos/i4-account-setting.ts +92 -0
  24. package/src/repos/i4-account.test.ts +223 -0
  25. package/src/repos/i4-account.ts +380 -0
  26. package/src/repos/i4-address-wellknown.ts +31 -0
  27. package/src/repos/i4-address.test.ts +358 -0
  28. package/src/repos/i4-address.ts +613 -0
  29. package/src/repos/i4-mailbox-lock.test.ts +368 -0
  30. package/src/repos/i4-mailbox-lock.ts +140 -0
  31. package/src/repos/i4-mailbox-special-use.ts +165 -0
  32. package/src/repos/i4-mailbox.test.ts +188 -0
  33. package/src/repos/i4-mailbox.ts +347 -0
  34. package/src/repos/i4-message-flag-push.test.ts +189 -0
  35. package/src/repos/i4-message-flag-push.ts +173 -0
  36. package/src/repos/i4-message-placement-move.test.ts +135 -0
  37. package/src/repos/i4-message-placement-move.ts +144 -0
  38. package/src/repos/i4-organize-job-request.ts +142 -0
  39. package/src/repos/i4-outbox-message.test.ts +298 -0
  40. package/src/repos/i4-outbox-message.ts +299 -0
  41. package/src/repos/label.conformance.sqlite.test.ts +23 -0
  42. package/src/repos/label.conformance.test.ts +19 -0
  43. package/src/repos/label.ts +125 -0
  44. package/src/repos/mappers.ts +171 -0
  45. package/src/repos/message-flag.ts +162 -0
  46. package/src/repos/message-label.test.ts +110 -0
  47. package/src/repos/message-label.ts +96 -0
  48. package/src/repos/message.sqlite.test.ts +118 -0
  49. package/src/repos/message.test.ts +488 -0
  50. package/src/repos/message.ts +558 -0
  51. package/src/repos/serialized-writes.sqlite.test.ts +199 -0
  52. package/src/repos/test-helpers.ts +222 -0
  53. package/src/repos/thread-message.sqlite.test.ts +198 -0
  54. package/src/repos/thread-message.test.ts +744 -0
  55. package/src/repos/thread-message.ts +832 -0
  56. package/src/repos/thread-search-predicates.ts +79 -0
  57. package/src/repos/unit-of-work.sqlite.test.ts +138 -0
  58. package/src/repos/unit-of-work.test.ts +105 -0
  59. package/src/repos/unit-of-work.ts +31 -0
  60. package/src/schema/active-entities.ts +19 -0
  61. package/src/schema/i4-account-config.ts +4 -0
  62. package/src/schema/i4-account-export-request.ts +3 -0
  63. package/src/schema/i4-account-setting.ts +3 -0
  64. package/src/schema/i4-address.ts +3 -0
  65. package/src/schema/i4-mailbox-lock.ts +3 -0
  66. package/src/schema/i4-mailbox.ts +4 -0
  67. package/src/schema/i4-message-flag-push.ts +3 -0
  68. package/src/schema/i4-message-placement-move.ts +3 -0
  69. package/src/schema/i4-organize-job-request.ts +1 -0
  70. package/src/schema/i4-outbox-message.ts +3 -0
  71. package/src/schema/message-data.ts +44 -0
  72. package/src/schema/outbox.ts +74 -0
  73. package/src/schema/thread-message.ts +3 -0
  74. package/src/schema-full-sqlite.ts +11 -0
  75. package/src/schema-full.ts +16 -0
  76. package/src/schema.ts +28 -0
  77. package/src/sqlite-client.ts +52 -0
  78. package/src/test-db-sqlite.ts +75 -0
  79. package/src/test-db.ts +76 -0
  80. package/src/tx.ts +208 -0
  81. package/src/vps-migrations-drift.test.ts +34 -0
  82. package/tsconfig.json +8 -0
@@ -0,0 +1,558 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ CreateMessageInput,
4
+ IMessageRepository,
5
+ MessageDescription,
6
+ MessageItem,
7
+ } from "@remit/data-ports";
8
+ import { and, asc, eq, gt, inArray, or } from "drizzle-orm";
9
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
10
+ import type { Db } from "../db.js";
11
+ import {
12
+ CreateFailedConflictError,
13
+ isUniqueViolation,
14
+ NotFoundError,
15
+ } from "../error.js";
16
+ import {
17
+ envelopeId as deriveEnvelopeId,
18
+ rootBodyPartId as deriveRootBodyPartId,
19
+ } from "../id.js";
20
+ import { decodeToken, resultList } from "../pagination.js";
21
+ import {
22
+ bodyPartContentTable,
23
+ bodyPartParameterTable,
24
+ bodyPartStorageTable,
25
+ bodyPartTable,
26
+ envelopeAddressTable,
27
+ envelopeTable,
28
+ type MessageDataSchema,
29
+ messageFlagTable,
30
+ messageReferenceTable,
31
+ messageTable,
32
+ outboxTable,
33
+ rawMessageStorageTable,
34
+ } from "../schema/message-data.js";
35
+ import { runInTransaction } from "../tx.js";
36
+ import {
37
+ toBodyPartContentItem,
38
+ toBodyPartItem,
39
+ toBodyPartParameterItem,
40
+ toBodyPartStorageItem,
41
+ toEnvelopeAddressItem,
42
+ toEnvelopeItem,
43
+ toMessageReferenceItem,
44
+ toRawMessageStorageItem,
45
+ } from "./mappers.js";
46
+
47
+ type DB = Db<MessageDataSchema>;
48
+
49
+ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
50
+ return {
51
+ messageId: row.messageId,
52
+ mailboxId: row.mailboxId,
53
+ uid: row.uid,
54
+ sequenceNumber: row.sequenceNumber,
55
+ rfc822Size: row.rfc822Size,
56
+ internalDate: row.internalDate,
57
+ envelopeId: row.envelopeId,
58
+ rootBodyPartId: row.rootBodyPartId,
59
+ status: row.status,
60
+ syncStatus: row.syncStatus,
61
+ category: row.category,
62
+ hasListUnsubscribe: row.hasListUnsubscribe,
63
+ movedByRemit: row.movedByRemit,
64
+ createdAt: row.createdAt,
65
+ updatedAt: row.updatedAt,
66
+ ...(row.messageIdHeader !== null
67
+ ? { messageIdHeader: row.messageIdHeader }
68
+ : {}),
69
+ ...(row.bodyStorageKey !== null
70
+ ? { bodyStorageKey: row.bodyStorageKey }
71
+ : {}),
72
+ ...(row.originalMailboxId !== null
73
+ ? { originalMailboxId: row.originalMailboxId }
74
+ : {}),
75
+ ...(row.originalUid !== null ? { originalUid: row.originalUid } : {}),
76
+ ...(row.authenticity !== null
77
+ ? { authenticity: row.authenticity as MessageItem["authenticity"] }
78
+ : {}),
79
+ ...(row.authResult !== null
80
+ ? { authResult: row.authResult as MessageItem["authResult"] }
81
+ : {}),
82
+ ...(row.providerSpam !== null
83
+ ? { providerSpam: row.providerSpam as MessageItem["providerSpam"] }
84
+ : {}),
85
+ ...(row.placementVerdict !== null
86
+ ? {
87
+ placementVerdict:
88
+ row.placementVerdict as MessageItem["placementVerdict"],
89
+ }
90
+ : {}),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Emitted into the transactional outbox when a message's rows are deleted, so
96
+ * the pg-index worker relays a search-index REMOVE and the vectors are dropped.
97
+ * The Postgres-parity equivalent of the DynamoDB stream's REMOVE record.
98
+ */
99
+ export const MESSAGE_REMOVED_EVENT = "message.removed";
100
+
101
+ export type SubtreeDb = Pick<
102
+ NodePgDatabase<Record<string, unknown>>,
103
+ "delete" | "insert"
104
+ >;
105
+
106
+ /**
107
+ * Delete a message and its whole per-message subtree by message id — the nine
108
+ * child tables, the message row, and its prior CDC outbox rows — then append one
109
+ * `message.removed` outbox row per id so search-index cleanup rides the same
110
+ * transactional outbox as body-sync and move. Runs inside a caller-supplied
111
+ * transaction so the row delete and the removal event commit atomically.
112
+ */
113
+ export async function deleteMessageSubtree(
114
+ tx: SubtreeDb,
115
+ messageIds: string[],
116
+ ): Promise<void> {
117
+ if (messageIds.length === 0) return;
118
+
119
+ await tx
120
+ .delete(messageFlagTable)
121
+ .where(inArray(messageFlagTable.messageId, messageIds));
122
+ await tx
123
+ .delete(messageReferenceTable)
124
+ .where(inArray(messageReferenceTable.messageId, messageIds));
125
+ await tx
126
+ .delete(envelopeAddressTable)
127
+ .where(inArray(envelopeAddressTable.messageId, messageIds));
128
+ await tx
129
+ .delete(bodyPartParameterTable)
130
+ .where(inArray(bodyPartParameterTable.messageId, messageIds));
131
+ await tx
132
+ .delete(bodyPartStorageTable)
133
+ .where(inArray(bodyPartStorageTable.messageId, messageIds));
134
+ await tx
135
+ .delete(bodyPartContentTable)
136
+ .where(inArray(bodyPartContentTable.messageId, messageIds));
137
+ await tx
138
+ .delete(rawMessageStorageTable)
139
+ .where(inArray(rawMessageStorageTable.messageId, messageIds));
140
+ await tx
141
+ .delete(bodyPartTable)
142
+ .where(inArray(bodyPartTable.messageId, messageIds));
143
+ await tx
144
+ .delete(envelopeTable)
145
+ .where(inArray(envelopeTable.messageId, messageIds));
146
+ await tx
147
+ .delete(messageTable)
148
+ .where(inArray(messageTable.messageId, messageIds));
149
+
150
+ await tx
151
+ .delete(outboxTable)
152
+ .where(inArray(outboxTable.messageId, messageIds));
153
+ await tx.insert(outboxTable).values(
154
+ messageIds.map((messageId) => ({
155
+ id: randomUUID(),
156
+ messageId,
157
+ event: MESSAGE_REMOVED_EVENT,
158
+ payload: { messageId },
159
+ createdAt: new Date(),
160
+ })),
161
+ );
162
+ }
163
+
164
+ export class DrizzleMessageRepository implements IMessageRepository {
165
+ constructor(private db: DB) {}
166
+
167
+ async create(input: CreateMessageInput): Promise<MessageItem> {
168
+ const now = Date.now();
169
+ const row = {
170
+ messageId: input.messageId,
171
+ mailboxId: input.mailboxId,
172
+ uid: input.uid,
173
+ sequenceNumber: input.sequenceNumber,
174
+ rfc822Size: input.rfc822Size,
175
+ internalDate: input.internalDate,
176
+ envelopeId: deriveEnvelopeId(input.messageId),
177
+ rootBodyPartId: deriveRootBodyPartId(input.messageId),
178
+ status: input.status ?? ("active" as const),
179
+ syncStatus: input.syncStatus ?? ("pending" as const),
180
+ category: input.category ?? ("uncategorized" as const),
181
+ hasListUnsubscribe: input.hasListUnsubscribe ?? false,
182
+ movedByRemit: input.movedByRemit ?? false,
183
+ messageIdHeader: input.messageIdHeader ?? null,
184
+ bodyStorageKey: input.bodyStorageKey ?? null,
185
+ originalMailboxId: input.originalMailboxId ?? null,
186
+ originalUid: input.originalUid ?? null,
187
+ authenticity: input.authenticity ?? null,
188
+ authResult: input.authResult ?? null,
189
+ providerSpam: input.providerSpam ?? null,
190
+ placementVerdict: input.placementVerdict ?? null,
191
+ createdAt: now,
192
+ updatedAt: now,
193
+ };
194
+
195
+ // Faithful to ElectroDB message.create: a duplicate messageId throws
196
+ // CreateFailedConflictError. The plain insert raises a PG unique
197
+ // violation, which rolls back the transaction so NO outbox row is
198
+ // written; we surface it as the domain conflict error.
199
+ try {
200
+ await runInTransaction(this.db, async (tx) => {
201
+ await tx.insert(messageTable).values(row);
202
+
203
+ await tx.insert(outboxTable).values({
204
+ id: randomUUID(),
205
+ messageId: input.messageId,
206
+ event: "message.created",
207
+ payload: { messageId: input.messageId },
208
+ createdAt: new Date(),
209
+ });
210
+ });
211
+ } catch (error) {
212
+ if (isUniqueViolation(error)) {
213
+ throw new CreateFailedConflictError("Message", input);
214
+ }
215
+ throw error;
216
+ }
217
+
218
+ return this.get(input.messageId);
219
+ }
220
+
221
+ upsert(input: CreateMessageInput): Promise<MessageItem> {
222
+ return this.upsertWithStatus(input).then((result) => result.item);
223
+ }
224
+
225
+ async upsertWithStatus(
226
+ input: CreateMessageInput,
227
+ ): Promise<{ item: MessageItem; created: boolean }> {
228
+ const existing = await this.db
229
+ .select()
230
+ .from(messageTable)
231
+ .where(eq(messageTable.messageId, input.messageId));
232
+
233
+ if (existing.length > 0) {
234
+ return { item: toMessageItem(existing[0]), created: false };
235
+ }
236
+
237
+ const item = await this.create(input);
238
+ return { item, created: true };
239
+ }
240
+
241
+ async get(messageId: string): Promise<MessageItem>;
242
+ async get(messageIds: string[]): Promise<MessageItem[]>;
243
+ async get(
244
+ messageId: string | string[],
245
+ ): Promise<MessageItem | MessageItem[]> {
246
+ if (Array.isArray(messageId)) {
247
+ if (messageId.length === 0) return [];
248
+ const rows = await this.db
249
+ .select()
250
+ .from(messageTable)
251
+ .where(inArray(messageTable.messageId, messageId));
252
+ return rows.map(toMessageItem);
253
+ }
254
+
255
+ const rows = await this.db
256
+ .select()
257
+ .from(messageTable)
258
+ .where(eq(messageTable.messageId, messageId));
259
+ if (rows.length === 0) {
260
+ throw new NotFoundError(`Message not found: ${messageId}`);
261
+ }
262
+ return toMessageItem(rows[0]);
263
+ }
264
+
265
+ async describe(messageId: string): Promise<MessageDescription> {
266
+ const [
267
+ messages,
268
+ messageFlags,
269
+ envelopes,
270
+ messageReferences,
271
+ envelopeAddresses,
272
+ bodyParts,
273
+ bodyPartParameters,
274
+ rawMessageStorages,
275
+ bodyPartStorages,
276
+ bodyPartContents,
277
+ ] = await Promise.all([
278
+ this.db
279
+ .select()
280
+ .from(messageTable)
281
+ .where(eq(messageTable.messageId, messageId)),
282
+ this.db
283
+ .select()
284
+ .from(messageFlagTable)
285
+ .where(eq(messageFlagTable.messageId, messageId)),
286
+ this.db
287
+ .select()
288
+ .from(envelopeTable)
289
+ .where(eq(envelopeTable.messageId, messageId)),
290
+ this.db
291
+ .select()
292
+ .from(messageReferenceTable)
293
+ .where(eq(messageReferenceTable.messageId, messageId)),
294
+ this.db
295
+ .select()
296
+ .from(envelopeAddressTable)
297
+ .where(eq(envelopeAddressTable.messageId, messageId)),
298
+ this.db
299
+ .select()
300
+ .from(bodyPartTable)
301
+ .where(eq(bodyPartTable.messageId, messageId)),
302
+ this.db
303
+ .select()
304
+ .from(bodyPartParameterTable)
305
+ .where(eq(bodyPartParameterTable.messageId, messageId)),
306
+ this.db
307
+ .select()
308
+ .from(rawMessageStorageTable)
309
+ .where(eq(rawMessageStorageTable.messageId, messageId)),
310
+ this.db
311
+ .select()
312
+ .from(bodyPartStorageTable)
313
+ .where(eq(bodyPartStorageTable.messageId, messageId)),
314
+ this.db
315
+ .select()
316
+ .from(bodyPartContentTable)
317
+ .where(eq(bodyPartContentTable.messageId, messageId)),
318
+ ]);
319
+
320
+ if (messages.length === 0) {
321
+ throw new NotFoundError(`Message not found: ${messageId}`);
322
+ }
323
+
324
+ return {
325
+ message: messages.map(toMessageItem),
326
+ messageFlag: messageFlags.map((row) => ({
327
+ messageFlagId: row.messageFlagId,
328
+ messageId: row.messageId,
329
+ flagName: row.flagName,
330
+ setAt: row.setAt,
331
+ createdAt: row.createdAt,
332
+ updatedAt: row.updatedAt,
333
+ })),
334
+ envelope: envelopes.map(toEnvelopeItem),
335
+ messageReference: messageReferences.map(toMessageReferenceItem),
336
+ envelopeAddress: envelopeAddresses.map(toEnvelopeAddressItem),
337
+ bodyPart: bodyParts.map(toBodyPartItem),
338
+ bodyPartParameter: bodyPartParameters.map(toBodyPartParameterItem),
339
+ rawMessageStorage: rawMessageStorages.map(toRawMessageStorageItem),
340
+ bodyPartStorage: bodyPartStorages.map(toBodyPartStorageItem),
341
+ bodyPartContent: bodyPartContents.map(toBodyPartContentItem),
342
+ };
343
+ }
344
+
345
+ async update(
346
+ messageId: string,
347
+ input: Parameters<IMessageRepository["update"]>[1],
348
+ ): ReturnType<IMessageRepository["update"]> {
349
+ const now = Date.now();
350
+ const setValues = {
351
+ ...(input.bodyStorageKey !== undefined
352
+ ? { bodyStorageKey: input.bodyStorageKey }
353
+ : {}),
354
+ ...(input.status !== undefined ? { status: input.status } : {}),
355
+ ...(input.syncStatus !== undefined
356
+ ? { syncStatus: input.syncStatus }
357
+ : {}),
358
+ ...(input.category !== undefined ? { category: input.category } : {}),
359
+ ...(input.hasListUnsubscribe !== undefined
360
+ ? { hasListUnsubscribe: input.hasListUnsubscribe }
361
+ : {}),
362
+ ...(input.movedByRemit !== undefined
363
+ ? { movedByRemit: input.movedByRemit }
364
+ : {}),
365
+ ...(input.authenticity !== undefined
366
+ ? { authenticity: input.authenticity }
367
+ : {}),
368
+ ...(input.authResult !== undefined
369
+ ? { authResult: input.authResult }
370
+ : {}),
371
+ ...(input.providerSpam !== undefined
372
+ ? { providerSpam: input.providerSpam }
373
+ : {}),
374
+ ...(input.placementVerdict !== undefined
375
+ ? { placementVerdict: input.placementVerdict }
376
+ : {}),
377
+ ...(input.messageIdHeader !== undefined
378
+ ? { messageIdHeader: input.messageIdHeader }
379
+ : {}),
380
+ updatedAt: now,
381
+ };
382
+
383
+ // A non-empty bodyStorageKey means body-sync just persisted the parsed
384
+ // body, so the message now has embeddable content and its threadMessage
385
+ // exists. Append a search-index event in the same transaction as the write
386
+ // (transactional outbox) — the pg-index worker relays it to SQS and embeds.
387
+ // The outbox is append-only: the worker's content-hash gate makes a
388
+ // redundant pass near-free.
389
+ const bodySynced =
390
+ typeof input.bodyStorageKey === "string" &&
391
+ input.bodyStorageKey.length > 0;
392
+
393
+ await runInTransaction(this.db, async (tx) => {
394
+ await tx
395
+ .update(messageTable)
396
+ .set(setValues)
397
+ .where(eq(messageTable.messageId, messageId));
398
+
399
+ if (bodySynced) {
400
+ await tx.insert(outboxTable).values({
401
+ id: randomUUID(),
402
+ messageId,
403
+ event: "message.body_synced",
404
+ payload: { messageId },
405
+ createdAt: new Date(),
406
+ });
407
+ }
408
+ });
409
+ return this.get(messageId);
410
+ }
411
+
412
+ async clearBodyStorageKey(
413
+ messageId: string,
414
+ ): ReturnType<IMessageRepository["clearBodyStorageKey"]> {
415
+ const now = Date.now();
416
+ await this.db
417
+ .update(messageTable)
418
+ .set({ bodyStorageKey: null, updatedAt: now })
419
+ .where(eq(messageTable.messageId, messageId));
420
+ return this.get(messageId);
421
+ }
422
+
423
+ async delete(messageId: string): Promise<void> {
424
+ await this.deleteMany([messageId]);
425
+ }
426
+
427
+ async deleteMany(messageIds: string[]): Promise<void> {
428
+ if (messageIds.length === 0) return;
429
+ await runInTransaction(this.db, (tx) =>
430
+ deleteMessageSubtree(tx as unknown as SubtreeDb, messageIds),
431
+ );
432
+ }
433
+
434
+ async listByMailbox(
435
+ mailboxId: string,
436
+ options?: Parameters<IMessageRepository["listByMailbox"]>[1],
437
+ ): ReturnType<IMessageRepository["listByMailbox"]> {
438
+ const limit = options?.limit ?? 100;
439
+ const cursor = options?.continuationToken
440
+ ? decodeToken(options.continuationToken)
441
+ : undefined;
442
+ const after = cursor
443
+ ? { uid: cursor.uid as number, messageId: cursor.messageId as string }
444
+ : undefined;
445
+
446
+ const rows = await this.db
447
+ .select()
448
+ .from(messageTable)
449
+ .where(
450
+ and(
451
+ eq(messageTable.mailboxId, mailboxId),
452
+ after
453
+ ? or(
454
+ gt(messageTable.uid, after.uid),
455
+ and(
456
+ eq(messageTable.uid, after.uid),
457
+ gt(messageTable.messageId, after.messageId),
458
+ ),
459
+ )
460
+ : undefined,
461
+ ),
462
+ )
463
+ .orderBy(asc(messageTable.uid), asc(messageTable.messageId))
464
+ .limit(limit + 1);
465
+
466
+ const hasMore = rows.length > limit;
467
+ const items = rows.slice(0, limit).map(toMessageItem);
468
+ const lastItem = items[items.length - 1];
469
+ return resultList(
470
+ items,
471
+ limit,
472
+ hasMore && lastItem
473
+ ? { uid: lastItem.uid, messageId: lastItem.messageId }
474
+ : undefined,
475
+ );
476
+ }
477
+
478
+ async listAllByMailbox(
479
+ mailboxId: string,
480
+ ): ReturnType<IMessageRepository["listAllByMailbox"]> {
481
+ const rows = await this.db
482
+ .select()
483
+ .from(messageTable)
484
+ .where(eq(messageTable.mailboxId, mailboxId))
485
+ .orderBy(asc(messageTable.uid), asc(messageTable.messageId));
486
+ return rows.map(toMessageItem);
487
+ }
488
+
489
+ async updateForMove(
490
+ messageId: string,
491
+ input: Parameters<IMessageRepository["updateForMove"]>[1],
492
+ ): ReturnType<IMessageRepository["updateForMove"]> {
493
+ const setValues = {
494
+ ...(input.mailboxId !== undefined ? { mailboxId: input.mailboxId } : {}),
495
+ ...(input.uid !== undefined ? { uid: input.uid } : {}),
496
+ ...(input.status !== undefined ? { status: input.status } : {}),
497
+ ...(input.syncStatus !== undefined
498
+ ? { syncStatus: input.syncStatus }
499
+ : {}),
500
+ ...(input.originalMailboxId !== undefined
501
+ ? { originalMailboxId: input.originalMailboxId }
502
+ : {}),
503
+ ...(input.originalUid !== undefined
504
+ ? { originalUid: input.originalUid }
505
+ : {}),
506
+ updatedAt: Date.now(),
507
+ };
508
+
509
+ const rows = await this.db
510
+ .update(messageTable)
511
+ .set(setValues)
512
+ .where(eq(messageTable.messageId, messageId))
513
+ .returning();
514
+ if (rows.length === 0) {
515
+ throw new NotFoundError(`Message not found: ${messageId}`);
516
+ }
517
+ return toMessageItem(rows[0]);
518
+ }
519
+
520
+ async updateUid(
521
+ messageId: string,
522
+ newUid: number,
523
+ newMailboxId: string,
524
+ ): ReturnType<IMessageRepository["updateUid"]> {
525
+ // updateUid settles a move against confirmed IMAP state (the destination
526
+ // mailbox and its COPYUID). The message's search vectors still carry the
527
+ // old mailbox in their metadata and their body is unchanged, so a normal
528
+ // re-index would skip them on content hash. Enqueue a move re-index event
529
+ // in the same transaction as the update; the pg-index worker drains it
530
+ // with force, refreshing the stored mailbox metadata.
531
+ const rows = await runInTransaction(this.db, async (tx) => {
532
+ const updated = await tx
533
+ .update(messageTable)
534
+ .set({
535
+ uid: newUid,
536
+ mailboxId: newMailboxId,
537
+ status: "active",
538
+ syncStatus: "synced",
539
+ updatedAt: Date.now(),
540
+ })
541
+ .where(eq(messageTable.messageId, messageId))
542
+ .returning();
543
+ if (updated.length === 0) {
544
+ throw new NotFoundError(`Message not found: ${messageId}`);
545
+ }
546
+
547
+ await tx.insert(outboxTable).values({
548
+ id: randomUUID(),
549
+ messageId,
550
+ event: "message.moved",
551
+ payload: { messageId },
552
+ createdAt: new Date(),
553
+ });
554
+ return updated;
555
+ });
556
+ return toMessageItem(rows[0]);
557
+ }
558
+ }