@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.
- 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 +32 -106
- package/src/repos/message.test.ts +6 -167
- package/src/repos/message.ts +216 -228
- 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
|
|
|
@@ -496,28 +446,35 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
496
446
|
messageId: string,
|
|
497
447
|
expected: PlacementPredicate,
|
|
498
448
|
): Promise<boolean> {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
.
|
|
502
|
-
.
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
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).
|
|
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
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
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
|
+
}
|