@cosmicdrift/kumiko-bundled-features 0.32.1 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -113,6 +113,17 @@ async function countDefinitions(tenantId: string, fieldKey: string): Promise<num
|
|
|
113
113
|
return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
async function fetchDefinitionRow(
|
|
117
|
+
tenantId: string,
|
|
118
|
+
fieldKey: string,
|
|
119
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
120
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
121
|
+
"SELECT entity_name, field_key, type, required, searchable, display_order, serialized_field FROM read_custom_field_definitions WHERE tenant_id = $1 AND field_key = $2",
|
|
122
|
+
[tenantId, fieldKey],
|
|
123
|
+
);
|
|
124
|
+
return (rows as ReadonlyArray<Record<string, unknown>>)[0];
|
|
125
|
+
}
|
|
126
|
+
|
|
116
127
|
async function defineField(entityName: string, fieldKey: string, type = "text") {
|
|
117
128
|
return stack.http.writeOk(
|
|
118
129
|
"custom-fields:write:define-tenant-field",
|
|
@@ -561,3 +572,142 @@ describe("custom-fields integration — value validation (Builder-Reuse)", () =>
|
|
|
561
572
|
expect(await rawCustomFields(id)).toMatchObject({ geo: { city: "Bonn" } });
|
|
562
573
|
});
|
|
563
574
|
});
|
|
575
|
+
|
|
576
|
+
describe("custom-fields integration — update-tenant-field (Bug-Bash D2)", () => {
|
|
577
|
+
async function updateField(fieldKey: string, overrides: Record<string, unknown>, user = admin) {
|
|
578
|
+
return stack.http.writeOk(
|
|
579
|
+
"custom-fields:write:update-tenant-field",
|
|
580
|
+
{
|
|
581
|
+
entityName: "property",
|
|
582
|
+
fieldKey,
|
|
583
|
+
serializedField: { type: "text" },
|
|
584
|
+
required: false,
|
|
585
|
+
searchable: false,
|
|
586
|
+
displayOrder: 0,
|
|
587
|
+
...overrides,
|
|
588
|
+
},
|
|
589
|
+
user,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
test("define → update ersetzt Spalten + serializedField-Inhalt (Projektion)", async () => {
|
|
594
|
+
await defineField("property", "priority", "number");
|
|
595
|
+
await updateField("priority", {
|
|
596
|
+
serializedField: { type: "number", min: 0, max: 10 },
|
|
597
|
+
required: true,
|
|
598
|
+
searchable: true,
|
|
599
|
+
displayOrder: 7,
|
|
600
|
+
label: { de: "Priorität", en: "Priority" },
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const row = await fetchDefinitionRow(admin.tenantId, "priority");
|
|
604
|
+
expect(row).toBeDefined();
|
|
605
|
+
expect(row?.["type"]).toBe("number");
|
|
606
|
+
expect(row?.["required"]).toBe(true);
|
|
607
|
+
expect(row?.["searchable"]).toBe(true);
|
|
608
|
+
expect(Number(row?.["display_order"])).toBe(7);
|
|
609
|
+
// serializedField über den update-Pfad (flattenCompoundTypes ≠ create-Pfad)
|
|
610
|
+
// zurückparsen — Inhalt beweisen, nicht nur write-success.
|
|
611
|
+
const sf = JSON.parse(String(row?.["serialized_field"])) as Record<string, unknown>;
|
|
612
|
+
expect(sf["min"]).toBe(0);
|
|
613
|
+
expect(sf["max"]).toBe(10);
|
|
614
|
+
expect(sf["label"]).toEqual({ de: "Priorität", en: "Priority" });
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("zwei sequentielle Updates ohne version_conflict (skipOptimisticLock)", async () => {
|
|
618
|
+
await defineField("property", "stage", "text");
|
|
619
|
+
await updateField("stage", { displayOrder: 1 });
|
|
620
|
+
await updateField("stage", { displayOrder: 2 });
|
|
621
|
+
const row = await fetchDefinitionRow(admin.tenantId, "stage");
|
|
622
|
+
expect(Number(row?.["display_order"])).toBe(2);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("update auf nicht-existente Definition → 404", async () => {
|
|
626
|
+
const err = await stack.http.writeErr(
|
|
627
|
+
"custom-fields:write:update-tenant-field",
|
|
628
|
+
{
|
|
629
|
+
entityName: "property",
|
|
630
|
+
fieldKey: "ghost",
|
|
631
|
+
serializedField: { type: "text" },
|
|
632
|
+
required: false,
|
|
633
|
+
searchable: false,
|
|
634
|
+
displayOrder: 0,
|
|
635
|
+
},
|
|
636
|
+
admin,
|
|
637
|
+
);
|
|
638
|
+
expect(err.httpStatus).toBe(404);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("type-Wechsel → 422 field_type_immutable, Bestand unverändert", async () => {
|
|
642
|
+
await defineField("property", "color", "text");
|
|
643
|
+
const err = await stack.http.writeErr(
|
|
644
|
+
"custom-fields:write:update-tenant-field",
|
|
645
|
+
{
|
|
646
|
+
entityName: "property",
|
|
647
|
+
fieldKey: "color",
|
|
648
|
+
serializedField: { type: "number" },
|
|
649
|
+
required: false,
|
|
650
|
+
searchable: false,
|
|
651
|
+
displayOrder: 0,
|
|
652
|
+
},
|
|
653
|
+
admin,
|
|
654
|
+
);
|
|
655
|
+
expect(err.httpStatus).toBe(422);
|
|
656
|
+
expect(err.details).toMatchObject({
|
|
657
|
+
reason: "field_type_immutable",
|
|
658
|
+
currentType: "text",
|
|
659
|
+
requestedType: "number",
|
|
660
|
+
});
|
|
661
|
+
const row = await fetchDefinitionRow(admin.tenantId, "color");
|
|
662
|
+
expect(row?.["type"]).toBe("text");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("Cross-Tenant: fremde (entityName,fieldKey) → 404, Owner-Definition unverändert", async () => {
|
|
666
|
+
await defineField("property", "secret", "text");
|
|
667
|
+
// Expliziter Fremd-Tenant — createTestUser ohne tenantId landet im
|
|
668
|
+
// selben Default-Test-Tenant wie `admin`.
|
|
669
|
+
const otherAdmin = createTestUser({
|
|
670
|
+
roles: ["TenantAdmin"],
|
|
671
|
+
tenantId: "00000000-0000-4000-8000-000000000099",
|
|
672
|
+
});
|
|
673
|
+
const err = await stack.http.writeErr(
|
|
674
|
+
"custom-fields:write:update-tenant-field",
|
|
675
|
+
{
|
|
676
|
+
entityName: "property",
|
|
677
|
+
fieldKey: "secret",
|
|
678
|
+
serializedField: { type: "text" },
|
|
679
|
+
required: true,
|
|
680
|
+
searchable: false,
|
|
681
|
+
displayOrder: 0,
|
|
682
|
+
},
|
|
683
|
+
otherAdmin,
|
|
684
|
+
);
|
|
685
|
+
// aggregate-id deriviert aus otherAdmin.tenantId → trifft nichts.
|
|
686
|
+
expect(err.httpStatus).toBe(404);
|
|
687
|
+
const row = await fetchDefinitionRow(admin.tenantId, "secret");
|
|
688
|
+
expect(row?.["required"]).toBe(false);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("update-tenant-field rejects a caller whose tenant IS the system tenant", async () => {
|
|
692
|
+
const systemScopedAdmin = createTestUser({
|
|
693
|
+
roles: ["TenantAdmin"],
|
|
694
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
695
|
+
});
|
|
696
|
+
const err = await stack.http.writeErr(
|
|
697
|
+
"custom-fields:write:update-tenant-field",
|
|
698
|
+
{
|
|
699
|
+
entityName: "property",
|
|
700
|
+
fieldKey: "leaky",
|
|
701
|
+
serializedField: { type: "text" },
|
|
702
|
+
required: false,
|
|
703
|
+
searchable: false,
|
|
704
|
+
displayOrder: 0,
|
|
705
|
+
},
|
|
706
|
+
systemScopedAdmin,
|
|
707
|
+
);
|
|
708
|
+
// Guard wirft plain Error → 500; Message pinnen (wie der define-Guard-Test).
|
|
709
|
+
expect(err.httpStatus).toBe(500);
|
|
710
|
+
const causeMessage = (err.details as { causeMessage?: string } | undefined)?.causeMessage ?? "";
|
|
711
|
+
expect(causeMessage).toContain("update-tenant-field");
|
|
712
|
+
});
|
|
713
|
+
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// 2. define-tenant-field / define-system-field — RBAC write-handlers für
|
|
7
7
|
// Definition-CRUD.
|
|
8
8
|
// 3. delete-tenant-field / delete-system-field — RBAC write-handlers.
|
|
9
|
+
// 3b. update-tenant-field — Vollersatz-Edit (Bug-Bash D2); type immutable.
|
|
9
10
|
// 4. set-custom-field / clear-custom-field — write-handlers für VALUES.
|
|
10
11
|
// Emittieren customField.set/.cleared-Events auf host-aggregate-stream.
|
|
11
12
|
// 5. r.defineEvent für customField.set/.cleared + fieldDefinition.deleted.
|
|
@@ -62,6 +63,7 @@ import {
|
|
|
62
63
|
import { deleteSystemFieldHandler } from "./handlers/delete-system-field.write";
|
|
63
64
|
import { deleteTenantFieldHandler } from "./handlers/delete-tenant-field.write";
|
|
64
65
|
import { setCustomFieldHandler } from "./handlers/set-custom-field.write";
|
|
66
|
+
import { updateTenantFieldHandler } from "./handlers/update-tenant-field.write";
|
|
65
67
|
|
|
66
68
|
const tenantAdminAccess = { access: { roles: ["TenantAdmin"] } } as const;
|
|
67
69
|
|
|
@@ -85,7 +87,7 @@ function registerCustomFields(
|
|
|
85
87
|
defineTenantHandler: WriteHandlerDef,
|
|
86
88
|
) {
|
|
87
89
|
r.describe(
|
|
88
|
-
"Tenant- and system-scoped custom field definitions with generic value storage on any host entity. Registers the `field-definition` entity (event-sourced CRUD via `define-tenant-field`, `define-system-field`, `delete-tenant-field`, `delete-system-field`) and two value write-handlers (`set-custom-field`, `clear-custom-field`) that emit `custom-fields:event:custom-field-set` / `custom-fields:event:custom-field-cleared` events on the host aggregate's stream. To attach custom fields to your own entity, call `wireCustomFieldsFor(r, entityName, entityTable)` in the host feature — this wires the JSONB projection, `postQuery` flattening hook, and search-payload extension. The host entity must declare a `customFieldsField()` JSONB column.",
|
|
90
|
+
"Tenant- and system-scoped custom field definitions with generic value storage on any host entity. Registers the `field-definition` entity (event-sourced CRUD via `define-tenant-field`, `define-system-field`, `update-tenant-field`, `delete-tenant-field`, `delete-system-field`) and two value write-handlers (`set-custom-field`, `clear-custom-field`) that emit `custom-fields:event:custom-field-set` / `custom-fields:event:custom-field-cleared` events on the host aggregate's stream. To attach custom fields to your own entity, call `wireCustomFieldsFor(r, entityName, entityTable)` in the host feature — this wires the JSONB projection, `postQuery` flattening hook, and search-payload extension. The host entity must declare a `customFieldsField()` JSONB column.",
|
|
89
91
|
);
|
|
90
92
|
r.entity("field-definition", fieldDefinitionEntity);
|
|
91
93
|
|
|
@@ -110,9 +112,10 @@ function registerCustomFields(
|
|
|
110
112
|
// explicit-wiring statt magic-auto-wiring.
|
|
111
113
|
r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
|
|
112
114
|
|
|
113
|
-
// Definition-CRUD handlers (B1).
|
|
115
|
+
// Definition-CRUD handlers (B1; update kam mit Bug-Bash D2 2026-06-08).
|
|
114
116
|
r.writeHandler(defineTenantHandler);
|
|
115
117
|
r.writeHandler(defineSystemFieldHandler);
|
|
118
|
+
r.writeHandler(updateTenantFieldHandler);
|
|
116
119
|
r.writeHandler(deleteTenantFieldHandler);
|
|
117
120
|
r.writeHandler(deleteSystemFieldHandler);
|
|
118
121
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
4
|
+
import { fieldDefinitionExecutor } from "../executor";
|
|
5
|
+
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
6
|
+
import { type UpdateFieldPayload, updateFieldPayloadSchema } from "../schemas";
|
|
7
|
+
|
|
8
|
+
// update-tenant-field — TenantAdmin ersetzt den Stand einer bestehenden
|
|
9
|
+
// Field-Definition (Vollersatz: Payload-Shape = define, der Edit-Screen
|
|
10
|
+
// schickt den kompletten neuen Stand). Identität ist (entityName, fieldKey)
|
|
11
|
+
// → deterministische aggregate-id wie bei define/delete; tenantId kommt
|
|
12
|
+
// aus event.user (Cross-Tenant-Mutation unmöglich, fremde Definitionen
|
|
13
|
+
// derivieren auf eine andere aggregate-id → 404).
|
|
14
|
+
//
|
|
15
|
+
// **type ist immutable.** Ein Type-Wechsel würde bestehende Values in den
|
|
16
|
+
// host-entity customFields-jsonbs verwaisen (text-Wert unter number-Feld);
|
|
17
|
+
// dafür ist delete + re-define der ehrliche Weg (Bug-Bash D2: bewusst KEIN
|
|
18
|
+
// delete+redefine im update — das würde Event-Historie + Field-Ids
|
|
19
|
+
// zerstören, aber ein Type-Wechsel will genau diese Zäsur).
|
|
20
|
+
//
|
|
21
|
+
// **skipOptimisticLock:** Definition-Edits sind admin-only + low-frequency
|
|
22
|
+
// (gleiche Abwägung wie der Quota-soft-cap in define). Last-write-wins
|
|
23
|
+
// statt version-Roundtrip durch den Edit-Screen.
|
|
24
|
+
export const updateTenantFieldHandler: WriteHandlerDef = {
|
|
25
|
+
name: "update-tenant-field",
|
|
26
|
+
schema: updateFieldPayloadSchema,
|
|
27
|
+
access: { roles: ["TenantAdmin"] },
|
|
28
|
+
handler: async (event, ctx) => {
|
|
29
|
+
const payload = event.payload as UpdateFieldPayload; // @cast-boundary engine-payload
|
|
30
|
+
const tenantId = event.user.tenantId;
|
|
31
|
+
|
|
32
|
+
if (isSystemTenant(tenantId)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"update-tenant-field: tenantId is SYSTEM_TENANT_ID — system-scope definitions have no update handler (delete + re-define via the system-field handlers)",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
|
|
39
|
+
|
|
40
|
+
const existing = await fieldDefinitionExecutor.detail({ id: aggregateId }, event.user, ctx.db);
|
|
41
|
+
if (!existing) {
|
|
42
|
+
return failNotFound("field-definition", aggregateId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (existing["type"] !== payload.serializedField.type) {
|
|
46
|
+
return failUnprocessable("field_type_immutable", {
|
|
47
|
+
entityName: payload.entityName,
|
|
48
|
+
fieldKey: payload.fieldKey,
|
|
49
|
+
currentType: existing["type"],
|
|
50
|
+
requestedType: payload.serializedField.type,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// entityName/fieldKey sind die Identität — nicht Teil der changes.
|
|
55
|
+
const {
|
|
56
|
+
entityName: _entityName,
|
|
57
|
+
fieldKey: _fieldKey,
|
|
58
|
+
...changes
|
|
59
|
+
} = buildFieldDefinitionColumns(payload);
|
|
60
|
+
|
|
61
|
+
return fieldDefinitionExecutor.update({ id: aggregateId, changes }, event.user, ctx.db, {
|
|
62
|
+
skipOptimisticLock: true,
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -83,6 +83,13 @@ export const defineFieldPayloadSchema = z.object({
|
|
|
83
83
|
});
|
|
84
84
|
export type DefineFieldPayload = z.infer<typeof defineFieldPayloadSchema>;
|
|
85
85
|
|
|
86
|
+
// Payload für `update-tenant-field` — bewusst dieselbe Shape wie define
|
|
87
|
+
// (Vollersatz-Semantik: der Edit-Screen schickt den kompletten neuen Stand;
|
|
88
|
+
// entityName+fieldKey sind die Identität, type ist immutable und wird im
|
|
89
|
+
// Handler gegen den Bestand geprüft).
|
|
90
|
+
export const updateFieldPayloadSchema = defineFieldPayloadSchema;
|
|
91
|
+
export type UpdateFieldPayload = DefineFieldPayload;
|
|
92
|
+
|
|
86
93
|
// Payload für `delete-tenant-field` / `delete-system-field`.
|
|
87
94
|
export const deleteFieldPayloadSchema = z.object({
|
|
88
95
|
entityName: z.string().min(1).max(64),
|