@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.358.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.358.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 — TenantAdmin ersetzt den Stand einer bestehenden
10
- // Field-Definition (Vollersatz: Payload-Shape = define, der Edit-Screen
11
- // schickt den kompletten neuen Stand). Identität ist (entityName, fieldKey)
12
- // → deterministische aggregate-id wie bei define/delete; tenantId kommt
13
- // aus event.user (Cross-Tenant-Mutation unmöglich, fremde Definitionen
14
- // derivieren auf eine andere aggregate-id → 404).
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 ist immutable.** Ein Type-Wechsel würde bestehende Values in den
17
- // host-entity customFields-jsonbs verwaisen (text-Wert unter number-Feld);
18
- // dafür ist delete + re-define der ehrliche Weg (Bug-Bash D2: bewusst KEIN
19
- // delete+redefine im update — das würde Event-Historie + Field-Ids
20
- // zerstören, aber ein Type-Wechsel will genau diese Zäsur).
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
- // **Bekannte MVP-Grenze (bewusst):** der Edit reconciled bestehende
23
- // Host-Werte NICHT gegen die neue Definition — Constraint-Narrowing
24
- // (enum-Wert weg, min/max enger) lässt alte Werte still non-conformant,
25
- // required false→true macht Bestands-Rows unvollständig, searchable-Toggle
26
- // re-indexed nicht. Werte werden beim NÄCHSTEN Write der Host-Row gegen
27
- // die aktuelle Def validiert; eine Reject-mit-Konflikt-Liste-Variante
28
- // wäre der Ausbau, wenn der Bedarf real wird.
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:** Definition-Edits sind admin-only + low-frequency
31
- // (gleiche Abwägung wie der Quota-soft-cap in define). Last-write-wins
32
- // statt version-Roundtrip durch den Edit-Screen.
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 while refusing any change to the field's type; use it to relabel or re-constrain an existing field rather than to create or remove one.",
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
- if (existing["type"] !== payload.serializedField.type) {
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: existing["type"],
78
- requestedType: payload.serializedField.type,
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(payload);
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
- (v) => !("sensitive" in v),
61
- "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.",
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 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;
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({