@cosmicdrift/kumiko-bundled-features 0.16.0 → 0.18.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 +1 -1
- package/src/billing-foundation/get-subscription-for-tenant.ts +2 -2
- package/src/cap-counter/__tests__/{cap-counter.integration.ts → cap-counter.integration.test.ts} +14 -3
- package/src/cap-counter/__tests__/enforce-cap.test.ts +8 -4
- package/src/cap-counter/__tests__/{with-cap-enforcement.integration.ts → with-cap-enforcement.integration.test.ts} +14 -3
- package/src/cap-counter/enforce-cap.ts +2 -4
- package/src/cap-counter/handlers/get-counter.query.ts +1 -3
- package/src/cap-counter/handlers/increment.write.ts +1 -2
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +1 -2
- package/src/channel-in-app/in-app-channel.ts +1 -3
- package/src/custom-fields/__tests__/cross-tenant-field-delete.integration.test.ts +177 -0
- package/src/custom-fields/__tests__/{custom-fields.integration.ts → custom-fields.integration.test.ts} +105 -0
- package/src/custom-fields/db/queries/projection.ts +33 -4
- package/src/custom-fields/db/queries/retention.ts +2 -2
- package/src/custom-fields/db/queries/user-data-rights.ts +6 -3
- package/src/custom-fields/feature.ts +10 -4
- package/src/custom-fields/handlers/delete-system-field.write.ts +5 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -1
- package/src/custom-fields/handlers/set-custom-field.write.ts +33 -17
- package/src/custom-fields/lib/field-access.ts +39 -14
- package/src/custom-fields/lib/value-schema.ts +45 -0
- package/src/custom-fields/run-retention.ts +1 -1
- package/src/custom-fields/wire-for-entity.ts +22 -4
- package/src/custom-fields/wire-user-data-rights.ts +3 -2
- package/src/delivery/delivery-service.ts +1 -1
- package/src/delivery/types.ts +2 -2
- package/src/feature-toggles/__tests__/{feature-toggles.integration.ts → feature-toggles.integration.test.ts} +6 -6
- package/src/feature-toggles/handlers/set.write.ts +10 -8
- package/src/subscription-stripe/__tests__/{stripe-foundation.integration.ts → stripe-foundation.integration.test.ts} +7 -10
- package/src/tier-engine/__tests__/{resolver.integration.ts → resolver.integration.test.ts} +4 -3
- package/src/user-data-rights/__tests__/{audit-log.integration.ts → audit-log.integration.test.ts} +12 -5
- package/src/user-data-rights/__tests__/{cross-data-matrix.integration.ts → cross-data-matrix.integration.test.ts} +29 -12
- package/src/user-data-rights/__tests__/{download.integration.ts → download.integration.test.ts} +15 -7
- package/src/user-data-rights/__tests__/{export-job-idempotency.integration.ts → export-job-idempotency.integration.test.ts} +13 -11
- package/src/user-data-rights/__tests__/{request-cancel-deletion.integration.ts → request-cancel-deletion.integration.test.ts} +8 -7
- package/src/user-data-rights/__tests__/{request-deletion-callback.integration.ts → request-deletion-callback.integration.test.ts} +8 -5
- package/src/user-data-rights/__tests__/{request-export.integration.ts → request-export.integration.test.ts} +6 -3
- package/src/user-data-rights/__tests__/{restriction-flow.integration.ts → restriction-flow.integration.test.ts} +11 -8
- package/src/user-data-rights/__tests__/{run-export-jobs.integration.ts → run-export-jobs.integration.test.ts} +25 -13
- package/src/user-data-rights/__tests__/{run-forget-cleanup.integration.ts → run-forget-cleanup.integration.test.ts} +6 -3
- package/src/user-data-rights/__tests__/{run-user-export.integration.ts → run-user-export.integration.test.ts} +6 -3
- package/src/user-data-rights/__tests__/{user-data-rights.integration.ts → user-data-rights.integration.test.ts} +3 -1
- package/src/user-data-rights/db/queries/export-jobs.ts +6 -5
- package/src/user-data-rights/db/queries/forget-cleanup.ts +11 -6
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +5 -10
- package/src/user-data-rights/handlers/export-status.query.ts +12 -12
- package/src/user-data-rights/run-export-jobs.ts +2 -5
- package/src/user-data-rights/run-forget-cleanup.ts +0 -1
- package/src/user-data-rights-defaults/__tests__/{user-data-rights-defaults.integration.ts → user-data-rights-defaults.integration.test.ts} +2 -0
- /package/src/__tests__/{es-ops-e2e.integration.ts → es-ops-e2e.integration.test.ts} +0 -0
- /package/src/audit/__tests__/{audit.integration.ts → audit.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{account-lockout-no-redis.integration.ts → account-lockout-no-redis.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{account-lockout.integration.ts → account-lockout.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{auth-claims.integration.ts → auth-claims.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{auth.integration.ts → auth.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{email-verification.integration.ts → email-verification.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{identity-v3-login.integration.ts → identity-v3-login.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{invite-flow.integration.ts → invite-flow.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{multi-roles.integration.ts → multi-roles.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{password-reset.integration.ts → password-reset.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{public-routes-rate-limit.integration.ts → public-routes-rate-limit.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{seed-admin.integration.ts → seed-admin.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{session-callbacks.integration.ts → session-callbacks.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{session-strict-mode.integration.ts → session-strict-mode.integration.test.ts} +0 -0
- /package/src/auth-email-password/__tests__/{signup-flow.integration.ts → signup-flow.integration.test.ts} +0 -0
- /package/src/billing-foundation/__tests__/{billing-foundation.integration.ts → billing-foundation.integration.test.ts} +0 -0
- /package/src/compliance-profiles/__tests__/{compliance-profiles.integration.ts → compliance-profiles.integration.test.ts} +0 -0
- /package/src/compliance-profiles/__tests__/{seeding.integration.ts → seeding.integration.test.ts} +0 -0
- /package/src/config/__tests__/{cascade.integration.ts → cascade.integration.test.ts} +0 -0
- /package/src/config/__tests__/{config.integration.ts → config.integration.test.ts} +0 -0
- /package/src/custom-fields/__tests__/{audit-integration.integration.ts → audit-integration.integration.test.ts} +0 -0
- /package/src/custom-fields/__tests__/{field-access.integration.ts → field-access.integration.test.ts} +0 -0
- /package/src/custom-fields/__tests__/{quota.integration.ts → quota.integration.test.ts} +0 -0
- /package/src/custom-fields/__tests__/{retention.integration.ts → retention.integration.test.ts} +0 -0
- /package/src/custom-fields/__tests__/{user-data-rights.integration.ts → user-data-rights.integration.test.ts} +0 -0
- /package/src/data-retention/__tests__/{data-retention.integration.ts → data-retention.integration.test.ts} +0 -0
- /package/src/data-retention/__tests__/{policy-for.integration.ts → policy-for.integration.test.ts} +0 -0
- /package/src/delivery/__tests__/{delivery-events.integration.ts → delivery-events.integration.test.ts} +0 -0
- /package/src/delivery/__tests__/{delivery.integration.ts → delivery.integration.test.ts} +0 -0
- /package/src/file-foundation/__tests__/{file-foundation.integration.ts → file-foundation.integration.test.ts} +0 -0
- /package/src/files/__tests__/{files.integration.ts → files.integration.test.ts} +0 -0
- /package/src/files-provider-s3/__tests__/{s3-provider.integration.ts → s3-provider.integration.test.ts} +0 -0
- /package/src/jobs/__tests__/{job-system-user.integration.ts → job-system-user.integration.test.ts} +0 -0
- /package/src/jobs/__tests__/{jobs-events.integration.ts → jobs-events.integration.test.ts} +0 -0
- /package/src/jobs/__tests__/{jobs-feature.integration.ts → jobs-feature.integration.test.ts} +0 -0
- /package/src/legal-pages/__tests__/{legal-pages.integration.ts → legal-pages.integration.test.ts} +0 -0
- /package/src/mail-foundation/__tests__/{mail-foundation.integration.ts → mail-foundation.integration.test.ts} +0 -0
- /package/src/rate-limiting/__tests__/{rate-limiting.integration.ts → rate-limiting.integration.test.ts} +0 -0
- /package/src/renderer-foundation/__tests__/{collect-plugins.integration.ts → collect-plugins.integration.test.ts} +0 -0
- /package/src/secrets/__tests__/{rotate.integration.ts → rotate.integration.test.ts} +0 -0
- /package/src/secrets/__tests__/{secrets-events.integration.ts → secrets-events.integration.test.ts} +0 -0
- /package/src/secrets/__tests__/{secrets.integration.ts → secrets.integration.test.ts} +0 -0
- /package/src/sessions/__tests__/{cleanup.integration.ts → cleanup.integration.test.ts} +0 -0
- /package/src/sessions/__tests__/{password-auto-revoke.integration.ts → password-auto-revoke.integration.test.ts} +0 -0
- /package/src/sessions/__tests__/{sessions.integration.ts → sessions.integration.test.ts} +0 -0
- /package/src/subscription-mollie/__tests__/{mollie-foundation.integration.ts → mollie-foundation.integration.test.ts} +0 -0
- /package/src/template-resolver/__tests__/{handlers.integration.ts → handlers.integration.test.ts} +0 -0
- /package/src/template-resolver/__tests__/{template-resolver.integration.ts → template-resolver.integration.test.ts} +0 -0
- /package/src/tenant/__tests__/{multi-tenant.integration.ts → multi-tenant.integration.test.ts} +0 -0
- /package/src/tenant/__tests__/{seed-testing.integration.ts → seed-testing.integration.test.ts} +0 -0
- /package/src/tenant/__tests__/{tenant.integration.ts → tenant.integration.test.ts} +0 -0
- /package/src/text-content/__tests__/{text-content.integration.ts → text-content.integration.test.ts} +0 -0
- /package/src/tier-engine/__tests__/{auto-default-tier.integration.ts → auto-default-tier.integration.test.ts} +0 -0
- /package/src/tier-engine/__tests__/{tier-engine.integration.ts → tier-engine.integration.test.ts} +0 -0
- /package/src/user/__tests__/{seed-testing.integration.ts → seed-testing.integration.test.ts} +0 -0
- /package/src/user/__tests__/{user.integration.ts → user.integration.test.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Resolver-helper: liest die current subscription-row für einen Tenant
|
|
2
2
|
// aus der read_subscriptions-projection.
|
|
3
3
|
|
|
4
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
5
4
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
5
|
import { subscriptionAggregateId } from "./aggregate-id";
|
|
7
6
|
import { subscriptionsProjectionTable } from "./projection";
|
|
@@ -22,7 +21,8 @@ export async function getSubscriptionForTenant(
|
|
|
22
21
|
tenantId: string,
|
|
23
22
|
): Promise<SubscriptionView | null> {
|
|
24
23
|
const aggId = subscriptionAggregateId(tenantId);
|
|
25
|
-
const
|
|
24
|
+
const rows = await ctx.db.selectMany(subscriptionsProjectionTable, { id: aggId }, { limit: 1 });
|
|
25
|
+
const row = rows[0];
|
|
26
26
|
if (!row) return null;
|
|
27
27
|
// @cast-boundary db-row — drizzle-row carries column-as-unknown
|
|
28
28
|
return {
|
package/src/cap-counter/__tests__/{cap-counter.integration.ts → cap-counter.integration.test.ts}
RENAMED
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
// assert. Mirrors the mail-foundation / file-foundation integration
|
|
7
7
|
// test pattern.
|
|
8
8
|
|
|
9
|
-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
10
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
createEntityExecutor,
|
|
13
|
+
defineFeature,
|
|
14
|
+
type WriteHandlerDef,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
13
17
|
import {
|
|
14
18
|
createTestUser,
|
|
15
19
|
setupTestStack,
|
|
@@ -18,6 +22,7 @@ import {
|
|
|
18
22
|
testTenantId,
|
|
19
23
|
unsafeCreateEntityTable,
|
|
20
24
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
21
26
|
import { z } from "zod";
|
|
22
27
|
import { CapCounterHandlers, CapCounterQueries } from "../constants";
|
|
23
28
|
import {
|
|
@@ -192,6 +197,12 @@ afterAll(async () => {
|
|
|
192
197
|
// which month — just need a stable iso-string so `increment` +
|
|
193
198
|
// `readCounter` hit the same aggregate.
|
|
194
199
|
const PERIOD = "2026-05-01T00:00:00Z";
|
|
200
|
+
const { table: capCounterTable } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
201
|
+
|
|
202
|
+
beforeEach(async () => {
|
|
203
|
+
await resetTestTables(db, [capCounterTable, eventsTable]);
|
|
204
|
+
});
|
|
205
|
+
|
|
195
206
|
const sysadmin = TestUsers.systemAdmin;
|
|
196
207
|
|
|
197
208
|
function adminFor(tenantNumber: number) {
|
|
@@ -17,15 +17,19 @@ import {
|
|
|
17
17
|
enforceRollingCapAndMaybeNotify,
|
|
18
18
|
} from "../enforce-cap";
|
|
19
19
|
|
|
20
|
-
// Test-mock: ctx.db
|
|
21
|
-
//
|
|
22
|
-
// direkt). Beide pfade returnen denselben rows-set unabhängig von filtern.
|
|
20
|
+
// Test-mock: ctx.db exposes TenantDb-style selectMany (production path)
|
|
21
|
+
// plus legacy drizzle .select().from().where() chain stubs.
|
|
23
22
|
|
|
24
23
|
function makeMockDb(rows: unknown[]) {
|
|
24
|
+
const selectMany = async (_table: unknown, _where?: unknown, options?: { limit?: number }) => {
|
|
25
|
+
const slice = options?.limit !== undefined ? rows.slice(0, options.limit) : rows;
|
|
26
|
+
return slice;
|
|
27
|
+
};
|
|
25
28
|
return {
|
|
26
29
|
unsafe: async () => rows,
|
|
30
|
+
selectMany,
|
|
27
31
|
begin: async <T>(fn: (tx: unknown) => Promise<T>) =>
|
|
28
|
-
fn({ unsafe: async () => rows, begin: async () => undefined }),
|
|
32
|
+
fn({ unsafe: async () => rows, selectMany, begin: async () => undefined }),
|
|
29
33
|
select: () => ({
|
|
30
34
|
from: () => ({
|
|
31
35
|
where: Object.assign(async () => rows, {
|
|
@@ -8,10 +8,14 @@
|
|
|
8
8
|
// 5. Failed handler: counter NICHT inkrementiert (cap-quota nicht
|
|
9
9
|
// verbrannt für gescheiterte writes)
|
|
10
10
|
|
|
11
|
-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
12
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
-
import {
|
|
14
|
-
|
|
13
|
+
import {
|
|
14
|
+
createEntityExecutor,
|
|
15
|
+
defineFeature,
|
|
16
|
+
type WriteHandlerDef,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
19
|
import {
|
|
16
20
|
createTestUser,
|
|
17
21
|
setupTestStack,
|
|
@@ -19,6 +23,7 @@ import {
|
|
|
19
23
|
testTenantId,
|
|
20
24
|
unsafeCreateEntityTable,
|
|
21
25
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
27
|
import { z } from "zod";
|
|
23
28
|
import { CapCounterQueries } from "../constants";
|
|
24
29
|
import type { SoftHitNotifier } from "../enforce-cap";
|
|
@@ -59,6 +64,8 @@ const innerSendHandler: WriteHandlerDef = {
|
|
|
59
64
|
|
|
60
65
|
const PERIOD = "2026-07-01T00:00:00Z";
|
|
61
66
|
|
|
67
|
+
const { table: capCounterTable } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
68
|
+
|
|
62
69
|
const wrappedCalendar = withCapEnforcement(innerSendHandler, () => ({
|
|
63
70
|
capName: "newsletter-cap",
|
|
64
71
|
periodStartIso: PERIOD,
|
|
@@ -103,6 +110,10 @@ afterAll(async () => {
|
|
|
103
110
|
await stack.cleanup();
|
|
104
111
|
});
|
|
105
112
|
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
await resetTestTables(db, [capCounterTable, eventsTable]);
|
|
115
|
+
});
|
|
116
|
+
|
|
106
117
|
function adminFor(tenantNumber: number) {
|
|
107
118
|
return createTestUser({
|
|
108
119
|
id: tenantNumber,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { createEntityExecutor, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
3
|
import { rollingCapAggregateId } from "./aggregate-id";
|
|
@@ -109,8 +108,7 @@ export async function enforceCap(
|
|
|
109
108
|
const softThreshold = options.limit * tolerance.soft;
|
|
110
109
|
const hardThreshold = options.limit * tolerance.hard;
|
|
111
110
|
|
|
112
|
-
const rows = await selectMany(
|
|
113
|
-
ctx.db,
|
|
111
|
+
const rows = await ctx.db.selectMany(
|
|
114
112
|
table,
|
|
115
113
|
{ capName: options.capName, periodStart: options.periodStartIso },
|
|
116
114
|
{ limit: 1 },
|
|
@@ -188,7 +186,7 @@ export async function enforceRollingCap(
|
|
|
188
186
|
// covers the prefix; the additional aggregate_id eq narrows to the
|
|
189
187
|
// single rolling-stream. Postgres can use the index even with the
|
|
190
188
|
// aggregate_id filter applied as a residual.
|
|
191
|
-
const rows = await selectMany<{ payload: { amount?: number } }>(
|
|
189
|
+
const rows = await ctx.db.selectMany<{ payload: { amount?: number } }>(eventsTable, {
|
|
192
190
|
tenantId: ctx.user.tenantId,
|
|
193
191
|
aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
|
|
194
192
|
aggregateId,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { createEntityExecutor, type QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import { capCounterEntity } from "../entity";
|
|
@@ -25,8 +24,7 @@ export const getCounterQuery: QueryHandlerDef = {
|
|
|
25
24
|
const { capName, periodStartIso } = query.payload as z.infer<typeof getCounterSchema>; // @cast-boundary engine-payload
|
|
26
25
|
|
|
27
26
|
// ctx.db is tenant-scoped; filter by capName + periodStart explicitly.
|
|
28
|
-
const rows = await selectMany(
|
|
29
|
-
ctx.db,
|
|
27
|
+
const rows = await ctx.db.selectMany(
|
|
30
28
|
table,
|
|
31
29
|
{ capName, periodStart: periodStartIso },
|
|
32
30
|
{ limit: 1 },
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { Temporal } from "temporal-polyfill";
|
|
4
3
|
import { z } from "zod";
|
|
@@ -55,7 +54,7 @@ export const incrementCapHandler: WriteHandlerDef = {
|
|
|
55
54
|
|
|
56
55
|
// Read existing aggregate's projection-row to decide create vs update.
|
|
57
56
|
// ctx.db is auto-tenant-scoped — id-lookup is unique per tenant.
|
|
58
|
-
const existing = await
|
|
57
|
+
const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
|
|
59
58
|
|
|
60
59
|
if (existing.length === 0) {
|
|
61
60
|
return executor.create(
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { Temporal } from "temporal-polyfill";
|
|
4
3
|
import { z } from "zod";
|
|
@@ -32,7 +31,7 @@ export const markSoftWarnedHandler: WriteHandlerDef = {
|
|
|
32
31
|
payload.periodStartIso,
|
|
33
32
|
);
|
|
34
33
|
|
|
35
|
-
const existing = await
|
|
34
|
+
const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
|
|
36
35
|
if (existing.length === 0) {
|
|
37
36
|
throw new Error(
|
|
38
37
|
`cap-counter: cannot mark-soft-warned, no counter found for tenant=${event.user.tenantId} cap=${payload.capName} period=${payload.periodStartIso}`,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { tenantChannel } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import type { DeliveryChannel } from "../delivery";
|
|
4
3
|
import { inAppMessagesTable } from "./tables";
|
|
@@ -15,8 +14,7 @@ export const inAppChannel: DeliveryChannel = {
|
|
|
15
14
|
// address is the user-id string after the ES migration — keep it as-is.
|
|
16
15
|
const userId = address;
|
|
17
16
|
|
|
18
|
-
const row = await insertOne<{ id: string }>(
|
|
19
|
-
tenantId: ctx.tenantId,
|
|
17
|
+
const row = await ctx.db.insertOne<{ id: string }>(inAppMessagesTable, {
|
|
20
18
|
userId,
|
|
21
19
|
notificationType: message.notificationType,
|
|
22
20
|
title: message.title,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Regression: deleting a tenant-scoped custom-field definition must only clear
|
|
2
|
+
// that tenant's rows — NOT every tenant's row that happens to use the same
|
|
3
|
+
// kebab fieldKey.
|
|
4
|
+
//
|
|
5
|
+
// Bug: the fieldDefinition.deleted MSP-handler stripped the jsonb key from
|
|
6
|
+
// every row of the host table (no tenant filter). Two tenants that each define
|
|
7
|
+
// their own field with the same key (e.g. "priority") share the jsonb key on
|
|
8
|
+
// the host-entity table; one tenant deleting their definition wiped the other
|
|
9
|
+
// tenant's values. The fix scopes cleanup by the deleted definition's owning
|
|
10
|
+
// tenant (system-scope still cascades to all).
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import {
|
|
16
|
+
createEntity,
|
|
17
|
+
createEntityExecutor,
|
|
18
|
+
createTextField,
|
|
19
|
+
defineEntityListHandler,
|
|
20
|
+
defineFeature,
|
|
21
|
+
type SessionUser,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
24
|
+
import {
|
|
25
|
+
createTestUser,
|
|
26
|
+
setupTestStack,
|
|
27
|
+
type TestStack,
|
|
28
|
+
testTenantId,
|
|
29
|
+
testUserId,
|
|
30
|
+
unsafeCreateEntityTable,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
import { fieldDefinitionEntity } from "../entity";
|
|
34
|
+
import { createCustomFieldsFeature } from "../feature";
|
|
35
|
+
import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
36
|
+
|
|
37
|
+
const propertyEntity = createEntity({
|
|
38
|
+
table: "read_xt_properties",
|
|
39
|
+
fields: {
|
|
40
|
+
name: createTextField({ required: true }),
|
|
41
|
+
customFields: customFieldsField(),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const propertyTable = buildEntityTable("property", propertyEntity);
|
|
45
|
+
|
|
46
|
+
const propertyFeature = defineFeature("property-xt", (r) => {
|
|
47
|
+
r.entity("property", propertyEntity);
|
|
48
|
+
r.requires("custom-fields");
|
|
49
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
50
|
+
|
|
51
|
+
const { executor: propertyExecutor } = createEntityExecutor("property", propertyEntity);
|
|
52
|
+
r.writeHandler({
|
|
53
|
+
name: "property:create",
|
|
54
|
+
schema: z.object({ id: z.string(), name: z.string() }),
|
|
55
|
+
access: { roles: ["TenantAdmin"] },
|
|
56
|
+
handler: async (event, ctx) => {
|
|
57
|
+
const payload = event.payload as { id: string; name: string };
|
|
58
|
+
return propertyExecutor.create(
|
|
59
|
+
{ id: payload.id, name: payload.name, customFields: {} },
|
|
60
|
+
event.user,
|
|
61
|
+
ctx.db,
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
r.queryHandler(
|
|
67
|
+
defineEntityListHandler("property", propertyEntity, { access: { roles: ["TenantAdmin"] } }),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const customFieldsFeature = createCustomFieldsFeature();
|
|
72
|
+
|
|
73
|
+
let stack: TestStack;
|
|
74
|
+
|
|
75
|
+
const adminA = createTestUser({
|
|
76
|
+
id: testUserId(1),
|
|
77
|
+
tenantId: testTenantId(1),
|
|
78
|
+
roles: ["TenantAdmin"],
|
|
79
|
+
});
|
|
80
|
+
const adminB = createTestUser({
|
|
81
|
+
id: testUserId(10),
|
|
82
|
+
tenantId: testTenantId(2),
|
|
83
|
+
roles: ["TenantAdmin"],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const PROP_A = "aaaaaaaa-aaaa-4000-8000-000000000001";
|
|
87
|
+
const PROP_B = "bbbbbbbb-bbbb-4000-8000-000000000002";
|
|
88
|
+
|
|
89
|
+
beforeAll(async () => {
|
|
90
|
+
stack = await setupTestStack({ features: [customFieldsFeature, propertyFeature] });
|
|
91
|
+
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
92
|
+
await unsafeCreateEntityTable(stack.db, propertyEntity);
|
|
93
|
+
await createEventsTable(stack.db);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterAll(async () => {
|
|
97
|
+
await stack.cleanup();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
beforeEach(async () => {
|
|
101
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM kumiko_events`);
|
|
102
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_xt_properties`);
|
|
103
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
async function defineField(user: SessionUser, fieldKey: string) {
|
|
107
|
+
return stack.http.writeOk(
|
|
108
|
+
"custom-fields:write:define-tenant-field",
|
|
109
|
+
{
|
|
110
|
+
entityName: "property",
|
|
111
|
+
fieldKey,
|
|
112
|
+
serializedField: { type: "text" },
|
|
113
|
+
required: false,
|
|
114
|
+
searchable: false,
|
|
115
|
+
displayOrder: 0,
|
|
116
|
+
},
|
|
117
|
+
user,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function setCustomField(
|
|
122
|
+
user: SessionUser,
|
|
123
|
+
entityId: string,
|
|
124
|
+
fieldKey: string,
|
|
125
|
+
value: unknown,
|
|
126
|
+
) {
|
|
127
|
+
return stack.http.writeOk(
|
|
128
|
+
"custom-fields:write:set-custom-field",
|
|
129
|
+
{ entityName: "property", entityId, fieldKey, value },
|
|
130
|
+
user,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function deleteField(user: SessionUser, fieldKey: string) {
|
|
135
|
+
return stack.http.writeOk(
|
|
136
|
+
"custom-fields:write:delete-tenant-field",
|
|
137
|
+
{ entityName: "property", fieldKey },
|
|
138
|
+
user,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function createProperty(user: SessionUser, id: string, name: string) {
|
|
143
|
+
return stack.http.writeOk("property-xt:write:property:create", { id, name }, user);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function priorityOf(user: SessionUser, id: string): Promise<unknown> {
|
|
147
|
+
const { rows } = (await stack.http.queryOk("property-xt:query:property:list", {}, user)) as {
|
|
148
|
+
rows: Array<Record<string, unknown>>;
|
|
149
|
+
};
|
|
150
|
+
return rows.find((r) => r["id"] === id)?.["priority"];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe("custom-fields cross-tenant isolation — fieldDefinition delete", () => {
|
|
154
|
+
test("deleting tenant A's field with a shared kebab key must NOT wipe tenant B's values", async () => {
|
|
155
|
+
// Both tenants independently define their own "priority" field on property.
|
|
156
|
+
await defineField(adminA, "priority");
|
|
157
|
+
await defineField(adminB, "priority");
|
|
158
|
+
|
|
159
|
+
await createProperty(adminA, PROP_A, "A-Prop");
|
|
160
|
+
await createProperty(adminB, PROP_B, "B-Prop");
|
|
161
|
+
|
|
162
|
+
await setCustomField(adminA, PROP_A, "priority", "A-value");
|
|
163
|
+
await setCustomField(adminB, PROP_B, "priority", "B-value");
|
|
164
|
+
await stack.eventDispatcher?.runOnce();
|
|
165
|
+
|
|
166
|
+
expect(await priorityOf(adminA, PROP_A)).toBe("A-value");
|
|
167
|
+
expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
|
|
168
|
+
|
|
169
|
+
// Tenant A deletes their "priority" field definition.
|
|
170
|
+
await deleteField(adminA, "priority");
|
|
171
|
+
await stack.eventDispatcher?.runOnce();
|
|
172
|
+
|
|
173
|
+
// A's value is correctly gone; B's value MUST survive (tenant-scoped cleanup).
|
|
174
|
+
expect(await priorityOf(adminA, PROP_A)).toBeUndefined();
|
|
175
|
+
expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -259,3 +259,108 @@ describe("custom-fields integration — Last-Wins on concurrent set", () => {
|
|
|
259
259
|
expect(p6?.["status"]).toBe("published");
|
|
260
260
|
});
|
|
261
261
|
});
|
|
262
|
+
|
|
263
|
+
describe("custom-fields integration — value validation (Builder-Reuse)", () => {
|
|
264
|
+
async function setErr(entityId: string, fieldKey: string, value: unknown) {
|
|
265
|
+
return stack.http.writeErr(
|
|
266
|
+
"custom-fields:write:set-custom-field",
|
|
267
|
+
{ entityName: "property", entityId, fieldKey, value },
|
|
268
|
+
admin,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function countSetEvents(entityId: string): Promise<number> {
|
|
273
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
274
|
+
"SELECT count(*)::int AS n FROM kumiko_events WHERE aggregate_id = $1 AND type = $2",
|
|
275
|
+
[entityId, "custom-fields:event:custom-field-set"],
|
|
276
|
+
);
|
|
277
|
+
return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function rawCustomFields(entityId: string): Promise<Record<string, unknown>> {
|
|
281
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
282
|
+
"SELECT custom_fields FROM read_t1_properties WHERE id = $1",
|
|
283
|
+
[entityId],
|
|
284
|
+
);
|
|
285
|
+
const cf = (rows as ReadonlyArray<{ custom_fields: unknown }>)[0]?.custom_fields;
|
|
286
|
+
return cf && typeof cf === "object" && !Array.isArray(cf)
|
|
287
|
+
? (cf as Record<string, unknown>)
|
|
288
|
+
: {};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
test("type mismatch → 422, no event emitted, no jsonb key after projection", async () => {
|
|
292
|
+
const id = "77777777-7777-4000-8000-000000000007";
|
|
293
|
+
await defineField("property", "count", "number");
|
|
294
|
+
await createProperty(id, "TypeMismatch");
|
|
295
|
+
|
|
296
|
+
const err = await setErr(id, "count", "not-a-number");
|
|
297
|
+
expect(err.httpStatus).toBe(422);
|
|
298
|
+
expect(err.code).toBe("unprocessable");
|
|
299
|
+
expect(err.details).toMatchObject({ reason: "custom_field_value_invalid" });
|
|
300
|
+
|
|
301
|
+
// Plan-Promise: kein Event entsteht — Projection bleibt typed.
|
|
302
|
+
expect(await countSetEvents(id)).toBe(0);
|
|
303
|
+
|
|
304
|
+
await stack.eventDispatcher?.runOnce();
|
|
305
|
+
expect(await rawCustomFields(id)).not.toHaveProperty("count");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("matching values pass — number/text/boolean land correctly", async () => {
|
|
309
|
+
const id = "88888888-8888-4000-8000-000000000008";
|
|
310
|
+
await defineField("property", "count", "number");
|
|
311
|
+
await defineField("property", "label", "text");
|
|
312
|
+
await defineField("property", "active", "boolean");
|
|
313
|
+
await createProperty(id, "Valid");
|
|
314
|
+
|
|
315
|
+
await setCustomField("property", id, "count", 42);
|
|
316
|
+
await setCustomField("property", id, "label", "ok");
|
|
317
|
+
await setCustomField("property", id, "active", true);
|
|
318
|
+
await stack.eventDispatcher?.runOnce();
|
|
319
|
+
|
|
320
|
+
const row = (await listProperties()).rows.find((r) => r["id"] === id);
|
|
321
|
+
expect(row?.["count"]).toBe(42);
|
|
322
|
+
expect(row?.["label"]).toBe("ok");
|
|
323
|
+
expect(row?.["active"]).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("boolean field rejects a string value → 422, no event", async () => {
|
|
327
|
+
const id = "99999999-9999-4000-8000-000000000009";
|
|
328
|
+
await defineField("property", "flag", "boolean");
|
|
329
|
+
await createProperty(id, "BoolReject");
|
|
330
|
+
|
|
331
|
+
const err = await setErr(id, "flag", "yes");
|
|
332
|
+
expect(err.httpStatus).toBe(422);
|
|
333
|
+
expect(err.code).toBe("unprocessable");
|
|
334
|
+
expect(err.details).toMatchObject({ reason: "custom_field_value_invalid" });
|
|
335
|
+
expect(await countSetEvents(id)).toBe(0);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("embedded field rejects a non-object value → 422, no event", async () => {
|
|
339
|
+
const id = "aaaaaaaa-aaaa-4000-8000-00000000000a";
|
|
340
|
+
// embedded carries a sub-field schema in serializedField — exercises
|
|
341
|
+
// fieldToZod's z.object(...) dispatch (the structurally complex path).
|
|
342
|
+
await stack.http.writeOk(
|
|
343
|
+
"custom-fields:write:define-tenant-field",
|
|
344
|
+
{
|
|
345
|
+
entityName: "property",
|
|
346
|
+
fieldKey: "geo",
|
|
347
|
+
serializedField: { type: "embedded", schema: { city: { type: "text", required: true } } },
|
|
348
|
+
required: false,
|
|
349
|
+
searchable: false,
|
|
350
|
+
displayOrder: 0,
|
|
351
|
+
},
|
|
352
|
+
admin,
|
|
353
|
+
);
|
|
354
|
+
await createProperty(id, "EmbeddedReject");
|
|
355
|
+
|
|
356
|
+
const err = await setErr(id, "geo", "not-an-object");
|
|
357
|
+
expect(err.httpStatus).toBe(422);
|
|
358
|
+
expect(err.details).toMatchObject({ reason: "custom_field_value_invalid" });
|
|
359
|
+
expect(await countSetEvents(id)).toBe(0);
|
|
360
|
+
|
|
361
|
+
// Positive: a matching object passes + lands.
|
|
362
|
+
await setCustomField("property", id, "geo", { city: "Bonn" });
|
|
363
|
+
await stack.eventDispatcher?.runOnce();
|
|
364
|
+
expect(await rawCustomFields(id)).toMatchObject({ geo: { city: "Bonn" } });
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -5,18 +5,27 @@ function quoteTable(tableName: string): string {
|
|
|
5
5
|
return `"${tableName.replace(/"/g, '""')}"`;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
function bindJsonbParam(value: unknown): { sql: string; bound: unknown } {
|
|
9
|
+
// postgres-js infers boolean params as boolean[] candidates — route via text::jsonb.
|
|
10
|
+
if (typeof value === "boolean") {
|
|
11
|
+
return { sql: "$1::text::jsonb", bound: JSON.stringify(value) };
|
|
12
|
+
}
|
|
13
|
+
return { sql: "$1::jsonb", bound: value };
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export async function setCustomFieldValue(
|
|
9
17
|
db: DbRunner,
|
|
10
18
|
tableName: string,
|
|
11
19
|
fieldKey: string,
|
|
12
|
-
|
|
20
|
+
value: unknown,
|
|
13
21
|
aggregateId: string,
|
|
14
22
|
): Promise<void> {
|
|
15
23
|
const tbl = quoteTable(tableName);
|
|
16
24
|
const escapedKey = fieldKey.replace(/'/g, "''");
|
|
25
|
+
const jsonb = bindJsonbParam(value);
|
|
17
26
|
await asRawClient(db).unsafe(
|
|
18
|
-
`UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', $
|
|
19
|
-
[
|
|
27
|
+
`UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', ${jsonb.sql}, true) WHERE id = $2`,
|
|
28
|
+
[jsonb.bound, aggregateId],
|
|
20
29
|
);
|
|
21
30
|
}
|
|
22
31
|
|
|
@@ -33,7 +42,27 @@ export async function clearCustomFieldKey(
|
|
|
33
42
|
);
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
|
|
45
|
+
// Tenant-scoped orphan-cleanup: removes the jsonb key only from the deleting
|
|
46
|
+
// tenant's rows. This is the default path for tenant-field deletions — without
|
|
47
|
+
// the tenant_id filter, deleting tenant A's field strips the same kebab key
|
|
48
|
+
// from every tenant's rows (cross-tenant data loss).
|
|
49
|
+
export async function removeCustomFieldKeyForTenant(
|
|
50
|
+
db: DbRunner,
|
|
51
|
+
tableName: string,
|
|
52
|
+
fieldKey: string,
|
|
53
|
+
tenantId: string,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const tbl = quoteTable(tableName);
|
|
56
|
+
await asRawClient(db).unsafe(
|
|
57
|
+
`UPDATE ${tbl} SET custom_fields = custom_fields - $1 WHERE tenant_id = $2`,
|
|
58
|
+
[fieldKey, tenantId],
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Cross-tenant cleanup: strips the key from EVERY tenant's rows. Only valid for
|
|
63
|
+
// system-scope field-definition deletions (the field applied to all tenants).
|
|
64
|
+
// Never call this for a tenant-scoped deletion — use removeCustomFieldKeyForTenant.
|
|
65
|
+
export async function removeCustomFieldKeyFromAllTenants(
|
|
37
66
|
db: DbRunner,
|
|
38
67
|
tableName: string,
|
|
39
68
|
fieldKey: string,
|
|
@@ -28,12 +28,12 @@ export async function selectHostRowsWithCustomFields(
|
|
|
28
28
|
export async function updateHostRowCustomFields(
|
|
29
29
|
db: DbRunner,
|
|
30
30
|
tableName: string,
|
|
31
|
-
|
|
31
|
+
customFields: Record<string, unknown>,
|
|
32
32
|
rowId: string,
|
|
33
33
|
): Promise<void> {
|
|
34
34
|
const quoted = `"${tableName.replace(/"/g, '""')}"`;
|
|
35
35
|
await asRawClient(db).unsafe(`UPDATE ${quoted} SET custom_fields = $1::jsonb WHERE id = $2`, [
|
|
36
|
-
|
|
36
|
+
customFields,
|
|
37
37
|
rowId,
|
|
38
38
|
]);
|
|
39
39
|
}
|
|
@@ -35,10 +35,13 @@ export async function stripSensitiveCustomFieldKeys(
|
|
|
35
35
|
): Promise<void> {
|
|
36
36
|
const tbl = quoteTable(tableName);
|
|
37
37
|
const userCol = quoteColumn(userIdColumn);
|
|
38
|
-
const placeholders = sensitiveKeys.map((_, i) => `$${i + 1}`).join(" - ");
|
|
39
38
|
await asRawClient(db).unsafe(
|
|
40
|
-
`UPDATE ${tbl} SET custom_fields =
|
|
41
|
-
|
|
39
|
+
`UPDATE ${tbl} SET custom_fields = CASE
|
|
40
|
+
WHEN jsonb_typeof(custom_fields) = 'object' THEN custom_fields - $1::text[]
|
|
41
|
+
ELSE custom_fields
|
|
42
|
+
END
|
|
43
|
+
WHERE ${userCol} = $2 AND tenant_id = $3`,
|
|
44
|
+
[sensitiveKeys, userId, tenantId],
|
|
42
45
|
);
|
|
43
46
|
}
|
|
44
47
|
|
|
@@ -30,12 +30,12 @@
|
|
|
30
30
|
// hand-gebauten Template-Literals mehr (T1 hat den toKebab-collapse-drift
|
|
31
31
|
// aufgedeckt, siehe Memory feedback_event_def_exports_pattern).
|
|
32
32
|
//
|
|
33
|
-
// **
|
|
33
|
+
// **Noch offen (future iterations)**:
|
|
34
34
|
// - Cross-scope-conflict-Detection (Tenant überschreibt system fieldKey)
|
|
35
|
-
// - cap-counter quota-Check beim fieldDefinition-create
|
|
36
|
-
// - user-data-rights anonymization-Wiring für sensitive customFields
|
|
37
|
-
// - Value-Validation gegen fieldDefinition.serializedField
|
|
38
35
|
// - Cross-Scope-Read-UNION (system + tenant fieldDefinitions in einem List)
|
|
36
|
+
// (cap-counter-quota → T1.5e, user-data-rights-anonymization → T1.5c,
|
|
37
|
+
// Value-Validation gegen serializedField → set-custom-field via fieldToZod —
|
|
38
|
+
// alle erledigt.)
|
|
39
39
|
|
|
40
40
|
import { defineEntityListHandler, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
41
41
|
import { z } from "zod";
|
|
@@ -63,6 +63,12 @@ const tenantAdminAccess = { access: { roles: ["TenantAdmin"] } } as const;
|
|
|
63
63
|
const fieldDefinitionDeletedSchema = z.object({
|
|
64
64
|
entityName: z.string(),
|
|
65
65
|
fieldKey: z.string(),
|
|
66
|
+
// Owning tenant of the deleted definition: a specific tenant for tenant-scope
|
|
67
|
+
// deletions, SYSTEM_TENANT_ID for system-scope. The cascade-MSP scopes its
|
|
68
|
+
// orphan-cleanup by this so a tenant deletion never touches other tenants'
|
|
69
|
+
// rows. Optional for backward-compat with events appended before this field
|
|
70
|
+
// existed — the MSP falls back to the event's stream tenantId.
|
|
71
|
+
tenantId: z.string().optional(),
|
|
66
72
|
});
|
|
67
73
|
|
|
68
74
|
// Singleton feature-definition mit typed exports. Handler + wire-for-entity
|
|
@@ -38,7 +38,11 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
|
|
|
38
38
|
aggregateId,
|
|
39
39
|
aggregateType: "field-definition",
|
|
40
40
|
type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
|
|
41
|
-
payload: {
|
|
41
|
+
payload: {
|
|
42
|
+
entityName: payload.entityName,
|
|
43
|
+
fieldKey: payload.fieldKey,
|
|
44
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
45
|
+
},
|
|
42
46
|
});
|
|
43
47
|
}
|
|
44
48
|
|
|
@@ -46,7 +46,7 @@ export const deleteTenantFieldHandler: WriteHandlerDef = {
|
|
|
46
46
|
aggregateId,
|
|
47
47
|
aggregateType: "field-definition",
|
|
48
48
|
type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
|
|
49
|
-
payload: { entityName: payload.entityName, fieldKey: payload.fieldKey },
|
|
49
|
+
payload: { entityName: payload.entityName, fieldKey: payload.fieldKey, tenantId },
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
52
|
|