@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,539 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/error-contract. Do not edit. -->
2
+
3
+ # error-contract
4
+
5
+ Typed errors on the wire. With snake_case reasons, because we care.
6
+
7
+ ## README
8
+
9
+ # Sample: Error Contract
10
+
11
+ **I want to write a handler that handles errors cleanly — no HTTP codes, no JSON bodies, no try/catch chains.**
12
+
13
+ ## What this sample shows
14
+
15
+ Every Kumiko error class in a real handler context. A single feature `orders-lite`, four handlers, 7 test cases each demonstrating a typical error situation.
16
+
17
+ ## The recipe in 3 sentences
18
+
19
+ 1. The handler throws or returns a `KumikoError` via `writeFailure(...)` or `failNotFound(...)` / `failUnprocessable(...)`.
20
+ 2. The dispatcher translates it into HTTP status + wire format — always `{ code, i18nKey, message, details?, requestId?, timestamp }`.
21
+ 3. The client reads `error.code` (stable category) or `error.details.reason` (feature-specific subtype).
22
+
23
+ ## The classes — when do I use which?
24
+
25
+ | Class | HTTP | Use in handler |
26
+ |---|---|---|
27
+ | `ValidationError` | 400 | Automatic from Zod. Never throw manually, only for validation-hook errors. |
28
+ | `AccessDeniedError` | 403 | "You're not allowed" — ownership, role check, field lock. |
29
+ | `NotFoundError` | 404 | Entity doesn't exist. Automatic via `failNotFound(entity, id)`. |
30
+ | `ConflictError` | 409 | State collision without a version (e.g. "paid orders can't be cancelled"). |
31
+ | `VersionConflictError` | 409 | Optimistic lock — comes out of CrudExecutor automatically. You never throw it. |
32
+ | `UnprocessableError` | 422 | Business rule violated. The reason string describes what. |
33
+ | `InternalError` | 500 | You **don't throw it yourself**. The framework wraps unexpected throws automatically. |
34
+
35
+ ## Convenience helpers
36
+
37
+ Instead of
38
+
39
+ ```ts illustration
40
+ return { isSuccess: false, error: toWriteErrorInfo(new NotFoundError("order", id)) };
41
+ ```
42
+
43
+ write
44
+
45
+ ```ts illustration
46
+ return failNotFound("order", id);
47
+ ```
48
+
49
+ Likewise: `failUnprocessable("reason", details?)` and `writeFailure(new AnyKumikoError(...))`.
50
+
51
+ ## Reason codes — the convention
52
+
53
+ When your feature needs its own differentiation (e.g. `already_paid` vs. `already_cancelled`), use `UnprocessableError` or `ConflictError` and set `details.reason`:
54
+
55
+ ```ts illustration
56
+ export const OrdersLiteReasons = {
57
+ alreadyPaid: "already_paid",
58
+ alreadyCancelled: "already_cancelled",
59
+ } as const;
60
+
61
+ return failUnprocessable(OrdersLiteReasons.alreadyPaid, { orderId });
62
+ ```
63
+
64
+ **Rules:**
65
+ - `snake_case`, no spaces
66
+ - One `<Feature>Reasons` const-object per feature
67
+ - Framework reasons (`stale_state`, `invalid_transition`, `field_access_denied`, `delete_restricted`) come from `FrameworkReasons` — **reuse, don't duplicate**
68
+
69
+ ## Throw vs. writeFailure
70
+
71
+ Both end up in the same wire format. Rule of thumb:
72
+
73
+ - **Handler top-level** → `return writeFailure(new X())` or the `failX(...)` helpers. The return type is explicit.
74
+ - **Deep inside a helper function** → `throw new KumikoError(...)`. Otherwise you'd have to thread `WriteResult` through every function signature.
75
+
76
+ ## Cause chain
77
+
78
+ When you throw a KumikoError that has another error as its cause:
79
+
80
+ ```ts illustration
81
+ try {
82
+ await externalApi.call();
83
+ } catch (e) {
84
+ throw new ConflictError({
85
+ message: "upstream rejected the sync",
86
+ i18nKey: "orders-lite.errors.upstreamReject",
87
+ details: { reason: "upstream_reject" },
88
+ cause: e instanceof Error ? e : undefined,
89
+ });
90
+ }
91
+ ```
92
+
93
+ The chain lands in the log (for forensics), but **not** in the response to the client. No manual filter required.
94
+
95
+ ## What you should **not** do
96
+
97
+ - `throw new Error("string")` — becomes `InternalError` (500), the client sees no helpful error
98
+ - `return { isSuccess: false, error: "string" }` — not a valid `WriteErrorInfo`, TypeScript blocks it but it's a typical pre-v1 pattern
99
+ - Custom `class MyError extends Error` — also becomes `InternalError`. Use `UnprocessableError` + `details.reason` for feature subtypes
100
+ - Reason strings like `"userNotAllowedToEditRecord"` (camelCase) or with spaces — the convention is `snake_case`
101
+
102
+ ## Further reading
103
+
104
+ - Full class definitions: `packages/framework/src/errors/classes.ts`
105
+ - Gold-standard integration test: `packages/framework/src/__tests__/error-contract.integration.test.ts`
106
+
107
+ ## Source files
108
+
109
+ ### src/__tests__/feature.integration.test.ts
110
+
111
+ ```ts
112
+ // Error-Contract Sample — integration test
113
+ //
114
+ // Proves every pattern in feature.ts makes it through the real stack with the
115
+ // documented wire shape. If you extend the sample, add a matching test here
116
+ // so the "copy this" guarantee stays valid.
117
+
118
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
119
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
120
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
121
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
122
+ import {
123
+ createTestUser,
124
+ setupTestStack,
125
+ type TestStack,
126
+ TestUsers,
127
+ unsafeCreateEntityTable,
128
+ } from "@cosmicdrift/kumiko-framework/stack";
129
+ import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
130
+ import { OrdersLiteReasons, orderEntity, ordersLiteFeature } from "../feature";
131
+
132
+ let stack: TestStack;
133
+ const orderTable = buildEntityTable("order", orderEntity);
134
+
135
+ const admin = TestUsers.admin;
136
+ const alice = createTestUser({ id: 10, roles: ["User"] });
137
+ const bob = createTestUser({ id: 11, roles: ["User"] });
138
+
139
+ beforeAll(async () => {
140
+ stack = await setupTestStack({ features: [ordersLiteFeature], systemHooks: [] });
141
+ await unsafeCreateEntityTable(stack.db, orderEntity);
142
+ await createEventsTable(stack.db);
143
+ });
144
+ afterAll(async () => stack.cleanup());
145
+ beforeEach(async () => {
146
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${orderTable.tableName}"`);
147
+ });
148
+
149
+ // --- 1) Zod validation error via schema: empty cart rejected ---
150
+
151
+ describe("Zod validation → ValidationError", () => {
152
+ test("totalCents < 1 rejects with validation_error + details.fields", async () => {
153
+ const error = await stack.http.writeErr(
154
+ "orders-lite:write:order:create",
155
+ { totalCents: 0 },
156
+ alice,
157
+ );
158
+ expect(error.code).toBe("validation_error");
159
+ const fields = (error.details as { fields: Array<{ path: string; code: string }> }).fields;
160
+ expect(fields).toContainEqual(expect.objectContaining({ path: "totalCents" }));
161
+ });
162
+ });
163
+
164
+ // --- 2) NotFoundError from failNotFound helper ---
165
+
166
+ describe("failNotFound → NotFoundError", () => {
167
+ test("pay on missing order → 404, reason = order_not_found", async () => {
168
+ const error = await stack.http.writeErr(
169
+ "orders-lite:write:order:pay",
170
+ { id: "00000000-0000-4000-8000-000000000999" },
171
+ alice,
172
+ );
173
+ expect(error.code).toBe("not_found");
174
+ expect(error.httpStatus).toBe(404);
175
+ expect(error.details).toMatchObject({ reason: "order_not_found", entity: "order" });
176
+ });
177
+ });
178
+
179
+ // --- 3) AccessDeniedError for ownership mismatch ---
180
+
181
+ describe("writeFailure(AccessDeniedError) → 403", () => {
182
+ test("Bob cannot pay Alice's order", async () => {
183
+ const alicesOrder = await stack.http.writeOk<{ id: number }>(
184
+ "orders-lite:write:order:create",
185
+ { totalCents: 100 },
186
+ alice,
187
+ );
188
+ const error = await stack.http.writeErr(
189
+ "orders-lite:write:order:pay",
190
+ { id: alicesOrder.id },
191
+ bob,
192
+ );
193
+ expect(error.code).toBe("access_denied");
194
+ expect(error.httpStatus).toBe(403);
195
+ expect(error.details).toMatchObject({ reason: "not_yours", orderId: alicesOrder.id });
196
+ });
197
+ });
198
+
199
+ // --- 4) UnprocessableError with feature reason ---
200
+
201
+ describe("failUnprocessable(OrdersLiteReasons.X) → 422", () => {
202
+ test("cancelling an already-cancelled order fails with reason=already_cancelled", async () => {
203
+ const order = await stack.http.writeOk<{ id: number }>(
204
+ "orders-lite:write:order:create",
205
+ { totalCents: 100 },
206
+ alice,
207
+ );
208
+ await stack.http.writeOk("orders-lite:write:order:cancel", { id: order.id }, alice);
209
+
210
+ const error = await stack.http.writeErr(
211
+ "orders-lite:write:order:cancel",
212
+ { id: order.id },
213
+ alice,
214
+ );
215
+ expect(error.code).toBe("unprocessable");
216
+ expect(error.httpStatus).toBe(422);
217
+ expect(error.details).toMatchObject({ reason: OrdersLiteReasons.alreadyCancelled });
218
+ });
219
+ });
220
+
221
+ // --- 5) ConflictError for a domain conflict ---
222
+
223
+ describe("ConflictError → 409", () => {
224
+ test("cancelling a paid order is a refund-required conflict", async () => {
225
+ const order = await stack.http.writeOk<{ id: number }>(
226
+ "orders-lite:write:order:create",
227
+ { totalCents: 100 },
228
+ alice,
229
+ );
230
+ // Force the row into `paid` via direct DB update. The sample feature
231
+ // doesn't expose a "place" action, so we can't walk the state machine —
232
+ // the point of this test is ConflictError semantics, not the transition
233
+ // path itself.
234
+ await updateRows(stack.db, orderTable, { status: "paid" }, { id: order.id });
235
+
236
+ const error = await stack.http.writeErr(
237
+ "orders-lite:write:order:cancel",
238
+ { id: order.id },
239
+ alice,
240
+ );
241
+ expect(error.code).toBe("conflict");
242
+ expect(error.httpStatus).toBe(409);
243
+ expect(error.details).toMatchObject({ reason: "refund_required" });
244
+ });
245
+ });
246
+
247
+ // --- 6) guardTransition → UnprocessableError with FrameworkReasons ---
248
+
249
+ describe("guardTransition → FrameworkReasons.invalidTransition", () => {
250
+ test("paying a cancelled order is an invalid transition", async () => {
251
+ const order = await stack.http.writeOk<{ id: number }>(
252
+ "orders-lite:write:order:create",
253
+ { totalCents: 100 },
254
+ admin,
255
+ );
256
+ await stack.http.writeOk("orders-lite:write:order:cancel", { id: order.id }, admin);
257
+
258
+ const error = await stack.http.writeErr("orders-lite:write:order:pay", { id: order.id }, admin);
259
+ expect(error.code).toBe("unprocessable");
260
+ expect(error.details).toMatchObject({
261
+ reason: "invalid_transition",
262
+ from: "cancelled",
263
+ to: "paid",
264
+ });
265
+ });
266
+ });
267
+
268
+ // --- 7) Throw-based KumikoError (not via writeFailure) ---
269
+
270
+ describe("direct throw of KumikoError", () => {
271
+ test("thrown NotFoundError round-trips to the same wire format", async () => {
272
+ const error = await stack.http.writeErr(
273
+ "orders-lite:write:order:rename",
274
+ { id: "00000000-0000-4000-8000-000000000999", nickname: "Anything" },
275
+ alice,
276
+ );
277
+ expect(error.code).toBe("not_found");
278
+ expect(error.details).toMatchObject({ entity: "order" });
279
+ });
280
+
281
+ test("thrown UnprocessableError with literal reason", async () => {
282
+ const order = await stack.http.writeOk<{ id: number }>(
283
+ "orders-lite:write:order:create",
284
+ { totalCents: 100 },
285
+ alice,
286
+ );
287
+ const error = await stack.http.writeErr(
288
+ "orders-lite:write:order:rename",
289
+ { id: order.id, nickname: "banned" },
290
+ alice,
291
+ );
292
+ expect(error.code).toBe("unprocessable");
293
+ expect(error.details).toMatchObject({ reason: "nickname_not_allowed" });
294
+ });
295
+ });
296
+ ```
297
+
298
+ ### src/feature.ts
299
+
300
+ ```ts
301
+ // Error-Contract Sample
302
+ //
303
+ // Shows: how a feature raises each Kumiko error class in the places a real
304
+ // handler would — pre-flight checks, business-rule violations, stale writes,
305
+ // access guards. Every pattern here is copy-pasteable into a real feature.
306
+ //
307
+ // Key idea: the handler only decides *which* Kumiko class fits. The framework
308
+ // does the rest — HTTP status, response shape, Zod parsing, cause chain,
309
+ // tx rollback. The handler author never has to touch HTTP codes or assemble
310
+ // JSON error bodies.
311
+
312
+ import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
313
+ import {
314
+ createEntity,
315
+ createNumberField,
316
+ createSelectField,
317
+ createTextField,
318
+ defineFeature,
319
+ defineTransitions,
320
+ guardTransition,
321
+ } from "@cosmicdrift/kumiko-framework/engine";
322
+ import {
323
+ AccessDeniedError,
324
+ ConflictError,
325
+ failNotFound,
326
+ failUnprocessable,
327
+ NotFoundError,
328
+ UnprocessableError,
329
+ writeFailure,
330
+ } from "@cosmicdrift/kumiko-framework/errors";
331
+ import { z } from "zod";
332
+
333
+ // --- Feature-local reasons: snake_case, no feature prefix needed since the
334
+ // feature name is already namespaced by defineFeature("orders-lite", ...).
335
+ // The framework convention: one const object per feature that needs its
336
+ // own reason strings. For framework-level reasons (stale_state,
337
+ // invalid_transition, ...) use FrameworkReasons instead.
338
+ export const OrdersLiteReasons = {
339
+ alreadyPaid: "already_paid",
340
+ alreadyCancelled: "already_cancelled",
341
+ emptyCart: "cart_is_empty",
342
+ } as const;
343
+
344
+ // --- Entity with a state machine so we can show guardTransition + version
345
+ // conflicts. Prices in cents (integer minor unit — see money-type.md).
346
+
347
+ const ORDER_STATES = ["draft", "placed", "paid", "cancelled"] as const;
348
+ type OrderState = (typeof ORDER_STATES)[number];
349
+
350
+ const ORDER_TRANSITIONS = defineTransitions({
351
+ draft: ["placed", "cancelled"],
352
+ placed: ["paid", "cancelled"],
353
+ paid: [],
354
+ cancelled: [],
355
+ });
356
+
357
+ export const orderEntity = createEntity({
358
+ table: "read_errctr_orders",
359
+ fields: {
360
+ ownerId: createTextField({ personal: "ref", required: true }),
361
+ status: createSelectField({ options: ORDER_STATES, default: "draft" }),
362
+ totalCents: createNumberField({ default: 0 }),
363
+ },
364
+ transitions: {
365
+ status: {
366
+ draft: ["placed", "cancelled"],
367
+ placed: ["paid", "cancelled"],
368
+ paid: [],
369
+ cancelled: [],
370
+ },
371
+ },
372
+ });
373
+
374
+ const orderTable = buildEntityTable("order", orderEntity);
375
+
376
+ export const ordersLiteFeature = defineFeature("orders-lite", (r) => {
377
+ r.entity("order", orderEntity);
378
+
379
+ // 1) Create — standard path. Zod schema rejects an empty cart; the
380
+ // dispatcher converts the ZodIssue into a ValidationError with
381
+ // details.fields[] (no handler code needed).
382
+ r.writeHandler(
383
+ "order:create",
384
+ z.object({
385
+ totalCents: z.number().int().min(1, "cart_is_empty"),
386
+ }),
387
+ async (event, ctx) => {
388
+ const crud = createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" });
389
+ return crud.create(
390
+ { ...event.payload, ownerId: event.user.id, status: "draft" },
391
+ event.user,
392
+ ctx.db,
393
+ );
394
+ },
395
+ { access: { roles: ["User", "Admin"] } },
396
+ );
397
+
398
+ // 2) Pay — business rule: only placed orders can be paid. Shows three
399
+ // different failure shapes in a single handler:
400
+ // - NotFoundError (order doesn't exist)
401
+ // - AccessDeniedError (someone else's order, distinct from not_found
402
+ // so the caller knows it's a permissions issue)
403
+ // - UnprocessableError with reason from OrdersLiteReasons
404
+ // - Framework-generated UnprocessableError with reason
405
+ // FrameworkReasons.invalidTransition via guardTransition
406
+ r.writeHandler(
407
+ "order:pay",
408
+ z.object({ id: z.uuid() }),
409
+ async (event, ctx) => {
410
+ const [current] = await ctx.db.selectMany(orderTable, { id: event.payload.id });
411
+
412
+ // NotFoundError — automatic reason = "order_not_found" via snake-case
413
+ // derivation of the entity name.
414
+ if (!current) return failNotFound("order", event.payload.id);
415
+
416
+ const data = current as Record<string, unknown>;
417
+
418
+ // AccessDeniedError — intentional info-leak-prevention. We *could*
419
+ // return NotFoundError here too, but splitting the two lets the
420
+ // admin UI distinguish "doesn't exist" from "exists but foreign".
421
+ if (data["ownerId"] !== event.user.id && !event.user.roles.includes("Admin")) {
422
+ return writeFailure(
423
+ new AccessDeniedError({
424
+ message: "order is not yours",
425
+ i18nKey: "orders-lite.errors.notYours",
426
+ details: { reason: "not_yours", orderId: event.payload.id },
427
+ }),
428
+ );
429
+ }
430
+
431
+ // UnprocessableError with feature reason — "this order is already in
432
+ // the terminal paid state". Uses the OrdersLiteReasons const so the
433
+ // literal string lives in one place.
434
+ if (data["status"] === "paid") {
435
+ return failUnprocessable(OrdersLiteReasons.alreadyPaid, {
436
+ orderId: event.payload.id,
437
+ });
438
+ }
439
+
440
+ // guardTransition throws UnprocessableError with
441
+ // reason = FrameworkReasons.invalidTransition. Callers can branch on
442
+ // that framework-level reason uniformly across entities.
443
+ guardTransition(ORDER_TRANSITIONS, data["status"] as OrderState, "paid");
444
+
445
+ const crud = createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" });
446
+ return crud.update(
447
+ {
448
+ id: event.payload.id,
449
+ changes: { status: "paid" },
450
+ version: data["version"] as number,
451
+ },
452
+ event.user,
453
+ ctx.db,
454
+ );
455
+ },
456
+ { access: { roles: ["User", "Admin"] } },
457
+ );
458
+
459
+ // 3) Cancel — shows ConflictError for a business conflict that isn't a
460
+ // transition or a stale write. E.g. a refund window expired; the row
461
+ // is fine but the action is no longer allowed.
462
+ r.writeHandler(
463
+ "order:cancel",
464
+ z.object({ id: z.uuid() }),
465
+ async (event, ctx) => {
466
+ const [current] = await ctx.db.selectMany(orderTable, { id: event.payload.id });
467
+ if (!current) return failNotFound("order", event.payload.id);
468
+
469
+ const data = current as Record<string, unknown>;
470
+
471
+ if (data["status"] === "cancelled") {
472
+ return failUnprocessable(OrdersLiteReasons.alreadyCancelled);
473
+ }
474
+ if (data["status"] === "paid") {
475
+ return writeFailure(
476
+ new ConflictError({
477
+ message: "paid orders cannot be cancelled — issue a refund instead",
478
+ i18nKey: "orders-lite.errors.cannotCancelPaid",
479
+ details: { reason: "refund_required", orderId: event.payload.id },
480
+ }),
481
+ );
482
+ }
483
+
484
+ const crud = createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" });
485
+ return crud.update(
486
+ {
487
+ id: event.payload.id,
488
+ changes: { status: "cancelled" },
489
+ version: data["version"] as number,
490
+ },
491
+ event.user,
492
+ ctx.db,
493
+ );
494
+ },
495
+ { access: { roles: ["User", "Admin"] } },
496
+ );
497
+
498
+ // 4) Update — demonstrates the throw-based path (KumikoError raised
499
+ // directly, not via writeFailure return). The dispatcher catches it
500
+ // and wraps it in a WriteErrorInfo the same as the return path.
501
+ // Use this style when a helper deep in the call tree needs to abort
502
+ // without threading a WriteResult back up.
503
+ r.writeHandler(
504
+ "order:rename",
505
+ z.object({ id: z.uuid(), nickname: z.string().min(1) }),
506
+ async (event, ctx) => {
507
+ const [current] = await ctx.db.selectMany(orderTable, { id: event.payload.id });
508
+
509
+ if (!current) {
510
+ // Throwing a KumikoError is equivalent to `return writeFailure(...)`
511
+ // — both land on the same wire format. Prefer throws from deeper
512
+ // helper functions, writeFailure from the handler's top level.
513
+ throw new NotFoundError("order", event.payload.id);
514
+ }
515
+ if (event.payload.nickname === "banned") {
516
+ throw new UnprocessableError("nickname_not_allowed", {
517
+ i18nKey: "orders-lite.errors.bannedNickname",
518
+ });
519
+ }
520
+
521
+ // No real rename yet — the entity has no nickname column. Return a
522
+ // synthetic save context so the handler stays well-typed.
523
+ return {
524
+ isSuccess: true,
525
+ data: {
526
+ kind: "save",
527
+ id: event.payload.id,
528
+ data: current as Record<string, unknown>,
529
+ changes: event.payload,
530
+ previous: current as Record<string, unknown>,
531
+ isNew: false,
532
+ entityName: "order",
533
+ },
534
+ };
535
+ },
536
+ { access: { roles: ["User", "Admin"] } },
537
+ );
538
+ });
539
+ ```