@cosmicdrift/kumiko-framework 0.317.0 → 0.318.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-framework",
3
- "version": "0.317.0",
3
+ "version": "0.318.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.317.0",
202
- "@cosmicdrift/kumiko-types": "0.317.0",
201
+ "@cosmicdrift/kumiko-http": "0.318.0",
202
+ "@cosmicdrift/kumiko-types": "0.318.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.317.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.318.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.318.0",
4
+ "type": "improvement",
5
+ "title": "Generic create/update entity handlers can exclude individual fields",
6
+ "detail": "`excludeFields` on defineEntityCreateHandler/defineEntityUpdateHandler, and `excludeFields: { create?, update? }` on registerEntityCrud/r.crud. A payload that still carries an excluded field fails validation instead of being stripped silently, so an app no longer needs a hand-written handler just to make one field read-only (e.g. a VIN after creation)."
7
+ },
2
8
  {
3
9
  "version": "0.317.0",
4
10
  "type": "fix",
@@ -0,0 +1,134 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { asRawClient } from "../../db/query";
3
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
4
+ import { defineFeature } from "../define-feature";
5
+ import { defineEntityUpdateHandler, defineEntityWriteHandler } from "../entity-handlers";
6
+ import { createEntity, createTextField } from "../factories";
7
+
8
+ const carEntity = createEntity({
9
+ table: "exclude_fields_cars",
10
+ fields: {
11
+ vin: createTextField({ required: true, personal: false, reason: "test_fixture" }),
12
+ model: createTextField({ personal: false, reason: "test_fixture" }),
13
+ internalNote: createTextField({ personal: false, reason: "test_fixture" }),
14
+ },
15
+ });
16
+
17
+ const carFeature = defineFeature("exclude-fields", (r) => {
18
+ r.crud("car", carEntity, {
19
+ write: { access: { roles: ["User"] } },
20
+ read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
21
+ excludeFields: { create: ["internalNote"], update: ["vin"] },
22
+ });
23
+ });
24
+
25
+ const CREATE = "exclude-fields:write:car:create";
26
+ const UPDATE = "exclude-fields:write:car:update";
27
+
28
+ type WriteBody = {
29
+ readonly isSuccess: boolean;
30
+ readonly data?: { readonly data: Record<string, unknown> & { id: string; version: number } };
31
+ readonly error?: {
32
+ readonly code: string;
33
+ readonly details?: { readonly fields?: readonly { readonly path: string }[] };
34
+ };
35
+ };
36
+
37
+ describe("excludeFields on the generic create/update handlers", () => {
38
+ let stack: TestStack;
39
+
40
+ beforeAll(async () => {
41
+ stack = await setupTestStack({ features: [carFeature] });
42
+ await unsafeCreateEntityTable(stack.db, carEntity);
43
+ });
44
+
45
+ afterAll(async () => {
46
+ await stack.cleanup();
47
+ });
48
+
49
+ beforeEach(async () => {
50
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
51
+ await asRawClient(stack.db).unsafe('DELETE FROM "exclude_fields_cars"');
52
+ });
53
+
54
+ async function createCar(): Promise<{ id: string; version: number }> {
55
+ const res = await stack.http.write(CREATE, { vin: "WVW123", model: "Golf" }, TestUsers.user);
56
+ expect(res.status).toBe(200);
57
+ const body = (await res.json()) as WriteBody;
58
+ if (!body.data) throw new Error("create returned no data");
59
+ return body.data.data;
60
+ }
61
+
62
+ test("update rejects a payload that still carries an excluded field and keeps the row unchanged", async () => {
63
+ const car = await createCar();
64
+
65
+ const res = await stack.http.write(
66
+ UPDATE,
67
+ { id: car.id, version: car.version, changes: { vin: "HACKED", model: "Polo" } },
68
+ TestUsers.user,
69
+ );
70
+ expect(res.status).toBe(400);
71
+ const body = (await res.json()) as WriteBody;
72
+ expect(body.error?.code).toBe("validation_error");
73
+ expect(body.error?.details?.fields?.map((field) => field.path)).toEqual(["changes.vin"]);
74
+
75
+ const rows = await asRawClient(stack.db).unsafe(
76
+ 'SELECT vin, model FROM "exclude_fields_cars" WHERE id = $1',
77
+ [car.id],
78
+ );
79
+ expect(rows[0]).toMatchObject({ vin: "WVW123", model: "Golf" });
80
+ });
81
+
82
+ test("update without the excluded field writes the other fields", async () => {
83
+ const car = await createCar();
84
+
85
+ const res = await stack.http.write(
86
+ UPDATE,
87
+ { id: car.id, version: car.version, changes: { model: "Polo" } },
88
+ TestUsers.user,
89
+ );
90
+ expect(res.status).toBe(200);
91
+ const body = (await res.json()) as WriteBody;
92
+ expect(body.data?.data).toMatchObject({ vin: "WVW123", model: "Polo" });
93
+ });
94
+
95
+ test("create honors its own exclusion list independently of update's", async () => {
96
+ const rejected = await stack.http.write(
97
+ CREATE,
98
+ { vin: "WVW999", internalNote: "set by client" },
99
+ TestUsers.user,
100
+ );
101
+ expect(rejected.status).toBe(400);
102
+ const body = (await rejected.json()) as WriteBody;
103
+ expect(body.error?.details?.fields?.map((field) => field.path)).toEqual(["internalNote"]);
104
+ });
105
+ });
106
+
107
+ describe("excludeFields definition-time checks", () => {
108
+ test("rejects an unknown field name", () => {
109
+ expect(() =>
110
+ defineEntityUpdateHandler("car", carEntity, {
111
+ access: { roles: ["User"] },
112
+ excludeFields: ["colour"],
113
+ }),
114
+ ).toThrow(/unknown field "colour"/);
115
+ });
116
+
117
+ test("rejects excluding a required field without default on create", () => {
118
+ expect(() =>
119
+ defineEntityWriteHandler("car:create", carEntity, {
120
+ access: { roles: ["User"] },
121
+ excludeFields: ["vin"],
122
+ }),
123
+ ).toThrow(/required without a default/);
124
+ });
125
+
126
+ test("rejects excludeFields on delete", () => {
127
+ expect(() =>
128
+ defineEntityWriteHandler("car:delete", carEntity, {
129
+ access: { roles: ["User"] },
130
+ excludeFields: ["model"],
131
+ }),
132
+ ).toThrow(/only applies to create and update/);
133
+ });
134
+ });
@@ -29,6 +29,7 @@ import type {
29
29
  EntityCrudVerb,
30
30
  EntityHandlerOptions,
31
31
  EntityQueryHandlerOptions,
32
+ EntityWriteHandlerOptions,
32
33
  RegisterEntityCrudOptions,
33
34
  } from "./types/entity-handlers";
34
35
 
@@ -38,6 +39,7 @@ export type {
38
39
  EntityCrudVerb,
39
40
  EntityHandlerOptions,
40
41
  EntityQueryHandlerOptions,
42
+ EntityWriteHandlerOptions,
41
43
  RegisterEntityCrudOptions,
42
44
  } from "./types/entity-handlers";
43
45
 
@@ -202,10 +204,48 @@ function parseHandlerName<TVerb extends string>(
202
204
  return { entityName, verb: verbCandidate as TVerb }; // @cast-boundary engine-bridge
203
205
  }
204
206
 
207
+ function isInTotalsMatchPair(entity: EntityDefinition, fieldName: string): boolean {
208
+ return Object.entries(entity.fields).some(
209
+ ([name, field]) =>
210
+ field.type === "embedded" &&
211
+ field.totalsMatch !== undefined &&
212
+ (name === fieldName || Object.values(field.totalsMatch).includes(fieldName)),
213
+ );
214
+ }
215
+
216
+ function assertExcludableFields(
217
+ name: string,
218
+ verb: (typeof WRITE_VERBS)[number],
219
+ entity: EntityDefinition,
220
+ excludedFields: readonly string[],
221
+ ): void {
222
+ if (excludedFields.length > 0 && verb !== "create" && verb !== "update") {
223
+ throw new Error(`"${name}": excludeFields only applies to create and update handlers.`);
224
+ }
225
+ for (const fieldName of excludedFields) {
226
+ const field = entity.fields[fieldName];
227
+ if (!field) {
228
+ throw new Error(`"${name}": excludeFields names unknown field "${fieldName}".`);
229
+ }
230
+ if (isInTotalsMatchPair(entity, fieldName)) {
231
+ throw new Error(
232
+ `"${name}": excludeFields cannot name "${fieldName}" — it is part of a totalsMatch pair, which must be written together.`,
233
+ );
234
+ }
235
+ const isRequired = "required" in field && field.required === true;
236
+ const hasDefault = "default" in field && field.default !== undefined;
237
+ if (verb === "create" && isRequired && !hasDefault) {
238
+ throw new Error(
239
+ `"${name}": excludeFields cannot name "${fieldName}" — it is required without a default, so no row could ever be created.`,
240
+ );
241
+ }
242
+ }
243
+ }
244
+
205
245
  export function defineEntityWriteHandler(
206
246
  name: string,
207
247
  entity: EntityDefinition,
208
- options: EntityHandlerOptions,
248
+ options: EntityWriteHandlerOptions,
209
249
  ): WriteHandlerDef {
210
250
  const { entityName, verb } = parseHandlerName(name, WRITE_VERBS);
211
251
  if (verb === "restore" && !entity.softDelete) {
@@ -213,6 +253,8 @@ export function defineEntityWriteHandler(
213
253
  `"${name}": restore is only valid for entities declared with softDelete: true.`,
214
254
  );
215
255
  }
256
+ const excludedFields = options.excludeFields ?? [];
257
+ assertExcludableFields(name, verb, entity, excludedFields);
216
258
 
217
259
  const table = buildEntityTable(entityName, entity);
218
260
  const executor = createEventStoreExecutor(table, entity, { entityName });
@@ -263,7 +305,7 @@ export function defineEntityWriteHandler(
263
305
 
264
306
  switch (verb) {
265
307
  case "create":
266
- schema = buildInsertSchema(entity);
308
+ schema = buildInsertSchema(entity, undefined, excludedFields);
267
309
  handler = async (event, ctx) => {
268
310
  const { runPreSave } = ctx;
269
311
  // A caller-chosen id (deterministic-id idempotent creates) is only
@@ -284,7 +326,7 @@ export function defineEntityWriteHandler(
284
326
  schema = z.object({
285
327
  id: z.uuid(),
286
328
  version: z.number(),
287
- changes: buildUpdateSchema(entity),
329
+ changes: buildUpdateSchema(entity, undefined, excludedFields),
288
330
  });
289
331
  handler = async (event, ctx) => {
290
332
  const { runPreSave } = ctx;
@@ -507,7 +549,7 @@ export function defineEntityQueryHandler(
507
549
  export function defineEntityCreateHandler(
508
550
  entityName: string,
509
551
  entity: EntityDefinition,
510
- options: EntityHandlerOptions,
552
+ options: EntityWriteHandlerOptions,
511
553
  ): WriteHandlerDef {
512
554
  return defineEntityWriteHandler(`${entityName}:create`, entity, options);
513
555
  }
@@ -516,7 +558,7 @@ export function defineEntityCreateHandler(
516
558
  export function defineEntityUpdateHandler(
517
559
  entityName: string,
518
560
  entity: EntityDefinition,
519
- options: EntityHandlerOptions,
561
+ options: EntityWriteHandlerOptions,
520
562
  ): WriteHandlerDef {
521
563
  return defineEntityWriteHandler(`${entityName}:update`, entity, options);
522
564
  }
@@ -651,11 +693,16 @@ export function registerEntityCrud(
651
693
  }
652
694
  return access;
653
695
  };
654
- const resolveWriteOpts = (verb: EntityCrudVerb): EntityHandlerOptions => ({
655
- ...writeOpts,
656
- access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
657
- description: options?.descriptions?.[verb] ?? writeOpts?.description,
658
- });
696
+ const resolveWriteOpts = (verb: EntityCrudVerb): EntityWriteHandlerOptions => {
697
+ const excludeFields =
698
+ verb === "create" || verb === "update" ? options?.excludeFields?.[verb] : undefined;
699
+ return {
700
+ ...writeOpts,
701
+ access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
702
+ description: options?.descriptions?.[verb] ?? writeOpts?.description,
703
+ ...(excludeFields !== undefined && { excludeFields }),
704
+ };
705
+ };
659
706
  const resolveReadOpts = (verb: EntityCrudVerb): EntityQueryHandlerOptions => ({
660
707
  ...readOpts,
661
708
  access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
@@ -367,13 +367,25 @@ function applyTotalsMatchRefinements(
367
367
  return result;
368
368
  }
369
369
 
370
+ // An excluded field stays in the shape as "must be absent" instead of being
371
+ // dropped: zod strips unknown keys silently, so a client still sending it
372
+ // would otherwise get a success without the write happening.
373
+ function notWritableField(): z.ZodTypeAny {
374
+ return z.never().optional();
375
+ }
376
+
370
377
  export function buildInsertSchema(
371
378
  entity: EntityDefinition,
372
379
  currencies: readonly string[] = [...DEFAULT_CURRENCIES],
380
+ excludedFields: readonly string[] = [],
373
381
  ): z.ZodObject<Record<string, z.ZodTypeAny>> {
374
382
  const shape: Record<string, z.ZodTypeAny> = {};
375
383
 
376
384
  for (const [name, field] of Object.entries(entity.fields)) {
385
+ if (excludedFields.includes(name)) {
386
+ shape[name] = notWritableField();
387
+ continue;
388
+ }
377
389
  const zodField = fieldToZod(field, currencies);
378
390
  const hasDefault = "default" in field && field.default !== undefined;
379
391
  const isRequired = "required" in field && field.required === true;
@@ -392,10 +404,15 @@ export function buildInsertSchema(
392
404
  export function buildUpdateSchema(
393
405
  entity: EntityDefinition,
394
406
  currencies: readonly string[] = [...DEFAULT_CURRENCIES],
407
+ excludedFields: readonly string[] = [],
395
408
  ): z.ZodObject<Record<string, z.ZodTypeAny>> {
396
409
  const shape: Record<string, z.ZodTypeAny> = {};
397
410
 
398
411
  for (const [name, field] of Object.entries(entity.fields)) {
412
+ if (excludedFields.includes(name)) {
413
+ shape[name] = notWritableField();
414
+ continue;
415
+ }
399
416
  // Update schemas never apply defaults for OMITTED fields — a user that
400
417
  // sends only `{ title }` means "only change title"; zod defaults would
401
418
  // silently inject default values for every omitted field and clobber
@@ -63,6 +63,7 @@ export type {
63
63
  EntityCrudVerb,
64
64
  EntityHandlerOptions,
65
65
  EntityQueryHandlerOptions,
66
+ EntityWriteHandlerOptions,
66
67
  RegisterEntityCrudOptions,
67
68
  } from "@cosmicdrift/kumiko-types/entity-handlers";
68
69
  // Cross-Feature Compile-Time-Type-Map — features extend per declare-module.
@@ -0,0 +1,7 @@
1
+ export type Formality = "formal" | "informal";
2
+
3
+ // BCP-47 private-use subtag: a translation bundle keyed "de-x-formal" holds the
4
+ // formal ("Sie") wording and only needs the keys that differ from plain "de".
5
+ export function formalLocaleTag<Locale extends string>(locale: Locale): `${Locale}-x-formal` {
6
+ return `${locale}-x-formal`;
7
+ }
@@ -137,6 +137,7 @@ export type {
137
137
  QualifiedContentCollection,
138
138
  WorkspaceSchema,
139
139
  } from "./app-schema";
140
+ export { type Formality, formalLocaleTag } from "./formality";
140
141
  export type {
141
142
  ListRowMetaColumnType,
142
143
  ListRowMetaReference,