@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,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
|
+
```
|