@remit/drizzle-service 0.0.98 → 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.
- package/package.json +1 -1
- package/src/batch.sqlite.test.ts +78 -0
- package/src/batch.ts +45 -0
- package/src/databases.ts +70 -0
- package/src/db.ts +1 -4
- package/src/generated-enum.test.ts +8 -0
- package/src/generated-id.test.ts +19 -0
- package/src/id.test.ts +0 -8
- package/src/id.ts +0 -6
- package/src/index.ts +16 -6
- package/src/repair/address-display-name.sqlite.test.ts +3 -5
- package/src/repair/address-display-name.ts +8 -3
- package/src/repair/junk-only-address.sqlite.test.ts +8 -36
- package/src/repair/stranded-sent-outbox.sqlite.test.ts +2 -9
- package/src/repos/account.conformance.sqlite.test.ts +1 -1
- package/src/repos/calendar-default.sqlite.test.ts +6 -6
- package/src/repos/calendar-put.sqlite.test.ts +40 -39
- package/src/repos/calendar-store.ts +273 -0
- package/src/repos/cascade-delete.sqlite.test.ts +7 -9
- package/src/repos/cascade-delete.test.ts +6 -13
- package/src/repos/cascade-delete.ts +127 -97
- package/src/repos/envelope.test.ts +1 -17
- package/src/repos/envelope.ts +83 -67
- package/src/repos/filter.test.ts +22 -2
- package/src/repos/filter.ts +1 -5
- package/src/repos/i4-account-config.test.ts +2 -2
- package/src/repos/i4-account-config.ts +5 -2
- package/src/repos/i4-account.test.ts +6 -2
- package/src/repos/i4-account.ts +12 -56
- package/src/repos/i4-address-junk-move.sqlite.test.ts +1 -1
- package/src/repos/i4-address-merge-flags.sqlite.test.ts +1 -2
- package/src/repos/i4-address.ts +58 -90
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +6 -2
- package/src/repos/i4-mailbox-special-use.ts +9 -6
- package/src/repos/i4-mailbox.sqlite.test.ts +12 -14
- package/src/repos/i4-mailbox.test.ts +17 -62
- package/src/repos/i4-mailbox.ts +121 -94
- package/src/repos/i4-organize-job-request.ts +0 -4
- package/src/repos/i4-outbox-attachment.ts +62 -33
- package/src/repos/mappers.ts +0 -16
- package/src/repos/message.sqlite.test.ts +62 -102
- package/src/repos/message.test.ts +6 -167
- package/src/repos/message.ts +227 -218
- package/src/repos/sync-warning.ts +76 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +5 -10
- package/src/repos/thread-message-field-terms.sqlite.test.ts +5 -1
- package/src/repos/thread-message.sqlite.test.ts +2 -1
- package/src/repos/thread-message.test.ts +17 -4
- package/src/repos/thread-message.ts +502 -285
- package/src/repos/thread-search-predicates.ts +24 -26
- package/src/schema/message-data.ts +4 -8
- package/src/schema/sync-warning.ts +3 -0
- package/src/schema-sets/app.ts +17 -0
- package/src/schema-sets/mail.ts +32 -0
- package/src/schema-sets/search.ts +5 -0
- package/src/schema-version-accounting.sqlite.test.ts +56 -43
- package/src/schema.ts +3 -4
- package/src/search/thread-message-search.sqlite.test.ts +131 -0
- package/src/search/thread-message-search.ts +271 -0
- package/src/sqlite-client.ts +5 -7
- package/src/sqlite-repositories.ts +75 -0
- package/src/test-db-sqlite.ts +5 -13
- package/src/test-shipped-sqlite-schema.ts +46 -45
- package/src/test-sqlite.ts +35 -11
- package/src/vps-migrations-drift.sqlite.test.ts +81 -15
- package/src/account-granted-scopes-migration.sqlite.test.ts +0 -66
- package/src/account-synced-services-migration.sqlite.test.ts +0 -56
- package/src/mailbox-path-dedupe.sqlite.test.ts +0 -462
- package/src/mailbox-sync-status-backfill.sqlite.test.ts +0 -118
- package/src/outbox-created-cleanup.sqlite.test.ts +0 -83
- package/src/repair/duplicate-mailbox-mail.ts +0 -98
- package/src/repair/thread-message-category-contract.ts +0 -505
- package/src/repair/thread-message-category.sqlite.test.ts +0 -100
- package/src/repair/thread-message-category.ts +0 -415
- package/src/repos/calendar-migrations.sqlite.test.ts +0 -345
- package/src/repos/calendar-unit-of-work.ts +0 -31
- package/src/repos/config-import-atomicity.sqlite.test.ts +0 -255
- package/src/repos/filter-anchor-transaction.test.ts +0 -136
- package/src/repos/filter-anchor-transaction.ts +0 -45
- package/src/repos/filter-anchor.test.ts +0 -131
- package/src/repos/filter-anchor.ts +0 -105
- package/src/repos/i4-mailbox-special-use.structure.test.ts +0 -27
- package/src/repos/message-placement-backfill.sqlite.test.ts +0 -190
- package/src/repos/search-index-shape.test.ts +0 -82
- package/src/repos/search-index-shape.ts +0 -56
- package/src/repos/serialized-writes.sqlite.test.ts +0 -199
- package/src/repos/unit-of-work.sqlite.test.ts +0 -138
- package/src/repos/unit-of-work.test.ts +0 -109
- package/src/repos/unit-of-work.ts +0 -31
- package/src/schema/outbox.ts +0 -46
- package/src/schema-full-sqlite.ts +0 -13
- package/src/tx.sqlite.test.ts +0 -83
- package/src/tx.ts +0 -233
package/src/repos/message.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
*
|
|
125
|
-
* child tables, the message row
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
|
131
|
-
|
|
123
|
+
export function deleteMessageSubtree(
|
|
124
|
+
db: SubtreeDb,
|
|
132
125
|
messageIds: string[],
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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(
|
|
195
|
+
constructor(
|
|
196
|
+
private db: DB,
|
|
197
|
+
private search: SearchIndex,
|
|
198
|
+
) {}
|
|
211
199
|
|
|
212
|
-
|
|
200
|
+
private rowOf(input: CreateMessageInput) {
|
|
213
201
|
const now = Date.now();
|
|
214
|
-
|
|
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
|
|
274
|
-
.
|
|
275
|
-
.
|
|
276
|
-
.
|
|
277
|
-
|
|
278
|
-
if (
|
|
279
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
|
|
@@ -492,11 +442,39 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
492
442
|
await this.deleteMany([messageId]);
|
|
493
443
|
}
|
|
494
444
|
|
|
445
|
+
async deleteIfPlacement(
|
|
446
|
+
messageId: string,
|
|
447
|
+
expected: PlacementPredicate,
|
|
448
|
+
): Promise<boolean> {
|
|
449
|
+
const placed = exists(
|
|
450
|
+
this.db
|
|
451
|
+
.select({ messageId: messageTable.messageId })
|
|
452
|
+
.from(messageTable)
|
|
453
|
+
.where(
|
|
454
|
+
and(
|
|
455
|
+
eq(messageTable.messageId, messageId),
|
|
456
|
+
...placementTerms(expected),
|
|
457
|
+
),
|
|
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;
|
|
473
|
+
}
|
|
474
|
+
|
|
495
475
|
async deleteMany(messageIds: string[]): Promise<void> {
|
|
496
476
|
if (messageIds.length === 0) return;
|
|
497
|
-
await
|
|
498
|
-
deleteMessageSubtree(tx as unknown as SubtreeDb, messageIds),
|
|
499
|
-
);
|
|
477
|
+
await runBatch(this.db, deleteMessageSubtree(this.db, messageIds));
|
|
500
478
|
}
|
|
501
479
|
|
|
502
480
|
async listByMailbox(
|
|
@@ -554,6 +532,41 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
554
532
|
return rows.map(toMessageItem);
|
|
555
533
|
}
|
|
556
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
|
+
|
|
557
570
|
async transitionPlacement(
|
|
558
571
|
messageId: string,
|
|
559
572
|
expected: PlacementPredicate,
|
|
@@ -587,17 +600,68 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
587
600
|
return toMessageItem(rows[0]);
|
|
588
601
|
}
|
|
589
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
|
+
|
|
590
658
|
async updateUid(
|
|
591
659
|
messageId: string,
|
|
592
660
|
newUid: number,
|
|
593
661
|
newMailboxId: string,
|
|
594
662
|
): ReturnType<IMessageRepository["updateUid"]> {
|
|
595
663
|
// updateUid settles a move against confirmed IMAP state (the destination
|
|
596
|
-
// mailbox and its COPYUID).
|
|
597
|
-
// old mailbox in their metadata and their body is unchanged, so a normal
|
|
598
|
-
// re-index would skip them on content hash. Enqueue a move re-index event
|
|
599
|
-
// in the same transaction as the update; the search-index worker drains it
|
|
600
|
-
// with force, refreshing the stored mailbox metadata.
|
|
664
|
+
// mailbox and its COPYUID).
|
|
601
665
|
//
|
|
602
666
|
// `originalUid` goes with the settle. It exists to say the row's `uid`
|
|
603
667
|
// was recorded under `originalMailboxId`, and that stops being true here;
|
|
@@ -605,82 +669,27 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
605
669
|
// in-flight one whenever the destination's counter happens to hand back
|
|
606
670
|
// the source's number, which on a young account is most of them (#1217).
|
|
607
671
|
// `originalMailboxId` stays: Undo restores the message to it.
|
|
608
|
-
const rows = await
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
throw new NotFoundError(`Message not found: ${messageId}`);
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
await tx.insert(outboxTable).values({
|
|
632
|
-
id: randomUUID(),
|
|
633
|
-
messageId,
|
|
634
|
-
event: "message.moved",
|
|
635
|
-
payload: { messageId },
|
|
636
|
-
createdAt: new Date(),
|
|
637
|
-
});
|
|
638
|
-
return updated;
|
|
639
|
-
});
|
|
640
|
-
return toMessageItem(rows[0]);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
async requestReembed(messageIds: string[]): Promise<ReembedRequest> {
|
|
644
|
-
const request: ReembedRequest = { queued: 0, alreadyQueued: 0 };
|
|
645
|
-
for (
|
|
646
|
-
let start = 0;
|
|
647
|
-
start < messageIds.length;
|
|
648
|
-
start += REEMBED_BATCH_SIZE
|
|
649
|
-
) {
|
|
650
|
-
const batch = messageIds.slice(start, start + REEMBED_BATCH_SIZE);
|
|
651
|
-
await runInTransaction(this.db, async (tx) => {
|
|
652
|
-
const present = await tx
|
|
653
|
-
.select({ messageId: messageTable.messageId })
|
|
654
|
-
.from(messageTable)
|
|
655
|
-
.where(inArray(messageTable.messageId, batch));
|
|
656
|
-
const pending = await tx
|
|
657
|
-
.selectDistinct({ messageId: outboxTable.messageId })
|
|
658
|
-
.from(outboxTable)
|
|
659
|
-
.where(
|
|
660
|
-
and(
|
|
661
|
-
inArray(outboxTable.messageId, batch),
|
|
662
|
-
eq(outboxTable.event, "message.moved"),
|
|
663
|
-
isNull(outboxTable.processedAt),
|
|
664
|
-
),
|
|
665
|
-
);
|
|
666
|
-
const pendingIds = new Set(pending.map(({ messageId }) => messageId));
|
|
667
|
-
const toQueue = present.filter(
|
|
668
|
-
({ messageId }) => !pendingIds.has(messageId),
|
|
669
|
-
);
|
|
670
|
-
request.alreadyQueued += present.length - toQueue.length;
|
|
671
|
-
request.queued += toQueue.length;
|
|
672
|
-
if (toQueue.length === 0) return;
|
|
673
|
-
await tx.insert(outboxTable).values(
|
|
674
|
-
toQueue.map(({ messageId }) => ({
|
|
675
|
-
id: randomUUID(),
|
|
676
|
-
messageId,
|
|
677
|
-
event: "message.moved" as const,
|
|
678
|
-
payload: { messageId },
|
|
679
|
-
createdAt: new Date(),
|
|
680
|
-
})),
|
|
681
|
-
);
|
|
682
|
-
});
|
|
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}`);
|
|
683
692
|
}
|
|
684
|
-
return
|
|
693
|
+
return toMessageItem(rows[0]);
|
|
685
694
|
}
|
|
686
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
|
+
}
|