@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.
- package/drizzle.config.ts +15 -0
- package/package.json +52 -0
- package/src/db.ts +13 -0
- package/src/dialect.ts +13 -0
- package/src/error.ts +37 -0
- package/src/id.ts +50 -0
- package/src/index.ts +62 -0
- package/src/pagination.ts +29 -0
- package/src/repos/cascade-delete.sqlite.test.ts +159 -0
- package/src/repos/cascade-delete.test.ts +423 -0
- package/src/repos/cascade-delete.ts +219 -0
- package/src/repos/envelope.sqlite.test.ts +94 -0
- package/src/repos/envelope.test.ts +225 -0
- package/src/repos/envelope.ts +342 -0
- package/src/repos/filter-anchor.test.ts +131 -0
- package/src/repos/filter-anchor.ts +105 -0
- package/src/repos/filter.test.ts +279 -0
- package/src/repos/filter.ts +253 -0
- package/src/repos/i4-account-config.test.ts +105 -0
- package/src/repos/i4-account-config.ts +257 -0
- package/src/repos/i4-account-export-request.ts +141 -0
- package/src/repos/i4-account-setting.test.ts +94 -0
- package/src/repos/i4-account-setting.ts +92 -0
- package/src/repos/i4-account.test.ts +223 -0
- package/src/repos/i4-account.ts +380 -0
- package/src/repos/i4-address-wellknown.ts +31 -0
- package/src/repos/i4-address.test.ts +358 -0
- package/src/repos/i4-address.ts +613 -0
- package/src/repos/i4-mailbox-lock.test.ts +368 -0
- package/src/repos/i4-mailbox-lock.ts +140 -0
- package/src/repos/i4-mailbox-special-use.ts +165 -0
- package/src/repos/i4-mailbox.test.ts +188 -0
- package/src/repos/i4-mailbox.ts +347 -0
- package/src/repos/i4-message-flag-push.test.ts +189 -0
- package/src/repos/i4-message-flag-push.ts +173 -0
- package/src/repos/i4-message-placement-move.test.ts +135 -0
- package/src/repos/i4-message-placement-move.ts +144 -0
- package/src/repos/i4-organize-job-request.ts +142 -0
- package/src/repos/i4-outbox-message.test.ts +298 -0
- package/src/repos/i4-outbox-message.ts +299 -0
- package/src/repos/label.conformance.sqlite.test.ts +23 -0
- package/src/repos/label.conformance.test.ts +19 -0
- package/src/repos/label.ts +125 -0
- package/src/repos/mappers.ts +171 -0
- package/src/repos/message-flag.ts +162 -0
- package/src/repos/message-label.test.ts +110 -0
- package/src/repos/message-label.ts +96 -0
- package/src/repos/message.sqlite.test.ts +118 -0
- package/src/repos/message.test.ts +488 -0
- package/src/repos/message.ts +558 -0
- package/src/repos/serialized-writes.sqlite.test.ts +199 -0
- package/src/repos/test-helpers.ts +222 -0
- package/src/repos/thread-message.sqlite.test.ts +198 -0
- package/src/repos/thread-message.test.ts +744 -0
- package/src/repos/thread-message.ts +832 -0
- package/src/repos/thread-search-predicates.ts +79 -0
- package/src/repos/unit-of-work.sqlite.test.ts +138 -0
- package/src/repos/unit-of-work.test.ts +105 -0
- package/src/repos/unit-of-work.ts +31 -0
- package/src/schema/active-entities.ts +19 -0
- package/src/schema/i4-account-config.ts +4 -0
- package/src/schema/i4-account-export-request.ts +3 -0
- package/src/schema/i4-account-setting.ts +3 -0
- package/src/schema/i4-address.ts +3 -0
- package/src/schema/i4-mailbox-lock.ts +3 -0
- package/src/schema/i4-mailbox.ts +4 -0
- package/src/schema/i4-message-flag-push.ts +3 -0
- package/src/schema/i4-message-placement-move.ts +3 -0
- package/src/schema/i4-organize-job-request.ts +1 -0
- package/src/schema/i4-outbox-message.ts +3 -0
- package/src/schema/message-data.ts +44 -0
- package/src/schema/outbox.ts +74 -0
- package/src/schema/thread-message.ts +3 -0
- package/src/schema-full-sqlite.ts +11 -0
- package/src/schema-full.ts +16 -0
- package/src/schema.ts +28 -0
- package/src/sqlite-client.ts +52 -0
- package/src/test-db-sqlite.ts +75 -0
- package/src/test-db.ts +76 -0
- package/src/tx.ts +208 -0
- package/src/vps-migrations-drift.test.ts +34 -0
- 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
|
+
}
|