@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.96",
3
+ "version": "0.0.97",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -1053,6 +1053,15 @@ export class ImapFlowConnection {
1053
1053
  }
1054
1054
 
1055
1055
  const result = await client.mailboxRename(oldPath, newPath);
1056
+ // `mailboxRename` resolves `undefined` when the connection is not
1057
+ // AUTHENTICATED or SELECTED — RENAME was never issued. Dereferencing it
1058
+ // raised a `TypeError` where the settle now reads the resolved path, and a
1059
+ // `TypeError` is not an outcome the rename settle can classify.
1060
+ if (!result) {
1061
+ throw new Error(
1062
+ `Rename of "${oldPath}" was not issued: the IMAP connection is not ready`,
1063
+ );
1064
+ }
1056
1065
  return {
1057
1066
  path: result.path,
1058
1067
  newPath: result.newPath,
package/src/index.ts CHANGED
@@ -193,6 +193,12 @@ export {
193
193
  MailboxSyncService,
194
194
  type SyncAccountInfo,
195
195
  } from "./mailbox-sync.js";
196
+ export {
197
+ FolderGoneUpstreamError,
198
+ FolderRenameSettleError,
199
+ isMailboxAbsentUpstream,
200
+ isMailboxPresentUpstream,
201
+ } from "./mailbox-upstream.js";
196
202
  export {
197
203
  denormalizeMessageCategory,
198
204
  type MessageCategoryDenormalizeDeps,
@@ -0,0 +1,109 @@
1
+ import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
2
+ import { ConflictError, NotFoundError } from "@remit/data-ports/errors";
3
+ import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
4
+ import { MailboxSyncStatus } from "@remit/domain-enums";
5
+ import { isNotFoundError } from "./mailbox-presence.js";
6
+
7
+ /**
8
+ * The states a rename or a delete intent may be recorded from (T4, T7). Both
9
+ * failed variants are legal starting points — a retry re-records the same
10
+ * intent on the same row, so no duplicate folder can be created — and every
11
+ * other state means a mutation is already in flight on the folder.
12
+ */
13
+ export const INTENT_RECORDABLE_FROM = [
14
+ MailboxSyncStatus.synced,
15
+ MailboxSyncStatus.failed,
16
+ ] as const;
17
+
18
+ const carriesIntent = (row: Pick<MailboxItem, "syncStatus">): boolean =>
19
+ (INTENT_RECORDABLE_FROM as readonly string[]).includes(row.syncStatus);
20
+
21
+ /**
22
+ * Whether a row was written by one particular rename intent
23
+ * (docs/architecture/folder-rename-and-delete.md D15).
24
+ *
25
+ * A recorded target that merely sits at or under the rename's target is not
26
+ * enough to identify one. Two folders can hold targets under the same path —
27
+ * `Work → Projects` in flight while `Other → Projects/Old` is recorded — and a
28
+ * settle that claimed both would stamp a row this rename never touched onto a
29
+ * path it never asked for, leaving that folder's own rename unrepresented and
30
+ * two rows claiming one path for the sweep to reap and re-insert.
31
+ *
32
+ * The identity is the pair: the row sits at or under the path the rename is
33
+ * leaving, **and** the target it recorded is exactly what this rename's own
34
+ * rebase would have written for it. Nothing but this intent writes that pair,
35
+ * because the intent is recorded over one subtree in one transaction (D6) and
36
+ * the transition is the only writer of either field (D3).
37
+ */
38
+ export const recordedByRename = (
39
+ row: Pick<MailboxItem, "fullPath" | "pendingPath">,
40
+ oldPath: string,
41
+ newPath: string,
42
+ delimiter: string,
43
+ ): boolean =>
44
+ row.pendingPath !== undefined &&
45
+ rebaseMailboxPath(row.fullPath, oldPath, newPath, delimiter) ===
46
+ row.pendingPath;
47
+
48
+ /**
49
+ * What is already happening to a folder, as a clause naming the folder. The
50
+ * six states, not the four enum values: `pending` covers a create in flight and
51
+ * a rename in flight, and only `pendingPath` tells them apart.
52
+ */
53
+ const inFlight = (row: MailboxItem): string => {
54
+ if (row.syncStatus === MailboxSyncStatus.deleting) {
55
+ return `A delete is already in progress for "${row.fullPath}"`;
56
+ }
57
+ if (row.pendingPath !== undefined) {
58
+ return `A rename is in progress for "${row.fullPath}"`;
59
+ }
60
+ return `"${row.fullPath}" is still being created`;
61
+ };
62
+
63
+ /**
64
+ * Say why a folder intent lost its compare-and-set (D3).
65
+ *
66
+ * Existence was established by the read the caller took before recording, so a
67
+ * lost predicate is a conflict rather than a 404 — but the row can have gone in
68
+ * between, and one primary-key read tells the two apart. For a subtree intent
69
+ * the blocker may be a descendant, and the message says so: a subtree cannot be
70
+ * half-renamed (D6), so a descendant mid-mutation refuses the whole rename and
71
+ * a user who was not thinking about it has to be told which folder it is.
72
+ *
73
+ * Always rejects.
74
+ */
75
+ export const refuseContestedIntent = async (
76
+ mailboxService: Pick<IMailboxRepository, "get" | "findByPathPrefix">,
77
+ accountId: string,
78
+ mailboxId: string,
79
+ scope: "folder" | "subtree",
80
+ ): Promise<never> => {
81
+ const root = await mailboxService
82
+ .get(accountId, mailboxId)
83
+ .catch((error: unknown) => {
84
+ if (isNotFoundError(error)) return undefined;
85
+ throw error;
86
+ });
87
+ if (!root) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
88
+
89
+ if (!carriesIntent(root)) throw new ConflictError(`${inFlight(root)}.`);
90
+
91
+ const descendants =
92
+ scope === "subtree"
93
+ ? await mailboxService.findByPathPrefix(
94
+ accountId,
95
+ root.fullPath,
96
+ root.hierarchyDelimiter,
97
+ )
98
+ : [];
99
+ const blocking = descendants.find((row) => !carriesIntent(row));
100
+ if (blocking) {
101
+ throw new ConflictError(`${inFlight(blocking)}, inside this folder.`);
102
+ }
103
+
104
+ // Every row reads recordable now, so the row that blocked has already moved
105
+ // on. Nothing to name, and the same request succeeds on a retry.
106
+ throw new ConflictError(
107
+ `"${root.fullPath}" changed while this request was being recorded. Try again.`,
108
+ );
109
+ };
@@ -256,6 +256,27 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
256
256
  assert.deepStrictEqual(settles[0].intent.set, {});
257
257
  assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
258
258
  });
259
+
260
+ it("settles only a row with no rename target on it", async () => {
261
+ // The predicate the seventh state turns on (D3). A create redelivered
262
+ // after a lost acknowledgement, against a row a rename has since claimed,
263
+ // passes a `pending`-only guard, gets `{created: false}` — which #346
264
+ // correctly reads as success — and would settle `synced` with the rename
265
+ // target still on it, killing the rename silently.
266
+ const { repo, settles } = recordingRepo();
267
+ const { connection } = stubConnection("Archive", ["Archive"]);
268
+ const service = new MailboxManagementService(repo);
269
+
270
+ await service.syncCreate(
271
+ "acc-1",
272
+ "mbx-5",
273
+ "Archive",
274
+ async () => connection,
275
+ );
276
+
277
+ assert.strictEqual(settles[0].intent.wherePendingPath, null);
278
+ assert.deepStrictEqual(settles[0].intent.from, [MailboxSyncStatus.pending]);
279
+ });
259
280
  });
260
281
 
261
282
  describe("validateMailboxOperation", () => {
@@ -275,8 +296,13 @@ describe("validateMailboxOperation", () => {
275
296
  assert.doesNotThrow(() => validateMailboxOperation("delete", "Archive"));
276
297
  });
277
298
 
278
- it("allows renaming INBOX", () => {
279
- assert.doesNotThrow(() => validateMailboxOperation("rename", "INBOX"));
299
+ it("throws when renaming INBOX", () => {
300
+ // The server would move INBOX's mail to the new name and leave an empty
301
+ // INBOX behind — a bulk move, not a path update (D5). The API refuses it;
302
+ // this is the worker's backstop.
303
+ assert.throws(() => validateMailboxOperation("rename", "INBOX"), {
304
+ message: "Cannot rename INBOX",
305
+ });
280
306
  });
281
307
 
282
308
  it("allows renaming other mailboxes", () => {