@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,444 @@
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` passes a server-side `filter`
34
+ (`eq`/`ne`/`lt`/`gt`/`lte`/`gte`/`in`) and `totalCount` to the executor's
35
+ list. Never filter a page in TypeScript: list is paginated, so rows after
36
+ the first page would be missed.
37
+ - **`failNotFound` helper** — typed error response for the 404 case
38
+ without a hand-built `KumikoError` subclass.
39
+
40
+ ## When to reach for it
41
+
42
+ You have an entity but the default CRUD doesn't capture what your
43
+ business rule actually does. The default `update` blindly writes the
44
+ payload; your `increment` needs to read first, decide, and write. That's
45
+ the line.
46
+
47
+ ## Source
48
+
49
+ The whole feature lives in `src/feature.ts` (~100 lines). Integration
50
+ tests cover the optimistic-lock case, the skip-lock case, and the
51
+ custom-filter query.
52
+
53
+ ```bash
54
+ bun kumiko test integration samples/custom-handlers
55
+ ```
56
+
57
+ ## Source files
58
+
59
+ ### src/__tests__/feature.integration.test.ts
60
+
61
+ ```ts
62
+ // Custom Handlers Sample — Integration Test
63
+ // Proves: custom business logic in handlers, payload transformation, custom queries
64
+
65
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
66
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
67
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
68
+ import {
69
+ createTestUser,
70
+ setupTestStack,
71
+ type TestStack,
72
+ TestUsers,
73
+ testTenantId,
74
+ unsafeCreateEntityTable,
75
+ } from "@cosmicdrift/kumiko-framework/stack";
76
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
77
+ import { counterEntity, counterFeature } from "../feature";
78
+
79
+ let stack: TestStack;
80
+
81
+ const admin = TestUsers.admin;
82
+ const user = createTestUser({ id: 2, roles: ["User"] });
83
+
84
+ beforeAll(async () => {
85
+ stack = await setupTestStack({ features: [counterFeature] });
86
+ await unsafeCreateEntityTable(stack.db, counterEntity);
87
+ await createEventsTable(stack.db);
88
+ });
89
+
90
+ afterAll(async () => {
91
+ await stack.cleanup();
92
+ });
93
+
94
+ beforeEach(() => {
95
+ stack.events.reset();
96
+ });
97
+
98
+ describe("custom write handler: increment", () => {
99
+ test("increment adds to existing count", async () => {
100
+ const created = await stack.http.writeOk<SaveContext>(
101
+ "counters:write:counter:create",
102
+ {
103
+ name: "page-views",
104
+ },
105
+ admin,
106
+ );
107
+ expect(created.data["count"]).toBe(0);
108
+
109
+ const incremented = await stack.http.writeOk<SaveContext>(
110
+ "counters:write:counter:increment",
111
+ {
112
+ id: created.id,
113
+ amount: 5,
114
+ },
115
+ user,
116
+ );
117
+
118
+ expect(incremented.data["count"]).toBe(5);
119
+ expect(incremented.data["lastIncrementedBy"]).toBe(`user:${user.id}`);
120
+ });
121
+
122
+ test("multiple increments accumulate", async () => {
123
+ const created = await stack.http.writeOk<SaveContext>(
124
+ "counters:write:counter:create",
125
+ {
126
+ name: "clicks",
127
+ },
128
+ admin,
129
+ );
130
+
131
+ await stack.http.writeOk<SaveContext>(
132
+ "counters:write:counter:increment",
133
+ {
134
+ id: created.id,
135
+ amount: 3,
136
+ },
137
+ user,
138
+ );
139
+ const result = await stack.http.writeOk<SaveContext>(
140
+ "counters:write:counter:increment",
141
+ {
142
+ id: created.id,
143
+ amount: 7,
144
+ },
145
+ user,
146
+ );
147
+
148
+ expect(result.data["count"]).toBe(10);
149
+ });
150
+
151
+ test("increment validates max amount", async () => {
152
+ const created = await stack.http.writeOk<SaveContext>(
153
+ "counters:write:counter:create",
154
+ {
155
+ name: "guarded",
156
+ },
157
+ admin,
158
+ );
159
+
160
+ const error = await stack.http.writeErr(
161
+ "counters:write:counter:increment",
162
+ {
163
+ id: created.id,
164
+ amount: 101,
165
+ },
166
+ user,
167
+ );
168
+ expectErrorIncludes(error, "validation");
169
+ });
170
+
171
+ test("increment on non-existent returns error", async () => {
172
+ const error = await stack.http.writeErr(
173
+ "counters:write:counter:increment",
174
+ {
175
+ id: "00000000-0000-4000-8000-000000000999",
176
+ amount: 1,
177
+ },
178
+ user,
179
+ );
180
+ expect(error.code).toBe("not_found");
181
+ });
182
+ });
183
+
184
+ describe("custom write handler: reset", () => {
185
+ test("reset sets count back to zero", async () => {
186
+ const created = await stack.http.writeOk<SaveContext>(
187
+ "counters:write:counter:create",
188
+ {
189
+ name: "resettable",
190
+ },
191
+ admin,
192
+ );
193
+
194
+ await stack.http.writeOk<SaveContext>(
195
+ "counters:write:counter:increment",
196
+ {
197
+ id: created.id,
198
+ amount: 42,
199
+ },
200
+ user,
201
+ );
202
+
203
+ const reset = await stack.http.writeOk<SaveContext>(
204
+ "counters:write:counter:reset",
205
+ {
206
+ id: created.id,
207
+ },
208
+ admin,
209
+ );
210
+
211
+ expect(reset.data["count"]).toBe(0);
212
+ expect(reset.data["lastIncrementedBy"]).toBe("");
213
+ });
214
+
215
+ test("only Admin can reset", async () => {
216
+ const created = await stack.http.writeOk<SaveContext>(
217
+ "counters:write:counter:create",
218
+ {
219
+ name: "admin-only-reset",
220
+ },
221
+ admin,
222
+ );
223
+
224
+ const error = await stack.http.writeErr(
225
+ "counters:write:counter:reset",
226
+ {
227
+ id: created.id,
228
+ },
229
+ user,
230
+ );
231
+ expect(error.code).toBe("access_denied");
232
+ });
233
+ });
234
+
235
+ describe("custom query handler: active counters", () => {
236
+ // Own tenants: the total assertions would otherwise count counters created by other tests.
237
+ const activeAdmin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(11) });
238
+ const activeUser = createTestUser({ id: 2, roles: ["User"], tenantId: testTenantId(11) });
239
+ const otherAdmin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(12) });
240
+
241
+ test("filters counters by minimum count", async () => {
242
+ const c1 = await stack.http.writeOk<SaveContext>(
243
+ "counters:write:counter:create",
244
+ { name: "active-a" },
245
+ activeAdmin,
246
+ );
247
+ const c2 = await stack.http.writeOk<SaveContext>(
248
+ "counters:write:counter:create",
249
+ { name: "active-b" },
250
+ activeAdmin,
251
+ );
252
+ await stack.http.writeOk<SaveContext>(
253
+ "counters:write:counter:create",
254
+ { name: "inactive" },
255
+ activeAdmin,
256
+ );
257
+
258
+ await stack.http.writeOk<SaveContext>(
259
+ "counters:write:counter:increment",
260
+ { id: c1.id, amount: 10 },
261
+ activeUser,
262
+ );
263
+ await stack.http.writeOk<SaveContext>(
264
+ "counters:write:counter:increment",
265
+ { id: c2.id, amount: 5 },
266
+ activeUser,
267
+ );
268
+
269
+ const result = await stack.http.queryOk<{
270
+ rows: Record<string, unknown>[];
271
+ nextCursor: string | null;
272
+ total?: number;
273
+ }>("counters:query:counter:active", { minCount: 5 }, activeAdmin);
274
+
275
+ const names = result.rows.map((r) => r["name"]);
276
+ expect(names).toContain("active-a");
277
+ expect(names).toContain("active-b");
278
+ expect(names).not.toContain("inactive");
279
+ expect(result.total).toBe(2);
280
+ });
281
+
282
+ test("total and nextCursor describe the full filtered set", async () => {
283
+ for (const name of ["total-a", "total-b", "total-c"]) {
284
+ const created = await stack.http.writeOk<SaveContext>(
285
+ "counters:write:counter:create",
286
+ { name },
287
+ otherAdmin,
288
+ );
289
+ await stack.http.writeOk<SaveContext>(
290
+ "counters:write:counter:increment",
291
+ { id: created.id, amount: 1 },
292
+ otherAdmin,
293
+ );
294
+ }
295
+ await stack.http.writeOk<SaveContext>(
296
+ "counters:write:counter:create",
297
+ { name: "total-untouched" },
298
+ otherAdmin,
299
+ );
300
+
301
+ const result = await stack.http.queryOk<{
302
+ rows: Record<string, unknown>[];
303
+ nextCursor: string | null;
304
+ total?: number;
305
+ }>("counters:query:counter:active", { minCount: 1 }, otherAdmin);
306
+
307
+ expect(result.rows).toHaveLength(3);
308
+ expect(result.total).toBe(3);
309
+ expect(result.nextCursor).toBeNull();
310
+ });
311
+ });
312
+ ```
313
+
314
+ ### src/feature.ts
315
+
316
+ ```ts
317
+ // Custom Handlers Sample
318
+ // Shows: writeHandler with business logic, queryHandler with a server-side filter,
319
+ // handler that modifies payload before DB write.
320
+
321
+ import {
322
+ createEntity,
323
+ createEntityExecutor,
324
+ createNumberField,
325
+ createTextField,
326
+ defineFeature,
327
+ } from "@cosmicdrift/kumiko-framework/engine";
328
+ import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
329
+ import { z } from "zod";
330
+
331
+ // detail() returns an untyped row; parse the two fields the increment needs once.
332
+ const counterVersionRowSchema = z.object({ count: z.number(), version: z.number() });
333
+
334
+ export const counterEntity = createEntity({
335
+ table: "read_sample_counters",
336
+ fields: {
337
+ name: createTextField({ personal: false, reason: "is_business_data", required: true }),
338
+ count: createNumberField({ default: 0 }),
339
+ lastIncrementedBy: createTextField({ personal: "ref" }),
340
+ },
341
+ });
342
+
343
+ export const counterFeature = defineFeature("counters", (r) => {
344
+ r.entity("counter", counterEntity);
345
+
346
+ // createEntityExecutor bundles buildEntityTable + createEventStoreExecutor —
347
+ // the same pair every custom write-handler opens with. Collapses 3 lines
348
+ // + the { entityName } bookkeeping into one destructure.
349
+ const { executor: counterExecutor } = createEntityExecutor("counter", counterEntity);
350
+
351
+ // Standard create
352
+ r.writeHandler(
353
+ "counter:create",
354
+ z.object({ name: z.string().min(1) }),
355
+ async (event, ctx) =>
356
+ counterExecutor.create({ ...event.payload, count: 0 }, event.user, ctx.db),
357
+ { access: { roles: ["Admin"] } },
358
+ );
359
+
360
+ // Custom handler: increment with business logic
361
+ r.writeHandler(
362
+ "counter:increment",
363
+ z.object({ id: z.uuid(), amount: z.number().min(1).max(100) }),
364
+ async (event, ctx) => {
365
+ const current = await counterExecutor.detail({ id: event.payload.id }, event.user, ctx.db);
366
+ if (!current) {
367
+ return failNotFound("counter", event.payload.id);
368
+ }
369
+
370
+ const { count, version } = counterVersionRowSchema.parse(current);
371
+ const newCount = count + event.payload.amount;
372
+ // Read-modify-write on counter: use the version we just read so two
373
+ // concurrent increments don't clobber each other's changes.
374
+ return counterExecutor.update(
375
+ {
376
+ id: event.payload.id,
377
+ version,
378
+ changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` },
379
+ },
380
+ event.user,
381
+ ctx.db,
382
+ );
383
+ },
384
+ { access: { roles: ["Admin", "User"] } },
385
+ );
386
+
387
+ // Custom handler: reset to zero
388
+ r.writeHandler(
389
+ "counter:reset",
390
+ z.object({ id: z.uuid() }),
391
+ async (event, ctx) =>
392
+ // Admin reset: last-writer-wins is intentional — there's no useful
393
+ // concurrent-reset race to guard against.
394
+ counterExecutor.update(
395
+ { id: event.payload.id, changes: { count: 0, lastIncrementedBy: "" } },
396
+ event.user,
397
+ ctx.db,
398
+ { skipOptimisticLock: true },
399
+ ),
400
+ { access: { roles: ["Admin"] } },
401
+ );
402
+
403
+ // Custom query: only counters above a threshold. list() returns one page
404
+ // (default 50, max 200, MAX_LIST_LIMIT), so a JS filter would miss rows after
405
+ // page 1; filter in the query instead. totalCount counts over the whole WHERE.
406
+ r.queryHandler(
407
+ "counter:active",
408
+ z.object({ minCount: z.number().int().default(1), cursor: z.string().optional() }),
409
+ async (query, ctx) =>
410
+ counterExecutor.list(
411
+ {
412
+ filter: { field: "count", op: "gte", value: query.payload.minCount },
413
+ totalCount: true,
414
+ cursor: query.payload.cursor,
415
+ },
416
+ query.user,
417
+ ctx.db,
418
+ ),
419
+ {
420
+ access: {
421
+ openToAll: {
422
+ reason:
423
+ "any signed-in user may list active counters; the sample has no per-user counter ownership",
424
+ },
425
+ },
426
+ },
427
+ );
428
+
429
+ // Standard detail
430
+ r.queryHandler(
431
+ "counter:detail",
432
+ z.object({ id: z.uuid() }),
433
+ async (query, ctx) => counterExecutor.detail(query.payload, query.user, ctx.db),
434
+ {
435
+ access: {
436
+ openToAll: {
437
+ reason:
438
+ "any signed-in user may read a counter by id; the sample has no per-user counter ownership",
439
+ },
440
+ },
441
+ },
442
+ );
443
+ });
444
+ ```
@@ -0,0 +1,229 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/dashboard-stats. Do not edit. -->
2
+
3
+ # dashboard-stats
4
+
5
+ Key figures on a dashboard: stat panels backed by one counting query.
6
+
7
+ ## README
8
+
9
+ # Dashboard stats
10
+
11
+ Key figures on a dashboard. This recipe ships a `ticket` entity, one query
12
+ handler that returns the figures as a flat object, and a `dashboard` screen
13
+ with a stat group (open, closed) and a single stat (total).
14
+
15
+ The point is to show how the three pieces connect: the screen only names a
16
+ query and the fields to read, the query does the counting.
17
+
18
+ ## What it shows
19
+
20
+ - **Panels reference a query by qualified name**: each `stat` carries
21
+ `query: "tickets:query:ticket:stats"`. The dashboard calls that handler and
22
+ renders the result.
23
+ - **`valueField` picks a field from a flat result**: the handler returns
24
+ `{ open, closed, total }`, and each stat names one of those keys.
25
+ - **`stat-group` and `stat`**: a group shows several stats side by side under
26
+ one title, a top-level `stat` stands alone.
27
+ - **Counting happens in the database**: the handler calls the executor's
28
+ `list` with `filter`, `limit: 1` and `totalCount: true`. The total is counted
29
+ over the whole WHERE clause, so no rows are loaded just to be counted.
30
+ - **Labels are translation keys**: declared with `r.translations`.
31
+
32
+ ## When to reach for it
33
+
34
+ You want a landing screen with a few numbers: open items, overdue invoices,
35
+ this month's revenue. Write one query per screen that returns every figure,
36
+ and let the stats pick their field.
37
+
38
+ ## Source
39
+
40
+ The whole feature lives in `src/feature.ts` (~100 lines). The integration test
41
+ checks that every stat reads a field the query returns, and that the query
42
+ counts open and closed tickets correctly.
43
+
44
+ ```bash
45
+ bun kumiko test integration samples/dashboard-stats
46
+ ```
47
+
48
+ ## Source files
49
+
50
+ ### src/__tests__/feature.integration.test.ts
51
+
52
+ ```ts
53
+ // Dashboard Stats Sample, integration test.
54
+ // Proves: the dashboard screen boots, its panels point at a registered query,
55
+ // and that query counts per status in the database.
56
+
57
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
58
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
59
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
60
+ import {
61
+ createTestUser,
62
+ setupTestStack,
63
+ type TestStack,
64
+ testTenantId,
65
+ unsafeCreateEntityTable,
66
+ } from "@cosmicdrift/kumiko-framework/stack";
67
+ import { TICKET_STATS_QUERY, ticketEntity, ticketFeature } from "../feature";
68
+
69
+ let stack: TestStack;
70
+
71
+ const admin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(31) });
72
+
73
+ beforeAll(async () => {
74
+ stack = await setupTestStack({ features: [ticketFeature] });
75
+ await unsafeCreateEntityTable(stack.db, ticketEntity);
76
+ await createEventsTable(stack.db);
77
+ });
78
+
79
+ afterAll(async () => {
80
+ await stack.cleanup();
81
+ });
82
+
83
+ describe("dashboard screen", () => {
84
+ test("boots, and every stat reads a field the stats query returns", async () => {
85
+ const screen = ticketFeature.screens["ticket-dashboard"];
86
+ if (screen?.type !== "dashboard") throw new Error("ticket-dashboard is not a dashboard screen");
87
+
88
+ const stats = screen.panels.flatMap((panel) =>
89
+ panel.kind === "stat-group" ? panel.stats : panel.kind === "stat" ? [panel] : [],
90
+ );
91
+ expect(stats.map((s) => s.valueField)).toEqual(["open", "closed", "total"]);
92
+ for (const stat of stats) expect(stat.query).toBe(TICKET_STATS_QUERY);
93
+
94
+ const result = await stack.http.queryOk<Record<string, unknown>>(TICKET_STATS_QUERY, {}, admin);
95
+ for (const stat of stats) expect(result).toHaveProperty(stat.valueField);
96
+ });
97
+ });
98
+
99
+ describe("ticket:stats", () => {
100
+ test("counts open and closed tickets in the database", async () => {
101
+ for (const [title, status] of [
102
+ ["a", "open"],
103
+ ["b", "open"],
104
+ ["c", "open"],
105
+ ["d", "closed"],
106
+ ["e", "closed"],
107
+ ] as const) {
108
+ await stack.http.writeOk<SaveContext>(
109
+ "tickets:write:ticket:create",
110
+ { title, status },
111
+ admin,
112
+ );
113
+ }
114
+
115
+ const result = await stack.http.queryOk<Record<string, unknown>>(TICKET_STATS_QUERY, {}, admin);
116
+
117
+ expect(result).toEqual({ open: 3, closed: 2, total: 5 });
118
+ });
119
+ });
120
+ ```
121
+
122
+ ### src/feature.ts
123
+
124
+ ```ts
125
+ // Dashboard Stats Sample
126
+ // Shows: a dashboard screen with a stat-group and a single stat, backed by one
127
+ // query handler that counts in the database.
128
+
129
+ import {
130
+ createEntity,
131
+ createEntityExecutor,
132
+ createSelectField,
133
+ createTextField,
134
+ defineFeature,
135
+ } from "@cosmicdrift/kumiko-framework/engine";
136
+ import { z } from "zod";
137
+
138
+ export const TICKET_STATUSES = ["open", "closed"] as const;
139
+
140
+ export const ticketEntity = createEntity({
141
+ table: "read_sample_tickets",
142
+ fields: {
143
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
144
+ status: createSelectField({ options: TICKET_STATUSES, default: "open", required: true }),
145
+ },
146
+ });
147
+
148
+ export const TICKET_STATS_QUERY = "tickets:query:ticket:stats";
149
+
150
+ const readerRoles = { roles: ["Admin", "User"] } as const;
151
+
152
+ export const ticketFeature = defineFeature("tickets", (r) => {
153
+ const { executor: ticketExecutor } = createEntityExecutor("ticket", ticketEntity);
154
+
155
+ r.crud("ticket", ticketEntity, {
156
+ write: { access: { roles: ["Admin"] } },
157
+ read: { access: readerRoles },
158
+ });
159
+
160
+ // Flat result: each dashboard stat reads one field via its valueField.
161
+ // limit: 1 keeps the page tiny; totalCount counts over the whole WHERE in
162
+ // the database, so no rows are loaded just to be counted.
163
+ r.queryHandler(
164
+ "ticket:stats",
165
+ z.object({}),
166
+ async (query, ctx) => {
167
+ const countWithStatus = async (status: (typeof TICKET_STATUSES)[number]): Promise<number> => {
168
+ const page = await ticketExecutor.list(
169
+ { filter: { field: "status", op: "eq", value: status }, limit: 1, totalCount: true },
170
+ query.user,
171
+ ctx.db,
172
+ );
173
+ return page.total ?? 0;
174
+ };
175
+ const [open, closed] = await Promise.all([
176
+ countWithStatus("open"),
177
+ countWithStatus("closed"),
178
+ ]);
179
+ return { open, closed, total: open + closed };
180
+ },
181
+ { access: readerRoles },
182
+ );
183
+
184
+ r.translations({
185
+ keys: {
186
+ "tickets.dashboard.group": { en: "Tickets by status" },
187
+ "tickets.dashboard.open": { en: "Open" },
188
+ "tickets.dashboard.closed": { en: "Closed" },
189
+ "tickets.dashboard.total": { en: "All tickets" },
190
+ },
191
+ });
192
+
193
+ r.screen({
194
+ id: "ticket-dashboard",
195
+ type: "dashboard",
196
+ panels: [
197
+ {
198
+ kind: "stat-group",
199
+ id: "by-status",
200
+ label: "tickets.dashboard.group",
201
+ stats: [
202
+ {
203
+ kind: "stat",
204
+ id: "open",
205
+ label: "tickets.dashboard.open",
206
+ query: TICKET_STATS_QUERY,
207
+ valueField: "open",
208
+ },
209
+ {
210
+ kind: "stat",
211
+ id: "closed",
212
+ label: "tickets.dashboard.closed",
213
+ query: TICKET_STATS_QUERY,
214
+ valueField: "closed",
215
+ },
216
+ ],
217
+ },
218
+ {
219
+ kind: "stat",
220
+ id: "total",
221
+ label: "tickets.dashboard.total",
222
+ query: TICKET_STATS_QUERY,
223
+ valueField: "total",
224
+ },
225
+ ],
226
+ access: readerRoles,
227
+ });
228
+ });
229
+ ```