@remit/data-ports 0.0.82 → 0.0.84

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.
Files changed (174) hide show
  1. package/dist/account-settings.d.ts +371 -0
  2. package/dist/account-settings.js +114 -0
  3. package/dist/conformance/account.d.ts +3 -0
  4. package/dist/conformance/account.js +118 -0
  5. package/dist/conformance/calendar-collection.d.ts +3 -0
  6. package/dist/conformance/calendar-collection.js +251 -0
  7. package/dist/conformance/calendar-event-index.d.ts +3 -0
  8. package/dist/conformance/calendar-event-index.js +114 -0
  9. package/dist/conformance/calendar-feed-token.d.ts +3 -0
  10. package/dist/conformance/calendar-feed-token.js +97 -0
  11. package/dist/conformance/calendar-object.d.ts +3 -0
  12. package/dist/conformance/calendar-object.js +156 -0
  13. package/dist/conformance/calendar-suggestion.d.ts +3 -0
  14. package/dist/conformance/calendar-suggestion.js +255 -0
  15. package/{src/conformance/harness.ts → dist/conformance/harness.d.ts} +4 -4
  16. package/dist/conformance/harness.js +1 -0
  17. package/dist/conformance/index.js +8 -0
  18. package/dist/conformance/label.d.ts +3 -0
  19. package/dist/conformance/label.js +92 -0
  20. package/dist/conformance/sender-signer-standing.d.ts +3 -0
  21. package/dist/conformance/sender-signer-standing.js +81 -0
  22. package/dist/display-name.d.ts +1 -0
  23. package/dist/display-name.js +26 -0
  24. package/dist/errors.d.ts +150 -0
  25. package/dist/errors.js +181 -0
  26. package/dist/filter-ttl.d.ts +7 -0
  27. package/{src/filter-ttl.ts → dist/filter-ttl.js} +8 -11
  28. package/dist/folder-role.d.ts +239 -0
  29. package/dist/folder-role.js +276 -0
  30. package/dist/id.d.ts +124 -0
  31. package/{src/id.ts → dist/id.js} +41 -127
  32. package/dist/index.d.ts +37 -0
  33. package/dist/index.js +5 -0
  34. package/dist/interfaces/account-config.d.ts +15 -0
  35. package/dist/interfaces/account-config.js +1 -0
  36. package/dist/interfaces/account-export-request.d.ts +10 -0
  37. package/dist/interfaces/account-export-request.js +1 -0
  38. package/dist/interfaces/account-setting.d.ts +7 -0
  39. package/dist/interfaces/account-setting.js +1 -0
  40. package/dist/interfaces/account.d.ts +29 -0
  41. package/dist/interfaces/account.js +1 -0
  42. package/dist/interfaces/address.d.ts +69 -0
  43. package/dist/interfaces/address.js +1 -0
  44. package/dist/interfaces/calendar-collection.d.ts +38 -0
  45. package/dist/interfaces/calendar-collection.js +1 -0
  46. package/dist/interfaces/calendar-event-index.d.ts +19 -0
  47. package/dist/interfaces/calendar-event-index.js +1 -0
  48. package/dist/interfaces/calendar-feed-token.d.ts +31 -0
  49. package/dist/interfaces/calendar-feed-token.js +1 -0
  50. package/dist/interfaces/calendar-object.d.ts +36 -0
  51. package/dist/interfaces/calendar-object.js +1 -0
  52. package/dist/interfaces/calendar-store.d.ts +93 -0
  53. package/dist/interfaces/calendar-store.js +1 -0
  54. package/dist/interfaces/calendar-suggestion.d.ts +62 -0
  55. package/dist/interfaces/calendar-suggestion.js +1 -0
  56. package/dist/interfaces/config-import.d.ts +8 -0
  57. package/dist/interfaces/config-import.js +1 -0
  58. package/dist/interfaces/envelope.d.ts +24 -0
  59. package/dist/interfaces/envelope.js +1 -0
  60. package/dist/interfaces/filter.d.ts +28 -0
  61. package/dist/interfaces/filter.js +1 -0
  62. package/dist/interfaces/label.d.ts +17 -0
  63. package/dist/interfaces/label.js +1 -0
  64. package/dist/interfaces/mailbox-lock.d.ts +12 -0
  65. package/dist/interfaces/mailbox-lock.js +1 -0
  66. package/dist/interfaces/mailbox-special-use.d.ts +54 -0
  67. package/dist/interfaces/mailbox-special-use.js +1 -0
  68. package/dist/interfaces/mailbox.d.ts +79 -0
  69. package/dist/interfaces/mailbox.js +1 -0
  70. package/dist/interfaces/message-flag-push.d.ts +9 -0
  71. package/dist/interfaces/message-flag-push.js +1 -0
  72. package/dist/interfaces/message-flag.d.ts +14 -0
  73. package/dist/interfaces/message-flag.js +1 -0
  74. package/dist/interfaces/message-label.d.ts +21 -0
  75. package/dist/interfaces/message-label.js +1 -0
  76. package/dist/interfaces/message-placement-move.d.ts +8 -0
  77. package/dist/interfaces/message-placement-move.js +1 -0
  78. package/dist/interfaces/message.d.ts +60 -0
  79. package/dist/interfaces/message.js +1 -0
  80. package/dist/interfaces/organize-job-request.d.ts +10 -0
  81. package/dist/interfaces/organize-job-request.js +1 -0
  82. package/dist/interfaces/outbox-attachment.d.ts +101 -0
  83. package/dist/interfaces/outbox-attachment.js +7 -0
  84. package/dist/interfaces/outbox-message.d.ts +70 -0
  85. package/dist/interfaces/outbox-message.js +20 -0
  86. package/dist/interfaces/quarantine.d.ts +20 -0
  87. package/dist/interfaces/quarantine.js +1 -0
  88. package/dist/interfaces/sender-signer-standing.d.ts +26 -0
  89. package/dist/interfaces/sender-signer-standing.js +1 -0
  90. package/dist/interfaces/sync-warning.d.ts +11 -0
  91. package/dist/interfaces/sync-warning.js +1 -0
  92. package/dist/interfaces/thread-message.d.ts +202 -0
  93. package/dist/interfaces/thread-message.js +1 -0
  94. package/dist/mailbox-name.d.ts +35 -0
  95. package/dist/mailbox-name.js +68 -0
  96. package/dist/message-parts.d.ts +22 -0
  97. package/dist/message-parts.js +18 -0
  98. package/dist/message-settlement.d.ts +35 -0
  99. package/{src/message-settlement.ts → dist/message-settlement.js} +5 -19
  100. package/dist/mutation-events.d.ts +19 -0
  101. package/{src/mutation-events.ts → dist/mutation-events.js} +2 -5
  102. package/dist/sender-mute.d.ts +26 -0
  103. package/{src/sender-mute.ts → dist/sender-mute.js} +12 -34
  104. package/dist/sync-warning-state.d.ts +3 -0
  105. package/dist/sync-warning-state.js +5 -0
  106. package/dist/types.d.ts +491 -0
  107. package/dist/types.js +1 -0
  108. package/dist/update-manifest.d.ts +24 -0
  109. package/dist/update-manifest.js +20 -0
  110. package/dist/virtual-copy.d.ts +8 -0
  111. package/{src/virtual-copy.ts → dist/virtual-copy.js} +10 -15
  112. package/dist/wellknown.d.ts +47 -0
  113. package/{src/wellknown.ts → dist/wellknown.js} +26 -49
  114. package/package.json +44 -79
  115. package/src/account-settings.ts +0 -194
  116. package/src/conformance/account.ts +0 -104
  117. package/src/conformance/calendar-collection.ts +0 -342
  118. package/src/conformance/calendar-event-index.ts +0 -203
  119. package/src/conformance/calendar-feed-token.ts +0 -148
  120. package/src/conformance/calendar-object.ts +0 -269
  121. package/src/conformance/calendar-suggestion.ts +0 -490
  122. package/src/conformance/label.ts +0 -136
  123. package/src/conformance/sender-signer-standing.ts +0 -117
  124. package/src/display-name.test.ts +0 -101
  125. package/src/display-name.ts +0 -40
  126. package/src/errors.ts +0 -226
  127. package/src/folder-role.test.ts +0 -422
  128. package/src/folder-role.ts +0 -447
  129. package/src/id.test.ts +0 -352
  130. package/src/index.ts +0 -170
  131. package/src/interfaces/account-config.ts +0 -25
  132. package/src/interfaces/account-export-request.ts +0 -21
  133. package/src/interfaces/account-setting.ts +0 -14
  134. package/src/interfaces/account.ts +0 -43
  135. package/src/interfaces/address.ts +0 -122
  136. package/src/interfaces/calendar-collection.ts +0 -60
  137. package/src/interfaces/calendar-event-index.ts +0 -34
  138. package/src/interfaces/calendar-feed-token.ts +0 -38
  139. package/src/interfaces/calendar-object.ts +0 -58
  140. package/src/interfaces/calendar-suggestion.ts +0 -93
  141. package/src/interfaces/calendar-unit-of-work.ts +0 -47
  142. package/src/interfaces/config-import.ts +0 -16
  143. package/src/interfaces/envelope.ts +0 -32
  144. package/src/interfaces/filter-anchor-transaction.ts +0 -23
  145. package/src/interfaces/filter-anchor.ts +0 -11
  146. package/src/interfaces/filter.ts +0 -41
  147. package/src/interfaces/label.ts +0 -30
  148. package/src/interfaces/mailbox-lock.ts +0 -32
  149. package/src/interfaces/mailbox-special-use.ts +0 -60
  150. package/src/interfaces/mailbox.ts +0 -115
  151. package/src/interfaces/message-flag-push.ts +0 -17
  152. package/src/interfaces/message-flag.ts +0 -15
  153. package/src/interfaces/message-label.ts +0 -25
  154. package/src/interfaces/message-placement-move.ts +0 -15
  155. package/src/interfaces/message.ts +0 -56
  156. package/src/interfaces/organize-job-request.ts +0 -19
  157. package/src/interfaces/outbox-attachment.ts +0 -132
  158. package/src/interfaces/outbox-message.ts +0 -106
  159. package/src/interfaces/quarantine.ts +0 -22
  160. package/src/interfaces/sender-signer-standing.ts +0 -37
  161. package/src/interfaces/thread-message.ts +0 -264
  162. package/src/interfaces/unit-of-work.ts +0 -28
  163. package/src/mailbox-name.test.ts +0 -106
  164. package/src/mailbox-name.ts +0 -90
  165. package/src/mailbox-state-doors.test.ts +0 -104
  166. package/src/message-placement-doors.test.ts +0 -100
  167. package/src/message-settlement.test.ts +0 -132
  168. package/src/sender-mute.test.ts +0 -76
  169. package/src/types.ts +0 -820
  170. package/src/update-manifest.test.ts +0 -130
  171. package/src/update-manifest.ts +0 -22
  172. package/src/virtual-copy.test.ts +0 -34
  173. package/tsconfig.json +0 -8
  174. /package/{src/conformance/index.ts → dist/conformance/index.d.ts} +0 -0
@@ -0,0 +1,255 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { CalendarInviteMethod, CalendarSuggestionSource, CalendarSuggestionState, ZoneCertainty, } from "@remit/domain-enums";
4
+ import { deriveCalendarSuggestionId } from "../id.js";
5
+ const suggestion = (accountConfigId, messageId, bodyPartId, icalUid, overrides = {}) => ({
6
+ accountConfigId,
7
+ messageId,
8
+ bodyPartId,
9
+ icalUid,
10
+ sequence: 0,
11
+ method: CalendarInviteMethod.Request,
12
+ source: CalendarSuggestionSource.IcalendarPart,
13
+ summary: "Design review",
14
+ dtStart: "2026-09-01T10:00:00+02:00",
15
+ dtEnd: "2026-09-01T11:00:00+02:00",
16
+ endsAtUtc: "2026-09-01T09:00:00Z",
17
+ allDay: false,
18
+ location: "",
19
+ organizer: "organizer@example.test",
20
+ zoneCertainty: ZoneCertainty.Explicit,
21
+ icalData: "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
22
+ ...overrides,
23
+ });
24
+ const EARLIEST_INSTANT = "1970-01-01T00:00:00.000Z";
25
+ export function calendarSuggestionRepositoryConformance(harness) {
26
+ describe("ICalendarSuggestionRepository conformance", () => {
27
+ let repo;
28
+ before(async () => {
29
+ repo = await harness.createRepository();
30
+ });
31
+ after(() => harness.teardown());
32
+ test("put writes a Pending suggestion under its derived id", async () => {
33
+ const accountConfigId = harness.makeId();
34
+ const messageId = harness.makeId();
35
+ const bodyPartId = harness.makeId();
36
+ const written = await repo.put(suggestion(accountConfigId, messageId, bodyPartId, "uid-1"));
37
+ assert.equal(written.suggestionId, deriveCalendarSuggestionId(messageId, bodyPartId, "uid-1"));
38
+ assert.equal(written.state, CalendarSuggestionState.Pending);
39
+ assert.equal(written.acceptedCalendarObjectId, "");
40
+ });
41
+ test("put re-reading the same message converges on one row", async () => {
42
+ const accountConfigId = harness.makeId();
43
+ const messageId = harness.makeId();
44
+ const bodyPartId = harness.makeId();
45
+ await repo.put(suggestion(accountConfigId, messageId, bodyPartId, "uid-1"));
46
+ await repo.put(suggestion(accountConfigId, messageId, bodyPartId, "uid-1", {
47
+ summary: "Design review, moved",
48
+ }));
49
+ const rows = await repo.listByMessage(accountConfigId, messageId);
50
+ assert.equal(rows.length, 1);
51
+ assert.equal(rows[0]?.summary, "Design review, moved");
52
+ });
53
+ test("put leaves an answered suggestion answered", async () => {
54
+ // A re-sync must never walk a decision back to Pending: the user
55
+ // already added this event to a calendar, and a producer write is not
56
+ // allowed to un-decide that.
57
+ const accountConfigId = harness.makeId();
58
+ const messageId = harness.makeId();
59
+ const bodyPartId = harness.makeId();
60
+ const written = await repo.put(suggestion(accountConfigId, messageId, bodyPartId, "uid-1"));
61
+ await repo.settle(accountConfigId, written.suggestionId, {
62
+ state: CalendarSuggestionState.Accepted,
63
+ acceptedCalendarObjectId: "cal-object-1",
64
+ });
65
+ await repo.put(suggestion(accountConfigId, messageId, bodyPartId, "uid-1"));
66
+ const reread = await repo.get(accountConfigId, written.suggestionId);
67
+ assert.equal(reread.state, CalendarSuggestionState.Accepted);
68
+ assert.equal(reread.acceptedCalendarObjectId, "cal-object-1");
69
+ });
70
+ test("get rejects a suggestion that is not there", async () => {
71
+ const accountConfigId = harness.makeId();
72
+ await assert.rejects(() => repo.get(accountConfigId, harness.makeId()), (error) => harness.isNotFoundError(error));
73
+ });
74
+ test("get scopes to the account config", async () => {
75
+ const accountConfigId = harness.makeId();
76
+ const written = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"));
77
+ await assert.rejects(() => repo.get(harness.makeId(), written.suggestionId), (error) => harness.isNotFoundError(error));
78
+ });
79
+ test("listByMessage returns every suggestion one message produced", async () => {
80
+ const accountConfigId = harness.makeId();
81
+ const messageId = harness.makeId();
82
+ const other = harness.makeId();
83
+ await repo.put(suggestion(accountConfigId, messageId, harness.makeId(), "uid-a"));
84
+ await repo.put(suggestion(accountConfigId, messageId, harness.makeId(), "uid-b"));
85
+ await repo.put(suggestion(accountConfigId, other, harness.makeId(), "uid-c"));
86
+ const rows = await repo.listByMessage(accountConfigId, messageId);
87
+ assert.deepEqual(rows.map((row) => row.icalUid).sort(), [
88
+ "uid-a",
89
+ "uid-b",
90
+ ]);
91
+ });
92
+ test("listByState returns only the state asked for", async () => {
93
+ const accountConfigId = harness.makeId();
94
+ const pending = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "p"));
95
+ const declined = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "d"));
96
+ await repo.settle(accountConfigId, declined.suggestionId, {
97
+ state: CalendarSuggestionState.Declined,
98
+ acceptedCalendarObjectId: "",
99
+ });
100
+ const page = await repo.listByState(accountConfigId, CalendarSuggestionState.Pending, { endsAfter: EARLIEST_INSTANT });
101
+ assert.deepEqual(page.items.map((row) => row.suggestionId), [pending.suggestionId]);
102
+ });
103
+ test("listByState drops a suggestion that ended before endsAfter, comparing instants across offsets", async () => {
104
+ const accountConfigId = harness.makeId();
105
+ await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "ended", {
106
+ dtEnd: "2026-10-06T23:00:00+14:00",
107
+ endsAtUtc: "2026-10-06T09:00:00Z",
108
+ }));
109
+ const running = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "running", {
110
+ dtEnd: "2026-10-06T01:00:00-12:00",
111
+ endsAtUtc: "2026-10-06T13:00:00Z",
112
+ }));
113
+ const page = await repo.listByState(accountConfigId, CalendarSuggestionState.Pending, { endsAfter: "2026-10-06T10:00:00Z" });
114
+ assert.deepEqual(page.items.map((row) => row.suggestionId), [running.suggestionId]);
115
+ });
116
+ test("listByState keeps a suggestion ending exactly at endsAfter", async () => {
117
+ const accountConfigId = harness.makeId();
118
+ const boundary = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "edge", {
119
+ endsAtUtc: "2026-10-06T10:00:00Z",
120
+ }));
121
+ const page = await repo.listByState(accountConfigId, CalendarSuggestionState.Pending, { endsAfter: "2026-10-06T10:00:00Z" });
122
+ assert.deepEqual(page.items.map((row) => row.suggestionId), [boundary.suggestionId]);
123
+ });
124
+ test("listByState scopes to the account config", async () => {
125
+ const accountConfigId = harness.makeId();
126
+ await repo.put(suggestion(harness.makeId(), harness.makeId(), harness.makeId(), "x"));
127
+ const page = await repo.listByState(accountConfigId, CalendarSuggestionState.Pending, { endsAfter: EARLIEST_INSTANT });
128
+ assert.equal(page.items.length, 0);
129
+ });
130
+ test("listByState pages, and the token resumes where the page stopped", async () => {
131
+ const accountConfigId = harness.makeId();
132
+ for (const uid of ["a", "b", "c"]) {
133
+ await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), uid));
134
+ }
135
+ const first = await repo.listByState(accountConfigId, CalendarSuggestionState.Pending, { endsAfter: EARLIEST_INSTANT, limit: 2 });
136
+ assert.equal(first.items.length, 2);
137
+ assert.ok(first.continuationToken);
138
+ const second = await repo.listByState(accountConfigId, CalendarSuggestionState.Pending, {
139
+ endsAfter: EARLIEST_INSTANT,
140
+ limit: 2,
141
+ continuationToken: first.continuationToken,
142
+ });
143
+ const seen = [...first.items, ...second.items].map((row) => row.suggestionId);
144
+ assert.equal(new Set(seen).size, 3);
145
+ });
146
+ test("listByAcceptedCalendarObjects returns, in one read, the account's suggestions answered into any of the resources", async () => {
147
+ const accountConfigId = harness.makeId();
148
+ const first = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"));
149
+ const second = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"));
150
+ const elsewhere = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"));
151
+ const unasked = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"));
152
+ const stranger = harness.makeId();
153
+ const strangers = await repo.put(suggestion(stranger, harness.makeId(), harness.makeId(), "uid"));
154
+ for (const [owner, row, objectId] of [
155
+ [accountConfigId, first, "cal-object-1"],
156
+ [accountConfigId, second, "cal-object-1"],
157
+ [accountConfigId, elsewhere, "cal-object-2"],
158
+ [accountConfigId, unasked, "cal-object-3"],
159
+ [stranger, strangers, "cal-object-1"],
160
+ ]) {
161
+ await repo.settle(owner, row.suggestionId, {
162
+ state: CalendarSuggestionState.Accepted,
163
+ acceptedCalendarObjectId: objectId,
164
+ });
165
+ }
166
+ const rows = await repo.listByAcceptedCalendarObjects(accountConfigId, [
167
+ "cal-object-1",
168
+ "cal-object-2",
169
+ ]);
170
+ assert.deepEqual(rows.map((row) => row.suggestionId).sort(), [
171
+ first.suggestionId,
172
+ second.suggestionId,
173
+ elsewhere.suggestionId,
174
+ ].sort());
175
+ assert.deepEqual(await repo.listByAcceptedCalendarObjects(accountConfigId, []), []);
176
+ });
177
+ test("settle records the accepted resource", async () => {
178
+ const accountConfigId = harness.makeId();
179
+ const written = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"));
180
+ const settled = await repo.settle(accountConfigId, written.suggestionId, {
181
+ state: CalendarSuggestionState.Accepted,
182
+ acceptedCalendarObjectId: "cal-object-9",
183
+ });
184
+ assert.equal(settled.state, CalendarSuggestionState.Accepted);
185
+ assert.equal(settled.acceptedCalendarObjectId, "cal-object-9");
186
+ });
187
+ test("supersedeIfPending retires a card still waiting on the user", async () => {
188
+ const accountConfigId = harness.makeId();
189
+ const written = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"));
190
+ const newerMessageId = harness.makeId();
191
+ const retired = await repo.supersedeIfPending(accountConfigId, written.suggestionId, newerMessageId);
192
+ assert.equal(retired?.state, CalendarSuggestionState.Superseded);
193
+ const reread = await repo.get(accountConfigId, written.suggestionId);
194
+ assert.equal(reread.supersededByMessageId, newerMessageId);
195
+ });
196
+ test("a re-read of the retired message keeps the revision that retired it", async () => {
197
+ const accountConfigId = harness.makeId();
198
+ const input = suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid");
199
+ const written = await repo.put(input);
200
+ const newerMessageId = harness.makeId();
201
+ await repo.supersedeIfPending(accountConfigId, written.suggestionId, newerMessageId);
202
+ const rewritten = await repo.put(input);
203
+ assert.equal(rewritten.state, CalendarSuggestionState.Superseded);
204
+ assert.equal(rewritten.supersededByMessageId, newerMessageId);
205
+ });
206
+ test("supersedeIfPending leaves an answered card alone and says so", async () => {
207
+ // The producer reads the pending set and writes to it in two steps. A
208
+ // person accepting in between must keep their acceptance — and the
209
+ // link to the event it put in their calendar.
210
+ const accountConfigId = harness.makeId();
211
+ const written = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"));
212
+ await repo.settle(accountConfigId, written.suggestionId, {
213
+ state: CalendarSuggestionState.Accepted,
214
+ acceptedCalendarObjectId: "cal-object-7",
215
+ });
216
+ const retired = await repo.supersedeIfPending(accountConfigId, written.suggestionId, harness.makeId());
217
+ assert.equal(retired, null);
218
+ const reread = await repo.get(accountConfigId, written.suggestionId);
219
+ assert.equal(reread.state, CalendarSuggestionState.Accepted);
220
+ assert.equal(reread.supersededByMessageId, "");
221
+ assert.equal(reread.acceptedCalendarObjectId, "cal-object-7");
222
+ });
223
+ test("supersedeIfPending scopes to the account config", async () => {
224
+ const accountConfigId = harness.makeId();
225
+ const written = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"));
226
+ assert.equal(await repo.supersedeIfPending(harness.makeId(), written.suggestionId, harness.makeId()), null);
227
+ });
228
+ test("repointSuperseded moves a retired card to a later revision", async () => {
229
+ const accountConfigId = harness.makeId();
230
+ const written = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"));
231
+ await repo.supersedeIfPending(accountConfigId, written.suggestionId, harness.makeId());
232
+ const newest = harness.makeId();
233
+ const repointed = await repo.repointSuperseded(accountConfigId, written.suggestionId, newest);
234
+ assert.equal(repointed?.supersededByMessageId, newest);
235
+ const reread = await repo.get(accountConfigId, written.suggestionId);
236
+ assert.equal(reread.state, CalendarSuggestionState.Superseded);
237
+ assert.equal(reread.supersededByMessageId, newest);
238
+ });
239
+ test("repointSuperseded leaves a card that is not superseded alone", async () => {
240
+ const accountConfigId = harness.makeId();
241
+ const written = await repo.put(suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"));
242
+ assert.equal(await repo.repointSuperseded(accountConfigId, written.suggestionId, harness.makeId()), null);
243
+ const reread = await repo.get(accountConfigId, written.suggestionId);
244
+ assert.equal(reread.state, CalendarSuggestionState.Pending);
245
+ assert.equal(reread.supersededByMessageId, "");
246
+ });
247
+ test("settle rejects a suggestion that is not there", async () => {
248
+ const accountConfigId = harness.makeId();
249
+ await assert.rejects(() => repo.settle(accountConfigId, harness.makeId(), {
250
+ state: CalendarSuggestionState.Declined,
251
+ acceptedCalendarObjectId: "",
252
+ }), (error) => harness.isNotFoundError(error));
253
+ });
254
+ });
255
+ }
@@ -5,8 +5,8 @@
5
5
  * which differ per implementation.
6
6
  */
7
7
  export interface RepositoryConformanceHarness<TRepo> {
8
- createRepository(): Promise<TRepo>;
9
- teardown(): Promise<void>;
10
- makeId(): string;
11
- isNotFoundError(error: unknown): boolean;
8
+ createRepository(): Promise<TRepo>;
9
+ teardown(): Promise<void>;
10
+ makeId(): string;
11
+ isNotFoundError(error: unknown): boolean;
12
12
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ export { accountRepositoryConformance } from "./account.js";
2
+ export { calendarCollectionRepositoryConformance } from "./calendar-collection.js";
3
+ export { calendarEventIndexRepositoryConformance } from "./calendar-event-index.js";
4
+ export { calendarFeedTokenRepositoryConformance } from "./calendar-feed-token.js";
5
+ export { calendarObjectRepositoryConformance } from "./calendar-object.js";
6
+ export { calendarSuggestionRepositoryConformance } from "./calendar-suggestion.js";
7
+ export { labelRepositoryConformance } from "./label.js";
8
+ export { senderSignerStandingRepositoryConformance } from "./sender-signer-standing.js";
@@ -0,0 +1,3 @@
1
+ import type { ILabelRepository } from "../interfaces/label.js";
2
+ import type { RepositoryConformanceHarness } from "./harness.js";
3
+ export declare function labelRepositoryConformance(harness: RepositoryConformanceHarness<ILabelRepository>): void;
@@ -0,0 +1,92 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { LabelColor } from "@remit/domain-enums";
4
+ export function labelRepositoryConformance(harness) {
5
+ describe("ILabelRepository conformance", () => {
6
+ let repo;
7
+ before(async () => {
8
+ repo = await harness.createRepository();
9
+ });
10
+ after(() => harness.teardown());
11
+ test("create derives normalizedName and defaults color", async () => {
12
+ const accountConfigId = harness.makeId();
13
+ const label = await repo.create({
14
+ accountConfigId,
15
+ name: " Receipts ",
16
+ });
17
+ assert.equal(label.name, " Receipts ");
18
+ assert.equal(label.normalizedName, "receipts");
19
+ assert.equal(label.color, LabelColor.Default);
20
+ });
21
+ test("get throws a not-found error for a missing label", async () => {
22
+ await assert.rejects(repo.get(harness.makeId(), harness.makeId()), (error) => harness.isNotFoundError(error));
23
+ });
24
+ test("update re-derives normalizedName when the name changes", async () => {
25
+ const accountConfigId = harness.makeId();
26
+ const label = await repo.create({ accountConfigId, name: "Old Name" });
27
+ const updated = await repo.update(accountConfigId, label.labelId, {
28
+ name: "New Name",
29
+ });
30
+ assert.equal(updated.name, "New Name");
31
+ assert.equal(updated.normalizedName, "new name");
32
+ });
33
+ test("update throws a not-found error for a missing label", async () => {
34
+ await assert.rejects(repo.update(harness.makeId(), harness.makeId(), { name: "x" }), (error) => harness.isNotFoundError(error));
35
+ });
36
+ test("listByAccountConfig scopes to the account", async () => {
37
+ const accountConfigId = harness.makeId();
38
+ const other = harness.makeId();
39
+ const mine = await repo.create({ accountConfigId, name: "Mine" });
40
+ await repo.create({ accountConfigId: other, name: "Foreign" });
41
+ const labels = await repo.listByAccountConfig(accountConfigId);
42
+ assert.equal(labels.length, 1);
43
+ assert.equal(labels[0]?.labelId, mine.labelId);
44
+ });
45
+ test("findByNormalizedName dedupes case- and whitespace-insensitively", async () => {
46
+ const accountConfigId = harness.makeId();
47
+ const label = await repo.create({ accountConfigId, name: "Work" });
48
+ const found = await repo.findByNormalizedName(accountConfigId, "WORK");
49
+ assert.equal(found?.labelId, label.labelId);
50
+ const missing = await repo.findByNormalizedName(accountConfigId, "nonexistent");
51
+ assert.equal(missing, null);
52
+ });
53
+ test("listPageByAccountConfig pages through an account's labels, scoped to the account", async () => {
54
+ const accountConfigId = harness.makeId();
55
+ const other = harness.makeId();
56
+ // Created concurrently so their `createdAt` millisecond can tie (as it
57
+ // reliably does against the synchronous sqlite driver): the keyset's
58
+ // `labelId` tiebreak then decides order, so this asserts pagination is
59
+ // exhaustive and duplicate-free rather than a specific creation order.
60
+ const created = await Promise.all([
61
+ repo.create({ accountConfigId, name: "First" }),
62
+ repo.create({ accountConfigId, name: "Second" }),
63
+ ]);
64
+ await repo.create({ accountConfigId: other, name: "Foreign" });
65
+ const page1 = await repo.listPageByAccountConfig(accountConfigId, {
66
+ limit: 1,
67
+ });
68
+ assert.equal(page1.items.length, 1);
69
+ assert.ok(page1.continuationToken);
70
+ const page2 = await repo.listPageByAccountConfig(accountConfigId, {
71
+ limit: 1,
72
+ continuationToken: page1.continuationToken,
73
+ });
74
+ assert.equal(page2.items.length, 1);
75
+ assert.equal(page2.continuationToken, undefined);
76
+ const seen = new Set([
77
+ ...page1.items.map((label) => label.labelId),
78
+ ...page2.items.map((label) => label.labelId),
79
+ ]);
80
+ assert.equal(seen.size, created.length, "every label is paged exactly once");
81
+ for (const label of created) {
82
+ assert.ok(seen.has(label.labelId));
83
+ }
84
+ });
85
+ test("delete removes the row", async () => {
86
+ const accountConfigId = harness.makeId();
87
+ const label = await repo.create({ accountConfigId, name: "Gone" });
88
+ await repo.delete(accountConfigId, label.labelId);
89
+ await assert.rejects(repo.get(accountConfigId, label.labelId), (error) => harness.isNotFoundError(error));
90
+ });
91
+ });
92
+ }
@@ -0,0 +1,3 @@
1
+ import type { ISenderSignerStandingRepository } from "../interfaces/sender-signer-standing.js";
2
+ import type { RepositoryConformanceHarness } from "./harness.js";
3
+ export declare function senderSignerStandingRepositoryConformance(harness: RepositoryConformanceHarness<ISenderSignerStandingRepository>): void;
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ export function senderSignerStandingRepositoryConformance(harness) {
4
+ describe("ISenderSignerStandingRepository conformance", () => {
5
+ let repo;
6
+ before(async () => {
7
+ repo = await harness.createRepository();
8
+ });
9
+ after(() => harness.teardown());
10
+ test("the first observation creates the row at a count of one", async () => {
11
+ const accountConfigId = harness.makeId();
12
+ const observedAt = 1_700_000_000_000;
13
+ const standing = await repo.observe({
14
+ accountConfigId,
15
+ senderKey: "example.com",
16
+ signerDomain: "esp.example",
17
+ observedAt,
18
+ });
19
+ assert.equal(standing.messageCount, 1);
20
+ assert.equal(standing.firstSeenAt, observedAt);
21
+ assert.equal(standing.lastSeenAt, observedAt);
22
+ assert.equal(standing.userAffirmedAt, 0);
23
+ });
24
+ test("a repeat observation increments the count", async () => {
25
+ const accountConfigId = harness.makeId();
26
+ const key = {
27
+ accountConfigId,
28
+ senderKey: "example.com",
29
+ signerDomain: "esp.example",
30
+ };
31
+ await repo.observe({ ...key, observedAt: 1_700_000_000_000 });
32
+ const second = await repo.observe({
33
+ ...key,
34
+ observedAt: 1_700_000_060_000,
35
+ });
36
+ assert.equal(second.messageCount, 2);
37
+ assert.equal((await repo.get(accountConfigId, key.senderKey, key.signerDomain))
38
+ .messageCount, 2);
39
+ });
40
+ test("firstSeenAt survives every later observation while lastSeenAt advances", async () => {
41
+ const accountConfigId = harness.makeId();
42
+ const key = {
43
+ accountConfigId,
44
+ senderKey: "news.example.com",
45
+ signerDomain: "unverified",
46
+ };
47
+ const first = 1_700_000_000_000;
48
+ await repo.observe({ ...key, observedAt: first });
49
+ await repo.observe({ ...key, observedAt: first + 60_000 });
50
+ const third = await repo.observe({ ...key, observedAt: first + 120_000 });
51
+ assert.equal(third.messageCount, 3);
52
+ assert.equal(third.firstSeenAt, first);
53
+ assert.equal(third.lastSeenAt, first + 120_000);
54
+ const reread = await repo.get(accountConfigId, key.senderKey, key.signerDomain);
55
+ assert.equal(reread.firstSeenAt, first);
56
+ assert.equal(reread.lastSeenAt, first + 120_000);
57
+ });
58
+ test("a key differing only in signer domain is a separate row", async () => {
59
+ const accountConfigId = harness.makeId();
60
+ const observedAt = 1_700_000_000_000;
61
+ await repo.observe({
62
+ accountConfigId,
63
+ senderKey: "example.com",
64
+ signerDomain: "esp-one.example",
65
+ observedAt,
66
+ });
67
+ const other = await repo.observe({
68
+ accountConfigId,
69
+ senderKey: "example.com",
70
+ signerDomain: "esp-two.example",
71
+ observedAt,
72
+ });
73
+ assert.equal(other.messageCount, 1);
74
+ });
75
+ test("get reports not-found for a key never observed, and does not create it", async () => {
76
+ const accountConfigId = harness.makeId();
77
+ await assert.rejects(repo.get(accountConfigId, "stranger.example", "esp.example"), (error) => harness.isNotFoundError(error));
78
+ await assert.rejects(repo.get(accountConfigId, "stranger.example", "esp.example"), (error) => harness.isNotFoundError(error));
79
+ });
80
+ });
81
+ }
@@ -0,0 +1 @@
1
+ export declare const storedDisplayName: (displayName: string | undefined, normalizedEmail: string | undefined) => string;
@@ -0,0 +1,26 @@
1
+ const EMBEDDED_ADDRESS = /[^\s@<>()[\],;:"'\\]+@[^\s@<>()[\],;:"'\\]+\.[^\s@<>()[\],;:"'\\.]{2,}/gu;
2
+ const DELIMITER_PAIR_LEFT_EMPTY = /[<([{"']\s*[>)\]}"']/gu;
3
+ const LEADING_OR_TRAILING_SEPARATORS = /^[\s,;:|/\\-]+|[\s,;:|/\\-]+$/gu;
4
+ const tidyAfterAddressRemoval = (text) => text
5
+ .replace(DELIMITER_PAIR_LEFT_EMPTY, " ")
6
+ .replace(/\s+/gu, " ")
7
+ .replace(LEADING_OR_TRAILING_SEPARATORS, "")
8
+ .trim();
9
+ const carriesAddressOtherThan = (text, ownAddress) => {
10
+ for (const [address] of text.matchAll(EMBEDDED_ADDRESS)) {
11
+ if (address.toLowerCase() !== ownAddress)
12
+ return true;
13
+ }
14
+ return false;
15
+ };
16
+ export const storedDisplayName = (displayName, normalizedEmail) => {
17
+ if (!displayName)
18
+ return "";
19
+ const ownAddress = normalizedEmail?.toLowerCase();
20
+ if (!carriesAddressOtherThan(displayName, ownAddress))
21
+ return displayName;
22
+ const remainder = tidyAfterAddressRemoval(displayName.replace(EMBEDDED_ADDRESS, (address) => address.toLowerCase() === ownAddress ? address : " "));
23
+ if (!remainder || carriesAddressOtherThan(remainder, ownAddress))
24
+ return "";
25
+ return remainder;
26
+ };
@@ -0,0 +1,150 @@
1
+ import type { CanonicalMailboxRoleValue, FolderRoleUnresolvedReason } from "./folder-role.js";
2
+ export type { FolderRoleUnresolvedReason };
3
+ /**
4
+ * The half of an error a client may read: a stable `code` it branches on and
5
+ * string `details` it words its own prompt from. Opt-in — an error that
6
+ * declares none keeps today's `{ message }` body, so no response gains a
7
+ * shape the API never promised and no internal failure leaks its innards.
8
+ */
9
+ export interface PublicApiError {
10
+ code: string;
11
+ details?: Record<string, string>;
12
+ }
13
+ export declare const isPublicApiError: (value: unknown) => value is PublicApiError;
14
+ export declare class HTTPError extends Error {
15
+ statusCode: number;
16
+ publicApiError?: PublicApiError;
17
+ }
18
+ export declare class BadRequestError extends HTTPError {
19
+ name: string;
20
+ statusCode: number;
21
+ }
22
+ export declare class ClientError extends HTTPError {
23
+ name: string;
24
+ statusCode: number;
25
+ }
26
+ export declare class ForbiddenError extends HTTPError {
27
+ name: string;
28
+ statusCode: number;
29
+ }
30
+ export declare class NotFoundError extends HTTPError {
31
+ name: string;
32
+ statusCode: number;
33
+ }
34
+ export declare class ConflictError extends HTTPError {
35
+ name: string;
36
+ statusCode: number;
37
+ }
38
+ /**
39
+ * A destructive action refused because the role it needs is unresolved. The
40
+ * client reads `code` and words its prompt from `details` — never from the
41
+ * message — so the copy can change without breaking the branch, and the
42
+ * account the prompt has to appoint a folder on travels with the refusal
43
+ * (the delete endpoint's body carries no accountId).
44
+ */
45
+ export declare class FolderRoleUnresolvedError extends ConflictError {
46
+ name: string;
47
+ constructor(message: string, role: CanonicalMailboxRoleValue, reason: FolderRoleUnresolvedReason, accountId: string);
48
+ }
49
+ /**
50
+ * A durable reference cannot be bound to a mailbox whose own mutation has not
51
+ * settled (folder-rename-and-delete.md D12, imap-mutations R2: wait). Its own
52
+ * code, because the role is not unresolved — the target is: the client words a
53
+ * wait, not a retry. Clearing a role is never refused this way.
54
+ *
55
+ * **422, not 409** (D4). 409 already means "this folder is being changed right
56
+ * now, refresh"; this means "the folder you are pointing at has not settled
57
+ * yet", which is a different sentence and a different remedy. The status is
58
+ * what separates them for a client that reads no further.
59
+ */
60
+ export declare class MailboxNotSettledError extends HTTPError {
61
+ name: string;
62
+ statusCode: number;
63
+ constructor(message: string, mailboxId: string, syncStatus: string);
64
+ }
65
+ /**
66
+ * Why the row's folder and uid do not name the same message. One value, because
67
+ * one state produces it: a mutation is still in flight and the pair clears on
68
+ * its own, so the client words a wait rather than a repair. A mutation that
69
+ * gave up does not reach here — it hands the row back to a placement the server
70
+ * confirmed first (docs/architecture/imap-mutations.md R3), which is a pair
71
+ * every dependent mutation can act on.
72
+ */
73
+ export type MessagePlacementUnsettledReason = "in_flight";
74
+ export declare class MessagePlacementUnsettledError extends ConflictError {
75
+ name: string;
76
+ constructor(message: string, accountId: string, messageId: string, reason: MessagePlacementUnsettledReason);
77
+ }
78
+ /**
79
+ * The row named a message the mail server no longer holds at that UID —
80
+ * another client expunged it or moved it away. Distinct from a plain
81
+ * not-found so the client can say where the message went rather than
82
+ * claiming the user deleted it.
83
+ */
84
+ export declare class MessageGoneFromServerError extends NotFoundError {
85
+ name: string;
86
+ constructor(messageId: string);
87
+ }
88
+ /**
89
+ * A configuration import refused because this configuration is not empty
90
+ * (#1021). The default is to refuse rather than to fold the file in: a person
91
+ * importing over a configuration they forgot they had should hear about it
92
+ * before anything moves. `details` counts what is already here, so the client
93
+ * can say what would be merged, and the same request with `onExisting: merge`
94
+ * goes through.
95
+ */
96
+ export declare class ConfigNotEmptyError extends ConflictError {
97
+ name: string;
98
+ constructor(message: string, details: Record<string, string>);
99
+ }
100
+ /**
101
+ * A message exists but its body could not be fetched/parsed after every
102
+ * body-sync retry was spent (issue #1270 / epic #1281 invariant 3). This is
103
+ * distinct from `NotFoundError`: the message is real, but its content is
104
+ * permanently unavailable — the client must show an explicit, actionable
105
+ * error instead of retrying (no 202, no silent skip).
106
+ */
107
+ export declare class UnrecoverableBodyError extends HTTPError {
108
+ name: string;
109
+ statusCode: number;
110
+ }
111
+ export declare class CreateFailedConflictError extends ConflictError {
112
+ statusCode: number;
113
+ name: string;
114
+ constructor(resourceType: string, params: unknown);
115
+ }
116
+ export declare class UnhandledError extends HTTPError {
117
+ name: string;
118
+ statusCode: number;
119
+ readonly cause: Error | undefined;
120
+ constructor(message: string, cause?: Error);
121
+ toJSON(): {
122
+ message: string;
123
+ cause: string | undefined;
124
+ stack: string | undefined;
125
+ };
126
+ }
127
+ export declare class InternalServerError extends UnhandledError {
128
+ name: string;
129
+ statusCode: number;
130
+ }
131
+ /**
132
+ * A repository lookup or write for a row that no longer exists rejects with a
133
+ * `NotFoundError`, name-matched rather than by `instanceof`: the class crosses
134
+ * the adapter boundary and an adapter may throw its own.
135
+ *
136
+ * A worker event that references a deliberately-deleted mailbox is completed or
137
+ * moot work, never a transient fault: every redelivery re-throws the same
138
+ * `NotFoundError`, and because the sync queues carry `MessageGroupId=accountId`
139
+ * that permanently-failing head message stalls the whole account's per-group
140
+ * FIFO. Handlers use this predicate to resolve such an event terminally (ack
141
+ * with a WARN) instead of retrying forever (issues #287, #289, #290).
142
+ *
143
+ * The guard is narrow on purpose: only a genuine not-found terminates. Real
144
+ * IMAP/infra failures carry other errors and must still propagate to be retried.
145
+ */
146
+ export declare const isNotFoundError: (error: unknown) => boolean;
147
+ export declare class MailSyncOffError extends BadRequestError {
148
+ name: string;
149
+ constructor(message: string, accountId: string);
150
+ }