@cosmicdrift/kumiko-bundled-features 0.313.0 → 0.315.0
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 +9 -9
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +66 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +10 -4
- package/src/compliance-profiles/resolve-for-tenant.ts +15 -5
- package/src/data-retention/__tests__/retention-cleanup-files.integration.test.ts +6 -6
- package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +1 -1
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +10 -10
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/feature.ts +1 -1
- package/src/data-retention/handlers/policy-for.query.ts +14 -31
- package/src/data-retention/index.ts +2 -0
- package/src/data-retention/resolve-for-tenant.ts +25 -17
- package/src/data-retention/resolve-tenant-preset.ts +5 -2
- package/src/data-retention/run-retention-cleanup.ts +5 -4
- package/src/delivery/__tests__/delivery.integration.test.ts +189 -19
- package/src/delivery/address-opt-out.ts +49 -21
- package/src/delivery/changes.json +12 -0
- package/src/delivery/constants.ts +1 -0
- package/src/delivery/delivery-service.ts +48 -27
- package/src/delivery/feature.ts +5 -1
- package/src/delivery/handlers/unsubscribe-address.write.ts +40 -0
- package/src/delivery/handlers/unsubscribe-user.write.ts +43 -0
- package/src/delivery/index.ts +1 -0
- package/src/delivery/public-names.ts +6 -0
- package/src/delivery/unsubscribe.ts +144 -67
- package/src/delivery/upsert-preference.ts +74 -60
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +1 -0
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +2 -1
- package/src/file-derivatives/__tests__/public-variant-predicate-args.integration.test.ts +2 -1
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +4 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/is-tenant-db.ts +9 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +5 -1
- package/src/user-data-rights/__tests__/retention-preset-forget.integration.test.ts +272 -0
- package/src/user-data-rights/run-forget-cleanup.ts +6 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +9 -7
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +7 -2
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Retention-preset-driven forget + policy-for (kumiko-framework#3283, Option B).
|
|
2
|
+
//
|
|
3
|
+
// Before this fix, forget (run-forget-cleanup.ts) and policy-for
|
|
4
|
+
// (policy-for.query.ts) resolved retention WITHOUT the tenant's
|
|
5
|
+
// compliance-profile-derived preset (Layer 2 of the 3-layer resolver): an
|
|
6
|
+
// entity with no own `retention` default (like `invoice`) fell straight to
|
|
7
|
+
// `source: "none"` -> policyToStrategy(null) -> "delete", hard-deleting data
|
|
8
|
+
// a mapped compliance profile (e.g. `de-hr-dsgvo-hgb`) requires to survive as
|
|
9
|
+
// blockDelete/anonymize. This proves the real end-to-end chain:
|
|
10
|
+
// compliance-profiles:write:set-profile -> resolveProfileForTenant ->
|
|
11
|
+
// resolveTenantRetentionPreset -> resolveRetentionPolicyForTenant ->
|
|
12
|
+
// (forget) policyToStrategy / (policy-for query) EffectiveRetentionPolicy.source
|
|
13
|
+
//
|
|
14
|
+
// A test-only `invoice` entity stands in for the real dsgvo-hgb preset entry
|
|
15
|
+
// (RETENTION_PRESETS["dsgvo-hgb"].invoice = blockDelete/10y) since no bundled
|
|
16
|
+
// feature ships a real invoice entity.
|
|
17
|
+
|
|
18
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
20
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
21
|
+
import {
|
|
22
|
+
buildEntityTable,
|
|
23
|
+
createEventStoreExecutor,
|
|
24
|
+
createTenantDb,
|
|
25
|
+
type TenantDb,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
27
|
+
import {
|
|
28
|
+
access,
|
|
29
|
+
createEntity,
|
|
30
|
+
createSystemUser,
|
|
31
|
+
createTextField,
|
|
32
|
+
defineFeature,
|
|
33
|
+
EXT_USER_DATA,
|
|
34
|
+
type FeatureDefinition,
|
|
35
|
+
type UserDataDeleteHook,
|
|
36
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
37
|
+
import {
|
|
38
|
+
createTestUser,
|
|
39
|
+
setupTestStack,
|
|
40
|
+
type TestStack,
|
|
41
|
+
unsafeCreateEntityTable,
|
|
42
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
43
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
44
|
+
import * as z from "zod";
|
|
45
|
+
import {
|
|
46
|
+
ComplianceProfileHandlers,
|
|
47
|
+
createComplianceProfilesFeature,
|
|
48
|
+
tenantComplianceProfileEntity,
|
|
49
|
+
tenantComplianceProfileTable,
|
|
50
|
+
} from "../../compliance-profiles";
|
|
51
|
+
import {
|
|
52
|
+
createDataRetentionFeature,
|
|
53
|
+
resolveRetentionPolicyForTenant,
|
|
54
|
+
tenantRetentionOverrideEntity,
|
|
55
|
+
tenantRetentionOverrideTable,
|
|
56
|
+
} from "../../data-retention";
|
|
57
|
+
import { createSessionsFeature } from "../../sessions";
|
|
58
|
+
import { createUserFeature, userEntity, userTable } from "../../user";
|
|
59
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
60
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
61
|
+
import {
|
|
62
|
+
createForgetSeeders,
|
|
63
|
+
nowInstant,
|
|
64
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
65
|
+
} from "./forget-test-helpers";
|
|
66
|
+
|
|
67
|
+
const POLICY_FOR = "data-retention:query:policy-for";
|
|
68
|
+
const CREATE_INVOICE = "test-invoice:write:create";
|
|
69
|
+
const INVOICE_TABLE = "read_test_retention_invoices";
|
|
70
|
+
|
|
71
|
+
// No own `retention` default -> Layer 1 is empty, so whether forget/policy-for
|
|
72
|
+
// see the preset (Layer 2) is exactly what's under test.
|
|
73
|
+
const invoiceEntity = createEntity({
|
|
74
|
+
table: INVOICE_TABLE,
|
|
75
|
+
fields: {
|
|
76
|
+
amount: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const invoiceTable = buildEntityTable("invoice", invoiceEntity);
|
|
80
|
+
const invoiceCrud = createEventStoreExecutor(invoiceTable, invoiceEntity, {
|
|
81
|
+
entityName: "invoice",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Mirrors fileRefDeleteHook's executor-based pattern (forget writes go
|
|
85
|
+
// through events, not deleteMany/updateMany, so a projection rebuild replays
|
|
86
|
+
// the erasure): hard-delete on "delete", sever the owner-link on "anonymize".
|
|
87
|
+
const invoiceDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
88
|
+
const systemUser = createSystemUser(ctx.tenantId);
|
|
89
|
+
const rows = await ctx.db.selectMany<{ id: string }>(invoiceTable, {
|
|
90
|
+
tenantId: ctx.tenantId,
|
|
91
|
+
insertedById: ctx.userId,
|
|
92
|
+
});
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
if (strategy === "delete") {
|
|
95
|
+
await invoiceCrud.forget({ id: row.id }, systemUser, ctx.db);
|
|
96
|
+
} else {
|
|
97
|
+
await invoiceCrud.update(
|
|
98
|
+
{ id: row.id, changes: { insertedById: null } },
|
|
99
|
+
systemUser,
|
|
100
|
+
ctx.db,
|
|
101
|
+
{
|
|
102
|
+
skipOptimisticLock: true,
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const testInvoiceFeature: FeatureDefinition = defineFeature("test-invoice", (r) => {
|
|
110
|
+
r.entity("invoice", invoiceEntity);
|
|
111
|
+
const handlers = {
|
|
112
|
+
create: r.writeHandler({
|
|
113
|
+
name: "create",
|
|
114
|
+
schema: z.object({ amount: z.string() }),
|
|
115
|
+
access: { roles: access.authenticated },
|
|
116
|
+
description: "test-only invoice creation for the retention-preset forget test",
|
|
117
|
+
handler: async (event, ctx) =>
|
|
118
|
+
invoiceCrud.create({ amount: event.payload.amount }, event.user, ctx.db),
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
r.useExtension(EXT_USER_DATA, "invoice", {
|
|
122
|
+
export: async () => null,
|
|
123
|
+
delete: invoiceDeleteHook,
|
|
124
|
+
});
|
|
125
|
+
return { handlers };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const TENANT_A = "00000000-0000-4000-8000-0000000000e1";
|
|
129
|
+
const TENANT_B = "00000000-0000-4000-8000-0000000000e2";
|
|
130
|
+
const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000e1";
|
|
131
|
+
|
|
132
|
+
let stack: TestStack;
|
|
133
|
+
// biome-ignore lint/suspicious/noExplicitAny: dummy writer; this fixture has no binaries.
|
|
134
|
+
const seed = (db: unknown) => createForgetSeeders(db as any, { write: async () => {} });
|
|
135
|
+
|
|
136
|
+
function tenantAdmin(id: string, tenantId: string) {
|
|
137
|
+
return createTestUser({ id, tenantId, roles: ["TenantAdmin"] });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function fetchInvoice(
|
|
141
|
+
id: string,
|
|
142
|
+
): Promise<{ id: string; inserted_by_id: string | null } | null> {
|
|
143
|
+
const result = await asRawClient(stack.db).unsafe(
|
|
144
|
+
`SELECT id, inserted_by_id FROM ${INVOICE_TABLE} WHERE id = $1`,
|
|
145
|
+
[id],
|
|
146
|
+
);
|
|
147
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
148
|
+
const rows = ((result as any).rows ?? result) as Array<{
|
|
149
|
+
id: string;
|
|
150
|
+
inserted_by_id: string | null;
|
|
151
|
+
}>;
|
|
152
|
+
return rows[0] ?? null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
beforeAll(async () => {
|
|
156
|
+
stack = await setupTestStack({
|
|
157
|
+
features: [
|
|
158
|
+
createUserFeature(),
|
|
159
|
+
authFoundationFeature,
|
|
160
|
+
createSessionsFeature(),
|
|
161
|
+
createDataRetentionFeature(),
|
|
162
|
+
createComplianceProfilesFeature(),
|
|
163
|
+
createUserDataRightsFeature(),
|
|
164
|
+
testInvoiceFeature,
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
168
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
169
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
170
|
+
await unsafeCreateEntityTable(stack.db, invoiceEntity, "invoice");
|
|
171
|
+
await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
afterAll(async () => {
|
|
175
|
+
await stack.cleanup();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
beforeEach(async () => {
|
|
179
|
+
await resetTestTables(stack.db, [
|
|
180
|
+
userTable,
|
|
181
|
+
"read_tenant_memberships",
|
|
182
|
+
invoiceTable,
|
|
183
|
+
tenantRetentionOverrideTable,
|
|
184
|
+
tenantComplianceProfileTable,
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("data-retention preset honored by forget + policy-for (kumiko-framework#3283)", () => {
|
|
189
|
+
test("tenant with a mapped compliance profile -> invoice forget anonymizes (blockDelete preset); tenant without one -> hard-deleted", async () => {
|
|
190
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
191
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT_A);
|
|
192
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT_B);
|
|
193
|
+
|
|
194
|
+
const adminA = tenantAdmin("11111111-0000-4000-8000-0000000000e1", TENANT_A);
|
|
195
|
+
await stack.http.writeOk(
|
|
196
|
+
ComplianceProfileHandlers.setProfile,
|
|
197
|
+
{ profileKey: "de-hr-dsgvo-hgb" },
|
|
198
|
+
adminA,
|
|
199
|
+
);
|
|
200
|
+
// Tenant B: no profile set -> control, unchanged hard-delete behavior.
|
|
201
|
+
|
|
202
|
+
const invoiceUserA = { id: FORGET_USER, tenantId: TENANT_A, roles: ["User"] };
|
|
203
|
+
const invoiceUserB = { id: FORGET_USER, tenantId: TENANT_B, roles: ["User"] };
|
|
204
|
+
const invoiceA = await stack.http.writeOk<{ id: string }>(
|
|
205
|
+
CREATE_INVOICE,
|
|
206
|
+
{ amount: "100.00" },
|
|
207
|
+
invoiceUserA,
|
|
208
|
+
);
|
|
209
|
+
const invoiceB = await stack.http.writeOk<{ id: string }>(
|
|
210
|
+
CREATE_INVOICE,
|
|
211
|
+
{ amount: "200.00" },
|
|
212
|
+
invoiceUserB,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const result = await runForgetCleanup({
|
|
216
|
+
db: stack.db,
|
|
217
|
+
registry: stack.registry,
|
|
218
|
+
now: nowInstant(),
|
|
219
|
+
});
|
|
220
|
+
expect(result.errors).toHaveLength(0);
|
|
221
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
222
|
+
|
|
223
|
+
const rowA = await fetchInvoice(invoiceA.id);
|
|
224
|
+
expect(rowA).not.toBeNull();
|
|
225
|
+
expect(rowA?.inserted_by_id).toBeNull();
|
|
226
|
+
|
|
227
|
+
expect(await fetchInvoice(invoiceB.id)).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("policy-for query reflects the same preset layer per tenant", async () => {
|
|
231
|
+
const adminA = tenantAdmin("11111111-0000-4000-8000-0000000000e2", TENANT_A);
|
|
232
|
+
const adminB = tenantAdmin("11111111-0000-4000-8000-0000000000e3", TENANT_B);
|
|
233
|
+
await stack.http.writeOk(
|
|
234
|
+
ComplianceProfileHandlers.setProfile,
|
|
235
|
+
{ profileKey: "de-hr-dsgvo-hgb" },
|
|
236
|
+
adminA,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const resultA = await stack.http.queryOk<{
|
|
240
|
+
source: string;
|
|
241
|
+
policy: { strategy: string } | null;
|
|
242
|
+
}>(POLICY_FOR, { entityName: "invoice" }, adminA);
|
|
243
|
+
expect(resultA.source).toBe("preset");
|
|
244
|
+
expect(resultA.policy?.strategy).toBe("blockDelete");
|
|
245
|
+
|
|
246
|
+
const resultB = await stack.http.queryOk<{ source: string }>(
|
|
247
|
+
POLICY_FOR,
|
|
248
|
+
{ entityName: "invoice" },
|
|
249
|
+
adminB,
|
|
250
|
+
);
|
|
251
|
+
expect(resultB.source).not.toBe("preset");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("resolveRetentionPolicyForTenant resolves the preset through a TenantDb, not just a bulk DbRunner", async () => {
|
|
255
|
+
const adminC = tenantAdmin("11111111-0000-4000-8000-0000000000e4", TENANT_A);
|
|
256
|
+
await stack.http.writeOk(
|
|
257
|
+
ComplianceProfileHandlers.setProfile,
|
|
258
|
+
{ profileKey: "de-hr-dsgvo-hgb" },
|
|
259
|
+
adminC,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const tenantDb: TenantDb = createTenantDb(stack.db, TENANT_A, "tenant");
|
|
263
|
+
const policy = await resolveRetentionPolicyForTenant({
|
|
264
|
+
db: tenantDb,
|
|
265
|
+
registry: stack.registry,
|
|
266
|
+
tenantId: TENANT_A,
|
|
267
|
+
entityName: "invoice",
|
|
268
|
+
});
|
|
269
|
+
expect(policy.source).toBe("preset");
|
|
270
|
+
expect(policy.policy?.strategy).toBe("blockDelete");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
type SearchAdapter,
|
|
64
64
|
} from "@cosmicdrift/kumiko-framework/search";
|
|
65
65
|
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
66
|
-
import { resolveRetentionPolicyForTenant } from "../data-retention";
|
|
66
|
+
import { resolveRetentionPolicyForTenant, resolveTenantRetentionPreset } from "../data-retention";
|
|
67
67
|
import { decryptStoredPii, runInSubTransaction } from "../shared";
|
|
68
68
|
import { tenantMembershipsTable } from "../tenant";
|
|
69
69
|
import { USER_STATUS, userTable } from "../user";
|
|
@@ -430,6 +430,10 @@ async function processUser(args: {
|
|
|
430
430
|
// tenant truly has one member, so a stray invite can't let a per-user
|
|
431
431
|
// forget erase a co-member's tenant-scoped data (money-path safety).
|
|
432
432
|
const tenantModel = await resolveEffectiveTenantModel(tx, tenantId, appTenantModel);
|
|
433
|
+
// Resolved ONCE per tenant (not per hookEntry below) — the compliance-
|
|
434
|
+
// profile lookup it does internally is a single indexed read; N entities
|
|
435
|
+
// re-deriving it per tenant would be N reads for the same answer.
|
|
436
|
+
const tenantPreset = await resolveTenantRetentionPreset({ db: tx, registry, tenantId });
|
|
433
437
|
for (const entry of hookEntries) {
|
|
434
438
|
currentEntityName = entry.entityName;
|
|
435
439
|
const policy = await resolveRetentionPolicyForTenant({
|
|
@@ -437,6 +441,7 @@ async function processUser(args: {
|
|
|
437
441
|
registry,
|
|
438
442
|
tenantId,
|
|
439
443
|
entityName: entry.entityName,
|
|
444
|
+
preloadedTenantPreset: tenantPreset,
|
|
440
445
|
});
|
|
441
446
|
const strategy = policyToStrategy(policy.policy?.strategy ?? null);
|
|
442
447
|
|
|
@@ -38,8 +38,9 @@ const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName
|
|
|
38
38
|
// not the subject axis.
|
|
39
39
|
// "anonymize": insertedById=null, row + binary survive (FK refs can still
|
|
40
40
|
// point at it; person-link removed) — applies to ALL rows
|
|
41
|
-
// when
|
|
42
|
-
// blockDelete retention)
|
|
41
|
+
// when a retention override on fileRef (anonymize or
|
|
42
|
+
// blockDelete, e.g. a statutory retention duty) sets the
|
|
43
|
+
// strategy, regardless of the field.
|
|
43
44
|
//
|
|
44
45
|
// **Provider-Resolution:** der Provider kommt zur Lauf-Zeit aus
|
|
45
46
|
// `ctx.buildStorageProvider(ctx.tenantId)` — der Forget-Orchestrator
|
|
@@ -216,11 +217,12 @@ export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
|
216
217
|
});
|
|
217
218
|
|
|
218
219
|
if (strategy !== "delete") {
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
220
|
+
// Only reachable via an explicit retention override on fileRef:
|
|
221
|
+
// policyToStrategy maps anonymize and blockDelete to "anonymize", typically
|
|
222
|
+
// for a statutory retention duty (Art. 17(3)(b) GDPR, HGB/AO). Row and
|
|
223
|
+
// binary must survive, so only the uploader link is severed. Applies to
|
|
224
|
+
// ALL rows because the override replaces the per-field decision below,
|
|
225
|
+
// which only runs for strategy="delete".
|
|
224
226
|
await severPersonLink(ctx.db, systemUser, rows);
|
|
225
227
|
// skip: anonymize is complete — the hard-delete path below runs only for strategy "delete".
|
|
226
228
|
return;
|
|
@@ -23,7 +23,12 @@ import {
|
|
|
23
23
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
24
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
25
25
|
import { getConsumerState } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
drainEventConsumers,
|
|
28
|
+
setupTestStack,
|
|
29
|
+
type TestStack,
|
|
30
|
+
TestUsers,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
32
|
import { workflowRunAggregateId } from "../aggregate-id";
|
|
28
33
|
import { registerEventTrigger } from "../event-trigger";
|
|
29
34
|
import { workflowRunnerFeature } from "../feature";
|
|
@@ -190,7 +195,7 @@ describe("workflow-runner event-trigger", () => {
|
|
|
190
195
|
// Consumers run their turns concurrently, so events an earlier test's
|
|
191
196
|
// workflow emitted may still be ahead of this consumer's cursor; drain
|
|
192
197
|
// them first so `processed` below counts only this test's trigger.
|
|
193
|
-
|
|
198
|
+
await drainEventConsumers(stack, [consumerName]);
|
|
194
199
|
|
|
195
200
|
await insertOne(stack.db, eventsTable, {
|
|
196
201
|
aggregateId: crypto.randomUUID(),
|