@remit/backend 0.0.1
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/.env.test +7 -0
- package/dev-server/content-auth.test.ts +113 -0
- package/dev-server/content-auth.ts +54 -0
- package/dev-server/content-handler.test.ts +105 -0
- package/dev-server/content-handler.ts +79 -0
- package/dev-server/content-path.test.ts +37 -0
- package/dev-server/content-path.ts +27 -0
- package/dev-server/cors.test.ts +48 -0
- package/dev-server/cors.ts +25 -0
- package/dev-server/lambda-helpers.ts +69 -0
- package/dev-server/server.ts +289 -0
- package/package.json +82 -0
- package/src/auth.ts +92 -0
- package/src/config/msoauth.ts +60 -0
- package/src/data-backend.test.ts +25 -0
- package/src/data-backend.ts +20 -0
- package/src/derive/autoMoved.ts +28 -0
- package/src/derive/contentSignature.test.ts +153 -0
- package/src/derive/contentSignature.ts +139 -0
- package/src/derive/contentUrl.test.ts +156 -0
- package/src/derive/contentUrl.ts +70 -0
- package/src/derive/enrichThreadRows.ts +155 -0
- package/src/derive/filterThreadCriteria.test.ts +119 -0
- package/src/derive/filterThreadCriteria.ts +60 -0
- package/src/derive/pendingMoveCounts.ts +90 -0
- package/src/derive/senderTrust.test.ts +67 -0
- package/src/derive/senderTrust.ts +23 -0
- package/src/error.ts +55 -0
- package/src/handlers/account-guards.ts +137 -0
- package/src/handlers/account-oauth.test.ts +383 -0
- package/src/handlers/account-oauth.ts +452 -0
- package/src/handlers/account-overrides.test.ts +69 -0
- package/src/handlers/account-overrides.ts +286 -0
- package/src/handlers/account-ownership.ts +25 -0
- package/src/handlers/account-signature.ts +129 -0
- package/src/handlers/account.ts +524 -0
- package/src/handlers/address.ts +136 -0
- package/src/handlers/config.test.ts +184 -0
- package/src/handlers/config.ts +219 -0
- package/src/handlers/ensure-account-config.ts +30 -0
- package/src/handlers/filter.ts +294 -0
- package/src/handlers/folder-role-appointments.test.ts +250 -0
- package/src/handlers/folder-role-appointments.ts +272 -0
- package/src/handlers/folder-role.ts +76 -0
- package/src/handlers/index.ts +48 -0
- package/src/handlers/mailbox.ts +411 -0
- package/src/handlers/me.ts +190 -0
- package/src/handlers/message.ts +886 -0
- package/src/handlers/organize.test.ts +43 -0
- package/src/handlers/organize.ts +196 -0
- package/src/handlers/outbox.ts +218 -0
- package/src/handlers/search.test.ts +182 -0
- package/src/handlers/search.ts +105 -0
- package/src/handlers/sync-progress.test.ts +158 -0
- package/src/handlers/sync-progress.ts +64 -0
- package/src/handlers/sync.test.ts +146 -0
- package/src/handlers/sync.ts +119 -0
- package/src/handlers/thread.ts +361 -0
- package/src/handlers/unified-threads.ts +196 -0
- package/src/handlers/vip-suggestions.ts +21 -0
- package/src/index.ts +170 -0
- package/src/json.ts +11 -0
- package/src/jwt-auth.test.ts +89 -0
- package/src/jwt-auth.ts +95 -0
- package/src/request-context.test.ts +61 -0
- package/src/request-context.ts +29 -0
- package/src/request.ts +10 -0
- package/src/response.test.ts +107 -0
- package/src/response.ts +80 -0
- package/src/service/compose-postgres.ts +72 -0
- package/src/service/compose-sqlite.ts +80 -0
- package/src/service/create-remit-client.ts +358 -0
- package/src/service/dynamodb.test.ts +100 -0
- package/src/service/dynamodb.ts +58 -0
- package/src/service/filter.ts +55 -0
- package/src/service/fire-and-forget.test.ts +126 -0
- package/src/service/fire-and-forget.ts +79 -0
- package/src/service/localhost-env-config.test.ts +40 -0
- package/src/service/organize.test.ts +384 -0
- package/src/service/organize.ts +354 -0
- package/src/service/semantic-capability.test.ts +64 -0
- package/src/service/semantic-capability.ts +62 -0
- package/src/service/sqs.ts +4 -0
- package/src/service/trigger-sync.test.ts +211 -0
- package/src/service/trigger-sync.ts +102 -0
- package/src/types.ts +161 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MessageCategory,
|
|
3
|
+
SemanticSearchResult,
|
|
4
|
+
} from "@remit/api-openapi-types";
|
|
5
|
+
import type { SearchResult } from "@remit/search-service";
|
|
6
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
7
|
+
import type { Context } from "openapi-backend";
|
|
8
|
+
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
9
|
+
import { getClient } from "../service/dynamodb.js";
|
|
10
|
+
import {
|
|
11
|
+
isSemanticSearchUnavailable,
|
|
12
|
+
noteSemanticCapabilityAbsence,
|
|
13
|
+
} from "../service/semantic-capability.js";
|
|
14
|
+
import type { OperationHandler, SemanticSearchOperationIds } from "../types.js";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_LIMIT = 25;
|
|
17
|
+
|
|
18
|
+
const toResponse = (item: SearchResult): SemanticSearchResult => {
|
|
19
|
+
const result: SemanticSearchResult = {
|
|
20
|
+
messageId: item.messageId,
|
|
21
|
+
threadId: item.threadId,
|
|
22
|
+
score: item.score,
|
|
23
|
+
matchedChunkType: item.matchedChunkType,
|
|
24
|
+
mailboxIds: item.mailboxIds,
|
|
25
|
+
sentDate: item.sentDate,
|
|
26
|
+
};
|
|
27
|
+
// fromName is null for messages with no sender name; exclude undefined (absent from old vectors)
|
|
28
|
+
if (item.fromName !== undefined) {
|
|
29
|
+
result.fromName = item.fromName ?? undefined;
|
|
30
|
+
}
|
|
31
|
+
if (item.subject !== undefined) {
|
|
32
|
+
result.subject = item.subject;
|
|
33
|
+
}
|
|
34
|
+
if (item.category !== undefined) {
|
|
35
|
+
result.category = item.category;
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const SemanticSearchOperations: Record<
|
|
41
|
+
SemanticSearchOperationIds,
|
|
42
|
+
OperationHandler<SemanticSearchOperationIds>
|
|
43
|
+
> = {
|
|
44
|
+
SemanticSearchOperations_semanticSearch: async (
|
|
45
|
+
context: Context,
|
|
46
|
+
...args: unknown[]
|
|
47
|
+
) => {
|
|
48
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
49
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
50
|
+
const {
|
|
51
|
+
query,
|
|
52
|
+
mailboxId,
|
|
53
|
+
sentDateFrom,
|
|
54
|
+
sentDateTo,
|
|
55
|
+
hasAttachment,
|
|
56
|
+
hasStars,
|
|
57
|
+
isRead,
|
|
58
|
+
category,
|
|
59
|
+
limit,
|
|
60
|
+
} = context.request.query as {
|
|
61
|
+
query: string;
|
|
62
|
+
mailboxId?: string;
|
|
63
|
+
sentDateFrom?: number;
|
|
64
|
+
sentDateTo?: number;
|
|
65
|
+
hasAttachment?: boolean;
|
|
66
|
+
hasStars?: boolean;
|
|
67
|
+
isRead?: boolean;
|
|
68
|
+
category?: MessageCategory;
|
|
69
|
+
limit?: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const sentDateRange =
|
|
73
|
+
sentDateFrom !== undefined || sentDateTo !== undefined
|
|
74
|
+
? { from: sentDateFrom, to: sentDateTo }
|
|
75
|
+
: undefined;
|
|
76
|
+
|
|
77
|
+
// Capability gate for the self-host compose profiles, whose backend
|
|
78
|
+
// image cannot run the semantic pipeline — see semantic-capability.ts.
|
|
79
|
+
if (isSemanticSearchUnavailable()) {
|
|
80
|
+
return { items: [] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let results: SearchResult[];
|
|
84
|
+
try {
|
|
85
|
+
results = await (await getClient()).search.search({
|
|
86
|
+
query,
|
|
87
|
+
accountConfigId,
|
|
88
|
+
mailboxId,
|
|
89
|
+
sentDateRange,
|
|
90
|
+
hasAttachment,
|
|
91
|
+
hasStars,
|
|
92
|
+
isRead,
|
|
93
|
+
category,
|
|
94
|
+
limit: limit ?? DEFAULT_LIMIT,
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (!noteSemanticCapabilityAbsence(error)) throw error;
|
|
98
|
+
return { items: [] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
items: results.map(toResponse),
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
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 MailboxSyncFields,
|
|
13
|
+
} from "./sync-progress.js";
|
|
14
|
+
|
|
15
|
+
// ─── deriveMailboxPhase tests ───────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
describe("deriveMailboxPhase", () => {
|
|
18
|
+
it("returns pending when lastMessageSyncAt is 0", () => {
|
|
19
|
+
const mailbox: MailboxSyncFields = {
|
|
20
|
+
lastMessageSyncAt: 0,
|
|
21
|
+
messageCount: 100,
|
|
22
|
+
lastSyncUid: 0,
|
|
23
|
+
highWaterMarkUid: 0,
|
|
24
|
+
};
|
|
25
|
+
assert.equal(deriveMailboxPhase(mailbox), "pending");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns pending even when watermarks are set but lastMessageSyncAt is 0", () => {
|
|
29
|
+
const mailbox: MailboxSyncFields = {
|
|
30
|
+
lastMessageSyncAt: 0,
|
|
31
|
+
messageCount: 50,
|
|
32
|
+
lastSyncUid: 50,
|
|
33
|
+
highWaterMarkUid: 100,
|
|
34
|
+
};
|
|
35
|
+
assert.equal(deriveMailboxPhase(mailbox), "pending");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns complete when the worker stamped initialSyncCompletedAt after the last batch", () => {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const mailbox: MailboxSyncFields = {
|
|
41
|
+
lastMessageSyncAt: now - 1000,
|
|
42
|
+
initialSyncCompletedAt: now,
|
|
43
|
+
messageCount: 200,
|
|
44
|
+
// Backfill settles at the smallest real UID (sparse UIDs) — the
|
|
45
|
+
// marker, not the watermark, decides completion.
|
|
46
|
+
lastSyncUid: 42,
|
|
47
|
+
highWaterMarkUid: 200,
|
|
48
|
+
};
|
|
49
|
+
assert.equal(deriveMailboxPhase(mailbox), "complete");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns complete when marker equals lastMessageSyncAt (same-millisecond writes)", () => {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const mailbox: MailboxSyncFields = {
|
|
55
|
+
lastMessageSyncAt: now,
|
|
56
|
+
initialSyncCompletedAt: now,
|
|
57
|
+
messageCount: 10,
|
|
58
|
+
lastSyncUid: 5,
|
|
59
|
+
highWaterMarkUid: 20,
|
|
60
|
+
};
|
|
61
|
+
assert.equal(deriveMailboxPhase(mailbox), "complete");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns complete for empty mailbox (messageCount 0) without a marker", () => {
|
|
65
|
+
const mailbox: MailboxSyncFields = {
|
|
66
|
+
lastMessageSyncAt: Date.now(),
|
|
67
|
+
messageCount: 0,
|
|
68
|
+
lastSyncUid: 0,
|
|
69
|
+
highWaterMarkUid: 0,
|
|
70
|
+
};
|
|
71
|
+
assert.equal(deriveMailboxPhase(mailbox), "complete");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns syncing when a batch was written after the completion marker", () => {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const mailbox: MailboxSyncFields = {
|
|
77
|
+
lastMessageSyncAt: now,
|
|
78
|
+
initialSyncCompletedAt: now - 60_000,
|
|
79
|
+
messageCount: 500,
|
|
80
|
+
lastSyncUid: 150,
|
|
81
|
+
highWaterMarkUid: 500,
|
|
82
|
+
};
|
|
83
|
+
assert.equal(deriveMailboxPhase(mailbox), "syncing");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns syncing when backfill is in progress and no marker is set", () => {
|
|
87
|
+
const mailbox: MailboxSyncFields = {
|
|
88
|
+
lastMessageSyncAt: Date.now(),
|
|
89
|
+
messageCount: 500,
|
|
90
|
+
lastSyncUid: 150,
|
|
91
|
+
highWaterMarkUid: 500,
|
|
92
|
+
};
|
|
93
|
+
assert.equal(deriveMailboxPhase(mailbox), "syncing");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ─── computeMessagesSynced tests ───────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe("computeMessagesSynced", () => {
|
|
100
|
+
it("returns 0 when no sync has started (highWaterMarkUid = 0)", () => {
|
|
101
|
+
const mailbox: MailboxSyncFields = {
|
|
102
|
+
lastMessageSyncAt: 0,
|
|
103
|
+
messageCount: 100,
|
|
104
|
+
lastSyncUid: 0,
|
|
105
|
+
highWaterMarkUid: 0,
|
|
106
|
+
};
|
|
107
|
+
assert.equal(computeMessagesSynced(mailbox), 0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("computes UID range approximation", () => {
|
|
111
|
+
// highWaterMarkUid - lastSyncUid + 1 = 200 - 150 + 1 = 51
|
|
112
|
+
const mailbox: MailboxSyncFields = {
|
|
113
|
+
lastMessageSyncAt: Date.now(),
|
|
114
|
+
messageCount: 200,
|
|
115
|
+
lastSyncUid: 150,
|
|
116
|
+
highWaterMarkUid: 200,
|
|
117
|
+
};
|
|
118
|
+
assert.equal(computeMessagesSynced(mailbox), 51);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("clamps result to messagesTotal", () => {
|
|
122
|
+
// UID range would be 200 - 1 + 1 = 200; total is 180 (sparse UIDs)
|
|
123
|
+
const mailbox: MailboxSyncFields = {
|
|
124
|
+
lastMessageSyncAt: Date.now(),
|
|
125
|
+
messageCount: 180,
|
|
126
|
+
lastSyncUid: 1,
|
|
127
|
+
highWaterMarkUid: 200,
|
|
128
|
+
};
|
|
129
|
+
assert.equal(
|
|
130
|
+
computeMessagesSynced(mailbox),
|
|
131
|
+
180,
|
|
132
|
+
"should clamp to messagesTotal",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("never returns negative value", () => {
|
|
137
|
+
const mailbox: MailboxSyncFields = {
|
|
138
|
+
lastMessageSyncAt: Date.now(),
|
|
139
|
+
messageCount: 0,
|
|
140
|
+
lastSyncUid: 100,
|
|
141
|
+
highWaterMarkUid: 50,
|
|
142
|
+
};
|
|
143
|
+
assert.ok(computeMessagesSynced(mailbox) >= 0, "should never be negative");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns messagesTotal when fully synced (backfill reached the oldest UID)", () => {
|
|
147
|
+
// Fully synced: lastSyncUid settled at the smallest real UID (42).
|
|
148
|
+
// Range 100 - 42 + 1 = 59, clamped to the 50 actual messages.
|
|
149
|
+
const mailbox: MailboxSyncFields = {
|
|
150
|
+
lastMessageSyncAt: Date.now(),
|
|
151
|
+
initialSyncCompletedAt: Date.now(),
|
|
152
|
+
messageCount: 50,
|
|
153
|
+
lastSyncUid: 42,
|
|
154
|
+
highWaterMarkUid: 100,
|
|
155
|
+
};
|
|
156
|
+
assert.equal(computeMessagesSynced(mailbox), 50);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
/**
|
|
14
|
+
* Derive a per-mailbox sync phase from worker-written sync state.
|
|
15
|
+
*
|
|
16
|
+
* - pending: never synced (lastMessageSyncAt is 0 / unset)
|
|
17
|
+
* - complete: the imap-worker drained the mailbox and stamped
|
|
18
|
+
* initialSyncCompletedAt after the last batch write
|
|
19
|
+
* (initialSyncCompletedAt >= lastMessageSyncAt), or the
|
|
20
|
+
* mailbox is empty
|
|
21
|
+
* - syncing: sync has started but is not yet complete
|
|
22
|
+
*
|
|
23
|
+
* Note: UID watermarks alone cannot express completion — backfill settles
|
|
24
|
+
* at the mailbox's smallest real UID (UIDs are sparse), so the completion
|
|
25
|
+
* marker written by the worker is the source of truth.
|
|
26
|
+
*/
|
|
27
|
+
export const deriveMailboxPhase = (mailbox: MailboxSyncFields): string => {
|
|
28
|
+
if (!mailbox.lastMessageSyncAt) return "pending";
|
|
29
|
+
const messageCount = mailbox.messageCount ?? 0;
|
|
30
|
+
const initialSyncCompletedAt = mailbox.initialSyncCompletedAt ?? 0;
|
|
31
|
+
|
|
32
|
+
// Completion marker is fresh: no batch was written after the worker
|
|
33
|
+
// recorded the mailbox as drained.
|
|
34
|
+
if (
|
|
35
|
+
initialSyncCompletedAt > 0 &&
|
|
36
|
+
initialSyncCompletedAt >= mailbox.lastMessageSyncAt
|
|
37
|
+
) {
|
|
38
|
+
return "complete";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Also complete if messageCount is 0 (empty mailbox)
|
|
42
|
+
if (messageCount === 0) return "complete";
|
|
43
|
+
|
|
44
|
+
return "syncing";
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compute approximate messagesSynced from UID watermarks.
|
|
49
|
+
*
|
|
50
|
+
* UIDs are sparse (not necessarily contiguous), so this is an approximation.
|
|
51
|
+
* We clamp the result to [0, messagesTotal].
|
|
52
|
+
*/
|
|
53
|
+
export const computeMessagesSynced = (mailbox: MailboxSyncFields): number => {
|
|
54
|
+
const messagesTotal = mailbox.messageCount ?? 0;
|
|
55
|
+
const lastSyncUid = mailbox.lastSyncUid ?? 0;
|
|
56
|
+
const highWaterMarkUid = mailbox.highWaterMarkUid ?? 0;
|
|
57
|
+
|
|
58
|
+
if (highWaterMarkUid === 0) return 0;
|
|
59
|
+
|
|
60
|
+
// UID range approximation: number of UIDs in the synced range
|
|
61
|
+
const synced = highWaterMarkUid - lastSyncUid + 1;
|
|
62
|
+
|
|
63
|
+
return Math.min(Math.max(synced, 0), messagesTotal);
|
|
64
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
it("does NOT reject when the SQS enqueue fails (POST /sync stays resilient)", async () => {
|
|
66
|
+
const econnrefused = Object.assign(new Error(""), {
|
|
67
|
+
name: "AggregateError",
|
|
68
|
+
code: "ECONNREFUSED",
|
|
69
|
+
});
|
|
70
|
+
const { logger } = createLoggerSpy();
|
|
71
|
+
|
|
72
|
+
await assert.doesNotReject(
|
|
73
|
+
triggerSyncSafe("acc-a", "config-1", {
|
|
74
|
+
sqsClient: deferredFailingSqsClient(econnrefused),
|
|
75
|
+
queueUrl: QUEUE_URL,
|
|
76
|
+
logger,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("logs the failure loudly with the alertable structured fields", async () => {
|
|
82
|
+
const econnrefused = Object.assign(new Error(""), {
|
|
83
|
+
name: "AggregateError",
|
|
84
|
+
code: "ECONNREFUSED",
|
|
85
|
+
});
|
|
86
|
+
const { logger, error } = createLoggerSpy();
|
|
87
|
+
|
|
88
|
+
await triggerSyncSafe("acc-a", "config-1", {
|
|
89
|
+
sqsClient: deferredFailingSqsClient(econnrefused),
|
|
90
|
+
queueUrl: QUEUE_URL,
|
|
91
|
+
logger,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
assert.equal(error.length, 1);
|
|
95
|
+
const call = error[0];
|
|
96
|
+
if (!call) throw new Error("expected an error log");
|
|
97
|
+
assert.equal(call.fields.alert, "sync_trigger_failed");
|
|
98
|
+
assert.equal(call.fields.source, "trigger_sync");
|
|
99
|
+
assert.equal(call.fields.accountId, "acc-a");
|
|
100
|
+
assert.equal(call.fields.accountConfigId, "config-1");
|
|
101
|
+
assert.equal(call.fields.errorCode, "ECONNREFUSED");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// The actual smoke-storm failure mode #746/#753 missed: POST /sync void-fires
|
|
105
|
+
// this trigger, the queue is unreachable, and a concurrent read is in flight on
|
|
106
|
+
// the same event loop. Before containment the escaped rejection landed on that
|
|
107
|
+
// read and 500'd it. The SQS send here rejects on a LATER tick (a real socket
|
|
108
|
+
// error never settles inline), which is exactly why a synchronous-only guard
|
|
109
|
+
// did not reproduce the failure. Assert nothing leaks onto the concurrent read.
|
|
110
|
+
it("void-fired with an unreachable queue leaks NO unhandled rejection onto a concurrent read", async () => {
|
|
111
|
+
const leaked: unknown[] = [];
|
|
112
|
+
const onUnhandled = (reason: unknown): void => {
|
|
113
|
+
leaked.push(reason);
|
|
114
|
+
};
|
|
115
|
+
process.on("unhandledRejection", onUnhandled);
|
|
116
|
+
|
|
117
|
+
const econnrefused = Object.assign(new Error(""), {
|
|
118
|
+
name: "AggregateError",
|
|
119
|
+
code: "ECONNREFUSED",
|
|
120
|
+
});
|
|
121
|
+
const { logger } = createLoggerSpy();
|
|
122
|
+
|
|
123
|
+
let readResolved = false;
|
|
124
|
+
try {
|
|
125
|
+
// Fire-and-forget exactly as SyncOperations_triggerSync does.
|
|
126
|
+
void triggerSyncSafe("acc-a", "config-1", {
|
|
127
|
+
sqsClient: deferredFailingSqsClient(econnrefused),
|
|
128
|
+
queueUrl: QUEUE_URL,
|
|
129
|
+
logger,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// A concurrent "read" sharing the event loop, like /mailboxes or /outbox.
|
|
133
|
+
await new Promise((resolve) => setImmediate(resolve)).then(() => {
|
|
134
|
+
readResolved = true;
|
|
135
|
+
});
|
|
136
|
+
// Drain remaining microtasks so any escaped rejection would surface.
|
|
137
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
138
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
139
|
+
} finally {
|
|
140
|
+
process.off("unhandledRejection", onUnhandled);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
assert.equal(readResolved, true);
|
|
144
|
+
assert.equal(leaked.length, 0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
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/dynamodb.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 { computeMessagesSynced, deriveMailboxPhase } 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
|
+
});
|
|
58
|
+
deps.logger.info(
|
|
59
|
+
{ accountId, eventId },
|
|
60
|
+
"Sync triggered - enqueued SYNC_MAILBOXES event",
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
source: "trigger_sync",
|
|
65
|
+
message: "Failed to enqueue SYNC_MAILBOXES on POST /sync (best-effort)",
|
|
66
|
+
ids: { accountId, accountConfigId },
|
|
67
|
+
logger: deps.logger,
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const SyncOperations: Record<
|
|
73
|
+
SyncOperationIds,
|
|
74
|
+
OperationHandler<SyncOperationIds>
|
|
75
|
+
> = {
|
|
76
|
+
SyncOperations_triggerSync: async (context, ...args: unknown[]) => {
|
|
77
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
78
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
79
|
+
const { accountId } = context.request.params as { accountId: string };
|
|
80
|
+
|
|
81
|
+
const account = await (await getClient()).account.get(accountId);
|
|
82
|
+
assertAccountOwnership(account, accountConfigId, "act");
|
|
83
|
+
|
|
84
|
+
void triggerSyncSafe(account.accountId, accountConfigId);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
triggered: true,
|
|
88
|
+
message: `Sync triggered for account ${accountId}`,
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
SyncOperations_getSyncStatus: async (context, ...args: unknown[]) => {
|
|
93
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
94
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
95
|
+
const { accountId } = context.request.params as { accountId: string };
|
|
96
|
+
|
|
97
|
+
const client = await getClient();
|
|
98
|
+
const account = await client.account.get(accountId);
|
|
99
|
+
assertAccountOwnership(account, accountConfigId, "read");
|
|
100
|
+
|
|
101
|
+
// Collect all mailboxes (read-only — does not write or contend with sync lock)
|
|
102
|
+
const mailboxes = await client.mailbox.listAllByAccount(accountId);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
accountId: account.accountId,
|
|
106
|
+
syncPhase: account.syncPhase,
|
|
107
|
+
mailboxCountTotal: account.mailboxCountTotal,
|
|
108
|
+
mailboxCountSynced: account.mailboxCountSynced,
|
|
109
|
+
mailboxes: mailboxes.map((mailbox) => ({
|
|
110
|
+
mailboxId: mailbox.mailboxId,
|
|
111
|
+
fullPath: mailbox.fullPath,
|
|
112
|
+
phase: deriveMailboxPhase(mailbox),
|
|
113
|
+
messagesTotal: mailbox.messageCount ?? 0,
|
|
114
|
+
messagesSynced: computeMessagesSynced(mailbox),
|
|
115
|
+
lastSyncedAt: mailbox.lastMessageSyncAt || undefined,
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|