@cosmicdrift/kumiko-bundled-features 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/package.json +5 -5
  3. package/src/channel-email/feature.ts +1 -1
  4. package/src/channel-in-app/constants.ts +1 -1
  5. package/src/channel-in-app/feature.ts +1 -1
  6. package/src/channel-push/feature.ts +1 -1
  7. package/src/custom-fields/__tests__/audit-integration.integration.ts +277 -0
  8. package/src/custom-fields/__tests__/custom-fields.integration.ts +261 -0
  9. package/src/custom-fields/__tests__/feature.test.ts +8 -1
  10. package/src/custom-fields/__tests__/field-access.integration.ts +268 -0
  11. package/src/custom-fields/__tests__/quota.integration.ts +162 -0
  12. package/src/custom-fields/__tests__/retention.integration.ts +262 -0
  13. package/src/custom-fields/__tests__/user-data-rights.integration.ts +290 -0
  14. package/src/custom-fields/__tests__/wire-for-entity.test.ts +123 -0
  15. package/src/custom-fields/constants.ts +19 -4
  16. package/src/custom-fields/events.ts +21 -0
  17. package/src/custom-fields/feature.ts +135 -29
  18. package/src/custom-fields/handlers/clear-custom-field.write.ts +57 -0
  19. package/src/custom-fields/handlers/define-tenant-field.write.ts +72 -35
  20. package/src/custom-fields/handlers/delete-system-field.write.ts +15 -1
  21. package/src/custom-fields/handlers/delete-tenant-field.write.ts +16 -1
  22. package/src/custom-fields/handlers/set-custom-field.write.ts +77 -0
  23. package/src/custom-fields/index.ts +17 -2
  24. package/src/custom-fields/lib/field-access.ts +75 -0
  25. package/src/custom-fields/lib/parse-serialized-field.ts +45 -0
  26. package/src/custom-fields/lib/quota.ts +28 -0
  27. package/src/custom-fields/run-retention.ts +215 -0
  28. package/src/custom-fields/schemas.ts +37 -4
  29. package/src/custom-fields/wire-for-entity.ts +162 -0
  30. package/src/custom-fields/wire-user-data-rights.ts +169 -0
  31. package/src/rate-limiting/constants.ts +1 -1
  32. package/src/rate-limiting/feature.ts +1 -1
  33. package/src/renderer-simple/feature.ts +1 -1
  34. package/src/template-resolver/table.ts +3 -1
  35. package/src/tenant/invitation-table.ts +2 -1
  36. package/src/text-content/table.ts +3 -1
  37. package/src/user/schema/user.ts +4 -2
@@ -0,0 +1,290 @@
1
+ // T1.5c — user-data-rights wiring for custom-fields.
2
+ //
3
+ // Verifies the full DSGVO loop for custom-field values on a user-owned
4
+ // host entity:
5
+ //
6
+ // * Export (Art. 15+20): every row owned by the user contributes its
7
+ // customFields jsonb into the user's export bundle under
8
+ // `<entity>.customFields`.
9
+ //
10
+ // * Forget strategy=anonymize (Art. 17 with retention obligation):
11
+ // sensitive customFields keys are stripped from the jsonb; non-
12
+ // sensitive keys stay so co-tenants / co-authors keep useful data.
13
+ //
14
+ // * Forget strategy=delete: no-op — the host entity's own user-data-
15
+ // rights hook handles the row delete, jsonb travels with the row.
16
+
17
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
18
+ import {
19
+ createEntity,
20
+ createEntityExecutor,
21
+ createTextField,
22
+ defineFeature,
23
+ EXT_USER_DATA,
24
+ type UserDataDeleteHook,
25
+ type UserDataExportHook,
26
+ } from "@cosmicdrift/kumiko-framework/engine";
27
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
28
+ import {
29
+ createTestUser,
30
+ resetEventStore,
31
+ setupTestStack,
32
+ type TestStack,
33
+ unsafeCreateEntityTable,
34
+ } from "@cosmicdrift/kumiko-framework/stack";
35
+ import { sql } from "drizzle-orm";
36
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
37
+ import { z } from "zod";
38
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
39
+ import { createDataRetentionFeature } from "../../data-retention";
40
+ import { createSessionsFeature } from "../../sessions";
41
+ import { createUserFeature, userEntity } from "../../user";
42
+ import { createUserDataRightsFeature } from "../../user-data-rights";
43
+ import { fieldDefinitionEntity } from "../entity";
44
+ import { createCustomFieldsFeature } from "../feature";
45
+ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
46
+ import { wireCustomFieldsUserDataRightsFor } from "../wire-user-data-rights";
47
+
48
+ const propertyEntity = createEntity({
49
+ table: "read_t15c_properties",
50
+ fields: {
51
+ name: createTextField({ required: true }),
52
+ customFields: customFieldsField(),
53
+ },
54
+ });
55
+ const propertyTable = buildDrizzleTable("property", propertyEntity);
56
+
57
+ // Host entity gets its own EXT_USER_DATA-registration too — that's the
58
+ // canonical setup (host bundle handles row-anonymize/delete, custom-fields
59
+ // adds its strip-sensitive-jsonb layer on top). Both hooks fire in the
60
+ // same cleanup-run.
61
+ const hostExportHook: UserDataExportHook = async (ctx) => {
62
+ const rows = await ctx.db.execute(sql`
63
+ SELECT id, name FROM read_t15c_properties
64
+ WHERE inserted_by_id = ${ctx.userId} AND tenant_id = ${ctx.tenantId}
65
+ `);
66
+ const list = rows as ReadonlyArray<Record<string, unknown>>;
67
+ if (list.length === 0) return null;
68
+ return {
69
+ entity: "property",
70
+ rows: list.map((r) => ({ id: r["id"] as string, name: r["name"] as string })),
71
+ };
72
+ };
73
+
74
+ const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
75
+ if (strategy === "delete") {
76
+ await ctx.db.execute(sql`
77
+ DELETE FROM read_t15c_properties
78
+ WHERE inserted_by_id = ${ctx.userId} AND tenant_id = ${ctx.tenantId}
79
+ `);
80
+ } else {
81
+ // anonymize: clear owner, keep row + non-sensitive customFields
82
+ await ctx.db.execute(sql`
83
+ UPDATE read_t15c_properties SET inserted_by_id = NULL
84
+ WHERE inserted_by_id = ${ctx.userId} AND tenant_id = ${ctx.tenantId}
85
+ `);
86
+ }
87
+ };
88
+
89
+ const propertyFeature = defineFeature("property-t15c", (r) => {
90
+ r.entity("property", propertyEntity);
91
+ r.requires("custom-fields");
92
+ wireCustomFieldsFor(r, "property", propertyTable);
93
+ wireCustomFieldsUserDataRightsFor(r, {
94
+ entityName: "property",
95
+ entityTable: propertyTable,
96
+ userIdColumn: "inserted_by_id",
97
+ });
98
+ r.useExtension(EXT_USER_DATA, "property", {
99
+ export: hostExportHook,
100
+ delete: hostDeleteHook,
101
+ });
102
+
103
+ const { executor } = createEntityExecutor("property", propertyEntity);
104
+ r.writeHandler({
105
+ name: "property:create",
106
+ schema: z.object({ id: z.string(), name: z.string() }),
107
+ access: { roles: ["TenantAdmin", "TenantMember"] },
108
+ handler: async (event, ctx) =>
109
+ executor.create(
110
+ { id: event.payload.id, name: event.payload.name, customFields: {} },
111
+ event.user,
112
+ ctx.db,
113
+ ),
114
+ });
115
+ });
116
+
117
+ const customFieldsFeature = createCustomFieldsFeature();
118
+ const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
119
+
120
+ let stack: TestStack;
121
+
122
+ beforeAll(async () => {
123
+ stack = await setupTestStack({
124
+ features: [
125
+ createUserFeature(),
126
+ createSessionsFeature(),
127
+ createDataRetentionFeature(),
128
+ createComplianceProfilesFeature(),
129
+ customFieldsFeature,
130
+ createUserDataRightsFeature(),
131
+ propertyFeature,
132
+ ],
133
+ });
134
+ await unsafeCreateEntityTable(stack.db, userEntity);
135
+ await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
136
+ await unsafeCreateEntityTable(stack.db, propertyEntity);
137
+ await createEventsTable(stack.db);
138
+ });
139
+
140
+ afterAll(async () => {
141
+ await stack.cleanup();
142
+ });
143
+
144
+ beforeEach(async () => {
145
+ await resetEventStore(stack);
146
+ await stack.db.execute(sql`DELETE FROM read_t15c_properties`);
147
+ await stack.db.execute(sql`DELETE FROM read_custom_field_definitions`);
148
+ });
149
+
150
+ async function defineField(fieldKey: string, serializedField: Record<string, unknown>) {
151
+ return stack.http.writeOk(
152
+ "custom-fields:write:define-tenant-field",
153
+ {
154
+ entityName: "property",
155
+ fieldKey,
156
+ serializedField,
157
+ required: false,
158
+ searchable: false,
159
+ displayOrder: 0,
160
+ },
161
+ admin,
162
+ );
163
+ }
164
+
165
+ async function createProperty(id: string, name: string) {
166
+ return stack.http.writeOk("property-t15c:write:property:create", { id, name }, admin);
167
+ }
168
+
169
+ async function setField(entityId: string, fieldKey: string, value: unknown) {
170
+ return stack.http.writeOk(
171
+ "custom-fields:write:set-custom-field",
172
+ { entityName: "property", entityId, fieldKey, value },
173
+ admin,
174
+ );
175
+ }
176
+
177
+ async function readRow(id: string): Promise<Record<string, unknown> | undefined> {
178
+ const rows = await stack.db.execute(
179
+ sql`SELECT id, custom_fields FROM read_t15c_properties WHERE id = ${id}`,
180
+ );
181
+ const list = rows as ReadonlyArray<Record<string, unknown>>;
182
+ return list[0];
183
+ }
184
+
185
+ async function callExportHook(userId: string, tenantId: string) {
186
+ const usages = stack.registry.getExtensionUsages(EXT_USER_DATA);
187
+ const customFieldsUsage = usages.find(
188
+ (u) =>
189
+ u.entityName === "property" &&
190
+ (u.options as { export?: unknown })?.export &&
191
+ u.options !== undefined &&
192
+ (u.options as Record<string, unknown>)["export"] !== hostExportHook,
193
+ );
194
+ if (!customFieldsUsage) throw new Error("custom-fields user-data-rights export hook not found");
195
+ const hook = (customFieldsUsage.options as { export: UserDataExportHook }).export;
196
+ return hook({ db: stack.db, tenantId, userId });
197
+ }
198
+
199
+ async function callDeleteHook(userId: string, tenantId: string, strategy: "anonymize" | "delete") {
200
+ const usages = stack.registry.getExtensionUsages(EXT_USER_DATA);
201
+ const customFieldsUsage = usages.find(
202
+ (u) =>
203
+ u.entityName === "property" &&
204
+ (u.options as { delete?: unknown })?.delete &&
205
+ u.options !== undefined &&
206
+ (u.options as Record<string, unknown>)["delete"] !== hostDeleteHook,
207
+ );
208
+ if (!customFieldsUsage) throw new Error("custom-fields user-data-rights delete hook not found");
209
+ const hook = (customFieldsUsage.options as { delete: UserDataDeleteHook }).delete;
210
+ return hook({ db: stack.db, tenantId, userId }, strategy);
211
+ }
212
+
213
+ describe("T1.5c: user-data-rights wiring for custom-fields", () => {
214
+ test("export: customFields jsonb travels into the user's export snippet", async () => {
215
+ const propertyId = "11111111-1111-4000-8000-000000000001";
216
+ await defineField("email", { type: "text", sensitive: true });
217
+ await defineField("vipFlag", { type: "boolean" });
218
+ await createProperty(propertyId, "Hofgarten 12");
219
+ await setField(propertyId, "email", "alice@example.com");
220
+ await setField(propertyId, "vipFlag", true);
221
+ await stack.eventDispatcher?.runOnce();
222
+
223
+ const snippet = await callExportHook(String(admin.id), admin.tenantId);
224
+ expect(snippet).not.toBeNull();
225
+ expect(snippet?.entity).toBe("property.customFields");
226
+ expect(snippet?.rows).toHaveLength(1);
227
+ expect(snippet?.rows[0]?.["customFields"]).toMatchObject({
228
+ email: "alice@example.com",
229
+ vipFlag: true,
230
+ });
231
+ });
232
+
233
+ test("forget anonymize: sensitive keys stripped, non-sensitive keys kept", async () => {
234
+ const propertyId = "22222222-2222-4000-8000-000000000002";
235
+ await defineField("email", { type: "text", sensitive: true });
236
+ await defineField("vipFlag", { type: "boolean" });
237
+ await createProperty(propertyId, "Anonymize-Me");
238
+ await setField(propertyId, "email", "alice@example.com");
239
+ await setField(propertyId, "vipFlag", true);
240
+ await stack.eventDispatcher?.runOnce();
241
+
242
+ await callDeleteHook(String(admin.id), admin.tenantId, "anonymize");
243
+
244
+ const row = await readRow(propertyId);
245
+ const customFields = row?.["custom_fields"] as Record<string, unknown> | undefined;
246
+ expect(customFields).toBeDefined();
247
+ expect(customFields).not.toHaveProperty("email");
248
+ expect(customFields).toMatchObject({ vipFlag: true });
249
+ });
250
+
251
+ test("forget delete: no-op on customFields (host hook removes the row)", async () => {
252
+ const propertyId = "33333333-3333-4000-8000-000000000003";
253
+ await defineField("email", { type: "text", sensitive: true });
254
+ await createProperty(propertyId, "Delete-Me");
255
+ await setField(propertyId, "email", "alice@example.com");
256
+ await stack.eventDispatcher?.runOnce();
257
+
258
+ // call only the custom-fields delete hook (strategy=delete) — verify
259
+ // it doesn't mutate the row (the host hook would handle the actual
260
+ // row delete; we're proving custom-fields stays out of the way).
261
+ await callDeleteHook(String(admin.id), admin.tenantId, "delete");
262
+
263
+ const row = await readRow(propertyId);
264
+ const customFields = row?.["custom_fields"] as Record<string, unknown> | undefined;
265
+ expect(customFields).toMatchObject({ email: "alice@example.com" });
266
+ });
267
+
268
+ test("export: rows without customFields are not included in the snippet", async () => {
269
+ const propertyId = "44444444-4444-4000-8000-000000000004";
270
+ await createProperty(propertyId, "NoCustomFields");
271
+
272
+ const snippet = await callExportHook(String(admin.id), admin.tenantId);
273
+ expect(snippet).toBeNull();
274
+ });
275
+
276
+ test("anonymize without sensitive fields defined is a no-op (everything kept)", async () => {
277
+ const propertyId = "55555555-5555-4000-8000-000000000005";
278
+ await defineField("nonSensitive", { type: "text" });
279
+ await createProperty(propertyId, "AllStay");
280
+ await setField(propertyId, "nonSensitive", "still-here");
281
+ await stack.eventDispatcher?.runOnce();
282
+
283
+ await callDeleteHook(String(admin.id), admin.tenantId, "anonymize");
284
+
285
+ const row = await readRow(propertyId);
286
+ expect((row?.["custom_fields"] as Record<string, unknown>)?.["nonSensitive"]).toBe(
287
+ "still-here",
288
+ );
289
+ });
290
+ });
@@ -0,0 +1,123 @@
1
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { describe, expect, test } from "vitest";
4
+ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
5
+
6
+ // B2 wireCustomFieldsFor: einziger Aufruf registriert MSP + postQuery-hook +
7
+ // search-payload-extension + useExtension-Marker. Tests pinnen die Surface
8
+ // — Integration via setupTestStack kommt im T1 sprint.
9
+
10
+ const propertyEntity = createEntity({
11
+ table: "read_test_properties",
12
+ fields: {
13
+ name: createTextField({ required: true }),
14
+ customFields: customFieldsField(),
15
+ },
16
+ });
17
+
18
+ const propertyTable = buildDrizzleTable("property", propertyEntity);
19
+
20
+ describe("wireCustomFieldsFor", () => {
21
+ test("registers useExtension + MSP + postQuery-entity-hook + search-payload-extension", () => {
22
+ const feature = defineFeature("test-property", (r) => {
23
+ r.entity("property", propertyEntity);
24
+ wireCustomFieldsFor(r, "property", propertyTable);
25
+ });
26
+
27
+ // 1. useExtension registered
28
+ expect(feature.extensionUsages).toEqual(
29
+ expect.arrayContaining([
30
+ expect.objectContaining({
31
+ extensionName: "customFields",
32
+ entityName: "property",
33
+ }),
34
+ ]),
35
+ );
36
+
37
+ // 2. MSP registered with the right name
38
+ expect(Object.keys(feature.multiStreamProjections)).toEqual(
39
+ expect.arrayContaining(["custom-fields-property-projection"]),
40
+ );
41
+
42
+ // 3. postQuery entity-hook on "property"
43
+ expect(feature.entityHooks.postQuery["property"]).toHaveLength(1);
44
+
45
+ // 4. search-payload-extension on "property"
46
+ expect(feature.searchPayloadExtensions["property"]).toHaveLength(1);
47
+ });
48
+
49
+ test("postQuery-hook flattens row.customFields onto root", async () => {
50
+ const feature = defineFeature("test-property", (r) => {
51
+ r.entity("property", propertyEntity);
52
+ wireCustomFieldsFor(r, "property", propertyTable);
53
+ });
54
+
55
+ const hook = feature.entityHooks.postQuery["property"]?.[0]?.fn;
56
+ expect(hook).toBeDefined();
57
+ const result = await hook?.(
58
+ {
59
+ entityName: "property",
60
+ rows: [
61
+ {
62
+ id: "p1",
63
+ name: "Hofgarten",
64
+ customFields: { internalNumber: "X-42", vipFlag: true },
65
+ },
66
+ ],
67
+ },
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
+ {} as never,
70
+ );
71
+ expect(result?.rows[0]).toMatchObject({
72
+ id: "p1",
73
+ name: "Hofgarten",
74
+ internalNumber: "X-42",
75
+ vipFlag: true,
76
+ });
77
+ });
78
+
79
+ test("postQuery-hook handles missing/invalid customFields gracefully", async () => {
80
+ const feature = defineFeature("test-property", (r) => {
81
+ r.entity("property", propertyEntity);
82
+ wireCustomFieldsFor(r, "property", propertyTable);
83
+ });
84
+
85
+ const hook = feature.entityHooks.postQuery["property"]?.[0]?.fn;
86
+ const result = await hook?.(
87
+ {
88
+ entityName: "property",
89
+ rows: [
90
+ { id: "p1", name: "NoCustomFields" }, // missing customFields
91
+ { id: "p2", name: "WithEmpty", customFields: {} },
92
+ { id: "p3", name: "WithNull", customFields: null },
93
+ ],
94
+ },
95
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
96
+ {} as never,
97
+ );
98
+ expect(result?.rows).toHaveLength(3);
99
+ expect(result?.rows[0]).toMatchObject({ id: "p1", name: "NoCustomFields" });
100
+ expect(result?.rows[1]).toMatchObject({ id: "p2", name: "WithEmpty" });
101
+ expect(result?.rows[2]).toMatchObject({ id: "p3", name: "WithNull" });
102
+ });
103
+
104
+ test("search-payload-extension returns customFields keys flat", async () => {
105
+ const feature = defineFeature("test-property", (r) => {
106
+ r.entity("property", propertyEntity);
107
+ wireCustomFieldsFor(r, "property", propertyTable);
108
+ });
109
+
110
+ const contributor = feature.searchPayloadExtensions["property"]?.[0]?.fn;
111
+ expect(contributor).toBeDefined();
112
+ const result = await contributor?.({
113
+ entityName: "property",
114
+ entityId: "p1",
115
+ state: {
116
+ id: "p1",
117
+ name: "Hofgarten",
118
+ customFields: { internalNumber: "X-42", vipFlag: true },
119
+ },
120
+ });
121
+ expect(result).toEqual({ internalNumber: "X-42", vipFlag: true });
122
+ });
123
+ });
@@ -6,10 +6,25 @@
6
6
  export const CUSTOM_FIELDS_FEATURE_NAME = "custom-fields";
7
7
 
8
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";
9
+ // names are `custom-fields:event:field-definition-created` etc.).
10
+ // Short-names MUST be in kebab-case (no dots): qualifyEntityName runs toKebab
11
+ // which collapses dots to dashes, so a dotted short-name diverges from the
12
+ // registry key when handlers hand-build the qualified string.
13
+ export const FIELD_DEFINITION_CREATED_EVENT = "field-definition-created";
14
+ export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition-updated";
15
+ export const FIELD_DEFINITION_DELETED_EVENT = "field-definition-deleted";
16
+
17
+ // Custom-field-VALUE events. Live auf host-aggregate stream (ES-Option-B).
18
+ // Short-names werden via r.defineEvent qualified zu `custom-fields:event:
19
+ // custom-field-set` etc. (qualifyEntityName runs toKebab which collapses dots
20
+ // to dashes — so the short-name MUST already be in dash form, otherwise
21
+ // handler-built strings won't match the registry key).
22
+ export const CUSTOM_FIELD_SET_EVENT = "custom-field-set";
23
+ export const CUSTOM_FIELD_CLEARED_EVENT = "custom-field-cleared";
24
+
25
+ // Extension-name für r.useExtension("customFields", "<entity>") — registriert
26
+ // dass eine host-entity Custom-Fields haben darf.
27
+ export const CUSTOM_FIELDS_EXTENSION = "customFields";
13
28
 
14
29
  // Field-type union — identisch zu Stammfeld-Field-Type-System (Spec Z.59-73:
15
30
  // `Identisch zu Entity-Feld-Typen`). Builder-Reuse-Promise: was `r.field.X()`
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+
3
+ // Domain-Events für custom-field-VALUES. customField.set + .cleared leben
4
+ // auf der host-aggregate stream (Plan-Doc v2 ES-Option-B: customField-Events
5
+ // auf demselben Stream wie die host-entity's eigene Events).
6
+ //
7
+ // Aggregate-Type im Event ist der host-entity-name (z.B. "property"),
8
+ // aggregate-id ist die host-entity-row-id (z.B. property-uuid). So konsumieren
9
+ // die customFields-MSPs nur die Events ihrer wired-entities (filtered via
10
+ // aggregate-type-match an der jeweiligen consumer-side-MSP-Registration).
11
+
12
+ export const customFieldSetSchema = z.object({
13
+ fieldKey: z.string().min(1).max(64),
14
+ value: z.unknown(),
15
+ });
16
+ export type CustomFieldSetPayload = z.infer<typeof customFieldSetSchema>;
17
+
18
+ export const customFieldClearedSchema = z.object({
19
+ fieldKey: z.string().min(1).max(64),
20
+ });
21
+ export type CustomFieldClearedPayload = z.infer<typeof customFieldClearedSchema>;
@@ -1,54 +1,160 @@
1
- // custom-fields — Tenant- + System-scoped Custom-Field-Definitions.
1
+ // custom-fields — Tenant- + System-scoped Custom-Field-Definitions +
2
+ // generische Custom-Field-VALUE write-handler (host-stream-events).
2
3
  //
3
- // **Was diese Feature liefert (B1, 2026-05-22):**
4
+ // **Was diese Feature liefert (B1 + B2, 2026-05-23):**
4
5
  // 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).
6
+ // 2. define-tenant-field / define-system-field — RBAC write-handlers für
7
+ // Definition-CRUD.
8
+ // 3. delete-tenant-field / delete-system-field RBAC write-handlers.
9
+ // 4. set-custom-field / clear-custom-field write-handlers für VALUES.
10
+ // Emittieren customField.set/.cleared-Events auf host-aggregate-stream.
11
+ // 5. r.defineEvent für customField.set/.cleared + fieldDefinition.deleted.
12
+ // 6. r.extendsRegistrar("customFields") — registriert die extension-name
13
+ // damit consumer via r.useExtension("customFields", "<entity>") opt-in.
14
+ // 7. defineEntityListHandler — read fieldDefinitions (B1-limit: nur tenant-
15
+ // scope; B2-todo: system+tenant UNION als custom query).
10
16
  //
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
17
+ // **Consumer-side-Wiring** (siehe wire-for-entity.ts):
18
+ // Consumer ruft `wireCustomFieldsFor(r, entityName, entityTable)` auf —
19
+ // das registriert pro host-entity: useExtension + MSP (jsonb-projection)
20
+ // + postQuery-hook (flatten) + search-payload-extension.
21
+ //
22
+ // **Host-Entity-Requirement**:
23
+ // Consumer MUSS in der entity-definition eine `customFields`-Spalte als
24
+ // `customFieldsField()` (jsonb) deklarieren.
25
+ //
26
+ // **Exports-Pattern (2026-05-23 refactor)**:
27
+ // `customFieldsFeature.exports.{setEvent,clearedEvent,fieldDefinitionDeletedEvent}`
28
+ // liefern typed EventDef-handles. Handler + wire-for-entity nutzen
29
+ // `<event>.name` als compile-time-literal-typed qualified-string — keine
30
+ // hand-gebauten Template-Literals mehr (T1 hat den toKebab-collapse-drift
31
+ // aufgedeckt, siehe Memory feedback_event_def_exports_pattern).
20
32
  //
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")
33
+ // **Out-of-B2 (future iterations)**:
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
+ // - Cross-Scope-Read-UNION (system + tenant fieldDefinitions in einem List)
25
39
 
26
40
  import { defineEntityListHandler, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
27
- import { CUSTOM_FIELDS_FEATURE_NAME } from "./constants";
41
+ import { z } from "zod";
42
+ import {
43
+ CUSTOM_FIELD_CLEARED_EVENT,
44
+ CUSTOM_FIELD_SET_EVENT,
45
+ CUSTOM_FIELDS_EXTENSION,
46
+ CUSTOM_FIELDS_FEATURE_NAME,
47
+ FIELD_DEFINITION_DELETED_EVENT,
48
+ } from "./constants";
28
49
  import { fieldDefinitionEntity } from "./entity";
50
+ import { customFieldClearedSchema, customFieldSetSchema } from "./events";
51
+ import { clearCustomFieldHandler } from "./handlers/clear-custom-field.write";
29
52
  import { defineSystemFieldHandler } from "./handlers/define-system-field.write";
30
- import { defineTenantFieldHandler } from "./handlers/define-tenant-field.write";
53
+ import {
54
+ createDefineTenantFieldHandler,
55
+ defineTenantFieldHandler,
56
+ } from "./handlers/define-tenant-field.write";
31
57
  import { deleteSystemFieldHandler } from "./handlers/delete-system-field.write";
32
58
  import { deleteTenantFieldHandler } from "./handlers/delete-tenant-field.write";
59
+ import { setCustomFieldHandler } from "./handlers/set-custom-field.write";
33
60
 
34
61
  const tenantAdminAccess = { access: { roles: ["TenantAdmin"] } } as const;
35
62
 
36
- export function createCustomFieldsFeature() {
63
+ const fieldDefinitionDeletedSchema = z.object({
64
+ entityName: z.string(),
65
+ fieldKey: z.string(),
66
+ });
67
+
68
+ // Singleton feature-definition mit typed exports. Handler + wire-for-entity
69
+ // importieren diesen `customFieldsFeature` und greifen lazy in ihrer
70
+ // runtime-arrow-fn auf `.exports.<event>.name` zu — der module-cycle
71
+ // (feature.ts -> handlers/*.write.ts -> feature.ts) löst sich auf weil
72
+ // kein top-level-access stattfindet.
73
+ export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) => {
74
+ r.entity("field-definition", fieldDefinitionEntity);
75
+
76
+ // Event-types — qualified als "custom-fields:event:<short-name>".
77
+ // Returned EventDefs liefern .name als compile-time literal-typed string,
78
+ // den Handler + MSP-keys konsumieren statt Template-Literal-Konstruktion.
79
+ const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema);
80
+ const clearedEvent = r.defineEvent(CUSTOM_FIELD_CLEARED_EVENT, customFieldClearedSchema);
81
+ const fieldDefinitionDeletedEvent = r.defineEvent(
82
+ FIELD_DEFINITION_DELETED_EVENT,
83
+ fieldDefinitionDeletedSchema,
84
+ );
85
+
86
+ // Extension-Registrar — registriert dass diese Extension existiert.
87
+ // Consumer-side: r.useExtension("customFields", "<entity>") MARKIERT
88
+ // opt-in, aber wired NICHTS automatisch. Consumer MUSS zusätzlich
89
+ // `wireCustomFieldsFor(r, entity, table)` aufrufen damit MSP +
90
+ // postQuery-hook + search-extension tatsächlich registriert werden.
91
+ // Empty-options-Pattern (`{}`) ist absichtlich — boot-time-onRegister-
92
+ // wiring würde Closure über Drizzle-Table benötigen, die der Consumer
93
+ // bei extendsRegistrar-Registration nicht kennt. Daher consumer-side
94
+ // explicit-wiring statt magic-auto-wiring.
95
+ r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
96
+
97
+ // Definition-CRUD handlers (B1).
98
+ r.writeHandler(defineTenantFieldHandler);
99
+ r.writeHandler(defineSystemFieldHandler);
100
+ r.writeHandler(deleteTenantFieldHandler);
101
+ r.writeHandler(deleteSystemFieldHandler);
102
+
103
+ // Value-write handlers (B2). Emittieren events auf host-aggregate-stream.
104
+ r.writeHandler(setCustomFieldHandler);
105
+ r.writeHandler(clearCustomFieldHandler);
106
+
107
+ // List-Query — tenant-scoped (B1-limit).
108
+ r.queryHandler(
109
+ defineEntityListHandler("field-definition", fieldDefinitionEntity, tenantAdminAccess),
110
+ );
111
+
112
+ return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
113
+ });
114
+
115
+ // Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
116
+ // host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
117
+ // module-level-Singleton — kein neuer build pro Aufruf, was für consumer
118
+ // nicht erkennbar ist (read-only inspection).
119
+ //
120
+ // **T1.5e options**: when `fieldDefinitionLimitPerTenant` is set, the
121
+ // returned feature carries a fresh `define-tenant-field` handler with the
122
+ // quota baked in. Callers that don't pass options get the singleton — no
123
+ // change in behavior.
124
+ export function createCustomFieldsFeature(
125
+ opts: { readonly fieldDefinitionLimitPerTenant?: number } = {},
126
+ ): typeof customFieldsFeature {
127
+ if (opts.fieldDefinitionLimitPerTenant === undefined) {
128
+ return customFieldsFeature;
129
+ }
37
130
  return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) => {
38
131
  r.entity("field-definition", fieldDefinitionEntity);
39
132
 
40
- // Write-Handlers tenant + system Scope getrennt durch dedicated Handlers
41
- // mit unterschiedlichen access-rules.
42
- r.writeHandler(defineTenantFieldHandler);
133
+ const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema);
134
+ const clearedEvent = r.defineEvent(CUSTOM_FIELD_CLEARED_EVENT, customFieldClearedSchema);
135
+ const fieldDefinitionDeletedEvent = r.defineEvent(
136
+ FIELD_DEFINITION_DELETED_EVENT,
137
+ fieldDefinitionDeletedSchema,
138
+ );
139
+
140
+ r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
141
+
142
+ r.writeHandler(
143
+ createDefineTenantFieldHandler({
144
+ fieldDefinitionLimitPerTenant: opts.fieldDefinitionLimitPerTenant,
145
+ }),
146
+ );
43
147
  r.writeHandler(defineSystemFieldHandler);
44
148
  r.writeHandler(deleteTenantFieldHandler);
45
149
  r.writeHandler(deleteSystemFieldHandler);
46
150
 
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.
151
+ r.writeHandler(setCustomFieldHandler);
152
+ r.writeHandler(clearCustomFieldHandler);
153
+
50
154
  r.queryHandler(
51
155
  defineEntityListHandler("field-definition", fieldDefinitionEntity, tenantAdminAccess),
52
156
  );
157
+
158
+ return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
53
159
  });
54
160
  }