@remit/drizzle-service 0.0.63 → 0.0.65

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.
@@ -6,6 +6,7 @@ import type {
6
6
  import {
7
7
  type CanonicalMailboxRoleValue,
8
8
  composeFolderRoleAppointmentName,
9
+ type JunkRoleMailboxes,
9
10
  type RoleMailboxCandidate,
10
11
  type RoleResolution,
11
12
  resolveMailboxForRole,
@@ -22,6 +23,23 @@ import { AccountSettingRepo } from "./i4-account-setting.js";
22
23
 
23
24
  type DB = Db<Record<string, unknown>>;
24
25
 
26
+ const JUNK_ROLES: readonly CanonicalMailboxRoleValue[] = [
27
+ CanonicalMailboxRole.Junk,
28
+ CanonicalMailboxRole.Trash,
29
+ ];
30
+
31
+ const appointmentKey = (
32
+ accountId: string,
33
+ role: CanonicalMailboxRoleValue,
34
+ ): string => `${accountId}\u0000${role}`;
35
+
36
+ // The only place this repository names an appointment setting, so that no read
37
+ // here can reach the display-only label row sitting beside it (#887).
38
+ const appointmentSettingName = (
39
+ accountId: string,
40
+ role: CanonicalMailboxRoleValue,
41
+ ): string => composeFolderRoleAppointmentName(accountId, role);
42
+
25
43
  interface RoleCandidate extends RoleMailboxCandidate {
26
44
  fullPath: string;
27
45
  }
@@ -173,6 +191,148 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
173
191
  return this.findMailboxForRole(accountId, CanonicalMailboxRole.Junk);
174
192
  }
175
193
 
194
+ /**
195
+ * Junk and Trash for every account under one config, in a fixed number of
196
+ * reads however many accounts the config holds — this feeds a predicate the
197
+ * per-message reconcile runs inside the sync loop.
198
+ */
199
+ async resolveJunkRolesForConfig(
200
+ accountConfigId: string,
201
+ ): Promise<JunkRoleMailboxes> {
202
+ const accounts = await this.db
203
+ .select({ accountId: accountTable.accountId })
204
+ .from(accountTable)
205
+ .where(eq(accountTable.accountConfigId, accountConfigId));
206
+ return this.resolveJunkRoles(accounts.map((row) => row.accountId));
207
+ }
208
+
209
+ /**
210
+ * The same answer for every account the instance holds. A mailbox id belongs
211
+ * to exactly one account, so a union across accounts is no less selective
212
+ * than asking each of them separately — which is what lets the boot sweep
213
+ * run one pass over the address table instead of one per config.
214
+ */
215
+ async resolveJunkRolesForInstance(): Promise<JunkRoleMailboxes> {
216
+ const accounts = await this.db
217
+ .select({ accountId: accountTable.accountId })
218
+ .from(accountTable);
219
+ return this.resolveJunkRoles(accounts.map((row) => row.accountId));
220
+ }
221
+
222
+ private async resolveJunkRoles(
223
+ accountIds: readonly string[],
224
+ ): Promise<JunkRoleMailboxes> {
225
+ if (accountIds.length === 0) {
226
+ return { junkMailboxIds: [], trashMailboxIds: [] };
227
+ }
228
+ const [candidates, appointments] = await Promise.all([
229
+ this.roleCandidatesFor(accountIds),
230
+ this.appointedMailboxIds(accountIds, JUNK_ROLES),
231
+ ]);
232
+
233
+ const junkMailboxIds: string[] = [];
234
+ const trashMailboxIds: string[] = [];
235
+ for (const accountId of accountIds) {
236
+ const mailboxes = candidates.get(accountId) ?? [];
237
+ const junk = resolveMailboxForRole(
238
+ CanonicalMailboxRole.Junk,
239
+ mailboxes,
240
+ appointments.get(appointmentKey(accountId, CanonicalMailboxRole.Junk)),
241
+ );
242
+ if (junk) junkMailboxIds.push(junk.mailboxId);
243
+ const trash = resolveMailboxForRole(
244
+ CanonicalMailboxRole.Trash,
245
+ mailboxes,
246
+ appointments.get(appointmentKey(accountId, CanonicalMailboxRole.Trash)),
247
+ );
248
+ if (trash) trashMailboxIds.push(trash.mailboxId);
249
+ }
250
+ return { junkMailboxIds, trashMailboxIds };
251
+ }
252
+
253
+ /**
254
+ * Each account's appointment for each role, in two reads. Same precedence
255
+ * input as `appointedMailboxId`, batched: an account row missing is a caller
256
+ * racing a delete, and leaves the account with no appointment rather than
257
+ * failing the lookup.
258
+ */
259
+ private async appointedMailboxIds(
260
+ accountIds: readonly string[],
261
+ roles: readonly CanonicalMailboxRoleValue[],
262
+ ): Promise<Map<string, string>> {
263
+ const accounts = await this.db
264
+ .select({
265
+ accountId: accountTable.accountId,
266
+ accountConfigId: accountTable.accountConfigId,
267
+ })
268
+ .from(accountTable)
269
+ .where(inArray(accountTable.accountId, [...accountIds]));
270
+ if (accounts.length === 0) return new Map();
271
+
272
+ const wanted = new Map<string, string>();
273
+ for (const account of accounts) {
274
+ for (const role of roles) {
275
+ wanted.set(
276
+ appointmentSettingName(account.accountId, role),
277
+ appointmentKey(account.accountId, role),
278
+ );
279
+ }
280
+ }
281
+
282
+ const settings = await this.accountSetting.getMany(
283
+ accounts.map((account) => account.accountConfigId),
284
+ [...wanted.keys()],
285
+ );
286
+
287
+ const appointed = new Map<string, string>();
288
+ for (const setting of settings) {
289
+ const key = wanted.get(setting.name);
290
+ if (!key) continue;
291
+ if (setting.value.kind !== "String") continue;
292
+ appointed.set(key, setting.value.value);
293
+ }
294
+ return appointed;
295
+ }
296
+
297
+ private async roleCandidatesFor(
298
+ accountIds: readonly string[],
299
+ ): Promise<Map<string, RoleCandidate[]>> {
300
+ const rows = await this.db
301
+ .select()
302
+ .from(mailboxTable)
303
+ .where(inArray(mailboxTable.accountId, [...accountIds]));
304
+ if (rows.length === 0) return new Map();
305
+
306
+ const entries = await this.db
307
+ .select()
308
+ .from(mailboxSpecialUseTable)
309
+ .where(
310
+ inArray(
311
+ mailboxSpecialUseTable.mailboxId,
312
+ rows.map((row) => row.mailboxId),
313
+ ),
314
+ );
315
+ const byMailbox = new Map<string, string[]>();
316
+ for (const entry of entries) {
317
+ const designations = byMailbox.get(entry.mailboxId) ?? [];
318
+ designations.push(entry.specialUse);
319
+ byMailbox.set(entry.mailboxId, designations);
320
+ }
321
+
322
+ const byAccount = new Map<string, RoleCandidate[]>();
323
+ for (const row of rows) {
324
+ const candidates = byAccount.get(row.accountId) ?? [];
325
+ candidates.push({
326
+ mailboxId: row.mailboxId,
327
+ fullPath: row.fullPath,
328
+ hierarchyDelimiter: row.hierarchyDelimiter,
329
+ specialUse: byMailbox.get(row.mailboxId) ?? [],
330
+ });
331
+ byAccount.set(row.accountId, candidates);
332
+ }
333
+ return byAccount;
334
+ }
335
+
176
336
  /**
177
337
  * The one read behind every `find<Role>Mailbox`: the account's mailboxes and
178
338
  * its appointment for this role, handed to the shared precedence rule. Read
@@ -211,7 +371,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
211
371
 
212
372
  const setting = await this.accountSetting.get(
213
373
  account.accountConfigId,
214
- composeFolderRoleAppointmentName(accountId, role),
374
+ appointmentSettingName(accountId, role),
215
375
  );
216
376
  if (!setting || setting.value.kind !== "String") return undefined;
217
377
  return setting.value.value;
@@ -105,6 +105,52 @@ describe("OutboxMessageRepo", () => {
105
105
  await repo.delete(accountConfigId, msg.outboxMessageId);
106
106
  });
107
107
 
108
+ test("updateIfStatus writes on the expected status and refuses any other", async () => {
109
+ const accountConfigId = randomId();
110
+ const msg = await repo.create(makeOutboxInput(randomId(), accountConfigId));
111
+
112
+ const written = await repo.updateIfStatus(
113
+ accountConfigId,
114
+ msg.outboxMessageId,
115
+ "queued",
116
+ { status: "failed", lastError: "the queue refused it" },
117
+ );
118
+ assert.equal(written?.status, "failed");
119
+
120
+ // The compare-and-set every outbox transition rests on: a caller that read
121
+ // `queued` and decided on it must not overwrite a row the worker has since
122
+ // moved. `null` says another writer got there first.
123
+ const refused = await repo.updateIfStatus(
124
+ accountConfigId,
125
+ msg.outboxMessageId,
126
+ "queued",
127
+ { status: "draft" },
128
+ );
129
+ assert.equal(refused, null);
130
+ const still = await repo.get(accountConfigId, msg.outboxMessageId);
131
+ assert.equal(still.status, "failed");
132
+
133
+ await repo.delete(accountConfigId, msg.outboxMessageId);
134
+ });
135
+
136
+ test("cross-tenant: updateIfStatus refuses a foreign accountConfig", async () => {
137
+ const accountConfigId = randomId();
138
+ const other = randomId();
139
+ const msg = await repo.create(makeOutboxInput(randomId(), accountConfigId));
140
+
141
+ const refused = await repo.updateIfStatus(
142
+ other,
143
+ msg.outboxMessageId,
144
+ "queued",
145
+ { status: "failed" },
146
+ );
147
+ assert.equal(refused, null);
148
+ const still = await repo.get(accountConfigId, msg.outboxMessageId);
149
+ assert.equal(still.status, "queued");
150
+
151
+ await repo.delete(accountConfigId, msg.outboxMessageId);
152
+ });
153
+
108
154
  test("markSent clears lastError and lastSmtpCode", async () => {
109
155
  const accountConfigId = randomId();
110
156
  const msg = await repo.create({
@@ -129,6 +129,40 @@ export class OutboxMessageRepo implements IOutboxMessageRepository {
129
129
  outboxMessageId: string,
130
130
  input: UpdateOutboxMessageInput,
131
131
  ): Promise<OutboxMessageItem> {
132
+ const [row] = await this.applyUpdate(
133
+ accountConfigId,
134
+ outboxMessageId,
135
+ input,
136
+ );
137
+ if (!row)
138
+ throw new NotFoundError(`OutboxMessage not found: ${outboxMessageId}`);
139
+ return rowToOutboxMessage(row);
140
+ }
141
+
142
+ async updateIfStatus(
143
+ accountConfigId: string,
144
+ outboxMessageId: string,
145
+ expected: OutboxMessageItem["status"],
146
+ input: UpdateOutboxMessageInput,
147
+ ): Promise<OutboxMessageItem | null> {
148
+ const [row] = await this.applyUpdate(
149
+ accountConfigId,
150
+ outboxMessageId,
151
+ input,
152
+ expected,
153
+ );
154
+ // No row means the status moved or the row went away — both say another
155
+ // writer got there first, which is the caller's answer rather than an
156
+ // error here.
157
+ return row ? rowToOutboxMessage(row) : null;
158
+ }
159
+
160
+ private async applyUpdate(
161
+ accountConfigId: string,
162
+ outboxMessageId: string,
163
+ input: UpdateOutboxMessageInput,
164
+ expected?: OutboxMessageItem["status"],
165
+ ): Promise<(typeof outboxMessageTable.$inferSelect)[]> {
132
166
  const now = Date.now();
133
167
  const updates: Partial<typeof outboxMessageTable.$inferInsert> = {
134
168
  updatedAt: now,
@@ -154,19 +188,20 @@ export class OutboxMessageRepo implements IOutboxMessageRepository {
154
188
  if (input.inReplyTo !== undefined) updates.inReplyTo = input.inReplyTo;
155
189
  if (input.references !== undefined) updates.references = input.references;
156
190
 
157
- const [row] = await this.db
191
+ const rows = await this.db
158
192
  .update(outboxMessageTable)
159
193
  .set(updates)
160
194
  .where(
161
195
  and(
162
196
  eq(outboxMessageTable.accountConfigId, accountConfigId),
163
197
  eq(outboxMessageTable.outboxMessageId, outboxMessageId),
198
+ expected === undefined
199
+ ? undefined
200
+ : eq(outboxMessageTable.status, expected),
164
201
  ),
165
202
  )
166
203
  .returning();
167
- if (!row)
168
- throw new NotFoundError(`OutboxMessage not found: ${outboxMessageId}`);
169
- return rowToOutboxMessage(row);
204
+ return rows;
170
205
  }
171
206
 
172
207
  async updateStatus(