@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.
- package/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- 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
|
+
```
|