@remit/data-ports 0.0.66 → 0.0.67

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/data-ports",
3
- "version": "0.0.66",
3
+ "version": "0.0.67",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -180,9 +180,9 @@ describe("resolveConfirmedMailboxForRole", () => {
180
180
 
181
181
  describe("resolveRoleForAccount", () => {
182
182
  it("resolves an appointment naming a mailbox whose last sync failed", () => {
183
- // `failed` has three writers (imap-worker mailbox-management: create :166
184
- // rolls nothing back, rename :275 restores oldPath, delete :384 leaves the
185
- // folder) and in two the folder is really at the path the row names.
183
+ // `failed` means the folder exists at `fullPath` and the last rename or
184
+ // delete intent did not land (folder-rename-and-delete.md D7), so the row
185
+ // names a path the server really holds.
186
186
  const failed: RoleMailboxCandidate & { syncStatus: string } = {
187
187
  ...mailbox("mb-trash", "INBOX/Prullenbak"),
188
188
  syncStatus: MailboxSyncStatus.failed,
package/src/index.ts CHANGED
@@ -117,6 +117,10 @@ export type {
117
117
  MailboxLockItem,
118
118
  MailboxSpecialUseItem,
119
119
  MailboxSpecialUseValue,
120
+ MailboxStatePredicate,
121
+ MailboxSubtreeTransitionIntent,
122
+ MailboxTransitionIntent,
123
+ MailboxTransitionWrite,
120
124
  MessageData,
121
125
  MessageDescription,
122
126
  MessageFlagItem,
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  CreateMailboxInput,
3
3
  MailboxItem,
4
+ MailboxSubtreeTransitionIntent,
5
+ MailboxTransitionIntent,
4
6
  ResultList,
5
7
  UpdateMailboxInput,
6
8
  } from "../types.js";
@@ -28,8 +30,67 @@ export interface IMailboxRepository {
28
30
  input: UpdateMailboxInput,
29
31
  remove?: string[],
30
32
  ): Promise<MailboxItem>;
33
+ /**
34
+ * Move one folder's mutation state from the state the caller read to the
35
+ * state it decided on, as one conditional write
36
+ * (docs/architecture/folder-rename-and-delete.md D3). The only writer of
37
+ * `syncStatus` and `pendingPath` — so a folder state written without a
38
+ * predicate is a type error rather than a convention.
39
+ *
40
+ * The predicate names one of the six states, not one of the four enum
41
+ * values: `wherePendingPath` as a string requires equality, `null` requires
42
+ * the column to be NULL, and omitting it predicates on `syncStatus` alone.
43
+ * A create settle that leaves it out matches a row a rename has claimed and
44
+ * strands it `synced` with a target on it, which is the seventh combination
45
+ * the invariant forbids.
46
+ *
47
+ * Resolves with the written row when the predicate matched, and with `null`
48
+ * when it did not — the row is absent, or somebody else won. The loser
49
+ * re-reads and re-decides; it never blind-retries and never writes anyway.
50
+ */
51
+ transition(
52
+ accountId: string,
53
+ mailboxId: string,
54
+ intent: MailboxTransitionIntent,
55
+ ): Promise<MailboxItem | null>;
56
+ /**
57
+ * Record one intent across a folder and every descendant, all-or-nothing, in
58
+ * one transaction (D6). Each row's UPDATE carries the from-state predicate
59
+ * and the affected-row count is compared to the resolved subtree, so a
60
+ * concurrent single-row transition rolls the whole thing back rather than
61
+ * being missed by a prior read.
62
+ *
63
+ * Resolves with every written row, or with `null` when any row in the
64
+ * subtree was not in an accepted from-state — in which case nothing was
65
+ * written. The named folder comes first and its descendants follow, but a
66
+ * caller wanting one row should find it by `mailboxId` rather than lean on
67
+ * that. This serves the intent only: a settle is per-row (D15).
68
+ */
69
+ transitionSubtree(
70
+ accountId: string,
71
+ mailboxId: string,
72
+ intent: MailboxSubtreeTransitionIntent,
73
+ ): Promise<MailboxItem[] | null>;
31
74
  delete(accountId: string, mailboxId: string): Promise<void>;
32
75
  deleteMany(accountId: string, mailboxIds: string[]): Promise<void>;
76
+ /**
77
+ * Remove a folder along with the mail it holds (D8): the messages through
78
+ * `deleteMessageSubtree`, so the nine per-message child tables go and one
79
+ * `message.removed` outbox row per message clears the search index; then the
80
+ * mailbox's own child rows; then, last, the mailbox row itself.
81
+ *
82
+ * "Its own child rows" includes a `message_placement_move` marker naming this
83
+ * folder as a move's *destination*, not only as its source. The folder it
84
+ * points at is gone, so the move it records can never land, and leaving the
85
+ * marker behind holds a message elsewhere in the account unsettled forever.
86
+ *
87
+ * Ordered, batched and resumable rather than one transaction — the caller
88
+ * keeps the row `deleting` until this returns, so a redelivery re-enters and
89
+ * continues. `filter` rows bound to this mailbox are never touched: D16
90
+ * refuses the delete while any binding stands, so there is nothing to unbind
91
+ * and deleting a user's filters is not a folder delete's decision.
92
+ */
93
+ deleteMailboxWithMail(accountId: string, mailboxId: string): Promise<void>;
33
94
  listByAccount(
34
95
  accountId: string,
35
96
  options?: { limit?: number; continuationToken?: string },
@@ -48,12 +109,6 @@ export interface IMailboxRepository {
48
109
  ): Promise<MailboxItem[]>;
49
110
  findBySyncStatus(
50
111
  accountId: string,
51
- syncStatus: NonNullable<MailboxItem["syncStatus"]>,
112
+ syncStatus: MailboxItem["syncStatus"],
52
113
  ): Promise<MailboxItem[]>;
53
- renameChildPaths(
54
- accountId: string,
55
- oldPath: string,
56
- newPath: string,
57
- delimiter?: string,
58
- ): Promise<void>;
59
114
  }
@@ -24,6 +24,31 @@ export const mailboxLeafName = (mailbox: MailboxPath): string => {
24
24
  return parts[parts.length - 1] || mailbox.fullPath;
25
25
  };
26
26
 
27
+ /**
28
+ * Where a path under a renamed folder lands, or `undefined` when the rename did
29
+ * not move it. IMAP RENAME moves the whole subtree in one command, so a path
30
+ * inside the renamed branch moves exactly as far as its prefix does, and a path
31
+ * merely starting with the same characters (`Workshop` beside `Work`) does not
32
+ * move at all.
33
+ *
34
+ * Sliced rather than replaced. `String.prototype.replace` with a string pattern
35
+ * expands `$&`, `` $` ``, `$'` and `$$` in the replacement, so a rename to a
36
+ * folder whose name contains one of those wrote a different path locally from
37
+ * the one the RENAME carried to the server — the row and the server diverge and
38
+ * the next sweep inserts a second row for the real path.
39
+ */
40
+ export const rebaseMailboxPath = (
41
+ recorded: string,
42
+ oldPath: string,
43
+ newPath: string,
44
+ delimiter: string,
45
+ ): string | undefined => {
46
+ if (recorded === oldPath) return newPath;
47
+ if (delimiter.length === 0) return undefined;
48
+ if (!recorded.startsWith(`${oldPath}${delimiter}`)) return undefined;
49
+ return `${newPath}${recorded.slice(oldPath.length)}`;
50
+ };
51
+
27
52
  const rank = (
28
53
  mailbox: MailboxNameCandidate,
29
54
  names: readonly string[],
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The doors onto a folder's mutation state, proved by the compiler.
3
+ *
4
+ * `docs/architecture/folder-rename-and-delete.md` D3 says every transition of a
5
+ * folder's state is a conditional write. A rule like that is worth exactly as
6
+ * much as the thing enforcing it, and a convention enforces nothing: every
7
+ * writer in the tree before this reached for the general-purpose `update()`,
8
+ * because nothing stopped them.
9
+ *
10
+ * So `syncStatus` and `pendingPath` are gone from `UpdateMailboxInput`, and
11
+ * `pendingPath` from `CreateMailboxInput` too — a create records no rename
12
+ * target (T1). What is left is `transition` and `transitionSubtree`, both of
13
+ * which take a predicate. Every `@ts-expect-error` below is a door that is
14
+ * shut: put the field back and these stop erroring, which fails the build.
15
+ *
16
+ * `@ts-expect-error` is the assertion here, so this file is checked rather than
17
+ * run. The one runtime case exists to keep the suite honest about having loaded
18
+ * it.
19
+ */
20
+
21
+ import assert from "node:assert/strict";
22
+ import { test } from "node:test";
23
+ import type {
24
+ CreateMailboxInput,
25
+ MailboxStatePredicate,
26
+ MailboxSubtreeTransitionIntent,
27
+ MailboxTransitionIntent,
28
+ UpdateMailboxInput,
29
+ } from "./types.js";
30
+
31
+ // --- The general-purpose writer refuses both state fields -------------------
32
+
33
+ // @ts-expect-error `syncStatus` is mutation state: write it through a transition
34
+ const syncStatus: UpdateMailboxInput = { syncStatus: "pending" };
35
+
36
+ // @ts-expect-error `pendingPath` is mutation state
37
+ const pendingPath: UpdateMailboxInput = { pendingPath: "Archive/2025" };
38
+
39
+ // The insert records a state — the intent recorders say which — but never a
40
+ // rename target, because a create has none.
41
+ const created: CreateMailboxInput = {
42
+ accountId: "acct-1",
43
+ namespacePrefix: "",
44
+ hierarchyDelimiter: "/",
45
+ fullPath: "Archive",
46
+ uidValidity: 1,
47
+ uidNext: 1,
48
+ highestModseq: "0",
49
+ messageCount: 0,
50
+ unseenCount: 0,
51
+ deletedCount: 0,
52
+ totalSize: 0,
53
+ lastSyncUid: 0,
54
+ highWaterMarkUid: 0,
55
+ lastMessageSyncAt: 0,
56
+ syncStatus: "pending",
57
+ };
58
+
59
+ // @ts-expect-error a create records no rename target (T1)
60
+ const createdWithTarget: CreateMailboxInput = { ...created, pendingPath: "X" };
61
+
62
+ // Everything else about a folder still goes through `update()`, unchanged.
63
+ const ordinary: UpdateMailboxInput = {
64
+ fullPath: "Archive/2025",
65
+ messageCount: 12,
66
+ cursorState: "cursor_invalid",
67
+ };
68
+
69
+ // --- The transitions are the doors, and they take a predicate ---------------
70
+
71
+ const transition: MailboxTransitionIntent = {
72
+ from: ["pending"],
73
+ wherePendingPath: null,
74
+ to: "synced",
75
+ set: { fullPath: "INBOX/Archive", pendingPath: null },
76
+ };
77
+
78
+ const subtree: MailboxSubtreeTransitionIntent = {
79
+ from: ["synced", "failed"],
80
+ to: "pending",
81
+ rowSet: (row) => ({ pendingPath: row.fullPath }),
82
+ };
83
+
84
+ // The predicate names states, never a free-form column: a caller cannot smuggle
85
+ // an unrelated field into the WHERE clause and call it a lock.
86
+ const notAState: MailboxStatePredicate = {
87
+ from: ["synced"],
88
+ // @ts-expect-error `fullPath` is not part of a folder's mutation state
89
+ fullPath: "INBOX",
90
+ };
91
+
92
+ test("the folder-state doors are the two transitions", () => {
93
+ assert.equal(transition.to, "synced");
94
+ assert.equal(
95
+ subtree.rowSet({ fullPath: "Work" } as never).pendingPath,
96
+ "Work",
97
+ );
98
+ assert.equal(ordinary.messageCount, 12);
99
+ assert.deepEqual(
100
+ [syncStatus, pendingPath, createdWithTarget, notAState].length,
101
+ 4,
102
+ "each field taken off an input, and the predicate's own shape, is pinned above",
103
+ );
104
+ });
package/src/types.ts CHANGED
@@ -360,6 +360,11 @@ export type BodyPartContentUpsertInput = {
360
360
  content: string;
361
361
  };
362
362
 
363
+ /**
364
+ * A mailbox row as an insert states it. `pendingPath` is absent: a create
365
+ * records no rename target (folder-rename-and-delete.md T1), and the transition
366
+ * is the only writer of it.
367
+ */
363
368
  export type CreateMailboxInput = Omit<
364
369
  MailboxItem,
365
370
  | "mailboxId"
@@ -368,14 +373,70 @@ export type CreateMailboxInput = Omit<
368
373
  | "namespaceType"
369
374
  | "parentMailboxId"
370
375
  | "cursorState"
376
+ | "syncStatus"
377
+ | "pendingPath"
371
378
  > & {
372
379
  namespaceType?: MailboxItem["namespaceType"];
373
380
  parentMailboxId?: MailboxItem["parentMailboxId"];
374
381
  /** Total per RFC 032 (defaults to `normal`) — optional at the input boundary, callers rarely set it explicitly. */
375
382
  cursorState?: MailboxItem["cursorState"];
383
+ /** Total per D1 (defaults to `synced`) — the insert paths that record an intent say so; the sweep's discovery insert does not have to. */
384
+ syncStatus?: MailboxItem["syncStatus"];
376
385
  };
377
386
 
378
- export type UpdateMailboxInput = Partial<Omit<CreateMailboxInput, "accountId">>;
387
+ /**
388
+ * Everything about a mailbox that is not its mutation state.
389
+ *
390
+ * `syncStatus` and `pendingPath` are omitted rather than merely discouraged
391
+ * (docs/architecture/folder-rename-and-delete.md D3): where they are gone from
392
+ * the only general-purpose writer, a folder state written without a predicate
393
+ * is a type error rather than a convention someone has to remember. The two
394
+ * doors are `transition` and `transitionSubtree`, and they are the whole set.
395
+ */
396
+ export type UpdateMailboxInput = Partial<
397
+ Omit<CreateMailboxInput, "accountId" | "syncStatus">
398
+ >;
399
+
400
+ /**
401
+ * The state a mailbox transition expects to find, as the six states this design
402
+ * defines rather than as the four enum values
403
+ * (docs/architecture/folder-rename-and-delete.md D3). `from` becomes an
404
+ * `IN (…)` term; `wherePendingPath` becomes an equality or an `IS NULL` term,
405
+ * and is omitted only by a caller that genuinely does not care which of the two
406
+ * states sharing a `syncStatus` it is looking at.
407
+ */
408
+ export type MailboxStatePredicate = {
409
+ from: readonly MailboxItem["syncStatus"][];
410
+ wherePendingPath?: string | null;
411
+ };
412
+
413
+ /**
414
+ * What a winning mailbox transition writes. `pendingPath` takes `null` to
415
+ * clear, and is only honoured at all when the state being written is one that
416
+ * may carry a rename target — a transition to `synced` or `deleting` drops it
417
+ * whatever the caller passes, which is what makes the invariant hold by
418
+ * construction rather than by convention.
419
+ */
420
+ export type MailboxTransitionWrite = {
421
+ fullPath?: string;
422
+ pendingPath?: string | null;
423
+ };
424
+
425
+ export type MailboxTransitionIntent = MailboxStatePredicate & {
426
+ to: MailboxItem["syncStatus"];
427
+ set?: MailboxTransitionWrite;
428
+ };
429
+
430
+ /**
431
+ * A subtree intent recorded all-or-nothing (D6). It carries no
432
+ * `wherePendingPath`: its accepted from-states are `synced` and the two failed
433
+ * variants, and both failed variants are legal starting points.
434
+ */
435
+ export type MailboxSubtreeTransitionIntent = {
436
+ from: readonly MailboxItem["syncStatus"][];
437
+ to: MailboxItem["syncStatus"];
438
+ rowSet: (row: MailboxItem) => MailboxTransitionWrite;
439
+ };
379
440
 
380
441
  export type CreateMessageInput = Omit<
381
442
  MessageItem,