@remit/imap-worker 0.0.72 → 0.0.74

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.72",
3
+ "version": "0.0.74",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -4,6 +4,7 @@ import {
4
4
  APPENDED_UID_UNREPORTED,
5
5
  isSentCopyFiled,
6
6
  } from "@remit/data-ports";
7
+ import { isNotFoundError } from "@remit/data-ports/errors";
7
8
  import { OutboxMessageStatus } from "@remit/domain-enums";
8
9
  import type { Logger } from "@remit/logger-lambda";
9
10
  import {
@@ -14,7 +15,6 @@ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
14
15
  import { isAccountDeleted } from "../account-check.js";
15
16
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
16
17
  import type { AppendSentMessageEvent } from "../events.js";
17
- import { isNotFoundError } from "../is-not-found.js";
18
18
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
19
19
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
20
20
 
@@ -45,6 +45,8 @@ interface Harness {
45
45
  localMessages: LocalMessage[];
46
46
  threadMessage: boolean;
47
47
  messagesWithoutListingRow: string[];
48
+ /** Message ids whose placement another lane has changed under the sweep. */
49
+ transitionsLost: string[];
48
50
  getConnectionCount: number;
49
51
  disconnectCount: number;
50
52
  }
@@ -88,6 +90,25 @@ const retryingIntoTrash = (messageId: string, uid: number): LocalMessage => ({
88
90
  syncStatus: "failed",
89
91
  });
90
92
 
93
+ /**
94
+ * A row whose move into Trash SETTLED, on a database written before #1217
95
+ * taught `updateUid` to clear `originalUid`. Two folders count uids
96
+ * independently, so Trash handing back the source's own number is ordinary —
97
+ * and the pair it leaves is indistinguishable from a move still in flight.
98
+ */
99
+ const settledWithStaleOriginalUid = (
100
+ messageId: string,
101
+ uid: number,
102
+ ): LocalMessage => ({
103
+ messageId,
104
+ uid,
105
+ status: "deleting",
106
+ syncStatus: "pending",
107
+ mailboxId: "trash-mbx",
108
+ originalMailboxId: "inbox-mbx",
109
+ originalUid: uid,
110
+ });
111
+
91
112
  /** The same row after an empty marked the folder, `status` overwritten. */
92
113
  const markedMidMove = (messageId: string, uid: number): LocalMessage => ({
93
114
  ...movingIntoTrash(messageId, uid),
@@ -106,6 +127,7 @@ const fresh = (): Harness => ({
106
127
  localMessages: [deleting("msg-1", 10), deleting("msg-2", 11)],
107
128
  threadMessage: true,
108
129
  messagesWithoutListingRow: [],
130
+ transitionsLost: [],
109
131
  getConnectionCount: 0,
110
132
  disconnectCount: 0,
111
133
  });
@@ -140,6 +162,18 @@ const deps = (): EmptyTrashDeps =>
140
162
  listAllByMailbox: async () => h.localMessages,
141
163
  delete: record("message.delete"),
142
164
  update: record("message.update"),
165
+ transitionPlacement: async (
166
+ messageId: string,
167
+ expected: unknown,
168
+ next: unknown,
169
+ ) => {
170
+ const won = !h.transitionsLost.includes(messageId);
171
+ h.calls.push({
172
+ method: "message.transitionPlacement",
173
+ args: [messageId, expected, next, won],
174
+ });
175
+ return won ? { messageId } : undefined;
176
+ },
143
177
  },
144
178
  threadMessage: {
145
179
  findByMessageId: async (_cfg: string, messageId: string) =>
@@ -193,13 +227,16 @@ const event: EmptyTrashEvent = {
193
227
  const called = (method: string): Call[] =>
194
228
  h.calls.filter((c) => c.method === method);
195
229
 
230
+ // Only a transition that WON reverted anything; a lost predicate is an offered
231
+ // write that the row refused.
196
232
  const revertedMessageIds = (): string[] =>
197
- called("message.update")
233
+ called("message.transitionPlacement")
198
234
  .filter(
199
235
  (c) =>
200
- (c.args[1] as { status?: string; syncStatus?: string }).status ===
236
+ c.args[3] === true &&
237
+ (c.args[2] as { status?: string; syncStatus?: string }).status ===
201
238
  "active" &&
202
- (c.args[1] as { syncStatus?: string }).syncStatus === "synced",
239
+ (c.args[2] as { syncStatus?: string }).syncStatus === "synced",
203
240
  )
204
241
  .map((c) => c.args[0] as string);
205
242
 
@@ -498,6 +535,84 @@ describe("handleEmptyTrash and an unsettled placement", () => {
498
535
  assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-marked"]);
499
536
  });
500
537
 
538
+ it("hands nothing back for a row another lane settled after the sweep read it", async () => {
539
+ // The hand-back is a transition off the row as this sweep read it
540
+ // (imap-mutations R3). PLACEMENT_MOVE_PUSH rides a standard queue that
541
+ // this account's FIFO group does not order, so it can settle a row
542
+ // mid-sweep; writing `active` + `synced` over that would declare a
543
+ // mutation settled that nobody confirmed, and take the listing row with
544
+ // it.
545
+ h.localMessages = [deleting("msg-1", 10), markedMidMove("msg-raced", 11)];
546
+ h.transitionsLost = ["msg-raced"];
547
+
548
+ await handleEmptyTrash(event, noopLogger, deps());
549
+
550
+ assert.deepEqual(
551
+ called("message.transitionPlacement").map((c) => c.args[0]),
552
+ ["msg-raced"],
553
+ "the predicate was still offered — the row just lost it",
554
+ );
555
+ assert.deepEqual(revertedMessageIds(), []);
556
+ assert.deepEqual(
557
+ undeletedThreadMessageIds(),
558
+ [],
559
+ "a lost predicate writes nothing at all, listing rows included",
560
+ );
561
+ });
562
+
563
+ it("predicates the hand-back on the placement the sweep actually read", async () => {
564
+ h.localMessages = [markedMidMove("msg-marked", 11)];
565
+
566
+ await handleEmptyTrash(event, noopLogger, deps());
567
+
568
+ const [call] = called("message.transitionPlacement");
569
+ assert.deepEqual(call?.args[1], {
570
+ status: "deleting",
571
+ mailboxId: "trash-mbx",
572
+ uid: 11,
573
+ });
574
+ });
575
+
576
+ // Issue #1230. The exclusion used to run after `deleteMessages`, so a row
577
+ // the sweep refused had already lost its server copy: mail that no longer
578
+ // exists, visible in Trash, with nothing able to clear it — pressing Empty
579
+ // Trash again skips it too, because the server no longer lists the uid.
580
+ it("never expunges a uid whose row it will refuse to remove", async () => {
581
+ h.localMessages = [
582
+ deleting("msg-1", 10),
583
+ settledWithStaleOriginalUid("msg-stale", 11),
584
+ ];
585
+
586
+ await handleEmptyTrash(event, noopLogger, deps());
587
+
588
+ assert.deepEqual(
589
+ called("connection.deleteMessages")[0]?.args,
590
+ [[10]],
591
+ "uid 11 survives on the server, because its row survives locally",
592
+ );
593
+ assert.deepEqual(
594
+ called("message.delete").map((c) => c.args[0]),
595
+ ["msg-1"],
596
+ );
597
+ assert.deepEqual(
598
+ revertedMessageIds(),
599
+ ["msg-stale"],
600
+ "and the refused row is handed back rather than left marked",
601
+ );
602
+ });
603
+
604
+ it("issues no expunge at all when every uid in the folder is refused", async () => {
605
+ h.localMessages = [
606
+ settledWithStaleOriginalUid("msg-stale-a", 10),
607
+ settledWithStaleOriginalUid("msg-stale-b", 11),
608
+ ];
609
+
610
+ await handleEmptyTrash(event, noopLogger, deps());
611
+
612
+ assert.equal(called("connection.deleteMessages").length, 0);
613
+ assert.equal(called("message.delete").length, 0);
614
+ });
615
+
501
616
  it("sweeps a settled row whose Trash uid matches the one it left behind", async () => {
502
617
  // Two folders count uids independently, so a move can land on the same
503
618
  // number it started from. `updateUid` drops `originalUid` when it settles,
@@ -1,10 +1,12 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import type { MessageItem } from "@remit/data-ports";
3
+ import { isNotFoundError } from "@remit/data-ports/errors";
3
4
  import { trashMailboxAt } from "@remit/data-ports/folder-role";
4
5
  import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
5
6
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
6
7
  import type { Logger } from "@remit/logger-lambda";
7
8
  import {
9
+ buildThreadMessageUndelete,
8
10
  carriesForeignUid,
9
11
  guardConnectionCursor,
10
12
  isCursorRebuildNeeded,
@@ -13,10 +15,8 @@ import {
13
15
  import { isAccountDeleted } from "../account-check.js";
14
16
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
15
17
  import type { EmptyTrashEvent } from "../events.js";
16
- import { isNotFoundError } from "../is-not-found.js";
17
18
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
18
19
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
19
- import { buildThreadMessageUndelete } from "./thread-message-rows.js";
20
20
 
21
21
  export interface EmptyTrashDeps {
22
22
  getClient: typeof getClient;
@@ -89,7 +89,10 @@ export const handleEmptyTrash = async (
89
89
  // an unsettled move is rewritten by its own MESSAGE_DELETE. Not `failed`:
90
90
  // the mail is intact, and saying otherwise about a whole folder is a lie.
91
91
  const handBackMarkedRows = async (
92
- rows: readonly { messageId: string; status: MessageItem["status"] }[],
92
+ rows: readonly Pick<
93
+ MessageItem,
94
+ "messageId" | "status" | "mailboxId" | "uid"
95
+ >[],
93
96
  ): Promise<number> => {
94
97
  let revertedCount = 0;
95
98
  for (const message of rows) {
@@ -105,10 +108,30 @@ export const handleEmptyTrash = async (
105
108
  );
106
109
  if (threadMessages.length === 0) continue;
107
110
 
108
- await messageService.update(message.messageId, {
109
- status: MessageStatus.active,
110
- syncStatus: MessageSyncStatus.synced,
111
- });
111
+ // The hand-back is a transition off the row as this sweep read it
112
+ // (imap-mutations R3), because the snapshot it walks holds nothing:
113
+ // between the listing and here another lane can settle the row onto a
114
+ // placement of its own, and writing `active` + `synced` over that would
115
+ // declare a mutation settled that nobody confirmed.
116
+ const restored = await messageService.transitionPlacement(
117
+ message.messageId,
118
+ {
119
+ status: MessageStatus.deleting,
120
+ mailboxId: message.mailboxId,
121
+ uid: message.uid,
122
+ },
123
+ {
124
+ status: MessageStatus.active,
125
+ syncStatus: MessageSyncStatus.synced,
126
+ },
127
+ );
128
+ if (!restored) {
129
+ log.info(
130
+ { accountId, messageId: message.messageId },
131
+ "Empty Trash left a row alone: its placement changed after the sweep read it",
132
+ );
133
+ continue;
134
+ }
112
135
  for (const threadMessage of threadMessages) {
113
136
  const args = buildThreadMessageUndelete(threadMessage);
114
137
  await threadMessageService.update(
@@ -234,39 +257,75 @@ export const handleEmptyTrash = async (
234
257
  return;
235
258
  }
236
259
 
237
- const uids = await connection.search(["ALL"]);
260
+ // One read, before the expunge, decides both what is destroyed on
261
+ // the server and what is removed locally — so the two sets are the
262
+ // same set by construction and no message can be expunged that the
263
+ // sweep then declines to remove (issue #1230). Reading afterwards
264
+ // put the exclusion downstream of the destruction: a refused row
265
+ // had already lost its server copy, could not be refused into
266
+ // existence again, and nothing routine repaired it.
267
+ //
268
+ // Reconciles, never waits (imap-mutations R2). A row whose move
269
+ // into Trash has not settled names this folder while still
270
+ // carrying the SOURCE folder's uid, so matching it against the
271
+ // expunge answers for whatever Trash held at that uid — another
272
+ // message, deleted here in both its rows. Waiting is not open to
273
+ // this handler the way it is to the API-side mutators: a move on
274
+ // this account's own FIFO group cannot run until this returns, so
275
+ // the ceiling would be spent to reach the same answer. It buys
276
+ // nothing against the other lane either — PLACEMENT_MOVE_PUSH
277
+ // rides a standard queue with no group at all
278
+ // (`deploy/vps/queues.json`), so it is ordered against nothing
279
+ // here and can settle a row mid-sweep. That is why the writes
280
+ // below are transitions rather than snapshot writes (R3).
281
+ //
282
+ // `carriesForeignUid`, not the placement binding: the binding reads
283
+ // `status`, and `status` is exactly what an operation marking this
284
+ // folder overwrites. This is the same predicate `emptyTrash`
285
+ // applies before it marks anything — one gate, one answer, now
286
+ // asked once and honoured on both sides of the expunge.
287
+ const localMessages =
288
+ await messageService.listAllByMailbox(trashMailboxId);
289
+ const sweepable = localMessages.filter(
290
+ (message) => !carriesForeignUid(message),
291
+ );
292
+
293
+ // A refused row spares its uid only where no row the sweep will
294
+ // carry claims the same number. The two cases the predicate cannot
295
+ // tell apart part here: a row genuinely mid-move borrows a uid that
296
+ // belongs to a different message in this folder, and sparing that
297
+ // number would strand the message that owns it; a settled row whose
298
+ // `originalUid` a pre-#1217 build left behind owns its uid outright,
299
+ // and expunging it destroys mail the sweep then refuses to remove.
300
+ const sweepableUids = new Set(
301
+ sweepable.map((message) => message.uid),
302
+ );
303
+ const refusedUids = new Set(
304
+ localMessages
305
+ .filter(
306
+ (message) =>
307
+ carriesForeignUid(message) && !sweepableUids.has(message.uid),
308
+ )
309
+ .map((message) => message.uid),
310
+ );
311
+
312
+ const uids = (await connection.search(["ALL"])).filter(
313
+ (uid) => !refusedUids.has(uid),
314
+ );
238
315
 
239
316
  if (uids.length > 0) {
240
317
  await connection.deleteMessages(uids);
241
318
  log.info(
242
- { count: uids.length },
319
+ { count: uids.length, refused: refusedUids.size },
243
320
  "Deleted messages from IMAP trash",
244
321
  );
245
322
  }
246
323
 
247
- // Local cleanup follows the expunge, uid by uid. What was expunged
248
- // is a fact this connection observed, and only those rows go.
324
+ // What was expunged is a fact this connection observed, and only
325
+ // those rows go.
249
326
  const expunged = new Set(uids);
250
- const localMessages =
251
- await messageService.listAllByMailbox(trashMailboxId);
252
-
253
- // Reconciles, never waits (imap-mutations R2). A row whose move
254
- // into Trash has not settled names this folder while still
255
- // carrying the SOURCE folder's uid, so matching it against the
256
- // expunge answers for whatever Trash held at that uid — another
257
- // message, deleted here in both its rows. Waiting is not open to
258
- // this handler the way it is to the API-side mutators: every event
259
- // of an account shares one FIFO group, so the move that would
260
- // settle the row cannot run until this returns, and the ceiling
261
- // would be spent to reach the same answer.
262
- //
263
- // `carriesForeignUid`, not the placement binding: the binding reads
264
- // `status`, and `status` is exactly what an operation marking this
265
- // folder overwrites. This is the second half of the gate
266
- // `emptyTrash` applies before it marks anything.
267
- const swept = localMessages.filter(
268
- (message) =>
269
- !carriesForeignUid(message) && expunged.has(message.uid),
327
+ const swept = sweepable.filter((message) =>
328
+ expunged.has(message.uid),
270
329
  );
271
330
  const sweptIds = new Set(swept.map((message) => message.messageId));
272
331
  const deletedCount = swept.length;
@@ -26,7 +26,8 @@ import type {
26
26
  MessageFlagPushItem,
27
27
  MessageItem,
28
28
  MessagePlacementMoveItem,
29
- UpdateMessageMoveInput,
29
+ PlacementPredicate,
30
+ PlacementTransitionInput,
30
31
  } from "@remit/data-ports";
31
32
  import type { Logger } from "@remit/logger-lambda";
32
33
  import {
@@ -87,11 +88,24 @@ const buildHarness = (): Harness => {
87
88
  assert.equal(messageId, MESSAGE_ID);
88
89
  return row;
89
90
  },
90
- updateForMove: async (
91
+ transitionPlacement: async (
91
92
  _messageId: string,
92
- input: UpdateMessageMoveInput,
93
+ expected: PlacementPredicate,
94
+ next: PlacementTransitionInput,
93
95
  ) => {
94
- Object.assign(row, input);
96
+ if (expected.status !== undefined && expected.status !== row.status) {
97
+ return undefined;
98
+ }
99
+ if (expected.uid !== undefined && expected.uid !== row.uid) {
100
+ return undefined;
101
+ }
102
+ if (
103
+ expected.mailboxId !== undefined &&
104
+ expected.mailboxId !== row.mailboxId
105
+ ) {
106
+ return undefined;
107
+ }
108
+ Object.assign(row, next);
95
109
  return row;
96
110
  },
97
111
  } as unknown as IMessageRepository;
@@ -1,4 +1,5 @@
1
1
  import { getClient } from "@remit/backend/client";
2
+ import { isNotFoundError } from "@remit/data-ports/errors";
2
3
  import type { Logger } from "@remit/logger-lambda";
3
4
  import { recordImapFailure } from "@remit/logger-lambda";
4
5
  import {
@@ -12,7 +13,6 @@ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
12
13
  import { isAccountDeleted } from "../account-check.js";
13
14
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
14
15
  import type { FlagPushEvent } from "../events.js";
15
- import { isNotFoundError } from "../is-not-found.js";
16
16
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
17
17
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
18
18
 
@@ -42,6 +42,7 @@ interface Harness {
42
42
  } | null;
43
43
  connection: Connection;
44
44
  mailboxUpdateError?: Error;
45
+ mailboxRowGone?: boolean;
45
46
  disconnectCount: number;
46
47
  }
47
48
 
@@ -96,6 +97,13 @@ const deps = (): MailboxManagementDeps =>
96
97
  h.calls.push({ method: "mailbox.update", args });
97
98
  if (h.mailboxUpdateError) throw h.mailboxUpdateError;
98
99
  },
100
+ // The conditional write the folder state now goes through: a row
101
+ // that is gone matches nothing, so the loser gets a null rather
102
+ // than a throw and the handler raises the NotFoundError itself.
103
+ transition: async (...args: unknown[]) => {
104
+ h.calls.push({ method: "mailbox.transition", args });
105
+ return h.mailboxRowGone ? null : {};
106
+ },
99
107
  findByPathPrefix: async (...args: unknown[]) => {
100
108
  h.calls.push({ method: "mailbox.findByPathPrefix", args });
101
109
  return [];
@@ -147,6 +155,12 @@ const called = (method: string): Call[] =>
147
155
  const lastUpdate = (): Record<string, unknown> =>
148
156
  (called("mailbox.update").at(-1)?.args[2] ?? {}) as Record<string, unknown>;
149
157
 
158
+ const lastSettle = (): { to?: string; set?: Record<string, unknown> } =>
159
+ (called("mailbox.transition").at(-1)?.args[2] ?? {}) as {
160
+ to?: string;
161
+ set?: Record<string, unknown>;
162
+ };
163
+
150
164
  describe("processMailboxManagement — MAILBOX_CREATE", () => {
151
165
  beforeEach(() => {
152
166
  h = fresh();
@@ -160,8 +174,8 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
160
174
  uidValidity: 7,
161
175
  uidNext: 42,
162
176
  messageCount: 3,
163
- syncStatus: "synced",
164
177
  });
178
+ assert.equal(lastSettle().to, "synced");
165
179
  assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
166
180
  });
167
181
 
@@ -183,7 +197,8 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
183
197
 
184
198
  await processMailboxManagement(createEvent, noopLogger, deps());
185
199
 
186
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
200
+ assert.equal(called("mailbox.update").length, 0);
201
+ assert.equal(lastSettle().to, "synced");
187
202
  });
188
203
 
189
204
  it("treats an already-existing folder as success rather than a failure", async () => {
@@ -193,7 +208,7 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
193
208
 
194
209
  await processMailboxManagement(createEvent, noopLogger, deps());
195
210
 
196
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
211
+ assert.equal(lastSettle().to, "synced");
197
212
  });
198
213
 
199
214
  it("marks the mailbox failed and rethrows on any other create error", async () => {
@@ -206,14 +221,14 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
206
221
  /server exploded/,
207
222
  );
208
223
 
209
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
224
+ assert.equal(lastSettle().to, "failed");
210
225
  assert.equal(h.disconnectCount, 1);
211
226
  });
212
227
 
213
228
  it("acks terminally without rethrowing when the mailbox row was deleted mid-create (#289)", async () => {
214
- // The status write-back throws NotFoundError because the row is gone; the
215
- // create is moot and must not poison the account's FIFO.
216
- h.mailboxUpdateError = notFoundError();
229
+ // The settle matches no row because the row is gone; the create is moot
230
+ // and must not poison the account's FIFO.
231
+ h.mailboxRowGone = true;
217
232
 
218
233
  await processMailboxManagement(createEvent, noopLogger, deps());
219
234
 
@@ -247,17 +262,14 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
247
262
  h = fresh();
248
263
  });
249
264
 
250
- it("renames on the server and clears the pending oldPath", async () => {
265
+ it("renames on the server and settles the row", async () => {
251
266
  await processMailboxManagement(renameEvent, noopLogger, deps());
252
267
 
253
268
  assert.deepEqual(called("connection.renameMailbox")[0]?.args, [
254
269
  "Archive",
255
270
  "Archive 2024",
256
271
  ]);
257
- assert.deepEqual(lastUpdate(), {
258
- oldPath: undefined,
259
- syncStatus: "synced",
260
- });
272
+ assert.equal(lastSettle().to, "synced");
261
273
  });
262
274
 
263
275
  it("drops the local row when the source folder is gone on the server", async () => {
@@ -268,14 +280,14 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
268
280
  await processMailboxManagement(renameEvent, noopLogger, deps());
269
281
 
270
282
  assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
271
- assert.equal(called("mailbox.update").length, 0);
283
+ assert.equal(called("mailbox.transition").length, 0);
272
284
  });
273
285
 
274
286
  it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
275
287
  h.connection.renameMailbox = async () => {
276
288
  throw new Error("server exploded");
277
289
  };
278
- h.mailboxUpdateError = notFoundError();
290
+ h.mailboxRowGone = true;
279
291
 
280
292
  await processMailboxManagement(renameEvent, noopLogger, deps());
281
293
 
@@ -292,11 +304,8 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
292
304
  /server exploded/,
293
305
  );
294
306
 
295
- assert.deepEqual(lastUpdate(), {
296
- fullPath: "Archive",
297
- oldPath: undefined,
298
- syncStatus: "failed",
299
- });
307
+ assert.equal(lastSettle().to, "failed");
308
+ assert.equal(lastSettle().set?.fullPath, "Archive");
300
309
  });
301
310
  });
302
311
 
@@ -329,7 +338,7 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
329
338
 
330
339
  await processMailboxManagement(deleteEvent, noopLogger, deps());
331
340
 
332
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
341
+ assert.equal(lastSettle().to, "synced");
333
342
  assert.equal(called("mailbox.delete").length, 0);
334
343
  });
335
344
 
@@ -343,14 +352,14 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
343
352
  /server exploded/,
344
353
  );
345
354
 
346
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
355
+ assert.equal(lastSettle().to, "failed");
347
356
  });
348
357
 
349
358
  it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
350
359
  h.connection.deleteMailbox = async () => {
351
360
  throw new Error("server exploded");
352
361
  };
353
- h.mailboxUpdateError = notFoundError();
362
+ h.mailboxRowGone = true;
354
363
 
355
364
  await processMailboxManagement(deleteEvent, noopLogger, deps());
356
365
 
@@ -381,7 +390,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
381
390
  processMailboxManagement(deleteEvent, noopLogger, deps()),
382
391
  );
383
392
  assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
384
- assert.equal(called("mailbox.update").length, 0);
393
+ assert.equal(called("mailbox.transition").length, 0);
385
394
  });
386
395
 
387
396
  it("still marks failed and rethrows when the server fails for any other reason", async () => {
@@ -396,7 +405,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
396
405
  processMailboxManagement(deleteEvent, noopLogger, deps()),
397
406
  /Command failed/,
398
407
  );
399
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
408
+ assert.equal(lastSettle().to, "failed");
400
409
  });
401
410
 
402
411
  /**
@@ -416,7 +425,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
416
425
  await assert.doesNotReject(
417
426
  processMailboxManagement(createEvent, noopLogger, deps()),
418
427
  );
419
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
428
+ assert.equal(lastSettle().to, "synced");
420
429
  });
421
430
 
422
431
  it("still marks failed and rethrows when a CREATE fails for any other reason", async () => {
@@ -431,7 +440,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
431
440
  processMailboxManagement(createEvent, noopLogger, deps()),
432
441
  /Command failed/,
433
442
  );
434
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
443
+ assert.equal(lastSettle().to, "failed");
435
444
  });
436
445
 
437
446
  it("reads NONEXISTENT on a RENAME as the source folder being gone", async () => {
@@ -460,6 +469,6 @@ describe("processMailboxManagement — a tagged NO the server means as success (
460
469
  processMailboxManagement(renameEvent, noopLogger, deps()),
461
470
  /Command failed/,
462
471
  );
463
- assert.equal(lastUpdate()?.syncStatus, "failed");
472
+ assert.equal(lastSettle().to, "failed");
464
473
  });
465
474
  });