@cosmicdrift/kumiko-bundled-features 0.305.0 → 0.307.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 (76) 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/changes.json +5 -0
  5. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -1
  6. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  7. package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
  8. package/src/auth-foundation/__tests__/tenant-providers.test.ts +1 -0
  9. package/src/auth-foundation/__tests__/token-verifier.test.ts +2 -0
  10. package/src/auth-foundation/types.ts +14 -4
  11. package/src/auth-mfa/__tests__/session-scoped-sse-revoke.integration.test.ts +270 -0
  12. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -1
  13. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  14. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +4 -63
  15. package/src/billing-foundation/__tests__/tenant-destroy.integration.test.ts +199 -0
  16. package/src/billing-foundation/changes.json +6 -0
  17. package/src/billing-foundation/feature.ts +8 -1
  18. package/src/billing-foundation/tenant-destroy-hook.ts +20 -20
  19. package/src/billing-foundation/types.ts +13 -1
  20. package/src/cap-counter/__tests__/enforce-cap.test.ts +7 -0
  21. package/src/cap-counter/changes.json +5 -0
  22. package/src/cap-counter/enforce-cap.ts +8 -1
  23. package/src/channel-email/feature.ts +2 -1
  24. package/src/channel-in-app/feature.ts +2 -1
  25. package/src/channel-push/feature.ts +2 -1
  26. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +14 -3
  27. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +11 -3
  28. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +7 -3
  29. package/src/config/__tests__/require-system-db.test.ts +3 -3
  30. package/src/config/handlers/reencrypt.job.ts +4 -8
  31. package/src/custom-fields/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +25 -24
  32. package/src/delivery/__tests__/delivery-screens.boot.test.ts +20 -2
  33. package/src/delivery/__tests__/delivery.integration.test.ts +140 -3
  34. package/src/delivery/constants.ts +4 -0
  35. package/src/delivery/delivery-service.ts +45 -29
  36. package/src/delivery/feature.ts +2 -1
  37. package/src/delivery/index.ts +14 -11
  38. package/src/delivery/types.ts +24 -0
  39. package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +0 -27
  40. package/src/document-ingest-foundation/__tests__/tenant-destroy.integration.test.ts +166 -0
  41. package/src/document-ingest-foundation/changes.json +5 -0
  42. package/src/document-ingest-foundation/tenant-destroy-hook.ts +6 -17
  43. package/src/file-derivatives/handlers/public-variant.query.ts +7 -0
  44. package/src/files/__tests__/files.integration.test.ts +2 -2
  45. package/src/form-draft/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +24 -21
  46. package/src/inbound-mail-foundation/__tests__/tenant-destroy.integration.test.ts +173 -0
  47. package/src/inbound-mail-foundation/changes.json +6 -0
  48. package/src/inbound-mail-foundation/feature.ts +4 -0
  49. package/src/inbound-mail-foundation/tenant-destroy-hook.ts +15 -20
  50. package/src/inbound-mail-foundation/types.ts +10 -3
  51. package/src/jobs/__tests__/job-system-user.integration.test.ts +3 -3
  52. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +15 -6
  53. package/src/mail-foundation/feature.ts +15 -4
  54. package/src/mail-foundation/index.ts +1 -0
  55. package/src/mail-transport-inmemory/feature.ts +5 -4
  56. package/src/mail-transport-smtp/feature.ts +5 -4
  57. package/src/renderer-foundation/__tests__/collect-plugins.integration.test.ts +24 -2
  58. package/src/renderer-foundation/changes.json +8 -1
  59. package/src/renderer-foundation/constants.ts +4 -0
  60. package/src/renderer-foundation/feature.ts +10 -13
  61. package/src/renderer-foundation/index.ts +3 -0
  62. package/src/renderer-foundation/types.ts +23 -1
  63. package/src/renderer-simple/feature.ts +2 -1
  64. package/src/sessions/handlers/revoke-all-others.write.ts +1 -0
  65. package/src/sessions/session-callbacks.ts +1 -0
  66. package/src/sessions/session-revoked-event.ts +3 -0
  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/workflow-runner/__tests__/workflow-runner.integration.test.ts +5 -0
  76. package/src/user/db/queries/__tests__/unsafe-read-retrying.test.ts +0 -52
@@ -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
+ }
@@ -114,6 +114,13 @@ describe("enforceCap — burstable profile (mails / tokens)", () => {
114
114
  await expect(enforceCap(ctx, opts)).rejects.toThrow(CapExceededError);
115
115
  });
116
116
 
117
+ test("amount checks the last of the booked units: 1190 + 10 units passes, + 11 throws", async () => {
118
+ const ctx = stubCalendarCtx([{ value: 1190, lastSoftWarnedAt: null }]);
119
+ const passing = await enforceCap(ctx, { ...opts, amount: 10 });
120
+ expect(passing).toEqual({ state: "soft-hit", value: 1199, crossed: true });
121
+ await expect(enforceCap(ctx, { ...opts, amount: 11 })).rejects.toThrow(CapExceededError);
122
+ });
123
+
117
124
  test("CapExceededError carries cap-name + limit + currentValue", async () => {
118
125
  const ctx = stubCalendarCtx([{ value: 1500, lastSoftWarnedAt: null }]);
119
126
  try {
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.307.0",
4
+ "type": "improvement",
5
+ "title": "enforceCap accepts an amount for multi-unit bookings"
6
+ },
2
7
  {
3
8
  "version": "0.278.0",
4
9
  "type": "improvement",
@@ -93,6 +93,9 @@ export async function enforceCap(
93
93
  readonly periodStartIso: string;
94
94
  readonly limit: number;
95
95
  readonly profile: CapToleranceProfileName;
96
+ // Units the caller is about to book in one go (default 1), checked with
97
+ // the same outcome as booking them one by one.
98
+ readonly amount?: number;
96
99
  },
97
100
  ): Promise<EnforceCapResult> {
98
101
  if (!ctx.db) {
@@ -110,7 +113,9 @@ export async function enforceCap(
110
113
  );
111
114
 
112
115
  const row = rows[0];
113
- const value = row ? (row["value"] as number) : 0; // @cast-boundary db-row
116
+ const storedValue = row ? (row["value"] as number) : 0; // @cast-boundary db-row
117
+ // The last of `amount` units sees this value before its own increment.
118
+ const value = storedValue + (options.amount ?? 1) - 1;
114
119
 
115
120
  if (value >= hardThreshold) {
116
121
  throw new CapExceededError(options.capName, options.limit, value, tolerance);
@@ -285,6 +290,7 @@ export async function enforceCapAndMaybeNotify(
285
290
  readonly limit: number;
286
291
  readonly profile: CapToleranceProfileName;
287
292
  readonly notify: SoftHitNotifier;
293
+ readonly amount?: number;
288
294
  },
289
295
  ): Promise<EnforceCapResult> {
290
296
  const result = await enforceCap(ctx, {
@@ -292,6 +298,7 @@ export async function enforceCapAndMaybeNotify(
292
298
  periodStartIso: options.periodStartIso,
293
299
  limit: options.limit,
294
300
  profile: options.profile,
301
+ ...(options.amount !== undefined && { amount: options.amount }),
295
302
  });
296
303
 
297
304
  if (result.state === "soft-hit" && result.crossed) {
@@ -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
  });
@@ -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([