@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,920 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/state-machine. Do not edit. -->
2
+
3
+ # state-machine
4
+
5
+ Enforced entity state transitions. No more illegal state changes.
6
+
7
+ ## README
8
+
9
+ # State Machine
10
+
11
+ Enforced entity state transitions.
12
+
13
+ ## What it shows
14
+
15
+ - `defineTransitions` + `guardTransition`
16
+ - Each transition as its own domain event for a readable audit trail
17
+
18
+ ## Source
19
+
20
+ Feature entry point: `src/feature.ts`.
21
+
22
+ ## Tests
23
+
24
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
25
+
26
+ ```bash
27
+ cd samples/recipes/state-machine
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // State Machine Sample — Integration Test
37
+ // Proves: valid transitions work, invalid transitions rejected,
38
+ // state skipping rejected, role-based access, business logic in handler,
39
+ // non-linear transitions (reopen)
40
+ //
41
+ // Workflow:
42
+ // draft → sent → paid
43
+ // → cancelled → draft (reopen)
44
+
45
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
46
+ import {
47
+ createTestUser,
48
+ setupTestStack,
49
+ type TestStack,
50
+ TestUsers,
51
+ } from "@cosmicdrift/kumiko-framework/stack";
52
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
53
+ import { stateMachineFeature } from "../feature";
54
+
55
+ let stack: TestStack;
56
+
57
+ const admin = TestUsers.admin;
58
+ const accounting = createTestUser({ id: 2, roles: ["Accounting"] });
59
+ const viewer = createTestUser({ id: 3, roles: ["Viewer"] });
60
+ const otherTenantAdmin = createTestUser({
61
+ id: 4,
62
+ tenantId: "00000000-0000-4000-8000-000000000002",
63
+ roles: ["Admin"],
64
+ });
65
+
66
+ beforeAll(async () => {
67
+ stack = await setupTestStack({
68
+ features: [stateMachineFeature],
69
+ systemHooks: [],
70
+ });
71
+ // The projection table (sample_sm_invoices) is auto-pushed by setupTestStack
72
+ // because r.projection() declares it in feature.ts.
73
+ });
74
+
75
+ afterAll(async () => {
76
+ await stack.cleanup();
77
+ });
78
+
79
+ // Helper: create a draft invoice. The writeOk return-shape carries the
80
+ // SaveContext envelope (`{ data: { id, data: { status, ... }, ... } }`)
81
+ // — typed here so individual call sites don't need to cast.
82
+ type InvoiceWriteResult = {
83
+ id: string;
84
+ data: { status: string };
85
+ };
86
+ async function createDraftInvoice(title = "Test Invoice"): Promise<InvoiceWriteResult> {
87
+ return stack.http.writeOk<InvoiceWriteResult>(
88
+ "billing:write:invoice:create",
89
+ { title, amount: 100, amountCurrency: "EUR" },
90
+ admin,
91
+ );
92
+ }
93
+
94
+ // --- Happy path ---
95
+
96
+ describe("happy path: draft → sent → paid", () => {
97
+ test("create starts in draft", async () => {
98
+ const data = await createDraftInvoice();
99
+ expect(data["data"]["status"]).toBe("draft");
100
+ });
101
+
102
+ test("send moves draft → sent", async () => {
103
+ const created = await createDraftInvoice();
104
+ const sent = await stack.http.writeOk<InvoiceWriteResult>(
105
+ "billing:write:invoice:send",
106
+ { id: created["id"] },
107
+ admin,
108
+ );
109
+ expect(sent["data"]["status"]).toBe("sent");
110
+ });
111
+
112
+ test("markPaid moves sent → paid", async () => {
113
+ const created = await createDraftInvoice();
114
+ await stack.http.writeOk<InvoiceWriteResult>(
115
+ "billing:write:invoice:send",
116
+ { id: created["id"] },
117
+ admin,
118
+ );
119
+ const paid = await stack.http.writeOk<InvoiceWriteResult>(
120
+ "billing:write:invoice:mark-paid",
121
+ { id: created["id"] },
122
+ accounting,
123
+ );
124
+ expect(paid["data"]["status"]).toBe("paid");
125
+ });
126
+ });
127
+
128
+ // --- Invalid transitions ---
129
+
130
+ describe("invalid transitions rejected", () => {
131
+ test("cannot skip draft → paid (must go through sent)", async () => {
132
+ const created = await createDraftInvoice();
133
+ const error = await stack.http.writeErr(
134
+ "billing:write:invoice:mark-paid",
135
+ { id: created["id"] },
136
+ accounting,
137
+ );
138
+ expectErrorIncludes(error, "Invalid transition");
139
+ });
140
+
141
+ test("cannot cancel from draft (only from sent)", async () => {
142
+ const created = await createDraftInvoice();
143
+ const error = await stack.http.writeErr(
144
+ "billing:write:invoice:cancel",
145
+ { id: created["id"] },
146
+ admin,
147
+ );
148
+ expectErrorIncludes(error, "Invalid transition");
149
+ });
150
+
151
+ test("cannot send from paid (terminal state)", async () => {
152
+ const created = await createDraftInvoice();
153
+ await stack.http.writeOk<InvoiceWriteResult>(
154
+ "billing:write:invoice:send",
155
+ { id: created["id"] },
156
+ admin,
157
+ );
158
+ await stack.http.writeOk<InvoiceWriteResult>(
159
+ "billing:write:invoice:mark-paid",
160
+ { id: created["id"] },
161
+ accounting,
162
+ );
163
+
164
+ const error = await stack.http.writeErr(
165
+ "billing:write:invoice:send",
166
+ { id: created["id"] },
167
+ admin,
168
+ );
169
+ expectErrorIncludes(error, "Invalid transition");
170
+ });
171
+ });
172
+
173
+ // --- Non-linear: reopen ---
174
+
175
+ describe("non-linear transitions", () => {
176
+ test("cancelled invoice can be reopened to draft", async () => {
177
+ const created = await createDraftInvoice("Reopen Test");
178
+ await stack.http.writeOk<InvoiceWriteResult>(
179
+ "billing:write:invoice:send",
180
+ { id: created["id"] },
181
+ admin,
182
+ );
183
+ await stack.http.writeOk<InvoiceWriteResult>(
184
+ "billing:write:invoice:cancel",
185
+ { id: created["id"] },
186
+ admin,
187
+ );
188
+
189
+ const reopened = await stack.http.writeOk<InvoiceWriteResult>(
190
+ "billing:write:invoice:reopen",
191
+ { id: created["id"] },
192
+ admin,
193
+ );
194
+ expect(reopened["data"]["status"]).toBe("draft");
195
+ });
196
+
197
+ test("cannot reopen from sent (only from cancelled)", async () => {
198
+ const created = await createDraftInvoice();
199
+ await stack.http.writeOk<InvoiceWriteResult>(
200
+ "billing:write:invoice:send",
201
+ { id: created["id"] },
202
+ admin,
203
+ );
204
+
205
+ const error = await stack.http.writeErr(
206
+ "billing:write:invoice:reopen",
207
+ { id: created["id"] },
208
+ admin,
209
+ );
210
+ expectErrorIncludes(error, "Invalid transition");
211
+ });
212
+ });
213
+
214
+ // --- Role-based access ---
215
+
216
+ describe("role-based access per transition", () => {
217
+ test("Viewer cannot send invoice", async () => {
218
+ const created = await createDraftInvoice();
219
+ const error = await stack.http.writeErr(
220
+ "billing:write:invoice:send",
221
+ { id: created["id"] },
222
+ viewer,
223
+ );
224
+ expect(error.code).toBe("access_denied");
225
+ });
226
+
227
+ test("Admin cannot markPaid (Accounting only)", async () => {
228
+ const created = await createDraftInvoice();
229
+ await stack.http.writeOk<InvoiceWriteResult>(
230
+ "billing:write:invoice:send",
231
+ { id: created["id"] },
232
+ admin,
233
+ );
234
+
235
+ const error = await stack.http.writeErr(
236
+ "billing:write:invoice:mark-paid",
237
+ { id: created["id"] },
238
+ admin,
239
+ );
240
+ expect(error.code).toBe("access_denied");
241
+ });
242
+
243
+ test("Accounting can markPaid", async () => {
244
+ const created = await createDraftInvoice();
245
+ await stack.http.writeOk<InvoiceWriteResult>(
246
+ "billing:write:invoice:send",
247
+ { id: created["id"] },
248
+ admin,
249
+ );
250
+
251
+ const paid = await stack.http.writeOk<InvoiceWriteResult>(
252
+ "billing:write:invoice:mark-paid",
253
+ { id: created["id"] },
254
+ accounting,
255
+ );
256
+ expect(paid["data"]["status"]).toBe("paid");
257
+ });
258
+ });
259
+
260
+ // --- Tenant isolation ---
261
+
262
+ describe("tenant isolation", () => {
263
+ test("other tenant cannot transition invoice", async () => {
264
+ const created = await createDraftInvoice("Tenant1 Invoice");
265
+
266
+ const error = await stack.http.writeErr(
267
+ "billing:write:invoice:send",
268
+ { id: created["id"] },
269
+ otherTenantAdmin,
270
+ );
271
+ expect(error.code).toBe("not_found");
272
+ });
273
+ });
274
+
275
+ // --- Business logic in handler ---
276
+
277
+ describe("business logic with transitions", () => {
278
+ test("cannot markPaid if amount is 0", async () => {
279
+ const created = await stack.http.writeOk<InvoiceWriteResult>(
280
+ "billing:write:invoice:create",
281
+ { title: "Zero Invoice", amount: 0, amountCurrency: "EUR" },
282
+ admin,
283
+ );
284
+ await stack.http.writeOk<InvoiceWriteResult>(
285
+ "billing:write:invoice:send",
286
+ { id: created["id"] },
287
+ admin,
288
+ );
289
+
290
+ const error = await stack.http.writeErr(
291
+ "billing:write:invoice:mark-paid",
292
+ { id: created["id"] },
293
+ accounting,
294
+ );
295
+ expectErrorIncludes(error, "cannot_pay_zero_amount");
296
+ });
297
+ });
298
+
299
+ // --- Auto transition guard (pipeline) ---
300
+
301
+ describe("auto transition guard from pipeline", () => {
302
+ test("updateStatus with valid transition succeeds (no manual guard)", async () => {
303
+ const created = await createDraftInvoice("Auto Guard OK");
304
+ const updated = await stack.http.writeOk<InvoiceWriteResult>(
305
+ "billing:write:invoice:update-status",
306
+ { id: created["id"], changes: { status: "sent" }, version: 1 },
307
+ admin,
308
+ );
309
+ expect(updated["data"]["status"]).toBe("sent");
310
+ });
311
+
312
+ test("updateStatus with invalid transition rejected by auto guard", async () => {
313
+ const created = await createDraftInvoice("Auto Guard Fail");
314
+ const error = await stack.http.writeErr(
315
+ "billing:write:invoice:update-status",
316
+ { id: created["id"], changes: { status: "paid" }, version: 1 },
317
+ admin,
318
+ );
319
+ expectErrorIncludes(error, "Invalid transition");
320
+ });
321
+ });
322
+
323
+ // --- skipTransitionGuard ---
324
+
325
+ describe("skipTransitionGuard", () => {
326
+ test("forceStatus bypasses transition guard (draft → paid directly)", async () => {
327
+ const created = await createDraftInvoice("Force Status");
328
+ const forced = await stack.http.writeOk<InvoiceWriteResult>(
329
+ "billing:write:invoice:force-status",
330
+ { id: created["id"], status: "paid" },
331
+ admin,
332
+ );
333
+ expect(forced["data"]["status"]).toBe("paid");
334
+ });
335
+
336
+ test("forceStatus can set any state from any state", async () => {
337
+ const created = await createDraftInvoice("Force Any");
338
+ // draft → paid (skip sent)
339
+ await stack.http.writeOk<InvoiceWriteResult>(
340
+ "billing:write:invoice:force-status",
341
+ { id: created["id"], status: "paid" },
342
+ admin,
343
+ );
344
+ // paid → draft (normally impossible)
345
+ const back = await stack.http.writeOk<InvoiceWriteResult>(
346
+ "billing:write:invoice:force-status",
347
+ { id: created["id"], status: "draft" },
348
+ admin,
349
+ );
350
+ expect(back["data"]["status"]).toBe("draft");
351
+ });
352
+ });
353
+ ```
354
+
355
+ ### src/__tests__/reducer.test.ts
356
+
357
+ ```ts
358
+ import { describe, expect, test } from "bun:test";
359
+ import { eventName, INVOICE_CREATED_EVENT, INVOICE_EVENTS } from "../events";
360
+ import { type InvoiceState, initialInvoiceState, reduceInvoice } from "../reducer";
361
+
362
+ function reduce(...events: { type: string; payload?: unknown }[]): InvoiceState {
363
+ const state: InvoiceState = { ...initialInvoiceState };
364
+ for (const evt of events) reduceInvoice(state, evt);
365
+ return state;
366
+ }
367
+
368
+ describe("reduceInvoice", () => {
369
+ test("initial state is missing + amount 0", () => {
370
+ expect(initialInvoiceState).toEqual({ status: "missing", amount: 0 });
371
+ });
372
+
373
+ test("invoice.created sets status=draft and copies amount from payload", () => {
374
+ const state = reduce({ type: INVOICE_CREATED_EVENT, payload: { amount: 250 } });
375
+ expect(state.status).toBe("draft");
376
+ expect(state.amount).toBe(250);
377
+ });
378
+
379
+ test("invoice.created without amount in payload defaults amount to 0", () => {
380
+ const state = reduce({ type: INVOICE_CREATED_EVENT, payload: {} });
381
+ expect(state.status).toBe("draft");
382
+ expect(state.amount).toBe(0);
383
+ });
384
+
385
+ test("invoice-sent moves status to sent", () => {
386
+ const state = reduce(
387
+ { type: INVOICE_CREATED_EVENT, payload: { amount: 100 } },
388
+ { type: eventName(INVOICE_EVENTS.sent) },
389
+ );
390
+ expect(state.status).toBe("sent");
391
+ });
392
+
393
+ test("invoice-marked-paid moves status to paid", () => {
394
+ const state = reduce(
395
+ { type: INVOICE_CREATED_EVENT, payload: { amount: 100 } },
396
+ { type: eventName(INVOICE_EVENTS.sent) },
397
+ { type: eventName(INVOICE_EVENTS.markedPaid) },
398
+ );
399
+ expect(state.status).toBe("paid");
400
+ });
401
+
402
+ test("invoice-cancelled moves status to cancelled", () => {
403
+ const state = reduce(
404
+ { type: INVOICE_CREATED_EVENT, payload: { amount: 100 } },
405
+ { type: eventName(INVOICE_EVENTS.sent) },
406
+ { type: eventName(INVOICE_EVENTS.cancelled) },
407
+ );
408
+ expect(state.status).toBe("cancelled");
409
+ });
410
+
411
+ test("invoice-reopened moves status back to draft", () => {
412
+ const state = reduce(
413
+ { type: INVOICE_CREATED_EVENT, payload: { amount: 100 } },
414
+ { type: eventName(INVOICE_EVENTS.sent) },
415
+ { type: eventName(INVOICE_EVENTS.cancelled) },
416
+ { type: eventName(INVOICE_EVENTS.reopened) },
417
+ );
418
+ expect(state.status).toBe("draft");
419
+ });
420
+
421
+ test("invoice-status-forced applies the newStatus from payload", () => {
422
+ const state = reduce(
423
+ { type: INVOICE_CREATED_EVENT, payload: { amount: 100 } },
424
+ { type: eventName(INVOICE_EVENTS.statusForced), payload: { newStatus: "paid" } },
425
+ );
426
+ expect(state.status).toBe("paid");
427
+ });
428
+
429
+ test("amount stays after status transitions (only created mutates amount)", () => {
430
+ const state = reduce(
431
+ { type: INVOICE_CREATED_EVENT, payload: { amount: 999 } },
432
+ { type: eventName(INVOICE_EVENTS.sent) },
433
+ { type: eventName(INVOICE_EVENTS.cancelled) },
434
+ );
435
+ expect(state.amount).toBe(999);
436
+ });
437
+
438
+ test("unknown event types are ignored, state stays untouched", () => {
439
+ const state = reduce(
440
+ { type: INVOICE_CREATED_EVENT, payload: { amount: 100 } },
441
+ { type: "some-other-feature:event:nonsense", payload: { ignored: true } },
442
+ );
443
+ expect(state.status).toBe("draft");
444
+ expect(state.amount).toBe(100);
445
+ });
446
+ });
447
+ ```
448
+
449
+ ### src/entities/invoice.ts
450
+
451
+ ```ts
452
+ import {
453
+ createEntity,
454
+ createMoneyField,
455
+ createSelectField,
456
+ createTextField,
457
+ defineTransitions,
458
+ } from "@app/define";
459
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
460
+
461
+ export const INVOICE_STATES = ["draft", "sent", "paid", "cancelled"] as const;
462
+
463
+ // Single source of truth for transitions — used by entity AND handlers
464
+ const TRANSITION_CONFIG = {
465
+ draft: ["sent"],
466
+ sent: ["paid", "cancelled"],
467
+ paid: [],
468
+ cancelled: ["draft"], // reopen: cancelled → draft
469
+ } as const;
470
+
471
+ export const INVOICE_TRANSITIONS = defineTransitions(TRANSITION_CONFIG);
472
+
473
+ export const invoiceEntity = createEntity({
474
+ table: "read_sample_sm_invoices",
475
+ fields: {
476
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
477
+ amount: createMoneyField({ required: true }),
478
+ status: createSelectField({ options: INVOICE_STATES, default: "draft" }),
479
+ },
480
+ defaultCurrency: "EUR",
481
+ transitions: {
482
+ status: TRANSITION_CONFIG,
483
+ },
484
+ });
485
+
486
+ export const invoiceTable = buildEntityTable("invoice", invoiceEntity);
487
+ ```
488
+
489
+ ### src/events.ts
490
+
491
+ ```ts
492
+ // Single source of truth for event names. Keeps the reducer and the feature
493
+ // in sync — both use INVOICE_EVENTS + eventName() to derive the qualified
494
+ // strings, so a rename here propagates everywhere instead of silently
495
+ // breaking the reducer.
496
+
497
+ export const FEATURE_NAME = "billing";
498
+ export const ENTITY_NAME = "invoice";
499
+
500
+ // Short event names — passed to r.defineEvent("...") in feature.ts. The
501
+ // framework qualifies them as "billing:event:<short>" at registration time.
502
+ export const INVOICE_EVENTS = {
503
+ sent: "invoice-sent",
504
+ markedPaid: "invoice-marked-paid",
505
+ cancelled: "invoice-cancelled",
506
+ reopened: "invoice-reopened",
507
+ statusForced: "invoice-status-forced",
508
+ } as const;
509
+
510
+ // Qualifier used by the reducer to match events from the stream.
511
+ // `<const TShort>` preserves the literal-string at the call-site, so
512
+ // `eventName(INVOICE_EVENTS.sent)` carries `"billing:event:invoice-sent"`
513
+ // as a literal type. That literal is what `ctx.appendEvent({ type, ... })`
514
+ // strict-checks against the augmented `KumikoEventTypeMap`.
515
+ export function eventName<const TShort extends string>(
516
+ short: TShort,
517
+ ): `${typeof FEATURE_NAME}:event:${TShort}` {
518
+ return `${FEATURE_NAME}:event:${short}` as `${typeof FEATURE_NAME}:event:${TShort}`;
519
+ }
520
+
521
+ // The auto-generated event the executor emits on create — entity name dot
522
+ // "created", no feature prefix (executor convention).
523
+ export const INVOICE_CREATED_EVENT = `${ENTITY_NAME}.created` as const;
524
+ ```
525
+
526
+ ### src/feature.ts
527
+
528
+ ```ts
529
+ // State Machine Sample
530
+ // Shows: defineTransitions, guardTransition (manual + auto), state machine via
531
+ // event-sourced status changes. Each transition is its own domain event so the
532
+ // audit log reads like the workflow ("invoice-sent", "invoice-cancelled"), not
533
+ // like a SQL changelog ("status updated to sent").
534
+ //
535
+ // Workflow:
536
+ // draft → sent → paid (happy path)
537
+ // → cancelled (abort)
538
+ // cancelled → draft (reopen)
539
+ //
540
+ // Roles:
541
+ // Admin — create, send, cancel, reopen, updateStatus, forceStatus
542
+ // Accounting — markPaid
543
+ //
544
+ // Files:
545
+ // entities/invoice.ts — entity + table + transition config
546
+ // events.ts — single source of truth for event names
547
+ // reducer.ts — derives status + amount from the event stream
548
+ // handlers/_helpers.ts — shared transitionHandler + successResult
549
+ // handlers/create.ts — invoice:create (uses executor)
550
+ // handlers/transitions.ts — the six state-change handlers
551
+
552
+ import { defineFeature, setFields } from "@app/define";
553
+ import { z } from "zod";
554
+ import { invoiceEntity, invoiceTable } from "./entities/invoice";
555
+ import { eventName, INVOICE_EVENTS } from "./events";
556
+ import { invoiceCreate } from "./handlers/create";
557
+ import {
558
+ invoiceCancel,
559
+ invoiceForceStatus,
560
+ invoiceMarkPaid,
561
+ invoiceReopen,
562
+ invoiceSend,
563
+ invoiceUpdateStatus,
564
+ } from "./handlers/transitions";
565
+ import type { InvoiceStatus } from "./reducer";
566
+
567
+ export { invoiceEntity } from "./entities/invoice";
568
+
569
+ export const stateMachineFeature = defineFeature("billing", (r) => {
570
+ r.entity("invoice", invoiceEntity);
571
+
572
+ // Domain events — one per intent. Names live in events.ts so the reducer
573
+ // and the projection stay in sync. The auto "invoice.created" event from
574
+ // the executor's create-path covers the initial insert, so we only declare
575
+ // the state-change events here.
576
+ r.defineEvent(INVOICE_EVENTS.sent, z.object({}), { piiFields: "none" });
577
+ r.defineEvent(INVOICE_EVENTS.markedPaid, z.object({}), { piiFields: "none" });
578
+ r.defineEvent(INVOICE_EVENTS.cancelled, z.object({}), { piiFields: "none" });
579
+ r.defineEvent(INVOICE_EVENTS.reopened, z.object({}), { piiFields: "none" });
580
+ r.defineEvent(
581
+ INVOICE_EVENTS.statusForced,
582
+ z.object({
583
+ newStatus: z.enum(["draft", "sent", "paid", "cancelled"]),
584
+ fromStatus: z.enum(["draft", "sent", "paid", "cancelled"]),
585
+ }),
586
+ { piiFields: "none" },
587
+ );
588
+
589
+ // The aggregate table (sample_sm_invoices) is INSERTed by the executor on
590
+ // invoice:create. State-change events update the same row via this inline
591
+ // projection — keeps reads consistent with writes inside the same TX.
592
+ // setFields collapses "UPDATE <table> SET <cols> WHERE id = aggregateId"
593
+ // into a one-liner. Pass a literal for fixed-target events, a reducer fn
594
+ // for events that carry the new value in their payload.
595
+ r.projection({
596
+ name: "invoice-status",
597
+ source: "invoice",
598
+ table: invoiceTable,
599
+ apply: {
600
+ [eventName(INVOICE_EVENTS.sent)]: setFields(invoiceTable, { status: "sent" }),
601
+ [eventName(INVOICE_EVENTS.markedPaid)]: setFields(invoiceTable, { status: "paid" }),
602
+ [eventName(INVOICE_EVENTS.cancelled)]: setFields(invoiceTable, { status: "cancelled" }),
603
+ [eventName(INVOICE_EVENTS.reopened)]: setFields(invoiceTable, { status: "draft" }),
604
+ [eventName(INVOICE_EVENTS.statusForced)]: setFields(invoiceTable, (e) => ({
605
+ status: (e.payload as { newStatus: InvoiceStatus }).newStatus,
606
+ })),
607
+ },
608
+ });
609
+
610
+ r.writeHandler(invoiceCreate);
611
+ r.writeHandler(invoiceSend);
612
+ r.writeHandler(invoiceMarkPaid);
613
+ r.writeHandler(invoiceCancel);
614
+ r.writeHandler(invoiceReopen);
615
+ r.writeHandler(invoiceUpdateStatus);
616
+ r.writeHandler(invoiceForceStatus);
617
+ });
618
+ ```
619
+
620
+ ### src/handlers/_helpers.ts
621
+
622
+ ```ts
623
+ // Shared shape for every state-changing handler: load the current state,
624
+ // optionally guardTransition, append the domain event, return a WriteResult
625
+ // that reflects the post-write status.
626
+
627
+ import type { AccessRule, KumikoEventTypeMap } from "@app/define";
628
+ import { defineWriteHandler, guardTransition } from "@app/define";
629
+ import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
630
+ import { z } from "zod";
631
+ import { INVOICE_TRANSITIONS } from "../entities/invoice";
632
+ import { ENTITY_NAME } from "../events";
633
+ import { type InvoiceStatus, loadInvoiceState } from "../reducer";
634
+
635
+ // Narrow TEvent to event-types whose payload is `{}` — this helper
636
+ // always emits an empty payload. Anyone trying to use it with a non-
637
+ // empty-payload event-type gets caught at the call-site, not at runtime.
638
+ type EmptyPayloadEvent = {
639
+ [K in keyof KumikoEventTypeMap]: KumikoEventTypeMap[K] extends Record<string, never> ? K : never;
640
+ }[keyof KumikoEventTypeMap];
641
+
642
+ export function transitionHandler<const TEvent extends EmptyPayloadEvent>(opts: {
643
+ name: string;
644
+ toStatus: InvoiceStatus;
645
+ eventType: TEvent;
646
+ access: AccessRule;
647
+ skipGuard?: boolean;
648
+ }) {
649
+ return defineWriteHandler({
650
+ name: opts.name,
651
+ schema: z.object({ id: z.uuid() }),
652
+ access: opts.access,
653
+ handler: async (event, ctx) => {
654
+ const state = await loadInvoiceState(ctx, event.payload.id);
655
+ if (!state) return failNotFound(ENTITY_NAME, event.payload.id);
656
+
657
+ // state.status carries the "missing" virtual-state for aggregates
658
+ // that haven't been seen yet; loadInvoiceState already filters
659
+ // null, so at this point we know we're past existence. Cast to
660
+ // InvoiceStatus for the guard — "missing" can never appear after
661
+ // the !state check above.
662
+ if (!opts.skipGuard) {
663
+ guardTransition(INVOICE_TRANSITIONS, state.status as InvoiceStatus, opts.toStatus);
664
+ }
665
+
666
+ await ctx.appendEvent({
667
+ aggregateId: event.payload.id,
668
+ aggregateType: ENTITY_NAME,
669
+ type: opts.eventType,
670
+ payload: {},
671
+ });
672
+
673
+ return successResult(event.payload.id, opts.toStatus, state.status);
674
+ },
675
+ });
676
+ }
677
+
678
+ export function successResult(id: string, newStatus: InvoiceStatus, previousStatus: string) {
679
+ return {
680
+ isSuccess: true as const,
681
+ data: {
682
+ id,
683
+ data: { status: newStatus },
684
+ changes: { status: newStatus },
685
+ previous: { status: previousStatus },
686
+ isNew: false,
687
+ entityName: ENTITY_NAME,
688
+ },
689
+ };
690
+ }
691
+ ```
692
+
693
+ ### src/handlers/create.ts
694
+
695
+ ```ts
696
+ // Initial create — uses createEventStoreExecutor.create so the executor
697
+ // generates the UUID, writes the auto "invoice.created" event onto the
698
+ // stream, and inserts the projection row with status="draft" (entity-default).
699
+ //
700
+ // Schema is hand-written (not buildInsertSchema) because the public API
701
+ // keeps amount + amountCurrency flat, while the entity stores them as a
702
+ // money pair.
703
+
704
+ import { defineWriteHandler } from "@app/define";
705
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
706
+ import { z } from "zod";
707
+ import { invoiceEntity, invoiceTable } from "../entities/invoice";
708
+
709
+ const invoiceExecutor = createEventStoreExecutor(invoiceTable, invoiceEntity, {
710
+ entityName: "invoice",
711
+ });
712
+
713
+ export const invoiceCreate = defineWriteHandler({
714
+ name: "invoice:create",
715
+ schema: z.object({
716
+ title: z.string().min(1),
717
+ amount: z.number(),
718
+ amountCurrency: z.string().length(3),
719
+ }),
720
+ access: { roles: ["Admin"] },
721
+ handler: async (event, ctx) => invoiceExecutor.create(event.payload, event.user, ctx.db),
722
+ });
723
+ ```
724
+
725
+ ### src/handlers/transitions.ts
726
+
727
+ ```ts
728
+ // All six state-change handlers in one place. Five of them follow the
729
+ // transitionHandler shape verbatim; mark-paid and update-status carry a
730
+ // little extra logic (amount-check, verb routing, dedicated forced-event)
731
+ // and stay inline so the variation is visible at the call site.
732
+
733
+ import { defineWriteHandler, guardTransition } from "@app/define";
734
+ import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
735
+ import { z } from "zod";
736
+ import { INVOICE_STATES, INVOICE_TRANSITIONS } from "../entities/invoice";
737
+ import { ENTITY_NAME, eventName, INVOICE_EVENTS } from "../events";
738
+ import { type InvoiceStatus, loadInvoiceState } from "../reducer";
739
+ import { successResult, transitionHandler } from "./_helpers";
740
+
741
+ const adminOnly = { roles: ["Admin"] } as const;
742
+
743
+ // --- Standard transitions (same shape, different target status) ---
744
+
745
+ export const invoiceSend = transitionHandler({
746
+ name: "invoice:send",
747
+ toStatus: "sent",
748
+ eventType: eventName(INVOICE_EVENTS.sent),
749
+ access: adminOnly,
750
+ });
751
+
752
+ export const invoiceCancel = transitionHandler({
753
+ name: "invoice:cancel",
754
+ toStatus: "cancelled",
755
+ eventType: eventName(INVOICE_EVENTS.cancelled),
756
+ access: adminOnly,
757
+ });
758
+
759
+ export const invoiceReopen = transitionHandler({
760
+ name: "invoice:reopen",
761
+ toStatus: "draft",
762
+ eventType: eventName(INVOICE_EVENTS.reopened),
763
+ access: adminOnly,
764
+ });
765
+
766
+ // --- markPaid: same shape + business rule (amount must be > 0).
767
+ // The amount is reduced from the event stream, so the check stays
768
+ // consistent with everything else (no out-of-band DB query).
769
+
770
+ export const invoiceMarkPaid = defineWriteHandler({
771
+ name: "invoice:mark-paid",
772
+ schema: z.object({ id: z.uuid() }),
773
+ access: { roles: ["Accounting"] },
774
+ handler: async (event, ctx) => {
775
+ const state = await loadInvoiceState(ctx, event.payload.id);
776
+ if (!state) return failNotFound(ENTITY_NAME, event.payload.id);
777
+
778
+ guardTransition(INVOICE_TRANSITIONS, state.status as InvoiceStatus, "paid");
779
+
780
+ if (state.amount === 0) {
781
+ return failUnprocessable("cannot_pay_zero_amount");
782
+ }
783
+
784
+ await ctx.appendEvent({
785
+ aggregateId: event.payload.id,
786
+ aggregateType: ENTITY_NAME,
787
+ type: eventName(INVOICE_EVENTS.markedPaid),
788
+ payload: {},
789
+ });
790
+
791
+ return successResult(event.payload.id, "paid", state.status);
792
+ },
793
+ });
794
+
795
+ // --- updateStatus: relies on the pipeline's auto transition guard.
796
+ // Routes the requested status to the matching domain event so the audit
797
+ // log still reads as a workflow step ("invoice-sent"), not a generic
798
+ // "status set to ...".
799
+
800
+ // `as const satisfies` keeps the literal-strings alive (so each value is
801
+ // `"billing:event:invoice-..."` literally typed, not just `string`) AND
802
+ // type-checks the shape against `Record<InvoiceStatus, string>` —
803
+ // missing-status entries fail at compile-time. Strict-mode for
804
+ // ctx.appendEvent kicks in because `VERB_TO_EVENT[target]` resolves to
805
+ // a union of literal event-names, all of which are augmented keys.
806
+ const VERB_TO_EVENT = {
807
+ draft: eventName(INVOICE_EVENTS.reopened),
808
+ sent: eventName(INVOICE_EVENTS.sent),
809
+ paid: eventName(INVOICE_EVENTS.markedPaid),
810
+ cancelled: eventName(INVOICE_EVENTS.cancelled),
811
+ } as const satisfies Record<InvoiceStatus, string>;
812
+
813
+ export const invoiceUpdateStatus = defineWriteHandler({
814
+ name: "invoice:update-status",
815
+ schema: z.object({
816
+ id: z.uuid(),
817
+ changes: z.object({
818
+ status: z.enum(INVOICE_STATES),
819
+ }),
820
+ }),
821
+ access: adminOnly,
822
+ // No skipTransitionGuard — pipeline auto-guards.
823
+ handler: async (event, ctx) => {
824
+ const state = await loadInvoiceState(ctx, event.payload.id);
825
+ if (!state) return failNotFound(ENTITY_NAME, event.payload.id);
826
+
827
+ const target = event.payload.changes.status;
828
+ await ctx.appendEvent({
829
+ aggregateId: event.payload.id,
830
+ aggregateType: ENTITY_NAME,
831
+ type: VERB_TO_EVENT[target],
832
+ payload: {},
833
+ });
834
+
835
+ return successResult(event.payload.id, target, state.status);
836
+ },
837
+ });
838
+
839
+ // --- forceStatus: skips the guard and emits a dedicated "forced" event so
840
+ // admin overrides stay visually distinct from normal workflow events in
841
+ // the audit log.
842
+
843
+ export const invoiceForceStatus = defineWriteHandler({
844
+ name: "invoice:force-status",
845
+ schema: z.object({
846
+ id: z.uuid(),
847
+ status: z.enum(INVOICE_STATES),
848
+ }),
849
+ access: adminOnly,
850
+ unsafeSkipTransitionGuard: true,
851
+ handler: async (event, ctx) => {
852
+ const state = await loadInvoiceState(ctx, event.payload.id);
853
+ if (!state) return failNotFound(ENTITY_NAME, event.payload.id);
854
+
855
+ await ctx.appendEvent({
856
+ aggregateId: event.payload.id,
857
+ aggregateType: ENTITY_NAME,
858
+ type: eventName(INVOICE_EVENTS.statusForced),
859
+ payload: { newStatus: event.payload.status, fromStatus: state.status as InvoiceStatus },
860
+ });
861
+
862
+ return successResult(event.payload.id, event.payload.status, state.status);
863
+ },
864
+ });
865
+ ```
866
+
867
+ ### src/reducer.ts
868
+
869
+ ```ts
870
+ // Reducer that derives invoice state from the event stream.
871
+ //
872
+ // Tracks `status` (used by transition guards + reported back in the
873
+ // WriteResult) and `amount` (used by the mark-paid business rule).
874
+ //
875
+ // Adding a new state field? Extend InvoiceState, set it in `initialInvoiceState`,
876
+ // and update the relevant `case` in `reduceInvoice` — that's the whole
877
+ // contract.
878
+
879
+ import type { HandlerContext } from "@app/define";
880
+ import { eventName, INVOICE_CREATED_EVENT, INVOICE_EVENTS } from "./events";
881
+
882
+ export type InvoiceStatus = "draft" | "sent" | "paid" | "cancelled";
883
+ export type InvoiceState = {
884
+ status: InvoiceStatus | "missing";
885
+ amount: number;
886
+ };
887
+
888
+ export const initialInvoiceState: InvoiceState = { status: "missing", amount: 0 };
889
+
890
+ export function reduceInvoice(state: InvoiceState, evt: { type: string; payload?: unknown }): void {
891
+ if (evt.type === INVOICE_CREATED_EVENT) {
892
+ const payload = evt.payload as { amount?: number };
893
+ state.status = "draft";
894
+ state.amount = payload.amount ?? 0;
895
+ } else if (evt.type === eventName(INVOICE_EVENTS.sent)) {
896
+ state.status = "sent";
897
+ } else if (evt.type === eventName(INVOICE_EVENTS.markedPaid)) {
898
+ state.status = "paid";
899
+ } else if (evt.type === eventName(INVOICE_EVENTS.cancelled)) {
900
+ state.status = "cancelled";
901
+ } else if (evt.type === eventName(INVOICE_EVENTS.reopened)) {
902
+ state.status = "draft";
903
+ } else if (evt.type === eventName(INVOICE_EVENTS.statusForced)) {
904
+ const payload = evt.payload as { newStatus: InvoiceStatus };
905
+ state.status = payload.newStatus;
906
+ }
907
+ }
908
+
909
+ export async function loadInvoiceState(
910
+ ctx: HandlerContext,
911
+ id: string,
912
+ ): Promise<InvoiceState | null> {
913
+ const events = await ctx.loadAggregate(id);
914
+ if (events.length === 0) return null;
915
+ const state: InvoiceState = { ...initialInvoiceState };
916
+ for (const evt of events) reduceInvoice(state, evt);
917
+ if (state.status === "missing") return null;
918
+ return state;
919
+ }
920
+ ```