@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,314 @@
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 { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
117
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
118
+ import {
119
+ createTestUser,
120
+ setupTestStack,
121
+ type TestStack,
122
+ unsafeCreateEntityTable,
123
+ } from "@cosmicdrift/kumiko-framework/stack";
124
+ import { propertyEntity, propertyFeature } from "../feature";
125
+
126
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
127
+ const customFields = createCustomFieldsFeature();
128
+
129
+ let stack: TestStack;
130
+
131
+ beforeAll(async () => {
132
+ stack = await setupTestStack({ features: [customFields, propertyFeature] });
133
+ await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
134
+ await unsafeCreateEntityTable(stack.db, propertyEntity);
135
+ await createEventsTable(stack.db);
136
+ });
137
+
138
+ afterAll(async () => {
139
+ await stack.cleanup();
140
+ });
141
+
142
+ beforeEach(async () => {
143
+ await asRawClient(stack.db).unsafe(`DELETE FROM kumiko_events`);
144
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_sample_cf_properties`);
145
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
146
+ });
147
+
148
+ async function defineField(fieldKey: string, type = "text") {
149
+ return stack.http.writeOk(
150
+ "custom-fields:write:define-tenant-field",
151
+ {
152
+ entityName: "property",
153
+ fieldKey,
154
+ serializedField: { type },
155
+ required: false,
156
+ searchable: false,
157
+ displayOrder: 0,
158
+ },
159
+ admin,
160
+ );
161
+ }
162
+
163
+ async function setField(entityId: string, fieldKey: string, value: unknown) {
164
+ return stack.http.writeOk(
165
+ "custom-fields:write:set-custom-field",
166
+ { entityName: "property", entityId, fieldKey, value },
167
+ admin,
168
+ );
169
+ }
170
+
171
+ async function listProperties() {
172
+ return (await stack.http.queryOk("property-management:query:property:list", {}, admin)) as {
173
+ rows: Array<Record<string, unknown>>;
174
+ };
175
+ }
176
+
177
+ describe("custom-fields-basic recipe — define + set + read", () => {
178
+ test("tenant-defined field lands flat on the entity response", async () => {
179
+ const id = "11111111-1111-4000-8000-000000000001";
180
+
181
+ await defineField("internalNumber");
182
+ await stack.http.writeOk(
183
+ "property-management:write:property:create",
184
+ { id, name: "Hofgarten 12" },
185
+ admin,
186
+ );
187
+ await setField(id, "internalNumber", "X-2042");
188
+ await stack.eventDispatcher?.runOnce();
189
+
190
+ const { rows } = await listProperties();
191
+ const property = rows.find((row) => row["id"] === id);
192
+ expect(property?.["internalNumber"]).toBe("X-2042");
193
+ });
194
+
195
+ test("typed field: number value preserves its type", async () => {
196
+ const id = "22222222-2222-4000-8000-000000000002";
197
+
198
+ await defineField("tier", "number");
199
+ await stack.http.writeOk(
200
+ "property-management:write:property:create",
201
+ { id, name: "MultiField" },
202
+ admin,
203
+ );
204
+ await setField(id, "tier", 2);
205
+ await stack.eventDispatcher?.runOnce();
206
+
207
+ const property = (await listProperties()).rows.find((row) => row["id"] === id);
208
+ expect(property?.["tier"]).toBe(2);
209
+ });
210
+ });
211
+ ```
212
+
213
+ ### src/feature.ts
214
+
215
+ ```ts
216
+ // kumiko-feature-version: 1
217
+ // Custom-Fields Basic Sample
218
+ //
219
+ // Shows how a feature opts a tenant-owned entity into the custom-fields
220
+ // extension: tenants can define their own fields at runtime, set values
221
+ // per row, and read them back flattened onto the entity — without writing
222
+ // a single migration or extra handler.
223
+ //
224
+ // Flow:
225
+ // 1. App-author defines a `property` entity and wires it via
226
+ // `wireCustomFieldsFor(r, "property", propertyTable)`.
227
+ // 2. A tenant admin defines a field at runtime
228
+ // (e.g. `internalNumber: text`) via the bundle's CRUD.
229
+ // 3. Values are written via `custom-fields:write:set-custom-field`,
230
+ // not through the host entity's own write handler.
231
+ // 4. Reads return the field flattened onto the row — looks like a
232
+ // first-class column. The flattening is an entity-level postQuery
233
+ // hook (registered by wireCustomFieldsFor), so it fires for ANY
234
+ // query whose name maps to this entity — including the hand-written
235
+ // `property:list` below.
236
+
237
+ import {
238
+ customFieldsField,
239
+ wireCustomFieldsFor,
240
+ } from "@cosmicdrift/kumiko-bundled-features/custom-fields";
241
+ import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
242
+ import { createEntity, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
243
+ import { z } from "zod";
244
+
245
+ // --- Entity ---
246
+ //
247
+ // The field shape is declared as a plain object literal so the feature is
248
+ // self-describing Object-Form (what the AI/Designer should emit). The same
249
+ // literal is passed inline to `r.entity` below. `{ type: "jsonb" }` is
250
+ // exactly what `customFieldsField()` produces — the jsonb column the
251
+ // bundle's projection writes into; without it `wireCustomFieldsFor` has
252
+ // nowhere to land the values.
253
+
254
+ export const propertyEntity = createEntity({
255
+ table: "read_sample_cf_properties",
256
+ fields: {
257
+ name: { type: "text", required: true, maxLength: 200 },
258
+ customFields: customFieldsField(),
259
+ },
260
+ });
261
+
262
+ export const propertyTable = buildEntityTable("property", propertyEntity);
263
+
264
+ function propertyExecutor() {
265
+ return createEventStoreExecutor(propertyTable, propertyEntity, { entityName: "property" });
266
+ }
267
+
268
+ // --- Feature ---
269
+
270
+ export const propertyFeature = defineFeature("property-management", (r) => {
271
+ r.requires("custom-fields");
272
+
273
+ r.entity("property", {
274
+ table: "read_sample_cf_properties",
275
+ fields: {
276
+ name: { type: "text", required: true, maxLength: 200 },
277
+ customFields: { type: "jsonb" },
278
+ },
279
+ });
280
+
281
+ // Opt this entity into the custom-fields extension. This registers the
282
+ // multi-stream projection (MSP) that consumes customField.set / .cleared
283
+ // events and writes them into the `customFields` jsonb column, plus the
284
+ // entity postQuery hook that flattens the jsonb onto the row at read-time.
285
+ wireCustomFieldsFor(r, "property", propertyTable);
286
+
287
+ r.writeHandler({
288
+ name: "property:create",
289
+ schema: z.object({ id: z.string(), name: z.string() }),
290
+ access: { roles: ["TenantAdmin"] },
291
+ handler: async (event, ctx) =>
292
+ propertyExecutor().create(
293
+ { id: event.payload.id, name: event.payload.name, customFields: {} },
294
+ event.user,
295
+ ctx.db,
296
+ ),
297
+ });
298
+
299
+ // List query. Named `property:list` so it maps to the `property` entity
300
+ // (colon convention) — that mapping is what lets the entity postQuery
301
+ // hook from wireCustomFieldsFor flatten customFields onto each row. The
302
+ // handler itself just reads the tenant-scoped read table; ctx.db is
303
+ // already tenant-scoped.
304
+ r.queryHandler({
305
+ name: "property:list",
306
+ schema: z.object({}),
307
+ access: { roles: ["TenantAdmin"] },
308
+ handler: async (_query, ctx) => {
309
+ const rows = await ctx.db.selectMany(propertyTable);
310
+ return { rows };
311
+ },
312
+ });
313
+ });
314
+ ```
@@ -0,0 +1,397 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/custom-handlers. Do not edit. -->
2
+
3
+ # custom-handlers
4
+
5
+ Beyond CRUD — when your domain refuses to fit a table.
6
+
7
+ ## README
8
+
9
+ # Custom handlers
10
+
11
+ Replace the default CRUD with handler bodies that carry business rules.
12
+ This recipe ships a `counter` entity with three custom write handlers
13
+ (create, increment with read-modify-write, reset with last-writer-wins)
14
+ plus a custom query that filters the projection.
15
+
16
+ The point is to show the **boundary**: where the entity executor stops
17
+ doing work for you and where your code has to take over. The framework
18
+ gives you `createEntityExecutor` so the executor's `create` / `update` /
19
+ `detail` / `list` are still one-liners — only the surrounding business
20
+ logic is yours.
21
+
22
+ ## What it shows
23
+
24
+ - **`r.writeHandler(name, schema, handler, options)`** — the positional
25
+ inline form, useful when the handler body is short and stays close to
26
+ its registration.
27
+ - **Read-modify-write with optimistic locking** — `increment` reads the
28
+ current count, computes the new one, and updates with the version it
29
+ read. Two concurrent increments cannot both succeed.
30
+ - **`skipOptimisticLock` for last-writer-wins** — `reset` deliberately
31
+ bypasses the version check because the operation is admin-driven and
32
+ resetting twice is semantically the same as resetting once.
33
+ - **Custom queries** — `counter:active` filters the projection in
34
+ TypeScript after the executor's list returns. Useful when the filter is
35
+ computed (above-threshold) rather than indexed.
36
+ - **`failNotFound` helper** — typed error response for the 404 case
37
+ without a hand-built `KumikoError` subclass.
38
+
39
+ ## When to reach for it
40
+
41
+ You have an entity but the default CRUD doesn't capture what your
42
+ business rule actually does. The default `update` blindly writes the
43
+ payload; your `increment` needs to read first, decide, and write. That's
44
+ the line.
45
+
46
+ ## Source
47
+
48
+ The whole feature lives in `src/feature.ts` (~100 lines). Integration
49
+ tests cover the optimistic-lock case, the skip-lock case, and the
50
+ custom-filter query.
51
+
52
+ ```bash
53
+ bun kumiko test integration samples/custom-handlers
54
+ ```
55
+
56
+ ## Source files
57
+
58
+ ### src/__tests__/feature.integration.test.ts
59
+
60
+ ```ts
61
+ // Custom Handlers Sample — Integration Test
62
+ // Proves: custom business logic in handlers, payload transformation, custom queries
63
+
64
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
65
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
66
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
67
+ import {
68
+ createTestUser,
69
+ setupTestStack,
70
+ type TestStack,
71
+ TestUsers,
72
+ unsafeCreateEntityTable,
73
+ } from "@cosmicdrift/kumiko-framework/stack";
74
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
75
+ import { counterEntity, counterFeature } from "../feature";
76
+
77
+ let stack: TestStack;
78
+
79
+ const admin = TestUsers.admin;
80
+ const user = createTestUser({ id: 2, roles: ["User"] });
81
+
82
+ beforeAll(async () => {
83
+ stack = await setupTestStack({ features: [counterFeature] });
84
+ await unsafeCreateEntityTable(stack.db, counterEntity);
85
+ await createEventsTable(stack.db);
86
+ });
87
+
88
+ afterAll(async () => {
89
+ await stack.cleanup();
90
+ });
91
+
92
+ beforeEach(() => {
93
+ stack.events.reset();
94
+ });
95
+
96
+ describe("custom write handler: increment", () => {
97
+ test("increment adds to existing count", async () => {
98
+ const created = await stack.http.writeOk<SaveContext>(
99
+ "counters:write:counter:create",
100
+ {
101
+ name: "page-views",
102
+ },
103
+ admin,
104
+ );
105
+ expect(created.data["count"]).toBe(0);
106
+
107
+ const incremented = await stack.http.writeOk<SaveContext>(
108
+ "counters:write:counter:increment",
109
+ {
110
+ id: created.id,
111
+ amount: 5,
112
+ },
113
+ user,
114
+ );
115
+
116
+ expect(incremented.data["count"]).toBe(5);
117
+ expect(incremented.data["lastIncrementedBy"]).toBe(`user:${user.id}`);
118
+ });
119
+
120
+ test("multiple increments accumulate", async () => {
121
+ const created = await stack.http.writeOk<SaveContext>(
122
+ "counters:write:counter:create",
123
+ {
124
+ name: "clicks",
125
+ },
126
+ admin,
127
+ );
128
+
129
+ await stack.http.writeOk<SaveContext>(
130
+ "counters:write:counter:increment",
131
+ {
132
+ id: created.id,
133
+ amount: 3,
134
+ },
135
+ user,
136
+ );
137
+ const result = await stack.http.writeOk<SaveContext>(
138
+ "counters:write:counter:increment",
139
+ {
140
+ id: created.id,
141
+ amount: 7,
142
+ },
143
+ user,
144
+ );
145
+
146
+ expect(result.data["count"]).toBe(10);
147
+ });
148
+
149
+ test("increment validates max amount", async () => {
150
+ const created = await stack.http.writeOk<SaveContext>(
151
+ "counters:write:counter:create",
152
+ {
153
+ name: "guarded",
154
+ },
155
+ admin,
156
+ );
157
+
158
+ const error = await stack.http.writeErr(
159
+ "counters:write:counter:increment",
160
+ {
161
+ id: created.id,
162
+ amount: 101,
163
+ },
164
+ user,
165
+ );
166
+ expectErrorIncludes(error, "validation");
167
+ });
168
+
169
+ test("increment on non-existent returns error", async () => {
170
+ const error = await stack.http.writeErr(
171
+ "counters:write:counter:increment",
172
+ {
173
+ id: "00000000-0000-4000-8000-000000000999",
174
+ amount: 1,
175
+ },
176
+ user,
177
+ );
178
+ expect(error.code).toBe("not_found");
179
+ });
180
+ });
181
+
182
+ describe("custom write handler: reset", () => {
183
+ test("reset sets count back to zero", async () => {
184
+ const created = await stack.http.writeOk<SaveContext>(
185
+ "counters:write:counter:create",
186
+ {
187
+ name: "resettable",
188
+ },
189
+ admin,
190
+ );
191
+
192
+ await stack.http.writeOk<SaveContext>(
193
+ "counters:write:counter:increment",
194
+ {
195
+ id: created.id,
196
+ amount: 42,
197
+ },
198
+ user,
199
+ );
200
+
201
+ const reset = await stack.http.writeOk<SaveContext>(
202
+ "counters:write:counter:reset",
203
+ {
204
+ id: created.id,
205
+ },
206
+ admin,
207
+ );
208
+
209
+ expect(reset.data["count"]).toBe(0);
210
+ expect(reset.data["lastIncrementedBy"]).toBe("");
211
+ });
212
+
213
+ test("only Admin can reset", async () => {
214
+ const created = await stack.http.writeOk<SaveContext>(
215
+ "counters:write:counter:create",
216
+ {
217
+ name: "admin-only-reset",
218
+ },
219
+ admin,
220
+ );
221
+
222
+ const error = await stack.http.writeErr(
223
+ "counters:write:counter:reset",
224
+ {
225
+ id: created.id,
226
+ },
227
+ user,
228
+ );
229
+ expect(error.code).toBe("access_denied");
230
+ });
231
+ });
232
+
233
+ describe("custom query handler: active counters", () => {
234
+ test("filters counters by minimum count", async () => {
235
+ const c1 = await stack.http.writeOk<SaveContext>(
236
+ "counters:write:counter:create",
237
+ { name: "active-a" },
238
+ admin,
239
+ );
240
+ const c2 = await stack.http.writeOk<SaveContext>(
241
+ "counters:write:counter:create",
242
+ { name: "active-b" },
243
+ admin,
244
+ );
245
+ await stack.http.writeOk<SaveContext>(
246
+ "counters:write:counter:create",
247
+ { name: "inactive" },
248
+ admin,
249
+ );
250
+
251
+ await stack.http.writeOk<SaveContext>(
252
+ "counters:write:counter:increment",
253
+ { id: c1.id, amount: 10 },
254
+ user,
255
+ );
256
+ await stack.http.writeOk<SaveContext>(
257
+ "counters:write:counter:increment",
258
+ { id: c2.id, amount: 5 },
259
+ user,
260
+ );
261
+
262
+ const result = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
263
+ "counters:query:counter:active",
264
+ { minCount: 5 },
265
+ admin,
266
+ );
267
+
268
+ const names = result.rows.map((r) => r["name"]);
269
+ expect(names).toContain("active-a");
270
+ expect(names).toContain("active-b");
271
+ expect(names).not.toContain("inactive");
272
+ });
273
+ });
274
+ ```
275
+
276
+ ### src/feature.ts
277
+
278
+ ```ts
279
+ // Custom Handlers Sample
280
+ // Shows: writeHandler with business logic, queryHandler with custom filtering,
281
+ // handler that modifies payload before DB write.
282
+
283
+ import {
284
+ createEntity,
285
+ createEntityExecutor,
286
+ createNumberField,
287
+ createTextField,
288
+ defineFeature,
289
+ } from "@cosmicdrift/kumiko-framework/engine";
290
+ import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
291
+ import { z } from "zod";
292
+
293
+ export const counterEntity = createEntity({
294
+ table: "read_sample_counters",
295
+ fields: {
296
+ name: createTextField({ personal: false, reason: "is_business_data", required: true }),
297
+ count: createNumberField({ default: 0 }),
298
+ lastIncrementedBy: createTextField({ personal: "ref" }),
299
+ },
300
+ });
301
+
302
+ export const counterFeature = defineFeature("counters", (r) => {
303
+ r.entity("counter", counterEntity);
304
+
305
+ // createEntityExecutor bundles buildEntityTable + createEventStoreExecutor —
306
+ // the same pair every custom write-handler opens with. Collapses 3 lines
307
+ // + the { entityName } bookkeeping into one destructure.
308
+ const { executor: counterExecutor } = createEntityExecutor("counter", counterEntity);
309
+
310
+ // Standard create
311
+ r.writeHandler(
312
+ "counter:create",
313
+ z.object({ name: z.string().min(1) }),
314
+ async (event, ctx) =>
315
+ counterExecutor.create({ ...event.payload, count: 0 }, event.user, ctx.db),
316
+ { access: { roles: ["Admin"] } },
317
+ );
318
+
319
+ // Custom handler: increment with business logic
320
+ r.writeHandler(
321
+ "counter:increment",
322
+ z.object({ id: z.uuid(), amount: z.number().min(1).max(100) }),
323
+ async (event, ctx) => {
324
+ const current = await counterExecutor.detail({ id: event.payload.id }, event.user, ctx.db);
325
+ if (!current) {
326
+ return failNotFound("counter", event.payload.id);
327
+ }
328
+
329
+ const newCount = (current["count"] as number) + event.payload.amount;
330
+ // Read-modify-write on counter: use the version we just read so two
331
+ // concurrent increments don't clobber each other's changes.
332
+ return counterExecutor.update(
333
+ {
334
+ id: event.payload.id,
335
+ version: current["version"] as number,
336
+ changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` },
337
+ },
338
+ event.user,
339
+ ctx.db,
340
+ );
341
+ },
342
+ { access: { roles: ["Admin", "User"] } },
343
+ );
344
+
345
+ // Custom handler: reset to zero
346
+ r.writeHandler(
347
+ "counter:reset",
348
+ z.object({ id: z.uuid() }),
349
+ async (event, ctx) =>
350
+ // Admin reset: last-writer-wins is intentional — there's no useful
351
+ // concurrent-reset race to guard against.
352
+ counterExecutor.update(
353
+ { id: event.payload.id, changes: { count: 0, lastIncrementedBy: "" } },
354
+ event.user,
355
+ ctx.db,
356
+ { skipOptimisticLock: true },
357
+ ),
358
+ { access: { roles: ["Admin"] } },
359
+ );
360
+
361
+ // Custom query: only counters above a threshold
362
+ r.queryHandler(
363
+ "counter:active",
364
+ z.object({ minCount: z.number().default(1) }),
365
+ async (query, ctx) => {
366
+ const all = await counterExecutor.list({}, query.user, ctx.db);
367
+ return {
368
+ ...all,
369
+ rows: all.rows.filter((r) => (r["count"] as number) >= query.payload.minCount),
370
+ };
371
+ },
372
+ {
373
+ access: {
374
+ openToAll: {
375
+ reason:
376
+ "any signed-in user may list active counters; the sample has no per-user counter ownership",
377
+ },
378
+ },
379
+ },
380
+ );
381
+
382
+ // Standard detail
383
+ r.queryHandler(
384
+ "counter:detail",
385
+ z.object({ id: z.uuid() }),
386
+ async (query, ctx) => counterExecutor.detail(query.payload, query.user, ctx.db),
387
+ {
388
+ access: {
389
+ openToAll: {
390
+ reason:
391
+ "any signed-in user may read a counter by id; the sample has no per-user counter ownership",
392
+ },
393
+ },
394
+ },
395
+ );
396
+ });
397
+ ```