@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.
- package/package.json +1 -1
- package/src/imapflow-connection.ts +9 -0
- package/src/index.ts +6 -0
- package/src/mailbox-delete-intent.test.ts +246 -0
- package/src/mailbox-intent.ts +109 -0
- package/src/mailbox-management.test.ts +28 -2
- package/src/mailbox-management.ts +544 -72
- package/src/mailbox-presence.ts +6 -5
- package/src/mailbox-queue.ts +157 -21
- package/src/mailbox-rename-intent.test.ts +271 -25
- package/src/mailbox-rename-subtree.test.ts +494 -106
- package/src/mailbox-sync.ts +1 -27
- package/src/mailbox-upstream.ts +83 -0
package/src/mailbox-presence.ts
CHANGED
|
@@ -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)
|
|
13
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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,
|
package/src/mailbox-queue.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
153
|
-
*
|
|
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
|
|
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
|
|
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).
|
|
172
|
-
// descendant
|
|
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:
|
|
187
|
+
from: INTENT_RECORDABLE_FROM,
|
|
180
188
|
to: MailboxSyncStatus.pending,
|
|
181
189
|
rowSet: (row) => {
|
|
182
|
-
const
|
|
190
|
+
const target = rebaseMailboxPath(
|
|
183
191
|
row.fullPath,
|
|
184
192
|
oldPath,
|
|
185
193
|
newPath,
|
|
186
194
|
mailbox.hierarchyDelimiter,
|
|
187
195
|
);
|
|
188
|
-
|
|
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)
|
|
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(
|
|
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
|
-
*
|
|
214
|
-
*
|
|
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:
|
|
352
|
+
{ from: INTENT_RECORDABLE_FROM, to: MailboxSyncStatus.deleting },
|
|
231
353
|
);
|
|
232
|
-
if (!recorded)
|
|
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
|
-
{
|
|
236
|
-
|
|
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 = (
|
|
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<
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
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(
|
|
106
|
-
assert.equal(
|
|
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,
|
|
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(
|
|
125
|
-
assert.equal(
|
|
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
|
});
|