@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
package/AGENTS.md ADDED
@@ -0,0 +1,307 @@
1
+ # Kumiko for coding agents
2
+
3
+ This file ships inside `@cosmicdrift/kumiko-framework`. It always matches the
4
+ installed version. Read it before you add or change a feature in a Kumiko app.
5
+
6
+ Next to it, under `agents/`, are generated files. They are rebuilt from the
7
+ framework source on every release, so they never describe an API that does
8
+ not exist in your version:
9
+
10
+ | File | What it holds |
11
+ | --- | --- |
12
+ | `agents/features.md` | Every bundled feature: name, purpose, config keys, dependencies |
13
+ | `agents/widgets.md` | Every value export of `@cosmicdrift/kumiko-renderer-web` with its short description |
14
+ | `agents/examples.md` | Index of the worked examples, one per concept |
15
+ | `agents/examples/<name>.md` | One tested sample app: README plus every source and test file |
16
+
17
+ The paths are relative to this file. From an app root they live under
18
+ `node_modules/@cosmicdrift/kumiko-framework/`. Type signatures are in
19
+ `dist/*.d.ts` of each package; this file does not repeat them.
20
+
21
+ Order of work for a new feature:
22
+
23
+ 1. Search `agents/features.md` for a bundled feature that already does it.
24
+ 2. Find the closest example in `agents/examples.md` and read it fully.
25
+ 3. Build the feature with the declarative building blocks below.
26
+ 4. Write an integration test against the real stack.
27
+ 5. Run boot validation and the tests. Boot errors name the fix; apply it.
28
+
29
+ ## Mental model
30
+
31
+ A Kumiko app is a list of features. Everything a feature does is registered
32
+ on the `r` registrar inside `defineFeature(name, (r) => { ... })`. There is no
33
+ router, controller or ORM to wire by hand. The framework reads the
34
+ registrations, validates them at boot, and builds HTTP endpoints, database
35
+ tables, events, screens and navigation from them.
36
+
37
+ **Feature.** A named unit (`defineFeature("tasks", ...)`). It owns entities,
38
+ handlers, screens, navs, translations, config and events. Features talk to
39
+ each other through events, declared APIs and extensions, never through
40
+ imports of each other's internals. An app mounts features in
41
+ `src/run-config.ts` (`APP_FEATURES`).
42
+
43
+ **Entity.** A typed record built with `createEntity({ fields })` and field
44
+ factories such as `createTextField`, `createNumberField`,
45
+ `createBooleanField`. An entity is event sourced: every write appends an
46
+ event, and the table you query is a projection of those events. You never
47
+ write the table yourself.
48
+
49
+ **Handler.** The only way in. A write handler changes state, a query handler
50
+ reads it. Both have a qualified name (QN), a Zod payload schema and an access
51
+ rule. `r.crud(name, entity, options)` registers the entity plus create,
52
+ update, delete, restore, list and detail handlers in one call. Turn single
53
+ verbs off with `verbs: { delete: false }` and register them yourself when
54
+ they need different access or logic. Custom logic goes into
55
+ `r.writeHandler(qn, schema, fn, { access })` and
56
+ `r.queryHandler(qn, schema, fn, { access })`; inside, use the entity
57
+ executor from `createEntityExecutor(name, entity)`.
58
+
59
+ **Screen.** A declarative page: `r.screen({ id, type, ... })`. Types include
60
+ `entityList`, `entityEdit`, `projectionList`, `projectionDetail`,
61
+ `actionForm`, `dashboard` and `custom`. The renderer builds the UI from the
62
+ declaration. Columns, sections, row actions, tabs, wizard steps and
63
+ conditional fields are data, not JSX.
64
+
65
+ **Nav.** A sidebar entry: `r.nav({ id, label, screen, parent, order })`.
66
+ Parents may belong to another feature; refer to them by QN.
67
+
68
+ **Events.** Declare a domain event with `r.defineEvent(name, zodSchema)`.
69
+ Emit it inside a write handler with `emitEvent(ctx, def, { ... })` or
70
+ `ctx.appendEvent(...)`; it commits in the same transaction as the write.
71
+ React to it with `r.projection(...)` (state on the same stream) or
72
+ `r.multiStreamProjection({ name, apply })` (async consumer across streams,
73
+ at least once, ordered).
74
+
75
+ **Tenant scoping.** Every request runs for one tenant. `ctx.db`, executors
76
+ and queries are already filtered to that tenant. You do not pass a
77
+ `tenantId` around and you do not add tenant filters yourself. A feature
78
+ that holds global data calls `r.systemScope()`.
79
+
80
+ **Access.** Default deny. Every handler states who may call it:
81
+ `{ access: { roles: ["Admin", "User"] } }`, or
82
+ `{ access: { openToAll: { reason: "<why every signed-in user may>" } } }`.
83
+ Screens and nav entries can narrow visibility further with their own
84
+ `access`. Anonymous endpoints use the `anonymous` role. Field level and row
85
+ level rules exist as well (see the table below).
86
+
87
+ **Personal data.** Every text field says whether it holds personal data;
88
+ the field types reject a text field without that stance.
89
+ `personal: false, reason: "is_business_data"` (a snake_case reason) for
90
+ business data. For personal data name the subject, `personal: "self"`,
91
+ `personal: { of: "<ownerField>" }` or `personal: "tenant"`, plus
92
+ `find: "exact" | "fuzzy" | "none" | "secret"`. A plain foreign key to a user
93
+ is `personal: "ref"`. The framework encrypts personal fields per subject and
94
+ can erase them on request.
95
+
96
+ **Client.** The web side of a feature is a `ClientFeatureDefinition` in the
97
+ feature's `web/index.ts` (name, translations, custom screen components). It
98
+ is mounted in `src/client.tsx`. Declarative screens need no client code.
99
+
100
+ ## Qualified names
101
+
102
+ Names are built from the feature name, a kind and an id. Use the QN wherever
103
+ another feature or a test refers to something.
104
+
105
+ | Thing | Pattern | Example from the scaffolded demo feature |
106
+ | --- | --- | --- |
107
+ | Write handler | `<feature>:write:<entity>:<verb>` | `tasks:write:task:create` |
108
+ | Query handler | `<feature>:query:<entity>:<verb>` | `tasks:query:task:list` |
109
+ | Screen | `<feature>:screen:<id>` | `tasks:screen:task-list` |
110
+ | Nav entry | `<feature>:nav:<id>` | `tasks:nav:tasks` |
111
+ | Translation key | `<feature>:<path>` | `tasks:nav.tasks` |
112
+ | Entity field label | `<feature>:entity:<entity>:field:<field>` | `tasks:entity:task:field:title` |
113
+
114
+ Inside one feature, a screen id without prefix (`screen: "task-edit"`) refers
115
+ to a screen of the same feature.
116
+
117
+ ## Where things live in an app
118
+
119
+ The scaffold (`bun create kumiko-app <name>`) produces this layout. Keep it.
120
+
121
+ | Path | Content |
122
+ | --- | --- |
123
+ | `src/run-config.ts` | `APP_FEATURES`, the mounted feature list |
124
+ | `src/features/<name>/feature.ts` | `defineFeature(...)` with entities, handlers, screens, navs |
125
+ | `src/features/<name>/index.ts` | Public exports of the feature |
126
+ | `src/features/<name>/web/index.ts` | Client feature definition |
127
+ | `src/client.tsx` | Web entry, mounts the client features |
128
+ | `src/__tests__/*.test.ts` | Unit tests, no services |
129
+ | `src/__tests__/*.integration.test.ts` | Integration tests against Postgres and Redis |
130
+ | `e2e/*.spec.ts` | Playwright tests against a booted app |
131
+ | `kumiko/schema.ts`, `kumiko/migrations/` | Database schema and generated migrations |
132
+
133
+ The scaffolded `src/features/tasks/feature.ts` and
134
+ `src/__tests__/tasks.integration.test.ts` are the smallest complete pair of
135
+ feature and test. Start from them.
136
+
137
+ ## Commands in an app
138
+
139
+ | Goal | Command |
140
+ | --- | --- |
141
+ | Run the app with demo data | `bun dev` |
142
+ | Boot validation without serving | `bun run boot` |
143
+ | Types | `bun run typecheck` |
144
+ | Lint | `bun run lint` |
145
+ | Unit tests | `bun run test` |
146
+ | Integration tests (needs `docker compose up -d`) | `bun run test:integration` |
147
+ | Browser tests | `bun run e2e` |
148
+ | New migration after an entity change | `bun run schema:generate` |
149
+ | Apply migrations | `bun run schema:apply` |
150
+
151
+ ## Rules: never this, do that
152
+
153
+ These rules are enforced by boot validation, guards or review. Breaking them
154
+ produces code that compiles and then fails in production.
155
+
156
+ ### Data and events
157
+
158
+ - Never `INSERT`, `UPDATE` or `DELETE` on an entity table or the events
159
+ table, and never call a raw database client for it. Write through a
160
+ handler: `r.crud`, the `defineEntity*Handler` helpers, the executor from
161
+ `createEntityExecutor`, or `emitEvent` / `ctx.appendEvent`.
162
+ - Never store a value that can be computed. Derive it in a query or a
163
+ projection.
164
+ - Never hand-write SQL for reads that an executor `list` or `detail` covers.
165
+ Use `r.queryHandler` with the executor, or a projection with
166
+ `ctx.queryProjection`.
167
+ - Never change an event schema in place once it has been emitted. Version it
168
+ (see `agents/examples/event-sourcing.md`).
169
+ - Never read or write another tenant's data. `ctx.db` is tenant scoped on
170
+ purpose. Cross-tenant work belongs in system features with explicit
171
+ system access.
172
+
173
+ ### Handlers and access
174
+
175
+ - Never register a handler without `access`. Use roles, or `openToAll` with
176
+ a concrete `reason`.
177
+ - Never throw plain `Error` for an expected domain failure. Return a typed
178
+ failure from `@cosmicdrift/kumiko-framework/errors` (for example
179
+ `failNotFound(entity, id)`); it reaches the client as a structured error
180
+ with a reason and, where documented, a `docsUrl`.
181
+ - Never parse request input by hand. The Zod schema of the handler is the
182
+ boundary; inside the handler the payload is typed.
183
+ - Read-modify-write updates pass the `version` they read, so concurrent
184
+ writes fail with `version_conflict` instead of overwriting each other.
185
+
186
+ ### Types
187
+
188
+ - Never use `any`. Validate external data once at the boundary (Zod), then
189
+ work with the inferred type.
190
+ - Never use `as` to force a type except at a real system boundary. Narrow
191
+ unions with a discriminant and `unknown` with a type guard.
192
+ - Never write a magic string where a union type or a QN exists.
193
+
194
+ ### UI
195
+
196
+ - Never write your own JSX for a list, form, detail page or dashboard.
197
+ Declare a screen. Use `type: "custom"` only when no screen type fits.
198
+ - Inside a custom screen, never build your own card, table, badge, button or
199
+ chart. Use the widgets from `@cosmicdrift/kumiko-renderer-web`
200
+ (`agents/widgets.md`) and the primitives from `usePrimitives()`.
201
+ - Never fetch data with `useEffect` plus `fetch`. Use `useQuery`
202
+ (`live: true` for server-sent updates) and `useMutation`.
203
+ - Never use inline styles, hex colors or Tailwind palette classes. Use the
204
+ theme tokens (`bg-primary`, `text-status-ok`, ...).
205
+ - Never put user-facing text in code. Use translation keys and `t("...")`;
206
+ register the texts with `r.translations` and the client translations.
207
+
208
+ ### Tests
209
+
210
+ - Integration tests run against the real stack over real HTTP. In an app:
211
+ `setupAppTestStack([feature])` plus `seedTenant(stack)` from
212
+ `@cosmicdrift/kumiko-testing`, then `tenant.api.writeOk` and
213
+ `tenant.api.queryOk`. Never assemble a dispatcher yourself
214
+ (`createTestDispatcher` or similar); it skips the production wiring.
215
+ - Every test seeds its own tenant. No shared tenant, no global fixtures.
216
+ - Assert behavior through handlers and their results, not internal tables.
217
+ - A red or flaky test gets diagnosed. Never fix it with a longer timeout, a
218
+ retry, `serial` or fewer workers.
219
+ - Tests that call real external providers end in `.real.test.ts` and run
220
+ only via `bun run test:real`, never in CI.
221
+
222
+ ### Boot errors
223
+
224
+ - Boot validation stops the app when a declaration is inconsistent: an
225
+ unknown field in a column, a nav pointing at a missing screen, an
226
+ encrypted field marked searchable. The message names the
227
+ feature, the place and the fix. Apply the fix the message names instead
228
+ of working around the check.
229
+
230
+ ## I need X, use Y
231
+
232
+ Before building anything here, search `agents/features.md`: auth, tenants,
233
+ mail, files, jobs, billing, audit, notifications and GDPR flows already exist
234
+ as bundled features.
235
+
236
+ | I need | Use | Read |
237
+ | --- | --- | --- |
238
+ | A CRUD entity with list and edit | `createEntity` + `r.crud` + `entityList` / `entityEdit` screens | `agents/examples/basic-entity.md` |
239
+ | Business logic beyond CRUD | `r.writeHandler` / `r.queryHandler` with `createEntityExecutor` | `agents/examples/custom-handlers.md` |
240
+ | Relations between entities | `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
241
+ | Structured sub-objects in a field | Embedded fields | `agents/examples/embedded.md` |
242
+ | List, edit, custom screens and navs | `r.screen`, `r.nav` | `agents/examples/screens-nav.md` |
243
+ | A record page with header, metrics, tabs | `projectionDetail` with `header`, `metrics`, `layout.mode: "tabs"` | `agents/examples/record-detail-layout.md` |
244
+ | A multi-step form | `layout.mode: "wizard"` | `agents/examples/wizard-form.md` |
245
+ | A form inside a drawer | `RenderEdit` in a `Drawer` | `agents/examples/embedded-entity-form.md` |
246
+ | Role-based access | `access: { roles }`, `openToAll` | `agents/examples/access-control.md` |
247
+ | Field-level access | Field access rules | `agents/examples/field-access.md` |
248
+ | Row ownership | Ownership rules | `agents/examples/ownership.md` |
249
+ | Tenant separation | Built in; see what it guarantees | `agents/examples/tenant-isolation.md` |
250
+ | Public or anonymous endpoints | `anonymous` role | `agents/examples/anonymous-access.md` |
251
+ | Allowed state changes | Entity transitions | `agents/examples/state-machine.md` |
252
+ | React to a write in another feature | `r.defineEvent` + `emitEvent` + `r.multiStreamProjection` | `agents/examples/cross-feature-events.md` |
253
+ | Projections, event versions, history | `r.projection`, `ctx.loadAggregate`, `ctx.queryProjection` | `agents/examples/event-sourcing.md` |
254
+ | Before or after save logic | Lifecycle hooks | `agents/examples/lifecycle-hooks.md` |
255
+ | Typed errors with reasons | `@cosmicdrift/kumiko-framework/errors` | `agents/examples/error-contract.md` |
256
+ | Translations | `r.translations` + client translations | `agents/examples/i18n.md` |
257
+ | Config per tenant or system | Managed config | `agents/examples/managed-config.md` |
258
+ | Tenant defaults (currency, locale) | Tenant settings | `agents/examples/tenant-settings.md` |
259
+ | Secrets such as API keys | Secrets with envelope encryption | `agents/examples/secrets-demo.md` |
260
+ | Personal data and erasure | `personal` / `find` annotations, crypto shredding | `agents/examples/crypto-shredding-hr.md` |
261
+ | GDPR export and forget for my data | `user-data-rights` extension | `agents/examples/user-data-rights.md` |
262
+ | Full-text search | Search feature | `agents/examples/search.md` |
263
+ | Live updates in the browser | Server-sent events, `useQuery({ live: true })` | `agents/examples/realtime-sse.md` |
264
+ | Feature toggles | Toggleable features | `agents/examples/feature-toggles.md` |
265
+ | Rate limits | Rate limiting | `agents/examples/rate-limiting.md` |
266
+ | Idempotent writes | Request id dedupe | `agents/examples/idempotency.md` |
267
+ | File variants and images | File fields, derivatives | `agents/examples/files-post-processing.md` |
268
+ | Money fields | Currency fields | `agents/examples/currencies-per-tenant.md` |
269
+ | Dates, times, time zones | Time-aware field types, `ctx.tz` | `agents/examples/timezones.md` |
270
+ | Tags or notes on any entity | Tags, notes history | `agents/examples/tags-basic.md`, `agents/examples/notes-history-basic.md` |
271
+ | Tenant-defined extra fields | Custom fields | `agents/examples/custom-fields-basic.md` |
272
+ | Lookup tables and seeds | Upsert seeds | `agents/examples/reference-data.md` |
273
+ | Multi-step background flows | Workflow engine | `agents/examples/workflow-engine.md` |
274
+ | A mount invariant for my feature | `r.bootCheck` | `agents/examples/boot-check.md` |
275
+ | A widget for a custom screen | Search by concern | `agents/widgets.md` |
276
+ | A bundled feature and its config | Search by name or purpose | `agents/features.md` |
277
+
278
+ ## Reading an example
279
+
280
+ Each `agents/examples/<name>.md` starts with the sample's README, then every
281
+ source file with its path. `src/feature.ts` holds the feature;
282
+ `src/__tests__/*.integration.test.ts` shows how it is tested. Samples inside
283
+ the framework repo set up their stack with `setupTestStack` from
284
+ `@cosmicdrift/kumiko-framework/stack` and create tables in the test. In an
285
+ app, `setupAppTestStack` and `seedTenant` do that for you; keep the
286
+ assertions, take the setup from your scaffolded integration test.
287
+
288
+ Some samples import from `@app/define`. That alias points at the generated
289
+ `.kumiko/define.ts`, which re-exports the engine with event types narrowed to
290
+ the app's own `r.defineEvent` declarations. `bun dev` and `bun run build`
291
+ regenerate it; never edit it. Without it, import the same names from
292
+ `@cosmicdrift/kumiko-framework/engine`.
293
+
294
+ ## Package entry points
295
+
296
+ Import only from these subpaths of `@cosmicdrift/kumiko-framework`. Deep
297
+ imports into `src/` or `dist/` break on the next release.
298
+
299
+ | Subpath | Use it for |
300
+ | --- | --- |
301
+ | `/engine` | `defineFeature`, `createEntity`, field factories, handler helpers, executors, events |
302
+ | `/errors` | Typed failures and error helpers |
303
+ | `/stack` | Test stack, `TestUsers`, `createTestUser` |
304
+ | `/testing` | Assertion helpers such as `expectErrorIncludes` |
305
+ | `/ui-types` | Screen definition types (`EntityListScreenDefinition`, ...) |
306
+ | `/db` | `buildEntityTable` and database helpers for tests and migrations |
307
+ | `/event-store` | Event store helpers |
@@ -0,0 +1,355 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/access-control. Do not edit. -->
2
+
3
+ # access-control
4
+
5
+ Default-deny access rules, openToAll vs. role-based handlers, and auto-indices for foreign keys.
6
+
7
+ ## README
8
+
9
+ # Access Control
10
+
11
+ Default-deny access rules and FK indices via relations.
12
+
13
+ ## What it shows
14
+
15
+ - Handler `access` roles as the default-deny gate
16
+ - Relation-declared FK indices for join-friendly schemas
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/access-control
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
37
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
38
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
39
+ import {
40
+ setupTestStack,
41
+ type TestStack,
42
+ TestUsers,
43
+ unsafeCreateEntityTable,
44
+ } from "@cosmicdrift/kumiko-framework/stack";
45
+ import { z } from "zod";
46
+ import {
47
+ accessControlFeature,
48
+ projectEntity,
49
+ projectTable,
50
+ taskEntity,
51
+ taskTable,
52
+ } from "../feature";
53
+
54
+ let stack: TestStack;
55
+ const admin = TestUsers.admin; // { id: 1, tenantId: 1, roles: ["Admin"] }
56
+ const user = TestUsers.user; // { id: 2, tenantId: 1, roles: ["User"] }
57
+
58
+ beforeAll(async () => {
59
+ stack = await setupTestStack({ features: [accessControlFeature] });
60
+ await unsafeCreateEntityTable(stack.db, projectEntity);
61
+ await unsafeCreateEntityTable(stack.db, taskEntity);
62
+ await createEventsTable(stack.db);
63
+ });
64
+
65
+ afterAll(async () => {
66
+ await stack?.cleanup();
67
+ });
68
+
69
+ describe("default-deny: refuses handlers without access", () => {
70
+ test("throws at registration when a handler lacks an access rule", () => {
71
+ // `access` is required since fw#2855 — the registrar throws before validateBoot ever runs.
72
+ expect(() =>
73
+ defineFeature("broken-feature", (r) => {
74
+ // @ts-expect-error — No { access: ... }: registration must reject this.
75
+ r.writeHandler("forgotAccess", z.object({ name: z.string() }), async () => ({
76
+ isSuccess: true as const,
77
+ data: {},
78
+ }));
79
+ }),
80
+ ).toThrow(/requires schema \+ handler \+ options\.access/);
81
+ });
82
+ });
83
+
84
+ describe("role-based vs openToAll", () => {
85
+ test("Admin-only handler rejects a User caller with access_denied", async () => {
86
+ const raw = await stack.http.write(
87
+ "access-control:write:project:create",
88
+ { name: "Rejected", ownerId: "11111111-0000-4000-8000-000000000001" },
89
+ user,
90
+ );
91
+ const body = (await raw.json()) as { isSuccess: boolean; error?: { code: string } };
92
+ expect(body.isSuccess).toBe(false);
93
+ expect(body.error?.code).toBe("access_denied");
94
+ });
95
+
96
+ test("Admin-only handler accepts an Admin caller", async () => {
97
+ await stack.http.writeOk(
98
+ "access-control:write:project:create",
99
+ { name: "Alpha", ownerId: "11111111-0000-4000-8000-000000000001" },
100
+ admin,
101
+ );
102
+ });
103
+
104
+ test("openToAll handler accepts any authenticated user", async () => {
105
+ const proj = await stack.http.writeOk<{ data: { id: string } }>(
106
+ "access-control:write:project:create",
107
+ { name: "Shared", ownerId: "11111111-0000-4000-8000-000000000001" },
108
+ admin,
109
+ );
110
+ await stack.http.writeOk(
111
+ "access-control:write:task:create",
112
+ { title: "User's task", projectId: proj.data.id },
113
+ user,
114
+ );
115
+ });
116
+ });
117
+
118
+ describe("auto-indices from relations", () => {
119
+ test("task table has an index on project_id because of the belongsTo relation", () => {
120
+ const names = taskTable.indexes.map((i) => i.name);
121
+
122
+ expect(names).toContain("read_ac_tasks_tenant_id_idx");
123
+ expect(names).toContain("read_ac_tasks_project_id_idx");
124
+ });
125
+
126
+ test("project table has only the tenant_id index (no belongsTo relations)", () => {
127
+ expect(projectTable.indexes.map((i) => i.name)).toEqual(["read_ac_projects_tenant_id_idx"]);
128
+ });
129
+ });
130
+
131
+ describe("optimistic locking: version required in update schema", () => {
132
+ test("update without version fails schema validation before reaching the crud executor", async () => {
133
+ const proj = await stack.http.writeOk<{ data: { id: string } }>(
134
+ "access-control:write:project:create",
135
+ { name: "VersionedProject", ownerId: "11111111-0000-4000-8000-000000000001" },
136
+ admin,
137
+ );
138
+ const created = await stack.http.writeOk<{ data: { id: string } }>(
139
+ "access-control:write:task:create",
140
+ { title: "Needs version", projectId: proj.data.id },
141
+ user,
142
+ );
143
+
144
+ const raw = await stack.http.write(
145
+ "access-control:write:task:update",
146
+ // Missing `version` — schema rejects before any DB call.
147
+ { id: created.data.id, changes: { title: "Updated" } },
148
+ user,
149
+ );
150
+ const body = (await raw.json()) as { isSuccess: boolean; error?: { code: string } };
151
+ expect(body.isSuccess).toBe(false);
152
+ expect(body.error?.code).toBe("validation_error");
153
+ });
154
+
155
+ test("update with correct version succeeds", async () => {
156
+ const proj = await stack.http.writeOk<{ data: { id: string } }>(
157
+ "access-control:write:project:create",
158
+ { name: "VersionedOk", ownerId: "11111111-0000-4000-8000-000000000001" },
159
+ admin,
160
+ );
161
+ const created = await stack.http.writeOk<{ data: { id: string } }>(
162
+ "access-control:write:task:create",
163
+ { title: "Task v1", projectId: proj.data.id },
164
+ user,
165
+ );
166
+
167
+ await stack.http.writeOk(
168
+ "access-control:write:task:update",
169
+ { id: created.data.id, version: 1, changes: { title: "Task v2" } },
170
+ user,
171
+ );
172
+ });
173
+ });
174
+
175
+ // Ensures this sample is wired through the full stack — if any of its
176
+ // handlers regress to missing access or broken schemas, boot or this list
177
+ // query will fail loudly.
178
+ describe("end-to-end", () => {
179
+ test("list returns seeded tasks across users", async () => {
180
+ const proj = await stack.http.writeOk<{ data: { id: string } }>(
181
+ "access-control:write:project:create",
182
+ { name: "ListTarget", ownerId: "11111111-0000-4000-8000-000000000001" },
183
+ admin,
184
+ );
185
+
186
+ await stack.http.writeOk(
187
+ "access-control:write:task:create",
188
+ { title: "t1", projectId: proj.data.id },
189
+ user,
190
+ );
191
+ await stack.http.writeOk(
192
+ "access-control:write:task:create",
193
+ { title: "t2", projectId: proj.data.id },
194
+ admin,
195
+ );
196
+
197
+ const list = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
198
+ "access-control:query:task:list",
199
+ {},
200
+ user,
201
+ );
202
+ expect(list.rows.length).toBeGreaterThanOrEqual(2);
203
+ });
204
+ });
205
+ ```
206
+
207
+ ### src/feature.ts
208
+
209
+ ```ts
210
+ // Access Control + FK-Indices Sample
211
+ //
212
+ // Demonstrates the framework's security + performance defaults:
213
+ //
214
+ // 1. Default-deny: every handler must declare an access rule. Boot fails
215
+ // loudly when one is missing. Two shapes:
216
+ // { roles: ["Admin", ...] } — role allowlist
217
+ // { openToAll: { reason: "..." } } — any authenticated user, with a required reason
218
+ //
219
+ // 2. Foreign-key relations declared via r.relation() become indexed columns
220
+ // automatically — no manual CREATE INDEX. Inspect the Drizzle table
221
+ // config in tests to verify.
222
+
223
+ import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
224
+ import {
225
+ createEntity,
226
+ createTextField,
227
+ defineEntityCreateHandler,
228
+ defineEntityListHandler,
229
+ defineFeature,
230
+ defineRoles,
231
+ } from "@cosmicdrift/kumiko-framework/engine";
232
+ import { z } from "zod";
233
+
234
+ // Typed role registry — `defineRoles` returns an object keyed by role name
235
+ // so access rules can reference `AccessControlRoles.Admin` instead of the
236
+ // magic string "Admin". Typos become compile errors, renames refactor.
237
+ export const AccessControlRoles = defineRoles(["Admin"] as const);
238
+
239
+ export const projectEntity = createEntity({
240
+ table: "read_ac_projects",
241
+ fields: {
242
+ name: createTextField({ personal: false, reason: "is_business_data", required: true }),
243
+ ownerId: createTextField({ personal: "ref", required: true }),
244
+ },
245
+ });
246
+
247
+ export const taskEntity = createEntity({
248
+ table: "read_ac_tasks",
249
+ fields: {
250
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
251
+ assigneeId: createTextField({ personal: "ref" }),
252
+ projectId: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
253
+ },
254
+ });
255
+
256
+ // The "owner" relation points at a hypothetical external `user` entity — in
257
+ // a real app this would be @cosmicdrift/kumiko-bundled-features/user. For the sample we
258
+ // keep it internal: projectRelations only holds FK metadata (no cross-entity
259
+ // resolution) so the sample stays self-contained.
260
+ export const projectRelations = {} as const;
261
+
262
+ export const taskRelations = {
263
+ project: { type: "belongsTo", target: "project", foreignKey: "projectId" },
264
+ } as const;
265
+
266
+ // Relations flow into buildEntityTable — every belongsTo FK gets its own
267
+ // index on this table.
268
+ export const projectTable = buildEntityTable("project", projectEntity, {
269
+ relations: projectRelations,
270
+ });
271
+ export const taskTable = buildEntityTable("task", taskEntity, {
272
+ relations: taskRelations,
273
+ });
274
+
275
+ export const accessControlFeature = defineFeature("access-control", (r) => {
276
+ r.entity("project", projectEntity);
277
+ r.entity("task", taskEntity);
278
+ r.relation("task", "project", taskRelations.project);
279
+
280
+ // Admin-only: standard create — no business logic, helper handles schema +
281
+ // executor + handler-body.
282
+ r.writeHandler(
283
+ defineEntityCreateHandler("project", projectEntity, {
284
+ access: { roles: [AccessControlRoles.Admin] },
285
+ }),
286
+ );
287
+
288
+ // Custom create: assigneeId defaults to the caller when omitted, so we
289
+ // hand-write this handler instead of using the helper. The framework helpers
290
+ // are deliberately scope-limited — opt out the moment you need defaults.
291
+ const taskExecutor = createEventStoreExecutor(taskTable, taskEntity, { entityName: "task" });
292
+ r.writeHandler(
293
+ "task:create",
294
+ z.object({
295
+ title: z.string().min(1),
296
+ projectId: z.uuid(),
297
+ assigneeId: z.uuid().optional(),
298
+ }),
299
+ async (event, ctx) =>
300
+ taskExecutor.create(
301
+ {
302
+ ...event.payload,
303
+ assigneeId: event.payload.assigneeId ?? event.user.id,
304
+ },
305
+ event.user,
306
+ ctx.db,
307
+ ),
308
+ {
309
+ access: {
310
+ openToAll: {
311
+ reason:
312
+ "any signed-in tenant user may create a task; assigneeId defaults to the caller when omitted",
313
+ },
314
+ },
315
+ },
316
+ );
317
+
318
+ // Update requires version — optimistic locking is the default and this
319
+ // narrowed schema (only title + assigneeId mutable) is stricter than the
320
+ // helper's generic update schema, so we keep it hand-written.
321
+ r.writeHandler(
322
+ "task:update",
323
+ z.object({
324
+ id: z.uuid(),
325
+ version: z.number(),
326
+ changes: z.object({
327
+ title: z.string().min(1).optional(),
328
+ assigneeId: z.uuid().optional(),
329
+ }),
330
+ }),
331
+ async (event, ctx) => taskExecutor.update(event.payload, event.user, ctx.db),
332
+ {
333
+ access: {
334
+ openToAll: {
335
+ reason:
336
+ "any signed-in tenant user may update a task; optimistic locking (version) " +
337
+ "guards against concurrent overwrites, not caller identity",
338
+ },
339
+ },
340
+ },
341
+ );
342
+
343
+ // Self-service list — no custom logic.
344
+ r.queryHandler(
345
+ defineEntityListHandler("task", taskEntity, {
346
+ access: {
347
+ openToAll: {
348
+ reason:
349
+ "any signed-in tenant user may list tasks; there is no per-user filter in this recipe",
350
+ },
351
+ },
352
+ }),
353
+ );
354
+ });
355
+ ```