@cosmicdrift/kumiko-samples 0.358.0 → 0.359.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 +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +39 -27
- package/packages/bundled-features/src/custom-fields/schemas.ts +38 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.359.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.359.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>",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.359.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "update-tenant-field keeps the stored type when serializedField.type is omitted",
|
|
6
|
+
"detail": "custom-fields: update-tenant-field accepts serializedField without type and keeps the stored type; a different type is still refused with 422 field_type_immutable. The handler declares excludedFields [\"type\"]."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.338.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -4,32 +4,33 @@ import { fieldDefinitionAggregateId } from "../aggregate-id.js";
|
|
|
4
4
|
import { DEFAULT_FIELD_DEFINITION_WRITE_ROLES } from "../constants.js";
|
|
5
5
|
import { fieldDefinitionExecutor } from "../executor.js";
|
|
6
6
|
import { buildFieldDefinitionColumns } from "../lib/field-definition-row.js";
|
|
7
|
-
import { type UpdateFieldPayload, updateFieldPayloadSchema } from "../schemas.js";
|
|
7
|
+
import { fieldTypeSchema, type UpdateFieldPayload, updateFieldPayloadSchema } from "../schemas.js";
|
|
8
8
|
|
|
9
|
-
// update-tenant-field
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
9
|
+
// update-tenant-field: a TenantAdmin replaces the state of an existing field
|
|
10
|
+
// definition (full replacement: payload shape = define except that
|
|
11
|
+
// `serializedField.type` may be omitted). Identity is (entityName, fieldKey),
|
|
12
|
+
// giving the same deterministic aggregate id as define/delete; tenantId comes
|
|
13
|
+
// from event.user, so a cross-tenant mutation is impossible (a foreign
|
|
14
|
+
// definition derives a different aggregate id and yields 404).
|
|
15
15
|
//
|
|
16
|
-
// **type
|
|
17
|
-
// host
|
|
18
|
-
//
|
|
19
|
-
// delete+redefine
|
|
20
|
-
//
|
|
16
|
+
// **type is immutable**; an omitted type keeps the stored one. A type change
|
|
17
|
+
// would orphan existing values in the host entities' customFields jsonb (a
|
|
18
|
+
// text value under a number field), so delete + re-define is the honest path
|
|
19
|
+
// (Bug-Bash D2: deliberately no delete+redefine inside update, since that
|
|
20
|
+
// destroys event history and field ids, which is exactly the break a type
|
|
21
|
+
// change needs to make visible).
|
|
21
22
|
//
|
|
22
|
-
// **
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// re-
|
|
27
|
-
//
|
|
28
|
-
//
|
|
23
|
+
// **Known MVP limit (deliberate):** the edit does NOT reconcile existing host
|
|
24
|
+
// values against the new definition. Narrowed constraints (enum value removed,
|
|
25
|
+
// tighter min/max) leave old values silently non-conformant, required
|
|
26
|
+
// false→true leaves existing rows incomplete, and toggling searchable does not
|
|
27
|
+
// re-index. Values are validated against the current definition on the NEXT
|
|
28
|
+
// write of the host row; a reject-with-conflict-list variant is the upgrade
|
|
29
|
+
// once the need is real.
|
|
29
30
|
//
|
|
30
|
-
// **skipOptimisticLock:**
|
|
31
|
-
// (
|
|
32
|
-
//
|
|
31
|
+
// **skipOptimisticLock:** definition edits are admin-only and low-frequency
|
|
32
|
+
// (same trade-off as the quota soft cap in define), so last write wins instead
|
|
33
|
+
// of a version round trip through the edit screen.
|
|
33
34
|
export interface UpdateTenantFieldOptions {
|
|
34
35
|
/** Access roles for this handler. Default ["TenantAdmin"] (#2296) — see
|
|
35
36
|
* DefineTenantFieldOptions.roles, the definition-CRUD triad shares one gate. */
|
|
@@ -44,7 +45,8 @@ export function createUpdateTenantFieldHandler(
|
|
|
44
45
|
schema: updateFieldPayloadSchema,
|
|
45
46
|
access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
|
|
46
47
|
description:
|
|
47
|
-
"Replaces a tenant-owned custom-field definition with a complete new spec
|
|
48
|
+
"Replaces a tenant-owned custom-field definition with a complete new spec; serializedField.type may be omitted, in which case the stored type applies, and a different type is refused. Use it to relabel or re-constrain an existing field rather than to create or remove one.",
|
|
49
|
+
excludedFields: ["type"],
|
|
48
50
|
handler: async (event, ctx) => {
|
|
49
51
|
const payload = event.payload as UpdateFieldPayload; // @cast-boundary engine-payload
|
|
50
52
|
const tenantId = event.user.tenantId;
|
|
@@ -70,12 +72,19 @@ export function createUpdateTenantFieldHandler(
|
|
|
70
72
|
return failNotFound("field-definition", aggregateId);
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
const storedType = fieldTypeSchema.safeParse(existing["type"]);
|
|
76
|
+
if (!storedType.success) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`update-tenant-field: stored definition ${aggregateId} has an invalid type`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const requestedType = payload.serializedField.type;
|
|
82
|
+
if (requestedType !== undefined && requestedType !== storedType.data) {
|
|
74
83
|
return failUnprocessable("field_type_immutable", {
|
|
75
84
|
entityName: payload.entityName,
|
|
76
85
|
fieldKey: payload.fieldKey,
|
|
77
|
-
currentType:
|
|
78
|
-
requestedType
|
|
86
|
+
currentType: storedType.data,
|
|
87
|
+
requestedType,
|
|
79
88
|
});
|
|
80
89
|
}
|
|
81
90
|
|
|
@@ -84,7 +93,10 @@ export function createUpdateTenantFieldHandler(
|
|
|
84
93
|
entityName: _entityName,
|
|
85
94
|
fieldKey: _fieldKey,
|
|
86
95
|
...changes
|
|
87
|
-
} = buildFieldDefinitionColumns(
|
|
96
|
+
} = buildFieldDefinitionColumns({
|
|
97
|
+
...payload,
|
|
98
|
+
serializedField: { ...payload.serializedField, type: storedType.data },
|
|
99
|
+
});
|
|
88
100
|
|
|
89
101
|
return fieldDefinitionExecutor.update({ id: aggregateId, changes }, event.user, ctx.db, {
|
|
90
102
|
skipOptimisticLock: true,
|
|
@@ -2,7 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
import { SUPPORTED_FIELD_TYPES } from "./constants.js";
|
|
3
3
|
|
|
4
4
|
// Field-Type-Validator — pinnt valid type-Werte für fieldDefinition.
|
|
5
|
-
const fieldTypeSchema = z.enum(SUPPORTED_FIELD_TYPES);
|
|
5
|
+
export const fieldTypeSchema = z.enum(SUPPORTED_FIELD_TYPES);
|
|
6
6
|
|
|
7
7
|
// Per-field access-control. When set, `set/clear-custom-field` handlers
|
|
8
8
|
// require the calling user to hold at least one of the listed roles —
|
|
@@ -37,29 +37,37 @@ export type CustomFieldAccess = z.infer<typeof customFieldAccessSchema>;
|
|
|
37
37
|
// Everything else stays loose pending B2's per-type discriminated-union —
|
|
38
38
|
// EXCEPT `sensitive`, which is explicitly rejected below: custom fields
|
|
39
39
|
// carry supplemental business data only, PII support was removed (#972).
|
|
40
|
+
const serializedFieldBaseShape = {
|
|
41
|
+
fieldAccess: customFieldAccessSchema,
|
|
42
|
+
// `retention` (T1.5d): per-field expiry. The retention-cron strips
|
|
43
|
+
// values whose host-row `modified_at` is older than `keepFor`. Strategy
|
|
44
|
+
// `delete` removes the key from the customFields jsonb; `anonymize`
|
|
45
|
+
// sets it to `null` (key stays, value gone — preserves the schema
|
|
46
|
+
// shape for downstream consumers).
|
|
47
|
+
retention: z
|
|
48
|
+
.object({
|
|
49
|
+
keepFor: z
|
|
50
|
+
.string()
|
|
51
|
+
.regex(/^\d+[hdwmy]$/, "keepFor must match /^\\d+[hdwmy]$/ (e.g. '30d', '10y')"),
|
|
52
|
+
strategy: z.enum(["delete", "anonymize"]),
|
|
53
|
+
})
|
|
54
|
+
.optional(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const rejectSensitive = [
|
|
58
|
+
(v: Record<string, unknown>) => !("sensitive" in v),
|
|
59
|
+
"custom fields don't support PII — `sensitive` was removed (#972). Model personal data as a schema entity field with a pii/userOwned/tenantOwned annotation instead.",
|
|
60
|
+
] as const;
|
|
61
|
+
|
|
40
62
|
const serializedFieldSchema = z
|
|
41
|
-
.looseObject({
|
|
42
|
-
type: fieldTypeSchema,
|
|
43
|
-
fieldAccess: customFieldAccessSchema,
|
|
44
|
-
// `retention` (T1.5d): per-field expiry. The retention-cron strips
|
|
45
|
-
// values whose host-row `modified_at` is older than `keepFor`. Strategy
|
|
46
|
-
// `delete` removes the key from the customFields jsonb; `anonymize`
|
|
47
|
-
// sets it to `null` (key stays, value gone — preserves the schema
|
|
48
|
-
// shape for downstream consumers).
|
|
49
|
-
retention: z
|
|
50
|
-
.object({
|
|
51
|
-
keepFor: z
|
|
52
|
-
.string()
|
|
53
|
-
.regex(/^\d+[hdwmy]$/, "keepFor must match /^\\d+[hdwmy]$/ (e.g. '30d', '10y')"),
|
|
54
|
-
strategy: z.enum(["delete", "anonymize"]),
|
|
55
|
-
})
|
|
56
|
-
.optional(),
|
|
57
|
-
})
|
|
63
|
+
.looseObject({ type: fieldTypeSchema, ...serializedFieldBaseShape })
|
|
58
64
|
.refine((v) => typeof v["type"] === "string", "serializedField must have a string `type`")
|
|
59
|
-
.refine(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
.refine(...rejectSensitive);
|
|
66
|
+
|
|
67
|
+
// Update may omit `type`: it is immutable, so the handler takes it from the stored definition.
|
|
68
|
+
const updateSerializedFieldSchema = z
|
|
69
|
+
.looseObject({ type: fieldTypeSchema.optional(), ...serializedFieldBaseShape })
|
|
70
|
+
.refine(...rejectSensitive);
|
|
63
71
|
|
|
64
72
|
// i18n-labels — `{ de: "...", en: "...", ... }`. Mindestens ein Eintrag.
|
|
65
73
|
const labelSchema = z.record(z.string().min(2).max(8), z.string().min(1));
|
|
@@ -83,12 +91,14 @@ export const defineFieldPayloadSchema = z.object({
|
|
|
83
91
|
});
|
|
84
92
|
export type DefineFieldPayload = z.infer<typeof defineFieldPayloadSchema>;
|
|
85
93
|
|
|
86
|
-
// Payload
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
export const updateFieldPayloadSchema = defineFieldPayloadSchema
|
|
91
|
-
|
|
94
|
+
// Payload for `update-tenant-field`: full replacement like define (the edit
|
|
95
|
+
// screen sends the complete new state; entityName+fieldKey are the identity),
|
|
96
|
+
// except that `serializedField.type` may be omitted. type is immutable; a
|
|
97
|
+
// differing value is refused in the handler.
|
|
98
|
+
export const updateFieldPayloadSchema = defineFieldPayloadSchema.extend({
|
|
99
|
+
serializedField: updateSerializedFieldSchema,
|
|
100
|
+
});
|
|
101
|
+
export type UpdateFieldPayload = z.infer<typeof updateFieldPayloadSchema>;
|
|
92
102
|
|
|
93
103
|
// Payload für `delete-tenant-field` / `delete-system-field`.
|
|
94
104
|
export const deleteFieldPayloadSchema = z.object({
|