@remit/backend 0.0.143 → 0.0.145

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 (279) hide show
  1. package/dist/dev-server/content-handler.d.ts +38 -0
  2. package/dist/dev-server/content-handler.js +46 -0
  3. package/dist/src/api.d.ts +5 -0
  4. package/dist/src/api.js +73 -0
  5. package/dist/src/auth.d.ts +24 -0
  6. package/dist/src/auth.js +81 -0
  7. package/dist/src/config/api-config.d.ts +20 -0
  8. package/dist/src/config/api-config.js +1 -0
  9. package/dist/src/config/from-env.d.ts +2 -0
  10. package/dist/src/config/from-env.js +65 -0
  11. package/dist/src/config/msoauth-secrets-manager.d.ts +2 -0
  12. package/dist/src/config/msoauth-secrets-manager.js +28 -0
  13. package/dist/src/config/msoauth.d.ts +19 -0
  14. package/dist/src/config/msoauth.js +6 -0
  15. package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
  16. package/dist/src/derive/autoMoved.js +49 -0
  17. package/dist/src/derive/contentSignature.d.ts +40 -0
  18. package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +23 -77
  19. package/dist/src/derive/contentUrl.d.ts +33 -0
  20. package/dist/src/derive/contentUrl.js +27 -0
  21. package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
  22. package/dist/src/derive/deriveMuted.js +7 -0
  23. package/dist/src/derive/enrichThreadRows.d.ts +118 -0
  24. package/dist/src/derive/enrichThreadRows.js +151 -0
  25. package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
  26. package/dist/src/derive/filterThreadCriteria.js +24 -0
  27. package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
  28. package/dist/src/derive/pendingMoveCounts.js +53 -0
  29. package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
  30. package/dist/src/derive/senderTrust.js +19 -0
  31. package/dist/src/error.d.ts +21 -0
  32. package/dist/src/error.js +85 -0
  33. package/dist/src/handlers/account-guards.d.ts +36 -0
  34. package/dist/src/handlers/account-guards.js +107 -0
  35. package/dist/src/handlers/account-oauth.d.ts +15 -0
  36. package/dist/src/handlers/account-oauth.js +334 -0
  37. package/dist/src/handlers/account-overrides.d.ts +53 -0
  38. package/dist/src/handlers/account-overrides.js +233 -0
  39. package/dist/src/handlers/account-signature.d.ts +28 -0
  40. package/dist/src/handlers/account-signature.js +85 -0
  41. package/dist/src/handlers/account.d.ts +7 -0
  42. package/dist/src/handlers/account.js +337 -0
  43. package/dist/src/handlers/address.d.ts +35 -0
  44. package/dist/src/handlers/address.js +133 -0
  45. package/dist/src/handlers/calendar-event.d.ts +70 -0
  46. package/dist/src/handlers/calendar-event.js +417 -0
  47. package/dist/src/handlers/calendar-feed.d.ts +47 -0
  48. package/dist/src/handlers/calendar-feed.js +184 -0
  49. package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
  50. package/dist/src/handlers/calendar-suggestion.js +204 -0
  51. package/dist/src/handlers/calendar.d.ts +116 -0
  52. package/dist/src/handlers/calendar.js +323 -0
  53. package/dist/src/handlers/config.d.ts +43 -0
  54. package/dist/src/handlers/config.js +164 -0
  55. package/dist/src/handlers/ensure-account-config.d.ts +9 -0
  56. package/dist/src/handlers/ensure-account-config.js +27 -0
  57. package/dist/src/handlers/filter.d.ts +3 -0
  58. package/dist/src/handlers/filter.js +88 -0
  59. package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
  60. package/dist/src/handlers/folder-role-appointments.js +168 -0
  61. package/dist/src/handlers/folder-role-labels.d.ts +26 -0
  62. package/dist/src/handlers/folder-role-labels.js +40 -0
  63. package/dist/src/handlers/folder-role.d.ts +9 -0
  64. package/dist/src/handlers/folder-role.js +37 -0
  65. package/dist/src/handlers/index.d.ts +88 -0
  66. package/dist/src/handlers/index.js +62 -0
  67. package/dist/src/handlers/label.d.ts +43 -0
  68. package/dist/src/handlers/label.js +107 -0
  69. package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
  70. package/dist/src/handlers/mailbox-bindings.js +61 -0
  71. package/dist/src/handlers/mailbox.d.ts +23 -0
  72. package/dist/src/handlers/mailbox.js +211 -0
  73. package/dist/src/handlers/me.d.ts +2 -0
  74. package/dist/src/handlers/me.js +117 -0
  75. package/dist/src/handlers/message.d.ts +82 -0
  76. package/dist/src/handlers/message.js +595 -0
  77. package/dist/src/handlers/organize.d.ts +10 -0
  78. package/dist/src/handlers/organize.js +103 -0
  79. package/dist/src/handlers/outbox-attachment.d.ts +25 -0
  80. package/dist/src/handlers/outbox-attachment.js +77 -0
  81. package/dist/src/handlers/outbox.d.ts +4 -0
  82. package/dist/src/handlers/outbox.js +136 -0
  83. package/dist/src/handlers/sync-progress.d.ts +46 -0
  84. package/dist/src/handlers/sync-progress.js +63 -0
  85. package/dist/src/handlers/sync.d.ts +28 -0
  86. package/dist/src/handlers/sync.js +73 -0
  87. package/dist/src/handlers/system-update.d.ts +2 -0
  88. package/dist/src/handlers/system-update.js +159 -0
  89. package/dist/src/handlers/thread.d.ts +148 -0
  90. package/dist/src/handlers/thread.js +228 -0
  91. package/dist/src/handlers/unified-threads.d.ts +247 -0
  92. package/dist/src/handlers/unified-threads.js +406 -0
  93. package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
  94. package/dist/src/handlers/vip-suggestions.js +16 -0
  95. package/dist/src/index.d.ts +5 -0
  96. package/dist/src/index.js +53 -0
  97. package/{src/json.ts → dist/src/json.d.ts} +1 -4
  98. package/dist/src/json.js +10 -0
  99. package/dist/src/jwt-auth.d.ts +15 -0
  100. package/dist/src/jwt-auth.js +45 -0
  101. package/dist/src/lambda.d.ts +13 -0
  102. package/dist/src/lambda.js +33 -0
  103. package/dist/src/request.d.ts +9 -0
  104. package/dist/src/request.js +17 -0
  105. package/dist/src/response.d.ts +44 -0
  106. package/dist/src/response.js +80 -0
  107. package/dist/src/serve.d.ts +31 -0
  108. package/dist/src/serve.js +117 -0
  109. package/dist/src/service/account-deletion.d.ts +20 -0
  110. package/dist/src/service/account-deletion.js +33 -0
  111. package/dist/src/service/compose-sqlite.d.ts +3 -0
  112. package/dist/src/service/compose-sqlite.js +13 -0
  113. package/dist/src/service/create-remit-client.d.ts +109 -0
  114. package/dist/src/service/create-remit-client.js +188 -0
  115. package/dist/src/service/data-client.d.ts +12 -0
  116. package/dist/src/service/data-client.js +42 -0
  117. package/dist/src/service/export-identity.d.ts +4 -0
  118. package/dist/src/service/export-identity.js +33 -0
  119. package/dist/src/service/fire-and-forget.d.ts +54 -0
  120. package/dist/src/service/fire-and-forget.js +37 -0
  121. package/dist/src/service/organize.d.ts +181 -0
  122. package/dist/src/service/organize.js +367 -0
  123. package/dist/src/service/queues.d.ts +12 -0
  124. package/dist/src/service/queues.js +1 -0
  125. package/dist/src/service/refusal.d.ts +4 -0
  126. package/dist/src/service/refusal.js +20 -0
  127. package/dist/src/service/sender-category.d.ts +51 -0
  128. package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
  129. package/dist/src/service/shared-deps.d.ts +6 -0
  130. package/dist/src/service/shared-deps.js +24 -0
  131. package/dist/src/service/trigger-sync.d.ts +57 -0
  132. package/dist/src/service/trigger-sync.js +31 -0
  133. package/dist/src/types.d.ts +72 -0
  134. package/dist/src/types.js +1 -0
  135. package/package.json +125 -98
  136. package/.env.test +0 -4
  137. package/dev-server/content-auth.test.ts +0 -113
  138. package/dev-server/content-auth.ts +0 -54
  139. package/dev-server/content-handler.test.ts +0 -105
  140. package/dev-server/content-handler.ts +0 -79
  141. package/dev-server/content-path.test.ts +0 -37
  142. package/dev-server/content-path.ts +0 -27
  143. package/dev-server/cors.test.ts +0 -48
  144. package/dev-server/cors.ts +0 -25
  145. package/dev-server/keep-alive.test.ts +0 -24
  146. package/dev-server/lambda-helpers.ts +0 -69
  147. package/dev-server/log-output.test.ts +0 -173
  148. package/dev-server/oauth-callback-route.test.ts +0 -66
  149. package/dev-server/relational-health.test.ts +0 -83
  150. package/dev-server/relational-health.ts +0 -39
  151. package/dev-server/server.ts +0 -384
  152. package/dev-server/sync-age.test.ts +0 -91
  153. package/dev-server/sync-age.ts +0 -55
  154. package/dev-server/upload-handler.ts +0 -132
  155. package/dev-server/upload-route.test.ts +0 -196
  156. package/scripts/backfill-classification.ts +0 -91
  157. package/scripts/backfill-list-id.ts +0 -83
  158. package/scripts/config-save.ts +0 -128
  159. package/src/auth.test.ts +0 -12
  160. package/src/auth.ts +0 -92
  161. package/src/config/msoauth.test.ts +0 -48
  162. package/src/config/msoauth.ts +0 -48
  163. package/src/data-backend.test.ts +0 -23
  164. package/src/data-backend.ts +0 -14
  165. package/src/derive/autoMoved.test.ts +0 -104
  166. package/src/derive/contentSignature.test.ts +0 -161
  167. package/src/derive/contentUrl.test.ts +0 -172
  168. package/src/derive/contentUrl.ts +0 -86
  169. package/src/derive/deriveMuted.test.ts +0 -34
  170. package/src/derive/enrichThreadRows.test.ts +0 -360
  171. package/src/derive/enrichThreadRows.ts +0 -234
  172. package/src/derive/filterThreadCriteria.test.ts +0 -126
  173. package/src/derive/filterThreadCriteria.ts +0 -51
  174. package/src/derive/senderTrust.test.ts +0 -67
  175. package/src/error.test.ts +0 -201
  176. package/src/error.ts +0 -123
  177. package/src/handlers/account-guards.test.ts +0 -67
  178. package/src/handlers/account-guards.ts +0 -169
  179. package/src/handlers/account-oauth-callback.test.ts +0 -155
  180. package/src/handlers/account-oauth-consent.test.ts +0 -336
  181. package/src/handlers/account-oauth-start.test.ts +0 -191
  182. package/src/handlers/account-oauth.test.ts +0 -439
  183. package/src/handlers/account-oauth.ts +0 -635
  184. package/src/handlers/account-overrides.test.ts +0 -88
  185. package/src/handlers/account-overrides.ts +0 -323
  186. package/src/handlers/account-ownership.ts +0 -25
  187. package/src/handlers/account-signature.ts +0 -117
  188. package/src/handlers/account-synced-services.test.ts +0 -245
  189. package/src/handlers/account.ts +0 -552
  190. package/src/handlers/address.test.ts +0 -381
  191. package/src/handlers/address.ts +0 -200
  192. package/src/handlers/calendar-event.test.ts +0 -685
  193. package/src/handlers/calendar-event.ts +0 -738
  194. package/src/handlers/calendar-feed-validators.test.ts +0 -228
  195. package/src/handlers/calendar-feed.test.ts +0 -439
  196. package/src/handlers/calendar-feed.ts +0 -291
  197. package/src/handlers/calendar-sqlite-fixture.ts +0 -36
  198. package/src/handlers/calendar-suggestion.test.ts +0 -1269
  199. package/src/handlers/calendar-suggestion.ts +0 -425
  200. package/src/handlers/calendar.test.ts +0 -1080
  201. package/src/handlers/calendar.ts +0 -517
  202. package/src/handlers/config.test.ts +0 -245
  203. package/src/handlers/config.ts +0 -332
  204. package/src/handlers/ensure-account-config.ts +0 -30
  205. package/src/handlers/filter.test.ts +0 -644
  206. package/src/handlers/filter.ts +0 -518
  207. package/src/handlers/folder-role-appointments.test.ts +0 -438
  208. package/src/handlers/folder-role-appointments.ts +0 -249
  209. package/src/handlers/folder-role-labels.ts +0 -63
  210. package/src/handlers/folder-role.test.ts +0 -209
  211. package/src/handlers/folder-role.ts +0 -80
  212. package/src/handlers/index.ts +0 -83
  213. package/src/handlers/label.test.ts +0 -112
  214. package/src/handlers/label.ts +0 -210
  215. package/src/handlers/mailbox-bindings.ts +0 -111
  216. package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
  217. package/src/handlers/mailbox-settled-target.test.ts +0 -251
  218. package/src/handlers/mailbox.test.ts +0 -280
  219. package/src/handlers/mailbox.ts +0 -581
  220. package/src/handlers/me.test.ts +0 -81
  221. package/src/handlers/me.ts +0 -203
  222. package/src/handlers/message-read-backfill.test.ts +0 -103
  223. package/src/handlers/message.test.ts +0 -131
  224. package/src/handlers/message.ts +0 -1131
  225. package/src/handlers/organize.test.ts +0 -277
  226. package/src/handlers/organize.ts +0 -197
  227. package/src/handlers/outbox-attachment.test.ts +0 -765
  228. package/src/handlers/outbox-attachment.ts +0 -126
  229. package/src/handlers/outbox-list.test.ts +0 -179
  230. package/src/handlers/outbox.test.ts +0 -772
  231. package/src/handlers/outbox.ts +0 -258
  232. package/src/handlers/search.test.ts +0 -161
  233. package/src/handlers/search.ts +0 -105
  234. package/src/handlers/sync-progress.test.ts +0 -205
  235. package/src/handlers/sync-progress.ts +0 -99
  236. package/src/handlers/sync.test.ts +0 -168
  237. package/src/handlers/sync.ts +0 -118
  238. package/src/handlers/system-update.test.ts +0 -497
  239. package/src/handlers/system-update.ts +0 -230
  240. package/src/handlers/thread.test.ts +0 -396
  241. package/src/handlers/thread.ts +0 -427
  242. package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
  243. package/src/handlers/unified-threads.test.ts +0 -785
  244. package/src/handlers/unified-threads.ts +0 -669
  245. package/src/index.test.ts +0 -83
  246. package/src/index.ts +0 -228
  247. package/src/jwt-auth.test.ts +0 -152
  248. package/src/jwt-auth.ts +0 -132
  249. package/src/log-redaction.test.ts +0 -144
  250. package/src/openapi-spec.test.ts +0 -118
  251. package/src/request-context.test.ts +0 -61
  252. package/src/request-context.ts +0 -33
  253. package/src/request-pipeline.test.ts +0 -88
  254. package/src/request.ts +0 -10
  255. package/src/response.test.ts +0 -130
  256. package/src/response.ts +0 -144
  257. package/src/service/compose-sqlite.ts +0 -98
  258. package/src/service/config-import.ts +0 -39
  259. package/src/service/create-remit-client.ts +0 -522
  260. package/src/service/data-client.test.ts +0 -97
  261. package/src/service/data-client.ts +0 -58
  262. package/src/service/export-identity.ts +0 -39
  263. package/src/service/filter.ts +0 -40
  264. package/src/service/fire-and-forget.test.ts +0 -126
  265. package/src/service/fire-and-forget.ts +0 -79
  266. package/src/service/localhost-env-config.test.ts +0 -36
  267. package/src/service/organize-queue.ts +0 -98
  268. package/src/service/organize.test.ts +0 -1451
  269. package/src/service/organize.ts +0 -1025
  270. package/src/service/read-path-filter-config.test.ts +0 -243
  271. package/src/service/semantic-capability.test.ts +0 -78
  272. package/src/service/semantic-capability.ts +0 -95
  273. package/src/service/sender-category.test.ts +0 -235
  274. package/src/service/sqs.ts +0 -4
  275. package/src/service/trigger-sync.test.ts +0 -205
  276. package/src/service/trigger-sync.ts +0 -100
  277. package/src/stdout-capture-fixture.ts +0 -40
  278. package/src/types.ts +0 -271
  279. package/tsconfig.json +0 -7
@@ -1,205 +0,0 @@
1
- /**
2
- * Unit tests for sync-status aggregation math and phase derivation.
3
- *
4
- * Imports the pure helpers from sync-progress.ts directly so the tests pin
5
- * the shipped implementation (no DynamoDB or AWS infrastructure required).
6
- */
7
- import assert from "node:assert/strict";
8
- import { describe, it } from "node:test";
9
- import {
10
- computeMessagesSynced,
11
- deriveMailboxPhase,
12
- type MailboxProgressFields,
13
- type MailboxSyncFields,
14
- toMailboxSyncProgress,
15
- } from "./sync-progress.js";
16
-
17
- // ─── deriveMailboxPhase tests ───────────────────────────────────────────────
18
-
19
- describe("deriveMailboxPhase", () => {
20
- it("returns pending when lastMessageSyncAt is 0", () => {
21
- const mailbox: MailboxSyncFields = {
22
- lastMessageSyncAt: 0,
23
- messageCount: 100,
24
- lastSyncUid: 0,
25
- highWaterMarkUid: 0,
26
- };
27
- assert.equal(deriveMailboxPhase(mailbox), "pending");
28
- });
29
-
30
- it("returns pending even when watermarks are set but lastMessageSyncAt is 0", () => {
31
- const mailbox: MailboxSyncFields = {
32
- lastMessageSyncAt: 0,
33
- messageCount: 50,
34
- lastSyncUid: 50,
35
- highWaterMarkUid: 100,
36
- };
37
- assert.equal(deriveMailboxPhase(mailbox), "pending");
38
- });
39
-
40
- it("returns complete when the worker stamped initialSyncCompletedAt after the last batch", () => {
41
- const now = Date.now();
42
- const mailbox: MailboxSyncFields = {
43
- lastMessageSyncAt: now - 1000,
44
- initialSyncCompletedAt: now,
45
- messageCount: 200,
46
- // Backfill settles at the smallest real UID (sparse UIDs) — the
47
- // marker, not the watermark, decides completion.
48
- lastSyncUid: 42,
49
- highWaterMarkUid: 200,
50
- };
51
- assert.equal(deriveMailboxPhase(mailbox), "complete");
52
- });
53
-
54
- it("returns complete when marker equals lastMessageSyncAt (same-millisecond writes)", () => {
55
- const now = Date.now();
56
- const mailbox: MailboxSyncFields = {
57
- lastMessageSyncAt: now,
58
- initialSyncCompletedAt: now,
59
- messageCount: 10,
60
- lastSyncUid: 5,
61
- highWaterMarkUid: 20,
62
- };
63
- assert.equal(deriveMailboxPhase(mailbox), "complete");
64
- });
65
-
66
- it("returns complete for empty mailbox (messageCount 0) without a marker", () => {
67
- const mailbox: MailboxSyncFields = {
68
- lastMessageSyncAt: Date.now(),
69
- messageCount: 0,
70
- lastSyncUid: 0,
71
- highWaterMarkUid: 0,
72
- };
73
- assert.equal(deriveMailboxPhase(mailbox), "complete");
74
- });
75
-
76
- it("returns syncing when a batch was written after the completion marker", () => {
77
- const now = Date.now();
78
- const mailbox: MailboxSyncFields = {
79
- lastMessageSyncAt: now,
80
- initialSyncCompletedAt: now - 60_000,
81
- messageCount: 500,
82
- lastSyncUid: 150,
83
- highWaterMarkUid: 500,
84
- };
85
- assert.equal(deriveMailboxPhase(mailbox), "syncing");
86
- });
87
-
88
- it("returns syncing when backfill is in progress and no marker is set", () => {
89
- const mailbox: MailboxSyncFields = {
90
- lastMessageSyncAt: Date.now(),
91
- messageCount: 500,
92
- lastSyncUid: 150,
93
- highWaterMarkUid: 500,
94
- };
95
- assert.equal(deriveMailboxPhase(mailbox), "syncing");
96
- });
97
- });
98
-
99
- // ─── computeMessagesSynced tests ───────────────────────────────────────────
100
-
101
- describe("computeMessagesSynced", () => {
102
- it("returns 0 when no sync has started (highWaterMarkUid = 0)", () => {
103
- const mailbox: MailboxSyncFields = {
104
- lastMessageSyncAt: 0,
105
- messageCount: 100,
106
- lastSyncUid: 0,
107
- highWaterMarkUid: 0,
108
- };
109
- assert.equal(computeMessagesSynced(mailbox), 0);
110
- });
111
-
112
- it("computes UID range approximation", () => {
113
- // highWaterMarkUid - lastSyncUid + 1 = 200 - 150 + 1 = 51
114
- const mailbox: MailboxSyncFields = {
115
- lastMessageSyncAt: Date.now(),
116
- messageCount: 200,
117
- lastSyncUid: 150,
118
- highWaterMarkUid: 200,
119
- };
120
- assert.equal(computeMessagesSynced(mailbox), 51);
121
- });
122
-
123
- it("clamps result to messagesTotal", () => {
124
- // UID range would be 200 - 1 + 1 = 200; total is 180 (sparse UIDs)
125
- const mailbox: MailboxSyncFields = {
126
- lastMessageSyncAt: Date.now(),
127
- messageCount: 180,
128
- lastSyncUid: 1,
129
- highWaterMarkUid: 200,
130
- };
131
- assert.equal(
132
- computeMessagesSynced(mailbox),
133
- 180,
134
- "should clamp to messagesTotal",
135
- );
136
- });
137
-
138
- it("never returns negative value", () => {
139
- const mailbox: MailboxSyncFields = {
140
- lastMessageSyncAt: Date.now(),
141
- messageCount: 0,
142
- lastSyncUid: 100,
143
- highWaterMarkUid: 50,
144
- };
145
- assert.ok(computeMessagesSynced(mailbox) >= 0, "should never be negative");
146
- });
147
-
148
- it("returns messagesTotal when fully synced (backfill reached the oldest UID)", () => {
149
- // Fully synced: lastSyncUid settled at the smallest real UID (42).
150
- // Range 100 - 42 + 1 = 59, clamped to the 50 actual messages.
151
- const mailbox: MailboxSyncFields = {
152
- lastMessageSyncAt: Date.now(),
153
- initialSyncCompletedAt: Date.now(),
154
- messageCount: 50,
155
- lastSyncUid: 42,
156
- highWaterMarkUid: 100,
157
- };
158
- assert.equal(computeMessagesSynced(mailbox), 50);
159
- });
160
- });
161
-
162
- // ─── toMailboxSyncProgress tests ────────────────────────────────────────────
163
-
164
- const mailboxRow = (
165
- overrides: Partial<MailboxProgressFields> = {},
166
- ): MailboxProgressFields => ({
167
- mailboxId: "mb-1",
168
- fullPath: "INBOX",
169
- lastMessageSyncAt: 1_700_000_000_000,
170
- initialSyncCompletedAt: 1_700_000_000_001,
171
- messageCount: 10,
172
- lastSyncUid: 1,
173
- highWaterMarkUid: 100,
174
- ...overrides,
175
- });
176
-
177
- describe("toMailboxSyncProgress", () => {
178
- // Issue #771: the client cannot tell an arrival from a departure by count —
179
- // one message in and one out nets to zero. The highest UID only ever moves
180
- // up, so the projection has to carry it or the arrival stays invisible.
181
- it("carries the mailbox's highest UID", () => {
182
- assert.equal(
183
- toMailboxSyncProgress(mailboxRow({ highWaterMarkUid: 4321 }))
184
- .highWaterMarkUid,
185
- 4321,
186
- );
187
- });
188
-
189
- it("reports the sentinel 0 for a mailbox that has never taken a message", () => {
190
- assert.equal(
191
- toMailboxSyncProgress(mailboxRow({ highWaterMarkUid: 0 }))
192
- .highWaterMarkUid,
193
- 0,
194
- );
195
- });
196
-
197
- it("projects identity, phase and totals alongside it", () => {
198
- const progress = toMailboxSyncProgress(mailboxRow());
199
- assert.equal(progress.mailboxId, "mb-1");
200
- assert.equal(progress.fullPath, "INBOX");
201
- assert.equal(progress.phase, "complete");
202
- assert.equal(progress.messagesTotal, 10);
203
- assert.equal(progress.lastSyncedAt, 1_700_000_000_000);
204
- });
205
- });
@@ -1,99 +0,0 @@
1
- import type { MailboxItem } from "@remit/data-ports";
2
-
3
- /** The subset of mailbox fields needed to derive sync progress. */
4
- export type MailboxSyncFields = Pick<
5
- MailboxItem,
6
- | "lastMessageSyncAt"
7
- | "initialSyncCompletedAt"
8
- | "messageCount"
9
- | "lastSyncUid"
10
- | "highWaterMarkUid"
11
- >;
12
-
13
- /** A mailbox row, as far as the sync-status projection reads it. */
14
- export type MailboxProgressFields = MailboxSyncFields &
15
- Pick<MailboxItem, "mailboxId" | "fullPath">;
16
-
17
- /** The per-mailbox entry `getSyncStatus` returns. */
18
- export interface MailboxSyncProgress {
19
- mailboxId: string;
20
- fullPath: string;
21
- phase: string;
22
- messagesTotal: number;
23
- messagesSynced: number;
24
- highWaterMarkUid: number;
25
- lastSyncedAt: number | undefined;
26
- }
27
-
28
- /**
29
- * Derive a per-mailbox sync phase from worker-written sync state.
30
- *
31
- * - pending: never synced (lastMessageSyncAt is 0 / unset)
32
- * - complete: the imap-worker drained the mailbox and stamped
33
- * initialSyncCompletedAt after the last batch write
34
- * (initialSyncCompletedAt >= lastMessageSyncAt), or the
35
- * mailbox is empty
36
- * - syncing: sync has started but is not yet complete
37
- *
38
- * Note: UID watermarks alone cannot express completion — backfill settles
39
- * at the mailbox's smallest real UID (UIDs are sparse), so the completion
40
- * marker written by the worker is the source of truth.
41
- */
42
- export const deriveMailboxPhase = (mailbox: MailboxSyncFields): string => {
43
- if (!mailbox.lastMessageSyncAt) return "pending";
44
- const messageCount = mailbox.messageCount ?? 0;
45
- const initialSyncCompletedAt = mailbox.initialSyncCompletedAt ?? 0;
46
-
47
- // Completion marker is fresh: no batch was written after the worker
48
- // recorded the mailbox as drained.
49
- if (
50
- initialSyncCompletedAt > 0 &&
51
- initialSyncCompletedAt >= mailbox.lastMessageSyncAt
52
- ) {
53
- return "complete";
54
- }
55
-
56
- // Also complete if messageCount is 0 (empty mailbox)
57
- if (messageCount === 0) return "complete";
58
-
59
- return "syncing";
60
- };
61
-
62
- /**
63
- * Compute approximate messagesSynced from UID watermarks.
64
- *
65
- * UIDs are sparse (not necessarily contiguous), so this is an approximation.
66
- * We clamp the result to [0, messagesTotal].
67
- */
68
- export const computeMessagesSynced = (mailbox: MailboxSyncFields): number => {
69
- const messagesTotal = mailbox.messageCount ?? 0;
70
- const lastSyncUid = mailbox.lastSyncUid ?? 0;
71
- const highWaterMarkUid = mailbox.highWaterMarkUid ?? 0;
72
-
73
- if (highWaterMarkUid === 0) return 0;
74
-
75
- // UID range approximation: number of UIDs in the synced range
76
- const synced = highWaterMarkUid - lastSyncUid + 1;
77
-
78
- return Math.min(Math.max(synced, 0), messagesTotal);
79
- };
80
-
81
- /**
82
- * Project a mailbox row into its sync-status entry.
83
- *
84
- * `highWaterMarkUid` is carried through because a total cannot tell an arrival
85
- * from a departure: a message landing in the same sync round that another one
86
- * leaves nets to zero, and the client's freshness check saw nothing (#771). The
87
- * highest UID only ever moves up, so it names the arrival on its own.
88
- */
89
- export const toMailboxSyncProgress = (
90
- mailbox: MailboxProgressFields,
91
- ): MailboxSyncProgress => ({
92
- mailboxId: mailbox.mailboxId,
93
- fullPath: mailbox.fullPath,
94
- phase: deriveMailboxPhase(mailbox),
95
- messagesTotal: mailbox.messageCount ?? 0,
96
- messagesSynced: computeMessagesSynced(mailbox),
97
- highWaterMarkUid: mailbox.highWaterMarkUid ?? 0,
98
- lastSyncedAt: mailbox.lastMessageSyncAt || undefined,
99
- });
@@ -1,168 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
4
- import { triggerSyncSafe } from "./sync.js";
5
-
6
- const QUEUE_URL = "http://localhost:9324/000000000000/remit-dev-mailboxes";
7
-
8
- interface LogCall {
9
- fields: Record<string, unknown>;
10
- message: string;
11
- }
12
-
13
- const createLoggerSpy = () => {
14
- const info: LogCall[] = [];
15
- const error: LogCall[] = [];
16
- const logger = {
17
- info: (fields: Record<string, unknown>, message: string): void => {
18
- info.push({ fields, message });
19
- },
20
- error: (fields: Record<string, unknown>, message: string): void => {
21
- error.push({ fields, message });
22
- },
23
- };
24
- return { info, error, logger };
25
- };
26
-
27
- const okSqsClient = (sent: SendMessageCommand[]): SQSClient =>
28
- ({
29
- send: async (cmd: SendMessageCommand) => {
30
- sent.push(cmd);
31
- return {};
32
- },
33
- }) as unknown as SQSClient;
34
-
35
- /**
36
- * Reproduces the real smoke failure shape: the SQS send does NOT reject inline —
37
- * it rejects on a later microtask/timer, the way a socket ECONNREFUSED settles
38
- * after the AWS SDK's connect + retries. A try/catch that only guards the
39
- * synchronous call would miss it; only awaiting the actual promise contains it.
40
- */
41
- const deferredFailingSqsClient = (error: Error): SQSClient =>
42
- ({
43
- send: () =>
44
- new Promise((_resolve, reject) => {
45
- setImmediate(() => reject(error));
46
- }),
47
- }) as unknown as SQSClient;
48
-
49
- describe("triggerSyncSafe", () => {
50
- it("enqueues a SYNC_MAILBOXES event and logs success", async () => {
51
- const sent: SendMessageCommand[] = [];
52
- const { logger, info, error } = createLoggerSpy();
53
-
54
- await triggerSyncSafe("acc-a", "config-1", {
55
- sqsClient: okSqsClient(sent),
56
- queueUrl: QUEUE_URL,
57
- logger,
58
- });
59
-
60
- assert.equal(sent.length, 1);
61
- assert.equal(info.length, 1);
62
- assert.equal(error.length, 0);
63
- });
64
-
65
- // The guarantee behind issue #37: POST /sync asks for a sync of this
66
- // account by name, so the event it enqueues has to carry the mark that
67
- // makes the worker's fan-out sync every mailbox rather than skip the fresh
68
- // ones. Its callers are the refresh control, pull-to-refresh and the
69
- // client's automatic poll — the poll is kept from abusing this by the
70
- // interval floor in useStaleAccountSync, not by the server.
71
- it("marks the event as an explicit request", async () => {
72
- const sent: SendMessageCommand[] = [];
73
- const { logger } = createLoggerSpy();
74
-
75
- await triggerSyncSafe("acc-a", "config-1", {
76
- sqsClient: okSqsClient(sent),
77
- queueUrl: QUEUE_URL,
78
- logger,
79
- });
80
-
81
- const body = JSON.parse(sent[0]?.input.MessageBody ?? "{}") as {
82
- explicitRequest?: boolean;
83
- };
84
- assert.equal(body.explicitRequest, true);
85
- });
86
-
87
- it("does NOT reject when the SQS enqueue fails (POST /sync stays resilient)", async () => {
88
- const econnrefused = Object.assign(new Error(""), {
89
- name: "AggregateError",
90
- code: "ECONNREFUSED",
91
- });
92
- const { logger } = createLoggerSpy();
93
-
94
- await assert.doesNotReject(
95
- triggerSyncSafe("acc-a", "config-1", {
96
- sqsClient: deferredFailingSqsClient(econnrefused),
97
- queueUrl: QUEUE_URL,
98
- logger,
99
- }),
100
- );
101
- });
102
-
103
- it("logs the failure loudly with the alertable structured fields", async () => {
104
- const econnrefused = Object.assign(new Error(""), {
105
- name: "AggregateError",
106
- code: "ECONNREFUSED",
107
- });
108
- const { logger, error } = createLoggerSpy();
109
-
110
- await triggerSyncSafe("acc-a", "config-1", {
111
- sqsClient: deferredFailingSqsClient(econnrefused),
112
- queueUrl: QUEUE_URL,
113
- logger,
114
- });
115
-
116
- assert.equal(error.length, 1);
117
- const call = error[0];
118
- if (!call) throw new Error("expected an error log");
119
- assert.equal(call.fields.alert, "sync_trigger_failed");
120
- assert.equal(call.fields.source, "trigger_sync");
121
- assert.equal(call.fields.accountId, "acc-a");
122
- assert.equal(call.fields.accountConfigId, "config-1");
123
- assert.equal(call.fields.errorCode, "ECONNREFUSED");
124
- });
125
-
126
- // The actual smoke-storm failure mode #746/#753 missed: POST /sync void-fires
127
- // this trigger, the queue is unreachable, and a concurrent read is in flight on
128
- // the same event loop. Before containment the escaped rejection landed on that
129
- // read and 500'd it. The SQS send here rejects on a LATER tick (a real socket
130
- // error never settles inline), which is exactly why a synchronous-only guard
131
- // did not reproduce the failure. Assert nothing leaks onto the concurrent read.
132
- it("void-fired with an unreachable queue leaks NO unhandled rejection onto a concurrent read", async () => {
133
- const leaked: unknown[] = [];
134
- const onUnhandled = (reason: unknown): void => {
135
- leaked.push(reason);
136
- };
137
- process.on("unhandledRejection", onUnhandled);
138
-
139
- const econnrefused = Object.assign(new Error(""), {
140
- name: "AggregateError",
141
- code: "ECONNREFUSED",
142
- });
143
- const { logger } = createLoggerSpy();
144
-
145
- let readResolved = false;
146
- try {
147
- // Fire-and-forget exactly as SyncOperations_triggerSync does.
148
- void triggerSyncSafe("acc-a", "config-1", {
149
- sqsClient: deferredFailingSqsClient(econnrefused),
150
- queueUrl: QUEUE_URL,
151
- logger,
152
- });
153
-
154
- // A concurrent "read" sharing the event loop, like /mailboxes or /outbox.
155
- await new Promise((resolve) => setImmediate(resolve)).then(() => {
156
- readResolved = true;
157
- });
158
- // Drain remaining microtasks so any escaped rejection would surface.
159
- await new Promise((resolve) => setImmediate(resolve));
160
- await new Promise((resolve) => setImmediate(resolve));
161
- } finally {
162
- process.off("unhandledRejection", onUnhandled);
163
- }
164
-
165
- assert.equal(readResolved, true);
166
- assert.equal(leaked.length, 0);
167
- });
168
- });
@@ -1,118 +0,0 @@
1
- import type { SQSClient } from "@aws-sdk/client-sqs";
2
- import { logger } from "@remit/logger-lambda";
3
- import type { APIGatewayProxyEvent } from "aws-lambda";
4
- import { env } from "expect-env";
5
- import { getAccountConfigIdFromEvent } from "../auth.js";
6
- import { getClient } from "../service/data-client.js";
7
- import {
8
- type FireAndForgetLogger,
9
- fireAndForget,
10
- } from "../service/fire-and-forget.js";
11
- import { sqsClient } from "../service/sqs.js";
12
- import { triggerAccountSync } from "../service/trigger-sync.js";
13
- import type { OperationHandler, SyncOperationIds } from "../types.js";
14
- import { assertAccountOwnership } from "./account-ownership.js";
15
- import { toMailboxSyncProgress } from "./sync-progress.js";
16
-
17
- interface SyncTriggerDeps {
18
- sqsClient: SQSClient;
19
- queueUrl: string;
20
- logger: FireAndForgetLogger & {
21
- info: (fields: Record<string, unknown>, message: string) => void;
22
- };
23
- }
24
-
25
- const defaultSyncTriggerDeps = (): SyncTriggerDeps => ({
26
- sqsClient,
27
- queueUrl: env.SQS_QUEUE_URL,
28
- logger,
29
- });
30
-
31
- /**
32
- * Fire-and-forget enqueue for the POST /sync kick.
33
- *
34
- * POST /sync is a best-effort nudge: the web client polls it to wake a sync and
35
- * acts on no enqueue result in the response. So the enqueue must never fail —
36
- * nor leak onto — this request or any concurrent read when the queue is
37
- * unreachable. Previously this handler `await`ed the SQS send directly; with the
38
- * queue down (smoke/e2e, or an SQS outage in prod) the awaited rejection escaped
39
- * the handler's promise and landed on whatever read was in flight on the shared
40
- * event loop, 500-ing unrelated `/mailboxes`, `/threads` and `/outbox` reads.
41
- *
42
- * Routing it through fireAndForget catches the rejection at the source, logs it
43
- * loudly with the alertable structured fields, and resolves to void. A genuine
44
- * account-lookup / ownership failure still propagates and 500s as normal.
45
- */
46
- export const triggerSyncSafe = async (
47
- accountId: string,
48
- accountConfigId: string,
49
- deps: SyncTriggerDeps = defaultSyncTriggerDeps(),
50
- ): Promise<void> => {
51
- await fireAndForget(
52
- async () => {
53
- const { eventId } = await triggerAccountSync({
54
- sqsClient: deps.sqsClient,
55
- queueUrl: deps.queueUrl,
56
- accountId,
57
- // POST /sync asks for a sync of this account by name — the refresh
58
- // control, pull-to-refresh, and the client's automatic poll all land
59
- // here — so it syncs every mailbox regardless of how recently one
60
- // ran.
61
- explicitRequest: true,
62
- });
63
- deps.logger.info(
64
- { accountId, eventId },
65
- "Sync triggered - enqueued SYNC_MAILBOXES event",
66
- );
67
- },
68
- {
69
- source: "trigger_sync",
70
- message: "Failed to enqueue SYNC_MAILBOXES on POST /sync (best-effort)",
71
- ids: { accountId, accountConfigId },
72
- logger: deps.logger,
73
- },
74
- );
75
- };
76
-
77
- export const SyncOperations: Record<
78
- SyncOperationIds,
79
- OperationHandler<SyncOperationIds>
80
- > = {
81
- SyncOperations_triggerSync: async (context, ...args: unknown[]) => {
82
- const event = args[0] as APIGatewayProxyEvent;
83
- const accountConfigId = getAccountConfigIdFromEvent(event);
84
- const { accountId } = context.request.params as { accountId: string };
85
-
86
- const account = await (await getClient()).account.get(accountId);
87
- assertAccountOwnership(account, accountConfigId, "act");
88
-
89
- void triggerSyncSafe(account.accountId, accountConfigId);
90
-
91
- return {
92
- triggered: true,
93
- message: `Sync triggered for account ${accountId}`,
94
- };
95
- },
96
-
97
- SyncOperations_getSyncStatus: async (context, ...args: unknown[]) => {
98
- const event = args[0] as APIGatewayProxyEvent;
99
- const accountConfigId = getAccountConfigIdFromEvent(event);
100
- const { accountId } = context.request.params as { accountId: string };
101
-
102
- const client = await getClient();
103
- const account = await client.account.get(accountId);
104
- assertAccountOwnership(account, accountConfigId, "read");
105
-
106
- // Collect all mailboxes (read-only — does not write or contend with sync lock)
107
- const mailboxes = await client.mailbox.listAllByAccount(accountId);
108
-
109
- return {
110
- accountId: account.accountId,
111
- syncPhase: account.syncPhase,
112
- mailboxCountTotal: account.mailboxCountTotal,
113
- mailboxCountSynced: account.mailboxCountSynced,
114
- lastSyncAt: account.lastSyncAt,
115
- mailboxes: mailboxes.map(toMailboxSyncProgress),
116
- };
117
- },
118
- };