@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.
- package/package.json +9 -9
- package/src/__tests__/extension-points-typed.test.ts +135 -0
- package/src/__tests__/use-extension-options.test.ts +278 -0
- package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +116 -0
- package/src/auth-email-password/changes.json +7 -0
- package/src/auth-email-password/i18n.ts +7 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +24 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +124 -2
- package/src/auth-email-password/web/__tests__/session-bootstrap-error.test.ts +32 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +1 -0
- package/src/auth-email-password/web/auth-client.ts +34 -2
- package/src/auth-email-password/web/auth-gate.tsx +5 -1
- package/src/auth-email-password/web/index.ts +4 -1
- package/src/auth-email-password/web/session-bootstrap-error.tsx +56 -0
- package/src/auth-email-password/web/session.tsx +32 -3
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +1 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +2 -0
- package/src/auth-foundation/types.ts +14 -4
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +4 -63
- package/src/billing-foundation/__tests__/tenant-destroy.integration.test.ts +199 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/feature.ts +8 -1
- package/src/billing-foundation/tenant-destroy-hook.ts +20 -20
- package/src/billing-foundation/types.ts +13 -1
- package/src/channel-email/feature.ts +2 -1
- package/src/channel-in-app/feature.ts +2 -1
- package/src/channel-push/feature.ts +2 -1
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +14 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +11 -3
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +7 -3
- package/src/config/__tests__/require-system-db.test.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +4 -8
- package/src/custom-fields/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +25 -24
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +20 -2
- package/src/delivery/__tests__/delivery.integration.test.ts +140 -3
- package/src/delivery/constants.ts +4 -0
- package/src/delivery/delivery-service.ts +45 -29
- package/src/delivery/feature.ts +2 -1
- package/src/delivery/index.ts +14 -11
- package/src/delivery/types.ts +24 -0
- package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +0 -27
- package/src/document-ingest-foundation/__tests__/tenant-destroy.integration.test.ts +166 -0
- package/src/document-ingest-foundation/changes.json +5 -0
- package/src/document-ingest-foundation/tenant-destroy-hook.ts +6 -17
- package/src/file-derivatives/handlers/public-variant.query.ts +7 -0
- package/src/files/__tests__/files.integration.test.ts +2 -2
- package/src/form-draft/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +24 -21
- package/src/inbound-mail-foundation/__tests__/tenant-destroy.integration.test.ts +173 -0
- package/src/inbound-mail-foundation/changes.json +6 -0
- package/src/inbound-mail-foundation/feature.ts +4 -0
- package/src/inbound-mail-foundation/tenant-destroy-hook.ts +15 -20
- package/src/inbound-mail-foundation/types.ts +10 -3
- package/src/jobs/__tests__/job-system-user.integration.test.ts +3 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +15 -6
- package/src/mail-foundation/feature.ts +15 -4
- package/src/mail-foundation/index.ts +1 -0
- package/src/mail-transport-inmemory/feature.ts +5 -4
- package/src/mail-transport-smtp/feature.ts +5 -4
- package/src/renderer-foundation/__tests__/collect-plugins.integration.test.ts +24 -2
- package/src/renderer-foundation/changes.json +8 -1
- package/src/renderer-foundation/constants.ts +4 -0
- package/src/renderer-foundation/feature.ts +10 -13
- package/src/renderer-foundation/index.ts +3 -0
- package/src/renderer-foundation/types.ts +23 -1
- package/src/renderer-simple/feature.ts +2 -1
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +2 -2
- package/src/tenant-lifecycle/__tests__/stages.test.ts +51 -0
- package/src/tenant-lifecycle/stages.ts +20 -18
- package/src/user/db/queries/__tests__/unsafe-read-retrying.integration.test.ts +54 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +3 -0
- package/src/user-data-rights/lib/default-mailers.ts +2 -1
- package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +6 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +6 -2
- 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
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
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<
|
|
16
|
+
function createTestPluginFeature(name: string, kinds: ReadonlyArray<RenderKind>) {
|
|
16
17
|
return defineFeature(`renderer-${name}`, (r) => {
|
|
17
18
|
r.requires("renderer-foundation");
|
|
18
|
-
r.useExtension(
|
|
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
|
|
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(
|
|
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(
|
|
43
|
+
const usages = registry.getExtensionUsages(RENDERER_EXTENSION);
|
|
43
44
|
return usages.map((usage) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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,
|
|
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,
|
|
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
|
+
});
|
|
@@ -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(
|
|
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:
|
|
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:
|
|
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
|
-
});
|