@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -0,0 +1,649 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/embedded. Do not edit. -->
2
+
3
+ # embedded
4
+
5
+ Embedded fields on entities. Structured JSON without the JSON part.
6
+
7
+ ## README
8
+
9
+ # Embedded fields and lists
10
+
11
+ Keep owned, fixed-shape data with its parent entity instead of giving every
12
+ nested value its own aggregate. This recipe covers both a single embedded
13
+ object and an embedded list rendered by a declarative `entityEdit` screen.
14
+
15
+ ## What it shows
16
+
17
+ - **Embedded objects** — `address` and optional `billingAddress` are validated
18
+ sub-schemas stored in the contact row.
19
+ - **Embedded lists** — invoice `lines` repeat a fixed row shape without their
20
+ own identity or event stream.
21
+ - **Structured cells** — a line uses a reference, select, number, and money
22
+ cell, with `amount` derived from `quantity × unitPrice` and a totals row.
23
+ - **Server-authoritative derived cells** — the write schema recomputes
24
+ `derived` values before validation, so a client cannot supply a stale amount;
25
+ the edit view model exposes the definitions as `embeddedListDerived`.
26
+ - **Declarative editing** — the `invoice-edit` screen uses
27
+ `createEmbeddedListField`; its renderer receives `embeddedListCells`, so no
28
+ custom screen component is required.
29
+
30
+ ## When to reach for it
31
+
32
+ Use an embedded field or list when the nested data is created, changed, and
33
+ deleted with its parent. Promote a row to its own entity when it needs an
34
+ independent status, history, or handler.
35
+
36
+ ## Source
37
+
38
+ The feature entry point is `src/feature.ts`. The embedded object and invoice
39
+ line-item schemas live under `src/entities/`; integration tests cover schema
40
+ validation, derived values, totals, references, and the declarative edit
41
+ screen.
42
+
43
+ ## Source files
44
+
45
+ ### src/__tests__/feature.integration.test.ts
46
+
47
+ ```ts
48
+ // Embedded Object Sample — Integration Test
49
+ // Proves: embedded objects stored as JSONB, read back correctly,
50
+ // required embedded fails when missing, optional embedded can be omitted,
51
+ // searchable sub-fields registered, field access on sub-fields E2E
52
+
53
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
54
+ import type { EntityId, SaveContext } from "@cosmicdrift/kumiko-framework/engine";
55
+ import {
56
+ createTestUser,
57
+ setupTestStack,
58
+ type TestStack,
59
+ TestUsers,
60
+ unsafeCreateEntityTable,
61
+ } from "@cosmicdrift/kumiko-framework/stack";
62
+ import { contactEntity } from "../entities/contact";
63
+ import { invoiceEntity } from "../entities/invoice";
64
+ import { productEntity } from "../entities/product";
65
+ import { embeddedFeature } from "../feature";
66
+
67
+ let stack: TestStack;
68
+
69
+ const admin = TestUsers.admin;
70
+ const viewer = createTestUser({ id: 2, roles: ["Viewer"] });
71
+ const otherTenant = createTestUser({ id: 3, tenantId: "00000000-0000-4000-8000-000000000002" });
72
+
73
+ beforeAll(async () => {
74
+ stack = await setupTestStack({
75
+ features: [embeddedFeature],
76
+ systemHooks: [],
77
+ });
78
+ await unsafeCreateEntityTable(stack.db, contactEntity);
79
+ await unsafeCreateEntityTable(stack.db, productEntity);
80
+ await unsafeCreateEntityTable(stack.db, invoiceEntity);
81
+ });
82
+
83
+ afterAll(async () => {
84
+ await stack.cleanup();
85
+ });
86
+
87
+ // --- Create + Read ---
88
+
89
+ describe("create and read embedded objects", () => {
90
+ test("create contact with required address succeeds", async () => {
91
+ const data = await stack.http.writeOk<SaveContext>(
92
+ "contacts:write:contact:create",
93
+ {
94
+ name: "Max Mustermann",
95
+ email: "max@test.de",
96
+ address: { street: "Hauptstr. 1", zip: "10115", city: "Berlin", country: "DE" },
97
+ },
98
+ admin,
99
+ );
100
+ expect(data.isNew).toBe(true);
101
+ expect(data.data["address"]).toEqual({
102
+ street: "Hauptstr. 1",
103
+ zip: "10115",
104
+ city: "Berlin",
105
+ country: "DE",
106
+ });
107
+ });
108
+
109
+ test("detail returns embedded object intact", async () => {
110
+ const created = await stack.http.writeOk<SaveContext>(
111
+ "contacts:write:contact:create",
112
+ {
113
+ name: "Detail Test",
114
+ address: { street: "Musterweg 5", zip: "80331", city: "München" },
115
+ },
116
+ admin,
117
+ );
118
+
119
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
120
+ "contacts:query:contact:detail",
121
+ { id: created.id },
122
+ admin,
123
+ );
124
+ expect(detail["name"]).toBe("Detail Test");
125
+ const addr = detail["address"] as Record<string, unknown>;
126
+ expect(addr["street"]).toBe("Musterweg 5");
127
+ expect(addr["city"]).toBe("München");
128
+ });
129
+
130
+ test("create with optional billingAddress", async () => {
131
+ const data = await stack.http.writeOk<SaveContext>(
132
+ "contacts:write:contact:create",
133
+ {
134
+ name: "With Billing",
135
+ address: { street: "A-Str.", zip: "12345", city: "Hamburg" },
136
+ billingAddress: {
137
+ street: "B-Str.",
138
+ zip: "54321",
139
+ city: "Frankfurt",
140
+ vatId: "DE123456789",
141
+ },
142
+ },
143
+ admin,
144
+ );
145
+ expect(data.isNew).toBe(true);
146
+ const billing = data.data["billingAddress"] as Record<string, unknown>;
147
+ expect(billing["vatId"]).toBe("DE123456789");
148
+ });
149
+
150
+ test("create without optional billingAddress succeeds", async () => {
151
+ const data = await stack.http.writeOk<SaveContext>(
152
+ "contacts:write:contact:create",
153
+ {
154
+ name: "No Billing",
155
+ address: { street: "C-Str.", zip: "11111", city: "Köln" },
156
+ },
157
+ admin,
158
+ );
159
+ expect(data.isNew).toBe(true);
160
+ });
161
+ });
162
+
163
+ // --- Validation ---
164
+
165
+ describe("embedded validation", () => {
166
+ test("missing required address fails", async () => {
167
+ const error = await stack.http.writeErr(
168
+ "contacts:write:contact:create",
169
+ { name: "No Address" },
170
+ admin,
171
+ );
172
+ expect(error).toBeDefined();
173
+ });
174
+
175
+ test("address with missing required sub-field fails", async () => {
176
+ const error = await stack.http.writeErr(
177
+ "contacts:write:contact:create",
178
+ {
179
+ name: "Bad Address",
180
+ address: { street: "Only Street" }, // missing zip + city
181
+ },
182
+ admin,
183
+ );
184
+ expect(error).toBeDefined();
185
+ });
186
+ });
187
+
188
+ // --- Field access on embedded sub-fields (E2E via API) ---
189
+
190
+ describe("field access on embedded sub-fields", () => {
191
+ let contactWithBillingId: EntityId;
192
+
193
+ test("create contact with billingAddress for access tests", async () => {
194
+ const created = await stack.http.writeOk<SaveContext>(
195
+ "contacts:write:contact:create",
196
+ {
197
+ name: "Access Test Contact",
198
+ address: { street: "A", zip: "1", city: "X" },
199
+ billingAddress: { street: "B", zip: "2", city: "Y", vatId: "DE999" },
200
+ },
201
+ admin,
202
+ );
203
+ contactWithBillingId = created.id;
204
+ });
205
+
206
+ test("Admin sees billingAddress.vatId via detail query", async () => {
207
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
208
+ "contacts:query:contact:detail",
209
+ { id: contactWithBillingId },
210
+ admin,
211
+ );
212
+ const billing = detail["billingAddress"] as Record<string, unknown>;
213
+ expect(billing["vatId"]).toBe("DE999");
214
+ expect(billing["street"]).toBe("B");
215
+ });
216
+
217
+ test("Viewer does NOT see billingAddress.vatId via detail query", async () => {
218
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
219
+ "contacts:query:contact:detail",
220
+ { id: contactWithBillingId },
221
+ viewer,
222
+ );
223
+ const billing = detail["billingAddress"] as Record<string, unknown>;
224
+ expect(billing["vatId"]).toBeUndefined();
225
+ expect(billing["street"]).toBe("B"); // other fields still visible
226
+ });
227
+ });
228
+
229
+ // --- Embedded list (createEmbeddedListField) ---
230
+
231
+ describe("embedded list of like-shaped rows", () => {
232
+ const withPhones = {
233
+ name: "Phone Book",
234
+ address: { street: "D-Str.", zip: "22222", city: "Bremen" },
235
+ phoneNumbers: [
236
+ { label: "Buero", number: "+49 30 1", note: "Durchwahl 12" },
237
+ { label: "Mobil", number: "+49 170 2" },
238
+ ],
239
+ };
240
+
241
+ test("rows round-trip as an array, in order", async () => {
242
+ const created = await stack.http.writeOk<SaveContext>(
243
+ "contacts:write:contact:create",
244
+ withPhones,
245
+ admin,
246
+ );
247
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
248
+ "contacts:query:contact:detail",
249
+ { id: created.id },
250
+ admin,
251
+ );
252
+ expect(detail["phoneNumbers"]).toEqual(withPhones.phoneNumbers);
253
+ });
254
+
255
+ test("an omitted list reads back as an empty array (column default)", async () => {
256
+ const created = await stack.http.writeOk<SaveContext>(
257
+ "contacts:write:contact:create",
258
+ { name: "No Phones", address: { street: "E-Str.", zip: "33333", city: "Essen" } },
259
+ admin,
260
+ );
261
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
262
+ "contacts:query:contact:detail",
263
+ { id: created.id },
264
+ admin,
265
+ );
266
+ expect(detail["phoneNumbers"]).toEqual([]);
267
+ });
268
+
269
+ test("a row missing a required sub-field is rejected", async () => {
270
+ const error = await stack.http.writeErr(
271
+ "contacts:write:contact:create",
272
+ {
273
+ name: "Bad Row",
274
+ address: { street: "F-Str.", zip: "44444", city: "Kiel" },
275
+ phoneNumbers: [{ label: "Buero", number: "+49 30 1" }, { label: "Mobil" }],
276
+ },
277
+ admin,
278
+ );
279
+ expect(error).toBeDefined();
280
+ });
281
+
282
+ test("field access is applied per row, the value stays an array", async () => {
283
+ const created = await stack.http.writeOk<SaveContext>(
284
+ "contacts:write:contact:create",
285
+ withPhones,
286
+ admin,
287
+ );
288
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
289
+ "contacts:query:contact:detail",
290
+ { id: created.id },
291
+ viewer,
292
+ );
293
+ expect(detail["phoneNumbers"]).toEqual([
294
+ { label: "Buero", number: "+49 30 1" },
295
+ { label: "Mobil", number: "+49 170 2" },
296
+ ]);
297
+ });
298
+ });
299
+
300
+ // --- Searchable embedded sub-fields (registry) ---
301
+
302
+ describe("searchable embedded sub-fields", () => {
303
+ test("registry reports embedded sub-fields as searchable", () => {
304
+ const searchable = stack.registry.getSearchableFields("contact");
305
+ expect(searchable).toContain("name");
306
+ expect(searchable).toContain("address_street");
307
+ expect(searchable).toContain("address_city");
308
+ expect(searchable).not.toContain("address_zip"); // not marked searchable
309
+ expect(searchable).not.toContain("address_country");
310
+ expect(searchable).toContain("phoneNumbers_label");
311
+ expect(searchable).not.toContain("phoneNumbers_number");
312
+ });
313
+ });
314
+
315
+ // --- Tenant isolation ---
316
+
317
+ describe("tenant isolation", () => {
318
+ test("other tenant cannot read contact", async () => {
319
+ const created = await stack.http.writeOk<SaveContext>(
320
+ "contacts:write:contact:create",
321
+ {
322
+ name: "Secret Contact",
323
+ address: { street: "Hidden", zip: "00000", city: "Nowhere" },
324
+ },
325
+ admin,
326
+ );
327
+
328
+ const detail = await stack.http.queryOk<null>(
329
+ "contacts:query:contact:detail",
330
+ { id: created.id },
331
+ otherTenant,
332
+ );
333
+ expect(detail).toBeNull();
334
+ });
335
+ });
336
+
337
+ // --- Invoice line items: select/reference/derived/totals metadata (#1835) ---
338
+
339
+ describe("invoice line items with select/reference/derived/totals metadata", () => {
340
+ const validLine = {
341
+ product: "00000000-0000-4000-8000-000000000001",
342
+ unit: "pcs",
343
+ quantity: 2,
344
+ unitPrice: 500,
345
+ amount: 1000,
346
+ };
347
+
348
+ test("valid lines round-trip, including the client-computed amount", async () => {
349
+ const created = await stack.http.writeOk<SaveContext>(
350
+ "contacts:write:invoice:create",
351
+ { customer: "Acme Inc.", lines: [validLine] },
352
+ admin,
353
+ );
354
+ expect(created.data["lines"]).toEqual([validLine]);
355
+
356
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
357
+ "contacts:query:invoice:detail",
358
+ { id: created.id },
359
+ admin,
360
+ );
361
+ expect(detail["lines"]).toEqual([validLine]);
362
+ });
363
+
364
+ test("a line with a unit outside the select options is rejected", async () => {
365
+ const error = await stack.http.writeErr(
366
+ "contacts:write:invoice:create",
367
+ { customer: "Bad Unit", lines: [{ ...validLine, unit: "liters" }] },
368
+ admin,
369
+ );
370
+ expect(error).toBeDefined();
371
+ });
372
+
373
+ test("a line with a non-UUID product reference is rejected", async () => {
374
+ const error = await stack.http.writeErr(
375
+ "contacts:write:invoice:create",
376
+ { customer: "Bad Product", lines: [{ ...validLine, product: "not-a-uuid" }] },
377
+ admin,
378
+ );
379
+ expect(error).toBeDefined();
380
+ });
381
+
382
+ test("an empty lines array violates minItems and is rejected", async () => {
383
+ const error = await stack.http.writeErr(
384
+ "contacts:write:invoice:create",
385
+ { customer: "No Lines", lines: [] },
386
+ admin,
387
+ );
388
+ expect(error).toBeDefined();
389
+ });
390
+
391
+ test("more than maxItems lines is rejected", async () => {
392
+ const tooManyLines = Array.from({ length: 6 }, () => ({ ...validLine }));
393
+ const error = await stack.http.writeErr(
394
+ "contacts:write:invoice:create",
395
+ { customer: "Too Many Lines", lines: tooManyLines },
396
+ admin,
397
+ );
398
+ expect(error).toBeDefined();
399
+ });
400
+ });
401
+ ```
402
+
403
+ ### src/entities/contact.ts
404
+
405
+ ```ts
406
+ // Contact with embedded address — address belongs 1:1 to the contact,
407
+ // is never shared, and is always read/written together. `phoneNumbers` is the
408
+ // same idea for N rows: written whole with the contact, no per-row history.
409
+
410
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
411
+ import {
412
+ createEmbeddedField,
413
+ createEmbeddedListField,
414
+ createEntity,
415
+ createTextField,
416
+ } from "@cosmicdrift/kumiko-framework/engine";
417
+
418
+ export const contactEntity = createEntity({
419
+ table: "read_sample_contacts",
420
+ fields: {
421
+ name: createTextField({ required: true, personal: "self", find: "fuzzy" }),
422
+ email: createTextField({ format: "email", personal: "self", find: "none" }),
423
+ address: createEmbeddedField(
424
+ {
425
+ street: { type: "text", required: true, searchable: true },
426
+ zip: { type: "text", required: true },
427
+ city: { type: "text", required: true, searchable: true },
428
+ country: { type: "text" },
429
+ },
430
+ { required: true },
431
+ ),
432
+ billingAddress: createEmbeddedField({
433
+ street: { type: "text", required: true },
434
+ zip: { type: "text", required: true },
435
+ city: { type: "text", required: true },
436
+ country: { type: "text" },
437
+ vatId: { type: "text", access: { read: ["Admin", "Accounting"] } },
438
+ }),
439
+ // A list of like-shaped rows. A phone number has no lifetime of its own —
440
+ // editing the contact rewrites the whole list. Something that starts and
441
+ // ends on its own (a contract position, a subscription) would be its own
442
+ // entity referencing the contact instead.
443
+ phoneNumbers: createEmbeddedListField({
444
+ label: { type: "text", required: true, searchable: true },
445
+ number: { type: "text", required: true },
446
+ note: { type: "text", access: { read: ["Admin"] } },
447
+ }),
448
+ },
449
+ });
450
+
451
+ export const contactTable = buildEntityTable("contact", contactEntity);
452
+ ```
453
+
454
+ ### src/entities/invoice.ts
455
+
456
+ ```ts
457
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
458
+ import {
459
+ createEmbeddedListField,
460
+ createEntity,
461
+ createTextField,
462
+ } from "@cosmicdrift/kumiko-framework/engine";
463
+
464
+ // Invoice line-items — the motivating case for select/reference/derived/
465
+ // totals metadata on createEmbeddedListField: each line references a
466
+ // product, has a unit (select), and a derived amount (qty × unitPrice)
467
+ // that the entity sums across all lines.
468
+ export const invoiceEntity = createEntity({
469
+ table: "read_sample_invoices",
470
+ fields: {
471
+ customer: createTextField({ personal: { of: "id" }, find: "none", required: true }),
472
+ lines: createEmbeddedListField(
473
+ {
474
+ product: { type: "reference", entity: "product", required: true },
475
+ unit: { type: "select", options: ["pcs", "hours", "kg"], required: true },
476
+ quantity: { type: "number", required: true },
477
+ unitPrice: { type: "money", required: true },
478
+ amount: { type: "money" },
479
+ },
480
+ {
481
+ required: true,
482
+ minItems: 1,
483
+ maxItems: 5,
484
+ derived: { amount: { op: "multiply", from: ["quantity", "unitPrice"] } },
485
+ totals: ["amount"],
486
+ },
487
+ ),
488
+ },
489
+ });
490
+
491
+ export const invoiceTable = buildEntityTable("invoice", invoiceEntity);
492
+ ```
493
+
494
+ ### src/entities/product.ts
495
+
496
+ ```ts
497
+ // A minimal entity for the invoice's `product` reference cell — just
498
+ // enough to demonstrate a reference sub-field on an embedded list.
499
+
500
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
501
+ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
502
+
503
+ export const productEntity = createEntity({
504
+ table: "read_sample_products",
505
+ fields: {
506
+ name: createTextField({ personal: false, reason: "is_business_data", required: true }),
507
+ },
508
+ });
509
+
510
+ export const productTable = buildEntityTable("product", productEntity);
511
+ ```
512
+
513
+ ### src/feature.ts
514
+
515
+ ```ts
516
+ // Embedded Object Sample
517
+ // Shows: Embedded objects stored as JSONB, searchable sub-fields,
518
+ // field access on sub-fields, required vs optional embedded objects
519
+ //
520
+ // Pattern: Address belongs 1:1 to contact — never shared, always read together.
521
+ // Use embedded when: data is owned by the parent entity, not referenced elsewhere.
522
+ //
523
+ // Tables:
524
+ // contact — with embedded address (required) and billingAddress (optional)
525
+ // address.street and address.city are searchable
526
+ // billingAddress.vatId has restricted field access
527
+ // product — minimal entity, only exists so `invoice.lines` has something
528
+ // to reference
529
+ // invoice — embedded list with select/reference/derived/totals metadata
530
+ // on its `lines` sub-fields (Issue #1835), plus a declarative
531
+ // entityEdit screen that renders the list end-to-end via
532
+ // EmbeddedListField, no custom-screen code required
533
+
534
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
535
+ import { contactEntity } from "./entities/contact";
536
+ import { invoiceEntity } from "./entities/invoice";
537
+ import { productEntity } from "./entities/product";
538
+ import { contactCreate } from "./handlers/contact-create.write";
539
+ import { contactDetail } from "./handlers/contact-detail.query";
540
+
541
+ export { contactEntity } from "./entities/contact";
542
+ export { invoiceEntity } from "./entities/invoice";
543
+ export { productEntity } from "./entities/product";
544
+
545
+ const adminWrite = { access: { roles: ["Admin"] } } as const;
546
+ const openRead = {
547
+ access: {
548
+ openToAll: {
549
+ reason:
550
+ "any signed-in user may read products and invoices in this recipe; writes are " +
551
+ "still gated by adminWrite",
552
+ },
553
+ },
554
+ } as const;
555
+
556
+ export const embeddedFeature = defineFeature("contacts", (r) => {
557
+ r.entity("contact", contactEntity);
558
+
559
+ r.writeHandler(contactCreate);
560
+ r.queryHandler(contactDetail);
561
+
562
+ r.crud("product", productEntity, { write: adminWrite, read: openRead });
563
+ r.crud("invoice", invoiceEntity, { write: adminWrite, read: openRead });
564
+
565
+ r.screen({
566
+ id: "invoice-edit",
567
+ type: "entityEdit",
568
+ entity: "invoice",
569
+ layout: {
570
+ sections: [{ columns: 1, fields: ["customer", "lines"] }],
571
+ },
572
+ });
573
+ });
574
+ ```
575
+
576
+ ### src/handlers/contact-create.write.ts
577
+
578
+ ```ts
579
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
580
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
581
+ import { z } from "zod";
582
+ import { contactEntity, contactTable } from "../entities/contact";
583
+
584
+ const contactCrud = createEventStoreExecutor(contactTable, contactEntity, {
585
+ entityName: "contact",
586
+ });
587
+
588
+ const addressSchema = z.object({
589
+ street: z.string().min(1),
590
+ zip: z.string().min(1),
591
+ city: z.string().min(1),
592
+ country: z.string().optional(),
593
+ });
594
+
595
+ const billingAddressSchema = z.object({
596
+ street: z.string().min(1),
597
+ zip: z.string().min(1),
598
+ city: z.string().min(1),
599
+ country: z.string().optional(),
600
+ vatId: z.string().optional(),
601
+ });
602
+
603
+ const phoneNumberSchema = z.object({
604
+ label: z.string().min(1),
605
+ number: z.string().min(1),
606
+ note: z.string().optional(),
607
+ });
608
+
609
+ export const contactCreate = defineWriteHandler({
610
+ name: "contact:create",
611
+ schema: z.object({
612
+ name: z.string().min(1),
613
+ email: z.email().optional(),
614
+ address: addressSchema,
615
+ billingAddress: billingAddressSchema.optional(),
616
+ phoneNumbers: z.array(phoneNumberSchema).optional(),
617
+ }),
618
+ access: { roles: ["Admin"] },
619
+ // Embedded fields (address/billingAddress) arrive as objects, the embedded
620
+ // list (phoneNumbers) as an array — already the combined API form the
621
+ // executor expects; it flattens them into the read row.
622
+ handler: async (event, ctx) => contactCrud.create(event.payload, event.user, ctx.db),
623
+ });
624
+ ```
625
+
626
+ ### src/handlers/contact-detail.query.ts
627
+
628
+ ```ts
629
+ import { defineQueryHandler, filterReadFields } from "@cosmicdrift/kumiko-framework/engine";
630
+ import { z } from "zod";
631
+ import { contactEntity, contactTable } from "../entities/contact";
632
+
633
+ export const contactDetail = defineQueryHandler({
634
+ name: "contact:detail",
635
+ schema: z.object({ id: z.uuid() }),
636
+ access: {
637
+ openToAll: {
638
+ reason:
639
+ "demo recipe: any signed-in user may look up any contact by id, subject to " +
640
+ "the entity's own field-level read access on restricted sub-fields like billingAddress.vatId",
641
+ },
642
+ },
643
+ handler: async (query, ctx) => {
644
+ const [row] = await ctx.db.selectMany(contactTable, { id: query.payload.id });
645
+ if (!row) return null;
646
+ return filterReadFields(contactEntity, row as Record<string, unknown>, query.user);
647
+ },
648
+ });
649
+ ```