@remit/backend 0.0.6 → 0.0.8

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/backend",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -41,21 +41,14 @@
41
41
  "dev:remote": "node --env-file ../../.env.dev --watch --import tsx dev-server/server.ts"
42
42
  },
43
43
  "devDependencies": {
44
- "@aws-sdk/client-secrets-manager": "*",
45
- "@aws-sdk/client-sqs": "*",
46
44
  "@aws-sdk/core": "*",
47
45
  "@remit/electrodb-entities": "*",
48
- "drizzle-orm": "^0.45.2",
49
46
  "@remit/search-index-worker": "*",
50
- "@types/aws-lambda": "*",
51
47
  "@types/express": "^5.0.3",
52
48
  "@types/swagger-ui-express": "^4.1.8",
53
49
  "electrodb": "*",
54
50
  "esbuild": "^0.27.7",
55
- "expect-env": "*",
56
51
  "express": "^5.1.0",
57
- "openapi-backend": "*",
58
- "p-map": "*",
59
52
  "swagger-ui-express": "^5.0.1",
60
53
  "tsx": "*"
61
54
  },
@@ -73,7 +66,14 @@
73
66
  "@remit/storage-service": "*",
74
67
  "@remit/auth-service": "*",
75
68
  "@remit/drizzle-service": "*",
76
- "@remit/sqs-client": "*"
69
+ "@remit/sqs-client": "*",
70
+ "@aws-sdk/client-secrets-manager": "*",
71
+ "@aws-sdk/client-sqs": "*",
72
+ "drizzle-orm": "^0.45.2",
73
+ "expect-env": "*",
74
+ "openapi-backend": "*",
75
+ "p-map": "*",
76
+ "@types/aws-lambda": "*"
77
77
  },
78
78
  "publishConfig": {
79
79
  "access": "public"
@@ -62,6 +62,28 @@ describe("triggerSyncSafe", () => {
62
62
  assert.equal(error.length, 0);
63
63
  });
64
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
+
65
87
  it("does NOT reject when the SQS enqueue fails (POST /sync stays resilient)", async () => {
66
88
  const econnrefused = Object.assign(new Error(""), {
67
89
  name: "AggregateError",
@@ -54,6 +54,13 @@ export const triggerSyncSafe = async (
54
54
  sqsClient: deps.sqsClient,
55
55
  queueUrl: deps.queueUrl,
56
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. The poll's cadence is floored client-side at the fan-out
61
+ // gate's window, so this branch cannot be driven faster than the
62
+ // gate would allow.
63
+ explicitRequest: true,
57
64
  });
58
65
  deps.logger.info(
59
66
  { accountId, eventId },
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
4
4
  import {
5
- buildManualSyncDedupId,
6
5
  buildScheduledSyncDedupId,
7
6
  buildSyncMailboxesCommand,
8
7
  triggerAccountSync,
@@ -29,20 +28,62 @@ describe("buildSyncMailboxesCommand", () => {
29
28
  assert.equal(cmd.input.MessageGroupId, "account-abc");
30
29
  });
31
30
 
32
- it("defaults MessageDeduplicationId to the time-bucketed manual dedup id for FIFO queues", () => {
31
+ it("defaults MessageDeduplicationId to the event's own id for FIFO queues", () => {
33
32
  const cmd = buildSyncMailboxesCommand({
34
33
  sqsClient: {} as SQSClient,
35
34
  queueUrl: FIFO_QUEUE_URL,
36
35
  accountId: "account-abc",
37
36
  });
38
37
 
39
- assert.ok(
40
- cmd.input.MessageDeduplicationId?.startsWith(
41
- "SYNC_MAILBOXES:manual:account-abc:",
42
- ),
38
+ assert.equal(cmd.input.MessageDeduplicationId, parseBody(cmd).eventId);
39
+ });
40
+
41
+ // Issue #37: a shared, time-bucketed dedup id let SQS FIFO's 5-minute
42
+ // window discard the second sync of an account, so mail that arrived after
43
+ // a sync stayed invisible until the window elapsed. Back-to-back triggers
44
+ // must each reach the queue.
45
+ it("gives two triggers for one account distinct dedup ids", () => {
46
+ const first = buildSyncMailboxesCommand({
47
+ sqsClient: {} as SQSClient,
48
+ queueUrl: FIFO_QUEUE_URL,
49
+ accountId: "account-abc",
50
+ });
51
+ const second = buildSyncMailboxesCommand({
52
+ sqsClient: {} as SQSClient,
53
+ queueUrl: FIFO_QUEUE_URL,
54
+ accountId: "account-abc",
55
+ });
56
+
57
+ assert.notEqual(
58
+ first.input.MessageDeduplicationId,
59
+ second.input.MessageDeduplicationId,
43
60
  );
44
61
  });
45
62
 
63
+ // The worker's fan-out gate reads this off the event: it is what lets a
64
+ // sync asked for by name cover every folder while a side-effect trigger
65
+ // skips the ones that were just enumerated.
66
+ it("marks an explicitly-requested trigger on the event", () => {
67
+ const cmd = buildSyncMailboxesCommand({
68
+ sqsClient: {} as SQSClient,
69
+ queueUrl: FIFO_QUEUE_URL,
70
+ accountId: "account-abc",
71
+ explicitRequest: true,
72
+ });
73
+
74
+ assert.equal(parseBody(cmd).explicitRequest, true);
75
+ });
76
+
77
+ it("leaves a side-effect trigger unmarked", () => {
78
+ const cmd = buildSyncMailboxesCommand({
79
+ sqsClient: {} as SQSClient,
80
+ queueUrl: FIFO_QUEUE_URL,
81
+ accountId: "account-abc",
82
+ });
83
+
84
+ assert.equal(parseBody(cmd).explicitRequest, undefined);
85
+ });
86
+
46
87
  it("does not set FIFO params for standard queues", () => {
47
88
  const cmd = buildSyncMailboxesCommand({
48
89
  sqsClient: {} as SQSClient,
@@ -93,43 +134,6 @@ describe("buildSyncMailboxesCommand", () => {
93
134
  });
94
135
  });
95
136
 
96
- describe("buildManualSyncDedupId", () => {
97
- const SIXTY_SECONDS_MS = 60 * 1000;
98
-
99
- it("is stable within the same time bucket (dedupes a rapid double-tap)", () => {
100
- const bucketStart = 10 * SIXTY_SECONDS_MS;
101
-
102
- const first = buildManualSyncDedupId("account-abc", bucketStart);
103
- const second = buildManualSyncDedupId("account-abc", bucketStart + 1_000);
104
-
105
- assert.equal(first, second);
106
- });
107
-
108
- it("changes across a bucket boundary (never dedupes two polls a minute apart)", () => {
109
- const bucketStart = 10 * SIXTY_SECONDS_MS;
110
-
111
- const thisTrigger = buildManualSyncDedupId("account-abc", bucketStart);
112
- const nextTrigger = buildManualSyncDedupId(
113
- "account-abc",
114
- bucketStart + SIXTY_SECONDS_MS,
115
- );
116
-
117
- assert.notEqual(thisTrigger, nextTrigger);
118
- });
119
-
120
- it("never collides with the scheduler's dedup namespace", () => {
121
- const now = 10 * SIXTY_SECONDS_MS;
122
- const manual = buildManualSyncDedupId("account-abc", now);
123
- const scheduled = buildScheduledSyncDedupId(
124
- "account-abc",
125
- now,
126
- SIXTY_SECONDS_MS,
127
- );
128
-
129
- assert.notEqual(manual, scheduled);
130
- });
131
- });
132
-
133
137
  describe("buildScheduledSyncDedupId", () => {
134
138
  const FIVE_MINUTES_MS = 5 * 60 * 1000;
135
139
 
@@ -6,6 +6,7 @@ interface SyncMailboxesEvent {
6
6
  eventId: string;
7
7
  timestamp: number;
8
8
  accountId: string;
9
+ explicitRequest?: boolean;
9
10
  }
10
11
 
11
12
  interface TriggerAccountSyncInput {
@@ -13,45 +14,42 @@ interface TriggerAccountSyncInput {
13
14
  queueUrl: string;
14
15
  accountId: string;
15
16
  /**
16
- * Override the FIFO `MessageDeduplicationId`. Defaults to a time-bucketed
17
- * manual-trigger id (`buildManualSyncDedupId`) shared by every manual call
18
- * site (POST /sync, OAuth connect, config load, pull-to-refresh, the
19
- * client's online-poll) so a rapid double-tap collapses into one enqueue,
20
- * while distinct triggers a bucket or more apart always pass through.
17
+ * Set by POST /sync, whose callers ask for a sync of one named account: the
18
+ * refresh control, pull-to-refresh, and the web client's automatic poll
19
+ * (`useStaleAccountSync`) a timer, not a person. It travels on the event
20
+ * and makes the worker's fan-out sync every mailbox even if one just ran.
21
21
  *
22
- * The scheduled-sync tick (#1247) must NOT share that namespace: pass
23
- * `buildScheduledSyncDedupId()` here instead, so the two paths can never
24
- * suppress each other.
22
+ * Everything else triggers a sync as a side effect of doing something else
23
+ * (config load, OAuth connect, account create, the scheduled tick), leaves
24
+ * this unset, and takes the freshness gate — see `mailboxNeedsSync` in
25
+ * imap-worker's sync-mailboxes handler.
26
+ *
27
+ * The poll cannot use this to outrun the gate: its interval is floored at
28
+ * the gate's own window (`MIN_POLL_INTERVAL_MS` in the client hook), so
29
+ * however low `mailboxPollIntervalSeconds` is set, a timer never fans out
30
+ * more often than the gate would have allowed anyway.
31
+ */
32
+ explicitRequest?: boolean;
33
+ /**
34
+ * Override the FIFO `MessageDeduplicationId`. Defaults to the event's own
35
+ * id, so the queue suppresses a re-send of one event and nothing else —
36
+ * every manual call site (POST /sync, OAuth connect, config load,
37
+ * pull-to-refresh, the client's online-poll) always enqueues. A shared,
38
+ * time-bucketed id instead turned SQS's 5-minute window into a rate limiter
39
+ * and silently discarded a sync the user asked for whenever one had run
40
+ * recently (issue #37). What a trigger costs is bounded in the worker's
41
+ * fan-out (`mailboxNeedsSync`), by skipping mailboxes not worth
42
+ * re-enumerating — never by dropping the trigger.
43
+ *
44
+ * The scheduled-sync tick (#1247) passes `buildScheduledSyncDedupId()`,
45
+ * which is bucketed by the tick's own cadence: there it collapses a
46
+ * re-invocation of a single tick, never two ticks or a manual trigger.
25
47
  */
26
48
  dedupId?: string;
27
49
  }
28
50
 
29
51
  const isFifoQueue = (queueUrl: string): boolean => queueUrl.endsWith(".fifo");
30
52
 
31
- /**
32
- * Bucket window for the manual-trigger dedup id. SQS FIFO's own dedup window
33
- * is 5 minutes; a static per-account id (the pre-#1250 behaviour) collided
34
- * with the client's 5-minute online poll and swallowed a pull-to-refresh
35
- * within 5 minutes of any prior trigger. Bucketing to ~60s keeps a rapid
36
- * double-tap deduped while letting distinct polls a minute or more apart
37
- * always enqueue.
38
- */
39
- const MANUAL_DEDUP_BUCKET_MS = 60_000;
40
-
41
- /**
42
- * Build the manual-trigger dedup id (POST /sync, OAuth connect, config load,
43
- * pull-to-refresh, client online-poll), bucketed so consecutive triggers a
44
- * bucket apart never collide — see `dedupId` above.
45
- */
46
- export const buildManualSyncDedupId = (
47
- accountId: string,
48
- now: number,
49
- bucketMs: number = MANUAL_DEDUP_BUCKET_MS,
50
- ): string => {
51
- const bucket = Math.floor(now / bucketMs);
52
- return `SYNC_MAILBOXES:manual:${accountId}:${bucket}`;
53
- };
54
-
55
53
  /**
56
54
  * Build the scheduler's own dedup namespace, bucketed by tick interval so
57
55
  * consecutive ticks each get a fresh id (never colliding with each other)
@@ -70,12 +68,13 @@ export const buildScheduledSyncDedupId = (
70
68
  export const buildSyncMailboxesCommand = (
71
69
  input: TriggerAccountSyncInput,
72
70
  ): SendMessageCommand => {
73
- const { queueUrl, accountId, dedupId } = input;
71
+ const { queueUrl, accountId, dedupId, explicitRequest } = input;
74
72
  const event: SyncMailboxesEvent = {
75
73
  type: "SYNC_MAILBOXES",
76
74
  eventId: randomUUID(),
77
75
  timestamp: Date.now(),
78
76
  accountId,
77
+ ...(explicitRequest && { explicitRequest }),
79
78
  };
80
79
 
81
80
  const useFifo = isFifoQueue(queueUrl);
@@ -85,8 +84,7 @@ export const buildSyncMailboxesCommand = (
85
84
  MessageBody: JSON.stringify(event),
86
85
  ...(useFifo && {
87
86
  MessageGroupId: accountId,
88
- MessageDeduplicationId:
89
- dedupId ?? buildManualSyncDedupId(accountId, event.timestamp),
87
+ MessageDeduplicationId: dedupId ?? event.eventId,
90
88
  }),
91
89
  });
92
90
  };