@cosmicdrift/kumiko-bundled-features 0.11.1 → 0.12.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # @cosmicdrift/kumiko-bundled-features
2
2
 
3
+ ## 0.12.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [f2ad7c4]
8
+ - @cosmicdrift/kumiko-framework@0.12.1
9
+ - @cosmicdrift/kumiko-renderer@0.12.1
10
+ - @cosmicdrift/kumiko-dispatcher-live@0.12.1
11
+ - @cosmicdrift/kumiko-renderer-web@0.12.1
12
+
13
+ ## 0.12.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 0c1ebe5: Add `@cosmicdrift/kumiko-bundled-features/custom-fields` — B1 phase of the custom-fields-bundle Sprint.
18
+
19
+ **Contents:**
20
+
21
+ - `fieldDefinition` entity (event-sourced) — stores tenant-scoped and system-scoped (`tenantId = SYSTEM_TENANT_ID`) custom-field definitions side-by-side
22
+ - 4 write-handlers: `define-tenant-field` (TenantAdmin), `define-system-field` (SystemAdmin), `delete-tenant-field`, `delete-system-field`
23
+ - 1 query-handler: list (tenant-scoped; B2 will add system+tenant UNION resolution)
24
+ - Deterministic aggregate-id from `(tenantId, entityName, fieldKey)` — same-scope conflicts surface naturally as `version_conflict`
25
+ - Builder-Reuse-ready: `serializedField` jsonb stores the dehydrated field-builder-options; B2 will rehydrate for value-validation against `customField.set` events
26
+
27
+ **Not in B1 (deferred to B2):**
28
+
29
+ - Event-types `customField.set` / `customField.cleared`
30
+ - MSP for value-projection in `read_<entity>.customFields` jsonb
31
+ - Schema-Migration trigger for jsonb-column on host-entities
32
+ - `r.extendsRegistrar("customFields", ...)` + onRegister wiring
33
+ - F1 postQuery + F3 search-payload-extension integration
34
+ - Cross-scope-conflict (tenant trying to override system fieldKey)
35
+ - user-data-rights anonymization wiring
36
+ - cap-counter quota wiring on define
37
+ - In-place type-change-lock (DELETE+CREATE workaround for v1)
38
+
39
+ Part of custom-fields-bundle Sprint Phase B1.
40
+
41
+ ### Patch Changes
42
+
43
+ - @cosmicdrift/kumiko-framework@0.12.0
44
+ - @cosmicdrift/kumiko-dispatcher-live@0.12.0
45
+ - @cosmicdrift/kumiko-renderer@0.12.0
46
+ - @cosmicdrift/kumiko-renderer-web@0.12.0
47
+
48
+ ## 0.11.2
49
+
50
+ ### Patch Changes
51
+
52
+ - Updated dependencies [92a84f0]
53
+ - @cosmicdrift/kumiko-framework@0.11.2
54
+ - @cosmicdrift/kumiko-renderer@0.11.2
55
+ - @cosmicdrift/kumiko-dispatcher-live@0.11.2
56
+ - @cosmicdrift/kumiko-renderer-web@0.11.2
57
+
3
58
  ## 0.11.1
4
59
 
5
60
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
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>",
@@ -25,6 +25,7 @@
25
25
  "./jobs": "./src/jobs/index.ts",
26
26
  "./tier-engine": "./src/tier-engine/index.ts",
27
27
  "./cap-counter": "./src/cap-counter/index.ts",
28
+ "./custom-fields": "./src/custom-fields/index.ts",
28
29
  "./billing-foundation": "./src/billing-foundation/index.ts",
29
30
  "./subscription-stripe": "./src/subscription-stripe/index.ts",
30
31
  "./subscription-mollie": "./src/subscription-mollie/index.ts",
@@ -74,10 +75,10 @@
74
75
  "@aws-sdk/client-s3": "^3.1045.0",
75
76
  "@aws-sdk/lib-storage": "^3.1045.0",
76
77
  "@aws-sdk/s3-request-presigner": "^3.1045.0",
77
- "@cosmicdrift/kumiko-dispatcher-live": "0.11.1",
78
- "@cosmicdrift/kumiko-framework": "0.11.1",
79
- "@cosmicdrift/kumiko-renderer": "0.11.1",
80
- "@cosmicdrift/kumiko-renderer-web": "0.11.1",
78
+ "@cosmicdrift/kumiko-dispatcher-live": "0.12.1",
79
+ "@cosmicdrift/kumiko-framework": "0.12.1",
80
+ "@cosmicdrift/kumiko-renderer": "0.12.1",
81
+ "@cosmicdrift/kumiko-renderer-web": "0.12.1",
81
82
  "@mollie/api-client": "^4.5.0",
82
83
  "@node-rs/argon2": "^2.0.2",
83
84
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,118 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { fieldDefinitionAggregateId } from "../aggregate-id";
3
+ import { SUPPORTED_FIELD_TYPES } from "../constants";
4
+ import { createCustomFieldsFeature } from "../feature";
5
+ import { defineFieldPayloadSchema, deleteFieldPayloadSchema } from "../schemas";
6
+
7
+ // B1 unit-tests: feature-shape, schema-validation, aggregate-id determinism.
8
+ // Integration tests (full-stack via setupTestStack) kommen in B2 wenn der
9
+ // MSP + Read-Pipeline da ist — die testen ES-Loop end-to-end.
10
+
11
+ describe("createCustomFieldsFeature shape", () => {
12
+ test("registers field-definition entity + 4 write-handlers + 1 query-handler", () => {
13
+ const feature = createCustomFieldsFeature();
14
+
15
+ expect(Object.keys(feature.entities)).toContain("field-definition");
16
+
17
+ const writeHandlerNames = Object.keys(feature.writeHandlers);
18
+ expect(writeHandlerNames).toEqual(
19
+ expect.arrayContaining([
20
+ expect.stringMatching(/define-tenant-field/),
21
+ expect.stringMatching(/define-system-field/),
22
+ expect.stringMatching(/delete-tenant-field/),
23
+ expect.stringMatching(/delete-system-field/),
24
+ ]),
25
+ );
26
+
27
+ expect(Object.keys(feature.queryHandlers)).toHaveLength(1);
28
+ });
29
+ });
30
+
31
+ describe("defineFieldPayloadSchema", () => {
32
+ test("accepts minimal payload with text field", () => {
33
+ const result = defineFieldPayloadSchema.safeParse({
34
+ entityName: "customer",
35
+ fieldKey: "internalNumber",
36
+ serializedField: { type: "text", required: true, maxLength: 50 },
37
+ });
38
+ expect(result.success).toBe(true);
39
+ });
40
+
41
+ test("rejects fieldKey with invalid chars", () => {
42
+ const result = defineFieldPayloadSchema.safeParse({
43
+ entityName: "customer",
44
+ fieldKey: "9starts-with-digit",
45
+ serializedField: { type: "text" },
46
+ });
47
+ expect(result.success).toBe(false);
48
+ });
49
+
50
+ test("rejects unknown field-type", () => {
51
+ const result = defineFieldPayloadSchema.safeParse({
52
+ entityName: "customer",
53
+ fieldKey: "weird",
54
+ serializedField: { type: "unknown-type" },
55
+ });
56
+ expect(result.success).toBe(false);
57
+ });
58
+
59
+ test("accepts all SUPPORTED_FIELD_TYPES", () => {
60
+ for (const type of SUPPORTED_FIELD_TYPES) {
61
+ const result = defineFieldPayloadSchema.safeParse({
62
+ entityName: "thing",
63
+ fieldKey: "f",
64
+ serializedField: { type },
65
+ });
66
+ expect(result.success).toBe(true);
67
+ }
68
+ });
69
+
70
+ test("accepts i18n-label", () => {
71
+ const result = defineFieldPayloadSchema.safeParse({
72
+ entityName: "customer",
73
+ fieldKey: "vipLevel",
74
+ serializedField: { type: "enum", values: ["bronze", "silver", "gold"] },
75
+ label: { de: "VIP-Stufe", en: "VIP Level" },
76
+ });
77
+ expect(result.success).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe("deleteFieldPayloadSchema", () => {
82
+ test("accepts minimal payload", () => {
83
+ const result = deleteFieldPayloadSchema.safeParse({
84
+ entityName: "customer",
85
+ fieldKey: "internalNumber",
86
+ });
87
+ expect(result.success).toBe(true);
88
+ });
89
+ });
90
+
91
+ describe("fieldDefinitionAggregateId determinism", () => {
92
+ test("same inputs produce same uuid", () => {
93
+ const id1 = fieldDefinitionAggregateId("t1", "customer", "internalNumber");
94
+ const id2 = fieldDefinitionAggregateId("t1", "customer", "internalNumber");
95
+ expect(id1).toBe(id2);
96
+ });
97
+
98
+ test("different tenants produce different uuids (scope-separation)", () => {
99
+ const idTenant = fieldDefinitionAggregateId("t1", "customer", "internalNumber");
100
+ const idSystem = fieldDefinitionAggregateId(
101
+ "00000000-0000-0000-0000-000000000000",
102
+ "customer",
103
+ "internalNumber",
104
+ );
105
+ expect(idTenant).not.toBe(idSystem);
106
+ });
107
+
108
+ test("different fieldKey on same entity produces different uuids", () => {
109
+ const idA = fieldDefinitionAggregateId("t1", "customer", "internalNumber");
110
+ const idB = fieldDefinitionAggregateId("t1", "customer", "vipFlag");
111
+ expect(idA).not.toBe(idB);
112
+ });
113
+
114
+ test("aggregate-id format is a valid uuid", () => {
115
+ const id = fieldDefinitionAggregateId("t1", "customer", "internalNumber");
116
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
117
+ });
118
+ });
@@ -0,0 +1,33 @@
1
+ import { v5 as uuidv5 } from "uuid";
2
+
3
+ // Fixed UUID-namespace für custom-field-definition aggregate-id-Ableitung.
4
+ // Generiert einmalig (2026-05-22), in Stein gemeißelt: ein Wechsel würde
5
+ // jeden existing fieldDefinition-Stream re-keyen → kaputter event-replay,
6
+ // kaputte definition-history. Drift-Pin in __tests__/drift.test.ts.
7
+ const FIELD_DEFINITION_NAMESPACE = "f1d3b2c7-4e5a-4b9c-8d1f-2a3b4c5d6e7f";
8
+
9
+ /**
10
+ * Deterministic aggregate-id für ein fieldDefinition-Aggregate aus dem
11
+ * Tripel (tenantId, entityName, fieldKey). Pro (Tenant|System, Entity,
12
+ * FieldKey) existiert genau ein Aggregate.
13
+ *
14
+ * **Scope-Semantik:**
15
+ * - System-Scope: `tenantId = SYSTEM_TENANT_ID` → die fieldDefinition
16
+ * gilt für alle Tenants (vendor-defined).
17
+ * - Tenant-Scope: `tenantId = <tenant-uuid>` → pro Tenant eigene
18
+ * fieldDefinition.
19
+ *
20
+ * **Conflict-Rule** (durchgesetzt im write-handler, NICHT via DB-constraint):
21
+ * Pro (entityName, fieldKey) darf nur EINE Definition existieren — entweder
22
+ * system oder tenant, nicht beide. Wenn system `customer.internalNumber`
23
+ * definiert, kann Tenant kein eigenes `customer.internalNumber` mehr
24
+ * definieren (422 `fieldKey_conflict`). Verhindert Resolution-Ambiguität
25
+ * beim Read.
26
+ */
27
+ export function fieldDefinitionAggregateId(
28
+ tenantId: string,
29
+ entityName: string,
30
+ fieldKey: string,
31
+ ): string {
32
+ return uuidv5(`${tenantId}|${entityName}|${fieldKey}`, FIELD_DEFINITION_NAMESPACE);
33
+ }
@@ -0,0 +1,26 @@
1
+ // custom-fields bundle constants — feature-name + event-names.
2
+ //
3
+ // Spec: kumiko-platform/docs/plans/features/custom-fields.md
4
+ // Sprint plan: kumiko-platform/docs/plans/custom-fields-sprint.md
5
+
6
+ export const CUSTOM_FIELDS_FEATURE_NAME = "custom-fields";
7
+
8
+ // Event-Type-Names (qualified at registration via r.defineEvent — final
9
+ // names are `custom-fields:event:field-definition.created` etc.).
10
+ export const FIELD_DEFINITION_CREATED_EVENT = "field-definition.created";
11
+ export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition.updated";
12
+ export const FIELD_DEFINITION_DELETED_EVENT = "field-definition.deleted";
13
+
14
+ // Field-type union — identisch zu Stammfeld-Field-Type-System (Spec Z.59-73:
15
+ // `Identisch zu Entity-Feld-Typen`). Builder-Reuse-Promise: was `r.field.X()`
16
+ // kann, kann eine Custom-Field-Definition auch.
17
+ export const SUPPORTED_FIELD_TYPES = [
18
+ "text",
19
+ "number",
20
+ "boolean",
21
+ "date",
22
+ "enum",
23
+ "money",
24
+ "embedded",
25
+ ] as const;
26
+ export type SupportedFieldType = (typeof SUPPORTED_FIELD_TYPES)[number];
@@ -0,0 +1,83 @@
1
+ import {
2
+ createBooleanField,
3
+ createEntity,
4
+ createNumberField,
5
+ createTextField,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ // fieldDefinition — Tenant- oder System-scoped Custom-Field-Definition.
9
+ //
10
+ // **Tenant-Scope:** `tenantId`-Base-Column wird vom Framework automatisch
11
+ // gesetzt. Bei system-scope-Definitionen ist `tenantId = SYSTEM_TENANT_ID`;
12
+ // bei tenant-scope der current-tenant. Beide leben in derselben Tabelle,
13
+ // Scope ergibt sich aus dem tenantId-Wert (no separate `scope`-column).
14
+ //
15
+ // **Conflict-Rule:** pro (entityName, fieldKey) darf nur eine Definition
16
+ // existieren — entweder system oder tenant, nicht beide. Resolution beim
17
+ // Read = `WHERE tenantId IN (SYSTEM_TENANT_ID, <current-tenant>)`.
18
+ // Conflict-Check ist write-handler-side, NICHT DB-constraint (DB hat
19
+ // natürliche UNIQUE auf (tenantId, entityName, fieldKey) durch Aggregate-
20
+ // ID-Konstruktion).
21
+ //
22
+ // **Spec-Drift**: Spec Z.40-54 hat eine separate `custom_field_value`-
23
+ // Tabelle. Plan-Doc v2 hat das durch jsonb-on-host-entity ersetzt
24
+ // (D2-pur-Storage). Hier definieren wir NUR die definition-Entity; values
25
+ // landen in `read_<entity>.customFields` jsonb via MSP (B2).
26
+ //
27
+ // **Was hier NICHT als Stammfeld-Spalte landet**:
28
+ // - `defaultValue`, `options`, `fieldAccess`, `label` — alles in
29
+ // `serializedField` jsonb gepackt (Builder-Reuse: dehydrierter
30
+ // r.field.X()-Output). Spec Z.18-38 listet sie als separate Spalten,
31
+ // aber Builder-Reuse macht das redundant — der serialized Builder
32
+ // enthält alle diese Aspekte type-safe.
33
+ // - `version` (optimistic-lock) — ES-redundant, wir nutzen aggregate-
34
+ // stream-version (Sprint E-Pattern).
35
+ // - `createdAt/updatedAt/createdBy/updatedBy` — automatic via base-
36
+ // columns + events.
37
+ export const fieldDefinitionEntity = createEntity({
38
+ table: "read_custom_field_definitions",
39
+ // B1.5 retention-policy — fieldDefinitions sind tenant-Schema-Metadaten,
40
+ // keine PII-Daten. Lange Retention für Audit (Compliance kann "wann hat
41
+ // Tenant das Feld definiert / geändert / gelöscht" fragen). Strategy
42
+ // softDelete: row bleibt als marker, value-cleanup (in B2's MSP) macht
43
+ // die eigentliche Anonymisierung wenn customFields PII enthielten.
44
+ //
45
+ // 10-Jahre keepFor ist konservativer Default; per-Tenant kann via
46
+ // tenantRetentionOverride für eigene Edge-Cases gesetzt werden
47
+ // (z.B. shorter für test-tenants).
48
+ retention: {
49
+ keepFor: "10y",
50
+ strategy: "softDelete",
51
+ },
52
+ fields: {
53
+ // Ziel-Entity-Name, für die dieses Field definiert wird (z.B. "property",
54
+ // "customer"). Max 64 char passt zu Kumiko's entity-name-Convention.
55
+ entityName: createTextField({ required: true, maxLength: 64 }),
56
+
57
+ // Field-Key (z.B. "internalNumber", "vipFlag") — kebab-case oder camelCase
58
+ // erlaubt; UI-rendering nutzt label statt fieldKey.
59
+ fieldKey: createTextField({ required: true, maxLength: 64 }),
60
+
61
+ // Field-Type aus dem SUPPORTED_FIELD_TYPES-Set. Validiert via Zod im
62
+ // write-handler.
63
+ type: createTextField({ required: true, maxLength: 16 }),
64
+
65
+ // Required-Flag — wird beim Entity-Write gegen den value gecheckt
66
+ // (Stammfeld-identische Semantik, Spec-Promise Z.4).
67
+ required: createBooleanField({ required: true }),
68
+
69
+ // Searchable-Flag — wenn true, contribuiert das Field zum Search-Doc
70
+ // via F3 search-payload-extension.
71
+ searchable: createBooleanField({ required: true }),
72
+
73
+ // UI-Display-Order. Tenant kann seine Felder via UI sortieren.
74
+ displayOrder: createNumberField({ required: true }),
75
+
76
+ // Builder-Reuse: serialisierter r.field.X(opts)-Output als jsonb.
77
+ // Beinhaltet type-options (enum-values, money-currency, embedded-schema),
78
+ // defaultValue, fieldAccess, i18n-labels. Beim Write-Validation
79
+ // dehydriert der handler dies zurück zu einer r.field.X()-Instanz und
80
+ // nutzt deren .schema für value-Validation.
81
+ serializedField: createTextField({ required: true, maxLength: 65536 }),
82
+ },
83
+ });
@@ -0,0 +1,54 @@
1
+ // custom-fields — Tenant- + System-scoped Custom-Field-Definitions.
2
+ //
3
+ // **Was diese Feature liefert (B1, 2026-05-22):**
4
+ // 1. r.entity("field-definition") — Definition-Storage (event-sourced).
5
+ // 2. define-tenant-field / define-system-field — write-handlers (RBAC).
6
+ // 3. delete-tenant-field / delete-system-field — write-handlers (RBAC).
7
+ // 4. defineEntityListHandler — read alle Definitionen des current-tenants
8
+ // (B1 limitation: nur tenant-scope; B2 wird system+tenant UNION
9
+ // ergänzen).
10
+ //
11
+ // **Was B2 ergänzen wird:**
12
+ // - customField.set / customField.cleared Event-Types
13
+ // - MSP für value-projection in read_<entity>.customFields jsonb
14
+ // - r.extendsRegistrar("customFields", ...) + onRegister-Wiring
15
+ // - F1 postQuery / F3 search-payload-extension contributors
16
+ // - Cross-scope-conflict-Detection (tenant darf system-fieldKey nicht
17
+ // überschreiben)
18
+ // - user-data-rights anonymization-Wiring für sensitive customFields
19
+ // - cap-counter wiring im fieldDefinition-create-Handler
20
+ //
21
+ // **Out-of-Scope (Plan-Doc v2):**
22
+ // - Tenant-Admin UI (Post-Todo, Phase β)
23
+ // - In-place type-change auf existing fieldDefinition — caller must
24
+ // DELETE + CREATE (Plan-Doc v2 B1.8 "Type-Change-Lock v1")
25
+
26
+ import { defineEntityListHandler, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
27
+ import { CUSTOM_FIELDS_FEATURE_NAME } from "./constants";
28
+ import { fieldDefinitionEntity } from "./entity";
29
+ import { defineSystemFieldHandler } from "./handlers/define-system-field.write";
30
+ import { defineTenantFieldHandler } from "./handlers/define-tenant-field.write";
31
+ import { deleteSystemFieldHandler } from "./handlers/delete-system-field.write";
32
+ import { deleteTenantFieldHandler } from "./handlers/delete-tenant-field.write";
33
+
34
+ const tenantAdminAccess = { access: { roles: ["TenantAdmin"] } } as const;
35
+
36
+ export function createCustomFieldsFeature() {
37
+ return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) => {
38
+ r.entity("field-definition", fieldDefinitionEntity);
39
+
40
+ // Write-Handlers — tenant + system Scope getrennt durch dedicated Handlers
41
+ // mit unterschiedlichen access-rules.
42
+ r.writeHandler(defineTenantFieldHandler);
43
+ r.writeHandler(defineSystemFieldHandler);
44
+ r.writeHandler(deleteTenantFieldHandler);
45
+ r.writeHandler(deleteSystemFieldHandler);
46
+
47
+ // List-Query — tenant kann seine eigenen Definitionen sehen. B2 wird
48
+ // einen Custom-Query mit UNION über (current-tenant ∪ SYSTEM_TENANT_ID)
49
+ // ergänzen.
50
+ r.queryHandler(
51
+ defineEntityListHandler("field-definition", fieldDefinitionEntity, tenantAdminAccess),
52
+ );
53
+ });
54
+ }
@@ -0,0 +1,59 @@
1
+ import {
2
+ createEntityExecutor,
3
+ SYSTEM_TENANT_ID,
4
+ type WriteHandlerDef,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { fieldDefinitionAggregateId } from "../aggregate-id";
7
+ import { fieldDefinitionEntity } from "../entity";
8
+ import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
9
+
10
+ const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
11
+
12
+ // define-system-field — SystemAdmin definiert eine system-weite Custom-Field-
13
+ // Definition die für ALLE Tenants gilt. tenantId wird auf SYSTEM_TENANT_ID
14
+ // gesetzt (NICHT vom Caller — SystemAdmin's event.user.tenantId würde sonst
15
+ // auf den admin's eigenen platform-tenant zeigen).
16
+ //
17
+ // **Use-Case:** Vendor (cdgs) sagt "alle Hausverwaltungs-customers haben ab
18
+ // heute ein `internalNumber`-Field". Tenant kann den Wert pro customer
19
+ // setzen, aber die Definition nicht ändern oder löschen.
20
+ //
21
+ // **Same-scope-conflict** wie bei define-tenant-field via aggregate-version-
22
+ // conflict (deterministische ID mit tenantId=SYSTEM_TENANT_ID).
23
+ export const defineSystemFieldHandler: WriteHandlerDef = {
24
+ name: "define-system-field",
25
+ schema: defineFieldPayloadSchema,
26
+ access: { roles: ["SystemAdmin"] },
27
+ handler: async (event, ctx) => {
28
+ const payload = event.payload as DefineFieldPayload; // @cast-boundary engine-payload
29
+
30
+ const aggregateId = fieldDefinitionAggregateId(
31
+ SYSTEM_TENANT_ID,
32
+ payload.entityName,
33
+ payload.fieldKey,
34
+ );
35
+
36
+ // Override event.user.tenantId to SYSTEM_TENANT_ID for the system-scope
37
+ // write. The framework's CrudExecutor writes the row with this tenantId
38
+ // — the row lives in the system-scope-stream.
39
+ const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
40
+
41
+ return executor.create(
42
+ {
43
+ id: aggregateId,
44
+ entityName: payload.entityName,
45
+ fieldKey: payload.fieldKey,
46
+ type: payload.serializedField.type,
47
+ required: payload.required,
48
+ searchable: payload.searchable,
49
+ displayOrder: payload.displayOrder,
50
+ serializedField: JSON.stringify({
51
+ ...payload.serializedField,
52
+ label: payload.label,
53
+ }),
54
+ },
55
+ systemUser,
56
+ ctx.db,
57
+ );
58
+ },
59
+ };
@@ -0,0 +1,62 @@
1
+ import {
2
+ createEntityExecutor,
3
+ isSystemTenant,
4
+ type WriteHandlerDef,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { fieldDefinitionAggregateId } from "../aggregate-id";
7
+ import { fieldDefinitionEntity } from "../entity";
8
+ import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
9
+
10
+ const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
11
+
12
+ // define-tenant-field — TenantAdmin definiert eine Custom-Field-Definition
13
+ // für seinen eigenen Tenant. tenantId wird automatisch aus event.user.tenantId
14
+ // abgeleitet (NICHT vom Caller setzbar — verhindert Cross-Tenant-Mutation).
15
+ //
16
+ // **Same-scope-conflict** wird natürlich durch aggregate-version-conflict
17
+ // enforced: deterministische aggregate-id aus uuidv5(tenant, entity, fieldKey)
18
+ // macht einen zweiten Create auf dieselbe Definition ein version_conflict.
19
+ // Dispatcher returnt 409. Saubere Idempotency-Garantie ohne extra DB-roundtrip.
20
+ //
21
+ // **Cross-scope-conflict** (tenant versucht fieldKey zu definieren der bereits
22
+ // system-scope existiert) wird in B1 NICHT enforced — aggregate-ids
23
+ // unterscheiden sich (verschiedene tenantIds in uuidv5), beide writes gehen
24
+ // durch. Resolution beim Read (B2) zeigt dann den system-scope-Wert. v2
25
+ // kann r.systemScope-Sub-Handler einführen um cross-scope-conflict am Write
26
+ // abzulehnen.
27
+ export const defineTenantFieldHandler: WriteHandlerDef = {
28
+ name: "define-tenant-field",
29
+ schema: defineFieldPayloadSchema,
30
+ access: { roles: ["TenantAdmin"] },
31
+ handler: async (event, ctx) => {
32
+ const payload = event.payload as DefineFieldPayload; // @cast-boundary engine-payload
33
+ const tenantId = event.user.tenantId;
34
+
35
+ // TenantAdmin darf NICHT system-scope schreiben — strict-guard.
36
+ if (isSystemTenant(tenantId)) {
37
+ throw new Error(
38
+ "define-tenant-field: tenantId is SYSTEM_TENANT_ID — use define-system-field for system-scope definitions",
39
+ );
40
+ }
41
+
42
+ const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
43
+
44
+ return executor.create(
45
+ {
46
+ id: aggregateId,
47
+ entityName: payload.entityName,
48
+ fieldKey: payload.fieldKey,
49
+ type: payload.serializedField.type,
50
+ required: payload.required,
51
+ searchable: payload.searchable,
52
+ displayOrder: payload.displayOrder,
53
+ serializedField: JSON.stringify({
54
+ ...payload.serializedField,
55
+ label: payload.label,
56
+ }),
57
+ },
58
+ event.user,
59
+ ctx.db,
60
+ );
61
+ },
62
+ };
@@ -0,0 +1,33 @@
1
+ import {
2
+ createEntityExecutor,
3
+ SYSTEM_TENANT_ID,
4
+ type WriteHandlerDef,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { fieldDefinitionAggregateId } from "../aggregate-id";
7
+ import { fieldDefinitionEntity } from "../entity";
8
+ import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
9
+
10
+ const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
11
+
12
+ // delete-system-field — SystemAdmin entfernt eine system-weite Field-
13
+ // Definition. Konsequenz: KEIN Tenant kann mehr neue Werte dafür setzen,
14
+ // existing Werte in read_<entity>.customFields jsonb bleiben aber bestehen
15
+ // (B2's MSP wird sie via customFieldDefinition.deleted-Event aufräumen).
16
+ // Events bleiben für Audit.
17
+ export const deleteSystemFieldHandler: WriteHandlerDef = {
18
+ name: "delete-system-field",
19
+ schema: deleteFieldPayloadSchema,
20
+ access: { roles: ["SystemAdmin"] },
21
+ handler: async (event, ctx) => {
22
+ const payload = event.payload as DeleteFieldPayload; // @cast-boundary engine-payload
23
+
24
+ const aggregateId = fieldDefinitionAggregateId(
25
+ SYSTEM_TENANT_ID,
26
+ payload.entityName,
27
+ payload.fieldKey,
28
+ );
29
+
30
+ const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
31
+ return executor.delete({ id: aggregateId }, systemUser, ctx.db);
32
+ },
33
+ };
@@ -0,0 +1,40 @@
1
+ import {
2
+ createEntityExecutor,
3
+ isSystemTenant,
4
+ type WriteHandlerDef,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { fieldDefinitionAggregateId } from "../aggregate-id";
7
+ import { fieldDefinitionEntity } from "../entity";
8
+ import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
9
+
10
+ const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
11
+
12
+ // delete-tenant-field — TenantAdmin löscht eigene Field-Definition.
13
+ // Spec-Promise (Plan-Doc v2 "wie Entity-Delete"): Events bleiben im event-
14
+ // store für Audit-Trail. Read-Projection-Row wird entfernt. B2 wird die
15
+ // Cleanup-Pipeline (`customFieldDefinition.deleted`-Event → MSP entfernt
16
+ // values aus read_<entity>.customFields jsonb) wirklich wiren — in B1
17
+ // kümmern wir uns nur um die Definition selbst.
18
+ //
19
+ // **Idempotency:** Delete auf nicht-existente Definition → version_conflict
20
+ // (executor.delete returns failure, dispatcher 404/422). Caller sieht "not
21
+ // found" — kein Crash.
22
+ export const deleteTenantFieldHandler: WriteHandlerDef = {
23
+ name: "delete-tenant-field",
24
+ schema: deleteFieldPayloadSchema,
25
+ access: { roles: ["TenantAdmin"] },
26
+ handler: async (event, ctx) => {
27
+ const payload = event.payload as DeleteFieldPayload; // @cast-boundary engine-payload
28
+ const tenantId = event.user.tenantId;
29
+
30
+ if (isSystemTenant(tenantId)) {
31
+ throw new Error(
32
+ "delete-tenant-field: tenantId is SYSTEM_TENANT_ID — use delete-system-field for system-scope deletions",
33
+ );
34
+ }
35
+
36
+ const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
37
+
38
+ return executor.delete({ id: aggregateId }, event.user, ctx.db);
39
+ },
40
+ };
@@ -0,0 +1,17 @@
1
+ export { fieldDefinitionAggregateId } from "./aggregate-id";
2
+ export {
3
+ CUSTOM_FIELDS_FEATURE_NAME,
4
+ FIELD_DEFINITION_CREATED_EVENT,
5
+ FIELD_DEFINITION_DELETED_EVENT,
6
+ FIELD_DEFINITION_UPDATED_EVENT,
7
+ SUPPORTED_FIELD_TYPES,
8
+ type SupportedFieldType,
9
+ } from "./constants";
10
+ export { fieldDefinitionEntity } from "./entity";
11
+ export { createCustomFieldsFeature } from "./feature";
12
+ export {
13
+ type DefineFieldPayload,
14
+ type DeleteFieldPayload,
15
+ defineFieldPayloadSchema,
16
+ deleteFieldPayloadSchema,
17
+ } from "./schemas";
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+ import { SUPPORTED_FIELD_TYPES } from "./constants";
3
+
4
+ // Field-Type-Validator — pinnt valid type-Werte für fieldDefinition.
5
+ const fieldTypeSchema = z.enum(SUPPORTED_FIELD_TYPES);
6
+
7
+ // Serialized-field-jsonb — was als `serializedField`-Spalte gespeichert wird.
8
+ // In v1 noch nicht strict-typed pro field-type (das kommt in B2 wenn die
9
+ // Stammfeld-Builder-Schemas exposed sind). Hier nur die Struktur-Garantie.
10
+ //
11
+ // Schema-shape (Beispiel-Inputs für die unterschiedlichen Field-Types):
12
+ //
13
+ // text: { type: "text", required: true, maxLength: 50 }
14
+ // number: { type: "number", required: false, min: 0, max: 100 }
15
+ // boolean: { type: "boolean", required: false }
16
+ // date: { type: "date", required: false }
17
+ // enum: { type: "enum", required: true, values: ["bronze", "silver", "gold"] }
18
+ // money: { type: "money", required: false, currency: "EUR" }
19
+ // embedded: { type: "embedded", required: false, schema: { ... } }
20
+ //
21
+ // In B1 akzeptieren wir alle Variants als Generic-jsonb-Payload mit nur dem
22
+ // `type`-Pflichtfeld validiert. B2 wird die volle discriminated-union mit
23
+ // per-type-options anschließen — sobald die Stammfeld-Field-Builder-Schemas
24
+ // exportiert sind.
25
+ const serializedFieldSchema = z
26
+ .looseObject({
27
+ type: fieldTypeSchema,
28
+ })
29
+ .refine((v) => typeof v["type"] === "string", "serializedField must have a string `type`");
30
+
31
+ // i18n-labels — `{ de: "...", en: "...", ... }`. Mindestens ein Eintrag.
32
+ const labelSchema = z.record(z.string().min(2).max(8), z.string().min(1));
33
+
34
+ // Payload für `define-tenant-field` und `define-system-field` write-handler.
35
+ export const defineFieldPayloadSchema = z.object({
36
+ entityName: z.string().min(1).max(64),
37
+ fieldKey: z
38
+ .string()
39
+ .min(1)
40
+ .max(64)
41
+ .regex(
42
+ /^[a-zA-Z][a-zA-Z0-9_-]*$/,
43
+ "fieldKey must start with a letter, only letters/digits/_/- allowed",
44
+ ),
45
+ serializedField: serializedFieldSchema,
46
+ required: z.boolean().default(false),
47
+ searchable: z.boolean().default(false),
48
+ displayOrder: z.number().int().min(0).default(0),
49
+ label: labelSchema.optional(),
50
+ });
51
+ export type DefineFieldPayload = z.infer<typeof defineFieldPayloadSchema>;
52
+
53
+ // Payload für `delete-tenant-field` / `delete-system-field`.
54
+ export const deleteFieldPayloadSchema = z.object({
55
+ entityName: z.string().min(1).max(64),
56
+ fieldKey: z.string().min(1).max(64),
57
+ });
58
+ export type DeleteFieldPayload = z.infer<typeof deleteFieldPayloadSchema>;