@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
@@ -26,9 +26,10 @@ import type {
26
26
  EmailTransport,
27
27
  } from "@cosmicdrift/kumiko-bundled-features/channel-email";
28
28
  import { createInMemoryTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
29
- import type {
30
- MailTransportContext,
31
- MailTransportPlugin,
29
+ import {
30
+ MAIL_TRANSPORT_EXTENSION,
31
+ type MailTransportContext,
32
+ type MailTransportPlugin,
32
33
  } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
33
34
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
34
35
 
@@ -92,5 +93,5 @@ export const mailTransportInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
92
93
  return getOrCreateTransportForTenant(tenantId);
93
94
  }, // @wrapper-known semantic-alias
94
95
  };
95
- r.useExtension("mailTransport", "inmemory", plugin);
96
+ r.useExtension(MAIL_TRANSPORT_EXTENSION, "inmemory", plugin);
96
97
  });
@@ -34,9 +34,10 @@ import {
34
34
  requireNonEmpty,
35
35
  requireSecretSet,
36
36
  } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
37
- import type {
38
- MailTransportContext,
39
- MailTransportPlugin,
37
+ import {
38
+ MAIL_TRANSPORT_EXTENSION,
39
+ type MailTransportContext,
40
+ type MailTransportPlugin,
40
41
  } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
41
42
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
42
43
  import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
@@ -114,7 +115,7 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
114
115
  const plugin: MailTransportPlugin = {
115
116
  build: async (ctx: MailTransportContext, tenantId: string) => buildSmtpTransport(ctx, tenantId), // @wrapper-known semantic-alias
116
117
  };
117
- r.useExtension("mailTransport", "smtp", plugin);
118
+ r.useExtension(MAIL_TRANSPORT_EXTENSION, "smtp", plugin);
118
119
 
119
120
  return {
120
121
  /** Config-key-handles — typed reads via `ctx.config(...)` in
@@ -3,6 +3,7 @@ import { defineFeature, type TenantId } from "@cosmicdrift/kumiko-framework/engi
3
3
  import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
4
4
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
5
5
  import { createRendererFoundationApi } from "../api";
6
+ import { RENDERER_EXTENSION, type RenderKind } from "../constants";
6
7
  import { collectRendererPlugins, createRendererFoundationFeature } from "../feature";
7
8
  import type { RenderRequest, RenderResponse } from "../types";
8
9
 
@@ -12,10 +13,10 @@ let stack: TestStack;
12
13
 
13
14
  // Mini-Plugin via defineFeature + r.useExtension — wie ein echter
14
15
  // renderer-plugin (renderer-simple, renderer-mail-html) sich registriert.
15
- function createTestPluginFeature(name: string, kinds: ReadonlyArray<string>) {
16
+ function createTestPluginFeature(name: string, kinds: ReadonlyArray<RenderKind>) {
16
17
  return defineFeature(`renderer-${name}`, (r) => {
17
18
  r.requires("renderer-foundation");
18
- r.useExtension("renderer", name, {
19
+ r.useExtension(RENDERER_EXTENSION, name, {
19
20
  kinds,
20
21
  render: async (req: RenderRequest): Promise<RenderResponse> => {
21
22
  if (req.kind === "notification") return { kind: "notification", html: `via:${name}` };
@@ -85,6 +86,27 @@ describe("renderer-foundation :: Plugin-Pool aus Registry", () => {
85
86
  );
86
87
  });
87
88
 
89
+ test("collectRendererPlugins fails loud on a registration with invalid options", async () => {
90
+ const brokenStack = await setupTestStack({
91
+ features: [
92
+ createTemplateResolverFeature(),
93
+ createRendererFoundationFeature(),
94
+ defineFeature("renderer-broken", (r) => {
95
+ r.requires("renderer-foundation");
96
+ // @ts-expect-error kinds/render are required — proves the guard rejects an empty options bag instead of silently dropping the plugin
97
+ r.useExtension(RENDERER_EXTENSION, "broken", {});
98
+ }),
99
+ ],
100
+ });
101
+ try {
102
+ expect(() => collectRendererPlugins(brokenStack.registry)).toThrow(
103
+ `${RENDERER_EXTENSION} registration for "broken" has invalid options`,
104
+ );
105
+ } finally {
106
+ await brokenStack.cleanup();
107
+ }
108
+ });
109
+
88
110
  test("Plugin.render mit echtem Pool fließt end-to-end durch", async () => {
89
111
  const plugins = collectRendererPlugins(stack.registry);
90
112
  const api = createRendererFoundationApi(plugins);
@@ -1 +1,8 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.306.0",
4
+ "type": "breaking",
5
+ "title": "Bundled extension points (renderer, deliveryChannel, mailTransport, inboundMailProvider, subscriptionProvider) are typed",
6
+ "migration": "r.useExtension options for renderer ({ kinds, render }), deliveryChannel ({ mode, resolve, render?, send, accept? }), mailTransport (MailTransportPlugin), inboundMailProvider (InboundMailProviderPlugin) and subscriptionProvider (SubscriptionProviderPlugin) are now type-checked and required; renderer and deliveryChannel registrations do not pass name (it comes from the entity name). Prefer the new constants RENDERER_EXTENSION, DELIVERY_CHANNEL_EXTENSION and MAIL_TRANSPORT_EXTENSION over string literals. A registration whose options do not match the plugin shape now throws with the entity name when channels or renderers are collected."
7
+ }
8
+ ]
@@ -4,6 +4,10 @@
4
4
  // über das Barrel (../template-resolver), nicht via deep-import.
5
5
  import type { RenderKind as RenderKindLocal } from "../template-resolver";
6
6
 
7
+ // Extension-point name for renderer plugins (renderer-simple,
8
+ // renderer-mail-html, renderer-puppeteer-client, ...).
9
+ export const RENDERER_EXTENSION = "renderer" as const;
10
+
7
11
  export {
8
12
  CONTENT_FORMATS,
9
13
  type ContentFormat,
@@ -1,5 +1,6 @@
1
1
  import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
2
- import type { RendererPlugin } from "./types";
2
+ import { RENDERER_EXTENSION } from "./constants";
3
+ import { isRendererRegistrationPlugin, type RendererPlugin } from "./types";
3
4
 
4
5
  // renderer-foundation — Plugin-Foundation für Renderer (Notification,
5
6
  // HTML-Mail, PDF, Image). Plan-Doc:
@@ -21,7 +22,7 @@ export function createRendererFoundationFeature() {
21
22
  });
22
23
  r.requires("template-resolver");
23
24
 
24
- r.extendsRegistrar("renderer", {
25
+ r.extendsRegistrar(RENDERER_EXTENSION, {
25
26
  onRegister: () => {
26
27
  // Plugin-Konformitäts-Check könnte hier: shape-validation der
27
28
  // options (kinds, render-Funktion present). Aktuell kein
@@ -39,17 +40,13 @@ export function createRendererFoundationFeature() {
39
40
  //
40
41
  // Symmetrisch zu collectChannels / collectRenderers in delivery-service.
41
42
  export function collectRendererPlugins(registry: Registry): RendererPlugin[] {
42
- const usages = registry.getExtensionUsages("renderer");
43
+ const usages = registry.getExtensionUsages(RENDERER_EXTENSION);
43
44
  return usages.map((usage) => {
44
- // @cast-boundary engine-payload — extension-usage carries unknown options
45
- const opts = usage.options as {
46
- kinds: RendererPlugin["kinds"];
47
- render: RendererPlugin["render"];
48
- };
49
- return {
50
- name: usage.entityName,
51
- kinds: opts.kinds,
52
- render: opts.render,
53
- };
45
+ if (!isRendererRegistrationPlugin(usage.options)) {
46
+ throw new Error(
47
+ `${RENDERER_EXTENSION} registration for "${usage.entityName}" has invalid options`,
48
+ );
49
+ }
50
+ return { name: usage.entityName, ...usage.options };
54
51
  });
55
52
  }
@@ -8,18 +8,21 @@ export {
8
8
  type ContentFormat,
9
9
  DEFAULT_PLUGIN_BY_KIND,
10
10
  RENDER_KINDS,
11
+ RENDERER_EXTENSION,
11
12
  type RenderKind,
12
13
  } from "./constants";
13
14
  export { collectRendererPlugins, createRendererFoundationFeature } from "./feature";
14
15
  export {
15
16
  type DocumentPayload,
16
17
  type ImageOptions,
18
+ isRendererRegistrationPlugin,
17
19
  type MailHtmlPayload,
18
20
  type NotificationPayload,
19
21
  type PdfOptions,
20
22
  type RendererContext,
21
23
  RendererError,
22
24
  type RendererPlugin,
25
+ type RendererRegistrationPlugin,
23
26
  type RenderRequest,
24
27
  type RenderResponse,
25
28
  } from "./types";
@@ -1,6 +1,6 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
- import type { ContentFormat, RenderKind } from "./constants";
3
+ import { type ContentFormat, RENDERER_EXTENSION, type RenderKind } from "./constants";
4
4
 
5
5
  // RenderRequest — Plugins erhalten das. Resource-Mode wählt der Caller
6
6
  // (renderer-foundation api) je nach kind oder explizit. Plugin selbst
@@ -107,3 +107,25 @@ export class RendererError extends Error {
107
107
  this.name = "RendererError";
108
108
  }
109
109
  }
110
+
111
+ // r.useExtension options-shape: `name` is NOT part of the registration
112
+ // payload — collectRendererPlugins derives it from the usage's entityName.
113
+ export type RendererRegistrationPlugin = Omit<RendererPlugin, "name">;
114
+
115
+ export function isRendererRegistrationPlugin(o: unknown): o is RendererRegistrationPlugin {
116
+ return (
117
+ typeof o === "object" &&
118
+ o !== null &&
119
+ "kinds" in o &&
120
+ Array.isArray(o.kinds) &&
121
+ "render" in o &&
122
+ typeof o.render === "function"
123
+ );
124
+ }
125
+
126
+ // r.useExtension options-shape, co-located since the framework never imports upward.
127
+ declare module "@cosmicdrift/kumiko-framework/engine" {
128
+ interface KumikoExtensionOptionsMap {
129
+ [RENDERER_EXTENSION]: RendererRegistrationPlugin;
130
+ }
131
+ }
@@ -1,5 +1,6 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import {
3
+ RENDERER_EXTENSION,
3
4
  type RendererContext,
4
5
  RendererError,
5
6
  type RenderRequest,
@@ -48,7 +49,7 @@ export function createRendererSimpleFeature(): FeatureDefinition {
48
49
  r.requires("renderer-foundation");
49
50
  r.optionalRequires("template-resolver");
50
51
 
51
- r.useExtension("renderer", "simple", {
52
+ r.useExtension(RENDERER_EXTENSION, "simple", {
52
53
  kinds: ["notification"] as const,
53
54
  render: adaptToFoundation,
54
55
  });
@@ -1,8 +1,8 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { buildServer, type JwtHelper } from "@cosmicdrift/kumiko-framework/api";
3
3
  import {
4
+ createSystemDbView,
4
5
  createTenantDb,
5
- createUncheckedSystemDb,
6
6
  type DbConnection,
7
7
  } from "@cosmicdrift/kumiko-framework/db";
8
8
  import {
@@ -102,7 +102,7 @@ beforeAll(async () => {
102
102
  {
103
103
  db: systemModeDb,
104
104
  dbOutsideTransaction: systemModeDb,
105
- systemDb: createUncheckedSystemDb(systemModeDb),
105
+ systemDb: createSystemDbView(systemModeDb),
106
106
  registry,
107
107
  ...bridgeStub(),
108
108
  },
@@ -1,7 +1,58 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ createRegistry,
5
+ defineFeature,
6
+ EXT_STORAGE_PROVIDER,
7
+ EXT_TENANT_DATA,
8
+ type TenantId,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
2
10
  import { DESTRUCTION_STAGES, isDestructionPipelineComplete, pickNextStage } from "../stages";
3
11
 
12
+ // Minimal declaring feature: real tenant-lifecycle feature drags in "tenant" +
13
+ // "compliance-profiles" requires, unrelated to what these guard tests exercise.
14
+ function declares(extensionName: string) {
15
+ return defineFeature(`declares-${extensionName}`, (r) => {
16
+ r.extendsRegistrar(extensionName, {});
17
+ });
18
+ }
19
+
20
+ const fakeDb = {} as DbRunner; // @cast-boundary test fixture — invalid-options guard throws before ctx.db is ever touched
21
+ const fakeTenantId = "00000000-0000-4000-8000-00000000000c" as TenantId;
22
+
23
+ function stageNamed(name: string) {
24
+ const stage = DESTRUCTION_STAGES.find((s) => s.name === name);
25
+ if (!stage) throw new Error(`stage "${name}" not found in DESTRUCTION_STAGES`);
26
+ return stage;
27
+ }
28
+
4
29
  describe("tenant-lifecycle stages", () => {
30
+ test("app-data stage fails loud when a tenantData registration has no destroy function", async () => {
31
+ const broken = defineFeature("broken-tenant-data", (r) => {
32
+ r.useExtension(EXT_TENANT_DATA, "bad-entity", {
33
+ // @ts-expect-error destroy must be a function — proves the guard rejects a non-function value instead of silently skipping it
34
+ destroy: "x",
35
+ });
36
+ });
37
+ const registry = createRegistry([declares(EXT_TENANT_DATA), broken]);
38
+ await expect(
39
+ stageNamed("app-data").run({ db: fakeDb, registry, tenantId: fakeTenantId }),
40
+ ).rejects.toThrow(`${EXT_TENANT_DATA} registration for "bad-entity" has no destroy function`);
41
+ });
42
+
43
+ test("files stage fails loud when a storageProvider registration has no destroyTenant function", async () => {
44
+ const broken = defineFeature("broken-storage-provider", (r) => {
45
+ // @ts-expect-error destroyTenant is required — proves the guard rejects an empty options bag instead of silently skipping it
46
+ r.useExtension(EXT_STORAGE_PROVIDER, "bad-entity", {});
47
+ });
48
+ const registry = createRegistry([declares(EXT_STORAGE_PROVIDER), broken]);
49
+ await expect(
50
+ stageNamed("files").run({ db: fakeDb, registry, tenantId: fakeTenantId }),
51
+ ).rejects.toThrow(
52
+ `${EXT_STORAGE_PROVIDER} registration for "bad-entity" has no destroy function`,
53
+ );
54
+ });
55
+
5
56
  test("pickNextStage halts when any stage was abandoned", () => {
6
57
  const completed = new Set(["external-resources", "search-indices"]);
7
58
  const abandoned = new Set(["app-data"]);
@@ -14,9 +14,12 @@ import {
14
14
  EXT_STORAGE_PROVIDER,
15
15
  EXT_TENANT_DATA,
16
16
  extensionUsageEscapeHatchReason,
17
+ isTenantDataExtensionHooks,
18
+ isTenantResourceExtensionHooks,
17
19
  type Registry,
18
20
  type TenantDataHookCtx,
19
21
  type TenantId,
22
+ type TenantResourceExtensionName,
20
23
  } from "@cosmicdrift/kumiko-framework/engine";
21
24
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
22
25
  import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
@@ -63,28 +66,29 @@ const tenantMembershipCrud = createEventStoreExecutor(
63
66
 
64
67
  async function runExtensionDestroyHooks(
65
68
  registry: Registry,
66
- extensionName: string,
67
- hookKey: "destroyTenant",
69
+ extensionName: TenantResourceExtensionName,
68
70
  ctx: DestructionStageCtx,
69
71
  ): Promise<void> {
70
72
  const usages = registry.getExtensionUsages(extensionName);
71
73
  for (const usage of usages) {
72
- const hook = usage.options?.[hookKey];
73
- if (typeof hook !== "function") continue;
74
- await (hook as (tenantId: TenantId, hookCtx: DestructionStageCtx) => Promise<void>)(
75
- ctx.tenantId,
76
- ctx,
77
- );
74
+ if (!isTenantResourceExtensionHooks(usage.options)) {
75
+ throw new Error(
76
+ `${extensionName} registration for "${usage.entityName}" has no destroy function`,
77
+ );
78
+ }
79
+ await usage.options.destroyTenant(ctx.tenantId, ctx);
78
80
  }
79
81
  }
80
82
 
81
83
  async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
82
84
  const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
83
85
  for (const usage of usages) {
84
- const destroy = usage.options?.["destroy"] as
85
- | ((hookCtx: TenantDataHookCtx) => Promise<void>)
86
- | undefined;
87
- if (!destroy) continue;
86
+ if (!isTenantDataExtensionHooks(usage.options)) {
87
+ throw new Error(
88
+ `${EXT_TENANT_DATA} registration for "${usage.entityName}" has no destroy function`,
89
+ );
90
+ }
91
+ const destroy = usage.options.destroy;
88
92
  const reason = extensionUsageEscapeHatchReason(usage);
89
93
  const report = createEscapeHatchReporter({
90
94
  handler: `${EXT_TENANT_DATA}:${usage.entityName}`,
@@ -179,13 +183,12 @@ export const DESTRUCTION_STAGES: readonly DestructionStage[] = [
179
183
  {
180
184
  name: "external-resources",
181
185
  maxAttempts: 3,
182
- run: (ctx) =>
183
- runExtensionDestroyHooks(ctx.registry, EXT_EXTERNAL_RESOURCE, "destroyTenant", ctx),
186
+ run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_EXTERNAL_RESOURCE, ctx),
184
187
  },
185
188
  {
186
189
  name: "search-indices",
187
190
  maxAttempts: 3,
188
- run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_SEARCH_ADAPTER, "destroyTenant", ctx),
191
+ run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_SEARCH_ADAPTER, ctx),
189
192
  },
190
193
  {
191
194
  name: "cache",
@@ -205,13 +208,12 @@ export const DESTRUCTION_STAGES: readonly DestructionStage[] = [
205
208
  {
206
209
  name: "files",
207
210
  maxAttempts: 3,
208
- run: (ctx) =>
209
- runExtensionDestroyHooks(ctx.registry, EXT_STORAGE_PROVIDER, "destroyTenant", ctx),
211
+ run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_STORAGE_PROVIDER, ctx),
210
212
  },
211
213
  {
212
214
  name: "infra-resources",
213
215
  maxAttempts: 3,
214
- run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_INFRA_RESOURCE, "destroyTenant", ctx),
216
+ run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_INFRA_RESOURCE, ctx),
215
217
  },
216
218
  {
217
219
  name: "tenant-row",
@@ -0,0 +1,54 @@
1
+ // backfillUserStreamTenants's candidate scan runs through unsafeReadRetrying;
2
+ // this proves the retry fires using a real captured driver error.
3
+ // migrateAggregate's SELECT ... FOR UPDATE stays out of scope — it runs
4
+ // inside transaction(), where the retry guard is a no-op on a tx handle.
5
+
6
+ import { beforeAll, describe, expect, test } from "bun:test";
7
+ import { captureClosedConnectionError } from "@cosmicdrift/kumiko-framework/testing";
8
+ import { backfillUserStreamTenants } from "../stream-tenant-backfill";
9
+
10
+ let closedConnectionError: unknown;
11
+
12
+ beforeAll(async () => {
13
+ closedConnectionError = await captureClosedConnectionError();
14
+ });
15
+
16
+ type FakeClient = {
17
+ unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
18
+ begin: () => never;
19
+ options: { max: number };
20
+ calls: number;
21
+ };
22
+
23
+ function fakeClient(failures: unknown[]): FakeClient {
24
+ const remaining = [...failures];
25
+ const client: FakeClient = {
26
+ calls: 0,
27
+ options: { max: 1 },
28
+ unsafe: async () => {
29
+ client.calls++;
30
+ const err = remaining.shift();
31
+ if (err) throw err;
32
+ return [];
33
+ },
34
+ begin: () => {
35
+ throw new Error("not used in test");
36
+ },
37
+ };
38
+ return client;
39
+ }
40
+
41
+ describe("user db/queries — closed-connection retry", () => {
42
+ test("backfillUserStreamTenants retries the candidate scan once and completes with no candidates", async () => {
43
+ const db = fakeClient([closedConnectionError]);
44
+ const result = await backfillUserStreamTenants(db as never);
45
+ expect(result).toEqual({ aggregatesMigrated: 0, eventsMigrated: 0, failed: [] });
46
+ expect(db.calls).toBe(2);
47
+ });
48
+
49
+ test("gives up after exhausting pool-bounded retries (max: 1 → 3 total calls)", async () => {
50
+ const db = fakeClient([closedConnectionError, closedConnectionError, closedConnectionError]);
51
+ await expect(backfillUserStreamTenants(db as never)).rejects.toBe(closedConnectionError);
52
+ expect(db.calls).toBe(3);
53
+ });
54
+ });
@@ -98,6 +98,9 @@ const noSignedUrlProviderFeature = defineFeature("test-no-signed-url-provider",
98
98
  async exists() {
99
99
  return true;
100
100
  },
101
+ async list() {
102
+ return [];
103
+ },
101
104
  // **kein** getSignedUrl — pinst den 422-Pfad
102
105
  }),
103
106
  });
@@ -10,6 +10,7 @@
10
10
  // createTransportForTenant(ctx, ...).
11
11
 
12
12
  import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
13
+ import { MAIL_TRANSPORT_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
13
14
  import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
14
15
  import { hasMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
15
16
  import {
@@ -34,7 +35,7 @@ export type GdprMailDefaults = {
34
35
  // kann ohnehin nichts versendet werden und die App bleibt beim bisherigen
35
36
  // "kein Callback → keine Email"-Verhalten.
36
37
  export function isMailTransportAvailable(registry: Registry): boolean {
37
- return registry.getExtensionUsages("mailTransport").length > 0;
38
+ return registry.getExtensionUsages(MAIL_TRANSPORT_EXTENSION).length > 0;
38
39
  }
39
40
 
40
41
  type TransportResolver = (tenantId: string) => Promise<EmailTransport>;
@@ -25,8 +25,8 @@
25
25
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
26
26
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
27
27
  import {
28
+ createSystemDbView,
28
29
  createTenantDb,
29
- createUncheckedSystemDb,
30
30
  insertOne,
31
31
  selectMany,
32
32
  } from "@cosmicdrift/kumiko-framework/db";
@@ -205,7 +205,11 @@ async function runResumeDueRunsJob(tenantId: string): Promise<void> {
205
205
  const systemUser = createSystemUser(tenantId);
206
206
  const ctx: JobContext = {
207
207
  db: createTenantDb(stack.db, tenantId),
208
- systemDb: createUncheckedSystemDb(createTenantDb(stack.db, tenantId, "system")),
208
+ systemDb: createSystemDbView(
209
+ createTenantDb(stack.db, tenantId, "system", undefined, undefined, undefined, {
210
+ unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
211
+ }),
212
+ ),
209
213
  registry: stack.registry,
210
214
  systemUser,
211
215
  log: noopLogger,
@@ -24,8 +24,8 @@
24
24
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
25
25
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
26
26
  import {
27
+ createSystemDbView,
27
28
  createTenantDb,
28
- createUncheckedSystemDb,
29
29
  insertOne,
30
30
  selectMany,
31
31
  } from "@cosmicdrift/kumiko-framework/db";
@@ -183,7 +183,11 @@ async function runResumeDueRunsJob(): Promise<void> {
183
183
  const systemUser = createSystemUser(admin.tenantId);
184
184
  const ctx: JobContext = {
185
185
  db: createTenantDb(stack.db, admin.tenantId),
186
- systemDb: createUncheckedSystemDb(createTenantDb(stack.db, admin.tenantId, "system")),
186
+ systemDb: createSystemDbView(
187
+ createTenantDb(stack.db, admin.tenantId, "system", undefined, undefined, undefined, {
188
+ unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
189
+ }),
190
+ ),
187
191
  registry: stack.registry,
188
192
  systemUser,
189
193
  log: noopLogger,
@@ -1,52 +0,0 @@
1
- // #2323: stream-tenant-backfill.ts's candidate scan (a plain, non-transactional
2
- // SELECT) read via asRawClient(db).unsafe() directly, bypassing the #1163
3
- // closed-connection retry. Routed it through unsafeReadRetrying instead — this
4
- // test mirrors bun-db/__tests__/select-many-retry.test.ts's fake-client
5
- // pattern to prove the retry now fires. migrateAggregate's per-aggregate
6
- // SELECT ... FOR UPDATE stays out of scope: it runs inside transaction(),
7
- // where the retry guard (no begin() on a tx handle) is a no-op by design.
8
-
9
- import { describe, expect, test } from "bun:test";
10
- import { backfillUserStreamTenants } from "../stream-tenant-backfill";
11
-
12
- function closedConnectionError(): Error {
13
- return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
14
- }
15
-
16
- type FakeClient = {
17
- unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
18
- begin: () => never;
19
- calls: number;
20
- };
21
-
22
- function fakeClient(failures: Error[]): FakeClient {
23
- const remaining = [...failures];
24
- const client: FakeClient = {
25
- calls: 0,
26
- unsafe: async () => {
27
- client.calls++;
28
- const err = remaining.shift();
29
- if (err) throw err;
30
- return [];
31
- },
32
- begin: () => {
33
- throw new Error("not used in test");
34
- },
35
- };
36
- return client;
37
- }
38
-
39
- describe("user db/queries — closed-connection retry (#2323)", () => {
40
- test("backfillUserStreamTenants retries the candidate scan once and completes with no candidates", async () => {
41
- const db = fakeClient([closedConnectionError()]);
42
- const result = await backfillUserStreamTenants(db as never);
43
- expect(result).toEqual({ aggregatesMigrated: 0, eventsMigrated: 0, failed: [] });
44
- expect(db.calls).toBe(2);
45
- });
46
-
47
- test("gives up after the single retry when the connection stays closed", async () => {
48
- const db = fakeClient([closedConnectionError(), closedConnectionError()]);
49
- await expect(backfillUserStreamTenants(db as never)).rejects.toThrow("connection was closed");
50
- expect(db.calls).toBe(2);
51
- });
52
- });