@remit/data-ports 0.0.63 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/data-ports",
3
- "version": "0.0.63",
3
+ "version": "0.0.65",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
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. `in_flight` clears
103
- * on its own, so the client words a wait; `unverified` does not, so it words a
104
- * resync instead.
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" | "unverified";
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 { hasAbandonedDelete } from "./message-settlement.js";
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,
@@ -138,6 +144,7 @@ export type {
138
144
  ResultList,
139
145
  SearchOptions,
140
146
  SenderSignerStandingItem,
147
+ ThreadMessageFieldTerm,
141
148
  ThreadMessageItem,
142
149
  UpdateAccountConfigInput,
143
150
  UpdateAccountExportRequestInput,
@@ -150,7 +157,6 @@ export type {
150
157
  UpdateLabelInput,
151
158
  UpdateMailboxInput,
152
159
  UpdateMessageInput,
153
- UpdateMessageMoveInput,
154
160
  UpdateOrganizeJobRequestInput,
155
161
  UpdateOutboxMessageInput,
156
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 };
@@ -2,6 +2,7 @@ import type {
2
2
  CreateThreadMessageInput,
3
3
  ResultList,
4
4
  SearchOptions,
5
+ ThreadMessageFieldTerm,
5
6
  ThreadMessageItem,
6
7
  UpdateThreadMessageInput,
7
8
  } from "../types.js";
@@ -85,6 +86,42 @@ export interface IThreadMessageRepository {
85
86
  excludeDeleted?: boolean;
86
87
  },
87
88
  ): Promise<ResultList<ThreadMessageItem>>;
89
+ /**
90
+ * Rows of a config whose sender, subject or `List-Id` column matches a set
91
+ * of substring terms, newest first, across every mailbox — the store-side
92
+ * narrowing a rule back-apply reads with.
93
+ *
94
+ * The terms are evaluated in the query, so `limit` is a page size over
95
+ * MATCHES and the scan reaches the whole config however old the matching
96
+ * mail is. Paging a date-ordered listing and filtering the rows it returned
97
+ * is what makes a rule for a quiet sender report a handful of matches on a
98
+ * mailbox holding thousands (#459).
99
+ *
100
+ * `operator` combines the terms — `"and"` requires every one, `"or"` any.
101
+ * An empty `terms` narrows nothing under either operator: the whole config,
102
+ * newest first.
103
+ *
104
+ * A term an implementation cannot evaluate faithfully is IGNORED, never
105
+ * approximated: the result may be wider than the terms asked for and never
106
+ * narrower, so the caller's own matcher stays the judge.
107
+ *
108
+ * Matching follows `searchByMailbox`: accent- and case-insensitive
109
+ * substring over the same folded columns.
110
+ *
111
+ * Rows are per mailbox, not per conversation: the same mail filed in two
112
+ * folders is two rows sharing a `threadId`.
113
+ */
114
+ listByFieldTerms(
115
+ accountConfigId: string,
116
+ terms: readonly ThreadMessageFieldTerm[],
117
+ options?: {
118
+ operator?: "and" | "or";
119
+ order?: "asc" | "desc";
120
+ limit?: number;
121
+ continuationToken?: string;
122
+ excludeDeleted?: boolean;
123
+ },
124
+ ): Promise<ResultList<ThreadMessageItem>>;
88
125
  /**
89
126
  * List starred rows for a config, newest first, across every mailbox.
90
127
  *
@@ -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 { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
4
- import { hasAbandonedDelete } from "./message-settlement.js";
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
- describe("hasAbandonedDelete", () => {
7
- test("the pair abandonDelete writes, its only writer", () => {
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
- hasAbandonedDelete({
10
- status: MessageStatus.active,
11
- syncStatus: MessageSyncStatus.failed,
12
- }),
13
- true,
26
+ abandonedMutationOf(
27
+ row({
28
+ syncStatus: MessageSyncStatus.abandoned,
29
+ abandonedMutation: MessageMutation.delete,
30
+ }),
31
+ ),
32
+ MessageMutation.delete,
14
33
  );
15
34
  });
16
35
 
17
- test("a move mid-retry is not a give-up, whatever `failed` suggests", () => {
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
- hasAbandonedDelete({
20
- status: MessageStatus.moving,
21
- syncStatus: MessageSyncStatus.failed,
22
- }),
23
- false,
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("a delete mid-retry keeps `deleting`, so it is not a give-up", () => {
54
+ test("`syncStatus` is the gate: a settled row's stale marker says nothing", () => {
28
55
  assert.equal(
29
- hasAbandonedDelete({
30
- status: MessageStatus.deleting,
31
- syncStatus: MessageSyncStatus.failed,
32
- }),
33
- false,
56
+ abandonedMutationOf(
57
+ row({
58
+ syncStatus: MessageSyncStatus.synced,
59
+ abandonedMutation: MessageMutation.delete,
60
+ }),
61
+ ),
62
+ MessageMutation.none,
34
63
  );
35
64
  });
36
65
 
37
- test("a copy that gave up hides its row as `deleted` rather than claiming this", () => {
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
- hasAbandonedDelete({
40
- status: MessageStatus.deleted,
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
- * Both a settled move (`updateUid`) and a delete that exhausted its retries
49
- * (#1143 repairs the row to where the message actually is) land on this pair.
50
- * The second is a give-up this predicate deliberately cannot see — pinned so
51
- * the gap is a decision on record, not an oversight.
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("`active` + `synced` is never a give-up, however it was reached", () => {
100
+ test("`synced` is never a give-up, however it was reached", () => {
54
101
  assert.equal(
55
- hasAbandonedDelete({
56
- status: MessageStatus.active,
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("an ordinary inbound row, `pending` forever, says nothing", () => {
64
- assert.equal(
65
- hasAbandonedDelete({
66
- status: MessageStatus.active,
67
- syncStatus: MessageSyncStatus.pending,
68
- }),
69
- false,
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 { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
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
- * A delete Remit refused to run, having already removed the message locally.
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
- * `status: active` alongside `syncStatus: failed` is the whole signal, and
18
- * `abandonDelete` (`imap-worker/src/handlers/message-delete.ts`) is its ONLY
19
- * writer. Every other writer of either value writes the other along with it:
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
- * - `updateUid` (`drizzle-service/src/repos/message.ts`) settles a confirmed
22
- * move by writing `active` and `synced` in the same statement, so a move that
23
- * worked after a failed attempt cannot leave `failed` behind.
24
- * - `empty-trash` and `message-copy`'s `settleCopied` both hand a row back as
25
- * `active` + `synced`.
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
- * `syncStatus: failed` ON ITS OWN is NOT a give-up marker, whatever
30
- * `placement-settled.ts`'s docstring says. `message-move.ts`, `message-delete.ts`
31
- * and `message-copy.ts` each write it on an ORDINARY TRANSIENT attempt and then
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 cannot see, and must not pretend to:
37
- *
38
- * - A MOVE or a DELETE that exhausted its retries settles `active` + `synced`
39
- * (`resolveExhaustedMessageMoveFailure` and
40
- * `resolveExhaustedMessageDeleteFailure` repair the row to where the message
41
- * actually is — #1098, #1005), so both read as fully settled here.
42
- * - `flag-push` and `placement-move-push` never write either field at all.
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.status === MessageStatus.active &&
47
- message.syncStatus === MessageSyncStatus.failed;
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<CreateMessageInput, "mailboxId" | "uid">
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;
@@ -488,6 +524,22 @@ export type UpdateThreadMessageInput = Partial<
488
524
  Omit<CreateThreadMessageInput, "accountConfigId" | "threadId" | "messageId">
489
525
  >;
490
526
 
527
+ /**
528
+ * One substring criterion over a text column of a ThreadMessage row, named by
529
+ * the column it selects on rather than by whatever rule derived it. `sender`
530
+ * covers the From address and display name together, the same pair
531
+ * `SearchOptions.from` reads.
532
+ *
533
+ * Matching is accent- and case-insensitive substring, identical to the search
534
+ * predicates, so a term is a NARROWING a caller refines further in memory. It
535
+ * may return more than the criterion it stands for and never less — a term an
536
+ * implementation cannot evaluate faithfully is dropped, not approximated.
537
+ */
538
+ export type ThreadMessageFieldTerm = {
539
+ field: "sender" | "subject" | "listId";
540
+ contains: string;
541
+ };
542
+
491
543
  export type SearchOptions = {
492
544
  query?: string;
493
545
  subject?: string;