@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.26.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/env-schemas.test.ts +53 -11
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +75 -11
  4. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +86 -16
  5. package/src/auth-email-password/handlers/confirm-token-flow.ts +12 -8
  6. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
  7. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
  8. package/src/custom-fields/__tests__/drift.test.ts +43 -0
  9. package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
  10. package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
  11. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +196 -75
  12. package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
  13. package/src/custom-fields/constants.ts +8 -7
  14. package/src/custom-fields/db/queries/field-access.ts +1 -1
  15. package/src/custom-fields/db/queries/projection.ts +13 -5
  16. package/src/custom-fields/db/queries/quota.ts +1 -1
  17. package/src/custom-fields/db/queries/retention.ts +20 -6
  18. package/src/custom-fields/executor.ts +10 -0
  19. package/src/custom-fields/feature.ts +32 -39
  20. package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
  21. package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
  22. package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
  23. package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
  24. package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
  25. package/src/custom-fields/lib/field-access.ts +4 -0
  26. package/src/custom-fields/lib/field-definition-row.ts +33 -0
  27. package/src/custom-fields/run-retention.ts +6 -5
  28. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
  29. package/src/custom-fields/web/client-plugin.tsx +2 -0
  30. package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
  31. package/src/custom-fields/web/i18n.ts +30 -0
  32. package/src/custom-fields/wire-for-entity.ts +1 -1
  33. package/src/custom-fields/wire-user-data-rights.ts +9 -0
  34. package/src/feature-toggles/handlers/set.write.ts +13 -8
  35. package/src/file-provider-inmemory/__tests__/feature.test.ts +55 -0
  36. package/src/file-provider-s3/__tests__/feature.test.ts +27 -0
  37. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +54 -12
  38. package/src/foundation-shared/__tests__/config-helpers.test.ts +72 -0
  39. package/src/foundation-shared/config-helpers.ts +7 -3
  40. package/src/secrets/feature.ts +4 -11
  41. package/src/subscription-stripe/feature.ts +2 -2
  42. package/src/template-resolver/handlers/list.query.ts +12 -10
  43. package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
  44. package/src/tenant/seeding.ts +3 -3
  45. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +55 -0
  46. package/src/tier-engine/feature.ts +8 -2
  47. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
  48. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
  49. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
  50. package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
  51. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  52. package/src/user-data-rights/run-forget-cleanup.ts +77 -36
  53. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
@@ -28,13 +28,14 @@ export const CustomFieldsQueries = {
28
28
  // `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`.
29
29
  export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
30
30
 
31
- // Event-Type-Names (qualified at registration via r.defineEvent — final
32
- // names are `custom-fields:event:field-definition-created` etc.).
33
- // Short-names MUST be in kebab-case (no dots): qualifyEntityName runs toKebab
34
- // which collapses dots to dashes, so a dotted short-name diverges from the
35
- // registry key when handlers hand-build the qualified string.
36
- export const FIELD_DEFINITION_CREATED_EVENT = "field-definition-created";
37
- export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition-updated";
31
+ // Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
32
+ // these as `${entityName}.created`/`.updated` (dot form) — they do NOT run
33
+ // through r.defineEvent/toKebab, so the dot MUST stay.
34
+ export const FIELD_DEFINITION_CREATED_EVENT = "field-definition.created";
35
+ export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition.updated";
36
+ // Qualified at registration via r.defineEvent — final name is
37
+ // `custom-fields:event:field-definition-deleted`. Short-name MUST be kebab
38
+ // (no dots): qualifyEntityName runs toKebab which collapses dots to dashes.
38
39
  export const FIELD_DEFINITION_DELETED_EVENT = "field-definition-deleted";
39
40
 
40
41
  // Custom-field-VALUE events. Live auf host-aggregate stream (ES-Option-B).
@@ -11,6 +11,6 @@ export async function selectSerializedFieldDefinition(
11
11
  "SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
12
12
  [entityName, fieldKey, tenantId],
13
13
  );
14
- const first = (rows as ReadonlyArray<Record<string, unknown>>)[0];
14
+ const first = (rows as ReadonlyArray<Record<string, unknown>>)[0]; // @cast-boundary db-row
15
15
  return first ? (first["serialized_field"] ?? null) : null;
16
16
  }
@@ -1,15 +1,23 @@
1
1
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
4
 
4
5
  function quoteTable(tableName: string): string {
5
6
  return `"${tableName.replace(/"/g, '""')}"`;
6
7
  }
7
8
 
8
9
  function bindJsonbParam(value: unknown): { sql: string; bound: unknown } {
9
- // postgres-js infers boolean params as boolean[] candidatesroute via text::jsonb.
10
- if (typeof value === "boolean") {
10
+ // Scalar JSON primitives can't bind directly to ::jsonb Postgres rejects a
11
+ // bound boolean/number with "cannot cast type boolean/integer to jsonb" (and
12
+ // Bun.SQL infers boolean[] candidates). Route them through ::text::jsonb with
13
+ // a JSON-encoded literal. Objects/arrays/strings already bind as ::jsonb.
14
+ if (typeof value === "boolean" || typeof value === "number") {
11
15
  return { sql: "$1::text::jsonb", bound: JSON.stringify(value) };
12
16
  }
17
+ // JSON.stringify throws on bigint; its decimal string is a valid JSON number literal.
18
+ if (typeof value === "bigint") {
19
+ return { sql: "$1::text::jsonb", bound: value.toString() };
20
+ }
13
21
  return { sql: "$1::jsonb", bound: value };
14
22
  }
15
23
 
@@ -21,7 +29,7 @@ export async function setCustomFieldValue(
21
29
  fieldKey: string,
22
30
  value: unknown,
23
31
  aggregateId: string,
24
- tenantId: string,
32
+ tenantId: TenantId,
25
33
  ): Promise<void> {
26
34
  const tbl = quoteTable(tableName);
27
35
  const escapedKey = fieldKey.replace(/'/g, "''");
@@ -37,7 +45,7 @@ export async function clearCustomFieldKey(
37
45
  tableName: string,
38
46
  fieldKey: string,
39
47
  aggregateId: string,
40
- tenantId: string,
48
+ tenantId: TenantId,
41
49
  ): Promise<void> {
42
50
  const tbl = quoteTable(tableName);
43
51
  await asRawClient(db).unsafe(
@@ -54,7 +62,7 @@ export async function removeCustomFieldKeyForTenant(
54
62
  db: DbRunner,
55
63
  tableName: string,
56
64
  fieldKey: string,
57
- tenantId: string,
65
+ tenantId: TenantId,
58
66
  ): Promise<void> {
59
67
  const tbl = quoteTable(tableName);
60
68
  await asRawClient(db).unsafe(
@@ -6,7 +6,7 @@ export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string
6
6
  "SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1",
7
7
  [tenantId],
8
8
  );
9
- const rows = rowsResult as ReadonlyArray<Record<string, unknown>>;
9
+ const rows = rowsResult as ReadonlyArray<Record<string, unknown>>; // @cast-boundary db-row
10
10
  const first = rows[0];
11
11
  if (!first) return 0;
12
12
  const n = first["n"];
@@ -25,15 +25,29 @@ export async function selectHostRowsWithCustomFields(
25
25
  return Array.isArray(rowsResult) ? rowsResult : [];
26
26
  }
27
27
 
28
- export async function updateHostRowCustomFields(
28
+ export async function applyRetentionRemovals(
29
29
  db: DbRunner,
30
30
  tableName: string,
31
- customFields: Record<string, unknown>,
31
+ deleteKeys: readonly string[],
32
+ anonymizeKeys: readonly string[],
32
33
  rowId: string,
33
34
  ): Promise<void> {
34
35
  const quoted = `"${tableName.replace(/"/g, '""')}"`;
35
- await asRawClient(db).unsafe(`UPDATE ${quoted} SET custom_fields = $1::jsonb WHERE id = $2`, [
36
- customFields,
37
- rowId,
38
- ]);
36
+ // Atomic per-row jsonb edit instead of read-modify-write of the whole
37
+ // object: delete-keys are dropped (`- $1::text[]`), anonymize-keys are set
38
+ // to JSON null via a merge patch. Operating on the live row value preserves
39
+ // a concurrent set-custom-field on any *other* key — no lost update.
40
+ await asRawClient(db).unsafe(
41
+ `UPDATE ${quoted} SET custom_fields = CASE
42
+ WHEN jsonb_typeof(custom_fields) = 'object' THEN
43
+ (custom_fields - $1::text[])
44
+ || COALESCE(
45
+ (SELECT jsonb_object_agg(k, 'null'::jsonb) FROM unnest($2::text[]) AS k),
46
+ '{}'::jsonb
47
+ )
48
+ ELSE custom_fields
49
+ END
50
+ WHERE id = $3`,
51
+ [deleteKeys, anonymizeKeys, rowId],
52
+ );
39
53
  }
@@ -0,0 +1,10 @@
1
+ import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { fieldDefinitionEntity } from "./entity";
3
+
4
+ // Single field-definition executor shared by the four define/delete handlers.
5
+ // createEntityExecutor is side-effect-free; instantiating it once keeps the
6
+ // table+executor pair in one place instead of rebuilding it per handler module.
7
+ export const { executor: fieldDefinitionExecutor } = createEntityExecutor(
8
+ "field-definition",
9
+ fieldDefinitionEntity,
10
+ );
@@ -37,7 +37,12 @@
37
37
  // Value-Validation gegen serializedField → set-custom-field via fieldToZod —
38
38
  // alle erledigt.)
39
39
 
40
- import { defineEntityListHandler, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
40
+ import {
41
+ defineEntityListHandler,
42
+ defineFeature,
43
+ type FeatureRegistrar,
44
+ type WriteHandlerDef,
45
+ } from "@cosmicdrift/kumiko-framework/engine";
41
46
  import { z } from "zod";
42
47
  import {
43
48
  CUSTOM_FIELD_CLEARED_EVENT,
@@ -71,12 +76,14 @@ const fieldDefinitionDeletedSchema = z.object({
71
76
  tenantId: z.string().optional(),
72
77
  });
73
78
 
74
- // Singleton feature-definition mit typed exports. Handler + wire-for-entity
75
- // importieren diesen `customFieldsFeature` und greifen lazy in ihrer
76
- // runtime-arrow-fn auf `.exports.<event>.name` zu der module-cycle
77
- // (feature.ts -> handlers/*.write.ts -> feature.ts) löst sich auf weil
78
- // kein top-level-access stattfindet.
79
- export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) => {
79
+ // Shared registration body for both the singleton and the quota-variant.
80
+ // Only the define-tenant-field handler differs (quota baked in or not); every
81
+ // other entity/event/handler is registered identically here so a new
82
+ // event/handler can never silently miss the quota variant.
83
+ function registerCustomFields(
84
+ r: FeatureRegistrar<typeof CUSTOM_FIELDS_FEATURE_NAME>,
85
+ defineTenantHandler: WriteHandlerDef,
86
+ ) {
80
87
  r.entity("field-definition", fieldDefinitionEntity);
81
88
 
82
89
  // Event-types — qualified als "custom-fields:event:<short-name>".
@@ -101,7 +108,7 @@ export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r)
101
108
  r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
102
109
 
103
110
  // Definition-CRUD handlers (B1).
104
- r.writeHandler(defineTenantFieldHandler);
111
+ r.writeHandler(defineTenantHandler);
105
112
  r.writeHandler(defineSystemFieldHandler);
106
113
  r.writeHandler(deleteTenantFieldHandler);
107
114
  r.writeHandler(deleteSystemFieldHandler);
@@ -116,7 +123,16 @@ export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r)
116
123
  );
117
124
 
118
125
  return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
119
- });
126
+ }
127
+
128
+ // Singleton feature-definition mit typed exports. Handler + wire-for-entity
129
+ // importieren diesen `customFieldsFeature` und greifen lazy in ihrer
130
+ // runtime-arrow-fn auf `.exports.<event>.name` zu — der module-cycle
131
+ // (feature.ts -> handlers/*.write.ts -> feature.ts) löst sich auf weil
132
+ // kein top-level-access stattfindet.
133
+ export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
134
+ registerCustomFields(r, defineTenantFieldHandler),
135
+ );
120
136
 
121
137
  // Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
122
138
  // host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
@@ -133,34 +149,11 @@ export function createCustomFieldsFeature(
133
149
  if (opts.fieldDefinitionLimitPerTenant === undefined) {
134
150
  return customFieldsFeature;
135
151
  }
136
- return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) => {
137
- r.entity("field-definition", fieldDefinitionEntity);
138
-
139
- const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema);
140
- const clearedEvent = r.defineEvent(CUSTOM_FIELD_CLEARED_EVENT, customFieldClearedSchema);
141
- const fieldDefinitionDeletedEvent = r.defineEvent(
142
- FIELD_DEFINITION_DELETED_EVENT,
143
- fieldDefinitionDeletedSchema,
144
- );
145
-
146
- r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
147
-
148
- r.writeHandler(
149
- createDefineTenantFieldHandler({
150
- fieldDefinitionLimitPerTenant: opts.fieldDefinitionLimitPerTenant,
151
- }),
152
- );
153
- r.writeHandler(defineSystemFieldHandler);
154
- r.writeHandler(deleteTenantFieldHandler);
155
- r.writeHandler(deleteSystemFieldHandler);
156
-
157
- r.writeHandler(setCustomFieldHandler);
158
- r.writeHandler(clearCustomFieldHandler);
159
-
160
- r.queryHandler(
161
- defineEntityListHandler("field-definition", fieldDefinitionEntity, tenantAdminAccess),
162
- );
163
-
164
- return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
165
- });
152
+ const limit = opts.fieldDefinitionLimitPerTenant;
153
+ return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
154
+ registerCustomFields(
155
+ r,
156
+ createDefineTenantFieldHandler({ fieldDefinitionLimitPerTenant: limit }),
157
+ ),
158
+ );
166
159
  }
@@ -7,7 +7,11 @@ import { checkFieldAccessForWrite } from "../lib/field-access";
7
7
  export const clearCustomFieldPayloadSchema = z.object({
8
8
  entityName: z.string().min(1).max(64),
9
9
  entityId: z.string().min(1),
10
- fieldKey: z.string().min(1).max(64),
10
+ fieldKey: z
11
+ .string()
12
+ .min(1)
13
+ .max(64)
14
+ .regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/),
11
15
  });
12
16
  export type ClearCustomFieldPayload = z.infer<typeof clearCustomFieldPayloadSchema>;
13
17
 
@@ -1,14 +1,9 @@
1
- import {
2
- createEntityExecutor,
3
- SYSTEM_TENANT_ID,
4
- type WriteHandlerDef,
5
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
7
- import { fieldDefinitionEntity } from "../entity";
3
+ import { fieldDefinitionExecutor } from "../executor";
4
+ import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
8
5
  import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
9
6
 
10
- const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
11
-
12
7
  // define-system-field — SystemAdmin definiert eine system-weite Custom-Field-
13
8
  // Definition die für ALLE Tenants gilt. tenantId wird auf SYSTEM_TENANT_ID
14
9
  // gesetzt (NICHT vom Caller — SystemAdmin's event.user.tenantId würde sonst
@@ -38,20 +33,8 @@ export const defineSystemFieldHandler: WriteHandlerDef = {
38
33
  // — the row lives in the system-scope-stream.
39
34
  const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
40
35
 
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
- },
36
+ return fieldDefinitionExecutor.create(
37
+ { id: aggregateId, ...buildFieldDefinitionColumns(payload) },
55
38
  systemUser,
56
39
  ctx.db,
57
40
  );
@@ -1,16 +1,11 @@
1
- import {
2
- createEntityExecutor,
3
- isSystemTenant,
4
- type WriteHandlerDef,
5
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
2
  import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
7
3
  import { fieldDefinitionAggregateId } from "../aggregate-id";
8
- import { fieldDefinitionEntity } from "../entity";
4
+ import { fieldDefinitionExecutor } from "../executor";
5
+ import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
9
6
  import { countTenantFieldDefinitions } from "../lib/quota";
10
7
  import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
11
8
 
12
- const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
13
-
14
9
  // define-tenant-field — TenantAdmin definiert eine Custom-Field-Definition
15
10
  // für seinen eigenen Tenant. tenantId wird automatisch aus event.user.tenantId
16
11
  // abgeleitet (NICHT vom Caller setzbar — verhindert Cross-Tenant-Mutation).
@@ -32,8 +27,15 @@ const { executor } = createEntityExecutor("field-definition", fieldDefinitionEnt
32
27
  // `unprocessable` + reason `cap_exceeded` BEFORE attempting the insert.
33
28
  // The factory below closes over the limit; the legacy const-export keeps
34
29
  // behavior unchanged for callers who didn't opt into a limit.
30
+ //
31
+ // Soft cap, not a hard guarantee: the count-then-insert is not serialized, so
32
+ // N concurrent defines with distinct fieldKeys can each read `current < limit`
33
+ // and overshoot by up to N. Acceptable here — defining fields is an admin-only,
34
+ // low-frequency action and the limit is not wired to billing/tier enforcement.
35
+ // If an exact cap is ever needed, serialize via advisory lock or a count
36
+ // constraint at the insert.
35
37
  export interface DefineTenantFieldOptions {
36
- /** Hard quota — `>= limit` definitions per tenant rejects further defines. */
38
+ /** Soft cap — `>= limit` definitions per tenant rejects further defines (see header: concurrent defines may overshoot). */
37
39
  readonly fieldDefinitionLimitPerTenant?: number;
38
40
  }
39
41
 
@@ -73,20 +75,8 @@ export function createDefineTenantFieldHandler(
73
75
  payload.fieldKey,
74
76
  );
75
77
 
76
- return executor.create(
77
- {
78
- id: aggregateId,
79
- entityName: payload.entityName,
80
- fieldKey: payload.fieldKey,
81
- type: payload.serializedField.type,
82
- required: payload.required,
83
- searchable: payload.searchable,
84
- displayOrder: payload.displayOrder,
85
- serializedField: JSON.stringify({
86
- ...payload.serializedField,
87
- label: payload.label,
88
- }),
89
- },
78
+ return fieldDefinitionExecutor.create(
79
+ { id: aggregateId, ...buildFieldDefinitionColumns(payload) },
90
80
  event.user,
91
81
  ctx.db,
92
82
  );
@@ -1,15 +1,9 @@
1
- import {
2
- createEntityExecutor,
3
- SYSTEM_TENANT_ID,
4
- type WriteHandlerDef,
5
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
7
- import { fieldDefinitionEntity } from "../entity";
3
+ import { fieldDefinitionExecutor } from "../executor";
8
4
  import { customFieldsFeature } from "../feature";
9
5
  import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
10
6
 
11
- const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
12
-
13
7
  // delete-system-field — SystemAdmin entfernt eine system-weite Field-
14
8
  // Definition. Konsequenz: KEIN Tenant kann mehr neue Werte dafür setzen,
15
9
  // existing Werte in read_<entity>.customFields jsonb bleiben aber bestehen
@@ -29,7 +23,7 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
29
23
  );
30
24
 
31
25
  const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
32
- const result = await executor.delete({ id: aggregateId }, systemUser, ctx.db);
26
+ const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, systemUser, ctx.db);
33
27
 
34
28
  // Cascade-cleanup-Event — host-entity-MSPs entfernen orphan values aus
35
29
  // ihrer customFields jsonb. Im selben TX = atomic.
@@ -1,15 +1,9 @@
1
- import {
2
- createEntityExecutor,
3
- isSystemTenant,
4
- type WriteHandlerDef,
5
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
7
- import { fieldDefinitionEntity } from "../entity";
3
+ import { fieldDefinitionExecutor } from "../executor";
8
4
  import { customFieldsFeature } from "../feature";
9
5
  import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
10
6
 
11
- const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
12
-
13
7
  // delete-tenant-field — TenantAdmin löscht eigene Field-Definition.
14
8
  // Spec-Promise (Plan-Doc v2 "wie Entity-Delete"): Events bleiben im event-
15
9
  // store für Audit-Trail. Read-Projection-Row wird entfernt. B2 wird die
@@ -36,7 +30,7 @@ export const deleteTenantFieldHandler: WriteHandlerDef = {
36
30
 
37
31
  const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
38
32
 
39
- const result = await executor.delete({ id: aggregateId }, event.user, ctx.db);
33
+ const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, event.user, ctx.db);
40
34
 
41
35
  // Emit cascade-cleanup-Event NACH erfolgreichem Delete. host-entity-MSPs
42
36
  // (registriert via wireCustomFieldsFor) konsumieren das + entfernen orphan
@@ -34,6 +34,10 @@ export async function loadFieldDefinition(
34
34
 
35
35
  // Pure access-check on an already-loaded definition. Returns the required
36
36
  // roles when the caller is denied, or `null` when access is allowed.
37
+ //
38
+ // Contract: `fieldAccess.write` entries MUST be verbatim membership-system role
39
+ // names (e.g. "TenantAdmin", not "Admin") — match is exact like framework-wide
40
+ // `engine/access.ts`; no normalization, so a drifted name denies silently.
37
41
  export function fieldWriteAccessDeniedRoles(
38
42
  field: SerializedFieldShape | null,
39
43
  userRoles: ReadonlyArray<string>,
@@ -0,0 +1,33 @@
1
+ import type { DefineFieldPayload } from "../schemas";
2
+
3
+ export interface FieldDefinitionColumns {
4
+ readonly entityName: string;
5
+ readonly fieldKey: string;
6
+ readonly type: string;
7
+ readonly required: boolean;
8
+ readonly searchable: boolean;
9
+ readonly displayOrder: number;
10
+ readonly serializedField: string;
11
+ }
12
+
13
+ // The required/searchable/displayOrder columns are a denormalized projection of
14
+ // serializedField — derive, don't default. Zod gives top-level `required` a
15
+ // `.default(false)`, so "caller omitted it" is indistinguishable from `false`
16
+ // post-parse; serializedField-present therefore wins to preserve caller intent.
17
+ export function buildFieldDefinitionColumns(payload: DefineFieldPayload): FieldDefinitionColumns {
18
+ const sf = payload.serializedField;
19
+ const required = typeof sf["required"] === "boolean" ? sf["required"] : payload.required;
20
+ const searchable = typeof sf["searchable"] === "boolean" ? sf["searchable"] : payload.searchable;
21
+ const displayOrder =
22
+ typeof sf["displayOrder"] === "number" ? sf["displayOrder"] : payload.displayOrder;
23
+
24
+ return {
25
+ entityName: payload.entityName,
26
+ fieldKey: payload.fieldKey,
27
+ type: sf.type,
28
+ required,
29
+ searchable,
30
+ displayOrder,
31
+ serializedField: JSON.stringify({ ...sf, label: payload.label }),
32
+ };
33
+ }
@@ -18,9 +18,9 @@
18
18
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
19
19
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
20
20
  import {
21
+ applyRetentionRemovals,
21
22
  selectFieldDefinitionsWithSerialized,
22
23
  selectHostRowsWithCustomFields,
23
- updateHostRowCustomFields,
24
24
  } from "./db/queries/retention";
25
25
  import { parseSerializedField } from "./lib/parse-serialized-field";
26
26
 
@@ -122,17 +122,18 @@ export async function runCustomFieldsRetention(
122
122
  // skip: nothing on this row aged out — no UPDATE needed.
123
123
  if (removals.length === 0) continue;
124
124
 
125
- const mutated: Record<string, unknown> = { ...row.customFields };
125
+ const deleteKeys: string[] = [];
126
+ const anonymizeKeys: string[] = [];
126
127
  for (const { key, strategy } of removals) {
127
128
  if (strategy === "delete") {
128
- delete mutated[key];
129
+ deleteKeys.push(key);
129
130
  } else {
130
- mutated[key] = null;
131
+ anonymizeKeys.push(key);
131
132
  }
132
133
  removalsByFieldKey[key] = (removalsByFieldKey[key] ?? 0) + 1;
133
134
  }
134
135
 
135
- await updateHostRowCustomFields(opts.db, tableName, mutated, row.id);
136
+ await applyRetentionRemovals(opts.db, tableName, deleteKeys, anonymizeKeys, row.id);
136
137
  rowsUpdated++;
137
138
  }
138
139
 
@@ -8,6 +8,7 @@ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
8
  import { fireEvent, render, screen } from "@testing-library/react";
9
9
  import type { ReactNode } from "react";
10
10
  import { CustomFieldsFormSection } from "../custom-fields-form-section";
11
+ import { defaultTranslations } from "../i18n";
11
12
 
12
13
  type FieldRow = {
13
14
  id: string;
@@ -21,6 +22,13 @@ type FieldRow = {
21
22
  const dispatchSpy = mock(async () => ({ isSuccess: true, data: undefined }));
22
23
  let mockedQueryRows: readonly FieldRow[] = [];
23
24
 
25
+ const useQuerySpy = mock((_type: string, _params: unknown, _options?: { enabled?: boolean }) => ({
26
+ data: { rows: mockedQueryRows },
27
+ loading: false,
28
+ error: null,
29
+ refetch: mock(),
30
+ }));
31
+
24
32
  const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
25
33
  mock.module("@cosmicdrift/kumiko-renderer", () => ({
26
34
  ...actual_renderer,
@@ -29,17 +37,12 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
29
37
  query: mock(),
30
38
  batch: mock(),
31
39
  })),
32
- useQuery: mock(() => ({
33
- data: { rows: mockedQueryRows },
34
- loading: false,
35
- error: null,
36
- refetch: mock(),
37
- })),
40
+ useQuery: useQuerySpy,
38
41
  }));
39
42
 
40
43
  function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
41
44
  return (
42
- <LocaleProvider resolver={createStaticLocaleResolver()}>
45
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
43
46
  <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
44
47
  </LocaleProvider>
45
48
  );
@@ -107,8 +110,9 @@ describe("CustomFieldsFormSection", () => {
107
110
  });
108
111
  });
109
112
 
110
- test("shows create-mode banner when entityId is null", () => {
113
+ test("shows create-mode banner when entityId is null and skips the fieldDefinition query", () => {
111
114
  mockedQueryRows = [];
115
+ useQuerySpy.mockClear();
112
116
 
113
117
  render(
114
118
  <Wrapper>
@@ -116,8 +120,14 @@ describe("CustomFieldsFormSection", () => {
116
120
  </Wrapper>,
117
121
  );
118
122
 
119
- expect(screen.getByTestId("custom-fields-form-create-mode")).toBeTruthy();
123
+ const banner = screen.getByTestId("custom-fields-form-create-mode");
124
+ expect(banner).toBeTruthy();
120
125
  expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
126
+ // The banner shows the translated string, not the raw i18n key.
127
+ expect(banner.textContent).toBe("Save the entity first to add custom field values.");
128
+ // create-mode discards the query result via the early return, so the
129
+ // fetch-on-mount must be disabled — no wasted server roundtrip.
130
+ expect(useQuerySpy.mock.calls[0]?.[2]).toEqual({ enabled: false });
121
131
  });
122
132
 
123
133
  test("shows empty banner when no fieldDefinitions match entityName", () => {
@@ -138,7 +148,32 @@ describe("CustomFieldsFormSection", () => {
138
148
  </Wrapper>,
139
149
  );
140
150
 
141
- expect(screen.getByTestId("custom-fields-form-empty")).toBeTruthy();
151
+ const banner = screen.getByTestId("custom-fields-form-empty");
152
+ expect(banner).toBeTruthy();
142
153
  expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
154
+ // Translated + interpolated with the host entity name, not the raw key.
155
+ expect(banner.textContent).toBe('No custom fields defined for "component".');
156
+ });
157
+
158
+ test("save button renders the translated label, not the raw i18n key", () => {
159
+ mockedQueryRows = [
160
+ {
161
+ id: "f1",
162
+ entityName: "component",
163
+ fieldKey: "vendor",
164
+ type: "text",
165
+ required: false,
166
+ displayOrder: 1,
167
+ },
168
+ ];
169
+
170
+ render(
171
+ <Wrapper>
172
+ <CustomFieldsFormSection entityName="component" entityId="row-42" />
173
+ </Wrapper>,
174
+ );
175
+
176
+ const saveBtn = screen.getByTestId("custom-fields-form-save");
177
+ expect(saveBtn.textContent).toBe("Save custom fields");
143
178
  });
144
179
  });
@@ -14,6 +14,7 @@
14
14
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
15
15
  import { CUSTOM_FIELDS_FEATURE_NAME, CUSTOM_FIELDS_FORM_EXTENSION_NAME } from "../constants";
16
16
  import { CustomFieldsFormSection } from "./custom-fields-form-section";
17
+ import { defaultTranslations } from "./i18n";
17
18
 
18
19
  export function customFieldsClient(): ClientFeatureDefinition {
19
20
  return {
@@ -21,5 +22,6 @@ export function customFieldsClient(): ClientFeatureDefinition {
21
22
  extensionSectionComponents: {
22
23
  [CUSTOM_FIELDS_FORM_EXTENSION_NAME]: CustomFieldsFormSection,
23
24
  },
25
+ translations: defaultTranslations,
24
26
  };
25
27
  }