@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 +4 -4
- package/src/changes.json +6 -0
- package/src/engine/__tests__/entity-exclude-fields.integration.test.ts +134 -0
- package/src/engine/entity-handlers.ts +57 -10
- package/src/engine/schema-builder.ts +17 -0
- package/src/engine/types/index.ts +1 -0
- package/src/ui-types/formality.ts +7 -0
- package/src/ui-types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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:
|
|
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:
|
|
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:
|
|
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):
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
+
}
|
package/src/ui-types/index.ts
CHANGED