@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,792 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/pipeline-basics. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# pipeline-basics
|
|
4
|
+
|
|
5
|
+
M.1 Tier-1 step-vocabulary showcase: every step in one inventory-management feature (aggregate.{create,update,appendEvent}, read.{findOne,findMany}, branch, forEach, compute, return, unsafeProjection{Upsert,Delete}).
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Pipeline Basics — Inventory-Management
|
|
10
|
+
|
|
11
|
+
End-to-end showcase of the **M.1 step-vocabulary**: every Tier-1 step
|
|
12
|
+
exercised in a single inventory-management feature, with a real
|
|
13
|
+
Postgres + Redis integration test that proves the pipeline form
|
|
14
|
+
behaves end-to-end against the worktree's framework source.
|
|
15
|
+
|
|
16
|
+
## What's inside
|
|
17
|
+
|
|
18
|
+
| Step | Where it shows up |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `r.step.aggregate.create` | `product:create` |
|
|
21
|
+
| `r.step.aggregate.update` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
|
|
22
|
+
| `r.step.aggregate.appendEvent` | `product:adjust-stock`, `product:archive`, `report:archive-low-stock-products` |
|
|
23
|
+
| `r.step.read.findOne` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
|
|
24
|
+
| `r.step.read.findMany` | `report:archive-low-stock-products` |
|
|
25
|
+
| `r.step.compute` | `product:adjust-stock`, `product:bulk-adjust` |
|
|
26
|
+
| `r.step.branch` | `product:rename`, `product:adjust-stock` |
|
|
27
|
+
| `r.step.forEach` | `product:bulk-adjust`, `report:archive-low-stock-products` |
|
|
28
|
+
| `r.step.return` | every handler |
|
|
29
|
+
| `r.step.unsafeProjectionUpsert` | `product:adjust-stock` |
|
|
30
|
+
| `r.step.unsafeProjectionDelete` | `product:adjust-stock`, `product:archive`, `report:archive-low-stock-products` |
|
|
31
|
+
|
|
32
|
+
## Domain
|
|
33
|
+
|
|
34
|
+
A product has an SKU, a name, and a current-stock counter. Operators
|
|
35
|
+
adjust stock individually (`product:adjust-stock`) or in bulk
|
|
36
|
+
(`product:bulk-adjust`). Whenever stock crosses below a threshold
|
|
37
|
+
(`LOW_STOCK_THRESHOLD = 10`), an alert row is upserted into a custom
|
|
38
|
+
projection (`read_inventory_low_stock_alerts`); when it returns above,
|
|
39
|
+
the alert is deleted — both inline in the same TX as the aggregate
|
|
40
|
+
write.
|
|
41
|
+
|
|
42
|
+
## Reading order
|
|
43
|
+
|
|
44
|
+
Open `src/feature.ts` and read top-to-bottom. Each handler is
|
|
45
|
+
preceded by a short comment block that names the steps it exercises
|
|
46
|
+
and why the combination is the production-realistic shape (not just
|
|
47
|
+
an API tour).
|
|
48
|
+
|
|
49
|
+
If you've only ever seen the free-form handler signature
|
|
50
|
+
(`r.writeHandler(name, schema, async fn, opts)`), compare:
|
|
51
|
+
|
|
52
|
+
- `samples/recipes/custom-handlers/src/feature.ts` — a similar
|
|
53
|
+
domain, written in the free-form style.
|
|
54
|
+
- this file — the same kind of operations expressed as
|
|
55
|
+
`defineWriteHandler({ perform: stepsPipeline(...) })`.
|
|
56
|
+
|
|
57
|
+
Two patterns the pipeline form catches that the free-form does not:
|
|
58
|
+
|
|
59
|
+
1. **Skip-if-noop**: `product:rename` runs `read.findOne` followed
|
|
60
|
+
by `branch` — the `aggregate.update` step only fires when the
|
|
61
|
+
name actually changed. In free-form code this kind of check
|
|
62
|
+
tends to be inlined inconsistently per handler; the pipeline
|
|
63
|
+
form makes it a visible step.
|
|
64
|
+
2. **Read-then-loop**: `report:archive-low-stock-products` calls
|
|
65
|
+
`read.findMany` and pipes the result into `forEach` whose body
|
|
66
|
+
appends an event + deletes the projection-row per item. The
|
|
67
|
+
sub-pipeline boundary is explicit, the boot-validator walks
|
|
68
|
+
into it, and the Designer (M.5) can render the loop body as a
|
|
69
|
+
nested step group.
|
|
70
|
+
|
|
71
|
+
## Running
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# From THIS sample directory
|
|
75
|
+
cd samples/recipes/pipeline-basics
|
|
76
|
+
bun test
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The test relies on Postgres + Redis from `docker compose up`
|
|
80
|
+
(framework dev stack — not the published image).
|
|
81
|
+
|
|
82
|
+
### Worktree / alias note
|
|
83
|
+
|
|
84
|
+
This sample may need tsconfig `paths` so `@cosmicdrift/kumiko-framework`
|
|
85
|
+
resolves to `packages/framework/src` when testing unreleased engine APIs.
|
|
86
|
+
The repo-wide integration test config excludes this sample until the
|
|
87
|
+
engine APIs land in a published release.
|
|
88
|
+
|
|
89
|
+
## Caveats / things to know
|
|
90
|
+
|
|
91
|
+
- **Pure-ES vs CRUD-update**: `product:adjust-stock` is written
|
|
92
|
+
in the pure event-sourcing shape — it appends a domain event
|
|
93
|
+
(`product-stock-adjusted`) and lets the inline
|
|
94
|
+
`product-stock-counter` projection update `currentStock`. The
|
|
95
|
+
alternative (calling `aggregate.update` *and* `appendEvent` in
|
|
96
|
+
one handler) inflates the stream-version mid-handler and trips
|
|
97
|
+
optimistic-locking on the next call. The other handlers
|
|
98
|
+
(`rename`, `bulk-adjust`) use `aggregate.update` directly
|
|
99
|
+
because they don't combine it with `appendEvent`.
|
|
100
|
+
- **`unsafeProjection.*` allowlist**: every projection-table
|
|
101
|
+
written via `unsafeProjectionUpsert`/`Delete` must be declared
|
|
102
|
+
via `r.requires.projection("table_name")` in the same feature.
|
|
103
|
+
The boot-validator enforces this and rejects writes against
|
|
104
|
+
aggregate-tables (registered via `r.entity`) — those have to
|
|
105
|
+
go through `r.step.aggregate.*`.
|
|
106
|
+
- **`PipelineCtx` import**: one resolver in `product:adjust-stock`
|
|
107
|
+
annotates its argument as `PipelineCtx` because `payload:
|
|
108
|
+
StepResolver<unknown>` can't infer the destructure. This is a
|
|
109
|
+
known M.1 DX gap (Followup #4 — TData-inference); the rest of
|
|
110
|
+
this sample's `as`-cast lastiness will get cleaner once that
|
|
111
|
+
followup is closed.
|
|
112
|
+
- **Worktree-local test setup**: tsconfig `paths` may alias
|
|
113
|
+
`@cosmicdrift/kumiko-framework` to the worktree source when testing
|
|
114
|
+
unreleased engine APIs. Once published, the alias can go away.
|
|
115
|
+
|
|
116
|
+
## Source files
|
|
117
|
+
|
|
118
|
+
### src/__tests__/inventory.integration.test.ts
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// Pipeline Basics — Integration Test
|
|
122
|
+
//
|
|
123
|
+
// Drives every Tier-1 step end-to-end against a real Postgres
|
|
124
|
+
// stack via setupTestStack. The handlers under test live in
|
|
125
|
+
// ../feature.ts; this file proves they wire correctly to the
|
|
126
|
+
// HTTP dispatcher + event-store + projections.
|
|
127
|
+
|
|
128
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
129
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
130
|
+
import {
|
|
131
|
+
createTestUser,
|
|
132
|
+
resetEventStore,
|
|
133
|
+
setupTestStack,
|
|
134
|
+
type TestStack,
|
|
135
|
+
unsafePushTables,
|
|
136
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
137
|
+
import { inventoryFeature, lowStockAlertsTable, productTable } from "../feature";
|
|
138
|
+
|
|
139
|
+
let stack: TestStack;
|
|
140
|
+
const admin = createTestUser({ roles: ["Admin"] });
|
|
141
|
+
|
|
142
|
+
beforeAll(async () => {
|
|
143
|
+
// setupTestStack auto-pushes every table registered via r.projection() —
|
|
144
|
+
// the inline `product-stock-counter` projection in the feature points
|
|
145
|
+
// at productTable, so the aggregate table gets created automatically.
|
|
146
|
+
// Only the custom non-aggregate projection (low_stock_alerts) needs an
|
|
147
|
+
// explicit unsafePushTables — it has no projection registration.
|
|
148
|
+
stack = await setupTestStack({ features: [inventoryFeature], systemHooks: [] });
|
|
149
|
+
await unsafePushTables(stack.db, {
|
|
150
|
+
read_inventory_low_stock_alerts: lowStockAlertsTable,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterAll(async () => {
|
|
155
|
+
await stack.cleanup();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
beforeEach(async () => {
|
|
159
|
+
await resetEventStore(stack, ["read_inventory_products", "read_inventory_low_stock_alerts"]);
|
|
160
|
+
await stack.redis.flushNamespace();
|
|
161
|
+
await stack.eventDispatcher?.ensureRegistered();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("Pipeline Basics — Inventory", () => {
|
|
165
|
+
test("product:create — aggregate.create lands a row + returns id", async () => {
|
|
166
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
167
|
+
"inventory:write:product:create",
|
|
168
|
+
{ sku: "SKU-001", name: "Widget", initialStock: 50 },
|
|
169
|
+
admin,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
|
173
|
+
|
|
174
|
+
const [row] = await selectMany(stack.db, productTable, { id });
|
|
175
|
+
expect(row).toMatchObject({ sku: "SKU-001", name: "Widget", currentStock: 50 });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("product:rename — first call updates, second call with same name is a no-op", async () => {
|
|
179
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
180
|
+
"inventory:write:product:create",
|
|
181
|
+
{ sku: "SKU-002", name: "Gizmo", initialStock: 5 },
|
|
182
|
+
admin,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const first = await stack.http.writeOk<{ id: string; renamed: boolean }>(
|
|
186
|
+
"inventory:write:product:rename",
|
|
187
|
+
{ id, name: "Gizmo Pro" },
|
|
188
|
+
admin,
|
|
189
|
+
);
|
|
190
|
+
expect(first.renamed).toBe(true);
|
|
191
|
+
|
|
192
|
+
const second = await stack.http.writeOk<{ id: string; renamed: boolean }>(
|
|
193
|
+
"inventory:write:product:rename",
|
|
194
|
+
{ id, name: "Gizmo Pro" },
|
|
195
|
+
admin,
|
|
196
|
+
);
|
|
197
|
+
expect(second.renamed).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("product:adjust-stock — below threshold inserts low-stock-alert", async () => {
|
|
201
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
202
|
+
"inventory:write:product:create",
|
|
203
|
+
{ sku: "SKU-003", name: "Bolt", initialStock: 15 },
|
|
204
|
+
admin,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const adjusted = await stack.http.writeOk<{ id: string; newStock: number }>(
|
|
208
|
+
"inventory:write:product:adjust-stock",
|
|
209
|
+
{ id, delta: -10, reason: "shipped" },
|
|
210
|
+
admin,
|
|
211
|
+
);
|
|
212
|
+
expect(adjusted.newStock).toBe(5);
|
|
213
|
+
|
|
214
|
+
const [alert] = await selectMany(stack.db, lowStockAlertsTable, { productId: id });
|
|
215
|
+
expect(alert).toBeDefined();
|
|
216
|
+
expect(alert).toMatchObject({
|
|
217
|
+
productId: id,
|
|
218
|
+
sku: "SKU-003",
|
|
219
|
+
currentStock: 5,
|
|
220
|
+
threshold: 10,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("product:adjust-stock — back above threshold deletes the alert", async () => {
|
|
225
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
226
|
+
"inventory:write:product:create",
|
|
227
|
+
{ sku: "SKU-004", name: "Nut", initialStock: 5 },
|
|
228
|
+
admin,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Initial create is below threshold; trigger an adjustment to seed the alert row.
|
|
232
|
+
await stack.http.writeOk(
|
|
233
|
+
"inventory:write:product:adjust-stock",
|
|
234
|
+
{ id, delta: 0, reason: "seed-alert" },
|
|
235
|
+
admin,
|
|
236
|
+
);
|
|
237
|
+
const [seeded] = await selectMany(stack.db, lowStockAlertsTable, { productId: id });
|
|
238
|
+
expect(seeded).toBeDefined();
|
|
239
|
+
|
|
240
|
+
// Restock above threshold — branch.onFalse should delete the alert.
|
|
241
|
+
await stack.http.writeOk(
|
|
242
|
+
"inventory:write:product:adjust-stock",
|
|
243
|
+
{ id, delta: 20, reason: "restock" },
|
|
244
|
+
admin,
|
|
245
|
+
);
|
|
246
|
+
const after = await selectMany(stack.db, lowStockAlertsTable, { productId: id });
|
|
247
|
+
expect(after).toHaveLength(0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("product:bulk-adjust — forEach updates each product sequentially", async () => {
|
|
251
|
+
const ids = await Promise.all(
|
|
252
|
+
[1, 2, 3].map(async (n) => {
|
|
253
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
254
|
+
"inventory:write:product:create",
|
|
255
|
+
{ sku: `SKU-BULK-${n}`, name: `Bulk-${n}`, initialStock: 100 },
|
|
256
|
+
admin,
|
|
257
|
+
);
|
|
258
|
+
return id;
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const result = await stack.http.writeOk<{ processed: number }>(
|
|
263
|
+
"inventory:write:product:bulk-adjust",
|
|
264
|
+
{
|
|
265
|
+
adjustments: [
|
|
266
|
+
{ id: ids[0]!, delta: -5 },
|
|
267
|
+
{ id: ids[1]!, delta: -10 },
|
|
268
|
+
{ id: ids[2]!, delta: -50 },
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
admin,
|
|
272
|
+
);
|
|
273
|
+
expect(result.processed).toBe(3);
|
|
274
|
+
|
|
275
|
+
// bulk-adjust deliberately omits the low-stock-alert branching —
|
|
276
|
+
// that logic lives only in the single-product `adjust-stock`
|
|
277
|
+
// handler. So even when bulk-adjustments push stock below the
|
|
278
|
+
// threshold, no alert rows are written here.
|
|
279
|
+
const alerts = await selectMany(stack.db, lowStockAlertsTable);
|
|
280
|
+
expect(alerts).toHaveLength(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("product:archive — appendEvent + projection cleanup in one TX", async () => {
|
|
284
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
285
|
+
"inventory:write:product:create",
|
|
286
|
+
{ sku: "SKU-ARCH", name: "Doomed", initialStock: 5 },
|
|
287
|
+
admin,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// Seed the alert row by adjusting (initial-stock 5 < threshold 10).
|
|
291
|
+
await stack.http.writeOk(
|
|
292
|
+
"inventory:write:product:adjust-stock",
|
|
293
|
+
{ id, delta: 0, reason: "seed" },
|
|
294
|
+
admin,
|
|
295
|
+
);
|
|
296
|
+
const [before] = await selectMany(stack.db, lowStockAlertsTable, { productId: id });
|
|
297
|
+
expect(before).toBeDefined();
|
|
298
|
+
|
|
299
|
+
await stack.http.writeOk(
|
|
300
|
+
"inventory:write:product:archive",
|
|
301
|
+
{ id, reason: "discontinued" },
|
|
302
|
+
admin,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const after = await selectMany(stack.db, lowStockAlertsTable, { productId: id });
|
|
306
|
+
expect(after).toHaveLength(0);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("report:archive-low-stock-products — read.findMany + forEach combined", async () => {
|
|
310
|
+
// Seed three low-stock products.
|
|
311
|
+
const ids = await Promise.all(
|
|
312
|
+
["A", "B", "C"].map(async (sku) => {
|
|
313
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
314
|
+
"inventory:write:product:create",
|
|
315
|
+
{ sku: `SKU-LO-${sku}`, name: `Low-${sku}`, initialStock: 3 },
|
|
316
|
+
admin,
|
|
317
|
+
);
|
|
318
|
+
// Trigger the low-stock-alert insertion.
|
|
319
|
+
await stack.http.writeOk(
|
|
320
|
+
"inventory:write:product:adjust-stock",
|
|
321
|
+
{ id, delta: 0, reason: "seed" },
|
|
322
|
+
admin,
|
|
323
|
+
);
|
|
324
|
+
return id;
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
expect(ids).toHaveLength(3);
|
|
328
|
+
|
|
329
|
+
const before = await selectMany(stack.db, lowStockAlertsTable);
|
|
330
|
+
expect(before).toHaveLength(3);
|
|
331
|
+
|
|
332
|
+
const result = await stack.http.writeOk<{ archivedCount: number }>(
|
|
333
|
+
"inventory:write:report:archive-low-stock-products",
|
|
334
|
+
{ reason: "ops-purge" },
|
|
335
|
+
admin,
|
|
336
|
+
);
|
|
337
|
+
expect(result.archivedCount).toBe(3);
|
|
338
|
+
|
|
339
|
+
const after = await selectMany(stack.db, lowStockAlertsTable);
|
|
340
|
+
expect(after).toHaveLength(0);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("low-stock-alerts:list — query handler returns alert rows", async () => {
|
|
344
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
345
|
+
"inventory:write:product:create",
|
|
346
|
+
{ sku: "SKU-LIST", name: "Alerted", initialStock: 2 },
|
|
347
|
+
admin,
|
|
348
|
+
);
|
|
349
|
+
await stack.http.writeOk(
|
|
350
|
+
"inventory:write:product:adjust-stock",
|
|
351
|
+
{ id, delta: 0, reason: "seed" },
|
|
352
|
+
admin,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const result = await stack.http.queryOk<{ rows: Array<{ productId: string }> }>(
|
|
356
|
+
"inventory:query:low-stock-alerts:list",
|
|
357
|
+
{},
|
|
358
|
+
admin,
|
|
359
|
+
);
|
|
360
|
+
expect(result.rows.map((row) => row.productId)).toContain(id);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### src/feature.ts
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
// Pipeline Basics — Inventory-Management Showcase
|
|
369
|
+
//
|
|
370
|
+
// Production-pattern sample that exercises every Tier-1 step from
|
|
371
|
+
// the M.1 step-vocabulary in a single feature. The handlers below
|
|
372
|
+
// are the canonical reference for "what the pipeline form looks
|
|
373
|
+
// like in real code"; the M.3 codemod (later) will translate the
|
|
374
|
+
// free-form `custom-handlers` sample into roughly this shape.
|
|
375
|
+
//
|
|
376
|
+
// Steps covered:
|
|
377
|
+
// - r.step.aggregate.create / aggregate.update / aggregate.appendEvent
|
|
378
|
+
// - r.step.read.findOne / read.findMany
|
|
379
|
+
// - r.step.compute / branch / forEach / return
|
|
380
|
+
// - r.step.unsafeProjectionUpsert / unsafeProjectionDelete
|
|
381
|
+
//
|
|
382
|
+
// Domain: products with a current-stock counter. Stock-adjustments
|
|
383
|
+
// are tracked as domain events on the product stream (in addition
|
|
384
|
+
// to the auto-CRUD updated-event). A custom non-aggregate
|
|
385
|
+
// projection (`low_stock_alerts`) gets upserted/deleted inline
|
|
386
|
+
// whenever stock crosses a threshold — exactly the use-case the
|
|
387
|
+
// `unsafe`-prefix is designed for: framework-author opting into
|
|
388
|
+
// raw projection-writes after seeing the prefix at every call site.
|
|
389
|
+
|
|
390
|
+
import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
391
|
+
import {
|
|
392
|
+
buildEntityTable,
|
|
393
|
+
createEventStoreExecutor,
|
|
394
|
+
integer,
|
|
395
|
+
selectMany,
|
|
396
|
+
table,
|
|
397
|
+
text,
|
|
398
|
+
updateMany,
|
|
399
|
+
uuid,
|
|
400
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
401
|
+
import type { PipelineCtx } from "@cosmicdrift/kumiko-framework/engine";
|
|
402
|
+
import {
|
|
403
|
+
createEntity,
|
|
404
|
+
createNumberField,
|
|
405
|
+
createTextField,
|
|
406
|
+
defineFeature,
|
|
407
|
+
defineWriteHandler,
|
|
408
|
+
stepsPipeline,
|
|
409
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
410
|
+
import { z } from "zod";
|
|
411
|
+
|
|
412
|
+
const LOW_STOCK_THRESHOLD = 10;
|
|
413
|
+
|
|
414
|
+
export const productEntity = createEntity({
|
|
415
|
+
table: "read_inventory_products",
|
|
416
|
+
fields: {
|
|
417
|
+
sku: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
418
|
+
name: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
419
|
+
currentStock: createNumberField({ default: 0 }),
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Custom non-aggregate projection. The boot-validator allows direct
|
|
424
|
+
// upsert/delete only because `r.requires.projection(...)` is declared
|
|
425
|
+
// inside the feature below — without that line, boot fails fast.
|
|
426
|
+
export const lowStockAlertsTable: EntityTableMeta = table("read_inventory_low_stock_alerts", {
|
|
427
|
+
productId: uuid("product_id").primaryKey(),
|
|
428
|
+
sku: text("sku").notNull(),
|
|
429
|
+
currentStock: integer("current_stock").notNull(),
|
|
430
|
+
threshold: integer("threshold").notNull(),
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Module-level drizzle-table + executor — the steps below capture
|
|
434
|
+
// `productExecutor` from this scope, and the integration test
|
|
435
|
+
// imports `productTable` for raw selects.
|
|
436
|
+
export const productTable = buildEntityTable("product", productEntity);
|
|
437
|
+
const productExecutor = createEventStoreExecutor(productTable, productEntity, {
|
|
438
|
+
entityName: "product",
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
export const inventoryFeature = defineFeature("inventory", (r) => {
|
|
442
|
+
r.entity("product", productEntity);
|
|
443
|
+
r.requires.projection("read_inventory_low_stock_alerts");
|
|
444
|
+
|
|
445
|
+
// Domain-events on the product stream (alongside the auto-CRUD
|
|
446
|
+
// events). r.defineEvent registers them globally — the `type`
|
|
447
|
+
// field of aggregate.appendEvent is a plain string, but using
|
|
448
|
+
// `<eventDef>.name` here gives type-safe aliasing if the event
|
|
449
|
+
// is later renamed.
|
|
450
|
+
const stockAdjusted = r.defineEvent(
|
|
451
|
+
"product-stock-adjusted",
|
|
452
|
+
z.object({
|
|
453
|
+
delta: z.number().int(),
|
|
454
|
+
reason: z.string(),
|
|
455
|
+
newStock: z.number().int(),
|
|
456
|
+
}),
|
|
457
|
+
{ piiFields: "none" },
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
const archived = r.defineEvent("product-archived", z.object({ reason: z.string() }), {
|
|
461
|
+
piiFields: "none",
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Inline projection that maintains `currentStock` from the
|
|
465
|
+
// stock-adjusted domain event. Pure event-sourcing pattern: the
|
|
466
|
+
// adjust-stock handler does NOT call aggregate.update — it only
|
|
467
|
+
// appends the domain event, and this projection (running in the
|
|
468
|
+
// same TX) replays its effect onto the product row.
|
|
469
|
+
//
|
|
470
|
+
// Why pure-ES (rather than aggregate.update + appendEvent in one
|
|
471
|
+
// handler): combining the two inflates the stream-version mid-
|
|
472
|
+
// handler, which would trip optimistic-locking on a back-to-back
|
|
473
|
+
// adjust-stock call. The split-here is the canonical Marten/CQRS
|
|
474
|
+
// shape — domain events are the source of truth, projections are
|
|
475
|
+
// the queryable view.
|
|
476
|
+
r.projection({
|
|
477
|
+
name: "product-stock-counter",
|
|
478
|
+
source: "product",
|
|
479
|
+
table: productTable,
|
|
480
|
+
apply: {
|
|
481
|
+
[stockAdjusted.name]: async (event, tx, table) => {
|
|
482
|
+
const p = event.payload as { newStock: number };
|
|
483
|
+
await updateMany(tx, table, { currentStock: p.newStock }, { id: event.aggregateId });
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// -------------------------------------------------------------
|
|
489
|
+
// 1. inventory:product:create — straight aggregate.create.
|
|
490
|
+
// Simplest pipeline form: one mutating step + return.
|
|
491
|
+
// -------------------------------------------------------------
|
|
492
|
+
r.writeHandler(
|
|
493
|
+
defineWriteHandler({
|
|
494
|
+
name: "product:create",
|
|
495
|
+
schema: z.object({
|
|
496
|
+
sku: z.string().min(1),
|
|
497
|
+
name: z.string().min(1),
|
|
498
|
+
initialStock: z.number().int().min(0).default(0),
|
|
499
|
+
}),
|
|
500
|
+
access: { roles: ["Admin"] },
|
|
501
|
+
perform: stepsPipeline<{ sku: string; name: string; initialStock: number }, { id: string }>(
|
|
502
|
+
({ event, r }) => [
|
|
503
|
+
r.step.aggregate.create("product", {
|
|
504
|
+
executor: productExecutor,
|
|
505
|
+
data: () => ({
|
|
506
|
+
sku: event.payload.sku,
|
|
507
|
+
name: event.payload.name,
|
|
508
|
+
currentStock: event.payload.initialStock,
|
|
509
|
+
}),
|
|
510
|
+
}),
|
|
511
|
+
r.step.return(({ steps }) => ({
|
|
512
|
+
isSuccess: true as const,
|
|
513
|
+
data: { id: (steps["product"] as { id: string }).id },
|
|
514
|
+
})),
|
|
515
|
+
],
|
|
516
|
+
),
|
|
517
|
+
}),
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
// -------------------------------------------------------------
|
|
521
|
+
// 2. inventory:product:rename — read + branch (skip-if-noop).
|
|
522
|
+
// Demonstrates the read-then-conditional-write pattern.
|
|
523
|
+
// The aggregate.update inside `onTrue` only runs when the
|
|
524
|
+
// name actually changed — saves an event + a projection
|
|
525
|
+
// write per redundant rename.
|
|
526
|
+
// -------------------------------------------------------------
|
|
527
|
+
r.writeHandler(
|
|
528
|
+
defineWriteHandler({
|
|
529
|
+
name: "product:rename",
|
|
530
|
+
schema: z.object({ id: z.uuid(), name: z.string().min(1) }),
|
|
531
|
+
access: { roles: ["Admin"] },
|
|
532
|
+
perform: stepsPipeline<{ id: string; name: string }, { id: string; renamed: boolean }>(
|
|
533
|
+
({ event, r }) => [
|
|
534
|
+
r.step.read.findOne("current", {
|
|
535
|
+
table: productTable,
|
|
536
|
+
where: () => ({ id: event.payload.id }),
|
|
537
|
+
}),
|
|
538
|
+
r.step.branch({
|
|
539
|
+
if: ({ steps }) => {
|
|
540
|
+
const cur = steps["current"] as { name: string } | null;
|
|
541
|
+
return cur !== null && cur.name !== event.payload.name;
|
|
542
|
+
},
|
|
543
|
+
onTrue: [
|
|
544
|
+
r.step.aggregate.update("product", {
|
|
545
|
+
executor: productExecutor,
|
|
546
|
+
id: () => event.payload.id,
|
|
547
|
+
version: ({ steps }) => (steps["current"] as { version?: number } | null)?.version,
|
|
548
|
+
changes: () => ({ name: event.payload.name }),
|
|
549
|
+
}),
|
|
550
|
+
],
|
|
551
|
+
}),
|
|
552
|
+
r.step.return(({ steps }) => ({
|
|
553
|
+
isSuccess: true as const,
|
|
554
|
+
data: {
|
|
555
|
+
id: event.payload.id,
|
|
556
|
+
// Truthy iff the aggregate.update step actually ran —
|
|
557
|
+
// the executor lands its SaveContext under steps.product.
|
|
558
|
+
renamed: steps["product"] !== undefined,
|
|
559
|
+
},
|
|
560
|
+
})),
|
|
561
|
+
],
|
|
562
|
+
),
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
// -------------------------------------------------------------
|
|
567
|
+
// 3. inventory:product:adjust-stock — every Tier-1 step in one
|
|
568
|
+
// handler. Production-realistic: read aggregate, compute new
|
|
569
|
+
// stock, persist update, append a domain-event for the
|
|
570
|
+
// audit-trail, then upsert OR delete the low-stock-alert
|
|
571
|
+
// based on whether the new stock crosses the threshold.
|
|
572
|
+
// -------------------------------------------------------------
|
|
573
|
+
r.writeHandler(
|
|
574
|
+
defineWriteHandler({
|
|
575
|
+
name: "product:adjust-stock",
|
|
576
|
+
schema: z.object({
|
|
577
|
+
id: z.uuid(),
|
|
578
|
+
delta: z.number().int(),
|
|
579
|
+
reason: z.string().min(1),
|
|
580
|
+
}),
|
|
581
|
+
access: { roles: ["Admin", "User"] },
|
|
582
|
+
perform: stepsPipeline<
|
|
583
|
+
{ id: string; delta: number; reason: string },
|
|
584
|
+
{ id: string; newStock: number }
|
|
585
|
+
>(({ event, r }) => [
|
|
586
|
+
r.step.read.findOne("current", {
|
|
587
|
+
table: productTable,
|
|
588
|
+
where: () => ({ id: event.payload.id }),
|
|
589
|
+
}),
|
|
590
|
+
r.step.compute("newStock", ({ steps }) => {
|
|
591
|
+
const cur = steps["current"] as { currentStock: number } | null;
|
|
592
|
+
if (!cur) {
|
|
593
|
+
throw new Error(`product not found: ${event.payload.id}`);
|
|
594
|
+
}
|
|
595
|
+
return cur.currentStock + event.payload.delta;
|
|
596
|
+
}),
|
|
597
|
+
// Pure-ES: emit the domain event; the inline `product-stock-counter`
|
|
598
|
+
// projection above replays it onto the product row in the same TX.
|
|
599
|
+
// No aggregate.update here — see the projection's comment for why
|
|
600
|
+
// the split is intentional.
|
|
601
|
+
r.step.aggregate.appendEvent({
|
|
602
|
+
aggregateId: () => event.payload.id,
|
|
603
|
+
aggregateType: "product",
|
|
604
|
+
type: stockAdjusted.name,
|
|
605
|
+
// Explicit ctx-type: payload's resolver is StepResolver<unknown>,
|
|
606
|
+
// which prevents TS from inferring `ctx.steps` shape via the
|
|
607
|
+
// destructure. Followup #4 (TData-Inference) tracks the DX-fix.
|
|
608
|
+
payload: ({ steps }: PipelineCtx) => ({
|
|
609
|
+
delta: event.payload.delta,
|
|
610
|
+
reason: event.payload.reason,
|
|
611
|
+
newStock: steps["newStock"] as number,
|
|
612
|
+
}),
|
|
613
|
+
}),
|
|
614
|
+
// Inline projection-maintenance: cross the threshold in
|
|
615
|
+
// either direction in the same TX as the aggregate write.
|
|
616
|
+
// Async multi-stream projections would lag here — this is
|
|
617
|
+
// the use-case for inline unsafeProjection.*.
|
|
618
|
+
r.step.branch({
|
|
619
|
+
if: ({ steps }) => (steps["newStock"] as number) < LOW_STOCK_THRESHOLD,
|
|
620
|
+
onTrue: [
|
|
621
|
+
r.step.unsafeProjectionUpsert({
|
|
622
|
+
table: lowStockAlertsTable,
|
|
623
|
+
on: ["productId"],
|
|
624
|
+
row: ({ steps }) => {
|
|
625
|
+
const cur = steps["current"] as { sku: string };
|
|
626
|
+
return {
|
|
627
|
+
productId: event.payload.id,
|
|
628
|
+
sku: cur.sku,
|
|
629
|
+
currentStock: steps["newStock"] as number,
|
|
630
|
+
threshold: LOW_STOCK_THRESHOLD,
|
|
631
|
+
};
|
|
632
|
+
},
|
|
633
|
+
}),
|
|
634
|
+
],
|
|
635
|
+
onFalse: [
|
|
636
|
+
r.step.unsafeProjectionDelete({
|
|
637
|
+
table: lowStockAlertsTable,
|
|
638
|
+
where: () => ({ productId: event.payload.id }),
|
|
639
|
+
}),
|
|
640
|
+
],
|
|
641
|
+
}),
|
|
642
|
+
r.step.return(({ steps }) => ({
|
|
643
|
+
isSuccess: true as const,
|
|
644
|
+
data: {
|
|
645
|
+
id: event.payload.id,
|
|
646
|
+
newStock: steps["newStock"] as number,
|
|
647
|
+
},
|
|
648
|
+
})),
|
|
649
|
+
]),
|
|
650
|
+
}),
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
// -------------------------------------------------------------
|
|
654
|
+
// 4. inventory:product:bulk-adjust — forEach over a list of
|
|
655
|
+
// adjustments. Each iteration is its own read+update mini-
|
|
656
|
+
// pipeline; `scope.adj` is the per-iteration item.
|
|
657
|
+
// Sequential by design (concurrency=1 in M.1.6).
|
|
658
|
+
// -------------------------------------------------------------
|
|
659
|
+
r.writeHandler(
|
|
660
|
+
defineWriteHandler({
|
|
661
|
+
name: "product:bulk-adjust",
|
|
662
|
+
schema: z.object({
|
|
663
|
+
adjustments: z.array(z.object({ id: z.uuid(), delta: z.number().int() })).min(1),
|
|
664
|
+
}),
|
|
665
|
+
access: { roles: ["Admin"] },
|
|
666
|
+
perform: stepsPipeline<
|
|
667
|
+
{ adjustments: ReadonlyArray<{ id: string; delta: number }> },
|
|
668
|
+
{ processed: number }
|
|
669
|
+
>(({ event, r }) => [
|
|
670
|
+
r.step.forEach({
|
|
671
|
+
over: () => event.payload.adjustments,
|
|
672
|
+
as: "adj",
|
|
673
|
+
do: [
|
|
674
|
+
r.step.read.findOne("current", {
|
|
675
|
+
table: productTable,
|
|
676
|
+
where: ({ scope }) => ({ id: (scope["adj"] as { id: string }).id }),
|
|
677
|
+
}),
|
|
678
|
+
r.step.compute("newStock", ({ steps, scope }) => {
|
|
679
|
+
const cur = steps["current"] as { currentStock: number } | null;
|
|
680
|
+
const adj = scope["adj"] as { delta: number };
|
|
681
|
+
if (!cur) throw new Error("product not found in bulk-adjust");
|
|
682
|
+
return cur.currentStock + adj.delta;
|
|
683
|
+
}),
|
|
684
|
+
r.step.aggregate.update("product", {
|
|
685
|
+
executor: productExecutor,
|
|
686
|
+
id: ({ scope }) => (scope["adj"] as { id: string }).id,
|
|
687
|
+
version: ({ steps }) => (steps["current"] as { version?: number } | null)?.version,
|
|
688
|
+
changes: ({ steps }) => ({ currentStock: steps["newStock"] as number }),
|
|
689
|
+
// Pure update path inside forEach — no appendEvent
|
|
690
|
+
// alongside, so optimistic-locking stays meaningful.
|
|
691
|
+
}),
|
|
692
|
+
],
|
|
693
|
+
}),
|
|
694
|
+
r.step.return(() => ({
|
|
695
|
+
isSuccess: true as const,
|
|
696
|
+
data: { processed: event.payload.adjustments.length },
|
|
697
|
+
})),
|
|
698
|
+
]),
|
|
699
|
+
}),
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
// -------------------------------------------------------------
|
|
703
|
+
// 5. inventory:product:archive — domain-event + projection
|
|
704
|
+
// cleanup. The aggregate is preserved (event-sourced!), the
|
|
705
|
+
// side-projection row gets purged in the same TX. Reads
|
|
706
|
+
// against archived products still work via loadAggregate;
|
|
707
|
+
// list-views skip them by filtering on the archived event.
|
|
708
|
+
// -------------------------------------------------------------
|
|
709
|
+
r.writeHandler(
|
|
710
|
+
defineWriteHandler({
|
|
711
|
+
name: "product:archive",
|
|
712
|
+
schema: z.object({ id: z.uuid(), reason: z.string().min(1) }),
|
|
713
|
+
access: { roles: ["Admin"] },
|
|
714
|
+
perform: stepsPipeline<{ id: string; reason: string }, { id: string }>(({ event, r }) => [
|
|
715
|
+
r.step.aggregate.appendEvent({
|
|
716
|
+
aggregateId: () => event.payload.id,
|
|
717
|
+
aggregateType: "product",
|
|
718
|
+
type: archived.name,
|
|
719
|
+
payload: () => ({ reason: event.payload.reason }),
|
|
720
|
+
}),
|
|
721
|
+
r.step.unsafeProjectionDelete({
|
|
722
|
+
table: lowStockAlertsTable,
|
|
723
|
+
where: () => ({ productId: event.payload.id }),
|
|
724
|
+
}),
|
|
725
|
+
r.step.return(() => ({
|
|
726
|
+
isSuccess: true as const,
|
|
727
|
+
data: { id: event.payload.id },
|
|
728
|
+
})),
|
|
729
|
+
]),
|
|
730
|
+
}),
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
// -------------------------------------------------------------
|
|
734
|
+
// 6. inventory:report:bulk-archive-stale — forEach + read.findMany.
|
|
735
|
+
// Demonstrates the read-list-then-loop pattern: pull a slice
|
|
736
|
+
// of low-stock alerts via read.findMany, archive each. Used
|
|
737
|
+
// by the integration test to prove findMany lands rows under
|
|
738
|
+
// steps.<name> as an array.
|
|
739
|
+
// -------------------------------------------------------------
|
|
740
|
+
r.writeHandler(
|
|
741
|
+
defineWriteHandler({
|
|
742
|
+
name: "report:archive-low-stock-products",
|
|
743
|
+
schema: z.object({ reason: z.string().min(1) }),
|
|
744
|
+
access: { roles: ["Admin"] },
|
|
745
|
+
perform: stepsPipeline<{ reason: string }, { archivedCount: number }>(({ event, r }) => [
|
|
746
|
+
r.step.read.findMany("alerts", {
|
|
747
|
+
table: lowStockAlertsTable,
|
|
748
|
+
}),
|
|
749
|
+
r.step.forEach<{ productId: string }>({
|
|
750
|
+
over: ({ steps }) => (steps["alerts"] as ReadonlyArray<{ productId: string }>) ?? [],
|
|
751
|
+
as: "alert",
|
|
752
|
+
do: [
|
|
753
|
+
r.step.aggregate.appendEvent({
|
|
754
|
+
aggregateId: ({ scope }) => (scope["alert"] as { productId: string }).productId,
|
|
755
|
+
aggregateType: "product",
|
|
756
|
+
type: archived.name,
|
|
757
|
+
payload: () => ({ reason: event.payload.reason }),
|
|
758
|
+
}),
|
|
759
|
+
r.step.unsafeProjectionDelete({
|
|
760
|
+
table: lowStockAlertsTable,
|
|
761
|
+
where: ({ scope }) => ({
|
|
762
|
+
productId: (scope["alert"] as { productId: string }).productId,
|
|
763
|
+
}),
|
|
764
|
+
}),
|
|
765
|
+
],
|
|
766
|
+
}),
|
|
767
|
+
r.step.return(({ steps }) => ({
|
|
768
|
+
isSuccess: true as const,
|
|
769
|
+
data: {
|
|
770
|
+
archivedCount: (steps["alerts"] as ReadonlyArray<unknown>).length,
|
|
771
|
+
},
|
|
772
|
+
})),
|
|
773
|
+
]),
|
|
774
|
+
}),
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
// -------------------------------------------------------------
|
|
778
|
+
// Query handlers — free-form. M.1's pipeline-engine is write-
|
|
779
|
+
// only; queries get the existing handler signature. The list
|
|
780
|
+
// below is what the integration test reads against.
|
|
781
|
+
// -------------------------------------------------------------
|
|
782
|
+
r.queryHandler(
|
|
783
|
+
"low-stock-alerts:list",
|
|
784
|
+
z.object({}),
|
|
785
|
+
async (_query, ctx) => {
|
|
786
|
+
const rows = await selectMany(ctx.db, lowStockAlertsTable);
|
|
787
|
+
return { rows };
|
|
788
|
+
},
|
|
789
|
+
{ access: { roles: ["Admin"] } },
|
|
790
|
+
);
|
|
791
|
+
});
|
|
792
|
+
```
|