@remit/mailbox-service 0.0.96 → 0.0.98

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.
@@ -9,12 +9,13 @@ export const isNotFoundError = (error: unknown): boolean =>
9
9
  * against none of them.
10
10
  *
11
11
  * A transition predicate is supposed to name the state the caller read
12
- * (docs/architecture/folder-rename-and-delete.md D3), and the intent recorders
13
- * and settles that use this one name nothing yet: they are today's
12
+ * (docs/architecture/folder-rename-and-delete.md D3). What still uses this one
13
+ * is the delete path — its intent recorder and its settle are today's
14
14
  * unconditional writes, moved onto the only door that can write a folder state
15
- * at all. Each is narrowed to the from-set its transition-table row allows when
16
- * the intents themselves land (#362, #363), which is also where a loser gets
17
- * its 409.
15
+ * at all, and #362 narrows each to the from-set its transition-table row
16
+ * allows, which is also where its loser gets a 409. The rename path is already
17
+ * narrowed: `INTENT_RECORDABLE_FROM` for the intent, and `pending` plus the
18
+ * recorded target for the settle.
18
19
  */
19
20
  export const EVERY_MAILBOX_STATE = [
20
21
  MailboxSyncStatus.synced,
@@ -9,7 +9,11 @@ import { NotFoundError } from "@remit/data-ports/errors";
9
9
  import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
10
10
  import { MailboxSyncStatus } from "@remit/domain-enums";
11
11
  import { createQueueProducer } from "@remit/sqs-client/producer";
12
- import { EVERY_MAILBOX_STATE } from "./mailbox-presence.js";
12
+ import {
13
+ INTENT_RECORDABLE_FROM,
14
+ recordedByRename,
15
+ refuseContestedIntent,
16
+ } from "./mailbox-intent.js";
13
17
 
14
18
  /**
15
19
  * MAILBOX_CREATE event structure (matches remit-imap-worker/events.ts)
@@ -149,13 +153,19 @@ export class MailboxQueueService {
149
153
  };
150
154
 
151
155
  /**
152
- * Rename a mailbox.
153
- * Updates local state (including children) and enqueues IMAP RENAME.
156
+ * Record a rename intent over the folder and every descendant, then enqueue
157
+ * IMAP RENAME.
158
+ *
159
+ * The target is recorded, not the path (D2): every row keeps the `fullPath`
160
+ * the server holds and takes its own rewritten target in `pendingPath`, so a
161
+ * rename that never lands needs no unwinding — dropping `pendingPath` is the
162
+ * whole revert — and nothing that resolves a folder path from a row can
163
+ * resolve one the server does not have.
154
164
  *
155
165
  * @param mailboxId - The mailbox to rename
156
- * @param newPath - The new path for the mailbox
166
+ * @param newPath - The path the rename is aiming at
157
167
  * @param accountId - The account ID for the IMAP sync event
158
- * @returns The updated mailbox
168
+ * @returns The folder that was named, carrying its recorded target
159
169
  */
160
170
  renameMailbox = async (
161
171
  mailboxId: string,
@@ -168,32 +178,58 @@ export class MailboxQueueService {
168
178
 
169
179
  // One intent over the folder and every descendant, in one transaction:
170
180
  // IMAP RENAME moves the subtree in one command, so a partially recorded
171
- // rename is the state that produced today's phantom rows (D6). Each
172
- // descendant's new path is equally absent from the server until
173
- // MAILBOX_RENAME lands, so each is `pending` too — otherwise a reconcile
174
- // in that window reaps it as server-deleted (#290).
181
+ // rename is the state that produced today's phantom rows (D6). A
182
+ // descendant already mid-mutation refuses the whole rename.
175
183
  const written = await this.mailboxService.transitionSubtree(
176
184
  accountId,
177
185
  mailboxId,
178
186
  {
179
- from: EVERY_MAILBOX_STATE,
187
+ from: INTENT_RECORDABLE_FROM,
180
188
  to: MailboxSyncStatus.pending,
181
189
  rowSet: (row) => {
182
- const moved = rebaseMailboxPath(
190
+ const target = rebaseMailboxPath(
183
191
  row.fullPath,
184
192
  oldPath,
185
193
  newPath,
186
194
  mailbox.hierarchyDelimiter,
187
195
  );
188
- return moved === undefined ? {} : { fullPath: moved };
196
+ // The subtree is the folder and the rows under its own prefix, so
197
+ // every one of them rebases. A row that does not is a resolution
198
+ // bug, and recording an intent with no target for it would strand
199
+ // it `pending` with nothing able to settle it.
200
+ if (target === undefined) {
201
+ throw new Error(
202
+ `Mailbox ${row.mailboxId} at "${row.fullPath}" is not under "${oldPath}"`,
203
+ );
204
+ }
205
+ return { pendingPath: target };
189
206
  },
190
207
  },
191
208
  );
192
- if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
209
+ if (!written) {
210
+ return refuseContestedIntent(
211
+ this.mailboxService,
212
+ accountId,
213
+ mailboxId,
214
+ "subtree",
215
+ );
216
+ }
193
217
  const updated = written.find((row) => row.mailboxId === mailboxId);
194
218
  if (!updated) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
195
219
 
196
- this.log.info({ mailboxId, oldPath, newPath }, "Renamed mailbox (local)");
220
+ this.log.info(
221
+ {
222
+ accountId,
223
+ mailboxId,
224
+ intent: "rename",
225
+ from: mailbox.syncStatus,
226
+ to: MailboxSyncStatus.pending,
227
+ oldPath,
228
+ newPath,
229
+ subtreeSize: written.length,
230
+ },
231
+ "Recorded rename intent",
232
+ );
197
233
 
198
234
  // Enqueue IMAP sync
199
235
  await this.enqueueEvent({
@@ -210,8 +246,95 @@ export class MailboxQueueService {
210
246
  };
211
247
 
212
248
  /**
213
- * Delete a mailbox.
214
- * Marks for deletion (syncStatus=deleting) and enqueues IMAP DELETE.
249
+ * The route out of a failed rename or a failed delete that keeps the folder
250
+ * as it is (T10, T11): the recorded target is dropped and the row settles
251
+ * back to `synced`. Nothing is enqueued — `fullPath` was never written, so
252
+ * the server has nothing to undo.
253
+ *
254
+ * Reached without new API surface, from a PATCH whose `fullPath` equals the
255
+ * row's confirmed one. From `synced` it is a no-op; from a state with a
256
+ * mutation in flight it is a 409, because dismissing an intent that is still
257
+ * running would leave the settle with no row to write.
258
+ */
259
+ dismissMailboxIntent = async (
260
+ mailboxId: string,
261
+ accountId: string,
262
+ ): Promise<MailboxItem> => {
263
+ const current = await this.mailboxService.get(accountId, mailboxId);
264
+ if (current.syncStatus === MailboxSyncStatus.synced) return current;
265
+ if (current.syncStatus !== MailboxSyncStatus.failed) {
266
+ return refuseContestedIntent(
267
+ this.mailboxService,
268
+ accountId,
269
+ mailboxId,
270
+ "folder",
271
+ );
272
+ }
273
+
274
+ // A failed rename was recorded over a subtree, so it is dismissed over the
275
+ // same one. A failed delete carries no target and was recorded on the
276
+ // folder alone, so there is nothing else to clear.
277
+ const target = current.pendingPath;
278
+ const alsoRecorded =
279
+ target === undefined
280
+ ? []
281
+ : (
282
+ await this.mailboxService.findBySyncStatus(
283
+ accountId,
284
+ MailboxSyncStatus.failed,
285
+ )
286
+ ).filter(
287
+ (row) =>
288
+ row.mailboxId !== mailboxId &&
289
+ recordedByRename(
290
+ row,
291
+ current.fullPath,
292
+ target,
293
+ current.hierarchyDelimiter,
294
+ ),
295
+ );
296
+
297
+ let dismissed: MailboxItem | undefined;
298
+ // The named folder first, so a caller always gets back the row it asked
299
+ // about. Each write carries its own predicate, so a row somebody else
300
+ // moved between the read and the write is skipped, not overwritten (D3).
301
+ for (const row of [current, ...alsoRecorded]) {
302
+ const written = await this.mailboxService.transition(
303
+ accountId,
304
+ row.mailboxId,
305
+ {
306
+ from: [MailboxSyncStatus.failed],
307
+ wherePendingPath: row.pendingPath ?? null,
308
+ to: MailboxSyncStatus.synced,
309
+ },
310
+ );
311
+ if (row.mailboxId === mailboxId) dismissed = written ?? undefined;
312
+ this.log.info(
313
+ {
314
+ accountId,
315
+ mailboxId: row.mailboxId,
316
+ intent: "dismiss",
317
+ from: MailboxSyncStatus.failed,
318
+ to: MailboxSyncStatus.synced,
319
+ outcome: written ? "settled" : "superseded",
320
+ },
321
+ "Dismissed folder intent",
322
+ );
323
+ }
324
+
325
+ if (dismissed) return dismissed;
326
+ // The row moved between the read and its write. Re-read rather than
327
+ // reporting a dismissal that did not happen.
328
+ return this.mailboxService.get(accountId, mailboxId);
329
+ };
330
+
331
+ /**
332
+ * Record a delete intent (T7) and enqueue IMAP DELETE.
333
+ *
334
+ * The row stays at `deleting` and stays visible until the worker confirms the
335
+ * server delete and removes it with the folder's mail (D8, D11). The enqueue
336
+ * happens only after the transition wins, and an enqueue failure stays thrown
337
+ * — a 500 to the caller, loud — rather than being swallowed or rolled back.
215
338
  *
216
339
  * @param mailboxId - The mailbox to delete
217
340
  * @param accountId - The account ID for the IMAP sync event
@@ -223,17 +346,30 @@ export class MailboxQueueService {
223
346
  // Get current mailbox to capture path
224
347
  const mailbox = await this.mailboxService.get(accountId, mailboxId);
225
348
 
226
- // Mark as deleting (soft delete - worker will do actual delete after IMAP sync)
227
349
  const recorded = await this.mailboxService.transition(
228
350
  accountId,
229
351
  mailboxId,
230
- { from: EVERY_MAILBOX_STATE, to: MailboxSyncStatus.deleting },
352
+ { from: INTENT_RECORDABLE_FROM, to: MailboxSyncStatus.deleting },
231
353
  );
232
- if (!recorded) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
354
+ if (!recorded) {
355
+ await refuseContestedIntent(
356
+ this.mailboxService,
357
+ accountId,
358
+ mailboxId,
359
+ "folder",
360
+ );
361
+ }
233
362
 
234
363
  this.log.info(
235
- { mailboxId, path: mailbox.fullPath },
236
- "Marked mailbox for deletion (local)",
364
+ {
365
+ accountId,
366
+ mailboxId,
367
+ intent: "delete",
368
+ from: mailbox.syncStatus,
369
+ to: MailboxSyncStatus.deleting,
370
+ path: mailbox.fullPath,
371
+ },
372
+ "Recorded delete intent",
237
373
  );
238
374
 
239
375
  // Enqueue IMAP sync
@@ -4,13 +4,18 @@ import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
4
4
  import { MailboxSyncStatus } from "@remit/domain-enums";
5
5
  import { MailboxQueueService } from "./mailbox-queue.js";
6
6
 
7
- const row = (mailboxId: string, fullPath: string): MailboxItem =>
7
+ const row = (
8
+ mailboxId: string,
9
+ fullPath: string,
10
+ over: Partial<MailboxItem> = {},
11
+ ): MailboxItem =>
8
12
  ({
9
13
  mailboxId,
10
14
  accountId: "acc-1",
11
15
  fullPath,
12
16
  hierarchyDelimiter: "/",
13
17
  syncStatus: MailboxSyncStatus.synced,
18
+ ...over,
14
19
  }) as MailboxItem;
15
20
 
16
21
  /**
@@ -19,8 +24,21 @@ const row = (mailboxId: string, fullPath: string): MailboxItem =>
19
24
  */
20
25
  const store = (rows: MailboxItem[]) => {
21
26
  const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
27
+ const subtreeOf = (root: MailboxItem) => {
28
+ const prefix = `${root.fullPath}${root.hierarchyDelimiter}`;
29
+ return [...byId.values()].filter(
30
+ (r) => r.mailboxId !== root.mailboxId && r.fullPath.startsWith(prefix),
31
+ );
32
+ };
22
33
 
23
- const repo: Pick<IMailboxRepository, "get" | "transitionSubtree"> = {
34
+ const repo: Pick<
35
+ IMailboxRepository,
36
+ | "get"
37
+ | "transition"
38
+ | "transitionSubtree"
39
+ | "findByPathPrefix"
40
+ | "findBySyncStatus"
41
+ > = {
24
42
  get: (async (_accountId: string, mailboxId: string) => {
25
43
  const found = byId.get(mailboxId as string);
26
44
  if (!found) {
@@ -30,19 +48,45 @@ const store = (rows: MailboxItem[]) => {
30
48
  }
31
49
  return found;
32
50
  }) as IMailboxRepository["get"],
51
+ findByPathPrefix: async (_accountId, pathPrefix) => {
52
+ const root = [...byId.values()].find((r) => r.fullPath === pathPrefix);
53
+ return root ? subtreeOf(root) : [];
54
+ },
55
+ findBySyncStatus: async (_accountId, syncStatus) =>
56
+ [...byId.values()].filter((r) => r.syncStatus === syncStatus),
57
+ transition: async (_accountId, mailboxId, intent) => {
58
+ const current = byId.get(mailboxId);
59
+ if (!current) return null;
60
+ if (!intent.from.includes(current.syncStatus)) return null;
61
+ if (
62
+ intent.wherePendingPath !== undefined &&
63
+ (intent.wherePendingPath ?? undefined) !== current.pendingPath
64
+ ) {
65
+ return null;
66
+ }
67
+ const keepsTarget =
68
+ intent.to === MailboxSyncStatus.pending ||
69
+ intent.to === MailboxSyncStatus.failed;
70
+ const next = {
71
+ ...current,
72
+ ...(intent.set?.fullPath !== undefined
73
+ ? { fullPath: intent.set.fullPath }
74
+ : {}),
75
+ syncStatus: intent.to,
76
+ } as MailboxItem;
77
+ if (!keepsTarget) delete (next as { pendingPath?: string }).pendingPath;
78
+ byId.set(mailboxId, next);
79
+ return next;
80
+ },
33
81
  transitionSubtree: async (_accountId, mailboxId, intent) => {
34
82
  const root = byId.get(mailboxId);
35
83
  if (!root) return null;
36
- const prefix = `${root.fullPath}${root.hierarchyDelimiter}`;
37
- const subtree = [
38
- root,
39
- ...[...byId.values()].filter(
40
- (r) => r !== root && r.fullPath.startsWith(prefix),
41
- ),
42
- ];
84
+ const subtree = [root, ...subtreeOf(root)];
85
+ // All-or-nothing, the way the real transaction is: one row that is not
86
+ // in an accepted from-state refuses the whole intent and writes nothing.
87
+ if (subtree.some((c) => !intent.from.includes(c.syncStatus))) return null;
43
88
  const written: MailboxItem[] = [];
44
89
  for (const candidate of subtree) {
45
- if (!intent.from.includes(candidate.syncStatus)) return null;
46
90
  const next = {
47
91
  ...candidate,
48
92
  ...intent.rowSet(candidate),
@@ -59,7 +103,7 @@ const store = (rows: MailboxItem[]) => {
59
103
 
60
104
  return {
61
105
  repo: repo as IMailboxRepository,
62
- pathOf: (id: string) => byId.get(id)?.fullPath,
106
+ rowOf: (id: string) => byId.get(id),
63
107
  };
64
108
  };
65
109
 
@@ -84,27 +128,40 @@ const queueOver = (repo: IMailboxRepository) => {
84
128
  };
85
129
 
86
130
  describe("MailboxQueueService.renameMailbox — the recorded intent", () => {
131
+ it("records the target and leaves every confirmed path alone", async () => {
132
+ // D2: `fullPath` is always a path the server holds. Writing the target onto
133
+ // the row is what made a failed rename strand a subtree at paths the server
134
+ // never had, which the next sweep then inserted second rows for.
135
+ const { repo, rowOf } = store([
136
+ row("mbx-parent", "Work"),
137
+ row("mbx-child", "Work/2026"),
138
+ ]);
139
+ const { service } = queueOver(repo);
140
+
141
+ await service.renameMailbox("mbx-parent", "Projects", "acc-1");
142
+
143
+ assert.equal(rowOf("mbx-parent")?.fullPath, "Work");
144
+ assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
145
+ assert.equal(rowOf("mbx-child")?.fullPath, "Work/2026");
146
+ assert.equal(rowOf("mbx-child")?.pendingPath, "Projects/2026");
147
+ assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.pending);
148
+ });
149
+
87
150
  it("records the path the RENAME will carry, `$` patterns and all", async () => {
88
151
  // `String.prototype.replace` expands `$&`, "$`", `$'` and `$$` in the
89
152
  // replacement, so a folder renamed to a name containing one of them was
90
153
  // stored under a different path from the one the event carries to the
91
- // server. The row and the server then disagree, and the next sweep finds
92
- // no row for the real path and inserts a second one.
93
- const { repo, pathOf } = store([
154
+ // server. The row and the server then disagree.
155
+ const { repo, rowOf } = store([
94
156
  row("mbx-parent", "Work"),
95
157
  row("mbx-child", "Work/2026"),
96
158
  ]);
97
159
  const { service, sent } = queueOver(repo);
98
160
 
99
- const renamed = await service.renameMailbox(
100
- "mbx-parent",
101
- "Q1$&Q2",
102
- "acc-1",
103
- );
161
+ await service.renameMailbox("mbx-parent", "Q1$&Q2", "acc-1");
104
162
 
105
- assert.equal(renamed.fullPath, "Q1$&Q2");
106
- assert.equal(pathOf("mbx-parent"), "Q1$&Q2");
107
- assert.equal(pathOf("mbx-child"), "Q1$&Q2/2026");
163
+ assert.equal(rowOf("mbx-parent")?.pendingPath, "Q1$&Q2");
164
+ assert.equal(rowOf("mbx-child")?.pendingPath, "Q1$&Q2/2026");
108
165
  assert.equal(
109
166
  (JSON.parse(sent[0]) as { newPath: string }).newPath,
110
167
  "Q1$&Q2",
@@ -113,7 +170,7 @@ describe("MailboxQueueService.renameMailbox — the recorded intent", () => {
113
170
  });
114
171
 
115
172
  it("moves only what is under the renamed branch", async () => {
116
- const { repo, pathOf } = store([
173
+ const { repo, rowOf } = store([
117
174
  row("mbx-parent", "Work"),
118
175
  row("mbx-lookalike", "Workshop"),
119
176
  ]);
@@ -121,8 +178,9 @@ describe("MailboxQueueService.renameMailbox — the recorded intent", () => {
121
178
 
122
179
  await service.renameMailbox("mbx-parent", "Projects", "acc-1");
123
180
 
124
- assert.equal(pathOf("mbx-parent"), "Projects");
125
- assert.equal(pathOf("mbx-lookalike"), "Workshop");
181
+ assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
182
+ assert.equal(rowOf("mbx-lookalike")?.pendingPath, undefined);
183
+ assert.equal(rowOf("mbx-lookalike")?.syncStatus, MailboxSyncStatus.synced);
126
184
  });
127
185
 
128
186
  it("returns the folder that was asked for, not whichever row came back first", async () => {
@@ -139,5 +197,193 @@ describe("MailboxQueueService.renameMailbox — the recorded intent", () => {
139
197
  );
140
198
 
141
199
  assert.equal(renamed.mailboxId, "mbx-parent");
200
+ assert.equal(renamed.pendingPath, "Projects");
201
+ });
202
+
203
+ it("records a retry from a failed rename onto the same row", async () => {
204
+ const { repo, rowOf } = store([
205
+ row("mbx-parent", "Work", {
206
+ syncStatus: MailboxSyncStatus.failed,
207
+ pendingPath: "Projects",
208
+ }),
209
+ ]);
210
+ const { service } = queueOver(repo);
211
+
212
+ await service.renameMailbox("mbx-parent", "Projects", "acc-1");
213
+
214
+ assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
215
+ assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
216
+ });
217
+
218
+ it("refuses the whole subtree with a 409 naming the descendant that blocks it", async () => {
219
+ // A subtree cannot be half-renamed (D6), so a descendant mid-mutation
220
+ // refuses the rename of everything above it — and the user has to be told
221
+ // which folder, because it is not the one they named.
222
+ const { repo, rowOf } = store([
223
+ row("mbx-parent", "Archive"),
224
+ row("mbx-child", "Archive/2025/Receipts", {
225
+ syncStatus: MailboxSyncStatus.pending,
226
+ pendingPath: "Archive/2025/Bonnen",
227
+ }),
228
+ ]);
229
+ const { service, sent } = queueOver(repo);
230
+
231
+ await assert.rejects(
232
+ service.renameMailbox("mbx-parent", "Archief", "acc-1"),
233
+ (error: unknown) =>
234
+ (error as { statusCode?: number }).statusCode === 409 &&
235
+ (error as Error).message ===
236
+ 'A rename is in progress for "Archive/2025/Receipts", inside this folder.',
237
+ );
238
+
239
+ assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
240
+ assert.equal(rowOf("mbx-parent")?.pendingPath, undefined);
241
+ assert.equal(sent.length, 0, "nothing is enqueued for a refused intent");
242
+ });
243
+
244
+ it("refuses with a 409 naming the folder's own delete when it is the blocker", async () => {
245
+ const { repo } = store([
246
+ row("mbx-parent", "Archive", {
247
+ syncStatus: MailboxSyncStatus.deleting,
248
+ }),
249
+ ]);
250
+ const { service } = queueOver(repo);
251
+
252
+ await assert.rejects(
253
+ service.renameMailbox("mbx-parent", "Archief", "acc-1"),
254
+ (error: unknown) =>
255
+ (error as { statusCode?: number }).statusCode === 409 &&
256
+ (error as Error).message ===
257
+ 'A delete is already in progress for "Archive".',
258
+ );
259
+ });
260
+
261
+ it("answers 404 when the folder went between the read and the intent", async () => {
262
+ const { repo } = store([row("mbx-parent", "Work")]);
263
+ let read = 0;
264
+ // The read that establishes existence sees the row; the classifying read
265
+ // on the lost predicate does not, so the answer is 404, not a conflict.
266
+ const vanishing = {
267
+ ...repo,
268
+ get: (async (accountId: string, mailboxId: string) => {
269
+ read += 1;
270
+ if (read > 1) {
271
+ throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
272
+ name: "NotFoundError",
273
+ });
274
+ }
275
+ return repo.get(accountId, mailboxId as string);
276
+ }) as IMailboxRepository["get"],
277
+ transitionSubtree: async () => null,
278
+ } as IMailboxRepository;
279
+ const { service } = queueOver(vanishing);
280
+
281
+ await assert.rejects(
282
+ service.renameMailbox("mbx-parent", "Projects", "acc-1"),
283
+ (error: unknown) => (error as Error).name === "NotFoundError",
284
+ );
285
+ });
286
+ });
287
+
288
+ describe("MailboxQueueService.dismissMailboxIntent", () => {
289
+ it("clears a failed rename's target and settles the row, enqueuing nothing", async () => {
290
+ const { repo, rowOf } = store([
291
+ row("mbx-1", "Work", {
292
+ syncStatus: MailboxSyncStatus.failed,
293
+ pendingPath: "Projects",
294
+ }),
295
+ ]);
296
+ const { service, sent } = queueOver(repo);
297
+
298
+ const dismissed = await service.dismissMailboxIntent("mbx-1", "acc-1");
299
+
300
+ assert.equal(dismissed.syncStatus, MailboxSyncStatus.synced);
301
+ assert.equal(rowOf("mbx-1")?.pendingPath, undefined);
302
+ assert.equal(rowOf("mbx-1")?.fullPath, "Work");
303
+ assert.equal(sent.length, 0);
304
+ });
305
+
306
+ it("clears the whole subtree the failed rename was recorded over", async () => {
307
+ // The refusal marks every row the intent recorded, so dismissing the named
308
+ // folder alone leaves each descendant `failed` with a stale target on it,
309
+ // offering a retry of a rename the user has already dropped and with
310
+ // nothing left that would ever clear it.
311
+ const { repo, rowOf } = store([
312
+ row("mbx-parent", "Work", {
313
+ syncStatus: MailboxSyncStatus.failed,
314
+ pendingPath: "Projects",
315
+ }),
316
+ row("mbx-child", "Work/2026", {
317
+ syncStatus: MailboxSyncStatus.failed,
318
+ pendingPath: "Projects/2026",
319
+ }),
320
+ ]);
321
+ const { service } = queueOver(repo);
322
+
323
+ await service.dismissMailboxIntent("mbx-parent", "acc-1");
324
+
325
+ for (const id of ["mbx-parent", "mbx-child"]) {
326
+ assert.equal(rowOf(id)?.syncStatus, MailboxSyncStatus.synced);
327
+ assert.equal(rowOf(id)?.pendingPath, undefined);
328
+ }
329
+ });
330
+
331
+ it("leaves a failed folder a different rename recorded alone", async () => {
332
+ const { repo, rowOf } = store([
333
+ row("mbx-parent", "Work", {
334
+ syncStatus: MailboxSyncStatus.failed,
335
+ pendingPath: "Projects",
336
+ }),
337
+ row("mbx-other", "Other", {
338
+ syncStatus: MailboxSyncStatus.failed,
339
+ pendingPath: "Projects/Old",
340
+ }),
341
+ ]);
342
+ const { service } = queueOver(repo);
343
+
344
+ await service.dismissMailboxIntent("mbx-parent", "acc-1");
345
+
346
+ assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.failed);
347
+ assert.equal(rowOf("mbx-other")?.pendingPath, "Projects/Old");
348
+ });
349
+
350
+ it("dismisses a failed delete on the named folder alone", async () => {
351
+ // No recorded target, so there is no subtree the intent was written over.
352
+ const { repo, rowOf } = store([
353
+ row("mbx-parent", "Work", { syncStatus: MailboxSyncStatus.failed }),
354
+ row("mbx-child", "Work/2026", { syncStatus: MailboxSyncStatus.failed }),
355
+ ]);
356
+ const { service } = queueOver(repo);
357
+
358
+ await service.dismissMailboxIntent("mbx-parent", "acc-1");
359
+
360
+ assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
361
+ assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.failed);
362
+ });
363
+
364
+ it("is a no-op on a folder with nothing to dismiss", async () => {
365
+ const { repo } = store([row("mbx-1", "Work")]);
366
+ const { service } = queueOver(repo);
367
+
368
+ const dismissed = await service.dismissMailboxIntent("mbx-1", "acc-1");
369
+
370
+ assert.equal(dismissed.syncStatus, MailboxSyncStatus.synced);
371
+ });
372
+
373
+ it("refuses with a 409 while a mutation is still in flight", async () => {
374
+ const { repo } = store([
375
+ row("mbx-1", "Work", {
376
+ syncStatus: MailboxSyncStatus.pending,
377
+ pendingPath: "Projects",
378
+ }),
379
+ ]);
380
+ const { service } = queueOver(repo);
381
+
382
+ await assert.rejects(
383
+ service.dismissMailboxIntent("mbx-1", "acc-1"),
384
+ (error: unknown) =>
385
+ (error as { statusCode?: number }).statusCode === 409 &&
386
+ (error as Error).message === 'A rename is in progress for "Work".',
387
+ );
142
388
  });
143
389
  });