@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
@@ -10,32 +10,21 @@
10
10
  // (kumiko-framework#1495). The `pages` ciphertext dies separately when the
11
11
  // pipeline's later `subject-keys` stage erases the tenant subject key.
12
12
 
13
- import {
14
- createEventStoreExecutor,
15
- createTenantDb,
16
- type DbRunner,
17
- selectMany,
18
- } from "@cosmicdrift/kumiko-framework/db";
19
- import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
14
+ import { createSystemUser, type TenantDataDestroyHook } from "@cosmicdrift/kumiko-framework/engine";
20
15
  import { documentExtractEntity, documentExtractsTable } from "./entity";
21
16
 
22
17
  const executor = createEventStoreExecutor(documentExtractsTable, documentExtractEntity, {
23
18
  entityName: "document-extract",
24
19
  });
25
20
 
26
- type DestroyCtx = {
27
- readonly db: DbRunner;
28
- readonly tenantId: TenantId;
29
- };
30
-
31
- export async function documentExtractTenantDestroyHook(ctx: DestroyCtx): Promise<void> {
32
- const rows = await selectMany<{ id: string }>(ctx.db, documentExtractsTable, {
21
+ export const documentExtractTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
22
+ const rows = await ctx.db.selectMany<{ id: string }>(documentExtractsTable, {
33
23
  tenantId: ctx.tenantId,
34
24
  });
35
25
  const user = createSystemUser(ctx.tenantId);
36
- const db = createTenantDb(ctx.db, ctx.tenantId, "system");
37
26
  for (const row of rows) {
38
- const result = await executor.forget({ id: row.id }, user, db);
27
+ const result = await executor.forget({ id: row.id }, user, ctx.db);
39
28
  // Executor writes return {isSuccess:false} instead of throwing — a
40
29
  // discarded result would report this destroy stage "succeeded" while the
41
30
  // extracted document text survives. Throw so the pipeline's retry/abandon
@@ -46,4 +35,4 @@ export async function documentExtractTenantDestroyHook(ctx: DestroyCtx): Promise
46
35
  );
47
36
  }
48
37
  }
49
- }
38
+ };
@@ -58,6 +58,13 @@ function isDerivativePublicPredicatePlugin(o: unknown): o is DerivativePublicPre
58
58
  return typeof o === "object" && o !== null && "isPublic" in o && typeof o.isPublic === "function";
59
59
  }
60
60
 
61
+ // r.useExtension options-shape, co-located since the framework never imports upward.
62
+ declare module "@cosmicdrift/kumiko-framework/engine" {
63
+ interface KumikoExtensionOptionsMap {
64
+ [EXT_DERIVATIVE_PUBLIC_PREDICATE]: DerivativePublicPredicatePlugin;
65
+ }
66
+ }
67
+
61
68
  // Full QN this handler is registered under once mounted into the
62
69
  // "file-derivatives" feature (short name below + registry qualification —
63
70
  // see qualifyEntityName). Kept as a separate literal, not derived, so
@@ -65,7 +65,7 @@ describe("files :: cross-feature behavior (F1, S1.7)", () => {
65
65
  // Stub-Hooks: in Sprint 2 werden diese die echte Forget-/Export-
66
66
  // Logik tragen. Hier reicht: useExtension findet die fileRef-
67
67
  // Entity in der Registry → kein Boot-Error.
68
- export: async () => [],
68
+ export: async () => null,
69
69
  delete: async () => undefined,
70
70
  });
71
71
  });
@@ -87,7 +87,7 @@ describe("files :: cross-feature behavior (F1, S1.7)", () => {
87
87
  const consumer = defineFeature("test-broken-consumer", (r) => {
88
88
  r.requires("test-user-data-provider-2");
89
89
  // GHOSTLY: useExtension auf "ghostEntity" gibt es nirgends.
90
- r.useExtension(EXT_USER_DATA, "ghostEntity", {});
90
+ r.useExtension(EXT_USER_DATA, "ghostEntity", { export: async () => null });
91
91
  });
92
92
 
93
93
  // Boot soll laufen — es ist NICHT der Job des Boot-Validators zu
@@ -1,31 +1,32 @@
1
- // #2323: draft-count.ts, owned-file-refs.ts and cleanup.ts read form-draft
2
- // rows via asRawClient(db).unsafe() directly, bypassing the #1163
3
- // closed-connection retry that only covered bun-db/query.ts's own
4
- // selectMany/countWhere. Routed the SELECT-only call sites through
5
- // unsafeReadRetrying instead — this test mirrors
6
- // bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
7
- // the retry now actually fires for each call site.
1
+ // draft-count.ts, owned-file-refs.ts and cleanup.ts read form-draft rows
2
+ // through unsafeReadRetrying; this proves the retry fires per call site
3
+ // using a real captured driver error.
8
4
 
9
- import { describe, expect, test } from "bun:test";
5
+ import { beforeAll, describe, expect, test } from "bun:test";
6
+ import { captureClosedConnectionError } from "@cosmicdrift/kumiko-framework/testing";
10
7
  import { Temporal } from "temporal-polyfill";
11
8
  import { selectStaleDraftsBatch } from "../cleanup";
12
9
  import { countDraftsByOwner } from "../draft-count";
13
10
  import { filterOwnedFileRefs } from "../owned-file-refs";
14
11
 
15
- function closedConnectionError(): Error {
16
- return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
17
- }
12
+ let closedConnectionError: unknown;
13
+
14
+ beforeAll(async () => {
15
+ closedConnectionError = await captureClosedConnectionError();
16
+ });
18
17
 
19
18
  type FakeClient = {
20
19
  unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
21
20
  begin: () => never;
21
+ options: { max: number };
22
22
  calls: number;
23
23
  };
24
24
 
25
- function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient {
25
+ function fakeClient(failures: unknown[], row: Record<string, unknown>): FakeClient {
26
26
  const remaining = [...failures];
27
27
  const client: FakeClient = {
28
28
  calls: 0,
29
+ options: { max: 1 },
29
30
  unsafe: async () => {
30
31
  client.calls++;
31
32
  const err = remaining.shift();
@@ -39,16 +40,16 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
39
40
  return client;
40
41
  }
41
42
 
42
- describe("form-draft db/queries — closed-connection retry (#2323)", () => {
43
+ describe("form-draft db/queries — closed-connection retry", () => {
43
44
  test("countDraftsByOwner retries once and returns the count", async () => {
44
- const db = fakeClient([closedConnectionError()], { count: 4 });
45
+ const db = fakeClient([closedConnectionError], { count: 4 });
45
46
  const result = await countDraftsByOwner(db as never, "t1" as never, "owner1");
46
47
  expect(result).toBe(4);
47
48
  expect(db.calls).toBe(2);
48
49
  });
49
50
 
50
51
  test("filterOwnedFileRefs retries once and returns rows", async () => {
51
- const db = fakeClient([closedConnectionError()], { id: "ref1", storage_key: "key1" });
52
+ const db = fakeClient([closedConnectionError], { id: "ref1", storage_key: "key1" });
52
53
  const rows = await filterOwnedFileRefs(
53
54
  db as never,
54
55
  "t1" as never,
@@ -63,7 +64,7 @@ describe("form-draft db/queries — closed-connection retry (#2323)", () => {
63
64
  });
64
65
 
65
66
  test("selectStaleDraftsBatch retries once and returns rows", async () => {
66
- const db = fakeClient([closedConnectionError()], {
67
+ const db = fakeClient([closedConnectionError], {
67
68
  id: "d1",
68
69
  tenant_id: "t1",
69
70
  owner_id: "owner1",
@@ -77,11 +78,13 @@ describe("form-draft db/queries — closed-connection retry (#2323)", () => {
77
78
  expect(db.calls).toBe(2);
78
79
  });
79
80
 
80
- test("gives up after the single retry when the connection stays closed", async () => {
81
- const db = fakeClient([closedConnectionError(), closedConnectionError()], { count: 4 });
82
- await expect(countDraftsByOwner(db as never, "t1" as never, "owner1")).rejects.toThrow(
83
- "connection was closed",
81
+ test("gives up after exhausting pool-bounded retries (max: 1 → 3 total calls)", async () => {
82
+ const db = fakeClient([closedConnectionError, closedConnectionError, closedConnectionError], {
83
+ count: 4,
84
+ });
85
+ await expect(countDraftsByOwner(db as never, "t1" as never, "owner1")).rejects.toBe(
86
+ closedConnectionError,
84
87
  );
85
- expect(db.calls).toBe(2);
88
+ expect(db.calls).toBe(3);
86
89
  });
87
90
  });
@@ -0,0 +1,173 @@
1
+ // Drives the real tenant-lifecycle destruction sweep: a direct hook call with a hand-built
2
+ // TenantDb hides the escapeHatch grant the "app-data" stage actually resolves.
3
+
4
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
6
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
7
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { append, isStreamArchived, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
10
+ import {
11
+ createTestUser,
12
+ setupTestStack,
13
+ type TestStack,
14
+ TestUsers,
15
+ testTenantId,
16
+ unsafeCreateEntityTable,
17
+ } from "@cosmicdrift/kumiko-framework/stack";
18
+ import {
19
+ resetPiiSubjectKmsForTests,
20
+ resetTestTables,
21
+ updateRows,
22
+ } from "@cosmicdrift/kumiko-framework/testing";
23
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
24
+ import {
25
+ createComplianceProfilesFeature,
26
+ tenantComplianceProfileEntity,
27
+ tenantComplianceProfileTable,
28
+ } from "../../compliance-profiles";
29
+ import { createConfigFeature } from "../../config";
30
+ import { inboundProviderInMemoryFeature } from "../../inbound-provider-inmemory";
31
+ import { tenantMembershipEntity } from "../../tenant";
32
+ import { TenantHandlers } from "../../tenant/constants";
33
+ import { createTenantFeature } from "../../tenant/feature";
34
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
35
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
36
+ import {
37
+ TENANT_AGGREGATE_TYPE,
38
+ TENANT_DESTRUCTION_STARTED_EVENT_QN,
39
+ } from "../../tenant-lifecycle/constants";
40
+ import { runTenantDestructionSweep } from "../../tenant-lifecycle/run-tenant-destroy";
41
+ import { InboundMailFoundationHandlers } from "../constants";
42
+ import { seenMessageEntity, syncCursorEntity } from "../entities";
43
+ import { inboundMailFoundationFeature } from "../feature";
44
+ import { mailAccountsProjectionTable } from "../projection";
45
+
46
+ let stack: TestStack;
47
+ let db: DbConnection;
48
+
49
+ function adminFor(tenantNumber: number) {
50
+ return createTestUser({
51
+ id: tenantNumber,
52
+ tenantId: testTenantId(tenantNumber),
53
+ roles: ["TenantAdmin", "SystemAdmin"],
54
+ });
55
+ }
56
+
57
+ const tenantA = adminFor(9101);
58
+ const tenantB = adminFor(9102);
59
+
60
+ beforeAll(async () => {
61
+ stack = await setupTestStack({
62
+ features: [
63
+ createConfigFeature(),
64
+ createTenantFeature(),
65
+ createComplianceProfilesFeature(),
66
+ createTenantLifecycleFeature(),
67
+ inboundMailFoundationFeature,
68
+ inboundProviderInMemoryFeature,
69
+ ],
70
+ });
71
+ db = stack.db;
72
+ await unsafeCreateEntityTable(db, tenantEntity);
73
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
74
+ await unsafeCreateEntityTable(db, tenantMembershipEntity);
75
+ await unsafeCreateEntityTable(db, syncCursorEntity);
76
+ await unsafeCreateEntityTable(db, seenMessageEntity);
77
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
78
+ });
79
+
80
+ afterAll(async () => {
81
+ await stack.cleanup();
82
+ resetPiiSubjectKmsForTests();
83
+ });
84
+
85
+ beforeEach(async () => {
86
+ stack.events.reset();
87
+ await resetTestTables(db, [tenantTable, tenantComplianceProfileTable]);
88
+ await stack.db.unsafe?.(`TRUNCATE kumiko_events, read_mail_accounts RESTART IDENTITY CASCADE`);
89
+ });
90
+
91
+ async function seedTenant(user: typeof tenantA): Promise<void> {
92
+ await stack.http.writeOk(
93
+ TenantHandlers.create,
94
+ { id: user.tenantId, key: `t-${user.tenantId}`, name: "Tenant" },
95
+ TestUsers.systemAdmin,
96
+ );
97
+ await stack.http.writeOk(
98
+ "compliance-profiles:write:set-profile",
99
+ { profileKey: "eu-dsgvo" },
100
+ user,
101
+ );
102
+ }
103
+
104
+ async function seedMailAccount(user: typeof tenantA, address: string): Promise<string> {
105
+ const result = (await stack.http.writeOk(
106
+ InboundMailFoundationHandlers.connectAccount,
107
+ {
108
+ provider: "inmemory",
109
+ authMethod: "password",
110
+ displayName: "Team-Inbox",
111
+ address,
112
+ scope: "shared",
113
+ },
114
+ user,
115
+ )) as { accountId: string };
116
+ return result.accountId;
117
+ }
118
+
119
+ async function seedDestroyingTenant(tenantId: TenantId): Promise<void> {
120
+ const now = getTemporal().Now.instant();
121
+ await updateRows(
122
+ db,
123
+ tenantTable,
124
+ { status: "destroying", destroyStartedAt: now },
125
+ { id: tenantId },
126
+ );
127
+ await append(db, {
128
+ aggregateId: tenantId,
129
+ aggregateType: TENANT_AGGREGATE_TYPE,
130
+ tenantId,
131
+ expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
132
+ type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
133
+ payload: { startedAt: now.toString() },
134
+ metadata: { userId: "system", requestId: "test:destruction-started" },
135
+ });
136
+ }
137
+
138
+ async function driveDestructionToCompletion(tenantId: TenantId): Promise<string> {
139
+ const farFuture = getTemporal()
140
+ .Now.instant()
141
+ .add({ hours: 24 * 3650 });
142
+ let status = "";
143
+ for (let i = 0; i < 20; i++) {
144
+ await runTenantDestructionSweep({ db: stack.db, registry: stack.registry, now: farFuture });
145
+ const rows = await selectMany(db, tenantTable, { id: tenantId });
146
+ status = String(rows[0]?.["status"]);
147
+ if (status === "destroyed" || status === "destroyFailed") break;
148
+ }
149
+ return status;
150
+ }
151
+
152
+ describe("inbound-mail-foundation :: tenant destroy (#3196)", () => {
153
+ test("deletes the mail-account row for the destroyed tenant, archives its stream, leaves another tenant's row untouched", async () => {
154
+ await seedTenant(tenantA);
155
+ await seedTenant(tenantB);
156
+
157
+ const accountIdA = await seedMailAccount(tenantA, "inbox-a@tenant.example");
158
+ await seedMailAccount(tenantB, "inbox-b@tenant.example");
159
+
160
+ await seedDestroyingTenant(tenantA.tenantId);
161
+
162
+ const finalStatus = await driveDestructionToCompletion(tenantA.tenantId);
163
+ expect(finalStatus).toBe("destroyed");
164
+
165
+ const rowsA = await selectMany(db, mailAccountsProjectionTable, { tenantId: tenantA.tenantId });
166
+ expect(rowsA).toHaveLength(0);
167
+
168
+ const rowsB = await selectMany(db, mailAccountsProjectionTable, { tenantId: tenantB.tenantId });
169
+ expect(rowsB).toHaveLength(1);
170
+
171
+ expect(await isStreamArchived(db, tenantA.tenantId, accountIdA)).toBe(true);
172
+ });
173
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.306.0",
4
+ "type": "fix",
5
+ "title": "Tenant destroy no longer fails on mail-account, inbound-message and mail-thread rows",
6
+ "detail": "The inbound-mail tenant-destroy hooks passed the tenant-scoped TenantDb they receive into raw DbRunner helpers, so the app-data stage failed and the tenant ended in destroyFailed. They now use ctx.db methods and declare an escapeHatch for the event-stream archive."
7
+ },
2
8
  {
3
9
  "version": "0.298.0",
4
10
  "type": "breaking",
@@ -91,6 +91,7 @@ import {
91
91
  } from "./projection";
92
92
  import { runInboundMailRetention } from "./retention-sweep";
93
93
  import {
94
+ INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON,
94
95
  inboundMessageTenantDestroyHook,
95
96
  mailAccountTenantDestroyHook,
96
97
  mailThreadTenantDestroyHook,
@@ -195,12 +196,15 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
195
196
  // prueft die EXT_TENANT_DATA-Registrierung pro tenant-subject-Entity.
196
197
  r.useExtension(EXT_TENANT_DATA, "mail-account", {
197
198
  destroy: mailAccountTenantDestroyHook,
199
+ escapeHatch: { reason: INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON },
198
200
  });
199
201
  r.useExtension(EXT_TENANT_DATA, "inbound-message", {
200
202
  destroy: inboundMessageTenantDestroyHook,
203
+ escapeHatch: { reason: INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON },
201
204
  });
202
205
  r.useExtension(EXT_TENANT_DATA, "mail-thread", {
203
206
  destroy: mailThreadTenantDestroyHook,
207
+ escapeHatch: { reason: INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON },
204
208
  });
205
209
 
206
210
  // Data-Retention (#957) — perTenant fan-out (one run per tenant, like
@@ -17,13 +17,8 @@
17
17
  // löscht den Tenant-Subject-Key, damit werden Event-Log-Payloads UND
18
18
  // etwaige Ciphertext-Kopien unlesbar. Diese Hooks entsorgen die Rows.
19
19
 
20
- import {
21
- type DbRunner,
22
- deleteMany,
23
- type EntityTableMeta,
24
- selectMany,
25
- } from "@cosmicdrift/kumiko-framework/db";
26
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
20
+ import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
21
+ import { declareEscapeHatch, type TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
27
22
  import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
28
23
  import { seenMessageTable, syncCursorTable } from "./entities";
29
24
  import {
@@ -40,19 +35,19 @@ import {
40
35
  const ARCHIVED_BY = "tenant-lifecycle:destroy";
41
36
  const REASON = "tenant_destroy";
42
37
 
43
- type DestroyCtx = {
44
- readonly db: DbRunner;
45
- readonly tenantId: TenantId;
46
- };
38
+ // archiveStream sits outside TenantDb's own methods — needs the escape hatch.
39
+ export const INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON =
40
+ "tenant-destroy archives each inbound-mail entity's event-streams before the projection rows are deleted";
47
41
 
48
42
  async function archiveAndDeleteRows(
49
- ctx: DestroyCtx,
43
+ ctx: TenantDataHookCtx,
50
44
  table: EntityTableMeta,
51
45
  aggregateType: string,
52
46
  ): Promise<readonly string[]> {
53
- const rows = await selectMany<{ id: string }>(ctx.db, table, { tenantId: ctx.tenantId });
47
+ declareEscapeHatch({ reason: INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON });
48
+ const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
54
49
  for (const row of rows) {
55
- await archiveStream(ctx.db, {
50
+ await archiveStream(ctx.db.unsafeRaw(INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON), {
56
51
  tenantId: ctx.tenantId,
57
52
  aggregateId: row.id,
58
53
  aggregateType,
@@ -60,23 +55,23 @@ async function archiveAndDeleteRows(
60
55
  reason: REASON,
61
56
  });
62
57
  }
63
- await deleteMany(ctx.db, table, { tenantId: ctx.tenantId });
58
+ await ctx.db.deleteMany(table, { tenantId: ctx.tenantId });
64
59
  return rows.map((row) => row.id);
65
60
  }
66
61
 
67
- export async function mailAccountTenantDestroyHook(ctx: DestroyCtx): Promise<void> {
62
+ export async function mailAccountTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
68
63
  const accountIds = await archiveAndDeleteRows(
69
64
  ctx,
70
65
  mailAccountsProjectionTable as EntityTableMeta,
71
66
  MAIL_ACCOUNT_AGGREGATE_TYPE,
72
67
  );
73
68
  for (const accountId of accountIds) {
74
- await deleteMany(ctx.db, syncCursorTable as EntityTableMeta, { accountId });
75
- await deleteMany(ctx.db, seenMessageTable as EntityTableMeta, { accountId });
69
+ await ctx.db.deleteMany(syncCursorTable as EntityTableMeta, { accountId });
70
+ await ctx.db.deleteMany(seenMessageTable as EntityTableMeta, { accountId });
76
71
  }
77
72
  }
78
73
 
79
- export async function inboundMessageTenantDestroyHook(ctx: DestroyCtx): Promise<void> {
74
+ export async function inboundMessageTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
80
75
  await archiveAndDeleteRows(
81
76
  ctx,
82
77
  inboundMessagesProjectionTable as EntityTableMeta,
@@ -84,7 +79,7 @@ export async function inboundMessageTenantDestroyHook(ctx: DestroyCtx): Promise<
84
79
  );
85
80
  }
86
81
 
87
- export async function mailThreadTenantDestroyHook(ctx: DestroyCtx): Promise<void> {
82
+ export async function mailThreadTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
88
83
  await archiveAndDeleteRows(
89
84
  ctx,
90
85
  mailThreadsProjectionTable as EntityTableMeta,
@@ -11,7 +11,7 @@
11
11
  // (sichtbarer breaking change) statt ctx-cast.
12
12
 
13
13
  import type { ConfigAccessor, Registry } from "@cosmicdrift/kumiko-framework/engine";
14
- import type { InboundMailAccountStatus } from "./constants";
14
+ import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "./constants";
15
15
 
16
16
  /**
17
17
  * Slim-Context für Provider-Plugins.
@@ -180,12 +180,19 @@ export function isInboundMailProviderPlugin(o: unknown): o is InboundMailProvide
180
180
  typeof o === "object" &&
181
181
  o !== null &&
182
182
  "verify" in o &&
183
- typeof (o as { verify: unknown }).verify === "function" &&
183
+ typeof o.verify === "function" &&
184
184
  "fetch" in o &&
185
- typeof (o as { fetch: unknown }).fetch === "function"
185
+ typeof o.fetch === "function"
186
186
  );
187
187
  }
188
188
 
189
+ // r.useExtension options-shape, co-located since the framework never imports upward.
190
+ declare module "@cosmicdrift/kumiko-framework/engine" {
191
+ interface KumikoExtensionOptionsMap {
192
+ [INBOUND_MAIL_PROVIDER_EXTENSION]: InboundMailProviderPlugin;
193
+ }
194
+ }
195
+
189
196
  // =============================================================================
190
197
  // Typisierte Fehlerklassen — Foundation reagiert einheitlich im Poll
191
198
  // (Plan §2, Fehler-Tabelle):
@@ -1,8 +1,8 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import {
4
+ createSystemDbView,
4
5
  createTenantDb,
5
- createUncheckedSystemDb,
6
6
  type DbConnection,
7
7
  } from "@cosmicdrift/kumiko-framework/db";
8
8
  import {
@@ -87,7 +87,7 @@ const billingFeature = defineFeature("billing", (r) => {
87
87
  {
88
88
  db: tenantDb,
89
89
  dbOutsideTransaction: tenantDb,
90
- systemDb: createUncheckedSystemDb(tenantDb),
90
+ systemDb: createSystemDbView(tenantDb),
91
91
  registry: reg,
92
92
  configResolver: ctx["configResolver"] as ConfigResolver,
93
93
  ...bridgeStub(),
@@ -177,7 +177,7 @@ describe("SYSTEM_USER in jobs", () => {
177
177
  {
178
178
  db: adminTenantDb,
179
179
  dbOutsideTransaction: adminTenantDb,
180
- systemDb: createUncheckedSystemDb(adminTenantDb),
180
+ systemDb: createSystemDbView(adminTenantDb),
181
181
  registry,
182
182
  configResolver: resolver,
183
183
  ...bridgeStub(),
@@ -10,8 +10,8 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
10
  import {
11
11
  buildEntityTable,
12
12
  createEventStoreExecutor,
13
+ createSystemDbView,
13
14
  createTenantDb,
14
- createUncheckedSystemDb,
15
15
  } from "@cosmicdrift/kumiko-framework/db";
16
16
  import {
17
17
  createEntity,
@@ -19,7 +19,6 @@ import {
19
19
  defineFeature,
20
20
  type JobContext,
21
21
  } from "@cosmicdrift/kumiko-framework/engine";
22
- import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
23
22
  import {
24
23
  setupTestStack,
25
24
  type TestStack,
@@ -102,7 +101,11 @@ describe("reindexEntityJob", () => {
102
101
  registry: stack.registry,
103
102
  searchAdapter: stack.search,
104
103
  systemUser: admin,
105
- systemDb: createUncheckedSystemDb(createTenantDb(stack.db, admin.tenantId, "system")),
104
+ systemDb: createSystemDbView(
105
+ createTenantDb(stack.db, admin.tenantId, "system", undefined, undefined, undefined, {
106
+ unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
107
+ }),
108
+ ),
106
109
  log: noopLogger,
107
110
  triggeredBy: null,
108
111
  ...bridgeStub(),
@@ -143,7 +146,11 @@ describe("reindexEntityJob", () => {
143
146
  registry: stack.registry,
144
147
  searchAdapter: stack.search,
145
148
  systemUser: admin,
146
- systemDb: createUncheckedSystemDb(createTenantDb(stack.db, admin.tenantId, "system")),
149
+ systemDb: createSystemDbView(
150
+ createTenantDb(stack.db, admin.tenantId, "system", undefined, undefined, undefined, {
151
+ unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
152
+ }),
153
+ ),
147
154
  log: noopLogger,
148
155
  triggeredBy: null,
149
156
  ...bridgeStub(),
@@ -207,11 +214,13 @@ describe("reindexEntityJob", () => {
207
214
  registry: stack.registry,
208
215
  searchAdapter: stack.search,
209
216
  systemUser: admin,
210
- systemDb: createUncheckedSystemDb(createTenantDb(stack.db, otherTenantId, "system")),
217
+ systemDb: createSystemDbView(createTenantDb(stack.db, otherTenantId, "system")),
211
218
  log: noopLogger,
212
219
  triggeredBy: null,
213
220
  ...bridgeStub(),
214
221
  };
215
- await expect(reindexEntityJob({ entity: "widget" }, ctx)).rejects.toThrow(AccessDeniedError);
222
+ await expect(reindexEntityJob({ entity: "widget" }, ctx)).rejects.toThrow(
223
+ /tenant self-check failed/,
224
+ );
216
225
  });
217
226
  });
@@ -46,6 +46,10 @@ import {
46
46
 
47
47
  const FEATURE_NAME = "mail-foundation";
48
48
 
49
+ // Extension-point name for transport-provider plugins (mail-transport-smtp,
50
+ // mail-transport-brevo-api, ...).
51
+ export const MAIL_TRANSPORT_EXTENSION = "mailTransport" as const;
52
+
49
53
  // =============================================================================
50
54
  // Plugin-Interface — what a Provider-Plugin must implement
51
55
  // =============================================================================
@@ -104,6 +108,13 @@ export function isMailTransportPlugin(o: unknown): o is MailTransportPlugin {
104
108
  return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
105
109
  }
106
110
 
111
+ // r.useExtension options-shape, co-located since the framework never imports upward.
112
+ declare module "@cosmicdrift/kumiko-framework/engine" {
113
+ interface KumikoExtensionOptionsMap {
114
+ [MAIL_TRANSPORT_EXTENSION]: MailTransportPlugin;
115
+ }
116
+ }
117
+
107
118
  // =============================================================================
108
119
  // Feature-definition
109
120
  // =============================================================================
@@ -122,7 +133,7 @@ export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
122
133
  // Plugin extension-point. Provider-features register here. The
123
134
  // entityName at registration time becomes the value tenants pick in
124
135
  // `provider` config-key (e.g. "smtp", "brevo-api").
125
- r.extendsRegistrar("mailTransport", {
136
+ r.extendsRegistrar(MAIL_TRANSPORT_EXTENSION, {
126
137
  onRegister: () => {
127
138
  // No side-effects at register-time — the registry stores the
128
139
  // usage, factory looks it up at request-time. Same shape as
@@ -150,7 +161,7 @@ export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
150
161
  );
151
162
  // Readiness gating: transport-plugins' required keys/secrets count only
152
163
  // while their plugin is the one this key selects.
153
- r.extensionSelector("mailTransport", providerConfigKey);
164
+ r.extensionSelector(MAIL_TRANSPORT_EXTENSION, providerConfigKey);
154
165
 
155
166
  return {
156
167
  /** Config-key-handle for the provider-selector. */
@@ -194,7 +205,7 @@ export async function createTransportForTenant(
194
205
  "provider",
195
206
  ) as string; // @cast-boundary engine-payload
196
207
  if (provider.length === 0) {
197
- const usages = ctx.registry.getExtensionUsages("mailTransport");
208
+ const usages = ctx.registry.getExtensionUsages(MAIL_TRANSPORT_EXTENSION);
198
209
  const known = usages.map((u) => u.entityName).join(", ") || "<none>";
199
210
  throw new Error(
200
211
  `${FEATURE_NAME}: no provider selected — set the 'provider' config-key to one of: ${known}. ` +
@@ -202,7 +213,7 @@ export async function createTransportForTenant(
202
213
  );
203
214
  }
204
215
 
205
- const usages = ctx.registry.getExtensionUsages("mailTransport");
216
+ const usages = ctx.registry.getExtensionUsages(MAIL_TRANSPORT_EXTENSION);
206
217
  const usage = usages.find((u) => u.entityName === provider);
207
218
  if (!usage) {
208
219
  const known = usages.map((u) => u.entityName).join(", ") || "<none>";
@@ -10,6 +10,7 @@
10
10
  export {
11
11
  createTransportForTenant,
12
12
  isMailTransportPlugin,
13
+ MAIL_TRANSPORT_EXTENSION,
13
14
  type MailTransportContext,
14
15
  type MailTransportPlugin,
15
16
  mailFoundationFeature,