@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 (281) hide show
  1. package/AGENTS.md +338 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +446 -0
  4. package/agents/examples/anonymous-access.md +288 -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 +319 -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 +315 -0
  20. package/agents/examples/custom-handlers.md +444 -0
  21. package/agents/examples/dashboard-stats.md +229 -0
  22. package/agents/examples/delivery-notifications.md +432 -0
  23. package/agents/examples/designer-demo.md +86 -0
  24. package/agents/examples/e2e-generator.md +211 -0
  25. package/agents/examples/embedded-entity-form.md +598 -0
  26. package/agents/examples/embedded.md +649 -0
  27. package/agents/examples/encrypted-tenant-config.md +414 -0
  28. package/agents/examples/error-contract.md +535 -0
  29. package/agents/examples/event-sourcing.md +1287 -0
  30. package/agents/examples/feature-toggles.md +274 -0
  31. package/agents/examples/field-access.md +271 -0
  32. package/agents/examples/files-post-processing.md +433 -0
  33. package/agents/examples/i18n.md +166 -0
  34. package/agents/examples/idempotency.md +179 -0
  35. package/agents/examples/inbound-mail-triage.md +315 -0
  36. package/agents/examples/lane-routing.md +246 -0
  37. package/agents/examples/ledger-banking.md +215 -0
  38. package/agents/examples/ledger-invoicing.md +236 -0
  39. package/agents/examples/legal-pages.md +393 -0
  40. package/agents/examples/lifecycle-hooks.md +368 -0
  41. package/agents/examples/managed-config.md +428 -0
  42. package/agents/examples/managed-pages.md +353 -0
  43. package/agents/examples/notes-history-basic.md +346 -0
  44. package/agents/examples/ownership.md +404 -0
  45. package/agents/examples/pdf-audit-export.md +306 -0
  46. package/agents/examples/pipeline-basics.md +792 -0
  47. package/agents/examples/public-share-token.md +351 -0
  48. package/agents/examples/rate-limiting.md +211 -0
  49. package/agents/examples/realtime-sse.md +187 -0
  50. package/agents/examples/record-detail-layout.md +297 -0
  51. package/agents/examples/reference-data.md +151 -0
  52. package/agents/examples/relations.md +280 -0
  53. package/agents/examples/screens-nav.md +353 -0
  54. package/agents/examples/search.md +233 -0
  55. package/agents/examples/secrets-demo.md +417 -0
  56. package/agents/examples/security-baseline.md +187 -0
  57. package/agents/examples/seed-migration.md +99 -0
  58. package/agents/examples/session-revocation.md +250 -0
  59. package/agents/examples/state-machine.md +920 -0
  60. package/agents/examples/tags-basic.md +401 -0
  61. package/agents/examples/tenant-isolation.md +173 -0
  62. package/agents/examples/tenant-settings.md +275 -0
  63. package/agents/examples/tier-admin.md +347 -0
  64. package/agents/examples/timezones.md +366 -0
  65. package/agents/examples/user-data-rights.md +418 -0
  66. package/agents/examples/user-profile.md +226 -0
  67. package/agents/examples/webhook-step.md +597 -0
  68. package/agents/examples/wizard-form.md +379 -0
  69. package/agents/examples/workflow-engine.md +1731 -0
  70. package/agents/examples/writeform-section.md +300 -0
  71. package/agents/examples.md +75 -0
  72. package/agents/features.md +768 -0
  73. package/agents/widgets.md +181 -0
  74. package/dist/api/auth-middleware.d.ts +2 -0
  75. package/dist/api/auth-middleware.js +7 -0
  76. package/dist/api/auth-routes.d.ts +0 -1
  77. package/dist/api/auth-routes.js +49 -46
  78. package/dist/api/extra-route.d.ts +3 -0
  79. package/dist/api/index.d.ts +2 -2
  80. package/dist/api/index.js +1 -1
  81. package/dist/api/routes.js +46 -17
  82. package/dist/api/server.d.ts +4 -3
  83. package/dist/api/server.js +39 -10
  84. package/dist/api/session-cookies.d.ts +1 -0
  85. package/dist/api/session-cookies.js +20 -5
  86. package/dist/api/sse-route.js +16 -1
  87. package/dist/bun-db/connection.js +3 -16
  88. package/dist/bun-db/index.d.ts +1 -1
  89. package/dist/bun-db/index.js +1 -1
  90. package/dist/bun-db/query.d.ts +15 -3
  91. package/dist/bun-db/query.js +76 -24
  92. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  93. package/dist/crypto/owner-transition-rekey.js +7 -3
  94. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  95. package/dist/crypto/pii-field-encryption.js +9 -2
  96. package/dist/crypto/subject-resolver.d.ts +1 -0
  97. package/dist/crypto/subject-resolver.js +7 -0
  98. package/dist/db/api.d.ts +4 -3
  99. package/dist/db/apply-entity-event.js +26 -2
  100. package/dist/db/blind-index-cleanup.js +2 -1
  101. package/dist/db/bun-provider.js +9 -18
  102. package/dist/db/collect-table-metas.js +11 -7
  103. package/dist/db/dialect.d.ts +1 -1
  104. package/dist/db/dialect.js +2 -0
  105. package/dist/db/eagerload.js +4 -3
  106. package/dist/db/event-store-executor-context.d.ts +6 -6
  107. package/dist/db/event-store-executor-context.js +25 -22
  108. package/dist/db/event-store-executor-read.js +31 -16
  109. package/dist/db/event-store-executor-write.d.ts +1 -1
  110. package/dist/db/event-store-executor-write.js +84 -63
  111. package/dist/db/index.d.ts +2 -1
  112. package/dist/db/index.js +2 -1
  113. package/dist/db/lock-guard.d.ts +4 -0
  114. package/dist/db/lock-guard.js +14 -0
  115. package/dist/db/migrate-generator.js +1 -1
  116. package/dist/db/migrate-runner.d.ts +1 -0
  117. package/dist/db/migrate-runner.js +64 -38
  118. package/dist/db/queries/backfill-pii.js +63 -25
  119. package/dist/db/queries/event-consumer.d.ts +6 -6
  120. package/dist/db/queries/event-consumer.js +2 -1
  121. package/dist/db/queries/event-store-transfer.js +15 -4
  122. package/dist/db/queries/event-store.d.ts +1 -1
  123. package/dist/db/queries/event-store.js +11 -10
  124. package/dist/db/queries/pii-backfill-state.js +3 -2
  125. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  126. package/dist/db/queries/postgres-array-literal.js +6 -0
  127. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  128. package/dist/db/queries/projection-rebuild.js +3 -2
  129. package/dist/db/queries/shadow-swap.d.ts +1 -1
  130. package/dist/db/queries/shadow-swap.js +12 -8
  131. package/dist/db/query.d.ts +1 -1
  132. package/dist/db/query.js +1 -1
  133. package/dist/db/retired-framework-tables.js +22 -4
  134. package/dist/db/table-builder.js +6 -2
  135. package/dist/db/tenant-db-runner.d.ts +4 -0
  136. package/dist/db/tenant-db-runner.js +17 -0
  137. package/dist/db/tenant-db.d.ts +4 -0
  138. package/dist/db/tenant-db.js +50 -5
  139. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  140. package/dist/derivatives/delete-stored-file.js +9 -0
  141. package/dist/derivatives/derivatives-context.js +1 -1
  142. package/dist/derivatives/index.d.ts +1 -1
  143. package/dist/derivatives/index.js +1 -1
  144. package/dist/engine/boot-validator/access-declarations.js +2 -2
  145. package/dist/engine/boot-validator/api-ext.js +3 -3
  146. package/dist/engine/boot-validator/boot-check.js +1 -1
  147. package/dist/engine/boot-validator/config-deps.js +13 -13
  148. package/dist/engine/boot-validator/detail-screens.js +1 -1
  149. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  150. package/dist/engine/boot-validator/entity-handler.js +87 -35
  151. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  152. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  153. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  154. package/dist/engine/boot-validator/nav.js +1 -1
  155. package/dist/engine/boot-validator/pii-retention.js +8 -8
  156. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  157. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  158. package/dist/engine/boot-validator/query-refs.js +14 -2
  159. package/dist/engine/boot-validator/screens.d.ts +2 -1
  160. package/dist/engine/boot-validator/screens.js +153 -95
  161. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  162. package/dist/engine/dedupe-features.js +31 -4
  163. package/dist/engine/define-handler.d.ts +2 -2
  164. package/dist/engine/define-handler.js +8 -1
  165. package/dist/engine/entity-handlers.js +14 -7
  166. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  167. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  168. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  169. package/dist/engine/feature-ast/render.js +10 -2
  170. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  171. package/dist/engine/feature-entity-handlers.js +12 -3
  172. package/dist/engine/handler-field-references.d.ts +21 -2
  173. package/dist/engine/handler-field-references.js +55 -17
  174. package/dist/engine/index.d.ts +3 -3
  175. package/dist/engine/index.js +1 -1
  176. package/dist/engine/schema-builder.js +2 -1
  177. package/dist/engine/screen-helpers.d.ts +1 -0
  178. package/dist/engine/screen-helpers.js +2 -2
  179. package/dist/engine/soft-delete-cleanup.js +10 -3
  180. package/dist/engine/steps/_read-source.js +5 -2
  181. package/dist/engine/steps/retry.js +2 -1
  182. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  183. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  184. package/dist/engine/template-variables.d.ts +3 -0
  185. package/dist/engine/template-variables.js +3 -0
  186. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  187. package/dist/engine/tier-resolver-extension.js +6 -0
  188. package/dist/engine/types/index.d.ts +1 -1
  189. package/dist/entrypoint/index.d.ts +2 -2
  190. package/dist/env/_zod-introspect.d.ts +1 -0
  191. package/dist/errors/classes.js +1 -0
  192. package/dist/errors/docs-url.js +6 -6
  193. package/dist/errors/index.d.ts +1 -1
  194. package/dist/errors/index.js +1 -1
  195. package/dist/errors/kumiko-error.d.ts +2 -0
  196. package/dist/errors/kumiko-error.js +2 -0
  197. package/dist/errors/subject-erased.d.ts +1 -0
  198. package/dist/errors/subject-erased.js +6 -0
  199. package/dist/errors/write-error-info.js +2 -0
  200. package/dist/event-store/admin-api.js +3 -2
  201. package/dist/event-store/errors.d.ts +9 -0
  202. package/dist/event-store/errors.js +20 -0
  203. package/dist/event-store/event-store.d.ts +6 -0
  204. package/dist/event-store/event-store.js +28 -6
  205. package/dist/event-store/index.d.ts +1 -1
  206. package/dist/event-store/index.js +1 -1
  207. package/dist/event-store/tenant-db-append.js +2 -2
  208. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  209. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  210. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  211. package/dist/files/bind-pending-file-refs.js +3 -3
  212. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  213. package/dist/files/bind-referenced-files.write.js +22 -13
  214. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  215. package/dist/files/create-file-ref-from-bytes.js +67 -4
  216. package/dist/files/create-file-ref.d.ts +24 -1
  217. package/dist/files/create-file-ref.js +67 -25
  218. package/dist/files/dispose-removed-file-refs.js +21 -6
  219. package/dist/files/feature.d.ts +1 -0
  220. package/dist/files/feature.js +2 -1
  221. package/dist/files/file-handle.d.ts +1 -1
  222. package/dist/files/file-handle.js +12 -0
  223. package/dist/files/file-routes.js +10 -6
  224. package/dist/files/index.d.ts +3 -3
  225. package/dist/files/index.js +2 -2
  226. package/dist/files/load-save-file-refs.d.ts +10 -0
  227. package/dist/files/load-save-file-refs.js +47 -0
  228. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  229. package/dist/files/purge-expired-file-ref-bytes.js +46 -21
  230. package/dist/files/resolve-content-type.js +40 -4
  231. package/dist/files/storage-tracking.js +5 -4
  232. package/dist/files/types.js +5 -8
  233. package/dist/files/zip-entry-names.d.ts +1 -0
  234. package/dist/files/zip-entry-names.js +46 -0
  235. package/dist/jobs/index.d.ts +1 -1
  236. package/dist/jobs/job-runner.d.ts +5 -1
  237. package/dist/jobs/job-runner.js +124 -37
  238. package/dist/observability/escape-hatch-report.d.ts +2 -1
  239. package/dist/observability/escape-hatch-report.js +48 -7
  240. package/dist/observability/index.d.ts +1 -1
  241. package/dist/observability/index.js +1 -1
  242. package/dist/observability/metrics-handle.d.ts +1 -0
  243. package/dist/observability/metrics-handle.js +7 -0
  244. package/dist/observability/standard-metrics.js +4 -4
  245. package/dist/pipeline/cascade-handler.js +5 -2
  246. package/dist/pipeline/dispatch-batch.js +79 -31
  247. package/dist/pipeline/dispatch-query.d.ts +2 -1
  248. package/dist/pipeline/dispatch-query.js +6 -6
  249. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  250. package/dist/pipeline/dispatch-shared.js +136 -48
  251. package/dist/pipeline/dispatch-stream.js +10 -4
  252. package/dist/pipeline/dispatch-write.js +34 -22
  253. package/dist/pipeline/dispatcher.js +3 -1
  254. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  255. package/dist/pipeline/event-dispatcher.js +11 -9
  256. package/dist/pipeline/event-retention.d.ts +1 -0
  257. package/dist/pipeline/event-retention.js +7 -1
  258. package/dist/pipeline/index.d.ts +1 -0
  259. package/dist/pipeline/index.js +1 -0
  260. package/dist/pipeline/notify-scope.js +3 -1
  261. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  262. package/dist/pipeline/pre-transaction-reservations.js +29 -9
  263. package/dist/rate-limit/bucket.d.ts +3 -3
  264. package/dist/rate-limit/bucket.js +14 -4
  265. package/dist/redis/cache-sync-bus.js +9 -3
  266. package/dist/schema-cli.js +12 -1
  267. package/dist/search/meilisearch-adapter.js +36 -9
  268. package/dist/stack/db.d.ts +2 -2
  269. package/dist/stack/test-stack.js +2 -1
  270. package/dist/testing/index.d.ts +1 -0
  271. package/dist/testing/index.js +1 -0
  272. package/dist/testing/multipart-helper.d.ts +6 -0
  273. package/dist/testing/multipart-helper.js +1 -1
  274. package/dist/testing/zip-fixture.d.ts +1 -0
  275. package/dist/testing/zip-fixture.js +33 -0
  276. package/dist/ui-types/card-column-roles.js +4 -2
  277. package/dist/ui-types/index.d.ts +1 -1
  278. package/dist/ui-types/index.js +1 -1
  279. package/dist/upgrade-cli.js +46 -13
  280. package/package.json +7 -5
  281. package/src/changes.json +512 -8
@@ -0,0 +1,315 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/custom-fields-basic. Do not edit. -->
2
+
3
+ # custom-fields-basic
4
+
5
+ Tenant-defined extra fields on an entity without writing migrations.
6
+
7
+ ## README
8
+
9
+ # Custom fields
10
+
11
+ Let a tenant add their own fields to your entity at runtime — without
12
+ writing a migration, without rebuilding your handler, without a code
13
+ change. The recipe wires a `property` entity into the `custom-fields`
14
+ bundle and drives a full *define → set → read* roundtrip.
15
+
16
+ The result: the tenant defines `internalNumber: text`, sets it on a
17
+ property, and reads it back flattened onto the row — looking exactly
18
+ like a first-class column.
19
+
20
+ ## What it shows
21
+
22
+ - **`customFieldsField()`** — a jsonb column factory that holds the
23
+ per-row custom field values. Add it to any entity you want
24
+ custom-field-capable.
25
+ - **`wireCustomFieldsFor(r, entityName, table)`** — wires the
26
+ bundle's projection: a multi-stream projection consumes
27
+ `customField.set` / `.cleared` events and writes them into the
28
+ jsonb column, and an entity-postQuery hook flattens the jsonb onto
29
+ the row at read-time.
30
+ - **No migrations at runtime** — the schema for custom fields lives
31
+ in two bundled tables (`field-definition` for the spec, jsonb on
32
+ the host row for the values). Defining a field is a write, not a
33
+ DDL.
34
+ - **Stammfeld-look on the response** — reads return `{ id, name,
35
+ internalNumber: "X-2042" }`. The consumer can't tell which fields
36
+ are first-class and which are custom.
37
+
38
+ ## Feature composition
39
+
40
+ ```
41
+ custom-fields → core bundle: events + write-handlers + MSP +
42
+ field-definition entity
43
+ property-management → our feature: opts the `property` entity into
44
+ custom-fields via wireCustomFieldsFor
45
+ ```
46
+
47
+ The `property-management` feature `r.requires("custom-fields")` — the
48
+ bundle is non-optional for this recipe because the wired entity would
49
+ otherwise have an empty jsonb column and no way to write into it.
50
+
51
+ ## Flow
52
+
53
+ 1. Tenant admin calls `custom-fields:write:define-tenant-field` to
54
+ declare `internalNumber: text` on `property`.
55
+ 2. App code creates a `property` row via
56
+ `property-management:write:property:create`.
57
+ 3. Tenant admin calls `custom-fields:write:set-custom-field` with the
58
+ `entityId`, `fieldKey: "internalNumber"`, `value: "X-2042"`.
59
+ 4. The bundle's MSP consumes the `customField.set` event and writes
60
+ the value into the property row's `customFields` jsonb column.
61
+ 5. The next `property-management:query:property:list` returns the row
62
+ with `internalNumber: "X-2042"` flattened onto the response — the
63
+ entity-postQuery hook merges the jsonb keys onto the row root.
64
+
65
+ ## When to reach for it
66
+
67
+ You ship a SaaS where every tenant wants one or two of their own
68
+ columns — internal IDs, vendor names, custom flags — and you don't
69
+ want to either ship them all as `extraFields1..extraFields5` or push
70
+ the tenant to a request-a-feature queue. Custom fields cover the
71
+ single-or-handful-of-extra-fields case without engineering
72
+ involvement.
73
+
74
+ If you need the field to drive business logic (`if vipFlag then ...`),
75
+ make it a first-class field instead — see [`basic-entity`](../basic-entity/).
76
+
77
+ ## Tests
78
+
79
+ The integration test under `src/__tests__/` walks two scenarios:
80
+
81
+ - Tenant defines a text field, sets a value, reads it back flat.
82
+ - Tenant defines a number field, sets it, reads it back with the
83
+ number type preserved.
84
+
85
+ ```bash
86
+ bun kumiko test integration samples/custom-fields-basic
87
+ ```
88
+
89
+ ## Source
90
+
91
+ The feature is ~80 lines (`src/feature.ts`). The integration test is
92
+ ~110 lines (`src/__tests__/feature.integration.test.ts`).
93
+
94
+ ## Source files
95
+
96
+ ### src/__tests__/feature.integration.test.ts
97
+
98
+ ```ts
99
+ // Custom-Fields Basic — integration test.
100
+ //
101
+ // Proves the full define → set → read roundtrip via the real dispatcher,
102
+ // MSP-pipeline and DB:
103
+ // 1. tenant defines `internalNumber` on `property`
104
+ // 2. property is created
105
+ // 3. customField is set → MSP writes into jsonb
106
+ // 4. list-query returns the value flattened onto the row
107
+ //
108
+ // This is the smallest evidence that wireCustomFieldsFor + the bundle's
109
+ // projection-hook + entity-postQuery flattening all line up.
110
+
111
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
112
+ import {
113
+ createCustomFieldsFeature,
114
+ fieldDefinitionEntity,
115
+ } from "@cosmicdrift/kumiko-bundled-features/custom-fields";
116
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
117
+ import {
118
+ createTestUser,
119
+ setupTestStack,
120
+ type TestStack,
121
+ testTenantId,
122
+ unsafeCreateEntityTable,
123
+ } from "@cosmicdrift/kumiko-framework/stack";
124
+ import { propertyEntity, propertyFeature } from "../feature";
125
+
126
+ // Each test runs in its own tenant, so rows from other tests never show up.
127
+ let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
128
+ let nextTenant = 1;
129
+ const customFields = createCustomFieldsFeature();
130
+
131
+ let stack: TestStack;
132
+
133
+ beforeAll(async () => {
134
+ stack = await setupTestStack({ features: [customFields, propertyFeature] });
135
+ await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
136
+ await unsafeCreateEntityTable(stack.db, propertyEntity);
137
+ await createEventsTable(stack.db);
138
+ });
139
+
140
+ afterAll(async () => {
141
+ await stack.cleanup();
142
+ });
143
+
144
+ beforeEach(() => {
145
+ nextTenant += 1;
146
+ admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
147
+ });
148
+
149
+ async function defineField(fieldKey: string, type = "text") {
150
+ return stack.http.writeOk(
151
+ "custom-fields:write:define-tenant-field",
152
+ {
153
+ entityName: "property",
154
+ fieldKey,
155
+ serializedField: { type },
156
+ required: false,
157
+ searchable: false,
158
+ displayOrder: 0,
159
+ },
160
+ admin,
161
+ );
162
+ }
163
+
164
+ async function setField(entityId: string, fieldKey: string, value: unknown) {
165
+ return stack.http.writeOk(
166
+ "custom-fields:write:set-custom-field",
167
+ { entityName: "property", entityId, fieldKey, value },
168
+ admin,
169
+ );
170
+ }
171
+
172
+ async function listProperties() {
173
+ return (await stack.http.queryOk("property-management:query:property:list", {}, admin)) as {
174
+ rows: Array<Record<string, unknown>>;
175
+ };
176
+ }
177
+
178
+ describe("custom-fields-basic recipe — define + set + read", () => {
179
+ test("tenant-defined field lands flat on the entity response", async () => {
180
+ const id = "11111111-1111-4000-8000-000000000001";
181
+
182
+ await defineField("internalNumber");
183
+ await stack.http.writeOk(
184
+ "property-management:write:property:create",
185
+ { id, name: "Hofgarten 12" },
186
+ admin,
187
+ );
188
+ await setField(id, "internalNumber", "X-2042");
189
+ await stack.eventDispatcher?.runOnce();
190
+
191
+ const { rows } = await listProperties();
192
+ const property = rows.find((row) => row["id"] === id);
193
+ expect(property?.["internalNumber"]).toBe("X-2042");
194
+ });
195
+
196
+ test("typed field: number value preserves its type", async () => {
197
+ const id = "22222222-2222-4000-8000-000000000002";
198
+
199
+ await defineField("tier", "number");
200
+ await stack.http.writeOk(
201
+ "property-management:write:property:create",
202
+ { id, name: "MultiField" },
203
+ admin,
204
+ );
205
+ await setField(id, "tier", 2);
206
+ await stack.eventDispatcher?.runOnce();
207
+
208
+ const property = (await listProperties()).rows.find((row) => row["id"] === id);
209
+ expect(property?.["tier"]).toBe(2);
210
+ });
211
+ });
212
+ ```
213
+
214
+ ### src/feature.ts
215
+
216
+ ```ts
217
+ // kumiko-feature-version: 1
218
+ // Custom-Fields Basic Sample
219
+ //
220
+ // Shows how a feature opts a tenant-owned entity into the custom-fields
221
+ // extension: tenants can define their own fields at runtime, set values
222
+ // per row, and read them back flattened onto the entity — without writing
223
+ // a single migration or extra handler.
224
+ //
225
+ // Flow:
226
+ // 1. App-author defines a `property` entity and wires it via
227
+ // `wireCustomFieldsFor(r, "property", propertyTable)`.
228
+ // 2. A tenant admin defines a field at runtime
229
+ // (e.g. `internalNumber: text`) via the bundle's CRUD.
230
+ // 3. Values are written via `custom-fields:write:set-custom-field`,
231
+ // not through the host entity's own write handler.
232
+ // 4. Reads return the field flattened onto the row — looks like a
233
+ // first-class column. The flattening is an entity-level postQuery
234
+ // hook (registered by wireCustomFieldsFor), so it fires for ANY
235
+ // query whose name maps to this entity — including the hand-written
236
+ // `property:list` below.
237
+
238
+ import {
239
+ customFieldsField,
240
+ wireCustomFieldsFor,
241
+ } from "@cosmicdrift/kumiko-bundled-features/custom-fields";
242
+ import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
243
+ import { createEntity, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
244
+ import { z } from "zod";
245
+
246
+ // --- Entity ---
247
+ //
248
+ // The field shape is declared as a plain object literal so the feature is
249
+ // self-describing Object-Form (what the AI/Designer should emit). The same
250
+ // literal is passed inline to `r.entity` below. `{ type: "jsonb" }` is
251
+ // exactly what `customFieldsField()` produces — the jsonb column the
252
+ // bundle's projection writes into; without it `wireCustomFieldsFor` has
253
+ // nowhere to land the values.
254
+
255
+ export const propertyEntity = createEntity({
256
+ table: "read_sample_cf_properties",
257
+ fields: {
258
+ name: { type: "text", required: true, maxLength: 200 },
259
+ customFields: customFieldsField(),
260
+ },
261
+ });
262
+
263
+ export const propertyTable = buildEntityTable("property", propertyEntity);
264
+
265
+ function propertyExecutor() {
266
+ return createEventStoreExecutor(propertyTable, propertyEntity, { entityName: "property" });
267
+ }
268
+
269
+ // --- Feature ---
270
+
271
+ export const propertyFeature = defineFeature("property-management", (r) => {
272
+ r.requires("custom-fields");
273
+
274
+ r.entity("property", {
275
+ table: "read_sample_cf_properties",
276
+ fields: {
277
+ name: { type: "text", required: true, maxLength: 200 },
278
+ customFields: { type: "jsonb" },
279
+ },
280
+ });
281
+
282
+ // Opt this entity into the custom-fields extension. This registers the
283
+ // multi-stream projection (MSP) that consumes customField.set / .cleared
284
+ // events and writes them into the `customFields` jsonb column, plus the
285
+ // entity postQuery hook that flattens the jsonb onto the row at read-time.
286
+ wireCustomFieldsFor(r, "property", propertyTable);
287
+
288
+ r.writeHandler({
289
+ name: "property:create",
290
+ schema: z.object({ id: z.string(), name: z.string() }),
291
+ access: { roles: ["TenantAdmin"] },
292
+ handler: async (event, ctx) =>
293
+ propertyExecutor().create(
294
+ { id: event.payload.id, name: event.payload.name, customFields: {} },
295
+ event.user,
296
+ ctx.db,
297
+ ),
298
+ });
299
+
300
+ // List query. Named `property:list` so it maps to the `property` entity
301
+ // (colon convention) — that mapping is what lets the entity postQuery
302
+ // hook from wireCustomFieldsFor flatten customFields onto each row. The
303
+ // handler itself just reads the tenant-scoped read table; ctx.db is
304
+ // already tenant-scoped.
305
+ r.queryHandler({
306
+ name: "property:list",
307
+ schema: z.object({}),
308
+ access: { roles: ["TenantAdmin"] },
309
+ handler: async (_query, ctx) => {
310
+ const rows = await ctx.db.selectMany(propertyTable);
311
+ return { rows };
312
+ },
313
+ });
314
+ });
315
+ ```