@cosmicdrift/kumiko-bundled-features 0.304.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 (75) 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-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +116 -0
  5. package/src/auth-email-password/changes.json +7 -0
  6. package/src/auth-email-password/i18n.ts +7 -0
  7. package/src/auth-email-password/web/__tests__/auth-client.test.ts +24 -0
  8. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +124 -2
  9. package/src/auth-email-password/web/__tests__/session-bootstrap-error.test.ts +32 -0
  10. package/src/auth-email-password/web/__tests__/test-utils.tsx +1 -0
  11. package/src/auth-email-password/web/auth-client.ts +34 -2
  12. package/src/auth-email-password/web/auth-gate.tsx +5 -1
  13. package/src/auth-email-password/web/index.ts +4 -1
  14. package/src/auth-email-password/web/session-bootstrap-error.tsx +56 -0
  15. package/src/auth-email-password/web/session.tsx +32 -3
  16. package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
  17. package/src/auth-foundation/__tests__/tenant-providers.test.ts +1 -0
  18. package/src/auth-foundation/__tests__/token-verifier.test.ts +2 -0
  19. package/src/auth-foundation/types.ts +14 -4
  20. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +4 -63
  21. package/src/billing-foundation/__tests__/tenant-destroy.integration.test.ts +199 -0
  22. package/src/billing-foundation/changes.json +6 -0
  23. package/src/billing-foundation/feature.ts +8 -1
  24. package/src/billing-foundation/tenant-destroy-hook.ts +20 -20
  25. package/src/billing-foundation/types.ts +13 -1
  26. package/src/channel-email/feature.ts +2 -1
  27. package/src/channel-in-app/feature.ts +2 -1
  28. package/src/channel-push/feature.ts +2 -1
  29. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +14 -3
  30. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +11 -3
  31. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +7 -3
  32. package/src/config/__tests__/require-system-db.test.ts +3 -3
  33. package/src/config/handlers/reencrypt.job.ts +4 -8
  34. package/src/custom-fields/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +25 -24
  35. package/src/delivery/__tests__/delivery-screens.boot.test.ts +20 -2
  36. package/src/delivery/__tests__/delivery.integration.test.ts +140 -3
  37. package/src/delivery/constants.ts +4 -0
  38. package/src/delivery/delivery-service.ts +45 -29
  39. package/src/delivery/feature.ts +2 -1
  40. package/src/delivery/index.ts +14 -11
  41. package/src/delivery/types.ts +24 -0
  42. package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +0 -27
  43. package/src/document-ingest-foundation/__tests__/tenant-destroy.integration.test.ts +166 -0
  44. package/src/document-ingest-foundation/changes.json +5 -0
  45. package/src/document-ingest-foundation/tenant-destroy-hook.ts +6 -17
  46. package/src/file-derivatives/handlers/public-variant.query.ts +7 -0
  47. package/src/files/__tests__/files.integration.test.ts +2 -2
  48. package/src/form-draft/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +24 -21
  49. package/src/inbound-mail-foundation/__tests__/tenant-destroy.integration.test.ts +173 -0
  50. package/src/inbound-mail-foundation/changes.json +6 -0
  51. package/src/inbound-mail-foundation/feature.ts +4 -0
  52. package/src/inbound-mail-foundation/tenant-destroy-hook.ts +15 -20
  53. package/src/inbound-mail-foundation/types.ts +10 -3
  54. package/src/jobs/__tests__/job-system-user.integration.test.ts +3 -3
  55. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +15 -6
  56. package/src/mail-foundation/feature.ts +15 -4
  57. package/src/mail-foundation/index.ts +1 -0
  58. package/src/mail-transport-inmemory/feature.ts +5 -4
  59. package/src/mail-transport-smtp/feature.ts +5 -4
  60. package/src/renderer-foundation/__tests__/collect-plugins.integration.test.ts +24 -2
  61. package/src/renderer-foundation/changes.json +8 -1
  62. package/src/renderer-foundation/constants.ts +4 -0
  63. package/src/renderer-foundation/feature.ts +10 -13
  64. package/src/renderer-foundation/index.ts +3 -0
  65. package/src/renderer-foundation/types.ts +23 -1
  66. package/src/renderer-simple/feature.ts +2 -1
  67. package/src/tenant/__tests__/multi-tenant.integration.test.ts +2 -2
  68. package/src/tenant-lifecycle/__tests__/stages.test.ts +51 -0
  69. package/src/tenant-lifecycle/stages.ts +20 -18
  70. package/src/user/db/queries/__tests__/unsafe-read-retrying.integration.test.ts +54 -0
  71. package/src/user-data-rights/__tests__/download.integration.test.ts +3 -0
  72. package/src/user-data-rights/lib/default-mailers.ts +2 -1
  73. package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +6 -2
  74. package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +6 -2
  75. package/src/user/db/queries/__tests__/unsafe-read-retrying.test.ts +0 -52
@@ -0,0 +1,199 @@
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 { tenantMembershipEntity } from "../../tenant";
31
+ import { TenantHandlers } from "../../tenant/constants";
32
+ import { createTenantFeature } from "../../tenant/feature";
33
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
34
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
35
+ import {
36
+ TENANT_AGGREGATE_TYPE,
37
+ TENANT_DESTRUCTION_STARTED_EVENT_QN,
38
+ } from "../../tenant-lifecycle/constants";
39
+ import { runTenantDestructionSweep } from "../../tenant-lifecycle/run-tenant-destroy";
40
+ import { subscriptionAggregateId } from "../aggregate-id";
41
+ import { SubscriptionEventTypes, SubscriptionFoundationHandlers } from "../constants";
42
+ import { billingFoundationFeature } from "../feature";
43
+ import { subscriptionsProjectionTable } from "../projection";
44
+
45
+ const SET_PROFILE = "compliance-profiles:write:set-profile";
46
+
47
+ let stack: TestStack;
48
+ let db: DbConnection;
49
+
50
+ function adminFor(tenantNumber: number) {
51
+ return createTestUser({
52
+ id: tenantNumber,
53
+ tenantId: testTenantId(tenantNumber),
54
+ roles: ["TenantAdmin", "SystemAdmin"],
55
+ });
56
+ }
57
+
58
+ const tenantA = adminFor(9001);
59
+ const tenantB = adminFor(9002);
60
+
61
+ beforeAll(async () => {
62
+ stack = await setupTestStack({
63
+ features: [
64
+ createConfigFeature(),
65
+ createTenantFeature(),
66
+ createComplianceProfilesFeature(),
67
+ createTenantLifecycleFeature(),
68
+ billingFoundationFeature,
69
+ ],
70
+ });
71
+ db = stack.db;
72
+ await unsafeCreateEntityTable(db, tenantEntity);
73
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
74
+ await unsafeCreateEntityTable(db, tenantMembershipEntity);
75
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
76
+ });
77
+
78
+ afterAll(async () => {
79
+ await stack.cleanup();
80
+ resetPiiSubjectKmsForTests();
81
+ });
82
+
83
+ beforeEach(async () => {
84
+ stack.events.reset();
85
+ await resetTestTables(db, [tenantTable, tenantComplianceProfileTable]);
86
+ await stack.db.unsafe?.(`TRUNCATE kumiko_events, read_subscriptions RESTART IDENTITY CASCADE`);
87
+ });
88
+
89
+ async function seedTenant(user: typeof tenantA, profileKey = "eu-dsgvo"): Promise<void> {
90
+ await stack.http.writeOk(
91
+ TenantHandlers.create,
92
+ { id: user.tenantId, key: `t-${user.tenantId}`, name: "Tenant" },
93
+ TestUsers.systemAdmin,
94
+ );
95
+ await stack.http.writeOk(SET_PROFILE, { profileKey }, user);
96
+ }
97
+
98
+ async function seedSubscription(user: typeof tenantA, eventIdSuffix: string): Promise<void> {
99
+ await stack.http.writeOk(
100
+ SubscriptionFoundationHandlers.processEvent,
101
+ {
102
+ providerEventId: `evt_${eventIdSuffix}`,
103
+ providerName: "stripe",
104
+ type: SubscriptionEventTypes.created,
105
+ status: "active",
106
+ tier: "pro",
107
+ providerCustomerId: `cus_${eventIdSuffix}`,
108
+ providerSubscriptionId: `sub_${eventIdSuffix}`,
109
+ currentPeriodEndIso: "2030-01-01T00:00:00Z",
110
+ rawPayload: "{}",
111
+ },
112
+ user,
113
+ );
114
+ }
115
+
116
+ async function seedDestroyingTenant(tenantId: TenantId): Promise<void> {
117
+ const now = getTemporal().Now.instant();
118
+ await updateRows(
119
+ db,
120
+ tenantTable,
121
+ { status: "destroying", destroyStartedAt: now },
122
+ { id: tenantId },
123
+ );
124
+ await append(db, {
125
+ aggregateId: tenantId,
126
+ aggregateType: TENANT_AGGREGATE_TYPE,
127
+ tenantId,
128
+ expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
129
+ type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
130
+ payload: { startedAt: now.toString() },
131
+ metadata: { userId: "system", requestId: "test:destruction-started" },
132
+ });
133
+ }
134
+
135
+ async function driveDestructionToCompletion(tenantId: TenantId): Promise<string> {
136
+ const farFuture = getTemporal()
137
+ .Now.instant()
138
+ .add({ hours: 24 * 3650 });
139
+ let status = "";
140
+ for (let i = 0; i < 20; i++) {
141
+ await runTenantDestructionSweep({ db: stack.db, registry: stack.registry, now: farFuture });
142
+ const rows = await selectMany(db, tenantTable, { id: tenantId });
143
+ status = String(rows[0]?.["status"]);
144
+ if (status === "destroyed" || status === "destroyFailed") break;
145
+ }
146
+ return status;
147
+ }
148
+
149
+ describe("billing-foundation :: tenant destroy (#3196)", () => {
150
+ test("deletes the subscription row for the destroyed tenant, archives its stream, leaves another tenant's row untouched", async () => {
151
+ await seedTenant(tenantA);
152
+ await seedTenant(tenantB);
153
+
154
+ await seedSubscription(tenantA, "a");
155
+ await seedSubscription(tenantB, "b");
156
+
157
+ await seedDestroyingTenant(tenantA.tenantId);
158
+
159
+ const finalStatus = await driveDestructionToCompletion(tenantA.tenantId);
160
+ expect(finalStatus).toBe("destroyed");
161
+
162
+ const rowsA = await selectMany(db, subscriptionsProjectionTable, {
163
+ id: subscriptionAggregateId(tenantA.tenantId),
164
+ });
165
+ expect(rowsA).toHaveLength(0);
166
+
167
+ const rowsB = await selectMany(db, subscriptionsProjectionTable, {
168
+ id: subscriptionAggregateId(tenantB.tenantId),
169
+ });
170
+ expect(rowsB).toHaveLength(1);
171
+
172
+ expect(
173
+ await isStreamArchived(db, tenantA.tenantId, subscriptionAggregateId(tenantA.tenantId)),
174
+ ).toBe(true);
175
+ });
176
+
177
+ test("HGB profile: redacts PII fields but keeps the row and archives its stream", async () => {
178
+ const tenantHgb = adminFor(9003);
179
+ await seedTenant(tenantHgb, "de-hr-dsgvo-hgb");
180
+
181
+ await seedSubscription(tenantHgb, "c");
182
+
183
+ await seedDestroyingTenant(tenantHgb.tenantId);
184
+
185
+ const finalStatus = await driveDestructionToCompletion(tenantHgb.tenantId);
186
+ expect(finalStatus).toBe("destroyed");
187
+
188
+ const rowsA = await selectMany(db, subscriptionsProjectionTable, {
189
+ id: subscriptionAggregateId(tenantHgb.tenantId),
190
+ });
191
+ expect(rowsA).toHaveLength(1);
192
+ expect(rowsA[0]?.["providerCustomerId"]).toBe("[erased]");
193
+ expect(rowsA[0]?.["providerSubscriptionId"]).toBe("[erased]");
194
+
195
+ expect(
196
+ await isStreamArchived(db, tenantHgb.tenantId, subscriptionAggregateId(tenantHgb.tenantId)),
197
+ ).toBe(true);
198
+ });
199
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.306.0",
4
+ "type": "fix",
5
+ "title": "Tenant destroy no longer fails on subscription and payment rows",
6
+ "detail": "The subscription and payment 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 compliance-profile lookup and the event-stream archive."
7
+ },
2
8
  {
3
9
  "version": "0.301.0",
4
10
  "type": "breaking",
@@ -81,7 +81,12 @@ import {
81
81
  paymentsProjectionTable,
82
82
  subscriptionsProjectionTable,
83
83
  } from "./projection";
84
- import { paymentTenantDestroyHook, subscriptionTenantDestroyHook } from "./tenant-destroy-hook";
84
+ import {
85
+ PAYMENT_TENANT_DESTROY_ARCHIVE_REASON,
86
+ paymentTenantDestroyHook,
87
+ SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON,
88
+ subscriptionTenantDestroyHook,
89
+ } from "./tenant-destroy-hook";
85
90
 
86
91
  export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
87
92
  r.describe(
@@ -169,8 +174,10 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
169
174
 
170
175
  r.useExtension(EXT_TENANT_DATA, "subscription", {
171
176
  destroy: subscriptionTenantDestroyHook,
177
+ escapeHatch: { reason: SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON },
172
178
  });
173
179
  r.useExtension(EXT_TENANT_DATA, "payment", {
174
180
  destroy: paymentTenantDestroyHook,
181
+ escapeHatch: { reason: PAYMENT_TENANT_DESTROY_ARCHIVE_REASON },
175
182
  });
176
183
  });
@@ -1,5 +1,5 @@
1
- import { deleteMany, type EntityTableMeta, updateMany } from "@cosmicdrift/kumiko-framework/db";
2
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
+ import { declareEscapeHatch, type TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { resolveProfileForTenant } from "../compliance-profiles";
5
5
  import { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id";
@@ -13,30 +13,33 @@ import { paymentsProjectionTable, subscriptionsProjectionTable } from "./project
13
13
  // it redacts the projection row directly instead of relying on key erasure.
14
14
  const REDACTED_PII_VALUE = "[erased]";
15
15
 
16
+ // Compliance-profile lookup and event-stream archive both sit outside TenantDb's own methods — need the escape hatch.
17
+ export const SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON =
18
+ "tenant-destroy looks up the compliance profile and archives the subscription event-stream before the projection row is redacted/deleted";
19
+ export const PAYMENT_TENANT_DESTROY_ARCHIVE_REASON =
20
+ "tenant-destroy looks up the compliance profile and archives the payment event-stream before the projection rows are redacted/deleted";
21
+
16
22
  /** Tenant-destroy hook for billing PII (#800). HGB retains the accounting
17
23
  * row but redacts the two provider-subject PII fields; other profiles
18
24
  * hard-delete the row. Either way the subscription stream is archived so a
19
25
  * future projection rebuild can't resurrect what was erased. */
20
- export async function subscriptionTenantDestroyHook(ctx: {
21
- readonly db: import("@cosmicdrift/kumiko-framework/db").DbRunner;
22
- readonly tenantId: TenantId;
23
- }): Promise<void> {
26
+ export async function subscriptionTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
27
+ declareEscapeHatch({ reason: SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON });
24
28
  const { profile } = await resolveProfileForTenant({
25
- db: ctx.db,
29
+ db: ctx.db.unsafeRaw(SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON),
26
30
  tenantId: ctx.tenantId,
27
31
  });
28
32
  const aggregateId = subscriptionAggregateId(ctx.tenantId);
29
33
  if (profile.key === "de-hr-dsgvo-hgb") {
30
- await updateMany(
31
- ctx.db,
34
+ await ctx.db.updateMany(
32
35
  subscriptionsProjectionTable as EntityTableMeta,
33
36
  { providerCustomerId: REDACTED_PII_VALUE, providerSubscriptionId: REDACTED_PII_VALUE },
34
37
  { id: aggregateId },
35
38
  );
36
39
  } else {
37
- await deleteMany(ctx.db, subscriptionsProjectionTable as EntityTableMeta, { id: aggregateId });
40
+ await ctx.db.deleteMany(subscriptionsProjectionTable as EntityTableMeta, { id: aggregateId });
38
41
  }
39
- await archiveStream(ctx.db, {
42
+ await archiveStream(ctx.db.unsafeRaw(SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON), {
40
43
  tenantId: ctx.tenantId,
41
44
  aggregateId,
42
45
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
@@ -50,28 +53,25 @@ export async function subscriptionTenantDestroyHook(ctx: {
50
53
  * rows — the predicate here is `tenantId`, not `id`. The payment-stream
51
54
  * itself is still one stream per tenant (paymentAggregateId), so
52
55
  * archiveStream targets that single aggregateId as usual. */
53
- export async function paymentTenantDestroyHook(ctx: {
54
- readonly db: import("@cosmicdrift/kumiko-framework/db").DbRunner;
55
- readonly tenantId: TenantId;
56
- }): Promise<void> {
56
+ export async function paymentTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
57
+ declareEscapeHatch({ reason: PAYMENT_TENANT_DESTROY_ARCHIVE_REASON });
57
58
  const { profile } = await resolveProfileForTenant({
58
- db: ctx.db,
59
+ db: ctx.db.unsafeRaw(PAYMENT_TENANT_DESTROY_ARCHIVE_REASON),
59
60
  tenantId: ctx.tenantId,
60
61
  });
61
62
  const aggregateId = paymentAggregateId(ctx.tenantId);
62
63
  if (profile.key === "de-hr-dsgvo-hgb") {
63
- await updateMany(
64
- ctx.db,
64
+ await ctx.db.updateMany(
65
65
  paymentsProjectionTable as EntityTableMeta,
66
66
  { providerCustomerId: REDACTED_PII_VALUE },
67
67
  { tenantId: ctx.tenantId },
68
68
  );
69
69
  } else {
70
- await deleteMany(ctx.db, paymentsProjectionTable as EntityTableMeta, {
70
+ await ctx.db.deleteMany(paymentsProjectionTable as EntityTableMeta, {
71
71
  tenantId: ctx.tenantId,
72
72
  });
73
73
  }
74
- await archiveStream(ctx.db, {
74
+ await archiveStream(ctx.db.unsafeRaw(PAYMENT_TENANT_DESTROY_ARCHIVE_REASON), {
75
75
  tenantId: ctx.tenantId,
76
76
  aggregateId,
77
77
  aggregateType: PAYMENT_AGGREGATE_TYPE,
@@ -26,7 +26,12 @@
26
26
 
27
27
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
28
28
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
29
- import type { BillingEventKinds, SubscriptionEventType, SubscriptionStatus } from "./constants";
29
+ import {
30
+ type BillingEventKinds,
31
+ SUBSCRIPTION_PROVIDER_EXTENSION,
32
+ type SubscriptionEventType,
33
+ type SubscriptionStatus,
34
+ } from "./constants";
30
35
 
31
36
  // =============================================================================
32
37
  // Normalisierter Webhook-Event
@@ -215,3 +220,10 @@ export type SubscriptionProviderPlugin = {
215
220
  providerSubscriptionId: string,
216
221
  ) => Promise<void>;
217
222
  };
223
+
224
+ // r.useExtension options-shape, co-located since the framework never imports upward.
225
+ declare module "@cosmicdrift/kumiko-framework/engine" {
226
+ interface KumikoExtensionOptionsMap {
227
+ [SUBSCRIPTION_PROVIDER_EXTENSION]: SubscriptionProviderPlugin;
228
+ }
229
+ }
@@ -1,3 +1,4 @@
1
+ import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
1
2
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { createEmailChannel, type EmailChannelOptions } from "./email-channel";
3
4
 
@@ -15,7 +16,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
15
16
  });
16
17
  r.requires("delivery");
17
18
 
18
- r.useExtension("deliveryChannel", "email", {
19
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "email", {
19
20
  mode: channel.mode,
20
21
  resolve: channel.resolve,
21
22
  render: channel.render,
@@ -1,3 +1,4 @@
1
+ import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
1
2
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { inboxQuery } from "./handlers/inbox.query";
3
4
  import { markAllReadWrite } from "./handlers/mark-all-read.write";
@@ -23,7 +24,7 @@ export function createChannelInAppFeature(): FeatureDefinition {
23
24
  });
24
25
 
25
26
  // Register as delivery channel via extension system
26
- r.useExtension("deliveryChannel", "inApp", {
27
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "inApp", {
27
28
  mode: inAppChannel.mode,
28
29
  resolve: inAppChannel.resolve,
29
30
  send: inAppChannel.send,
@@ -1,3 +1,4 @@
1
+ import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
1
2
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { createPushChannel, type PushChannelOptions } from "./push-channel";
3
4
 
@@ -15,7 +16,7 @@ export function createChannelPushFeature(options: PushChannelOptions): FeatureDe
15
16
  });
16
17
  r.requires("delivery");
17
18
 
18
- r.useExtension("deliveryChannel", "push", {
19
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
19
20
  mode: channel.mode,
20
21
  resolve: channel.resolve,
21
22
  send: channel.send,
@@ -19,8 +19,8 @@ import { randomBytes } from "node:crypto";
19
19
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
20
20
  import {
21
21
  createEventStoreExecutor,
22
+ createSystemDbView,
22
23
  createTenantDb,
23
- createUncheckedSystemDb,
24
24
  } from "@cosmicdrift/kumiko-framework/db";
25
25
  import {
26
26
  access,
@@ -157,12 +157,23 @@ function racyJobCtx(
157
157
  };
158
158
  return {
159
159
  db: createTenantDb(stack.db, SYSTEM_TENANT_ID),
160
- systemDb: createUncheckedSystemDb(createTenantDb(stack.db, SYSTEM_TENANT_ID, "system")),
160
+ systemDb: createSystemDbView(
161
+ createTenantDb(stack.db, SYSTEM_TENANT_ID, "system", undefined, undefined, undefined, {
162
+ unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
163
+ }),
164
+ ),
161
165
  registry: stack.registry,
162
166
  masterKeyProvider: mutableProvider,
163
167
  configEncryption: racyCipher,
164
168
  log,
165
- } as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log
169
+ // Without a sink, the job's escape-hatch reporter warn-logs through `log` and its
170
+ // report is deduped by the framework's process-wide window (escape-hatch-report.ts),
171
+ // so whether the unconditional per-run scan ack reaches `log.warn` here depends on
172
+ // unrelated sibling tests' order in the same process. A sink (mirrors production,
173
+ // where the audit feature wires one) routes that report away from `log` entirely,
174
+ // isolating this test from that shared window.
175
+ _escapeHatchAuditSink: async () => {},
176
+ } as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log/_escapeHatchAuditSink
166
177
  }
167
178
 
168
179
  beforeAll(async () => {
@@ -12,8 +12,8 @@ import { randomBytes } from "node:crypto";
12
12
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
13
  import {
14
14
  createEventStoreExecutor,
15
+ createSystemDbView,
15
16
  createTenantDb,
16
- createUncheckedSystemDb,
17
17
  } from "@cosmicdrift/kumiko-framework/db";
18
18
  import {
19
19
  access,
@@ -138,10 +138,18 @@ function capturingLog(captured: CapturedLog): TestJobLog {
138
138
 
139
139
  function jobCtx(captured?: CapturedLog): Parameters<typeof reencryptJob>[1] {
140
140
  const log = captured ? capturingLog(captured) : noopLog;
141
- const systemModeDb = createTenantDb(stack.db, SYSTEM_TENANT_ID, "system");
141
+ const systemModeDb = createTenantDb(
142
+ stack.db,
143
+ SYSTEM_TENANT_ID,
144
+ "system",
145
+ undefined,
146
+ undefined,
147
+ undefined,
148
+ { unsafeRaw: { reason: "test: job context mirrors systemScope() grant" } },
149
+ );
142
150
  return {
143
151
  db: systemModeDb,
144
- systemDb: createUncheckedSystemDb(systemModeDb),
152
+ systemDb: createSystemDbView(systemModeDb),
145
153
  registry: stack.registry,
146
154
  masterKeyProvider: mutableProvider,
147
155
  configEncryption: cipher,
@@ -1,5 +1,5 @@
1
1
  // Regression guard for kumiko-framework#2072: the KEK-rotation job's
2
- // systemScope() fail-closed checks — createUncheckedSystemDb().
2
+ // systemScope() fail-closed checks — createSystemDbView().
3
3
  // acknowledgeCrossTenant() gating the initial cross-tenant scan,
4
4
  // assertRowsTenant() re-verifying each per-tenant slice before it's served
5
5
  // to the write loop — must not block legitimate multi-tenant rotation. A
@@ -14,8 +14,8 @@ import { randomBytes } from "node:crypto";
14
14
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
15
  import {
16
16
  createEventStoreExecutor,
17
+ createSystemDbView,
17
18
  createTenantDb,
18
- createUncheckedSystemDb,
19
19
  } from "@cosmicdrift/kumiko-framework/db";
20
20
  import {
21
21
  access,
@@ -117,7 +117,11 @@ function capturingLog(captured: CapturedLog): TestJobLog {
117
117
  function jobCtx(captured: CapturedLog): Parameters<typeof reencryptJob>[1] {
118
118
  return {
119
119
  db: createTenantDb(stack.db, SYSTEM_TENANT_ID),
120
- systemDb: createUncheckedSystemDb(createTenantDb(stack.db, SYSTEM_TENANT_ID, "system")),
120
+ systemDb: createSystemDbView(
121
+ createTenantDb(stack.db, SYSTEM_TENANT_ID, "system", undefined, undefined, undefined, {
122
+ unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
123
+ }),
124
+ ),
121
125
  registry: stack.registry,
122
126
  masterKeyProvider: mutableProvider,
123
127
  configEncryption: cipher,
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
- import { createUncheckedSystemDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createSystemDbView } from "@cosmicdrift/kumiko-framework/db";
4
4
  import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import { requireSystemDb } from "../feature";
7
7
 
8
8
  // Minimal TenantDb stub — only .tenantId is read by assertTenantMatch /
9
- // createUncheckedSystemDb; the real db-query methods are never called by
9
+ // createSystemDbView; the real db-query methods are never called by
10
10
  // requireSystemDb itself.
11
11
  function fakeTenantDb(tenantId: TenantId): TenantDb {
12
12
  return { tenantId, mode: "system" } as unknown as TenantDb;
@@ -16,7 +16,7 @@ function fakeTenantDb(tenantId: TenantId): TenantDb {
16
16
  function contextStub(db: TenantDb, withSystemDb: boolean): HandlerContext {
17
17
  return {
18
18
  db,
19
- systemDb: withSystemDb ? createUncheckedSystemDb(db) : undefined,
19
+ systemDb: withSystemDb ? createSystemDbView(db) : undefined,
20
20
  // biome-ignore lint/suspicious/noExplicitAny: rest of HandlerContext isn't touched by requireSystemDb.
21
21
  } as any;
22
22
  }
@@ -21,12 +21,12 @@
21
21
  // UncheckedSystemDb.acknowledgeCrossTenant(). Rows are bucketed by tenant,
22
22
  // and assertRowsTenant() re-verifies each row against its bucket's tenant
23
23
  // right before the write — both checks are built locally from the exported
24
- // createUncheckedSystemDb().
24
+ // createSystemDbView().
25
25
 
26
26
  import {
27
27
  createEventStoreExecutor,
28
+ createSystemDbView,
28
29
  createTenantDb,
29
- createUncheckedSystemDb,
30
30
  type DbConnection,
31
31
  type TenantDb,
32
32
  type UncheckedSystemDb,
@@ -135,11 +135,7 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
135
135
  function sdbFor(tenantId: TenantId): UncheckedSystemDb {
136
136
  let existing = sdbCache.get(tenantId);
137
137
  if (!existing) {
138
- existing = createUncheckedSystemDb(
139
- tdbFor(tenantId),
140
- undefined,
141
- reportEscapeHatchFor(tenantId),
142
- );
138
+ existing = createSystemDbView(tdbFor(tenantId), undefined, reportEscapeHatchFor(tenantId));
143
139
  sdbCache.set(tenantId, existing);
144
140
  }
145
141
  return existing;
@@ -149,7 +145,7 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
149
145
  // to acknowledge the cross-tenant scan below, never bound to a real
150
146
  // tenant's writes — sharing it with sdbFor(SYSTEM_TENANT_ID) would make a
151
147
  // write-path cache entry double as the scan's ack gate.
152
- const scanDb = createUncheckedSystemDb(
148
+ const scanDb = createSystemDbView(
153
149
  createTenantDb(db, SYSTEM_TENANT_ID, "system"),
154
150
  undefined,
155
151
  reportEscapeHatchFor(SYSTEM_TENANT_ID),
@@ -1,32 +1,33 @@
1
- // #2323: field-access.ts, quota.ts, user-data-rights.ts and retention.ts read
2
- // custom-field rows via asRawClient(db).unsafe() directly, bypassing the
3
- // #1163 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. applyRetentionRemovals
8
- // (an UPDATE) is out of scope per #1358 — writes stay unretried.
1
+ // field-access.ts, quota.ts, user-data-rights.ts and retention.ts read
2
+ // custom-field rows through unsafeReadRetrying; this proves the retry fires
3
+ // per call site using a real captured driver error. applyRetentionRemovals
4
+ // (an UPDATE) stays out of scope — unretried by design.
9
5
 
10
- import { describe, expect, test } from "bun:test";
6
+ import { beforeAll, describe, expect, test } from "bun:test";
7
+ import { captureClosedConnectionError } from "@cosmicdrift/kumiko-framework/testing";
11
8
  import { selectSerializedFieldDefinition } from "../field-access";
12
9
  import { countTenantFieldDefinitions } from "../quota";
13
10
  import { selectHostRowsWithCustomFields } from "../retention";
14
11
  import { selectCustomFieldsHostRows, selectFieldDefinitionsForEntity } from "../user-data-rights";
15
12
 
16
- function closedConnectionError(): Error {
17
- return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
18
- }
13
+ let closedConnectionError: unknown;
14
+
15
+ beforeAll(async () => {
16
+ closedConnectionError = await captureClosedConnectionError();
17
+ });
19
18
 
20
19
  type FakeClient = {
21
20
  unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
22
21
  begin: () => never;
22
+ options: { max: number };
23
23
  calls: number;
24
24
  };
25
25
 
26
- function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient {
26
+ function fakeClient(failures: unknown[], row: Record<string, unknown>): FakeClient {
27
27
  const remaining = [...failures];
28
28
  const client: FakeClient = {
29
29
  calls: 0,
30
+ options: { max: 1 },
30
31
  unsafe: async () => {
31
32
  client.calls++;
32
33
  const err = remaining.shift();
@@ -40,30 +41,30 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
40
41
  return client;
41
42
  }
42
43
 
43
- describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
44
+ describe("custom-fields db/queries — closed-connection retry", () => {
44
45
  test("selectSerializedFieldDefinition retries once through db.unsafeRaw and returns the row", async () => {
45
- const raw = fakeClient([closedConnectionError()], { serialized_field: "sf1" });
46
+ const raw = fakeClient([closedConnectionError], { serialized_field: "sf1" });
46
47
  const result = await selectSerializedFieldDefinition(raw, "t1", "entity", "field");
47
48
  expect(result).toBe("sf1");
48
49
  expect(raw.calls).toBe(2);
49
50
  });
50
51
 
51
52
  test("countTenantFieldDefinitions retries once through db.unsafeRaw and returns the count", async () => {
52
- const raw = fakeClient([closedConnectionError()], { n: 3 });
53
+ const raw = fakeClient([closedConnectionError], { n: 3 });
53
54
  const result = await countTenantFieldDefinitions(raw, "t1");
54
55
  expect(result).toBe(3);
55
56
  expect(raw.calls).toBe(2);
56
57
  });
57
58
 
58
59
  test("selectCustomFieldsHostRows retries once and returns rows", async () => {
59
- const db = fakeClient([closedConnectionError()], { id: "1", custom_fields: {} });
60
+ const db = fakeClient([closedConnectionError], { id: "1", custom_fields: {} });
60
61
  const rows = await selectCustomFieldsHostRows(db as never, "host_table", "user_id", "u1", "t1");
61
62
  expect(rows).toHaveLength(1);
62
63
  expect(db.calls).toBe(2);
63
64
  });
64
65
 
65
66
  test("selectFieldDefinitionsForEntity retries once and returns rows", async () => {
66
- const db = fakeClient([closedConnectionError()], { field_key: "k1", serialized_field: "sf" });
67
+ const db = fakeClient([closedConnectionError], { field_key: "k1", serialized_field: "sf" });
67
68
  const rows = await selectFieldDefinitionsForEntity(db as never, "entity", "t1");
68
69
  expect(rows).toHaveLength(1);
69
70
  expect(rows[0]?.field_key).toBe("k1");
@@ -71,7 +72,7 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
71
72
  });
72
73
 
73
74
  test("selectHostRowsWithCustomFields retries once and returns rows", async () => {
74
- const db = fakeClient([closedConnectionError()], {
75
+ const db = fakeClient([closedConnectionError], {
75
76
  id: "1",
76
77
  modified_at: null,
77
78
  custom_fields: {},
@@ -81,13 +82,13 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
81
82
  expect(db.calls).toBe(2);
82
83
  });
83
84
 
84
- test("gives up after the single retry when the connection stays closed", async () => {
85
- const raw = fakeClient([closedConnectionError(), closedConnectionError()], {
85
+ test("gives up after exhausting pool-bounded retries (max: 1 → 3 total calls)", async () => {
86
+ const raw = fakeClient([closedConnectionError, closedConnectionError, closedConnectionError], {
86
87
  serialized_field: "sf1",
87
88
  });
88
- await expect(selectSerializedFieldDefinition(raw, "t1", "entity", "field")).rejects.toThrow(
89
- "connection was closed",
89
+ await expect(selectSerializedFieldDefinition(raw, "t1", "entity", "field")).rejects.toBe(
90
+ closedConnectionError,
90
91
  );
91
- expect(raw.calls).toBe(2);
92
+ expect(raw.calls).toBe(3);
92
93
  });
93
94
  });