@remit/account-worker 0.0.3 → 0.0.5
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 +1 -1
- package/src/cascade.test.ts +58 -61
- package/src/config.test.ts +37 -0
- package/src/config.ts +16 -5
- package/src/handlers/account-fanout.ts +9 -12
- package/src/handlers/account-finalize.ts +1 -5
- package/src/handlers/account-purge.ts +10 -4
package/package.json
CHANGED
package/src/cascade.test.ts
CHANGED
|
@@ -12,69 +12,66 @@ const modelsDir = resolve(
|
|
|
12
12
|
);
|
|
13
13
|
|
|
14
14
|
describe("cascade entity coverage snapshot", () => {
|
|
15
|
-
it(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
()
|
|
19
|
-
|
|
20
|
-
.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
)
|
|
24
|
-
.map((f) => f.replace(".ts", ""));
|
|
15
|
+
it("covers every entity model in remit-electrodb-service", {
|
|
16
|
+
skip: !existsSync(modelsDir),
|
|
17
|
+
}, () => {
|
|
18
|
+
const modelFiles = readdirSync(modelsDir)
|
|
19
|
+
.filter(
|
|
20
|
+
(f) => f.endsWith(".ts") && !f.endsWith(".test.ts") && f !== "index.ts",
|
|
21
|
+
)
|
|
22
|
+
.map((f) => f.replace(".ts", ""));
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
24
|
+
// Map model file names to the entity types they contain
|
|
25
|
+
const entityTypesByFile: Record<string, string[]> = {
|
|
26
|
+
"account-config": ["AccountConfig"],
|
|
27
|
+
"account-export-request": [],
|
|
28
|
+
"account-setting": ["AccountSetting"],
|
|
29
|
+
"account-setting-registry": [],
|
|
30
|
+
account: ["Account"],
|
|
31
|
+
address: ["Address", "EnvelopeAddress"],
|
|
32
|
+
envelope: [
|
|
33
|
+
"Envelope",
|
|
34
|
+
"MessageReference",
|
|
35
|
+
"BodyPart",
|
|
36
|
+
"BodyPartParameter",
|
|
37
|
+
"RawMessageStorage",
|
|
38
|
+
"BodyPartStorage",
|
|
39
|
+
"BodyPartContent",
|
|
40
|
+
],
|
|
41
|
+
filter: ["Filter"],
|
|
42
|
+
"filter-anchor": ["FilterAnchor"],
|
|
43
|
+
label: ["Label"],
|
|
44
|
+
mailbox: ["Mailbox"],
|
|
45
|
+
"mailbox-lock": ["MailboxLock"],
|
|
46
|
+
"mailbox-special-use": [],
|
|
47
|
+
message: ["Message"],
|
|
48
|
+
"message-flag": ["MessageFlag"],
|
|
49
|
+
"message-flag-push": ["MessageFlagPush"],
|
|
50
|
+
"message-label": ["MessageLabel"],
|
|
51
|
+
"message-placement-move": ["MessagePlacementMove"],
|
|
52
|
+
"organize-job-request": [],
|
|
53
|
+
"outbox-message": ["OutboxMessage"],
|
|
54
|
+
"thread-message": ["ThreadMessage"],
|
|
55
|
+
"wellknown-rule": [],
|
|
56
|
+
};
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
const allEntityTypes = Object.values(entityTypesByFile).flat().sort();
|
|
59
|
+
const coveredSorted = [...COVERED_ENTITY_TYPES].sort();
|
|
62
60
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
assert.deepStrictEqual(
|
|
62
|
+
coveredSorted,
|
|
63
|
+
allEntityTypes,
|
|
64
|
+
`Cascade does not cover all entity types.\n` +
|
|
65
|
+
`Missing: ${allEntityTypes.filter((t) => !(coveredSorted as readonly string[]).includes(t)).join(", ")}\n` +
|
|
66
|
+
`Extra: ${(coveredSorted as readonly string[]).filter((t) => !allEntityTypes.includes(t)).join(", ")}`,
|
|
67
|
+
);
|
|
70
68
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
);
|
|
69
|
+
// Verify all model files are accounted for
|
|
70
|
+
const unmappedFiles = modelFiles.filter((f) => !(f in entityTypesByFile));
|
|
71
|
+
assert.deepStrictEqual(
|
|
72
|
+
unmappedFiles,
|
|
73
|
+
[],
|
|
74
|
+
`Model files not mapped to entity types: ${unmappedFiles.join(", ")}`,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
80
77
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
|
|
4
|
+
import { getSqsClient } from "./config.js";
|
|
5
|
+
|
|
6
|
+
// The queue URL a self-hosted compose stack hands the account worker
|
|
7
|
+
// (deploy/vps/remit.env.template). It is a plain http:// URL that is not
|
|
8
|
+
// localhost, which is exactly the case the endpoint heuristic used to miss.
|
|
9
|
+
const composeQueueUrl =
|
|
10
|
+
"http://queue:9324/000000000000/remit-account-purge-delete.fifo";
|
|
11
|
+
const awsQueueUrl =
|
|
12
|
+
"https://sqs.eu-west-1.amazonaws.com/123456789012/remit-account-purge-delete.fifo";
|
|
13
|
+
|
|
14
|
+
describe("getSqsClient", () => {
|
|
15
|
+
// Regression: the account-deletion cascade sends through this client. It
|
|
16
|
+
// used to be built once at import with no endpoint and no protocol, so on a
|
|
17
|
+
// self-hosted stack every cascade send went to real SQS instead of the local
|
|
18
|
+
// queue server and account deletion never completed.
|
|
19
|
+
it("selects the query protocol and the queue origin for a compose-stack queue URL", async () => {
|
|
20
|
+
const client = getSqsClient(composeQueueUrl);
|
|
21
|
+
assert.equal(client.config.protocol instanceof AwsQueryProtocol, true);
|
|
22
|
+
const endpoint = await client.config.endpoint?.();
|
|
23
|
+
assert.equal(endpoint?.hostname, "queue");
|
|
24
|
+
assert.equal(endpoint?.port, 9324);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("leaves the default protocol and endpoint for a real SQS queue URL", async () => {
|
|
28
|
+
const client = getSqsClient(awsQueueUrl);
|
|
29
|
+
assert.equal(client.config.protocol instanceof AwsQueryProtocol, false);
|
|
30
|
+
assert.equal(client.config.endpoint, undefined);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("reuses one client per queue URL and keeps distinct queues apart", () => {
|
|
34
|
+
assert.equal(getSqsClient(composeQueueUrl), getSqsClient(composeQueueUrl));
|
|
35
|
+
assert.notEqual(getSqsClient(composeQueueUrl), getSqsClient(awsQueueUrl));
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
|
-
import { SQSClient } from "@aws-sdk/client-sqs";
|
|
1
|
+
import type { SQSClient } from "@aws-sdk/client-sqs";
|
|
2
2
|
import { getClient } from "@remit/backend/client";
|
|
3
|
-
import {
|
|
3
|
+
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
4
4
|
import type { StorageService } from "@remit/storage-service";
|
|
5
5
|
import { createStorageService } from "@remit/storage-service/s3";
|
|
6
6
|
import { env } from "expect-env";
|
|
7
7
|
import type { CascadeServices } from "./cascade.js";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// A client per queue URL, resolved at send time rather than at import. The
|
|
10
|
+
// endpoint and wire protocol are derived from the URL, so a self-hosted stack
|
|
11
|
+
// (http://queue:9324/...) gets the query protocol its queue server speaks while
|
|
12
|
+
// real SQS keeps the default. Building at import is not an option: the queue
|
|
13
|
+
// URLs are read lazily for the reason given below.
|
|
14
|
+
const sqsClients = new Map<string, SQSClient>();
|
|
15
|
+
|
|
16
|
+
export const getSqsClient = (queueUrl: string): SQSClient => {
|
|
17
|
+
const existing = sqsClients.get(queueUrl);
|
|
18
|
+
if (existing) return existing;
|
|
19
|
+
const client = createQueueProducer({ queueUrl });
|
|
20
|
+
sqsClients.set(queueUrl, client);
|
|
21
|
+
return client;
|
|
22
|
+
};
|
|
12
23
|
|
|
13
24
|
// SQS env vars are lazy-evaluated. The fanout worker needs them at handler time;
|
|
14
25
|
// the finalize worker imports `getCascadeServices` from this module but talks to
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
|
|
2
|
-
import {
|
|
3
|
-
createLogger,
|
|
4
|
-
type Logger,
|
|
5
|
-
withTelemetry,
|
|
6
|
-
} from "@remit/logger-lambda";
|
|
2
|
+
import { createLogger, type Logger, withTelemetry } from "@remit/logger-lambda";
|
|
7
3
|
import type { SQSBatchResponse, SQSEvent, SQSHandler } from "aws-lambda";
|
|
8
4
|
import type { CascadeServices } from "../cascade.js";
|
|
9
5
|
import { enumerateCascadeEntities } from "../cascade.js";
|
|
@@ -12,7 +8,7 @@ import {
|
|
|
12
8
|
getAccountPurgeDeleteQueueUrl,
|
|
13
9
|
getCascadeServices,
|
|
14
10
|
getImapWorkerQueueUrl,
|
|
15
|
-
|
|
11
|
+
getSqsClient,
|
|
16
12
|
} from "../config.js";
|
|
17
13
|
import {
|
|
18
14
|
type DeletionCapabilities,
|
|
@@ -49,12 +45,13 @@ export const processAccountFanout = async (
|
|
|
49
45
|
deps: ProcessAccountFanoutDeps = {},
|
|
50
46
|
): Promise<void> => {
|
|
51
47
|
const services = deps.services ?? (await getCascadeServices());
|
|
52
|
-
const
|
|
48
|
+
const sqsFor = (queueUrl: string): SQSClient =>
|
|
49
|
+
deps.sqs ?? getSqsClient(queueUrl);
|
|
53
50
|
|
|
54
51
|
if (event.type === "AccountDataPurge") {
|
|
55
52
|
await processAccountDataPurge(event, log, {
|
|
56
53
|
services,
|
|
57
|
-
sqs,
|
|
54
|
+
sqs: deps.sqs,
|
|
58
55
|
accountPurgeDeleteQueueUrl:
|
|
59
56
|
deps.accountPurgeDeleteQueueUrl ?? getAccountPurgeDeleteQueueUrl(),
|
|
60
57
|
});
|
|
@@ -71,14 +68,14 @@ export const processAccountFanout = async (
|
|
|
71
68
|
return;
|
|
72
69
|
}
|
|
73
70
|
|
|
74
|
-
await processAccountDelete(event, log, services,
|
|
71
|
+
await processAccountDelete(event, log, services, sqsFor, deps);
|
|
75
72
|
};
|
|
76
73
|
|
|
77
74
|
const processAccountDelete = async (
|
|
78
75
|
event: AccountDeleteEvent,
|
|
79
76
|
log: Logger,
|
|
80
77
|
services: CascadeServices,
|
|
81
|
-
|
|
78
|
+
sqsFor: (queueUrl: string) => SQSClient,
|
|
82
79
|
deps: ProcessAccountFanoutDeps,
|
|
83
80
|
): Promise<void> => {
|
|
84
81
|
const { accountConfigId } = event;
|
|
@@ -102,7 +99,7 @@ const processAccountDelete = async (
|
|
|
102
99
|
.map((e) => e.key.accountId);
|
|
103
100
|
|
|
104
101
|
for (const accountId of accountIds) {
|
|
105
|
-
await
|
|
102
|
+
await sqsFor(imapWorkerQueueUrl).send(
|
|
106
103
|
new SendMessageCommand({
|
|
107
104
|
QueueUrl: imapWorkerQueueUrl,
|
|
108
105
|
MessageBody: JSON.stringify({
|
|
@@ -124,7 +121,7 @@ const processAccountDelete = async (
|
|
|
124
121
|
type: "FinalizeAccountDelete",
|
|
125
122
|
accountConfigId,
|
|
126
123
|
};
|
|
127
|
-
await
|
|
124
|
+
await sqsFor(accountFinalizeQueueUrl).send(
|
|
128
125
|
new SendMessageCommand({
|
|
129
126
|
QueueUrl: accountFinalizeQueueUrl,
|
|
130
127
|
MessageBody: JSON.stringify(finalizeEvent),
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { inspect } from "node:util";
|
|
2
|
-
import {
|
|
3
|
-
createLogger,
|
|
4
|
-
type Logger,
|
|
5
|
-
withTelemetry,
|
|
6
|
-
} from "@remit/logger-lambda";
|
|
2
|
+
import { createLogger, type Logger, withTelemetry } from "@remit/logger-lambda";
|
|
7
3
|
import type { SQSEvent, SQSHandler } from "aws-lambda";
|
|
8
4
|
import {
|
|
9
5
|
type CascadeEntity,
|
|
@@ -11,10 +11,10 @@ import { Entity } from "electrodb";
|
|
|
11
11
|
import type { CascadeServices } from "../cascade.js";
|
|
12
12
|
import {
|
|
13
13
|
dataBackend as defaultDataBackend,
|
|
14
|
-
sqsClient as defaultSqsClient,
|
|
15
14
|
getAccountPurgeDeleteQueueUrl,
|
|
16
15
|
getCascadeServices,
|
|
17
16
|
getSearchIndexQueueUrl,
|
|
17
|
+
getSqsClient,
|
|
18
18
|
} from "../config.js";
|
|
19
19
|
import type {
|
|
20
20
|
AccountDataPurgeEvent,
|
|
@@ -162,7 +162,8 @@ export const processAccountDataPurge = async (
|
|
|
162
162
|
): Promise<void> => {
|
|
163
163
|
const { accountId, accountConfigId } = event;
|
|
164
164
|
const services = deps.services ?? (await getCascadeServices());
|
|
165
|
-
const
|
|
165
|
+
const sqsFor = (queueUrl: string): SQSClient =>
|
|
166
|
+
deps.sqs ?? getSqsClient(queueUrl);
|
|
166
167
|
const accountPurgeDeleteQueueUrl =
|
|
167
168
|
deps.accountPurgeDeleteQueueUrl ?? getAccountPurgeDeleteQueueUrl();
|
|
168
169
|
const searchIndexQueueUrl =
|
|
@@ -200,7 +201,12 @@ export const processAccountDataPurge = async (
|
|
|
200
201
|
// here too would double the removal. On DynamoDB this fanout is the sole
|
|
201
202
|
// producer of the vector deletes (#457).
|
|
202
203
|
if (dataBackend !== "postgres") {
|
|
203
|
-
await enqueueVectorDeletes(
|
|
204
|
+
await enqueueVectorDeletes(
|
|
205
|
+
sqsFor(searchIndexQueueUrl),
|
|
206
|
+
searchIndexQueueUrl,
|
|
207
|
+
accountId,
|
|
208
|
+
messageIds,
|
|
209
|
+
);
|
|
204
210
|
log.info(
|
|
205
211
|
{ accountConfigId, accountId, count: messageIds.length },
|
|
206
212
|
"Enqueued search-index deletes for purged account",
|
|
@@ -208,7 +214,7 @@ export const processAccountDataPurge = async (
|
|
|
208
214
|
}
|
|
209
215
|
|
|
210
216
|
await enqueuePurgeFinalize(
|
|
211
|
-
|
|
217
|
+
sqsFor(accountPurgeDeleteQueueUrl),
|
|
212
218
|
accountPurgeDeleteQueueUrl,
|
|
213
219
|
accountId,
|
|
214
220
|
accountConfigId,
|