@cosmicdrift/kumiko-bundled-features 0.305.0 → 0.306.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.
Files changed (63) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/extension-points-typed.test.ts +135 -0
  3. package/src/__tests__/use-extension-options.test.ts +278 -0
  4. package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
  5. package/src/auth-foundation/__tests__/tenant-providers.test.ts +1 -0
  6. package/src/auth-foundation/__tests__/token-verifier.test.ts +2 -0
  7. package/src/auth-foundation/types.ts +14 -4
  8. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +4 -63
  9. package/src/billing-foundation/__tests__/tenant-destroy.integration.test.ts +199 -0
  10. package/src/billing-foundation/changes.json +6 -0
  11. package/src/billing-foundation/feature.ts +8 -1
  12. package/src/billing-foundation/tenant-destroy-hook.ts +20 -20
  13. package/src/billing-foundation/types.ts +13 -1
  14. package/src/channel-email/feature.ts +2 -1
  15. package/src/channel-in-app/feature.ts +2 -1
  16. package/src/channel-push/feature.ts +2 -1
  17. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +14 -3
  18. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +11 -3
  19. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +7 -3
  20. package/src/config/__tests__/require-system-db.test.ts +3 -3
  21. package/src/config/handlers/reencrypt.job.ts +4 -8
  22. package/src/custom-fields/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +25 -24
  23. package/src/delivery/__tests__/delivery-screens.boot.test.ts +20 -2
  24. package/src/delivery/__tests__/delivery.integration.test.ts +140 -3
  25. package/src/delivery/constants.ts +4 -0
  26. package/src/delivery/delivery-service.ts +45 -29
  27. package/src/delivery/feature.ts +2 -1
  28. package/src/delivery/index.ts +14 -11
  29. package/src/delivery/types.ts +24 -0
  30. package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +0 -27
  31. package/src/document-ingest-foundation/__tests__/tenant-destroy.integration.test.ts +166 -0
  32. package/src/document-ingest-foundation/changes.json +5 -0
  33. package/src/document-ingest-foundation/tenant-destroy-hook.ts +6 -17
  34. package/src/file-derivatives/handlers/public-variant.query.ts +7 -0
  35. package/src/files/__tests__/files.integration.test.ts +2 -2
  36. package/src/form-draft/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +24 -21
  37. package/src/inbound-mail-foundation/__tests__/tenant-destroy.integration.test.ts +173 -0
  38. package/src/inbound-mail-foundation/changes.json +6 -0
  39. package/src/inbound-mail-foundation/feature.ts +4 -0
  40. package/src/inbound-mail-foundation/tenant-destroy-hook.ts +15 -20
  41. package/src/inbound-mail-foundation/types.ts +10 -3
  42. package/src/jobs/__tests__/job-system-user.integration.test.ts +3 -3
  43. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +15 -6
  44. package/src/mail-foundation/feature.ts +15 -4
  45. package/src/mail-foundation/index.ts +1 -0
  46. package/src/mail-transport-inmemory/feature.ts +5 -4
  47. package/src/mail-transport-smtp/feature.ts +5 -4
  48. package/src/renderer-foundation/__tests__/collect-plugins.integration.test.ts +24 -2
  49. package/src/renderer-foundation/changes.json +8 -1
  50. package/src/renderer-foundation/constants.ts +4 -0
  51. package/src/renderer-foundation/feature.ts +10 -13
  52. package/src/renderer-foundation/index.ts +3 -0
  53. package/src/renderer-foundation/types.ts +23 -1
  54. package/src/renderer-simple/feature.ts +2 -1
  55. package/src/tenant/__tests__/multi-tenant.integration.test.ts +2 -2
  56. package/src/tenant-lifecycle/__tests__/stages.test.ts +51 -0
  57. package/src/tenant-lifecycle/stages.ts +20 -18
  58. package/src/user/db/queries/__tests__/unsafe-read-retrying.integration.test.ts +54 -0
  59. package/src/user-data-rights/__tests__/download.integration.test.ts +3 -0
  60. package/src/user-data-rights/lib/default-mailers.ts +2 -1
  61. package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +6 -2
  62. package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +6 -2
  63. package/src/user/db/queries/__tests__/unsafe-read-retrying.test.ts +0 -52
@@ -1,9 +1,16 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { access, isOpenToAllGranted, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ access,
4
+ createRegistry,
5
+ defineFeature,
6
+ isOpenToAllGranted,
7
+ validateBoot,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
3
9
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
10
  import { createConfigFeature } from "../../config/feature";
5
11
  import { createTenantFeature } from "../../tenant/feature";
6
- import { DELIVERY_LOG_SCREEN_ID } from "../constants";
12
+ import { DELIVERY_CHANNEL_EXTENSION, DELIVERY_LOG_SCREEN_ID } from "../constants";
13
+ import { collectChannels } from "../delivery-service";
7
14
  import { createDeliveryFeature } from "../feature";
8
15
 
9
16
  describe("delivery screens + handler access alignment", () => {
@@ -38,6 +45,17 @@ describe("delivery screens + handler access alignment", () => {
38
45
  expect(preferencesAccess !== undefined && isOpenToAllGranted(preferencesAccess)).toBe(true);
39
46
  });
40
47
 
48
+ test("collectChannels fails loud on a registration with invalid options", () => {
49
+ const broken = defineFeature("broken-channel", (r) => {
50
+ // @ts-expect-error resolve/send are required — proves the guard rejects a malformed options bag instead of silently dropping the channel
51
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "bad-entity", { anything: 1 });
52
+ });
53
+ const registry = createRegistry([...features, broken]);
54
+ expect(() => collectChannels(registry)).toThrow(
55
+ `${DELIVERY_CHANNEL_EXTENSION} registration for "bad-entity" has invalid options`,
56
+ );
57
+ });
58
+
41
59
  test("boot-validates with a narrowed access option", () => {
42
60
  expect(() =>
43
61
  validateBoot([
@@ -4,8 +4,8 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
4
  import {
5
5
  buildEntityTable,
6
6
  createEventStoreExecutor,
7
+ createSystemDbView,
7
8
  createTenantDb,
8
- createUncheckedSystemDb,
9
9
  } from "@cosmicdrift/kumiko-framework/db";
10
10
  import {
11
11
  createEntity,
@@ -251,6 +251,65 @@ const ticketFeature = defineFeature("tickets", (r) => {
251
251
  });
252
252
  });
253
253
 
254
+ // A tenantUserIdsQuery handler NOT declared r.systemScope() must get the same
255
+ // tenant-filtered ctx.db / no ctx.systemDb a real dispatch would give it —
256
+ // captured here so the test can assert on it directly.
257
+ let capturedNonSystemQueryCtx: { hasSystemDb: boolean; dbMode: string } | undefined;
258
+
259
+ const nonSystemUserIdsQueryName = "test-non-system-broadcast:query:non-system-user-ids";
260
+
261
+ const nonSystemBroadcastFeature = defineFeature("test-non-system-broadcast", (r) => {
262
+ r.queryHandler({
263
+ name: "nonSystemUserIds",
264
+ schema: z.object({ tenantId: z.string() }),
265
+ access: { roles: ["Admin"] },
266
+ handler: async (_query, ctx) => {
267
+ capturedNonSystemQueryCtx = { hasSystemDb: ctx.systemDb !== undefined, dbMode: ctx.db.mode };
268
+ const rows = await selectMany<{ userId: string }>(ctx.db, tenantMembershipsTable, {});
269
+ return rows.map((r2) => r2.userId);
270
+ },
271
+ });
272
+ });
273
+
274
+ // The dispatcher grants a systemScope handler's ctx.systemDb.unsafeRaw ungated —
275
+ // the grant comes from this feature's own r.systemScope() declaration, not from
276
+ // the delivery service caller. ctx.db stays ungated even here.
277
+ let capturedSystemQueryCtx:
278
+ | { systemDbUnsafeRawWorked: boolean; dbUnsafeRawThrew: boolean }
279
+ | undefined;
280
+
281
+ const systemUserIdsQueryName = "test-system-broadcast:query:system-user-ids";
282
+
283
+ const systemBroadcastFeature = defineFeature("test-system-broadcast", (r) => {
284
+ r.systemScope();
285
+ r.queryHandler({
286
+ name: "systemUserIds",
287
+ schema: z.object({ tenantId: z.string() }),
288
+ access: { roles: ["Admin"] },
289
+ handler: async (_query, ctx) => {
290
+ let dbUnsafeRawThrew = false;
291
+ try {
292
+ ctx.db.unsafeRaw("x");
293
+ } catch {
294
+ dbUnsafeRawThrew = true;
295
+ }
296
+ const runner = ctx.systemDb?.unsafeRaw(
297
+ "test: systemScope handler grants ctx.systemDb.unsafeRaw",
298
+ );
299
+ const rows = runner
300
+ ? await selectMany<{ userId: string }>(runner, tenantMembershipsTable, {
301
+ tenantId: _query.payload.tenantId,
302
+ })
303
+ : undefined;
304
+ capturedSystemQueryCtx = {
305
+ systemDbUnsafeRawWorked: rows !== undefined,
306
+ dbUnsafeRawThrew,
307
+ };
308
+ return (rows ?? []).map((r2) => r2.userId);
309
+ },
310
+ });
311
+ });
312
+
254
313
  const configFeature = createConfigFeature();
255
314
  const tenantFeature = createTenantFeature();
256
315
  const templateResolverFeature = createTemplateResolverFeature();
@@ -279,6 +338,8 @@ const features = [
279
338
  channelPushFeature,
280
339
  appFeature,
281
340
  ticketFeature,
341
+ nonSystemBroadcastFeature,
342
+ systemBroadcastFeature,
282
343
  ] as const;
283
344
 
284
345
  beforeAll(async () => {
@@ -1327,6 +1388,76 @@ describe("flow 15b: escape-hatch audit for tenant broadcast", () => {
1327
1388
  });
1328
1389
  });
1329
1390
 
1391
+ // --- Flow 15c: tenantUserIdsQuery that is NOT r.systemScope() ---
1392
+
1393
+ describe("flow 15c: tenantUserIdsQuery handler without r.systemScope()", () => {
1394
+ test("gets a tenant-mode db and no systemDb, and the broadcast still resolves recipients", async () => {
1395
+ const nonSystemService = createDeliveryService({
1396
+ db,
1397
+ registry: stack.registry,
1398
+ channels: collectChannels(stack.registry),
1399
+ tenantUserIdsQuery: nonSystemUserIdsQueryName,
1400
+ });
1401
+
1402
+ // Unique title — flow 8 already broadcasts app:notify:tenant-alert to the same
1403
+ // admin/user1/user2, so a plain notificationType filter would pass even if this
1404
+ // service delivered nothing new. Filtering by this test's own title isolates it.
1405
+ const nonSystemBroadcastTitle = "non-system-broadcast-flow-15c";
1406
+ await nonSystemService.notify(
1407
+ "app:notify:tenant-alert",
1408
+ { to: { tenant: admin.tenantId }, data: { title: nonSystemBroadcastTitle, body: "X" } },
1409
+ admin,
1410
+ admin.tenantId,
1411
+ );
1412
+
1413
+ expect(capturedNonSystemQueryCtx).toEqual({ hasSystemDb: false, dbMode: "tenant" });
1414
+
1415
+ const messages = await selectMany(db, inAppMessagesTable, {
1416
+ notificationType: "app:notify:tenant-alert",
1417
+ title: nonSystemBroadcastTitle,
1418
+ });
1419
+ const recipientIds = messages.map((m) => m["userId"]);
1420
+ expect(recipientIds).toContain(admin.id);
1421
+ expect(recipientIds).toContain(user1.id);
1422
+ expect(recipientIds).toContain(user2.id);
1423
+ });
1424
+ });
1425
+
1426
+ // --- Flow 15d: tenantUserIdsQuery that IS r.systemScope() ---
1427
+
1428
+ describe("flow 15d: tenantUserIdsQuery handler with r.systemScope()", () => {
1429
+ test("ctx.systemDb.unsafeRaw works, ctx.db.unsafeRaw is denied, broadcast still resolves recipients", async () => {
1430
+ const systemService = createDeliveryService({
1431
+ db,
1432
+ registry: stack.registry,
1433
+ channels: collectChannels(stack.registry),
1434
+ tenantUserIdsQuery: systemUserIdsQueryName,
1435
+ });
1436
+
1437
+ const systemBroadcastTitle = "system-broadcast-flow-15d";
1438
+ await systemService.notify(
1439
+ "app:notify:tenant-alert",
1440
+ { to: { tenant: admin.tenantId }, data: { title: systemBroadcastTitle, body: "X" } },
1441
+ admin,
1442
+ admin.tenantId,
1443
+ );
1444
+
1445
+ expect(capturedSystemQueryCtx).toEqual({
1446
+ systemDbUnsafeRawWorked: true,
1447
+ dbUnsafeRawThrew: true,
1448
+ });
1449
+
1450
+ const messages = await selectMany(db, inAppMessagesTable, {
1451
+ notificationType: "app:notify:tenant-alert",
1452
+ title: systemBroadcastTitle,
1453
+ });
1454
+ const recipientIds = messages.map((m) => m["userId"]);
1455
+ expect(recipientIds).toContain(admin.id);
1456
+ expect(recipientIds).toContain(user1.id);
1457
+ expect(recipientIds).toContain(user2.id);
1458
+ });
1459
+ });
1460
+
1330
1461
  // --- Flow 16: Unsubscribe race — ON CONFLICT makes repeated clicks safe ---
1331
1462
 
1332
1463
  describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
@@ -1545,10 +1676,16 @@ describe("flow 17: async render→send pipeline", () => {
1545
1676
 
1546
1677
  // Mirrors what job-runner.ts's handleJob really builds for a
1547
1678
  // r.systemScope()'d job (framework#2105) — a real TenantDb wrapped by
1548
- // createUncheckedSystemDb, not a mock, so requireTenantScopedDeps'
1679
+ // createSystemDbView, not a mock, so requireTenantScopedDeps'
1549
1680
  // assertTenantMatch() call exercises the actual production code path.
1681
+ // The unsafeRaw grant mirrors the systemScope() grant that job-runner.ts's
1682
+ // real ctx.systemDb carries in production.
1550
1683
  function makeSystemDb(tenantId: TenantId) {
1551
- return createUncheckedSystemDb(createTenantDb(db, tenantId, "system"));
1684
+ return createSystemDbView(
1685
+ createTenantDb(db, tenantId, "system", undefined, undefined, undefined, {
1686
+ unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
1687
+ }),
1688
+ );
1552
1689
  }
1553
1690
 
1554
1691
  test("delivery.render on a channel without a render step records failed and does not dispatch send", async () => {
@@ -2,6 +2,10 @@ import type { NotifyPriority } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { QnTypes, qn } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { DELIVERY_FEATURE, DeliveryJobNames } from "./public-names";
4
4
 
5
+ // Extension-point name for delivery-channel plugins (channel-email,
6
+ // channel-in-app, channel-push, ...).
7
+ export const DELIVERY_CHANNEL_EXTENSION = "deliveryChannel" as const;
8
+
5
9
  export {
6
10
  DELIVERY_ATTEMPT_EVENT,
7
11
  DELIVERY_FEATURE,
@@ -1,6 +1,6 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
3
- import { createTenantDb, createUncheckedSystemDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
4
  import type {
5
5
  EscapeHatchAuditSink,
6
6
  NotifyPriority,
@@ -16,14 +16,15 @@ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
16
16
  import type { Redis } from "ioredis";
17
17
  import { appendAttemptEvent, logAttempt } from "./attempt-log";
18
18
  import { buildChannelContext } from "./channel-context";
19
- import { DeliveryJobs, deliveryPriorityRank } from "./constants";
19
+ import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants";
20
20
  import { selectNotificationPreferences } from "./db/queries/preferences";
21
- import type {
22
- ChannelContext,
23
- ChannelMessage,
24
- DeliveryChannel,
25
- DeliveryLogEntry,
26
- DeliveryService,
21
+ import {
22
+ type ChannelContext,
23
+ type ChannelMessage,
24
+ type DeliveryChannel,
25
+ type DeliveryLogEntry,
26
+ type DeliveryService,
27
+ isDeliveryChannelPlugin,
27
28
  } from "./types";
28
29
 
29
30
  export type RateLimitConfig = {
@@ -58,22 +59,14 @@ export type DeliveryServiceOptions = {
58
59
 
59
60
  // Build channel list from registry extension usages
60
61
  export function collectChannels(registry: Registry): DeliveryChannel[] {
61
- const usages = registry.getExtensionUsages("deliveryChannel");
62
+ const usages = registry.getExtensionUsages(DELIVERY_CHANNEL_EXTENSION);
62
63
  return usages.map((usage) => {
63
- // @cast-boundary engine-payload — extension-usage carries unknown options
64
- const opts = usage.options as {
65
- mode: DeliveryChannel["mode"];
66
- resolve: DeliveryChannel["resolve"];
67
- render?: DeliveryChannel["render"];
68
- send: DeliveryChannel["send"];
69
- };
70
- return {
71
- name: usage.entityName,
72
- mode: opts.mode,
73
- resolve: opts.resolve,
74
- render: opts.render,
75
- send: opts.send,
76
- };
64
+ if (!isDeliveryChannelPlugin(usage.options)) {
65
+ throw new Error(
66
+ `${DELIVERY_CHANNEL_EXTENSION} registration for "${usage.entityName}" has invalid options`,
67
+ );
68
+ }
69
+ return { name: usage.entityName, ...usage.options };
77
70
  });
78
71
  }
79
72
 
@@ -177,18 +170,41 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
177
170
  sink: escapeHatchAuditSink,
178
171
  log,
179
172
  });
180
- const tenantDb = createTenantDb(db, tenantId, "system", undefined, undefined, undefined, {
181
- report,
182
- });
183
- // Hand-built context, not routed through the dispatcher — tenantUserIdsQuery is
184
- // typically an r.systemScope() handler, fail-closed on ctx.db, so this needs both.
173
+ // Mirrors buildHandlerContext (pipeline/dispatch-shared.ts): a non-systemScope
174
+ // tenantUserIdsQuery must get the same tenant-filtered db/no-systemDb context
175
+ // the real dispatcher would give it — handing it "system" mode + a systemDb
176
+ // view regardless would grant cross-tenant reach the dispatcher never would.
177
+ const isSystem = registry.isHandlerSystemScoped(tenantUserIdsQuery);
178
+ const tenantDb = createTenantDb(
179
+ db,
180
+ tenantId,
181
+ isSystem ? "system" : "tenant",
182
+ undefined,
183
+ undefined,
184
+ undefined,
185
+ { report },
186
+ );
187
+ // The dispatcher grants a systemScope handler's ctx.systemDb.unsafeRaw() ungated —
188
+ // the grant comes from the handler's own r.systemScope() registration, not from this
189
+ // caller. ctx.db/dbOutsideTransaction stay on the grant-less tenantDb above; only the
190
+ // systemDb view's own source TenantDb carries the grant, so it can't leak onto ctx.db.
191
+ const systemDbSourceTenantDb = isSystem
192
+ ? createTenantDb(db, tenantId, "system", undefined, undefined, undefined, {
193
+ report,
194
+ unsafeRaw: { reason: `r.systemScope() handler "${tenantUserIdsQuery}"` },
195
+ })
196
+ : undefined;
197
+ // Hand-built context, not routed through the dispatcher — this needs both
198
+ // db and dbOutsideTransaction wired directly.
185
199
  // @cast-boundary engine-payload — generic query-handler return for typed convention
186
200
  return (await handler.handler(
187
201
  { type: tenantUserIdsQuery, payload: { tenantId }, user: systemUser },
188
202
  {
189
203
  db: tenantDb,
190
204
  dbOutsideTransaction: tenantDb,
191
- systemDb: createUncheckedSystemDb(tenantDb, undefined, report),
205
+ systemDb: systemDbSourceTenantDb
206
+ ? createSystemDbView(systemDbSourceTenantDb, undefined, report)
207
+ : undefined,
192
208
  registry,
193
209
  ...bridgeStub(),
194
210
  },
@@ -8,6 +8,7 @@ import {
8
8
  import type { z } from "zod";
9
9
  import {
10
10
  DELIVERY_ATTEMPT_EVENT,
11
+ DELIVERY_CHANNEL_EXTENSION,
11
12
  DELIVERY_LOG_SCREEN_ID,
12
13
  DELIVERY_STATUS_CELL_COMPONENT,
13
14
  DeliveryJobNames,
@@ -118,7 +119,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
118
119
  // eigenen mehr — channel-email nimmt renderer als direkte
119
120
  // Konstruktor-Option (siehe email-channel.ts), nicht via Extension-
120
121
  // Usage. Migration 2026-05-19.
121
- r.extendsRegistrar("deliveryChannel", {
122
+ r.extendsRegistrar(DELIVERY_CHANNEL_EXTENSION, {
122
123
  onRegister: () => {},
123
124
  });
124
125
 
@@ -1,5 +1,6 @@
1
1
  export type { DeliveryStatusValue } from "./constants";
2
2
  export {
3
+ DELIVERY_CHANNEL_EXTENSION,
3
4
  DELIVERY_FEATURE,
4
5
  DELIVERY_LOG_SCREEN_ID,
5
6
  DeliveryErrors,
@@ -22,17 +23,19 @@ export {
22
23
  notificationPreferencesTable,
23
24
  } from "./tables";
24
25
  export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing";
25
- export type {
26
- ChannelContext,
27
- ChannelMessage,
28
- ChannelResult,
29
- DeliveryChannel,
30
- DeliveryChannelMode,
31
- DeliveryLogEntry,
32
- DeliveryService,
33
- NotificationRenderer,
34
- RenderedMessage,
35
- RendererInput,
26
+ export {
27
+ type ChannelContext,
28
+ type ChannelMessage,
29
+ type ChannelResult,
30
+ type DeliveryChannel,
31
+ type DeliveryChannelMode,
32
+ type DeliveryChannelPlugin,
33
+ type DeliveryLogEntry,
34
+ type DeliveryService,
35
+ isDeliveryChannelPlugin,
36
+ type NotificationRenderer,
37
+ type RenderedMessage,
38
+ type RendererInput,
36
39
  } from "./types";
37
40
  export {
38
41
  createUnsubscribeRoute,
@@ -7,6 +7,7 @@ import type {
7
7
  SessionUser,
8
8
  TenantId,
9
9
  } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { DELIVERY_CHANNEL_EXTENSION } from "./constants";
10
11
 
11
12
  // --- Channel Interface ---
12
13
 
@@ -92,3 +93,26 @@ export type DeliveryService = {
92
93
  tenantId: TenantId,
93
94
  ): Promise<void>;
94
95
  };
96
+
97
+ // r.useExtension options-shape: `name` is NOT part of the registration
98
+ // payload — collectChannels derives it from the usage's entityName instead.
99
+ export type DeliveryChannelPlugin = Omit<DeliveryChannel, "name">;
100
+
101
+ export function isDeliveryChannelPlugin(o: unknown): o is DeliveryChannelPlugin {
102
+ return (
103
+ typeof o === "object" &&
104
+ o !== null &&
105
+ "mode" in o &&
106
+ "resolve" in o &&
107
+ typeof o.resolve === "function" &&
108
+ "send" in o &&
109
+ typeof o.send === "function"
110
+ );
111
+ }
112
+
113
+ // r.useExtension options-shape, co-located since the framework never imports upward.
114
+ declare module "@cosmicdrift/kumiko-framework/engine" {
115
+ interface KumikoExtensionOptionsMap {
116
+ [DELIVERY_CHANNEL_EXTENSION]: DeliveryChannelPlugin;
117
+ }
118
+ }
@@ -25,7 +25,6 @@ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testin
25
25
  import { createConfigFeature } from "../../config";
26
26
  import { documentExtractEntity, documentExtractsTable } from "../entity";
27
27
  import { readIngestPages, writeIngestPages } from "../pages";
28
- import { documentExtractTenantDestroyHook } from "../tenant-destroy-hook";
29
28
 
30
29
  let stack: TestStack;
31
30
  let kms: InMemoryKmsAdapter;
@@ -110,29 +109,3 @@ describe("documentExtract.pages — tenant-subject encryption (#1621)", () => {
110
109
  expect(readIngestPages(row?.["pages"])).toEqual([]);
111
110
  });
112
111
  });
113
-
114
- describe("documentExtractTenantDestroyHook (#1621)", () => {
115
- test("drops the tenant's extract rows and leaves other tenants alone", async () => {
116
- const mine = await createExtract("file-mine");
117
- const otherTenantId = testTenantId(2);
118
- const otherResult = await executor.create(
119
- {
120
- fileRefId: "file-theirs",
121
- storageKey: "s3://bucket/file-theirs",
122
- pages: writeIngestPages(pages),
123
- meta: {},
124
- },
125
- createSystemUser(otherTenantId),
126
- createTenantDb(stack.db, otherTenantId),
127
- );
128
- if (!otherResult.isSuccess) throw new Error("setup create failed");
129
-
130
- await documentExtractTenantDestroyHook({ db: stack.db, tenantId });
131
-
132
- const remaining = (await asRawClient(stack.db).unsafe(
133
- `SELECT id FROM read_document_extracts`,
134
- )) as ReadonlyArray<{ id: string }>;
135
- expect(remaining.map((row) => row.id)).not.toContain(mine);
136
- expect(remaining).toHaveLength(1);
137
- });
138
- });
@@ -0,0 +1,166 @@
1
+ // Drives the real tenant-lifecycle destruction sweep: a direct hook call with a raw DbRunner
2
+ // hides the TenantDb shape the "app-data" stage actually passes.
3
+
4
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
6
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
7
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
8
+ import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { append, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
10
+ import {
11
+ setupTestStack,
12
+ type TestStack,
13
+ TestUsers,
14
+ unsafeCreateEntityTable,
15
+ } from "@cosmicdrift/kumiko-framework/stack";
16
+ import { resetTestTables, updateRows } from "@cosmicdrift/kumiko-framework/testing";
17
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
18
+ import {
19
+ createComplianceProfilesFeature,
20
+ tenantComplianceProfileEntity,
21
+ tenantComplianceProfileTable,
22
+ } from "../../compliance-profiles";
23
+ import { createConfigFeature } from "../../config";
24
+ import { tenantMembershipEntity } from "../../tenant";
25
+ import { TenantHandlers } from "../../tenant/constants";
26
+ import { createTenantFeature } from "../../tenant/feature";
27
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
28
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
29
+ import {
30
+ TENANT_AGGREGATE_TYPE,
31
+ TENANT_DESTRUCTION_STARTED_EVENT_QN,
32
+ } from "../../tenant-lifecycle/constants";
33
+ import { runTenantDestructionSweep } from "../../tenant-lifecycle/run-tenant-destroy";
34
+ import { documentExtractEntity, documentExtractsTable } from "../entity";
35
+ import { documentIngestFoundationFeature } from "../feature";
36
+ import { writeIngestPages } from "../pages";
37
+
38
+ const SET_PROFILE = "compliance-profiles:write:set-profile";
39
+
40
+ const documentExtractCrud = createEventStoreExecutor(documentExtractsTable, documentExtractEntity, {
41
+ entityName: "document-extract",
42
+ });
43
+
44
+ let stack: TestStack;
45
+ let db: DbConnection;
46
+
47
+ const tenantA = TestUsers.admin;
48
+ const tenantB = TestUsers.otherTenant;
49
+
50
+ beforeAll(async () => {
51
+ stack = await setupTestStack({
52
+ features: [
53
+ createConfigFeature(),
54
+ createTenantFeature(),
55
+ createComplianceProfilesFeature(),
56
+ createTenantLifecycleFeature(),
57
+ documentIngestFoundationFeature,
58
+ ],
59
+ });
60
+ db = stack.db;
61
+
62
+ await unsafeCreateEntityTable(db, tenantEntity);
63
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
64
+ await unsafeCreateEntityTable(db, tenantMembershipEntity);
65
+ await unsafeCreateEntityTable(db, documentExtractEntity);
66
+ });
67
+
68
+ afterAll(async () => {
69
+ await stack.cleanup();
70
+ });
71
+
72
+ beforeEach(async () => {
73
+ stack.events.reset();
74
+ await resetTestTables(db, [tenantTable, tenantComplianceProfileTable]);
75
+ await stack.db.unsafe?.(
76
+ `TRUNCATE kumiko_events, read_document_extracts RESTART IDENTITY CASCADE`,
77
+ );
78
+ });
79
+
80
+ async function seedTenant(user: typeof tenantA): Promise<void> {
81
+ await stack.http.writeOk(
82
+ TenantHandlers.create,
83
+ { id: user.tenantId, key: `t-${user.tenantId}`, name: "Tenant" },
84
+ TestUsers.systemAdmin,
85
+ );
86
+ await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, user);
87
+ }
88
+
89
+ async function seedDocumentExtract(tenantId: TenantId, fileRefId: string): Promise<string> {
90
+ const user = createSystemUser(tenantId);
91
+ const tdb = createTenantDb(db, tenantId);
92
+ const result = await documentExtractCrud.create(
93
+ {
94
+ fileRefId,
95
+ storageKey: `s3://bucket/${fileRefId}`,
96
+ pages: writeIngestPages([{ pageNumber: 1, text: "invoice text" }]),
97
+ meta: { provider: "test", ms: 1, needsOcr: false, pagesParsed: 1, totalPages: 1 },
98
+ },
99
+ user,
100
+ tdb,
101
+ );
102
+ if (!result.isSuccess) throw new Error(`seed failed: ${result.error.message}`);
103
+ return String(result.data.id);
104
+ }
105
+
106
+ // Sidesteps the `request-destruction` write handler (needs user/auth/sessions
107
+ // features wired) by seeding the same "destroying" state it would produce —
108
+ // same pattern as files-tenant-data's hooks.integration.test.ts.
109
+ async function seedDestroyingTenant(tenantId: TenantId): Promise<void> {
110
+ const now = getTemporal().Now.instant();
111
+ await updateRows(
112
+ db,
113
+ tenantTable,
114
+ { status: "destroying", destroyStartedAt: now },
115
+ { id: tenantId },
116
+ );
117
+ await append(db, {
118
+ aggregateId: tenantId,
119
+ aggregateType: TENANT_AGGREGATE_TYPE,
120
+ tenantId,
121
+ expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
122
+ type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
123
+ payload: { startedAt: now.toString() },
124
+ metadata: { userId: "system", requestId: "test:destruction-started" },
125
+ });
126
+ }
127
+
128
+ async function driveDestructionToCompletion(tenantId: TenantId): Promise<string> {
129
+ const farFuture = getTemporal()
130
+ .Now.instant()
131
+ .add({ hours: 24 * 3650 });
132
+ let status = "";
133
+ for (let i = 0; i < 20; i++) {
134
+ await runTenantDestructionSweep({ db: stack.db, registry: stack.registry, now: farFuture });
135
+ const rows = await selectMany(db, tenantTable, { id: tenantId });
136
+ status = String(rows[0]?.["status"]);
137
+ if (status === "destroyed" || status === "destroyFailed") break;
138
+ }
139
+ return status;
140
+ }
141
+
142
+ describe("document-ingest-foundation :: tenant destroy (#3196)", () => {
143
+ test("purges documentExtract rows for the destroyed tenant, leaving another tenant's row untouched", async () => {
144
+ await seedTenant(tenantA);
145
+ await seedTenant(tenantB);
146
+
147
+ const idA = await seedDocumentExtract(tenantA.tenantId, "file-a");
148
+ await seedDocumentExtract(tenantB.tenantId, "file-b");
149
+
150
+ await seedDestroyingTenant(tenantA.tenantId);
151
+
152
+ const finalStatus = await driveDestructionToCompletion(tenantA.tenantId);
153
+ expect(finalStatus).toBe("destroyed");
154
+
155
+ const rowsA = await selectMany(db, documentExtractsTable, { tenantId: tenantA.tenantId });
156
+ expect(rowsA).toHaveLength(0);
157
+
158
+ const rowsB = await selectMany(db, documentExtractsTable, { tenantId: tenantB.tenantId });
159
+ expect(rowsB).toHaveLength(1);
160
+
161
+ // Proves this went through the executor (rebuild-safe forget), not a raw
162
+ // deleteMany — the forgotten row's aggregate carries a `.forgotten` event.
163
+ const events = await loadAggregate(db, idA, tenantA.tenantId);
164
+ expect(events.some((e) => e.type === "document-extract.forgotten")).toBe(true);
165
+ });
166
+ });
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.306.0",
4
+ "type": "fix",
5
+ "title": "Tenant destroy no longer fails on documentExtract rows"
6
+ },
2
7
  {
3
8
  "version": "0.166.0",
4
9
  "type": "breaking",