@remit/data-ports 0.0.64 → 0.0.65
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/errors.ts +25 -4
- package/src/index.ts +7 -2
- package/src/interfaces/message.ts +20 -6
- package/src/message-placement-doors.test.ts +100 -0
- package/src/message-settlement.test.ts +105 -45
- package/src/message-settlement.ts +37 -34
- package/src/types.ts +41 -5
package/package.json
CHANGED
package/src/errors.ts
CHANGED
|
@@ -99,11 +99,14 @@ export class MailboxNotSettledError extends ConflictError {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* Why the row's folder and uid do not name the same message.
|
|
103
|
-
*
|
|
104
|
-
*
|
|
102
|
+
* Why the row's folder and uid do not name the same message. One value, because
|
|
103
|
+
* one state produces it: a mutation is still in flight and the pair clears on
|
|
104
|
+
* its own, so the client words a wait rather than a repair. A mutation that
|
|
105
|
+
* gave up does not reach here — it hands the row back to a placement the server
|
|
106
|
+
* confirmed first (docs/architecture/imap-mutations.md R3), which is a pair
|
|
107
|
+
* every dependent mutation can act on.
|
|
105
108
|
*/
|
|
106
|
-
export type MessagePlacementUnsettledReason = "in_flight"
|
|
109
|
+
export type MessagePlacementUnsettledReason = "in_flight";
|
|
107
110
|
|
|
108
111
|
export class MessagePlacementUnsettledError extends ConflictError {
|
|
109
112
|
name = "MessagePlacementUnsettledError";
|
|
@@ -185,3 +188,21 @@ export class InternalServerError extends UnhandledError {
|
|
|
185
188
|
name = "InternalServerError";
|
|
186
189
|
public statusCode = 500;
|
|
187
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A repository lookup or write for a row that no longer exists rejects with a
|
|
194
|
+
* `NotFoundError`, name-matched rather than by `instanceof`: the class crosses
|
|
195
|
+
* the adapter boundary and an adapter may throw its own.
|
|
196
|
+
*
|
|
197
|
+
* A worker event that references a deliberately-deleted mailbox is completed or
|
|
198
|
+
* moot work, never a transient fault: every redelivery re-throws the same
|
|
199
|
+
* `NotFoundError`, and because the sync queues carry `MessageGroupId=accountId`
|
|
200
|
+
* that permanently-failing head message stalls the whole account's per-group
|
|
201
|
+
* FIFO. Handlers use this predicate to resolve such an event terminally (ack
|
|
202
|
+
* with a WARN) instead of retrying forever (issues #287, #289, #290).
|
|
203
|
+
*
|
|
204
|
+
* The guard is narrow on purpose: only a genuine not-found terminates. Real
|
|
205
|
+
* IMAP/infra failures carry other errors and must still propagate to be retried.
|
|
206
|
+
*/
|
|
207
|
+
export const isNotFoundError = (error: unknown): boolean =>
|
|
208
|
+
(error as { name?: string })?.name === "NotFoundError";
|
package/src/index.ts
CHANGED
|
@@ -52,7 +52,11 @@ export type {
|
|
|
52
52
|
UnitOfWorkRepositories,
|
|
53
53
|
} from "./interfaces/unit-of-work.js";
|
|
54
54
|
export type { MessageSettlementFields } from "./message-settlement.js";
|
|
55
|
-
export {
|
|
55
|
+
export {
|
|
56
|
+
abandonedMutationOf,
|
|
57
|
+
hasAbandonedDelete,
|
|
58
|
+
hasAbandonedMove,
|
|
59
|
+
} from "./message-settlement.js";
|
|
56
60
|
export {
|
|
57
61
|
FILTER_NO_ACTION,
|
|
58
62
|
isSenderMuted,
|
|
@@ -126,6 +130,8 @@ export type {
|
|
|
126
130
|
OrganizeJobRequestItem,
|
|
127
131
|
OutboxAttachmentItem,
|
|
128
132
|
OutboxMessageItem,
|
|
133
|
+
PlacementPredicate,
|
|
134
|
+
PlacementTransitionInput,
|
|
129
135
|
PutCalendarFeedTokenInput,
|
|
130
136
|
PutCalendarObjectInput,
|
|
131
137
|
PutCalendarSuggestionInput,
|
|
@@ -151,7 +157,6 @@ export type {
|
|
|
151
157
|
UpdateLabelInput,
|
|
152
158
|
UpdateMailboxInput,
|
|
153
159
|
UpdateMessageInput,
|
|
154
|
-
UpdateMessageMoveInput,
|
|
155
160
|
UpdateOrganizeJobRequestInput,
|
|
156
161
|
UpdateOutboxMessageInput,
|
|
157
162
|
UpdateThreadMessageInput,
|
|
@@ -3,9 +3,10 @@ import type {
|
|
|
3
3
|
MessageDescription,
|
|
4
4
|
MessageIdSource,
|
|
5
5
|
MessageItem,
|
|
6
|
+
PlacementPredicate,
|
|
7
|
+
PlacementTransitionInput,
|
|
6
8
|
ResultList,
|
|
7
9
|
UpdateMessageInput,
|
|
8
|
-
UpdateMessageMoveInput,
|
|
9
10
|
} from "../types.js";
|
|
10
11
|
|
|
11
12
|
export interface IMessageRepository {
|
|
@@ -19,7 +20,6 @@ export interface IMessageRepository {
|
|
|
19
20
|
update(messageId: string, input: UpdateMessageInput): Promise<MessageItem>;
|
|
20
21
|
clearBodyStorageKey(messageId: string): Promise<MessageItem>;
|
|
21
22
|
clearSpamReport(messageId: string): Promise<MessageItem>;
|
|
22
|
-
clearOriginalMailboxId(messageId: string): Promise<MessageItem>;
|
|
23
23
|
delete(messageId: string): Promise<void>;
|
|
24
24
|
deleteMany(messageIds: string[]): Promise<void>;
|
|
25
25
|
listByMailbox(
|
|
@@ -28,15 +28,29 @@ export interface IMessageRepository {
|
|
|
28
28
|
): Promise<ResultList<MessageItem>>;
|
|
29
29
|
listAllByMailbox(mailboxId: string): Promise<MessageItem[]>;
|
|
30
30
|
describe(messageId: string): Promise<MessageDescription>;
|
|
31
|
-
updateForMove(
|
|
32
|
-
messageId: string,
|
|
33
|
-
input: UpdateMessageMoveInput,
|
|
34
|
-
): Promise<MessageItem>;
|
|
35
31
|
updateUid(
|
|
36
32
|
messageId: string,
|
|
37
33
|
newUid: number,
|
|
38
34
|
newMailboxId: string,
|
|
39
35
|
): Promise<MessageItem>;
|
|
36
|
+
/**
|
|
37
|
+
* Move the row's placement from the state the caller read to the state it
|
|
38
|
+
* decided on, as one conditional write (imap-mutations R3). The only writer
|
|
39
|
+
* of `mailboxId`, `status`, `syncStatus`, `abandonedMutation`,
|
|
40
|
+
* `originalMailboxId` and `originalUid`, apart from the settle in
|
|
41
|
+
* {@link updateUid} — so a placement written without a predicate is a type
|
|
42
|
+
* error rather than a convention.
|
|
43
|
+
*
|
|
44
|
+
* Resolves with the written row when the predicate matched, and with
|
|
45
|
+
* `undefined` when it did not — someone else won, or the row is gone. The
|
|
46
|
+
* loser re-reads and re-decides; it never blind-retries and never writes
|
|
47
|
+
* anyway.
|
|
48
|
+
*/
|
|
49
|
+
transitionPlacement(
|
|
50
|
+
messageId: string,
|
|
51
|
+
expected: PlacementPredicate,
|
|
52
|
+
next: PlacementTransitionInput,
|
|
53
|
+
): Promise<MessageItem | undefined>;
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
export type { MessageIdSource };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two doors, proved by the compiler.
|
|
3
|
+
*
|
|
4
|
+
* `docs/architecture/imap-mutations.md` R3 says a placement is written only as
|
|
5
|
+
* a conditional write. A rule like that is worth exactly as much as the thing
|
|
6
|
+
* enforcing it, and a convention enforces nothing: the first four writers on
|
|
7
|
+
* this branch each reached for the general-purpose `update()` and nobody
|
|
8
|
+
* noticed, because nothing stopped them.
|
|
9
|
+
*
|
|
10
|
+
* So the placement fields are gone from every input type but the two that are
|
|
11
|
+
* allowed to carry them — `transitionPlacement`, which takes a predicate, and
|
|
12
|
+
* the settle in `updateUid`, which takes no input object at all and writes what
|
|
13
|
+
* the mail server confirmed. Every `@ts-expect-error` below is a door that is
|
|
14
|
+
* shut: delete the omission from `UpdateMessageInput` and these stop erroring,
|
|
15
|
+
* which fails the build.
|
|
16
|
+
*
|
|
17
|
+
* `@ts-expect-error` is the assertion here, so this file is checked rather than
|
|
18
|
+
* run. The one runtime case exists to keep the suite honest about having loaded
|
|
19
|
+
* it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { test } from "node:test";
|
|
24
|
+
import type {
|
|
25
|
+
PlacementPredicate,
|
|
26
|
+
PlacementTransitionInput,
|
|
27
|
+
UpdateMessageInput,
|
|
28
|
+
} from "./types.js";
|
|
29
|
+
|
|
30
|
+
// --- The general-purpose writer refuses every placement field ---------------
|
|
31
|
+
|
|
32
|
+
// @ts-expect-error `status` is a placement field: write it through a transition
|
|
33
|
+
const status: UpdateMessageInput = { status: "moving" };
|
|
34
|
+
|
|
35
|
+
// @ts-expect-error `syncStatus` is a placement field
|
|
36
|
+
const syncStatus: UpdateMessageInput = { syncStatus: "failed" };
|
|
37
|
+
|
|
38
|
+
// @ts-expect-error `abandonedMutation` is a placement field
|
|
39
|
+
const abandonedMutation: UpdateMessageInput = { abandonedMutation: "delete" };
|
|
40
|
+
|
|
41
|
+
// @ts-expect-error `mailboxId` is a placement field
|
|
42
|
+
const mailboxId: UpdateMessageInput = { mailboxId: "mbx-1" };
|
|
43
|
+
|
|
44
|
+
// @ts-expect-error `uid` is a placement field
|
|
45
|
+
const uid: UpdateMessageInput = { uid: 42 };
|
|
46
|
+
|
|
47
|
+
// @ts-expect-error `originalMailboxId` is a placement field
|
|
48
|
+
const originalMailboxId: UpdateMessageInput = { originalMailboxId: "mbx-1" };
|
|
49
|
+
|
|
50
|
+
// @ts-expect-error `originalUid` is a placement field
|
|
51
|
+
const originalUid: UpdateMessageInput = { originalUid: 42 };
|
|
52
|
+
|
|
53
|
+
// Everything else about a message still goes through it, unchanged.
|
|
54
|
+
const ordinary: UpdateMessageInput = {
|
|
55
|
+
bodyStorageKey: "s3://body",
|
|
56
|
+
category: "personal",
|
|
57
|
+
spamReport: { reportedAt: 0 },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// --- The transition is the door, and it takes a predicate -------------------
|
|
61
|
+
|
|
62
|
+
const transition: {
|
|
63
|
+
expected: PlacementPredicate;
|
|
64
|
+
next: PlacementTransitionInput;
|
|
65
|
+
} = {
|
|
66
|
+
expected: { status: ["moving", "deleting"], mailboxId: "mbx-1", uid: 42 },
|
|
67
|
+
next: {
|
|
68
|
+
status: "active",
|
|
69
|
+
syncStatus: "abandoned",
|
|
70
|
+
abandonedMutation: "delete",
|
|
71
|
+
mailboxId: "mbx-1",
|
|
72
|
+
uid: 42,
|
|
73
|
+
originalMailboxId: null,
|
|
74
|
+
originalUid: null,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// The predicate names states, never a free-form column: a caller cannot smuggle
|
|
79
|
+
// an unrelated field into the WHERE clause and call it a lock.
|
|
80
|
+
// @ts-expect-error `category` is not part of a placement
|
|
81
|
+
const notAPlacement: PlacementPredicate = { category: "personal" };
|
|
82
|
+
|
|
83
|
+
test("the placement doors are the transition and the settle", () => {
|
|
84
|
+
assert.equal(transition.next.status, "active");
|
|
85
|
+
assert.equal(ordinary.category, "personal");
|
|
86
|
+
assert.deepEqual(
|
|
87
|
+
[
|
|
88
|
+
status,
|
|
89
|
+
syncStatus,
|
|
90
|
+
abandonedMutation,
|
|
91
|
+
mailboxId,
|
|
92
|
+
uid,
|
|
93
|
+
originalMailboxId,
|
|
94
|
+
originalUid,
|
|
95
|
+
notAPlacement,
|
|
96
|
+
].length,
|
|
97
|
+
8,
|
|
98
|
+
"each of the seven fields, and the predicate's own shape, is pinned above",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
@@ -1,72 +1,132 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
MessageMutation,
|
|
5
|
+
MessageStatus,
|
|
6
|
+
MessageSyncStatus,
|
|
7
|
+
} from "@remit/domain-enums";
|
|
8
|
+
import {
|
|
9
|
+
abandonedMutationOf,
|
|
10
|
+
hasAbandonedDelete,
|
|
11
|
+
hasAbandonedMove,
|
|
12
|
+
type MessageSettlementFields,
|
|
13
|
+
} from "./message-settlement.js";
|
|
5
14
|
|
|
6
|
-
|
|
7
|
-
|
|
15
|
+
const row = (over: Partial<MessageSettlementFields>): MessageSettlementFields =>
|
|
16
|
+
({
|
|
17
|
+
status: MessageStatus.active,
|
|
18
|
+
syncStatus: MessageSyncStatus.synced,
|
|
19
|
+
abandonedMutation: MessageMutation.none,
|
|
20
|
+
...over,
|
|
21
|
+
}) as MessageSettlementFields;
|
|
22
|
+
|
|
23
|
+
describe("abandonedMutationOf", () => {
|
|
24
|
+
test("names the delete `abandonDelete` gave up on", () => {
|
|
8
25
|
assert.equal(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
abandonedMutationOf(
|
|
27
|
+
row({
|
|
28
|
+
syncStatus: MessageSyncStatus.abandoned,
|
|
29
|
+
abandonedMutation: MessageMutation.delete,
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
MessageMutation.delete,
|
|
14
33
|
);
|
|
15
34
|
});
|
|
16
35
|
|
|
17
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Issue #1229: the two used to be one state on the wire, so a move that
|
|
38
|
+
* handed back rendered the delete copy under a button that deleted the
|
|
39
|
+
* message. The row has to say which, because the hand-back is what erased
|
|
40
|
+
* the `status` that said it.
|
|
41
|
+
*/
|
|
42
|
+
test("names the move a hand-back gave up on, distinctly", () => {
|
|
18
43
|
assert.equal(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
44
|
+
abandonedMutationOf(
|
|
45
|
+
row({
|
|
46
|
+
syncStatus: MessageSyncStatus.abandoned,
|
|
47
|
+
abandonedMutation: MessageMutation.move,
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
MessageMutation.move,
|
|
24
51
|
);
|
|
25
52
|
});
|
|
26
53
|
|
|
27
|
-
test("
|
|
54
|
+
test("`syncStatus` is the gate: a settled row's stale marker says nothing", () => {
|
|
28
55
|
assert.equal(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
56
|
+
abandonedMutationOf(
|
|
57
|
+
row({
|
|
58
|
+
syncStatus: MessageSyncStatus.synced,
|
|
59
|
+
abandonedMutation: MessageMutation.delete,
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
MessageMutation.none,
|
|
34
63
|
);
|
|
35
64
|
});
|
|
36
65
|
|
|
37
|
-
|
|
66
|
+
/**
|
|
67
|
+
* `failed` is the transient attempt marker — every handler writes it and
|
|
68
|
+
* re-throws for redelivery (imap-mutations R3) — so it can never mean a
|
|
69
|
+
* give-up, whatever `status` sits beside it. This is issue #1153: before the
|
|
70
|
+
* give-up had a value of its own, these three were the same row.
|
|
71
|
+
*/
|
|
72
|
+
for (const status of [
|
|
73
|
+
MessageStatus.active,
|
|
74
|
+
MessageStatus.moving,
|
|
75
|
+
MessageStatus.deleting,
|
|
76
|
+
]) {
|
|
77
|
+
test(`a ${status} row mid-retry is not a give-up`, () => {
|
|
78
|
+
assert.equal(
|
|
79
|
+
abandonedMutationOf(
|
|
80
|
+
row({ status, syncStatus: MessageSyncStatus.failed }),
|
|
81
|
+
),
|
|
82
|
+
MessageMutation.none,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test("an ordinary inbound row, `pending` forever, says nothing", () => {
|
|
38
88
|
assert.equal(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
syncStatus: MessageSyncStatus.failed,
|
|
42
|
-
}),
|
|
43
|
-
false,
|
|
89
|
+
abandonedMutationOf(row({ syncStatus: MessageSyncStatus.pending })),
|
|
90
|
+
MessageMutation.none,
|
|
44
91
|
);
|
|
45
92
|
});
|
|
46
93
|
|
|
47
94
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
95
|
+
* A move or delete that exhausted its retries is repaired against IMAP and
|
|
96
|
+
* now carries the give-up (#1098, #1005). What still lands on `synced` is a
|
|
97
|
+
* hand-back that refused nothing — the server never heard the command — and
|
|
98
|
+
* that is settled, not abandoned.
|
|
52
99
|
*/
|
|
53
|
-
test("`
|
|
100
|
+
test("`synced` is never a give-up, however it was reached", () => {
|
|
54
101
|
assert.equal(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
syncStatus: MessageSyncStatus.synced,
|
|
58
|
-
}),
|
|
59
|
-
false,
|
|
102
|
+
abandonedMutationOf(row({ syncStatus: MessageSyncStatus.synced })),
|
|
103
|
+
MessageMutation.none,
|
|
60
104
|
);
|
|
61
105
|
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("the per-operation predicates", () => {
|
|
109
|
+
const abandoned = (
|
|
110
|
+
mutation: MessageSettlementFields["abandonedMutation"],
|
|
111
|
+
): MessageSettlementFields =>
|
|
112
|
+
row({
|
|
113
|
+
syncStatus: MessageSyncStatus.abandoned,
|
|
114
|
+
abandonedMutation: mutation,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a delete that gave up is a delete and not a move", () => {
|
|
118
|
+
assert.equal(hasAbandonedDelete(abandoned(MessageMutation.delete)), true);
|
|
119
|
+
assert.equal(hasAbandonedMove(abandoned(MessageMutation.delete)), false);
|
|
120
|
+
});
|
|
62
121
|
|
|
63
|
-
test("
|
|
64
|
-
assert.equal(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
122
|
+
test("a move that gave up is a move and not a delete", () => {
|
|
123
|
+
assert.equal(hasAbandonedMove(abandoned(MessageMutation.move)), true);
|
|
124
|
+
assert.equal(hasAbandonedDelete(abandoned(MessageMutation.move)), false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a copy that gave up claims neither", () => {
|
|
128
|
+
const copy = abandoned(MessageMutation.copy);
|
|
129
|
+
assert.equal(hasAbandonedDelete(copy), false);
|
|
130
|
+
assert.equal(hasAbandonedMove(copy), false);
|
|
71
131
|
});
|
|
72
132
|
});
|
|
@@ -1,47 +1,50 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
|
|
2
2
|
import type { MessageItem } from "./types.js";
|
|
3
3
|
|
|
4
4
|
export type MessageSettlementFields = Pick<
|
|
5
5
|
MessageItem,
|
|
6
|
-
"status" | "syncStatus"
|
|
6
|
+
"status" | "syncStatus" | "abandonedMutation"
|
|
7
7
|
>;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* `abandonDelete` reaches this from four checks, all of them made before any
|
|
12
|
-
* expunge: the Trash folder the event names is not on the server (TRYCREATE),
|
|
13
|
-
* the event carries no destination, it names an operation this build does not
|
|
14
|
-
* recognise, or it was minted under an unknown contract. The row is handed back
|
|
15
|
-
* to the folder the server still holds the message in.
|
|
10
|
+
* Which mutation gave up on this row, and `none` where none did.
|
|
16
11
|
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* `syncStatus: abandoned` is the gate and the only gate: it is the give-up
|
|
13
|
+
* value of the placement state model (docs/architecture/imap-mutations.md R3),
|
|
14
|
+
* written only after the row has been put back on a placement the mail server
|
|
15
|
+
* confirmed. Every settle overwrites it, and the settle in `updateUid` clears
|
|
16
|
+
* `abandonedMutation` alongside it. A transient attempt writes
|
|
17
|
+
* `failed` and re-throws for redelivery, so `failed` never reaches here.
|
|
20
18
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* - `upsertWithStatus` leaves an existing row alone, so no inbound sync writes
|
|
27
|
-
* either value onto a row that already has them.
|
|
19
|
+
* `abandonedMutation` behind that gate says WHICH mutation, which the row
|
|
20
|
+
* otherwise cannot say: the hand-back sets `status` back to `active`, and
|
|
21
|
+
* `status` was the field naming the mutation that was outstanding. Reading the
|
|
22
|
+
* pair without it is how a move that handed back came to be reported to the
|
|
23
|
+
* user as a failed delete, under a "Delete again" button (issue #1229).
|
|
28
24
|
*
|
|
29
|
-
*
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
32
|
-
* re-throw for queue redelivery — the row is mid-retry and about to succeed. In
|
|
33
|
-
* those handlers `status` stays at its in-flight value (`moving`, `deleting`),
|
|
34
|
-
* which is what separates them from the pair above.
|
|
25
|
+
* Outside the gate the field is `none` and says nothing, exactly as
|
|
26
|
+
* `originalUid` says nothing once a placement has settled. Reading it
|
|
27
|
+
* unconditionally would resurrect a give-up a later mutation has settled.
|
|
35
28
|
*
|
|
36
|
-
* Two give-ups this
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* Their give-up state lives on their own marker rows.
|
|
29
|
+
* Two give-ups this deliberately does not name. `flag-push` never writes a
|
|
30
|
+
* placement at all — its give-up lives on its own marker row and in an
|
|
31
|
+
* operator alert. `placement-move-push` does restore the row when its push is
|
|
32
|
+
* exhausted, and drops its marker with it, but leaves this field at `none`:
|
|
33
|
+
* Remit's own classification move is not a mutation the user asked for, so a
|
|
34
|
+
* per-message treatment for it would report a failure against an intent nobody
|
|
35
|
+
* formed.
|
|
44
36
|
*/
|
|
37
|
+
export const abandonedMutationOf = (
|
|
38
|
+
message: MessageSettlementFields,
|
|
39
|
+
): MessageItem["abandonedMutation"] =>
|
|
40
|
+
message.syncStatus === MessageSyncStatus.abandoned
|
|
41
|
+
? message.abandonedMutation
|
|
42
|
+
: MessageMutation.none;
|
|
43
|
+
|
|
44
|
+
/** A delete Remit refused to run or gave up on, having removed it locally first. */
|
|
45
45
|
export const hasAbandonedDelete = (message: MessageSettlementFields): boolean =>
|
|
46
|
-
message
|
|
47
|
-
|
|
46
|
+
abandonedMutationOf(message) === MessageMutation.delete;
|
|
47
|
+
|
|
48
|
+
/** A move Remit gave up on, having already pointed the row at the destination. */
|
|
49
|
+
export const hasAbandonedMove = (message: MessageSettlementFields): boolean =>
|
|
50
|
+
abandonedMutationOf(message) === MessageMutation.move;
|
package/src/types.ts
CHANGED
|
@@ -384,6 +384,7 @@ export type CreateMessageInput = Omit<
|
|
|
384
384
|
| "updatedAt"
|
|
385
385
|
| "status"
|
|
386
386
|
| "syncStatus"
|
|
387
|
+
| "abandonedMutation"
|
|
387
388
|
| "category"
|
|
388
389
|
| "classificationState"
|
|
389
390
|
| "authenticityVerdict"
|
|
@@ -393,6 +394,7 @@ export type CreateMessageInput = Omit<
|
|
|
393
394
|
messageId: string;
|
|
394
395
|
status?: MessageItem["status"];
|
|
395
396
|
syncStatus?: MessageItem["syncStatus"];
|
|
397
|
+
abandonedMutation?: MessageItem["abandonedMutation"];
|
|
396
398
|
category?: MessageItem["category"];
|
|
397
399
|
classificationState?: MessageItem["classificationState"];
|
|
398
400
|
authenticityVerdict?: MessageItem["authenticityVerdict"];
|
|
@@ -400,22 +402,56 @@ export type CreateMessageInput = Omit<
|
|
|
400
402
|
movedByRemit?: MessageItem["movedByRemit"];
|
|
401
403
|
};
|
|
402
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Everything about a message that is not its placement.
|
|
407
|
+
*
|
|
408
|
+
* The six placement fields are omitted rather than merely discouraged
|
|
409
|
+
* (docs/architecture/imap-mutations.md R3): where they are gone from the only
|
|
410
|
+
* general-purpose writer, a placement written without a predicate is a type
|
|
411
|
+
* error rather than a convention someone has to remember. `transitionPlacement`
|
|
412
|
+
* and the settle in `updateUid` are the two doors, and they are the whole set.
|
|
413
|
+
*/
|
|
403
414
|
export type UpdateMessageInput = Partial<
|
|
404
|
-
Omit<
|
|
405
|
-
>;
|
|
406
|
-
|
|
407
|
-
export type UpdateMessageMoveInput = Partial<
|
|
408
|
-
Pick<
|
|
415
|
+
Omit<
|
|
409
416
|
CreateMessageInput,
|
|
410
417
|
| "mailboxId"
|
|
411
418
|
| "uid"
|
|
412
419
|
| "status"
|
|
413
420
|
| "syncStatus"
|
|
421
|
+
| "abandonedMutation"
|
|
414
422
|
| "originalMailboxId"
|
|
415
423
|
| "originalUid"
|
|
416
424
|
>
|
|
417
425
|
>;
|
|
418
426
|
|
|
427
|
+
/**
|
|
428
|
+
* The placement a transition expects to find, as the caller read it
|
|
429
|
+
* (docs/architecture/imap-mutations.md R3). Every field named here becomes a
|
|
430
|
+
* term of the UPDATE's WHERE clause; what the caller did not read, it does not
|
|
431
|
+
* supply. `syncStatus` takes a set because several of the six placement states
|
|
432
|
+
* share one `status`.
|
|
433
|
+
*/
|
|
434
|
+
export type PlacementPredicate = {
|
|
435
|
+
status?: MessageItem["status"] | readonly MessageItem["status"][];
|
|
436
|
+
syncStatus?: MessageItem["syncStatus"] | readonly MessageItem["syncStatus"][];
|
|
437
|
+
mailboxId?: string;
|
|
438
|
+
uid?: number;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* What a winning transition writes. `originalMailboxId` and `originalUid` take
|
|
443
|
+
* `null` to clear, which is how a settle spells a dropped pre-move pair.
|
|
444
|
+
*/
|
|
445
|
+
export type PlacementTransitionInput = {
|
|
446
|
+
status?: MessageItem["status"];
|
|
447
|
+
syncStatus?: MessageItem["syncStatus"];
|
|
448
|
+
abandonedMutation?: MessageItem["abandonedMutation"];
|
|
449
|
+
mailboxId?: string;
|
|
450
|
+
uid?: number;
|
|
451
|
+
originalMailboxId?: string | null;
|
|
452
|
+
originalUid?: number | null;
|
|
453
|
+
};
|
|
454
|
+
|
|
419
455
|
export type MessageIdSource = {
|
|
420
456
|
messageId?: string;
|
|
421
457
|
uid: number;
|