@remit/account-worker 0.0.31 → 0.0.33
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.ts +21 -0
- package/src/config.ts +2 -20
- package/src/deletion-capabilities.ts +2 -54
- package/src/deletion-port.ts +55 -0
- package/src/handlers/account-finalize.ts +4 -151
- package/src/handlers/account-purge-finalize.test.ts +155 -0
- package/src/handlers/account-purge-finalize.ts +161 -0
- package/src/handlers/account-purge.ts +2 -3
- package/src/handlers/organize-job.test.ts +9 -5
- package/src/process.ts +40 -0
package/package.json
CHANGED
package/src/cascade.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RemitClient } from "@remit/backend/client";
|
|
1
2
|
import type {
|
|
2
3
|
IAccountConfigRepository,
|
|
3
4
|
IAccountExportRequestRepository,
|
|
@@ -45,6 +46,26 @@ export interface CascadeServices {
|
|
|
45
46
|
messageLabelService: IMessageLabelRepository;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
export const cascadeServicesOf = (client: RemitClient): CascadeServices => ({
|
|
50
|
+
accountConfigService: client.accountConfig,
|
|
51
|
+
accountService: client.account,
|
|
52
|
+
addressService: client.address,
|
|
53
|
+
mailboxService: client.mailbox,
|
|
54
|
+
messageService: client.message,
|
|
55
|
+
messageFlagService: client.messageFlag,
|
|
56
|
+
envelopeService: client.envelope,
|
|
57
|
+
outboxMessageService: client.outboxMessage,
|
|
58
|
+
threadMessageService: client.threadMessage,
|
|
59
|
+
mailboxLockService: client.mailboxLock,
|
|
60
|
+
messagePlacementMoveService: client.placementMove,
|
|
61
|
+
messageFlagPushService: client.flagPush,
|
|
62
|
+
accountExportRequestService: client.accountExportRequest,
|
|
63
|
+
accountSettingService: client.accountSetting,
|
|
64
|
+
filterService: client.filter,
|
|
65
|
+
labelService: client.label,
|
|
66
|
+
messageLabelService: client.messageLabel,
|
|
67
|
+
});
|
|
68
|
+
|
|
48
69
|
export interface CascadeResult {
|
|
49
70
|
entities: CascadeEntity[];
|
|
50
71
|
messageIds: string[];
|
package/src/config.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import type { StorageService } from "@remit/storage-service";
|
|
8
8
|
import { createStorageService } from "@remit/storage-service/s3";
|
|
9
9
|
import { env } from "expect-env";
|
|
10
|
-
import type
|
|
10
|
+
import { type CascadeServices, cascadeServicesOf } from "./cascade.js";
|
|
11
11
|
import type {
|
|
12
12
|
AccountDataPurgeFinalizeEvent,
|
|
13
13
|
AccountFinalizeEvent,
|
|
@@ -49,25 +49,7 @@ let cascadeServicesPromise: Promise<CascadeServices> | null = null;
|
|
|
49
49
|
// backend-specific wiring here.
|
|
50
50
|
export const getCascadeServices = (): Promise<CascadeServices> => {
|
|
51
51
|
if (!cascadeServicesPromise) {
|
|
52
|
-
cascadeServicesPromise = getClient().then(
|
|
53
|
-
accountConfigService: remitClient.accountConfig,
|
|
54
|
-
accountService: remitClient.account,
|
|
55
|
-
addressService: remitClient.address,
|
|
56
|
-
mailboxService: remitClient.mailbox,
|
|
57
|
-
messageService: remitClient.message,
|
|
58
|
-
messageFlagService: remitClient.messageFlag,
|
|
59
|
-
envelopeService: remitClient.envelope,
|
|
60
|
-
outboxMessageService: remitClient.outboxMessage,
|
|
61
|
-
threadMessageService: remitClient.threadMessage,
|
|
62
|
-
mailboxLockService: remitClient.mailboxLock,
|
|
63
|
-
messagePlacementMoveService: remitClient.placementMove,
|
|
64
|
-
messageFlagPushService: remitClient.flagPush,
|
|
65
|
-
accountExportRequestService: remitClient.accountExportRequest,
|
|
66
|
-
accountSettingService: remitClient.accountSetting,
|
|
67
|
-
filterService: remitClient.filter,
|
|
68
|
-
labelService: remitClient.label,
|
|
69
|
-
messageLabelService: remitClient.messageLabel,
|
|
70
|
-
}));
|
|
52
|
+
cascadeServicesPromise = getClient().then(cascadeServicesOf);
|
|
71
53
|
}
|
|
72
54
|
return cascadeServicesPromise;
|
|
73
55
|
};
|
|
@@ -1,58 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { CascadeEntity } from "./cascade.js";
|
|
1
|
+
import type { DeletionCapabilities } from "./deletion-port.js";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
* The four steps of the deletion cascade that vary by deployment. Everything
|
|
6
|
-
* else — enumerating the AccountConfig's rows, the FIFO purge orchestration, the
|
|
7
|
-
* queue sends — is backend-neutral and lives in the handlers unchanged.
|
|
8
|
-
*
|
|
9
|
-
* Two composition roots build this:
|
|
10
|
-
*
|
|
11
|
-
* - The DynamoDB composition (AWS custody stack): Cognito global sign-out,
|
|
12
|
-
* CloudFront invalidation, raw-S3 prefix delete, DynamoDB `BatchWriteItem`
|
|
13
|
-
* cascade. Byte-for-byte the pre-split behavior. It imports the AWS SDK /
|
|
14
|
-
* ElectroDB graph and lives outside this shared, open-core module; it is
|
|
15
|
-
* injected here through `setDeletionCapabilities` so that graph never reaches
|
|
16
|
-
* the relational tree.
|
|
17
|
-
* - `compose-relational.ts` (self-host stack, RFC 035/036): no-op sign-out
|
|
18
|
-
* (deleting the AccountConfig severs the session's data resolution; there is
|
|
19
|
-
* no CDN to sign out of), no-op invalidation (Caddy proxies `/content/*`
|
|
20
|
-
* straight to the backend with no cache), filesystem prefix delete, and the
|
|
21
|
-
* Drizzle cascade over SQLite.
|
|
22
|
-
*/
|
|
23
|
-
export interface DeletionCapabilities {
|
|
24
|
-
/**
|
|
25
|
-
* Validate every precondition the destructive steps depend on, BEFORE any row
|
|
26
|
-
* or object is deleted. AWS checks both `CONTENT_DISTRIBUTION_ID` and
|
|
27
|
-
* `S3_STORAGE_BUCKET_NAME` up front so a missing var fails loud instead of
|
|
28
|
-
* destroying rows in the cascade and only then erroring at the storage step.
|
|
29
|
-
* Relational is a no-op — its steps carry no such deploy-time configuration.
|
|
30
|
-
*/
|
|
31
|
-
assertReady(log: Logger): Promise<void>;
|
|
32
|
-
/**
|
|
33
|
-
* Invalidate the deleted user's active auth sessions. AWS: Cognito
|
|
34
|
-
* `AdminUserGlobalSignOut`. Relational: no-op.
|
|
35
|
-
*/
|
|
36
|
-
signOut(userId: string, log: Logger): Promise<void>;
|
|
37
|
-
/**
|
|
38
|
-
* Purge any CDN/edge cache under a tenant/account content prefix
|
|
39
|
-
* (`accountConfigId` or `accountConfigId/accountId`). AWS: CloudFront
|
|
40
|
-
* invalidation of `/content/accounts/{prefix}/*`. Relational: no-op.
|
|
41
|
-
*/
|
|
42
|
-
invalidateContent(contentPrefix: string, log: Logger): Promise<void>;
|
|
43
|
-
/**
|
|
44
|
-
* Delete every stored object under a storage key prefix
|
|
45
|
-
* (`accounts/{accountConfigId}/…`). AWS: raw-S3 list+delete. Relational:
|
|
46
|
-
* recursive filesystem removal under the local storage root.
|
|
47
|
-
*/
|
|
48
|
-
deleteStoragePrefix(keyPrefix: string, log: Logger): Promise<void>;
|
|
49
|
-
/**
|
|
50
|
-
* Remove the enumerated rows in dependency order (children → parents). AWS:
|
|
51
|
-
* DynamoDB `BatchWriteItem`. Relational: a Drizzle transaction. Excludes the AccountConfig row, which the caller deletes last
|
|
52
|
-
* through its repository as the cascade-in-progress marker.
|
|
53
|
-
*/
|
|
54
|
-
cascadeDelete(entities: CascadeEntity[], log: Logger): Promise<void>;
|
|
55
|
-
}
|
|
3
|
+
export type { DeletionCapabilities };
|
|
56
4
|
|
|
57
5
|
let injectedCapabilities: DeletionCapabilities | null = null;
|
|
58
6
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Logger } from "@remit/logger";
|
|
2
|
+
import type { CascadeEntity } from "./cascade.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The four steps of the deletion cascade that vary by deployment. Everything
|
|
6
|
+
* else — enumerating the AccountConfig's rows, the FIFO purge orchestration, the
|
|
7
|
+
* queue sends — is backend-neutral and lives in the handlers unchanged.
|
|
8
|
+
*
|
|
9
|
+
* Two composition roots build this:
|
|
10
|
+
*
|
|
11
|
+
* - The DynamoDB composition (AWS custody stack): Cognito global sign-out,
|
|
12
|
+
* CloudFront invalidation, raw-S3 prefix delete, DynamoDB `BatchWriteItem`
|
|
13
|
+
* cascade. Byte-for-byte the pre-split behavior. It imports the AWS SDK /
|
|
14
|
+
* ElectroDB graph and lives outside this shared, open-core module; it is
|
|
15
|
+
* injected here through `setDeletionCapabilities` so that graph never reaches
|
|
16
|
+
* the relational tree.
|
|
17
|
+
* - `compose-relational.ts` (self-host stack, RFC 035/036): no-op sign-out
|
|
18
|
+
* (deleting the AccountConfig severs the session's data resolution; there is
|
|
19
|
+
* no CDN to sign out of), no-op invalidation (Caddy proxies `/content/*`
|
|
20
|
+
* straight to the backend with no cache), filesystem prefix delete, and the
|
|
21
|
+
* Drizzle cascade over SQLite.
|
|
22
|
+
*/
|
|
23
|
+
export interface DeletionCapabilities {
|
|
24
|
+
/**
|
|
25
|
+
* Validate every precondition the destructive steps depend on, BEFORE any row
|
|
26
|
+
* or object is deleted. AWS checks both `CONTENT_DISTRIBUTION_ID` and
|
|
27
|
+
* `S3_STORAGE_BUCKET_NAME` up front so a missing var fails loud instead of
|
|
28
|
+
* destroying rows in the cascade and only then erroring at the storage step.
|
|
29
|
+
* Relational is a no-op — its steps carry no such deploy-time configuration.
|
|
30
|
+
*/
|
|
31
|
+
assertReady(log: Logger): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Invalidate the deleted user's active auth sessions. AWS: Cognito
|
|
34
|
+
* `AdminUserGlobalSignOut`. Relational: no-op.
|
|
35
|
+
*/
|
|
36
|
+
signOut(userId: string, log: Logger): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Purge any CDN/edge cache under a tenant/account content prefix
|
|
39
|
+
* (`accountConfigId` or `accountConfigId/accountId`). AWS: CloudFront
|
|
40
|
+
* invalidation of `/content/accounts/{prefix}/*`. Relational: no-op.
|
|
41
|
+
*/
|
|
42
|
+
invalidateContent(contentPrefix: string, log: Logger): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Delete every stored object under a storage key prefix
|
|
45
|
+
* (`accounts/{accountConfigId}/…`). AWS: raw-S3 list+delete. Relational:
|
|
46
|
+
* recursive filesystem removal under the local storage root.
|
|
47
|
+
*/
|
|
48
|
+
deleteStoragePrefix(keyPrefix: string, log: Logger): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Remove the enumerated rows in dependency order (children → parents). AWS:
|
|
51
|
+
* DynamoDB `BatchWriteItem`. Relational: a Drizzle transaction. Excludes the AccountConfig row, which the caller deletes last
|
|
52
|
+
* through its repository as the cascade-in-progress marker.
|
|
53
|
+
*/
|
|
54
|
+
cascadeDelete(entities: CascadeEntity[], log: Logger): Promise<void>;
|
|
55
|
+
}
|
|
@@ -9,7 +9,6 @@ import type { SQSEvent, SQSHandler } from "aws-lambda";
|
|
|
9
9
|
import {
|
|
10
10
|
type CascadeEntity,
|
|
11
11
|
type CascadeServices,
|
|
12
|
-
collectMessageChildEntities,
|
|
13
12
|
enumerateCascadeEntities,
|
|
14
13
|
} from "../cascade.js";
|
|
15
14
|
import { getCascadeServices } from "../config.js";
|
|
@@ -18,10 +17,10 @@ import {
|
|
|
18
17
|
getDeletionCapabilities,
|
|
19
18
|
} from "../deletion-capabilities.js";
|
|
20
19
|
import type {
|
|
21
|
-
AccountDataPurgeFinalizeEvent,
|
|
22
20
|
AccountDeleteFinalizeEvent,
|
|
23
21
|
AccountFinalizeEvent,
|
|
24
22
|
} from "../events.js";
|
|
23
|
+
import { processAccountDataPurgeFinalize } from "./account-purge-finalize.js";
|
|
25
24
|
|
|
26
25
|
installNodeLogger();
|
|
27
26
|
|
|
@@ -124,154 +123,6 @@ export const processAccountFinalize = async (
|
|
|
124
123
|
log.info({ accountConfigId }, "AccountConfig deleted; cascade complete");
|
|
125
124
|
};
|
|
126
125
|
|
|
127
|
-
/**
|
|
128
|
-
* Destructive phase of the per-account purge. Consumes the FIFO stream the
|
|
129
|
-
* fanout producer emits (#1069), one message group per account so the messages
|
|
130
|
-
* arrive strictly in order: every `subtrees` batch, then exactly one
|
|
131
|
-
* `container` leftover. There is NO self-re-enqueue — the producer enqueues all
|
|
132
|
-
* the work; this worker only ever consumes.
|
|
133
|
-
*
|
|
134
|
-
* Idempotency: the cascade on a missing key is a no-op, so a redelivered batch
|
|
135
|
-
* (or container) no-ops on already-gone rows.
|
|
136
|
-
*/
|
|
137
|
-
export const processAccountDataPurgeFinalize = async (
|
|
138
|
-
event: AccountDataPurgeFinalizeEvent,
|
|
139
|
-
log: Logger,
|
|
140
|
-
deps: ProcessFinalizeDeps = {},
|
|
141
|
-
): Promise<void> => {
|
|
142
|
-
if (event.kind === "subtrees") {
|
|
143
|
-
await processPurgeSubtrees(event, log, deps);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
await processPurgeContainer(event, log, deps);
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Delete a batch of message subtrees. For each `{ threadMessageId, messageId }`
|
|
151
|
-
* the manifest row is deleted UNCONDITIONALLY — there are ghost ThreadMessages
|
|
152
|
-
* whose Message is already gone — and the Message + its 9 child entities are
|
|
153
|
-
* deleted only when `describe()` resolves. A `describe()` 404 means the subtree
|
|
154
|
-
* is already gone: skip the children, still drop the manifest row.
|
|
155
|
-
*/
|
|
156
|
-
const processPurgeSubtrees = async (
|
|
157
|
-
event: Extract<AccountDataPurgeFinalizeEvent, { kind: "subtrees" }>,
|
|
158
|
-
log: Logger,
|
|
159
|
-
deps: ProcessFinalizeDeps,
|
|
160
|
-
): Promise<void> => {
|
|
161
|
-
const { accountId, accountConfigId, items } = event;
|
|
162
|
-
const { capabilities, services } = await resolveDeps(deps);
|
|
163
|
-
|
|
164
|
-
const entities: CascadeEntity[] = [];
|
|
165
|
-
for (const { threadMessageId, messageId } of items) {
|
|
166
|
-
entities.push({
|
|
167
|
-
entityType: "ThreadMessage",
|
|
168
|
-
key: { accountConfigId, threadMessageId },
|
|
169
|
-
});
|
|
170
|
-
try {
|
|
171
|
-
const messageData = await services.messageService.describe(messageId);
|
|
172
|
-
entities.push({ entityType: "Message", key: { messageId } });
|
|
173
|
-
collectMessageChildEntities(entities, messageData);
|
|
174
|
-
} catch (error) {
|
|
175
|
-
if ((error as { name?: string })?.name === "NotFoundError") {
|
|
176
|
-
log.info(
|
|
177
|
-
{ accountConfigId, accountId, messageId },
|
|
178
|
-
"Message subtree already gone — deleting manifest row only",
|
|
179
|
-
);
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
throw error;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
await capabilities.cascadeDelete(entities, log);
|
|
187
|
-
log.info(
|
|
188
|
-
{ accountConfigId, accountId, count: items.length },
|
|
189
|
-
"Per-account purge subtree batch deleted",
|
|
190
|
-
);
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* The container leftover — processed last in the FIFO group, after every subtree
|
|
195
|
-
* delete. Deletes the account-keyed container rows (mailboxes, outbox, locks),
|
|
196
|
-
* the storage prefix `accounts/{cfg}/{acct}/`, and invalidates the account's
|
|
197
|
-
* cached content. Ordering is invalidate-before-storage, storage-after-cascade.
|
|
198
|
-
* The tenant-shared Address rows, the AccountConfig, sibling accounts, and the
|
|
199
|
-
* soft-deleted account row (the purge-in-progress marker) are all kept.
|
|
200
|
-
*/
|
|
201
|
-
const processPurgeContainer = async (
|
|
202
|
-
event: Extract<AccountDataPurgeFinalizeEvent, { kind: "container" }>,
|
|
203
|
-
log: Logger,
|
|
204
|
-
deps: ProcessFinalizeDeps,
|
|
205
|
-
): Promise<void> => {
|
|
206
|
-
const { accountId, accountConfigId } = event;
|
|
207
|
-
const { capabilities, services } = await resolveDeps(deps);
|
|
208
|
-
|
|
209
|
-
// The account row is kept as the purge-in-progress marker. Its absence means
|
|
210
|
-
// the container leftover already ran — no-op on redelivery.
|
|
211
|
-
let accountDescription: Awaited<
|
|
212
|
-
ReturnType<CascadeServices["accountService"]["describe"]>
|
|
213
|
-
>;
|
|
214
|
-
try {
|
|
215
|
-
accountDescription = await services.accountService.describe(accountId);
|
|
216
|
-
} catch (error) {
|
|
217
|
-
if ((error as { name?: string })?.name === "NotFoundError") {
|
|
218
|
-
log.info(
|
|
219
|
-
{ accountConfigId, accountId },
|
|
220
|
-
"Per-account purge already complete (account row gone) — no-op",
|
|
221
|
-
);
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
throw error;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Validate every deploy-time precondition BEFORE the first destructive step.
|
|
228
|
-
// Runs after the account-existence guard so a redelivery whose account row is
|
|
229
|
-
// already gone still returns cleanly without needing the config present.
|
|
230
|
-
await capabilities.assertReady(log);
|
|
231
|
-
|
|
232
|
-
// Step 1: content invalidation — first, so cached body parts cannot leak
|
|
233
|
-
// after the underlying objects are gone.
|
|
234
|
-
log.info(
|
|
235
|
-
{ accountConfigId, accountId },
|
|
236
|
-
"Invalidating cached content for purged account",
|
|
237
|
-
);
|
|
238
|
-
await capabilities.invalidateContent(`${accountConfigId}/${accountId}`, log);
|
|
239
|
-
|
|
240
|
-
// Step 2: container rows — mailboxes, outbox, locks. Address is
|
|
241
|
-
// tenant-shared and never deleted on a per-account purge.
|
|
242
|
-
const entities: CascadeEntity[] = [];
|
|
243
|
-
for (const mailbox of accountDescription.mailbox) {
|
|
244
|
-
entities.push({
|
|
245
|
-
entityType: "Mailbox",
|
|
246
|
-
key: { mailboxId: mailbox.mailboxId },
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
const outboxMessages =
|
|
250
|
-
await services.outboxMessageService.listByAccount(accountId);
|
|
251
|
-
for (const outbox of outboxMessages.items) {
|
|
252
|
-
entities.push({
|
|
253
|
-
entityType: "OutboxMessage",
|
|
254
|
-
key: { outboxMessageId: outbox.outboxMessageId },
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
const locks = await services.mailboxLockService.listByAccount(accountId);
|
|
258
|
-
for (const lock of locks) {
|
|
259
|
-
entities.push({
|
|
260
|
-
entityType: "MailboxLock",
|
|
261
|
-
key: { mailboxId: lock.mailboxId, eventName: lock.eventName },
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
await capabilities.cascadeDelete(entities, log);
|
|
265
|
-
|
|
266
|
-
// Step 3: storage prefix cleanup scoped to this account only.
|
|
267
|
-
await capabilities.deleteStoragePrefix(
|
|
268
|
-
`accounts/${accountConfigId}/${accountId}/`,
|
|
269
|
-
log,
|
|
270
|
-
);
|
|
271
|
-
|
|
272
|
-
log.info({ accountConfigId, accountId }, "Per-account data purge complete");
|
|
273
|
-
};
|
|
274
|
-
|
|
275
126
|
// ---------- SQS handler ----------
|
|
276
127
|
|
|
277
128
|
const log = createLogger();
|
|
@@ -292,7 +143,9 @@ export const handler: SQSHandler = withTelemetry(async (event: SQSEvent) => {
|
|
|
292
143
|
const start = Date.now();
|
|
293
144
|
const work =
|
|
294
145
|
finalizeEvent.type === "FinalizeAccountDataPurge"
|
|
295
|
-
?
|
|
146
|
+
? resolveDeps({}).then((deps) =>
|
|
147
|
+
processAccountDataPurgeFinalize(finalizeEvent, log, deps),
|
|
148
|
+
)
|
|
296
149
|
: processAccountFinalize(finalizeEvent, log);
|
|
297
150
|
|
|
298
151
|
const failed = await work
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitClient } from "@remit/backend/client";
|
|
4
|
+
import { recordingQueue } from "@remit/data-ports/recording-queue";
|
|
5
|
+
import type { CascadeEntity } from "../cascade.js";
|
|
6
|
+
import type { DeletionCapabilities } from "../deletion-port.js";
|
|
7
|
+
import { processAccountPurgeEvent } from "../process.js";
|
|
8
|
+
|
|
9
|
+
const notFound = (): Error =>
|
|
10
|
+
Object.assign(new Error("gone"), { name: "NotFoundError" });
|
|
11
|
+
|
|
12
|
+
const messageDescription = {
|
|
13
|
+
messageFlag: [{ messageFlagId: "flag-1" }],
|
|
14
|
+
envelope: [{ envelopeId: "env-1" }],
|
|
15
|
+
messageReference: [],
|
|
16
|
+
envelopeAddress: [],
|
|
17
|
+
bodyPart: [],
|
|
18
|
+
bodyPartParameter: [],
|
|
19
|
+
rawMessageStorage: [],
|
|
20
|
+
bodyPartStorage: [],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const fakeClient = (accountGone = false): RemitClient =>
|
|
24
|
+
({
|
|
25
|
+
account: {
|
|
26
|
+
describe: async () => {
|
|
27
|
+
if (accountGone) throw notFound();
|
|
28
|
+
return { mailbox: [{ mailboxId: "mbx-1" }] };
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
message: {
|
|
32
|
+
describe: async (messageId: string) => {
|
|
33
|
+
if (messageId === "msg-gone") throw notFound();
|
|
34
|
+
return messageDescription;
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
outboxMessage: {
|
|
38
|
+
listByAccount: async () => ({ items: [{ outboxMessageId: "out-1" }] }),
|
|
39
|
+
},
|
|
40
|
+
mailboxLock: {
|
|
41
|
+
listByAccount: async () => [
|
|
42
|
+
{ mailboxId: "mbx-1", eventName: "SYNC_MESSAGES" },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
}) as unknown as RemitClient;
|
|
46
|
+
|
|
47
|
+
interface Recorded {
|
|
48
|
+
readonly capabilities: DeletionCapabilities;
|
|
49
|
+
readonly deleted: CascadeEntity[];
|
|
50
|
+
readonly prefixes: string[];
|
|
51
|
+
readonly invalidated: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const recordingCapabilities = (): Recorded => {
|
|
55
|
+
const deleted: CascadeEntity[] = [];
|
|
56
|
+
const prefixes: string[] = [];
|
|
57
|
+
const invalidated: string[] = [];
|
|
58
|
+
return {
|
|
59
|
+
deleted,
|
|
60
|
+
prefixes,
|
|
61
|
+
invalidated,
|
|
62
|
+
capabilities: {
|
|
63
|
+
assertReady: async () => {},
|
|
64
|
+
signOut: async () => {},
|
|
65
|
+
invalidateContent: async (prefix) => {
|
|
66
|
+
invalidated.push(prefix);
|
|
67
|
+
},
|
|
68
|
+
deleteStoragePrefix: async (prefix) => {
|
|
69
|
+
prefixes.push(prefix);
|
|
70
|
+
},
|
|
71
|
+
cascadeDelete: async (entities) => {
|
|
72
|
+
deleted.push(...entities);
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const typesOf = (entities: CascadeEntity[]): string[] =>
|
|
79
|
+
entities.map((entity) => entity.entityType);
|
|
80
|
+
|
|
81
|
+
describe("processAccountPurgeEvent", () => {
|
|
82
|
+
it("deletes each message subtree and the manifest row of a message already gone", async () => {
|
|
83
|
+
const recorded = recordingCapabilities();
|
|
84
|
+
await processAccountPurgeEvent(
|
|
85
|
+
{
|
|
86
|
+
type: "FinalizeAccountDataPurge",
|
|
87
|
+
kind: "subtrees",
|
|
88
|
+
accountId: "acc-1",
|
|
89
|
+
accountConfigId: "cfg-1",
|
|
90
|
+
items: [
|
|
91
|
+
{ threadMessageId: "tm-1", messageId: "msg-1" },
|
|
92
|
+
{ threadMessageId: "tm-2", messageId: "msg-gone" },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
client: fakeClient(),
|
|
97
|
+
purgeQueue: recordingQueue().grouped,
|
|
98
|
+
capabilities: recorded.capabilities,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
assert.deepEqual(typesOf(recorded.deleted), [
|
|
103
|
+
"ThreadMessage",
|
|
104
|
+
"Message",
|
|
105
|
+
"MessageFlag",
|
|
106
|
+
"Envelope",
|
|
107
|
+
"ThreadMessage",
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("deletes the account's mailboxes, outbox and locks, then its stored objects", async () => {
|
|
112
|
+
const recorded = recordingCapabilities();
|
|
113
|
+
await processAccountPurgeEvent(
|
|
114
|
+
{
|
|
115
|
+
type: "FinalizeAccountDataPurge",
|
|
116
|
+
kind: "container",
|
|
117
|
+
accountId: "acc-1",
|
|
118
|
+
accountConfigId: "cfg-1",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
client: fakeClient(),
|
|
122
|
+
purgeQueue: recordingQueue().grouped,
|
|
123
|
+
capabilities: recorded.capabilities,
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
assert.deepEqual(typesOf(recorded.deleted), [
|
|
128
|
+
"Mailbox",
|
|
129
|
+
"OutboxMessage",
|
|
130
|
+
"MailboxLock",
|
|
131
|
+
]);
|
|
132
|
+
assert.deepEqual(recorded.invalidated, ["cfg-1/acc-1"]);
|
|
133
|
+
assert.deepEqual(recorded.prefixes, ["accounts/cfg-1/acc-1/"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("does nothing for a container whose account row is gone", async () => {
|
|
137
|
+
const recorded = recordingCapabilities();
|
|
138
|
+
await processAccountPurgeEvent(
|
|
139
|
+
{
|
|
140
|
+
type: "FinalizeAccountDataPurge",
|
|
141
|
+
kind: "container",
|
|
142
|
+
accountId: "acc-1",
|
|
143
|
+
accountConfigId: "cfg-1",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
client: fakeClient(true),
|
|
147
|
+
purgeQueue: recordingQueue().grouped,
|
|
148
|
+
capabilities: recorded.capabilities,
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(recorded.deleted, []);
|
|
153
|
+
assert.deepEqual(recorded.prefixes, []);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { Logger } from "@remit/logger";
|
|
2
|
+
import {
|
|
3
|
+
type CascadeEntity,
|
|
4
|
+
type CascadeServices,
|
|
5
|
+
collectMessageChildEntities,
|
|
6
|
+
} from "../cascade.js";
|
|
7
|
+
import type { DeletionCapabilities } from "../deletion-port.js";
|
|
8
|
+
import type { AccountDataPurgeFinalizeEvent } from "../events.js";
|
|
9
|
+
|
|
10
|
+
export interface PurgeFinalizeDeps {
|
|
11
|
+
capabilities: DeletionCapabilities;
|
|
12
|
+
services: CascadeServices;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Destructive phase of the per-account purge. Consumes the FIFO stream the
|
|
17
|
+
* fanout producer emits (#1069), one message group per account so the messages
|
|
18
|
+
* arrive strictly in order: every `subtrees` batch, then exactly one
|
|
19
|
+
* `container` leftover. There is NO self-re-enqueue — the producer enqueues all
|
|
20
|
+
* the work; this worker only ever consumes.
|
|
21
|
+
*
|
|
22
|
+
* Idempotency: the cascade on a missing key is a no-op, so a redelivered batch
|
|
23
|
+
* (or container) no-ops on already-gone rows.
|
|
24
|
+
*/
|
|
25
|
+
export const processAccountDataPurgeFinalize = async (
|
|
26
|
+
event: AccountDataPurgeFinalizeEvent,
|
|
27
|
+
log: Logger,
|
|
28
|
+
deps: PurgeFinalizeDeps,
|
|
29
|
+
): Promise<void> => {
|
|
30
|
+
if (event.kind === "subtrees") {
|
|
31
|
+
await processPurgeSubtrees(event, log, deps);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await processPurgeContainer(event, log, deps);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Delete a batch of message subtrees. For each `{ threadMessageId, messageId }`
|
|
39
|
+
* the manifest row is deleted UNCONDITIONALLY — there are ghost ThreadMessages
|
|
40
|
+
* whose Message is already gone — and the Message + its 9 child entities are
|
|
41
|
+
* deleted only when `describe()` resolves. A `describe()` 404 means the subtree
|
|
42
|
+
* is already gone: skip the children, still drop the manifest row.
|
|
43
|
+
*/
|
|
44
|
+
const processPurgeSubtrees = async (
|
|
45
|
+
event: Extract<AccountDataPurgeFinalizeEvent, { kind: "subtrees" }>,
|
|
46
|
+
log: Logger,
|
|
47
|
+
deps: PurgeFinalizeDeps,
|
|
48
|
+
): Promise<void> => {
|
|
49
|
+
const { accountId, accountConfigId, items } = event;
|
|
50
|
+
const { capabilities, services } = deps;
|
|
51
|
+
|
|
52
|
+
const entities: CascadeEntity[] = [];
|
|
53
|
+
for (const { threadMessageId, messageId } of items) {
|
|
54
|
+
entities.push({
|
|
55
|
+
entityType: "ThreadMessage",
|
|
56
|
+
key: { accountConfigId, threadMessageId },
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
const messageData = await services.messageService.describe(messageId);
|
|
60
|
+
entities.push({ entityType: "Message", key: { messageId } });
|
|
61
|
+
collectMessageChildEntities(entities, messageData);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if ((error as { name?: string })?.name === "NotFoundError") {
|
|
64
|
+
log.info(
|
|
65
|
+
{ accountConfigId, accountId, messageId },
|
|
66
|
+
"Message subtree already gone — deleting manifest row only",
|
|
67
|
+
);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await capabilities.cascadeDelete(entities, log);
|
|
75
|
+
log.info(
|
|
76
|
+
{ accountConfigId, accountId, count: items.length },
|
|
77
|
+
"Per-account purge subtree batch deleted",
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The container leftover — processed last in the FIFO group, after every subtree
|
|
83
|
+
* delete. Deletes the account-keyed container rows (mailboxes, outbox, locks),
|
|
84
|
+
* the storage prefix `accounts/{cfg}/{acct}/`, and invalidates the account's
|
|
85
|
+
* cached content. Ordering is invalidate-before-storage, storage-after-cascade.
|
|
86
|
+
* The tenant-shared Address rows, the AccountConfig, sibling accounts, and the
|
|
87
|
+
* soft-deleted account row (the purge-in-progress marker) are all kept.
|
|
88
|
+
*/
|
|
89
|
+
const processPurgeContainer = async (
|
|
90
|
+
event: Extract<AccountDataPurgeFinalizeEvent, { kind: "container" }>,
|
|
91
|
+
log: Logger,
|
|
92
|
+
deps: PurgeFinalizeDeps,
|
|
93
|
+
): Promise<void> => {
|
|
94
|
+
const { accountId, accountConfigId } = event;
|
|
95
|
+
const { capabilities, services } = deps;
|
|
96
|
+
|
|
97
|
+
// The account row is kept as the purge-in-progress marker. Its absence means
|
|
98
|
+
// the container leftover already ran — no-op on redelivery.
|
|
99
|
+
let accountDescription: Awaited<
|
|
100
|
+
ReturnType<CascadeServices["accountService"]["describe"]>
|
|
101
|
+
>;
|
|
102
|
+
try {
|
|
103
|
+
accountDescription = await services.accountService.describe(accountId);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if ((error as { name?: string })?.name === "NotFoundError") {
|
|
106
|
+
log.info(
|
|
107
|
+
{ accountConfigId, accountId },
|
|
108
|
+
"Per-account purge already complete (account row gone) — no-op",
|
|
109
|
+
);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Validate every deploy-time precondition BEFORE the first destructive step.
|
|
116
|
+
// Runs after the account-existence guard so a redelivery whose account row is
|
|
117
|
+
// already gone still returns cleanly without needing the config present.
|
|
118
|
+
await capabilities.assertReady(log);
|
|
119
|
+
|
|
120
|
+
// Step 1: content invalidation — first, so cached body parts cannot leak
|
|
121
|
+
// after the underlying objects are gone.
|
|
122
|
+
log.info(
|
|
123
|
+
{ accountConfigId, accountId },
|
|
124
|
+
"Invalidating cached content for purged account",
|
|
125
|
+
);
|
|
126
|
+
await capabilities.invalidateContent(`${accountConfigId}/${accountId}`, log);
|
|
127
|
+
|
|
128
|
+
// Step 2: container rows — mailboxes, outbox, locks. Address is
|
|
129
|
+
// tenant-shared and never deleted on a per-account purge.
|
|
130
|
+
const entities: CascadeEntity[] = [];
|
|
131
|
+
for (const mailbox of accountDescription.mailbox) {
|
|
132
|
+
entities.push({
|
|
133
|
+
entityType: "Mailbox",
|
|
134
|
+
key: { mailboxId: mailbox.mailboxId },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const outboxMessages =
|
|
138
|
+
await services.outboxMessageService.listByAccount(accountId);
|
|
139
|
+
for (const outbox of outboxMessages.items) {
|
|
140
|
+
entities.push({
|
|
141
|
+
entityType: "OutboxMessage",
|
|
142
|
+
key: { outboxMessageId: outbox.outboxMessageId },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const locks = await services.mailboxLockService.listByAccount(accountId);
|
|
146
|
+
for (const lock of locks) {
|
|
147
|
+
entities.push({
|
|
148
|
+
entityType: "MailboxLock",
|
|
149
|
+
key: { mailboxId: lock.mailboxId, eventName: lock.eventName },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
await capabilities.cascadeDelete(entities, log);
|
|
153
|
+
|
|
154
|
+
// Step 3: storage prefix cleanup scoped to this account only.
|
|
155
|
+
await capabilities.deleteStoragePrefix(
|
|
156
|
+
`accounts/${accountConfigId}/${accountId}/`,
|
|
157
|
+
log,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
log.info({ accountConfigId, accountId }, "Per-account data purge complete");
|
|
161
|
+
};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { GroupedQueueProducer } from "@remit/data-ports";
|
|
2
2
|
import type { Logger } from "@remit/logger";
|
|
3
3
|
import type { CascadeServices } from "../cascade.js";
|
|
4
|
-
import { getCascadeServices } from "../config.js";
|
|
5
4
|
import type {
|
|
6
5
|
AccountDataPurgeEvent,
|
|
7
6
|
AccountDataPurgeFinalizeEvent,
|
|
@@ -10,7 +9,7 @@ import type {
|
|
|
10
9
|
|
|
11
10
|
export interface ProcessPurgeFanoutDeps {
|
|
12
11
|
queue: GroupedQueueProducer<AccountDataPurgeFinalizeEvent>;
|
|
13
|
-
services
|
|
12
|
+
services: CascadeServices;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
/**
|
|
@@ -81,7 +80,7 @@ export const processAccountDataPurge = async (
|
|
|
81
80
|
deps: ProcessPurgeFanoutDeps,
|
|
82
81
|
): Promise<void> => {
|
|
83
82
|
const { accountId, accountConfigId } = event;
|
|
84
|
-
const services = deps
|
|
83
|
+
const { services } = deps;
|
|
85
84
|
|
|
86
85
|
let mailboxIds: Set<string>;
|
|
87
86
|
try {
|
|
@@ -10,6 +10,7 @@ import type { FilterItem } from "@remit/data-ports";
|
|
|
10
10
|
import { NotFoundError } from "@remit/data-ports/errors";
|
|
11
11
|
import { recordingQueue } from "@remit/data-ports/recording-queue";
|
|
12
12
|
import {
|
|
13
|
+
BETTER_SQLITE3_LIMITS,
|
|
13
14
|
createSqliteRepositories,
|
|
14
15
|
type DrizzleThreadMessageRepository,
|
|
15
16
|
} from "@remit/drizzle-service";
|
|
@@ -185,11 +186,14 @@ interface World {
|
|
|
185
186
|
|
|
186
187
|
const world = async (filters: FilterItem[]): Promise<World> => {
|
|
187
188
|
const store = createShippedSqliteDb();
|
|
188
|
-
const threadMessage = createSqliteRepositories(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
189
|
+
const threadMessage = createSqliteRepositories(
|
|
190
|
+
{
|
|
191
|
+
app: store.db,
|
|
192
|
+
mail: store.db,
|
|
193
|
+
search: store.db,
|
|
194
|
+
},
|
|
195
|
+
BETTER_SQLITE3_LIMITS,
|
|
196
|
+
).threadMessage;
|
|
193
197
|
const { inbox, archive } = await seedThreads(threadMessage);
|
|
194
198
|
const updates: Update[] = [];
|
|
195
199
|
const filterUpdates: World["filterUpdates"] = [];
|
package/src/process.ts
CHANGED
|
@@ -1,18 +1,39 @@
|
|
|
1
1
|
import type { RemitClient } from "@remit/backend/client";
|
|
2
|
+
import type { GroupedQueueProducer } from "@remit/data-ports";
|
|
2
3
|
import { createLogger } from "@remit/logger";
|
|
4
|
+
import { cascadeServicesOf } from "./cascade.js";
|
|
5
|
+
import type { DeletionCapabilities } from "./deletion-port.js";
|
|
3
6
|
import type {
|
|
7
|
+
AccountDataPurgeEvent,
|
|
8
|
+
AccountDataPurgeFinalizeEvent,
|
|
4
9
|
OrganizeJobEvent,
|
|
5
10
|
SenderCategoryBackApplyEvent,
|
|
6
11
|
} from "./events.js";
|
|
12
|
+
import { processAccountDataPurge } from "./handlers/account-purge.js";
|
|
13
|
+
import { processAccountDataPurgeFinalize } from "./handlers/account-purge-finalize.js";
|
|
7
14
|
import { processOrganizeJob } from "./handlers/organize-job.js";
|
|
8
15
|
import { processSenderCategoryBackApply } from "./handlers/sender-category-backapply.js";
|
|
9
16
|
|
|
17
|
+
export type { CascadeEntity } from "./cascade.js";
|
|
18
|
+
export type { DeletionCapabilities } from "./deletion-port.js";
|
|
19
|
+
export type { AccountDataPurgeEvent, AccountDataPurgeFinalizeEvent };
|
|
20
|
+
|
|
10
21
|
export type ClientFanoutEvent = OrganizeJobEvent | SenderCategoryBackApplyEvent;
|
|
11
22
|
|
|
12
23
|
export interface AccountFanoutContext {
|
|
13
24
|
client: RemitClient<"messageManagement">;
|
|
14
25
|
}
|
|
15
26
|
|
|
27
|
+
export type AccountPurgeEvent =
|
|
28
|
+
| AccountDataPurgeEvent
|
|
29
|
+
| AccountDataPurgeFinalizeEvent;
|
|
30
|
+
|
|
31
|
+
export interface AccountPurgeContext {
|
|
32
|
+
client: RemitClient;
|
|
33
|
+
purgeQueue: GroupedQueueProducer<AccountDataPurgeFinalizeEvent>;
|
|
34
|
+
capabilities: DeletionCapabilities;
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
const log = createLogger();
|
|
17
38
|
|
|
18
39
|
export const processAccountFanoutEvent = (
|
|
@@ -26,3 +47,22 @@ export const processAccountFanoutEvent = (
|
|
|
26
47
|
return processSenderCategoryBackApply(event, log, { client: ctx.client });
|
|
27
48
|
}
|
|
28
49
|
};
|
|
50
|
+
|
|
51
|
+
export const processAccountPurgeEvent = (
|
|
52
|
+
event: AccountPurgeEvent,
|
|
53
|
+
ctx: AccountPurgeContext,
|
|
54
|
+
): Promise<void> => {
|
|
55
|
+
const services = cascadeServicesOf(ctx.client);
|
|
56
|
+
switch (event.type) {
|
|
57
|
+
case "AccountDataPurge":
|
|
58
|
+
return processAccountDataPurge(event, log, {
|
|
59
|
+
services,
|
|
60
|
+
queue: ctx.purgeQueue,
|
|
61
|
+
});
|
|
62
|
+
case "FinalizeAccountDataPurge":
|
|
63
|
+
return processAccountDataPurgeFinalize(event, log, {
|
|
64
|
+
services,
|
|
65
|
+
capabilities: ctx.capabilities,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
};
|