@remit/data-ports 0.0.66 → 0.0.68
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 +41 -3
- package/src/folder-role.ts +35 -0
- 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 +62 -1
package/package.json
CHANGED
package/src/folder-role.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
composeFolderRoleAppointmentLabelName,
|
|
11
11
|
composeFolderRoleAppointmentName,
|
|
12
|
+
isReservedFolderName,
|
|
12
13
|
meetsTrashAssurance,
|
|
13
14
|
parseFolderRoleAppointmentLabelName,
|
|
14
15
|
parseFolderRoleAppointmentName,
|
|
@@ -180,9 +181,9 @@ describe("resolveConfirmedMailboxForRole", () => {
|
|
|
180
181
|
|
|
181
182
|
describe("resolveRoleForAccount", () => {
|
|
182
183
|
it("resolves an appointment naming a mailbox whose last sync failed", () => {
|
|
183
|
-
// `failed`
|
|
184
|
-
//
|
|
185
|
-
//
|
|
184
|
+
// `failed` means the folder exists at `fullPath` and the last rename or
|
|
185
|
+
// delete intent did not land (folder-rename-and-delete.md D7), so the row
|
|
186
|
+
// names a path the server really holds.
|
|
186
187
|
const failed: RoleMailboxCandidate & { syncStatus: string } = {
|
|
187
188
|
...mailbox("mb-trash", "INBOX/Prullenbak"),
|
|
188
189
|
syncStatus: MailboxSyncStatus.failed,
|
|
@@ -382,3 +383,40 @@ describe("the appointment name and its label sibling", () => {
|
|
|
382
383
|
});
|
|
383
384
|
});
|
|
384
385
|
});
|
|
386
|
+
|
|
387
|
+
describe("isReservedFolderName", () => {
|
|
388
|
+
/**
|
|
389
|
+
* The sweep's own predicate. A folder whose leaf name is a role's
|
|
390
|
+
* conventional name but which lacks the server's flag is read as a duplicate
|
|
391
|
+
* and its row is deleted — under D8, with the folder's mail — as soon as
|
|
392
|
+
* another folder holds the flag. A rename to one of these settles `synced`
|
|
393
|
+
* and is then reaped, which is why the API refuses it (D5).
|
|
394
|
+
*/
|
|
395
|
+
for (const name of [
|
|
396
|
+
"archive",
|
|
397
|
+
"Archive",
|
|
398
|
+
"ARCHIVES",
|
|
399
|
+
"trash",
|
|
400
|
+
"Deleted Items",
|
|
401
|
+
"drafts",
|
|
402
|
+
"Sent Mail",
|
|
403
|
+
"junk",
|
|
404
|
+
"Spam",
|
|
405
|
+
"All Mail",
|
|
406
|
+
]) {
|
|
407
|
+
it(`reserves "${name}"`, () => {
|
|
408
|
+
assert.equal(isReservedFolderName(name), true);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* #843 dropped a bare "Deleted" and "Bin" from the Trash hints precisely
|
|
414
|
+
* because they are ordinary folder names a user keeps mail in. Reserving
|
|
415
|
+
* them here would refuse a rename nothing would ever have reaped.
|
|
416
|
+
*/
|
|
417
|
+
for (const name of ["Deleted", "Bin", "Receipts", "Work", "Inbox 2024"]) {
|
|
418
|
+
it(`leaves "${name}" alone`, () => {
|
|
419
|
+
assert.equal(isReservedFolderName(name), false);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
});
|
package/src/folder-role.ts
CHANGED
|
@@ -134,6 +134,41 @@ export const ROLE_NAME_HINTS: Partial<
|
|
|
134
134
|
[CanonicalMailboxRole.All]: ["all mail", "all"],
|
|
135
135
|
};
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* The role a folder's own leaf name is most conventionally for. A name that
|
|
139
|
+
* several roles list goes to the role that ranks it highest, so `All Mail` is
|
|
140
|
+
* an All folder rather than a lookalike of Archive.
|
|
141
|
+
*
|
|
142
|
+
* `name` is the leaf segment, lower case.
|
|
143
|
+
*/
|
|
144
|
+
export const roleForFolderName = (
|
|
145
|
+
name: string,
|
|
146
|
+
): CanonicalMailboxRoleValue | undefined => {
|
|
147
|
+
let best: { role: CanonicalMailboxRoleValue; rank: number } | undefined;
|
|
148
|
+
for (const role of CANONICAL_ROLES) {
|
|
149
|
+
const rank = ROLE_NAME_HINTS[role]?.indexOf(name) ?? -1;
|
|
150
|
+
if (rank < 0) continue;
|
|
151
|
+
if (!best || rank < best.rank) best = { role, rank };
|
|
152
|
+
}
|
|
153
|
+
return best?.role;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether the mailbox sweep would read a folder with this leaf name as a
|
|
158
|
+
* duplicate of a role's canonical folder — and so delete its row, and under D8
|
|
159
|
+
* the mail in it, as soon as another folder carries the role's SPECIAL-USE
|
|
160
|
+
* flag. It is the sweep's own predicate, so a rename refused on it and a folder
|
|
161
|
+
* reaped by it can never disagree (docs/architecture/folder-rename-and-delete.md
|
|
162
|
+
* D5).
|
|
163
|
+
*
|
|
164
|
+
* A role with no flag to be a duplicate of is not reserved: nothing reaps a
|
|
165
|
+
* folder for it.
|
|
166
|
+
*/
|
|
167
|
+
export const isReservedFolderName = (name: string): boolean => {
|
|
168
|
+
const role = roleForFolderName(name.toLowerCase());
|
|
169
|
+
return role !== undefined && ROLE_SPECIAL_USE[role] !== undefined;
|
|
170
|
+
};
|
|
171
|
+
|
|
137
172
|
/**
|
|
138
173
|
* The pane where a user appoints a folder to a role, named as it is labelled.
|
|
139
174
|
* Every refusal that asks for an appointment points here, in one wording — the
|
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,
|