@cosmicdrift/kumiko-framework 0.362.0 → 0.364.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -0,0 +1,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
+ ```