@remit/mailbox-service 0.0.96 → 0.0.97
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-intent.ts +109 -0
- package/src/mailbox-management.test.ts +28 -2
- package/src/mailbox-management.ts +453 -69
- package/src/mailbox-presence.ts +6 -5
- package/src/mailbox-queue.ts +133 -13
- 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-queue.ts
CHANGED
|
@@ -9,6 +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 {
|
|
13
|
+
INTENT_RECORDABLE_FROM,
|
|
14
|
+
recordedByRename,
|
|
15
|
+
refuseContestedIntent,
|
|
16
|
+
} from "./mailbox-intent.js";
|
|
12
17
|
import { EVERY_MAILBOX_STATE } from "./mailbox-presence.js";
|
|
13
18
|
|
|
14
19
|
/**
|
|
@@ -149,13 +154,19 @@ export class MailboxQueueService {
|
|
|
149
154
|
};
|
|
150
155
|
|
|
151
156
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
157
|
+
* Record a rename intent over the folder and every descendant, then enqueue
|
|
158
|
+
* IMAP RENAME.
|
|
159
|
+
*
|
|
160
|
+
* The target is recorded, not the path (D2): every row keeps the `fullPath`
|
|
161
|
+
* the server holds and takes its own rewritten target in `pendingPath`, so a
|
|
162
|
+
* rename that never lands needs no unwinding — dropping `pendingPath` is the
|
|
163
|
+
* whole revert — and nothing that resolves a folder path from a row can
|
|
164
|
+
* resolve one the server does not have.
|
|
154
165
|
*
|
|
155
166
|
* @param mailboxId - The mailbox to rename
|
|
156
|
-
* @param newPath - The
|
|
167
|
+
* @param newPath - The path the rename is aiming at
|
|
157
168
|
* @param accountId - The account ID for the IMAP sync event
|
|
158
|
-
* @returns The
|
|
169
|
+
* @returns The folder that was named, carrying its recorded target
|
|
159
170
|
*/
|
|
160
171
|
renameMailbox = async (
|
|
161
172
|
mailboxId: string,
|
|
@@ -168,32 +179,58 @@ export class MailboxQueueService {
|
|
|
168
179
|
|
|
169
180
|
// One intent over the folder and every descendant, in one transaction:
|
|
170
181
|
// 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).
|
|
182
|
+
// rename is the state that produced today's phantom rows (D6). A
|
|
183
|
+
// descendant already mid-mutation refuses the whole rename.
|
|
175
184
|
const written = await this.mailboxService.transitionSubtree(
|
|
176
185
|
accountId,
|
|
177
186
|
mailboxId,
|
|
178
187
|
{
|
|
179
|
-
from:
|
|
188
|
+
from: INTENT_RECORDABLE_FROM,
|
|
180
189
|
to: MailboxSyncStatus.pending,
|
|
181
190
|
rowSet: (row) => {
|
|
182
|
-
const
|
|
191
|
+
const target = rebaseMailboxPath(
|
|
183
192
|
row.fullPath,
|
|
184
193
|
oldPath,
|
|
185
194
|
newPath,
|
|
186
195
|
mailbox.hierarchyDelimiter,
|
|
187
196
|
);
|
|
188
|
-
|
|
197
|
+
// The subtree is the folder and the rows under its own prefix, so
|
|
198
|
+
// every one of them rebases. A row that does not is a resolution
|
|
199
|
+
// bug, and recording an intent with no target for it would strand
|
|
200
|
+
// it `pending` with nothing able to settle it.
|
|
201
|
+
if (target === undefined) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Mailbox ${row.mailboxId} at "${row.fullPath}" is not under "${oldPath}"`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return { pendingPath: target };
|
|
189
207
|
},
|
|
190
208
|
},
|
|
191
209
|
);
|
|
192
|
-
if (!written)
|
|
210
|
+
if (!written) {
|
|
211
|
+
return refuseContestedIntent(
|
|
212
|
+
this.mailboxService,
|
|
213
|
+
accountId,
|
|
214
|
+
mailboxId,
|
|
215
|
+
"subtree",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
193
218
|
const updated = written.find((row) => row.mailboxId === mailboxId);
|
|
194
219
|
if (!updated) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
195
220
|
|
|
196
|
-
this.log.info(
|
|
221
|
+
this.log.info(
|
|
222
|
+
{
|
|
223
|
+
accountId,
|
|
224
|
+
mailboxId,
|
|
225
|
+
intent: "rename",
|
|
226
|
+
from: mailbox.syncStatus,
|
|
227
|
+
to: MailboxSyncStatus.pending,
|
|
228
|
+
oldPath,
|
|
229
|
+
newPath,
|
|
230
|
+
subtreeSize: written.length,
|
|
231
|
+
},
|
|
232
|
+
"Recorded rename intent",
|
|
233
|
+
);
|
|
197
234
|
|
|
198
235
|
// Enqueue IMAP sync
|
|
199
236
|
await this.enqueueEvent({
|
|
@@ -209,6 +246,89 @@ export class MailboxQueueService {
|
|
|
209
246
|
return updated;
|
|
210
247
|
};
|
|
211
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The route out of a failed rename or a failed delete that keeps the folder
|
|
251
|
+
* as it is (T10, T11): the recorded target is dropped and the row settles
|
|
252
|
+
* back to `synced`. Nothing is enqueued — `fullPath` was never written, so
|
|
253
|
+
* the server has nothing to undo.
|
|
254
|
+
*
|
|
255
|
+
* Reached without new API surface, from a PATCH whose `fullPath` equals the
|
|
256
|
+
* row's confirmed one. From `synced` it is a no-op; from a state with a
|
|
257
|
+
* mutation in flight it is a 409, because dismissing an intent that is still
|
|
258
|
+
* running would leave the settle with no row to write.
|
|
259
|
+
*/
|
|
260
|
+
dismissMailboxIntent = async (
|
|
261
|
+
mailboxId: string,
|
|
262
|
+
accountId: string,
|
|
263
|
+
): Promise<MailboxItem> => {
|
|
264
|
+
const current = await this.mailboxService.get(accountId, mailboxId);
|
|
265
|
+
if (current.syncStatus === MailboxSyncStatus.synced) return current;
|
|
266
|
+
if (current.syncStatus !== MailboxSyncStatus.failed) {
|
|
267
|
+
return refuseContestedIntent(
|
|
268
|
+
this.mailboxService,
|
|
269
|
+
accountId,
|
|
270
|
+
mailboxId,
|
|
271
|
+
"folder",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// A failed rename was recorded over a subtree, so it is dismissed over the
|
|
276
|
+
// same one. A failed delete carries no target and was recorded on the
|
|
277
|
+
// folder alone, so there is nothing else to clear.
|
|
278
|
+
const target = current.pendingPath;
|
|
279
|
+
const alsoRecorded =
|
|
280
|
+
target === undefined
|
|
281
|
+
? []
|
|
282
|
+
: (
|
|
283
|
+
await this.mailboxService.findBySyncStatus(
|
|
284
|
+
accountId,
|
|
285
|
+
MailboxSyncStatus.failed,
|
|
286
|
+
)
|
|
287
|
+
).filter(
|
|
288
|
+
(row) =>
|
|
289
|
+
row.mailboxId !== mailboxId &&
|
|
290
|
+
recordedByRename(
|
|
291
|
+
row,
|
|
292
|
+
current.fullPath,
|
|
293
|
+
target,
|
|
294
|
+
current.hierarchyDelimiter,
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
let dismissed: MailboxItem | undefined;
|
|
299
|
+
// The named folder first, so a caller always gets back the row it asked
|
|
300
|
+
// about. Each write carries its own predicate, so a row somebody else
|
|
301
|
+
// moved between the read and the write is skipped, not overwritten (D3).
|
|
302
|
+
for (const row of [current, ...alsoRecorded]) {
|
|
303
|
+
const written = await this.mailboxService.transition(
|
|
304
|
+
accountId,
|
|
305
|
+
row.mailboxId,
|
|
306
|
+
{
|
|
307
|
+
from: [MailboxSyncStatus.failed],
|
|
308
|
+
wherePendingPath: row.pendingPath ?? null,
|
|
309
|
+
to: MailboxSyncStatus.synced,
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
if (row.mailboxId === mailboxId) dismissed = written ?? undefined;
|
|
313
|
+
this.log.info(
|
|
314
|
+
{
|
|
315
|
+
accountId,
|
|
316
|
+
mailboxId: row.mailboxId,
|
|
317
|
+
intent: "dismiss",
|
|
318
|
+
from: MailboxSyncStatus.failed,
|
|
319
|
+
to: MailboxSyncStatus.synced,
|
|
320
|
+
outcome: written ? "settled" : "superseded",
|
|
321
|
+
},
|
|
322
|
+
"Dismissed folder intent",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (dismissed) return dismissed;
|
|
327
|
+
// The row moved between the read and its write. Re-read rather than
|
|
328
|
+
// reporting a dismissal that did not happen.
|
|
329
|
+
return this.mailboxService.get(accountId, mailboxId);
|
|
330
|
+
};
|
|
331
|
+
|
|
212
332
|
/**
|
|
213
333
|
* Delete a mailbox.
|
|
214
334
|
* Marks for deletion (syncStatus=deleting) and enqueues IMAP DELETE.
|
|
@@ -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
|
});
|