@cosmicdrift/kumiko-framework 0.399.0 → 0.400.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.
Files changed (133) hide show
  1. package/agents/api/framework-engine.md +9 -3
  2. package/agents/api/framework-ui-types.md +10 -5
  3. package/agents/api/renderer-web.md +2 -1
  4. package/agents/api/types.md +242 -80
  5. package/agents/api.md +2 -2
  6. package/agents/examples/derived-fields-sql.md +251 -0
  7. package/agents/examples/documents.md +161 -0
  8. package/agents/examples/inspection-checklist.md +807 -0
  9. package/agents/examples.md +3 -0
  10. package/agents/features-index.md +2 -0
  11. package/agents/features.md +8 -0
  12. package/dist/api/entity-read-access.d.ts +2 -0
  13. package/dist/api/entity-read-access.js +46 -2
  14. package/dist/api/server-boot-checks.js +2 -2
  15. package/dist/api/server-routes.js +3 -1
  16. package/dist/db/derived-field-sql.d.ts +23 -0
  17. package/dist/db/derived-field-sql.js +115 -0
  18. package/dist/db/derived-related-sql.d.ts +31 -0
  19. package/dist/db/derived-related-sql.js +137 -0
  20. package/dist/db/driver-row-coercion.d.ts +1 -0
  21. package/dist/db/driver-row-coercion.js +13 -0
  22. package/dist/db/eagerload.d.ts +3 -1
  23. package/dist/db/eagerload.js +4 -6
  24. package/dist/db/event-store-executor-context.js +1 -1
  25. package/dist/db/event-store-executor-read-paging.js +1 -1
  26. package/dist/db/event-store-executor-read-search.d.ts +3 -0
  27. package/dist/db/event-store-executor-read-search.js +17 -9
  28. package/dist/db/event-store-executor-read-sort.d.ts +9 -2
  29. package/dist/db/event-store-executor-read-sort.js +27 -14
  30. package/dist/db/event-store-executor-read-where.d.ts +0 -5
  31. package/dist/db/event-store-executor-read-where.js +3 -9
  32. package/dist/db/event-store-executor-read.js +23 -4
  33. package/dist/db/event-store-executor-write-steps.d.ts +6 -1
  34. package/dist/db/event-store-executor-write-steps.js +20 -1
  35. package/dist/db/event-store-executor-write.js +8 -8
  36. package/dist/db/money.d.ts +4 -0
  37. package/dist/db/money.js +34 -29
  38. package/dist/db/parent-ref-clause.d.ts +1 -0
  39. package/dist/db/parent-ref-clause.js +18 -8
  40. package/dist/db/read-gate-clause.d.ts +7 -0
  41. package/dist/db/read-gate-clause.js +188 -0
  42. package/dist/db/read-ownership.d.ts +9 -0
  43. package/dist/db/read-ownership.js +12 -0
  44. package/dist/engine/boot-validator/boot-check.d.ts +3 -1
  45. package/dist/engine/boot-validator/boot-check.js +7 -11
  46. package/dist/engine/boot-validator/boot-context.d.ts +26 -0
  47. package/dist/engine/boot-validator/boot-context.js +57 -0
  48. package/dist/engine/boot-validator/boot-options.d.ts +33 -0
  49. package/dist/engine/boot-validator/boot-options.js +1 -0
  50. package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
  51. package/dist/engine/boot-validator/boot-phases-app.js +103 -0
  52. package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
  53. package/dist/engine/boot-validator/boot-phases.js +140 -0
  54. package/dist/engine/boot-validator/boot-report.d.ts +6 -0
  55. package/dist/engine/boot-validator/boot-report.js +47 -0
  56. package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
  57. package/dist/engine/boot-validator/boot-rule.js +14 -0
  58. package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
  59. package/dist/engine/boot-validator/boot-warnings.js +20 -0
  60. package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
  61. package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
  62. package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
  63. package/dist/engine/boot-validator/entity-handler.js +6 -3
  64. package/dist/engine/boot-validator/entity-list-screens.js +3 -1
  65. package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
  66. package/dist/engine/boot-validator/field-conditions.js +178 -0
  67. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
  68. package/dist/engine/boot-validator/i18n-keys.js +1 -3
  69. package/dist/engine/boot-validator/index.d.ts +5 -34
  70. package/dist/engine/boot-validator/index.js +24 -204
  71. package/dist/engine/boot-validator/pii-retention.js +2 -7
  72. package/dist/engine/boot-validator/projection-list-screens.js +19 -1
  73. package/dist/engine/boot-validator/read-gate.d.ts +2 -0
  74. package/dist/engine/boot-validator/read-gate.js +54 -0
  75. package/dist/engine/boot-validator/screens-forms.js +19 -6
  76. package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
  77. package/dist/engine/boot-validator/screens-lists.js +8 -0
  78. package/dist/engine/boot-validator/zod-shape.js +4 -2
  79. package/dist/engine/build-app-schema.js +11 -5
  80. package/dist/engine/embedded-derived.d.ts +8 -6
  81. package/dist/engine/embedded-derived.js +25 -7
  82. package/dist/engine/entity-handlers.d.ts +1 -1
  83. package/dist/engine/entity-handlers.js +17 -18
  84. package/dist/engine/entity-list-runtime-options.d.ts +2 -1
  85. package/dist/engine/entity-list-runtime-options.js +86 -5
  86. package/dist/engine/factories.d.ts +5 -5
  87. package/dist/engine/factories.js +3 -5
  88. package/dist/engine/field-access.d.ts +2 -0
  89. package/dist/engine/field-access.js +48 -1
  90. package/dist/engine/field-state.d.ts +12 -0
  91. package/dist/engine/field-state.js +67 -0
  92. package/dist/engine/handler-helpers.d.ts +3 -0
  93. package/dist/engine/handler-helpers.js +7 -1
  94. package/dist/engine/index.d.ts +2 -2
  95. package/dist/engine/index.js +2 -2
  96. package/dist/engine/ownership.d.ts +1 -1
  97. package/dist/engine/ownership.js +9 -5
  98. package/dist/engine/registry-state.d.ts +1 -0
  99. package/dist/engine/registry-state.js +1 -1
  100. package/dist/engine/schema-builder.js +3 -0
  101. package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
  102. package/dist/engine/strip-unreadable-related-lists.js +80 -0
  103. package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
  104. package/dist/files/bind-pending-file-refs.d.ts +3 -2
  105. package/dist/files/bind-pending-file-refs.js +11 -12
  106. package/dist/files/bind-referenced-files.write.js +5 -5
  107. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  108. package/dist/files/create-file-ref-from-bytes.js +2 -0
  109. package/dist/files/create-file-ref.d.ts +2 -0
  110. package/dist/files/create-file-ref.js +39 -22
  111. package/dist/files/dispose-removed-file-refs.js +5 -8
  112. package/dist/files/file-field-slots.d.ts +10 -0
  113. package/dist/files/file-field-slots.js +53 -0
  114. package/dist/files/file-ref-entity.d.ts +1 -1
  115. package/dist/files/file-ref-table.d.ts +1 -1
  116. package/dist/files/file-routes.js +12 -3
  117. package/dist/files/index.d.ts +1 -0
  118. package/dist/files/index.js +1 -0
  119. package/dist/files/load-save-file-refs.js +3 -4
  120. package/dist/files/record-read-check.js +2 -30
  121. package/dist/files/record-write-check.d.ts +2 -0
  122. package/dist/files/record-write-check.js +18 -3
  123. package/dist/files/upload-field-resolution.d.ts +2 -1
  124. package/dist/files/upload-field-resolution.js +33 -13
  125. package/dist/pipeline/dispatch-batch.js +6 -5
  126. package/dist/pipeline/dispatch-write.js +2 -1
  127. package/dist/pipeline/write-result-masking.js +2 -1
  128. package/dist/schema-cli/validate-command.js +46 -12
  129. package/dist/schema-cli.js +1 -1
  130. package/dist/ui-types/index.d.ts +2 -1
  131. package/dist/ui-types/index.js +2 -1
  132. package/package.json +3 -3
  133. package/src/changes.json +55 -0
@@ -0,0 +1,807 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/inspection-checklist. Do not edit. -->
2
+
3
+ # inspection-checklist
4
+
5
+ Embedded list as a phone inspection checklist: conditional fields, derived pass/fail, photos per row.
6
+
7
+ ## README
8
+
9
+ # Inspection checklist
10
+
11
+ An embedded list as a phone inspection checklist: the inspector walks fixed rows,
12
+ enters a measurement or a result, and documents deviations with a finding and photos.
13
+
14
+ ## What it shows
15
+
16
+ - **Conditional cells** (`src/entities/inspection-protocol.ts`): `visibleWhen` and
17
+ `requiredWhen` on sub-fields and on top-level fields. The server enforces
18
+ `requiredWhen` on create and update and answers with one `required` path per
19
+ missing cell (`checkpoints.0.finding`, `condition`).
20
+ - **Pass/fail by range**: the derived `inRange` cell turns `measured`, `min` and `max`
21
+ into `ok` or `not_ok` (bounds included). The server recomputes it, so a client-sent
22
+ result never survives a value outside the range. `when` limits it to measurement rows;
23
+ a check row keeps the result the inspector picked.
24
+ - **Photos per row**: an `images` sub-field. Upload with `entityType`, `fieldName` of the
25
+ embedded list and `subField` of the photo cell; files are bound, tenant-checked and
26
+ disposed like a top-level images field, always under the embedded list's field name.
27
+ - **Fixed rows**: `inspection-protocol:create-from-plan` copies label, kind and limits from
28
+ a plan; the edit screen keeps those cells read-only and the row count fixed. It sets
29
+ `layout.width: "full"` because the default form width pushes result, finding and photos
30
+ behind horizontal scroll.
31
+
32
+ ## Source
33
+
34
+ `src/feature.ts` wires the two entities, the handler and the screen. The integration test
35
+ covers every behavior above over real HTTP.
36
+
37
+ ## Source files
38
+
39
+ ### src/__tests__/feature.integration.test.ts
40
+
41
+ ```ts
42
+ // Inspection Checklist Sample: Integration Test (conditional rows and derived result)
43
+ // Proves, through real HTTP: server-enforced requiredWhen on rows and top-level fields and
44
+ // the server-authoritative inRange cell.
45
+
46
+ import { describe, expect, test } from "bun:test";
47
+ import {
48
+ admin,
49
+ measurement,
50
+ PROTOCOL_CREATE,
51
+ PROTOCOL_UPDATE,
52
+ useInspectionStack,
53
+ } from "./inspection-test-kit";
54
+
55
+ const kit = useInspectionStack();
56
+ const { pendingPhoto, createProtocol, checkpointsOf, storedResultOf } = kit;
57
+
58
+ describe("requiredWhen on rows", () => {
59
+ test("a not_ok row without finding and photos is rejected with a path per cell", async () => {
60
+ const error = await kit.stack.http.writeErr(
61
+ PROTOCOL_CREATE,
62
+ { checkpoints: [{ label: "Guard", kind: "check", result: "not_ok" }] },
63
+ admin,
64
+ );
65
+ expect(error.httpStatus).toBe(400);
66
+ expect(error.code).toBe("validation_error");
67
+ const paths = (error.details as { fields: { path: string }[] }).fields.map((f) => f.path);
68
+ expect(paths).toEqual(["checkpoints.0.finding", "checkpoints.0.photos"]);
69
+ });
70
+
71
+ test("a not_ok row with finding and photo is accepted", async () => {
72
+ const photo = await pendingPhoto();
73
+ const created = await createProtocol({
74
+ checkpoints: [
75
+ { label: "Guard", kind: "check", result: "not_ok", finding: "Cracked", photos: [photo] },
76
+ ],
77
+ });
78
+ expect((await checkpointsOf(created.id))[0]).toMatchObject({
79
+ result: "not_ok",
80
+ finding: "Cracked",
81
+ photos: [photo],
82
+ });
83
+ });
84
+ });
85
+
86
+ describe("inRange result", () => {
87
+ test("the server derives not_ok above and ok inside the range, bounds included", async () => {
88
+ const photo = await pendingPhoto();
89
+ const outside = measurement(210, { finding: "Too high", photos: [photo] });
90
+ expect(await storedResultOf(outside)).toBe("not_ok");
91
+ expect(await storedResultOf(measurement(190))).toBe("ok");
92
+ expect(await storedResultOf(measurement(180))).toBe("ok");
93
+ expect(await storedResultOf(measurement(200))).toBe("ok");
94
+ });
95
+
96
+ test("a client-sent ok never survives a measured value outside the range", async () => {
97
+ const photo = await pendingPhoto();
98
+ const row = measurement(210, { result: "ok", finding: "Too high", photos: [photo] });
99
+ expect(await storedResultOf(row)).toBe("not_ok");
100
+ });
101
+
102
+ test("requiredWhen sees the derived result, not the client-sent one", async () => {
103
+ const error = await kit.stack.http.writeErr(
104
+ PROTOCOL_CREATE,
105
+ { checkpoints: [measurement(210, { result: "ok" })] },
106
+ admin,
107
+ );
108
+ expect(error.httpStatus).toBe(400);
109
+ const paths = (error.details as { fields: { path: string }[] }).fields.map((f) => f.path);
110
+ expect(paths).toEqual(["checkpoints.0.finding", "checkpoints.0.photos"]);
111
+ });
112
+
113
+ test("a manual result on a check row is kept", async () => {
114
+ const row = { label: "Guard", kind: "check", result: "ok" };
115
+ expect(await storedResultOf(row)).toBe("ok");
116
+ });
117
+ });
118
+
119
+ describe("requiredWhen on top-level fields", () => {
120
+ test("with_condition needs a condition on create and on update", async () => {
121
+ const onCreate = await kit.stack.http.writeErr(
122
+ PROTOCOL_CREATE,
123
+ { checkpoints: [], continuedOperation: "with_condition" },
124
+ admin,
125
+ );
126
+ expect((onCreate.details as { fields: { path: string }[] }).fields[0]?.path).toBe("condition");
127
+
128
+ const created = await createProtocol({ checkpoints: [], continuedOperation: "yes" });
129
+ const onUpdate = await kit.stack.http.writeErr(
130
+ PROTOCOL_UPDATE,
131
+ { id: created.id, version: 1, changes: { continuedOperation: "with_condition" } },
132
+ admin,
133
+ );
134
+ expect(onUpdate.httpStatus).toBe(400);
135
+ expect((onUpdate.details as { fields: { path: string }[] }).fields[0]?.path).toBe("condition");
136
+
137
+ await kit.stack.http.writeOk(
138
+ PROTOCOL_UPDATE,
139
+ {
140
+ id: created.id,
141
+ version: 1,
142
+ changes: { continuedOperation: "with_condition", condition: "Daylight only" },
143
+ },
144
+ admin,
145
+ );
146
+ });
147
+
148
+ test("an unrelated update passes when the conditional fields are unchanged", async () => {
149
+ const created = await createProtocol({
150
+ checkpoints: [],
151
+ continuedOperation: "with_condition",
152
+ condition: "Daylight only",
153
+ });
154
+ await kit.stack.http.writeOk(
155
+ PROTOCOL_UPDATE,
156
+ { id: created.id, version: 1, changes: { measuringDevice: "Gauge 7" } },
157
+ admin,
158
+ );
159
+ });
160
+ });
161
+ ```
162
+
163
+ ### src/__tests__/inspection-test-kit.ts
164
+
165
+ ```ts
166
+ import { afterAll, beforeAll, expect } from "bun:test";
167
+ import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
168
+ import { createFileFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
169
+ import {
170
+ createFilesFeature,
171
+ createInMemoryFileProvider,
172
+ } from "@cosmicdrift/kumiko-framework/files";
173
+ import { createTestUser, testTenantId } from "@cosmicdrift/kumiko-framework/seeding";
174
+ import {
175
+ buildMultipartBody,
176
+ patchFileInstanceofForBunTest,
177
+ } from "@cosmicdrift/kumiko-testing/helpers";
178
+ import {
179
+ setupTestStack,
180
+ type TestStack,
181
+ unsafeCreateEntityTable,
182
+ } from "@cosmicdrift/kumiko-testing/stack";
183
+ import { inspectionPlanEntity } from "../entities/inspection-plan";
184
+ import { inspectionProtocolEntity } from "../entities/inspection-protocol";
185
+ import { inspectionChecklistFeature } from "../feature";
186
+
187
+ export const PROTOCOL_CREATE = "inspection-checklist:write:inspection-protocol:create";
188
+ export const PROTOCOL_UPDATE = "inspection-checklist:write:inspection-protocol:update";
189
+ export const PROTOCOL_DETAIL = "inspection-checklist:query:inspection-protocol:detail";
190
+ export const PLAN_CREATE = "inspection-checklist:write:inspection-plan:create";
191
+ export const CREATE_FROM_PLAN = "inspection-checklist:write:inspection-protocol:create-from-plan";
192
+
193
+ const JPEG_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(10).fill(0)]);
194
+ const PDF_BYTES = new TextEncoder().encode("%PDF-1.4\n%test\n");
195
+
196
+ export const UploadFiles = {
197
+ jpeg: { name: "pump.jpg", type: "image/jpeg", bytes: JPEG_BYTES },
198
+ pdf: { name: "report.pdf", type: "application/pdf", bytes: PDF_BYTES },
199
+ } as const;
200
+
201
+ const tenantId = testTenantId(1);
202
+ export const admin = createTestUser({ id: 1, tenantId, roles: ["Admin"] });
203
+ export const inspector = createTestUser({ id: 4, tenantId, roles: ["Inspector"] });
204
+ export const viewer = createTestUser({ id: 2, tenantId, roles: ["Viewer"] });
205
+ export const otherTenantAdmin = createTestUser({
206
+ id: 3,
207
+ tenantId: testTenantId(2),
208
+ roles: ["Admin"],
209
+ });
210
+
211
+ type User = typeof admin;
212
+ export type Row = Record<string, unknown>;
213
+ type Protocol = { readonly id: string; readonly version: number };
214
+
215
+ export function measurement(measured: number | undefined, extra: Row = {}): Row {
216
+ return { label: "Pressure", kind: "measurement", min: 180, max: 200, measured, ...extra };
217
+ }
218
+
219
+ export function useInspectionStack() {
220
+ let stack: TestStack;
221
+
222
+ beforeAll(async () => {
223
+ patchFileInstanceofForBunTest();
224
+ stack = await setupTestStack({
225
+ features: [
226
+ createConfigFeature(),
227
+ createFileFoundationFeature(),
228
+ createFilesFeature(),
229
+ inspectionChecklistFeature,
230
+ ],
231
+ files: { storageProvider: createInMemoryFileProvider() },
232
+ });
233
+ await unsafeCreateEntityTable(stack.db, inspectionPlanEntity, "inspection-plan");
234
+ await unsafeCreateEntityTable(stack.db, inspectionProtocolEntity, "inspection-protocol");
235
+ });
236
+
237
+ afterAll(async () => {
238
+ await stack.cleanup();
239
+ });
240
+
241
+ type UploadTarget = {
242
+ entityId?: string;
243
+ fieldName?: string;
244
+ subField?: string;
245
+ file?: (typeof UploadFiles)[keyof typeof UploadFiles];
246
+ };
247
+
248
+ async function upload(
249
+ asUser: User,
250
+ target: UploadTarget = { fieldName: "checkpoints", subField: "photos" },
251
+ ) {
252
+ const fd = new FormData();
253
+ fd.append("entityType", "inspection-protocol");
254
+ if (target.fieldName !== undefined) fd.append("fieldName", target.fieldName);
255
+ if (target.subField !== undefined) fd.append("subField", target.subField);
256
+ if (target.entityId !== undefined) fd.append("entityId", target.entityId);
257
+ const file = target.file ?? UploadFiles.jpeg;
258
+ fd.append("file", new File([Buffer.from(file.bytes)], file.name, { type: file.type }));
259
+ const { body, contentType } = await buildMultipartBody(fd);
260
+ const token = await stack.jwt.sign(asUser);
261
+ const res = await stack.app.request("/api/files", {
262
+ method: "POST",
263
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
264
+ body,
265
+ });
266
+ const json = (await res.json()) as { id?: string };
267
+ return { status: res.status, id: json.id ?? "" };
268
+ }
269
+
270
+ async function pendingPhoto(asUser: User = admin): Promise<string> {
271
+ const { status, id } = await upload(asUser);
272
+ expect(status).toBe(201);
273
+ return id;
274
+ }
275
+
276
+ async function pendingDocument(asUser: User = admin): Promise<string> {
277
+ const { status, id } = await upload(asUser, {
278
+ fieldName: "checkpoints",
279
+ subField: "documents",
280
+ file: UploadFiles.pdf,
281
+ });
282
+ expect(status).toBe(201);
283
+ return id;
284
+ }
285
+
286
+ async function fileStatus(fileId: string, asUser: User = admin): Promise<number> {
287
+ const token = await stack.jwt.sign(asUser);
288
+ const res = await stack.app.request(`/api/files/${fileId}`, {
289
+ headers: { Authorization: `Bearer ${token}` },
290
+ });
291
+ return res.status;
292
+ }
293
+
294
+ async function createProtocol(payload: Row, asUser: User = admin): Promise<Protocol> {
295
+ return stack.http.writeOk<Protocol>(PROTOCOL_CREATE, payload, asUser);
296
+ }
297
+
298
+ async function checkpointsOf(id: string): Promise<Row[]> {
299
+ const detail = await stack.http.queryOk<{ checkpoints: Row[] }>(PROTOCOL_DETAIL, { id }, admin);
300
+ return detail.checkpoints;
301
+ }
302
+
303
+ async function storedResultOf(row: Row): Promise<unknown> {
304
+ const created = await createProtocol({ checkpoints: [row] });
305
+ return (await checkpointsOf(created.id))[0]?.["result"];
306
+ }
307
+
308
+ return {
309
+ get stack() {
310
+ return stack;
311
+ },
312
+ upload,
313
+ pendingPhoto,
314
+ pendingDocument,
315
+ fileStatus,
316
+ createProtocol,
317
+ checkpointsOf,
318
+ storedResultOf,
319
+ };
320
+ }
321
+ ```
322
+
323
+ ### src/__tests__/photos.integration.test.ts
324
+
325
+ ```ts
326
+ // Inspection Checklist Sample: Integration Test (photos in a row, create-from-plan)
327
+ // Proves, through real HTTP: pending upload with `subField`, binding under the embedded
328
+ // field name, read access, tenant isolation, disposal and copying rows from a plan.
329
+
330
+ import { describe, expect, test } from "bun:test";
331
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
332
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
333
+ import {
334
+ admin,
335
+ CREATE_FROM_PLAN,
336
+ otherTenantAdmin,
337
+ PLAN_CREATE,
338
+ PROTOCOL_CREATE,
339
+ PROTOCOL_UPDATE,
340
+ type Row,
341
+ useInspectionStack,
342
+ viewer,
343
+ } from "./inspection-test-kit";
344
+
345
+ const kit = useInspectionStack();
346
+ const { upload, pendingPhoto, fileStatus, createProtocol, checkpointsOf } = kit;
347
+
348
+ describe("photos inside a row", () => {
349
+ test("a pending upload is bound to the protocol under the top-level field name", async () => {
350
+ const photo = await pendingPhoto();
351
+ const created = await createProtocol({
352
+ checkpoints: [
353
+ { label: "Guard", kind: "check", result: "not_ok", finding: "Cracked", photos: [photo] },
354
+ ],
355
+ });
356
+
357
+ const [ref] = await selectMany(kit.stack.db, fileRefsTable, { id: photo });
358
+ expect(ref).toMatchObject({
359
+ entityType: "inspection-protocol",
360
+ entityId: created.id,
361
+ fieldName: "checkpoints",
362
+ });
363
+ });
364
+
365
+ test("reads: owner 200, other tenant 404, same tenant without read access denied", async () => {
366
+ const photo = await pendingPhoto();
367
+ await createProtocol({
368
+ checkpoints: [
369
+ { label: "Guard", kind: "check", result: "not_ok", finding: "Cracked", photos: [photo] },
370
+ ],
371
+ });
372
+ expect(await fileStatus(photo, admin)).toBe(200);
373
+ expect(await fileStatus(photo, otherTenantAdmin)).toBe(404);
374
+ expect(await fileStatus(photo, viewer)).toBe(404);
375
+ });
376
+
377
+ test("an upload against an existing record binds the file at once", async () => {
378
+ const created = await createProtocol({ checkpoints: [{ label: "Guard", kind: "check" }] });
379
+ const { status, id } = await upload(admin, {
380
+ entityId: created.id,
381
+ fieldName: "checkpoints",
382
+ subField: "photos",
383
+ });
384
+ expect(status).toBe(201);
385
+ const [ref] = await selectMany(kit.stack.db, fileRefsTable, { id });
386
+ expect(ref).toMatchObject({ entityId: created.id, fieldName: "checkpoints" });
387
+ });
388
+
389
+ test("file ids from another tenant or another protocol are rejected", async () => {
390
+ const foreign = await pendingPhoto(otherTenantAdmin);
391
+ const row = (photo: string): Row => ({
392
+ label: "Guard",
393
+ kind: "check",
394
+ result: "not_ok",
395
+ finding: "Cracked",
396
+ photos: [photo],
397
+ });
398
+ const foreignTenant = await kit.stack.http.writeErr(
399
+ PROTOCOL_CREATE,
400
+ { checkpoints: [row(foreign)] },
401
+ admin,
402
+ );
403
+ expect([foreignTenant.httpStatus, foreignTenant.code]).toEqual([400, "validation_error"]);
404
+
405
+ const own = await pendingPhoto();
406
+ await createProtocol({ checkpoints: [row(own)] });
407
+ const otherProtocol = await kit.stack.http.writeErr(
408
+ PROTOCOL_CREATE,
409
+ { checkpoints: [row(own)] },
410
+ admin,
411
+ );
412
+ expect([otherProtocol.httpStatus, otherProtocol.code]).toEqual([400, "validation_error"]);
413
+ });
414
+
415
+ test("removing a photo from a row disposes its file", async () => {
416
+ const keep = await pendingPhoto();
417
+ const drop = await pendingPhoto();
418
+ const row = (photos: string[]): Row => ({
419
+ label: "Guard",
420
+ kind: "check",
421
+ result: "not_ok",
422
+ finding: "Cracked",
423
+ photos,
424
+ });
425
+ const created = await createProtocol({ checkpoints: [row([keep, drop])] });
426
+
427
+ await kit.stack.http.writeOk(
428
+ PROTOCOL_UPDATE,
429
+ { id: created.id, version: 1, changes: { checkpoints: [row([keep])] } },
430
+ admin,
431
+ );
432
+
433
+ expect(await fileStatus(keep)).toBe(200);
434
+ expect(await fileStatus(drop)).toBe(404);
435
+ });
436
+
437
+ test("a subField that is not a file sub-field or not a plain name is a 400", async () => {
438
+ for (const subField of ["label", "a.b", "../x"]) {
439
+ const { status } = await upload(admin, { fieldName: "checkpoints", subField });
440
+ expect(status).toBe(400);
441
+ }
442
+ });
443
+ });
444
+
445
+ describe("create-from-plan", () => {
446
+ test("copies label, kind and limits and leaves the result empty", async () => {
447
+ const plan = await kit.stack.http.writeOk<{ id: string }>(
448
+ PLAN_CREATE,
449
+ {
450
+ name: "Monthly pump check",
451
+ checkpoints: [
452
+ { label: "Guard fitted", kind: "check" },
453
+ { label: "Pressure", kind: "measurement", min: 180, max: 200 },
454
+ ],
455
+ },
456
+ admin,
457
+ );
458
+
459
+ const created = await kit.stack.http.writeOk<{ id: string }>(
460
+ CREATE_FROM_PLAN,
461
+ { planId: plan.id },
462
+ admin,
463
+ );
464
+
465
+ expect(await checkpointsOf(created.id)).toEqual([
466
+ { label: "Guard fitted", kind: "check" },
467
+ { label: "Pressure", kind: "measurement", min: 180, max: 200 },
468
+ ]);
469
+ });
470
+ });
471
+ ```
472
+
473
+ ### src/__tests__/sub-field-rules.integration.test.ts
474
+
475
+ ```ts
476
+ // Inspection Checklist Sample: Integration Test (rules of single cells on save)
477
+ // Proves, through real HTTP: a cell's write access is enforced when the record is saved, and a
478
+ // file only lands in a file cell whose accept list it fits.
479
+
480
+ import { describe, expect, test } from "bun:test";
481
+ import {
482
+ admin,
483
+ inspector,
484
+ PROTOCOL_CREATE,
485
+ PROTOCOL_UPDATE,
486
+ type Row,
487
+ useInspectionStack,
488
+ } from "./inspection-test-kit";
489
+
490
+ const kit = useInspectionStack();
491
+
492
+ // The same denial a top-level field with a write rule gets.
493
+ const OWNERSHIP_DENIED = [422, "errors.ownershipDenied"];
494
+
495
+ const check = (extra: Row = {}): Row => ({ label: "Guard", kind: "check", ...extra });
496
+
497
+ describe("write access of a single cell", () => {
498
+ test("a user without the role cannot set the cell, on create or update", async () => {
499
+ const created = await kit.stack.http.writeErr(
500
+ PROTOCOL_CREATE,
501
+ { checkpoints: [check({ approvedBy: "Me" })] },
502
+ inspector,
503
+ );
504
+ expect([created.httpStatus, created.i18nKey]).toEqual(OWNERSHIP_DENIED);
505
+
506
+ const protocol = await kit.createProtocol({ checkpoints: [check()] }, inspector);
507
+ const updated = await kit.stack.http.writeErr(
508
+ PROTOCOL_UPDATE,
509
+ { id: protocol.id, version: 1, changes: { checkpoints: [check({ approvedBy: "Me" })] } },
510
+ inspector,
511
+ );
512
+ expect([updated.httpStatus, updated.i18nKey]).toEqual(OWNERSHIP_DENIED);
513
+ expect(await kit.checkpointsOf(protocol.id)).toEqual([check()]);
514
+ });
515
+
516
+ test("the same user may save rows that leave the cell as it is", async () => {
517
+ const protocol = await kit.createProtocol({ checkpoints: [check({ approvedBy: "Boss" })] });
518
+ await kit.stack.http.writeOk(
519
+ PROTOCOL_UPDATE,
520
+ {
521
+ id: protocol.id,
522
+ version: 1,
523
+ changes: { checkpoints: [check({ approvedBy: "Boss", label: "Guard fitted" })] },
524
+ },
525
+ inspector,
526
+ );
527
+ expect(await kit.checkpointsOf(protocol.id)).toEqual([
528
+ check({ approvedBy: "Boss", label: "Guard fitted" }),
529
+ ]);
530
+ });
531
+
532
+ test("changing or clearing the cell on an existing row is denied, removing the row too", async () => {
533
+ const protocol = await kit.createProtocol({ checkpoints: [check({ approvedBy: "Boss" })] });
534
+ for (const checkpoints of [[check({ approvedBy: "Me" })], [check()], []]) {
535
+ const denied = await kit.stack.http.writeErr(
536
+ PROTOCOL_UPDATE,
537
+ { id: protocol.id, version: 1, changes: { checkpoints } },
538
+ inspector,
539
+ );
540
+ expect([denied.httpStatus, denied.i18nKey]).toEqual(OWNERSHIP_DENIED);
541
+ }
542
+ await kit.stack.http.writeOk(
543
+ PROTOCOL_UPDATE,
544
+ { id: protocol.id, version: 1, changes: { checkpoints: [check({ approvedBy: "Me" })] } },
545
+ admin,
546
+ );
547
+ });
548
+ });
549
+
550
+ describe("a file only lands in a cell it fits", () => {
551
+ test("a pdf uploaded to the loose cell is rejected in the photos cell", async () => {
552
+ const pdf = await kit.pendingDocument();
553
+ const denied = await kit.stack.http.writeErr(
554
+ PROTOCOL_CREATE,
555
+ {
556
+ checkpoints: [
557
+ check({ result: "not_ok", finding: "Cracked", photos: [pdf], documents: [pdf] }),
558
+ ],
559
+ },
560
+ admin,
561
+ );
562
+ expect([denied.httpStatus, denied.code]).toEqual([422, "unprocessable"]);
563
+ });
564
+
565
+ test("the same pdf is accepted where it was uploaded", async () => {
566
+ const pdf = await kit.pendingDocument();
567
+ const created = await kit.createProtocol({ checkpoints: [check({ documents: [pdf] })] });
568
+ expect(await kit.checkpointsOf(created.id)).toEqual([check({ documents: [pdf] })]);
569
+ });
570
+
571
+ test("moving a stored pdf from the documents cell to the photos cell is rejected", async () => {
572
+ const pdf = await kit.pendingDocument();
573
+ const created = await kit.createProtocol({ checkpoints: [check({ documents: [pdf] })] });
574
+ const denied = await kit.stack.http.writeErr(
575
+ PROTOCOL_UPDATE,
576
+ {
577
+ id: created.id,
578
+ version: 1,
579
+ changes: {
580
+ checkpoints: [check({ result: "not_ok", finding: "Cracked", photos: [pdf] })],
581
+ },
582
+ },
583
+ admin,
584
+ );
585
+ expect([denied.httpStatus, denied.code]).toEqual([422, "unprocessable"]);
586
+ });
587
+ });
588
+ ```
589
+
590
+ ### src/entities/inspection-plan.ts
591
+
592
+ ```ts
593
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
594
+ import {
595
+ createEmbeddedListField,
596
+ createEntity,
597
+ createTextField,
598
+ } from "@cosmicdrift/kumiko-framework/engine";
599
+
600
+ export const inspectionPlanEntity = createEntity({
601
+ table: "read_sample_inspection_plans",
602
+ fields: {
603
+ name: createTextField({ personal: false, reason: "is_business_data", required: true }),
604
+ checkpoints: createEmbeddedListField(
605
+ {
606
+ label: { type: "text", required: true },
607
+ kind: { type: "select", options: ["check", "measurement"], required: true },
608
+ min: { type: "number" },
609
+ max: { type: "number" },
610
+ },
611
+ { personal: false, reason: "is_business_data" },
612
+ ),
613
+ },
614
+ });
615
+
616
+ export const inspectionPlanTable = buildEntityTable("inspection-plan", inspectionPlanEntity);
617
+ ```
618
+
619
+ ### src/entities/inspection-protocol.ts
620
+
621
+ ```ts
622
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
623
+ import {
624
+ createEmbeddedListField,
625
+ createEntity,
626
+ createSelectField,
627
+ createTextField,
628
+ } from "@cosmicdrift/kumiko-framework/engine";
629
+
630
+ const isMeasurement = { field: "kind", eq: "measurement" } as const;
631
+ const isNotOk = { field: "result", eq: "not_ok" } as const;
632
+
633
+ export const inspectionProtocolEntity = createEntity({
634
+ table: "read_sample_inspection_protocols",
635
+ fields: {
636
+ measuringDevice: createTextField({ personal: false, reason: "is_business_data" }),
637
+ checkpoints: createEmbeddedListField(
638
+ {
639
+ label: { type: "text", required: true },
640
+ kind: { type: "select", options: ["check", "measurement"], required: true },
641
+ min: { type: "number", visibleWhen: isMeasurement },
642
+ max: { type: "number", visibleWhen: isMeasurement },
643
+ measured: { type: "number", visibleWhen: isMeasurement },
644
+ result: {
645
+ type: "select",
646
+ options: ["ok", "not_ok"],
647
+ display: "radio",
648
+ optionTones: { ok: "ok", not_ok: "bad" },
649
+ },
650
+ finding: { type: "text", visibleWhen: isNotOk, requiredWhen: isNotOk },
651
+ photos: {
652
+ type: "images",
653
+ capture: "environment",
654
+ maxCount: 5,
655
+ accept: ["jpg", "jpeg", "png"],
656
+ visibleWhen: isNotOk,
657
+ requiredWhen: isNotOk,
658
+ },
659
+ documents: { type: "files", maxCount: 3 },
660
+ approvedBy: { type: "text", access: { write: ["Admin"] } },
661
+ },
662
+ {
663
+ personal: false,
664
+ reason: "is_business_data",
665
+ derived: {
666
+ result: {
667
+ op: "inRange",
668
+ from: ["measured", "min", "max"],
669
+ inside: "ok",
670
+ outside: "not_ok",
671
+ when: isMeasurement,
672
+ },
673
+ },
674
+ },
675
+ ),
676
+ continuedOperation: createSelectField({
677
+ options: ["yes", "with_condition", "no"],
678
+ display: "radio",
679
+ optionTones: { yes: "ok", with_condition: "warn", no: "bad" },
680
+ }),
681
+ condition: createTextField({
682
+ personal: false,
683
+ reason: "is_business_data",
684
+ visibleWhen: { field: "continuedOperation", eq: "with_condition" },
685
+ requiredWhen: { field: "continuedOperation", eq: "with_condition" },
686
+ }),
687
+ },
688
+ });
689
+
690
+ export const inspectionProtocolTable = buildEntityTable(
691
+ "inspection-protocol",
692
+ inspectionProtocolEntity,
693
+ );
694
+ ```
695
+
696
+ ### src/feature.ts
697
+
698
+ ```ts
699
+ // Inspection Checklist Sample
700
+ // Shows: an embedded list as a phone inspection checklist.
701
+ // - visibleWhen / requiredWhen on cells and top-level fields (server-enforced)
702
+ // - a derived pass/fail cell (inRange) that the server recomputes
703
+ // - an images cell per row, uploaded with the `subField` multipart part
704
+ // - fixed rows copied from a plan, only the result cells stay editable
705
+
706
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
707
+ import { inspectionPlanEntity } from "./entities/inspection-plan";
708
+ import { inspectionProtocolEntity } from "./entities/inspection-protocol";
709
+ import { createFromPlan } from "./handlers/create-from-plan.write";
710
+ import { inspectionProtocolEditScreen } from "./screens/inspection-protocol-edit.screen";
711
+
712
+ export { inspectionPlanEntity } from "./entities/inspection-plan";
713
+ export { inspectionProtocolEntity } from "./entities/inspection-protocol";
714
+
715
+ const inspectorAccess = { access: { roles: ["Admin", "Inspector"] } } as const;
716
+
717
+ export const inspectionChecklistFeature = defineFeature("inspection-checklist", (r) => {
718
+ r.requires({ features: ["files"] });
719
+
720
+ r.crud("inspection-plan", inspectionPlanEntity, {
721
+ write: inspectorAccess,
722
+ read: inspectorAccess,
723
+ });
724
+ r.crud("inspection-protocol", inspectionProtocolEntity, {
725
+ write: inspectorAccess,
726
+ read: inspectorAccess,
727
+ });
728
+ r.writeHandler(createFromPlan);
729
+
730
+ r.screen(inspectionProtocolEditScreen);
731
+ });
732
+ ```
733
+
734
+ ### src/handlers/create-from-plan.write.ts
735
+
736
+ ```ts
737
+ import { createEntityExecutor, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
738
+ import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
739
+ import { z } from "zod";
740
+ import { inspectionPlanEntity } from "../entities/inspection-plan";
741
+ import { inspectionProtocolEntity } from "../entities/inspection-protocol";
742
+
743
+ const { executor: planExecutor } = createEntityExecutor("inspection-plan", inspectionPlanEntity);
744
+ const { executor: protocolExecutor } = createEntityExecutor(
745
+ "inspection-protocol",
746
+ inspectionProtocolEntity,
747
+ );
748
+
749
+ const planRows = z.array(
750
+ z.object({
751
+ label: z.string(),
752
+ kind: z.enum(["check", "measurement"]),
753
+ min: z.number().optional(),
754
+ max: z.number().optional(),
755
+ }),
756
+ );
757
+
758
+ // Copies only the plan's fixed columns; result, finding and photos stay empty for the inspector.
759
+ export const createFromPlan = defineWriteHandler({
760
+ name: "inspection-protocol:create-from-plan",
761
+ schema: z.object({ planId: z.uuid() }),
762
+ access: { roles: ["Admin", "Inspector"] },
763
+ handler: async (event, ctx) => {
764
+ const plan = await planExecutor.detail({ id: event.payload.planId }, event.user, ctx.db);
765
+ if (!plan) return failNotFound("inspection-plan", event.payload.planId);
766
+ const checkpoints = planRows.parse(plan["checkpoints"]).map(({ label, kind, min, max }) => ({
767
+ label,
768
+ kind,
769
+ ...(min !== undefined ? { min } : {}),
770
+ ...(max !== undefined ? { max } : {}),
771
+ }));
772
+ return protocolExecutor.create({ checkpoints }, event.user, ctx.db);
773
+ },
774
+ });
775
+ ```
776
+
777
+ ### src/screens/inspection-protocol-edit.screen.ts
778
+
779
+ ```ts
780
+ import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
781
+
782
+ export const inspectionProtocolEditScreen: EntityEditScreenDefinition = {
783
+ id: "inspection-protocol-edit",
784
+ type: "entityEdit",
785
+ entity: "inspection-protocol",
786
+ layout: {
787
+ width: "full",
788
+ sections: [
789
+ {
790
+ columns: 1,
791
+ fields: [
792
+ "measuringDevice",
793
+ {
794
+ field: "checkpoints",
795
+ embeddedList: {
796
+ readOnlyCells: ["label", "kind", "min", "max"],
797
+ cardHiddenCells: ["kind"],
798
+ fixedRows: true,
799
+ },
800
+ },
801
+ ],
802
+ },
803
+ { columns: 1, fields: ["continuedOperation", "condition"] },
804
+ ],
805
+ },
806
+ };
807
+ ```