@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 +55 -0
- package/package.json +6 -5
- package/src/custom-fields/__tests__/feature.test.ts +118 -0
- package/src/custom-fields/aggregate-id.ts +33 -0
- package/src/custom-fields/constants.ts +26 -0
- package/src/custom-fields/entity.ts +83 -0
- package/src/custom-fields/feature.ts +54 -0
- package/src/custom-fields/handlers/define-system-field.write.ts +59 -0
- package/src/custom-fields/handlers/define-tenant-field.write.ts +62 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +33 -0
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +40 -0
- package/src/custom-fields/index.ts +17 -0
- package/src/custom-fields/schemas.ts +58 -0
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.
|
|
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.
|
|
78
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
79
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
80
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
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>;
|