@remit/data-ports 0.0.65 → 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 +1 -1
- package/src/folder-role.test.ts +3 -3
- package/src/index.ts +4 -0
- package/src/interfaces/mailbox.ts +62 -7
- package/src/mailbox-name.ts +25 -0
- package/src/mailbox-state-doors.test.ts +104 -0
- package/src/types.ts +73 -1
package/package.json
CHANGED
package/src/folder-role.test.ts
CHANGED
|
@@ -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`
|
|
184
|
-
//
|
|
185
|
-
//
|
|
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:
|
|
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
|
}
|
package/src/mailbox-name.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
@@ -547,6 +608,17 @@ export type SearchOptions = {
|
|
|
547
608
|
unread?: boolean;
|
|
548
609
|
starred?: boolean;
|
|
549
610
|
attachments?: boolean;
|
|
611
|
+
/**
|
|
612
|
+
* Whether the From address is muted, as a three-state filter: `false`
|
|
613
|
+
* excludes muted senders, `true` keeps only them, absent filters nothing.
|
|
614
|
+
*
|
|
615
|
+
* The one criterion here that is not a column on the ThreadMessage row —
|
|
616
|
+
* muting is a flag on the Address, reached through the row's `fromEmail` —
|
|
617
|
+
* and it is a predicate rather than a pass over a page for the same reason
|
|
618
|
+
* every other criterion is: a caller that hides muted mail while counting
|
|
619
|
+
* with it in states a number larger than the list it renders (#1137).
|
|
620
|
+
*/
|
|
621
|
+
muted?: boolean;
|
|
550
622
|
/**
|
|
551
623
|
* Any-of set over the denormalized `category` column on the ThreadMessage
|
|
552
624
|
* row. An empty or absent set means no category filter. `uncategorized` is
|