@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,280 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/relations. Do not edit. -->
2
+
3
+ # relations
4
+
5
+ belongsTo, hasMany, manyToMany — the good stuff.
6
+
7
+ ## README
8
+
9
+ # Relations
10
+
11
+ Parent-child relations with cascade / restrict.
12
+
13
+ ## What it shows
14
+
15
+ - Relation declarations between entities
16
+ - Delete behaviour (cascade vs restrict)
17
+
18
+ ## Source
19
+
20
+ Feature entry point: `src/feature.ts`.
21
+
22
+ ## Tests
23
+
24
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
25
+
26
+ ```bash
27
+ cd samples/recipes/relations
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // Relations Sample — Integration Test
37
+ // Proves: hasMany relations, cascade delete, restrict delete
38
+
39
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
40
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
41
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
42
+ import { createCascadeDeleteHook } from "@cosmicdrift/kumiko-framework/pipeline";
43
+ import {
44
+ setupTestStack,
45
+ type TestStack,
46
+ TestUsers,
47
+ unsafeCreateEntityTable,
48
+ } from "@cosmicdrift/kumiko-framework/stack";
49
+ import {
50
+ memberEntity,
51
+ memberTable,
52
+ relationsFeature,
53
+ taskEntity,
54
+ taskTable,
55
+ teamEntity,
56
+ } from "../feature";
57
+
58
+ let stack: TestStack;
59
+
60
+ const admin = TestUsers.admin;
61
+
62
+ beforeAll(async () => {
63
+ stack = await setupTestStack({ features: [relationsFeature] });
64
+ await unsafeCreateEntityTable(stack.db, teamEntity);
65
+ await unsafeCreateEntityTable(stack.db, memberEntity);
66
+ await unsafeCreateEntityTable(stack.db, taskEntity);
67
+ await createEventsTable(stack.db);
68
+ });
69
+
70
+ afterAll(async () => {
71
+ await stack.cleanup();
72
+ });
73
+
74
+ beforeEach(() => {
75
+ stack.events.reset();
76
+ });
77
+
78
+ describe("restrict: team cannot be deleted with members", () => {
79
+ test("delete team with members fails", async () => {
80
+ const team = await stack.http.writeOk<SaveContext>(
81
+ "org:write:team:create",
82
+ { name: "Engineering" },
83
+ admin,
84
+ );
85
+ await stack.http.writeOk<SaveContext>(
86
+ "org:write:member:create",
87
+ {
88
+ name: "Alice",
89
+ teamId: team.id,
90
+ },
91
+ admin,
92
+ );
93
+
94
+ // Cascade hook would block this — test via hook directly
95
+ const cascadeHook = createCascadeDeleteHook(stack.registry, new Map([["member", memberTable]]));
96
+
97
+ await expect(
98
+ cascadeHook.fn(
99
+ {
100
+ kind: "delete",
101
+ id: team.id,
102
+ data: { tenantId: "00000000-0000-4000-8000-000000000001" },
103
+ entityName: "team",
104
+ },
105
+ { db: stack.db },
106
+ ),
107
+ ).rejects.toMatchObject({ code: "conflict", details: { reason: "delete_restricted" } });
108
+ });
109
+
110
+ test("delete empty team succeeds", async () => {
111
+ const team = await stack.http.writeOk<SaveContext>(
112
+ "org:write:team:create",
113
+ { name: "Empty Team" },
114
+ admin,
115
+ );
116
+
117
+ const cascadeHook = createCascadeDeleteHook(stack.registry, new Map([["member", memberTable]]));
118
+
119
+ await expect(
120
+ cascadeHook.fn(
121
+ {
122
+ kind: "delete",
123
+ id: team.id,
124
+ data: { tenantId: "00000000-0000-4000-8000-000000000001" },
125
+ entityName: "team",
126
+ },
127
+ { db: stack.db },
128
+ ),
129
+ ).resolves.toBeUndefined();
130
+ });
131
+ });
132
+
133
+ describe("cascade: deleting member removes tasks", () => {
134
+ test("member's tasks are deleted when member is deleted", async () => {
135
+ const team = await stack.http.writeOk<SaveContext>(
136
+ "org:write:team:create",
137
+ { name: "Cascade Team" },
138
+ admin,
139
+ );
140
+ const member = await stack.http.writeOk<SaveContext>(
141
+ "org:write:member:create",
142
+ {
143
+ name: "Bob",
144
+ teamId: team.id,
145
+ },
146
+ admin,
147
+ );
148
+
149
+ const task1 = await stack.http.writeOk<SaveContext>(
150
+ "org:write:task:create",
151
+ {
152
+ title: "Task A",
153
+ memberId: member.id,
154
+ },
155
+ admin,
156
+ );
157
+ const task2 = await stack.http.writeOk<SaveContext>(
158
+ "org:write:task:create",
159
+ {
160
+ title: "Task B",
161
+ memberId: member.id,
162
+ },
163
+ admin,
164
+ );
165
+
166
+ // Run cascade hook
167
+ const cascadeHook = createCascadeDeleteHook(stack.registry, new Map([["task", taskTable]]));
168
+
169
+ await cascadeHook.fn(
170
+ {
171
+ kind: "delete",
172
+ id: member.id,
173
+ data: { tenantId: "00000000-0000-4000-8000-000000000001" },
174
+ entityName: "member",
175
+ },
176
+ { db: stack.db },
177
+ );
178
+
179
+ // Tasks should be gone
180
+ const detail1 = await stack.http.queryOk<null>(
181
+ "org:query:task:detail",
182
+ { id: task1.id },
183
+ admin,
184
+ );
185
+ const detail2 = await stack.http.queryOk<null>(
186
+ "org:query:task:detail",
187
+ { id: task2.id },
188
+ admin,
189
+ );
190
+ expect(detail1).toBeNull();
191
+ expect(detail2).toBeNull();
192
+ });
193
+ });
194
+ ```
195
+
196
+ ### src/feature.ts
197
+
198
+ ```ts
199
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
200
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
201
+
202
+ export const teamEntity = createEntity({
203
+ table: "read_sample_teams",
204
+ fields: {
205
+ name: createTextField({ personal: false, reason: "is_business_data", required: true }),
206
+ },
207
+ });
208
+
209
+ export const memberEntity = createEntity({
210
+ table: "read_sample_members",
211
+ fields: {
212
+ name: createTextField({ personal: { of: "id" }, find: "none", required: true }),
213
+ teamId: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
214
+ role: createTextField({ personal: false, reason: "is_business_data" }),
215
+ },
216
+ });
217
+
218
+ export const taskEntity = createEntity({
219
+ table: "read_sample_member_tasks",
220
+ fields: {
221
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
222
+ memberId: createTextField({ personal: "ref", required: true }),
223
+ },
224
+ });
225
+
226
+ export const teamTable = buildEntityTable("team", teamEntity);
227
+ export const memberTable = buildEntityTable("member", memberEntity);
228
+ export const taskTable = buildEntityTable("task", taskEntity);
229
+
230
+ const adminWrite = { access: { roles: ["Admin"] } } as const;
231
+ const openRead = {
232
+ access: {
233
+ openToAll: {
234
+ reason: "any signed-in user may read a task by id; writes are still gated by adminWrite",
235
+ },
236
+ },
237
+ } as const;
238
+ const createOnly = {
239
+ update: false,
240
+ delete: false,
241
+ restore: false,
242
+ list: false,
243
+ detail: false,
244
+ } as const;
245
+
246
+ export const relationsFeature = defineFeature("org", (r) => {
247
+ const team = r.entity("team", teamEntity);
248
+ const member = r.entity("member", memberEntity);
249
+
250
+ r.relation(team, "members", {
251
+ type: "hasMany",
252
+ target: "member",
253
+ foreignKey: "teamId",
254
+ onDelete: "restrict",
255
+ });
256
+
257
+ r.relation(member, "tasks", {
258
+ type: "hasMany",
259
+ target: "task",
260
+ foreignKey: "memberId",
261
+ onDelete: "cascade",
262
+ });
263
+
264
+ r.crud("team", teamEntity, {
265
+ write: adminWrite,
266
+ verbs: createOnly,
267
+ registerEntity: false,
268
+ });
269
+ r.crud("member", memberEntity, {
270
+ write: adminWrite,
271
+ verbs: createOnly,
272
+ registerEntity: false,
273
+ });
274
+ r.crud("task", taskEntity, {
275
+ write: adminWrite,
276
+ read: openRead,
277
+ verbs: { ...createOnly, detail: true },
278
+ });
279
+ });
280
+ ```
@@ -0,0 +1,353 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/screens-nav. Do not edit. -->
2
+
3
+ # screens-nav
4
+
5
+ r.screen() + r.nav() — entityList/entityEdit/custom screens, cross-feature nav parents, FieldCondition<TData>, boot-validator behaviour.
6
+
7
+ ## README
8
+
9
+ # Screens Nav
10
+
11
+ Register screens and navigation.
12
+
13
+ ## What it shows
14
+
15
+ - `r.screen` + `r.nav` with cross-feature parents
16
+ - How the admin shell discovers the tree
17
+
18
+ ## Source
19
+
20
+ Feature entry point: `src/feature.ts`.
21
+
22
+ ## Tests
23
+
24
+ ```bash
25
+ cd samples/recipes/screens-nav
26
+ bun test
27
+ ```
28
+
29
+ ## Source files
30
+
31
+ ### src/__tests__/feature.test.ts
32
+
33
+ ```ts
34
+ // Screens + Nav Showcase — Unit Test (no DB / HTTP needed).
35
+ // Proves the Framework surface that M1's ui-core Phase 4 + Phase 5 consume:
36
+ // - registry exposes screens + navs with qualified ids
37
+ // - cross-feature nav parents resolve
38
+ // - convenience indexes (getScreensByEntity / getTopLevelNavs /
39
+ // getNavsByParent) enable zero-reverse-index tree walks
40
+ // - declarative FieldCondition ({ field, eq/ne } | boolean) lands on EditFieldSpec
41
+ // - normalizeEditField / normalizeListColumn collapse the string shorthand
42
+ // - validateBoot catches the common author mistakes (unknown field,
43
+ // custom screen without a renderer component)
44
+
45
+ import { describe, expect, test } from "bun:test";
46
+ import {
47
+ createRegistry,
48
+ defineFeature,
49
+ isFieldsEditSection,
50
+ normalizeEditField,
51
+ normalizeListColumn,
52
+ validateBoot as validateBootRaw,
53
+ } from "@cosmicdrift/kumiko-framework/engine";
54
+ import { withBootValidatorFixture } from "@cosmicdrift/kumiko-framework/testing";
55
+ import { bookEntity, createBookshopAdminFeature, createBookshopFeature } from "../feature";
56
+
57
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
58
+ validateBootRaw(withBootValidatorFixture(features));
59
+ }
60
+
61
+ const bookshop = createBookshopFeature();
62
+ const bookshopAdmin = createBookshopAdminFeature();
63
+ const registry = createRegistry([bookshop, bookshopAdmin]);
64
+
65
+ describe("screens-nav showcase — registry state", () => {
66
+ test("validateBoot accepts the full registered app", () => {
67
+ expect(() => validateBoot([bookshop, bookshopAdmin])).not.toThrow();
68
+ });
69
+
70
+ test("raw validateBoot (no fixture) still enforces entityList sortable/defaultSort/i18n rules", () => {
71
+ expect(() => validateBootRaw([bookshop, bookshopAdmin])).toThrow(/i18n|sortable|defaultSort/);
72
+ });
73
+
74
+ test("all three screen variants registered with qualified ids", () => {
75
+ expect(registry.getScreen("bookshop:screen:book-list")?.id).toBe("bookshop:screen:book-list");
76
+ expect(registry.getScreen("bookshop:screen:book-edit")?.id).toBe("bookshop:screen:book-edit");
77
+ expect(registry.getScreen("bookshop-admin:screen:audit-log")?.id).toBe(
78
+ "bookshop-admin:screen:audit-log",
79
+ );
80
+ });
81
+
82
+ test("getScreensByEntity indexes entityList + entityEdit, excludes custom", () => {
83
+ const byBook = registry.getScreensByEntity("book");
84
+ expect(byBook.map((s) => s.id).sort()).toEqual([
85
+ "bookshop:screen:book-edit",
86
+ "bookshop:screen:book-list",
87
+ ]);
88
+ // Custom screens (no entity) are never indexed here.
89
+ for (const s of byBook) expect(s.type).not.toBe("custom");
90
+ });
91
+ });
92
+
93
+ describe("screens-nav showcase — nav tree", () => {
94
+ test("getTopLevelNavs returns only parent-less entries, qualified id", () => {
95
+ const tops = registry.getTopLevelNavs();
96
+ expect(tops.map((n) => n.id)).toEqual(["bookshop:nav:main"]);
97
+ });
98
+
99
+ test("cross-feature nav parent: bookshop-admin hangs under bookshop:nav:main", () => {
100
+ const children = registry.getNavsByParent("bookshop:nav:main");
101
+ // Two direct children — one from bookshop, one from bookshop-admin.
102
+ // The registry preserves registration order (bookshop first, admin
103
+ // second), so that's how they come back; the renderer sorts by `order`
104
+ // at tree-assembly time.
105
+ expect(children.map((n) => n.id)).toEqual(["bookshop:nav:books", "bookshop-admin:nav:audit"]);
106
+ });
107
+
108
+ test("nav entries preserve screen/access/icon/order from the declaration", () => {
109
+ const books = registry.getNav("bookshop:nav:books");
110
+ expect(books).toMatchObject({
111
+ label: "bookshop:nav.books",
112
+ parent: "bookshop:nav:main",
113
+ screen: "bookshop:screen:book-list",
114
+ order: 10,
115
+ });
116
+ const audit = registry.getNav("bookshop-admin:nav:audit");
117
+ expect(audit?.access).toEqual({ roles: ["Admin"] });
118
+ });
119
+
120
+ test("feature.navs (unregistered) keeps the short id side-by-side with the qualified registry-side", () => {
121
+ // Same NavDefinition — different perspective. feature-author-side uses
122
+ // short ids (stable across feature-renames via auto-prefix), the
123
+ // registry mutates `id` to qualified for downstream consumers.
124
+ expect(bookshop.navs["main"]?.id).toBe("main");
125
+ expect(registry.getNav("bookshop:nav:main")?.id).toBe("bookshop:nav:main");
126
+ });
127
+ });
128
+
129
+ describe("screens-nav showcase — field specs", () => {
130
+ test("normalizeEditField collapses string shorthand into the object form", () => {
131
+ // String form: just a field reference — downstream consumers treat
132
+ // it the same as the full object with no overrides.
133
+ expect(normalizeEditField("title")).toEqual({ field: "title" });
134
+ // Object form is returned verbatim.
135
+ const condition = { field: "published", eq: true } as const;
136
+ expect(normalizeEditField({ field: "price", readOnly: condition, span: 1 })).toEqual({
137
+ field: "price",
138
+ readOnly: condition,
139
+ span: 1,
140
+ });
141
+ });
142
+
143
+ test("normalizeListColumn collapses string columns too", () => {
144
+ expect(normalizeListColumn("author")).toEqual({ field: "author" });
145
+ const renderer = { format: "currency", symbol: "€" } as const;
146
+ expect(normalizeListColumn({ field: "price", renderer })).toEqual({
147
+ field: "price",
148
+ renderer,
149
+ });
150
+ });
151
+
152
+ test("declarative FieldCondition survives registry storage intact", () => {
153
+ const screen = registry.getScreen("bookshop:screen:book-edit");
154
+ if (screen?.type !== "entityEdit") throw new Error("expected entityEdit");
155
+ const basicsSection = screen.layout.sections[0];
156
+ const publishingSection = screen.layout.sections[1];
157
+ if (!basicsSection || !isFieldsEditSection(basicsSection)) {
158
+ throw new Error("expected fields section");
159
+ }
160
+ if (!publishingSection || !isFieldsEditSection(publishingSection)) {
161
+ throw new Error("expected fields section");
162
+ }
163
+ const priceField = normalizeEditField(publishingSection.fields[0]!);
164
+ expect(priceField.readOnly).toEqual({ field: "published", eq: true });
165
+ const authorField = normalizeEditField(basicsSection.fields[1]!);
166
+ expect(authorField.visible).toEqual({ field: "published", eq: false });
167
+ });
168
+ });
169
+
170
+ describe("screens-nav showcase — boot-validator catches author mistakes", () => {
171
+ test("unknown field in entityEdit section fails boot with a useful message", () => {
172
+ // Typo-protection: field refs must exist on the entity. Mis-spelled
173
+ // column names would otherwise silently vanish from the form.
174
+ const broken = defineFeature("broken-shop", (r) => {
175
+ r.entity("book", bookEntity);
176
+ r.screen({
177
+ id: "book-edit",
178
+ type: "entityEdit",
179
+ entity: "book",
180
+ layout: {
181
+ sections: [
182
+ { title: "s", fields: ["title", "tittle"] }, // typo
183
+ ],
184
+ },
185
+ });
186
+ });
187
+ expect(() => validateBoot([broken])).toThrow(/field "tittle"/);
188
+ });
189
+
190
+ test("custom screen needs at least one platform renderer", () => {
191
+ // A custom screen with an empty renderer object is structurally empty
192
+ // — ui-core would have nothing to mount. Caught at boot.
193
+ const broken = defineFeature("empty-custom", (r) => {
194
+ r.screen({ id: "blank", type: "custom", renderer: {} });
195
+ });
196
+ expect(() => validateBoot([broken])).toThrow(/react nor a native component/);
197
+ });
198
+
199
+ test("nav with dangling screen ref fails boot", () => {
200
+ const broken = defineFeature("ghost-ref", (r) => {
201
+ r.nav({ id: "lost", label: "x", screen: "does-not:screen:exist" });
202
+ });
203
+ expect(() => validateBoot([broken])).toThrow(/references screen "does-not:screen:exist"/);
204
+ });
205
+ });
206
+ ```
207
+
208
+ ### src/feature.ts
209
+
210
+ ```ts
211
+ // Screens + Nav Showcase — two features prove:
212
+ // 1. All three screen variants (entityList, entityEdit, custom) register
213
+ // through r.screen()
214
+ // 2. Field-level conditionals (visible/readOnly/required) use declarative
215
+ // FieldCondition ({ field, eq/ne } | boolean) — JSON-safe for schema
216
+ // injection and ui-core evaluation at render time
217
+ // 3. Level-4 slots + Level-2 renderers are plain-data shapes — engine
218
+ // stores them verbatim, ui-core resolves at mount-time
219
+ // 4. r.nav() flattens into a registry-wide tree whose parent refs can
220
+ // cross feature boundaries (bookshop-admin hangs a nav under
221
+ // bookshop's main group — no import, no coupling)
222
+ //
223
+ // The rendering is ui-core's job (M1 Phase 4/5); this sample focuses on
224
+ // the Framework surface — registration + boot-validation + registry
225
+ // lookups the renderer will consume.
226
+
227
+ import {
228
+ createBooleanField,
229
+ createEntity,
230
+ createNumberField,
231
+ createTextField,
232
+ defineFeature,
233
+ type FeatureDefinition,
234
+ } from "@cosmicdrift/kumiko-framework/engine";
235
+
236
+ export const bookEntity = createEntity({
237
+ table: "read_books",
238
+ fields: {
239
+ title: createTextField({
240
+ personal: false,
241
+ reason: "is_business_data",
242
+ required: true,
243
+ maxLength: 200,
244
+ }),
245
+ author: createTextField({
246
+ personal: { of: "id" },
247
+ find: "none",
248
+ required: true,
249
+ maxLength: 100,
250
+ }),
251
+ price: createNumberField({ required: true }),
252
+ published: createBooleanField({ default: false }),
253
+ },
254
+ });
255
+
256
+ export function createBookshopFeature(): FeatureDefinition {
257
+ return defineFeature("bookshop", (r) => {
258
+ r.systemScope();
259
+ r.entity("book", bookEntity);
260
+
261
+ // entityList — columns carry string shorthand and the object form
262
+ // side by side; the framework normalizes both through
263
+ // normalizeListColumn() at ui-core's iteration time.
264
+ r.screen({
265
+ id: "book-list",
266
+ type: "entityList",
267
+ entity: "book",
268
+ columns: [
269
+ "title",
270
+ "author",
271
+ {
272
+ field: "price",
273
+ renderer: { format: "currency", symbol: "€" },
274
+ },
275
+ "published",
276
+ ],
277
+ });
278
+
279
+ // entityEdit — sections + spans + declarative conditionals + slots.
280
+ r.screen({
281
+ id: "book-edit",
282
+ type: "entityEdit",
283
+ entity: "book",
284
+ layout: {
285
+ sections: [
286
+ {
287
+ title: "bookshop:section.basics",
288
+ columns: 2,
289
+ // Demonstrates the declarative FieldCondition MECHANIC (data-gate
290
+ // on another field) — role-gates are not expressible here; use
291
+ // field-level access on the entity for those.
292
+ fields: ["title", { field: "author", visible: { field: "published", eq: false } }],
293
+ },
294
+ {
295
+ title: "bookshop:section.publishing",
296
+ columns: 2,
297
+ fields: [
298
+ {
299
+ field: "price",
300
+ readOnly: { field: "published", eq: true },
301
+ span: 1,
302
+ },
303
+ { field: "published", span: 1 },
304
+ ],
305
+ },
306
+ ],
307
+ },
308
+ slots: {
309
+ afterForm: { react: { __component: "BookHistorySidebar" } },
310
+ },
311
+ access: { roles: ["Admin", "Editor"] },
312
+ });
313
+
314
+ r.nav({
315
+ id: "main",
316
+ label: "bookshop:nav.main",
317
+ icon: "book-open",
318
+ order: 10,
319
+ });
320
+ r.nav({
321
+ id: "books",
322
+ label: "bookshop:nav.books",
323
+ icon: "list",
324
+ parent: "bookshop:nav:main",
325
+ screen: "bookshop:screen:book-list",
326
+ order: 10,
327
+ });
328
+ });
329
+ }
330
+
331
+ export function createBookshopAdminFeature(): FeatureDefinition {
332
+ return defineFeature("bookshop-admin", (r) => {
333
+ r.systemScope();
334
+
335
+ r.screen({
336
+ id: "audit-log",
337
+ type: "custom",
338
+ renderer: { react: { __component: "AuditLogScreen" } },
339
+ access: { roles: ["Admin"] },
340
+ });
341
+
342
+ r.nav({
343
+ id: "audit",
344
+ label: "bookshop-admin:nav.audit",
345
+ icon: "shield",
346
+ parent: "bookshop:nav:main",
347
+ screen: "bookshop-admin:screen:audit-log",
348
+ access: { roles: ["Admin"] },
349
+ order: 20,
350
+ });
351
+ });
352
+ }
353
+ ```