@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,1287 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/event-sourcing. Do not edit. -->
2
+
3
+ # event-sourcing
4
+
5
+ Production-pattern ES sample: r.defineEvent (versioned + migrations) + ctx.appendEvent + r.projection + r.multiStreamProjection + ctx.loadAggregate (asOf) + ctx.archiveStream + ctx.queryProjection.
6
+
7
+ ## README
8
+
9
+ # Event Sourcing Showcase
10
+
11
+ Production-pattern sample showing every Sprint-E Marten-gold-standard
12
+ API in one feature. Serves as a reference for all the ES building
13
+ blocks Kumiko provides.
14
+
15
+ ## What the sample shows
16
+
17
+ | API | Purpose |
18
+ |---|---|
19
+ | `r.defineEvent(name, schema, { version, migrations })` | Event schema with version number + upcaster chain |
20
+ | `ctx.appendEvent({ aggregateId, aggregateType, type, payload })` | Domain event on aggregate stream |
21
+ | `r.projection(...)` | Single-stream read model, inline in the write TX |
22
+ | `r.multiStreamProjection(...)` | Cross-aggregate read model, async via dispatcher |
23
+ | `ctx.loadAggregate(id, { asOf })` | Live aggregation with point-in-time |
24
+ | `ctx.archiveStream(id, { aggregateType })` | Archive a stream (Marten `ArchiveStream`) |
25
+ | `ctx.queryProjection(name)` | Tenant-scoped read-model query |
26
+
27
+ ## Domain
28
+
29
+ Invoices flow through `draft` → `approved` → `paid` → (optional)
30
+ `archived`. Two read models:
31
+
32
+ - **`invoice-detail`** (single-stream, inline): one row per invoice,
33
+ reacts to `created`, `approved`, `paid`.
34
+ - **`customer-revenue`** (multi-stream, async): one row per customer,
35
+ sums up paid invoices.
36
+
37
+ The `approved` event is at schema version 2 — v1 stored `amount` as a
38
+ string, v2 uses `amountCents` as an integer. The migration shows how
39
+ old events are transparently upcast on read.
40
+
41
+ ## Flow
42
+
43
+ ```
44
+ HTTP POST /api/write { type: "showcase:write:invoice:create", ... }
45
+ ↓
46
+ writeHandler("invoice:create")
47
+ ↓ TX begin
48
+ invoiceExecutor.create(...) ← auto event: showcaseInvoice.created
49
+ r.projection("invoice-detail") ← inline, writes detail row
50
+ ↓ TX commit
51
+
52
+ HTTP POST /api/write { type: "showcase:write:invoice:approve", ... }
53
+ ↓
54
+ writeHandler("invoice:approve")
55
+ ↓ TX begin
56
+ ctx.appendEvent({ type: "showcase:event:invoice-approved", ... })
57
+ r.projection("invoice-detail") ← updates status = "approved"
58
+ ↓ TX commit
59
+ (async)
60
+ eventDispatcher.runOnce()
61
+ ↓
62
+ r.multiStreamProjection("customer-revenue") ← skips (only on paid)
63
+ ```
64
+
65
+ ## Test
66
+
67
+ ```bash
68
+ bun kumiko test integration samples/recipes/event-sourcing
69
+ ```
70
+
71
+ Six integration tests pin everything at once:
72
+
73
+ 1. Walk-through `create → approve → pay` with the inline projection
74
+ 2. Async MSP accumulates across multiple paid invoices
75
+ 3. `asOf` returns the state before payment
76
+ 4. v1 event on disk reaches the reducer as v2 (upcaster)
77
+ 5. `archiveStream` hides events from `loadAggregate`; ops bypass via `{ includeArchived }`
78
+ 6. `queryProjection` automatically filters by `tenant_id`
79
+
80
+ ## Source files
81
+
82
+ ### src/__tests__/feature.integration.test.ts
83
+
84
+ ```ts
85
+ // Event Sourcing Showcase — Integration Test
86
+ //
87
+ // Exercises every Sprint-E Marten gold-standard API end-to-end:
88
+ // - r.defineEvent with version + migrations (sync + async upcaster)
89
+ // - ctx.appendEvent onto the aggregate stream (incl. headers metadata)
90
+ // - r.projection (single-stream, inline)
91
+ // - r.multiStreamProjection (async)
92
+ // - ctx.loadAggregate with { asOf }
93
+ // - ctx.archiveStream
94
+ // - ctx.queryProjection
95
+ // - streamAllEventsByType (memory-bounded ops iteration)
96
+ // - getAllProjectionProgress (projection lag for ops dashboards)
97
+
98
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
99
+ import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
100
+ import type { DbTx } from "@cosmicdrift/kumiko-framework/db";
101
+ import {
102
+ append,
103
+ loadAggregate as loadAggregateRaw,
104
+ makeUpcastCtx,
105
+ streamAllEventsByType,
106
+ upcastStoredEvent,
107
+ } from "@cosmicdrift/kumiko-framework/event-store";
108
+ import { getAllProjectionProgress } from "@cosmicdrift/kumiko-framework/pipeline";
109
+ import {
110
+ createTestUser,
111
+ resetEventStore,
112
+ setupTestStack,
113
+ type TestStack,
114
+ unsafeCreateEntityTable,
115
+ unsafePushTables,
116
+ } from "@cosmicdrift/kumiko-framework/stack";
117
+ import {
118
+ approverDirectoryTable,
119
+ invoiceDetailTable,
120
+ invoiceEntity,
121
+ invoiceFeature,
122
+ } from "../feature";
123
+
124
+ let stack: TestStack;
125
+ const admin = createTestUser({ roles: ["Admin"] });
126
+
127
+ beforeAll(async () => {
128
+ stack = await setupTestStack({ features: [invoiceFeature], systemHooks: [] });
129
+ await unsafeCreateEntityTable(stack.db, invoiceEntity, "showcase-invoice");
130
+ await unsafePushTables(stack.db, { read_showcase_approver_directory: approverDirectoryTable });
131
+ });
132
+
133
+ afterAll(async () => {
134
+ await stack.cleanup();
135
+ });
136
+
137
+ beforeEach(async () => {
138
+ await resetEventStore(stack, [
139
+ "read_showcase_invoices",
140
+ "read_showcase_invoice_detail",
141
+ "read_showcase_customer_revenue",
142
+ "read_showcase_approver_directory",
143
+ ]);
144
+ // Reset Redis namespace too — invoice:pay declares a per-user
145
+ // rate limit, so leftover bucket state from a previous test would
146
+ // throttle the next test's pay-calls.
147
+ await stack.redis.flushNamespace();
148
+ await stack.eventDispatcher?.ensureRegistered();
149
+ });
150
+
151
+ describe("Event Sourcing Showcase", () => {
152
+ test("create → approve → pay: inline projection reflects each step", async () => {
153
+ const { id } = await stack.http.writeOk<{ id: string }>(
154
+ "showcase:write:invoice:create",
155
+ { customer: "Acme" },
156
+ admin,
157
+ );
158
+ await stack.http.writeOk(
159
+ "showcase:write:invoice:approve",
160
+ { id, amountCents: 2500, approvedBy: "cfo" },
161
+ admin,
162
+ );
163
+ await stack.http.writeOk("showcase:write:invoice:pay", { id, amountCents: 2500 }, admin);
164
+
165
+ const [row] = await selectMany(stack.db, invoiceDetailTable, { invoiceId: id });
166
+ expect(row).toMatchObject({
167
+ customer: "Acme",
168
+ status: "paid",
169
+ amountCents: 2500,
170
+ });
171
+ });
172
+
173
+ test("async MSP: customer-revenue accumulates across paid invoices", async () => {
174
+ const inv1 = await stack.http.writeOk<{ id: string }>(
175
+ "showcase:write:invoice:create",
176
+ { customer: "Acme" },
177
+ admin,
178
+ );
179
+ const inv2 = await stack.http.writeOk<{ id: string }>(
180
+ "showcase:write:invoice:create",
181
+ { customer: "Acme" },
182
+ admin,
183
+ );
184
+ const inv3 = await stack.http.writeOk<{ id: string }>(
185
+ "showcase:write:invoice:create",
186
+ { customer: "Globex" },
187
+ admin,
188
+ );
189
+ await stack.http.writeOk(
190
+ "showcase:write:invoice:approve",
191
+ { id: inv1.id, amountCents: 1000, approvedBy: "a" },
192
+ admin,
193
+ );
194
+ await stack.http.writeOk(
195
+ "showcase:write:invoice:pay",
196
+ { id: inv1.id, amountCents: 1000 },
197
+ admin,
198
+ );
199
+ await stack.http.writeOk(
200
+ "showcase:write:invoice:approve",
201
+ { id: inv2.id, amountCents: 500, approvedBy: "a" },
202
+ admin,
203
+ );
204
+ await stack.http.writeOk(
205
+ "showcase:write:invoice:pay",
206
+ { id: inv2.id, amountCents: 500 },
207
+ admin,
208
+ );
209
+ await stack.http.writeOk(
210
+ "showcase:write:invoice:approve",
211
+ { id: inv3.id, amountCents: 300, approvedBy: "a" },
212
+ admin,
213
+ );
214
+ await stack.http.writeOk(
215
+ "showcase:write:invoice:pay",
216
+ { id: inv3.id, amountCents: 300 },
217
+ admin,
218
+ );
219
+
220
+ await stack.eventDispatcher?.runOnce();
221
+
222
+ const revenue = await stack.http.queryOk<
223
+ Array<{ customer: string; paidInvoices: number; totalCents: number }>
224
+ >("showcase:query:revenue:list", {}, admin);
225
+
226
+ const byCustomer = new Map(revenue.map((r) => [r.customer, r]));
227
+ expect(byCustomer.get("Acme")).toMatchObject({ paidInvoices: 2, totalCents: 1500 });
228
+ expect(byCustomer.get("Globex")).toMatchObject({ paidInvoices: 1, totalCents: 300 });
229
+ });
230
+
231
+ test("asOf point-in-time read: invoice state before the pay event", async () => {
232
+ const { id } = await stack.http.writeOk<{ id: string }>(
233
+ "showcase:write:invoice:create",
234
+ { customer: "TimeTraveler" },
235
+ admin,
236
+ );
237
+ await stack.http.writeOk(
238
+ "showcase:write:invoice:approve",
239
+ { id, amountCents: 900, approvedBy: "cfo" },
240
+ admin,
241
+ );
242
+ // Cutoff from DB event timestamp — host Date() drifts vs Postgres now()
243
+ // under Docker and excluded the approve event (fw#2425).
244
+ const beforePay = await loadAggregateRaw(stack.db, id, admin.tenantId);
245
+ expect(beforePay).toHaveLength(2);
246
+ const cutoff = beforePay.at(-1)!.createdAt;
247
+ await new Promise((r) => setTimeout(r, 10));
248
+ await stack.http.writeOk("showcase:write:invoice:pay", { id, amountCents: 900 }, admin);
249
+
250
+ const now = await stack.http.queryOk<{ status: string; paid: boolean }>(
251
+ "showcase:query:invoice:state",
252
+ { id },
253
+ admin,
254
+ );
255
+ expect(now.status).toBe("paid");
256
+ expect(now.paid).toBe(true);
257
+
258
+ const past = await stack.http.queryOk<{ status: string; paid: boolean }>(
259
+ "showcase:query:invoice:state",
260
+ { id, asOf: cutoff.toString() },
261
+ admin,
262
+ );
263
+ expect(past.status).toBe("approved");
264
+ expect(past.paid).toBe(false);
265
+ });
266
+
267
+ test("upcaster: v1 approved event on disk reaches the reducer as v2", async () => {
268
+ // Simulate a pre-migration event shape by appending directly at
269
+ // eventVersion=1. This is what stored v1 approval events look like
270
+ // on disk today — the upcaster walks them to v2 on read.
271
+ const invoiceId = "00000000-0000-4000-8000-00000000f001";
272
+ await stack.db.begin(async (tx: DbTx) => {
273
+ await insertOne(tx, invoiceDetailTable, {
274
+ invoiceId,
275
+ tenantId: admin.tenantId,
276
+ customer: "Legacy Co",
277
+ status: "draft",
278
+ amountCents: 0,
279
+ });
280
+ await append(tx, {
281
+ aggregateId: invoiceId,
282
+ aggregateType: "showcase-invoice",
283
+ tenantId: admin.tenantId,
284
+ expectedVersion: 0,
285
+ type: "showcase-invoice.created",
286
+ payload: { id: invoiceId, customer: "Legacy Co", status: "draft" },
287
+ metadata: { userId: admin.id },
288
+ });
289
+ await append(tx, {
290
+ aggregateId: invoiceId,
291
+ aggregateType: "showcase-invoice",
292
+ tenantId: admin.tenantId,
293
+ expectedVersion: 1,
294
+ type: "showcase:event:invoice-approved",
295
+ eventVersion: 1,
296
+ payload: { amount: "15.50", approvedBy: "legacy-cfo" },
297
+ metadata: { userId: admin.id },
298
+ });
299
+ });
300
+
301
+ const state = await stack.http.queryOk<{
302
+ status: string;
303
+ amountCents: number;
304
+ approvedBy: string | null;
305
+ }>("showcase:query:invoice:state", { id: invoiceId }, admin);
306
+
307
+ // v1 payload { amount: "15.50" } upcasts to v2 { amountCents: 1550 }
308
+ expect(state.status).toBe("approved");
309
+ expect(state.amountCents).toBe(1550);
310
+ expect(state.approvedBy).toBe("legacy-cfo");
311
+
312
+ // On-disk row is untouched — upcaster is read-time only.
313
+ const raw = await loadAggregateRaw(stack.db, invoiceId, admin.tenantId);
314
+ const rawApproved = raw.find((e) => e.type === "showcase:event:invoice-approved");
315
+ expect(rawApproved?.eventVersion).toBe(1);
316
+ expect(rawApproved?.payload).toEqual({ amount: "15.50", approvedBy: "legacy-cfo" });
317
+ });
318
+
319
+ test("archiveStream: loadAggregate returns empty after archive, ops bypass surfaces events", async () => {
320
+ const { id } = await stack.http.writeOk<{ id: string }>(
321
+ "showcase:write:invoice:create",
322
+ { customer: "ArchivePal" },
323
+ admin,
324
+ );
325
+ await stack.http.writeOk("showcase:write:invoice:archive", { id }, admin);
326
+
327
+ const state = await stack.http.queryOk<{ status: string }>(
328
+ "showcase:query:invoice:state",
329
+ { id },
330
+ admin,
331
+ );
332
+ expect(state.status).toBe("missing"); // loadAggregate returned [] → reducer starts clean
333
+
334
+ const raw = await loadAggregateRaw(stack.db, id, admin.tenantId, {
335
+ includeArchived: true,
336
+ });
337
+ expect(raw.length).toBeGreaterThan(0); // events are still on disk for ops
338
+ });
339
+
340
+ test("snapshot: fast-state uses the snapshot + upcaster-wrapped delta replay", async () => {
341
+ // Seed a full invoice lifecycle so there are three events on the
342
+ // stream (created, approved, paid).
343
+ const { id } = await stack.http.writeOk<{ id: string }>(
344
+ "showcase:write:invoice:create",
345
+ { customer: "Snapco" },
346
+ admin,
347
+ );
348
+ await stack.http.writeOk(
349
+ "showcase:write:invoice:approve",
350
+ { id, amountCents: 3300, approvedBy: "cfo" },
351
+ admin,
352
+ );
353
+ await stack.http.writeOk("showcase:write:invoice:pay", { id, amountCents: 3300 }, admin);
354
+
355
+ // Without a snapshot: fast-state still returns the correct state
356
+ // (falls back to full replay, snapshotHit=false).
357
+ const beforeSnapshot = await stack.http.queryOk<{
358
+ state: { status: string; amountCents: number };
359
+ version: number;
360
+ snapshotHit: boolean;
361
+ }>("showcase:query:invoice:fast-state", { id }, admin);
362
+ expect(beforeSnapshot.snapshotHit).toBe(false);
363
+ expect(beforeSnapshot.state.status).toBe("paid");
364
+ expect(beforeSnapshot.state.amountCents).toBe(3300);
365
+
366
+ // Take a snapshot after the pay event. ctx.snapshotAggregate writes
367
+ // kumiko_snapshots with the committed state + version.
368
+ const snapResult = await stack.http.writeOk<{ id: string; snapshotVersion: number }>(
369
+ "showcase:write:invoice:take-snapshot",
370
+ { id },
371
+ admin,
372
+ );
373
+ expect(snapResult.snapshotVersion).toBe(3);
374
+
375
+ // After the snapshot: fast-state sees the cache (snapshotHit=true) and
376
+ // no delta events beyond it. Same final state.
377
+ const afterSnapshot = await stack.http.queryOk<{
378
+ state: { status: string; amountCents: number };
379
+ version: number;
380
+ snapshotHit: boolean;
381
+ }>("showcase:query:invoice:fast-state", { id }, admin);
382
+ expect(afterSnapshot.snapshotHit).toBe(true);
383
+ expect(afterSnapshot.state.status).toBe("paid");
384
+ expect(afterSnapshot.state.amountCents).toBe(3300);
385
+ });
386
+
387
+ test("snapshot: upcaster runs on delta events past the snapshot", async () => {
388
+ // Seed only v1-shaped events directly to prove upcasting walks delta.
389
+ const invoiceId = "00000000-0000-4000-8000-00000000f002";
390
+ await stack.db.begin(async (tx: DbTx) => {
391
+ await insertOne(tx, invoiceDetailTable, {
392
+ invoiceId,
393
+ tenantId: admin.tenantId,
394
+ customer: "Upcast Co",
395
+ status: "draft",
396
+ amountCents: 0,
397
+ });
398
+ await append(tx, {
399
+ aggregateId: invoiceId,
400
+ aggregateType: "showcase-invoice",
401
+ tenantId: admin.tenantId,
402
+ expectedVersion: 0,
403
+ type: "showcase-invoice.created",
404
+ payload: { id: invoiceId, customer: "Upcast Co", status: "draft" },
405
+ metadata: { userId: admin.id },
406
+ });
407
+ });
408
+
409
+ // Snapshot the "just-created" state at v1 — before any v1-shaped
410
+ // approval event lands on the stream.
411
+ await stack.http.writeOk("showcase:write:invoice:take-snapshot", { id: invoiceId }, admin);
412
+
413
+ // Now append a v1-shape approval event (raw) — this becomes a delta
414
+ // past the snapshot. fast-state must upcast it before the reducer.
415
+ await stack.db.begin(async (tx: DbTx) => {
416
+ await append(tx, {
417
+ aggregateId: invoiceId,
418
+ aggregateType: "showcase-invoice",
419
+ tenantId: admin.tenantId,
420
+ expectedVersion: 1,
421
+ type: "showcase:event:invoice-approved",
422
+ eventVersion: 1,
423
+ payload: { amount: "27.50", approvedBy: "legacy-cfo" },
424
+ metadata: { userId: admin.id },
425
+ });
426
+ });
427
+
428
+ const state = await stack.http.queryOk<{
429
+ state: { status: string; amountCents: number; approvedBy: string | null };
430
+ snapshotHit: boolean;
431
+ }>("showcase:query:invoice:fast-state", { id: invoiceId }, admin);
432
+
433
+ expect(state.snapshotHit).toBe(true);
434
+ expect(state.state.status).toBe("approved");
435
+ // v1 { amount: "27.50" } upcasted → v2 { amountCents: 2750 }
436
+ expect(state.state.amountCents).toBe(2750);
437
+ expect(state.state.approvedBy).toBe("legacy-cfo");
438
+ });
439
+
440
+ test("tenant isolation: queryProjection auto-filters by tenant_id", async () => {
441
+ const otherAdmin = createTestUser({
442
+ id: 99,
443
+ roles: ["Admin"],
444
+ tenantId: "00000000-0000-4000-8000-000000000042",
445
+ });
446
+
447
+ const mine = await stack.http.writeOk<{ id: string }>(
448
+ "showcase:write:invoice:create",
449
+ { customer: "Mine" },
450
+ admin,
451
+ );
452
+ const theirs = await stack.http.writeOk<{ id: string }>(
453
+ "showcase:write:invoice:create",
454
+ { customer: "Theirs" },
455
+ otherAdmin,
456
+ );
457
+ await stack.http.writeOk(
458
+ "showcase:write:invoice:approve",
459
+ { id: mine.id, amountCents: 100, approvedBy: "a" },
460
+ admin,
461
+ );
462
+ await stack.http.writeOk(
463
+ "showcase:write:invoice:pay",
464
+ { id: mine.id, amountCents: 100 },
465
+ admin,
466
+ );
467
+ await stack.http.writeOk(
468
+ "showcase:write:invoice:approve",
469
+ { id: theirs.id, amountCents: 200, approvedBy: "b" },
470
+ otherAdmin,
471
+ );
472
+ await stack.http.writeOk(
473
+ "showcase:write:invoice:pay",
474
+ { id: theirs.id, amountCents: 200 },
475
+ otherAdmin,
476
+ );
477
+ await stack.eventDispatcher?.runOnce();
478
+
479
+ const adminView = await stack.http.queryOk<Array<{ customer: string; totalCents: number }>>(
480
+ "showcase:query:revenue:list",
481
+ {},
482
+ admin,
483
+ );
484
+ expect(adminView.map((r) => r.customer)).toEqual(["Mine"]);
485
+ expect(adminView[0]?.totalCents).toBe(100);
486
+
487
+ const otherView = await stack.http.queryOk<Array<{ customer: string; totalCents: number }>>(
488
+ "showcase:query:revenue:list",
489
+ {},
490
+ otherAdmin,
491
+ );
492
+ expect(otherView.map((r) => r.customer)).toEqual(["Theirs"]);
493
+ expect(otherView[0]?.totalCents).toBe(200);
494
+ });
495
+
496
+ test("ctx.appendEvent with headers: Marten free key/value metadata lands in stored event", async () => {
497
+ const { id } = await stack.http.writeOk<{ id: string }>(
498
+ "showcase:write:invoice:create",
499
+ { customer: "MetaCo" },
500
+ admin,
501
+ );
502
+ await stack.http.writeOk(
503
+ "showcase:write:invoice:approve",
504
+ { id, amountCents: 700, approvedBy: "cfo", geoRegion: "EU", abTestBucket: "B" },
505
+ admin,
506
+ );
507
+
508
+ const events = await loadAggregateRaw(stack.db, id, admin.tenantId);
509
+ const approved = events.find((e) => e.type === "showcase:event:invoice-approved");
510
+ expect(approved?.metadata.headers).toEqual({ geoRegion: "EU", abTestBucket: "B" });
511
+
512
+ // Headers stay opt-in: omitting them keeps the field absent (no empty
513
+ // object surface). Verifies we don't pollute every event with a {}.
514
+ const { id: bareId } = await stack.http.writeOk<{ id: string }>(
515
+ "showcase:write:invoice:create",
516
+ { customer: "BareCo" },
517
+ admin,
518
+ );
519
+ await stack.http.writeOk(
520
+ "showcase:write:invoice:approve",
521
+ { id: bareId, amountCents: 100, approvedBy: "cfo" },
522
+ admin,
523
+ );
524
+ const bareEvents = await loadAggregateRaw(stack.db, bareId, admin.tenantId);
525
+ const bareApproved = bareEvents.find((e) => e.type === "showcase:event:invoice-approved");
526
+ expect(bareApproved?.metadata.headers).toBeUndefined();
527
+ });
528
+
529
+ test("async upcaster: invoice-acknowledged v1 enriches via directory DB lookup at read time", async () => {
530
+ // Seed the directory so the async upcaster can find a name.
531
+ await insertOne(stack.db, approverDirectoryTable, {
532
+ approverId: "u-42",
533
+ displayName: "Quincy Acknowledger",
534
+ });
535
+
536
+ // Append a v1 acknowledged event directly to simulate a pre-migration
537
+ // payload on disk. ctx.appendEvent always writes the current version,
538
+ // so the only way to test the read-time upcaster is to write the older
539
+ // shape via the raw event-store.
540
+ const invoiceId = "00000000-0000-4000-8000-00000000ac01";
541
+ await stack.db.begin(async (tx: DbTx) => {
542
+ await insertOne(tx, invoiceDetailTable, {
543
+ invoiceId,
544
+ tenantId: admin.tenantId,
545
+ customer: "AckCo",
546
+ status: "draft",
547
+ amountCents: 0,
548
+ });
549
+ await append(tx, {
550
+ aggregateId: invoiceId,
551
+ aggregateType: "showcase-invoice",
552
+ tenantId: admin.tenantId,
553
+ expectedVersion: 0,
554
+ type: "showcase-invoice.created",
555
+ payload: { id: invoiceId, customer: "AckCo", status: "draft" },
556
+ metadata: { userId: admin.id },
557
+ });
558
+ await append(tx, {
559
+ aggregateId: invoiceId,
560
+ aggregateType: "showcase-invoice",
561
+ tenantId: admin.tenantId,
562
+ expectedVersion: 1,
563
+ type: "showcase:event:invoice-acknowledged",
564
+ eventVersion: 1,
565
+ payload: { approverId: "u-42" },
566
+ metadata: { userId: admin.id },
567
+ });
568
+ });
569
+
570
+ // Raw on-disk row keeps the v1 shape — upcasters never rewrite history.
571
+ const rawEvents = await loadAggregateRaw(stack.db, invoiceId, admin.tenantId);
572
+ const rawAck = rawEvents.find((e) => e.type === "showcase:event:invoice-acknowledged");
573
+ expect(rawAck?.eventVersion).toBe(1);
574
+ expect(rawAck?.payload).toEqual({ approverId: "u-42" });
575
+
576
+ // The actual proof: run the v1 row through the upcaster chain and
577
+ // verify the async DB lookup populated approverDisplayName. This is
578
+ // exactly what dispatcher.loadAggregate / projection-rebuild do
579
+ // internally — calling the primitive directly here lets the sample
580
+ // assert the read-path enrichment without an extra wrapper handler.
581
+ if (!rawAck) throw new Error("seed failed");
582
+ const enriched = await upcastStoredEvent(
583
+ rawAck,
584
+ stack.registry.getEventUpcasters(),
585
+ makeUpcastCtx(stack.db, admin.tenantId),
586
+ );
587
+ expect(enriched.eventVersion).toBe(2);
588
+ expect(enriched.payload).toMatchObject({
589
+ approverId: "u-42",
590
+ approverDisplayName: "Quincy Acknowledger",
591
+ });
592
+
593
+ // Sanity check the ctx.appendEvent path too: it writes the current
594
+ // shape directly (handler resolved displayName synchronously), so
595
+ // the upcaster is a no-op on these events.
596
+ const { id: liveId } = await stack.http.writeOk<{ id: string }>(
597
+ "showcase:write:invoice:create",
598
+ { customer: "AckCo2" },
599
+ admin,
600
+ );
601
+ await stack.http.writeOk(
602
+ "showcase:write:invoice:acknowledge",
603
+ { id: liveId, approverId: "u-42" },
604
+ admin,
605
+ );
606
+ const liveEvents = await loadAggregateRaw(stack.db, liveId, admin.tenantId);
607
+ const liveAck = liveEvents.find((e) => e.type === "showcase:event:invoice-acknowledged");
608
+ expect(liveAck?.eventVersion).toBe(2);
609
+ expect(liveAck?.payload).toMatchObject({
610
+ approverId: "u-42",
611
+ approverDisplayName: "Quincy Acknowledger",
612
+ });
613
+ });
614
+
615
+ test("streamAllEventsByType: ops iteration walks all events without buffering", async () => {
616
+ // Seed 25 invoices with create + approve = 50 events across the
617
+ // showcaseInvoice aggregate type.
618
+ for (let i = 0; i < 25; i++) {
619
+ const { id } = await stack.http.writeOk<{ id: string }>(
620
+ "showcase:write:invoice:create",
621
+ { customer: `C${i}` },
622
+ admin,
623
+ );
624
+ await stack.http.writeOk(
625
+ "showcase:write:invoice:approve",
626
+ { id, amountCents: 100 + i, approvedBy: "cfo" },
627
+ admin,
628
+ );
629
+ }
630
+
631
+ // batchSize=10 forces 5 batches (10+10+10+10+10) — proves cursor
632
+ // advance across multiple round-trips, not just a single load.
633
+ let count = 0;
634
+ const types = new Set<string>();
635
+ for await (const evt of streamAllEventsByType(stack.db, "showcase-invoice", 10)) {
636
+ count++;
637
+ types.add(evt.type);
638
+ }
639
+ expect(count).toBe(50);
640
+ expect([...types].sort()).toEqual([
641
+ "showcase-invoice.created",
642
+ "showcase:event:invoice-approved",
643
+ ]);
644
+ });
645
+
646
+ test("getAllProjectionProgress: HWM advances with the event log", async () => {
647
+ // Inline projections (r.projection) don't track a cursor in
648
+ // kumiko_projections — the row only gets written by ops-rebuild.
649
+ // So getAllProjectionProgress mainly surfaces HWM + the cursor
650
+ // remembered from the last rebuild. Useful as a "is anyone behind?"
651
+ // dashboard once you actually run rebuilds. This test pins the HWM
652
+ // advance and the never-rebuilt baseline so a regression in either
653
+ // surfaces cleanly.
654
+ const before = await getAllProjectionProgress(stack.db, stack.registry);
655
+ const detail = before.find((p) => p.name === "showcase:projection:invoice-detail");
656
+ expect(detail?.status).toBe("never-rebuilt");
657
+ expect(detail?.highWaterMark).toBe(0n);
658
+ expect(detail?.lastProcessedEventId).toBe(0n);
659
+ expect(detail?.lag).toBe(0n);
660
+
661
+ const { id } = await stack.http.writeOk<{ id: string }>(
662
+ "showcase:write:invoice:create",
663
+ { customer: "LagCheck" },
664
+ admin,
665
+ );
666
+ await stack.http.writeOk(
667
+ "showcase:write:invoice:approve",
668
+ { id, amountCents: 200, approvedBy: "cfo" },
669
+ admin,
670
+ );
671
+
672
+ const after = await getAllProjectionProgress(stack.db, stack.registry);
673
+ const detailAfter = after.find((p) => p.name === "showcase:projection:invoice-detail");
674
+ expect(detailAfter?.highWaterMark).toBe(2n);
675
+ expect(detailAfter?.lastProcessedEventId).toBe(0n);
676
+ expect(detailAfter?.lag).toBe(2n);
677
+ });
678
+
679
+ test("AllConsumerProgress: MSP cursor catches up to HWM after dispatcher run", async () => {
680
+ const { getAllConsumerProgress } = await import("@cosmicdrift/kumiko-framework/pipeline");
681
+ const consumerName = "showcase:projection:customer-revenue";
682
+
683
+ const baseline = await getAllConsumerProgress(stack.db, [consumerName]);
684
+ expect(baseline[0]?.lag).toBe(0n);
685
+
686
+ // Drive a full create→approve→pay → 1 event log entry per write,
687
+ // plus the inline projection writes = 3 events on the stream.
688
+ // The MSP only reacts to invoice-paid, but its cursor still has
689
+ // to catch up to the latest events.id to be "current".
690
+ const { id } = await stack.http.writeOk<{ id: string }>(
691
+ "showcase:write:invoice:create",
692
+ { customer: "ConsumerLag" },
693
+ admin,
694
+ );
695
+ await stack.http.writeOk(
696
+ "showcase:write:invoice:approve",
697
+ { id, amountCents: 400, approvedBy: "cfo" },
698
+ admin,
699
+ );
700
+ await stack.http.writeOk("showcase:write:invoice:pay", { id, amountCents: 400 }, admin);
701
+
702
+ // Before runOnce: events on the log, MSP cursor untouched → lag > 0.
703
+ const lagged = await getAllConsumerProgress(stack.db, [consumerName]);
704
+ expect(lagged[0]?.highWaterMark).toBeGreaterThan(0n);
705
+ expect(lagged[0]?.lag).toBe(lagged[0]?.highWaterMark);
706
+
707
+ // runOnce drains the dispatcher → cursor advances to HWM → lag = 0.
708
+ await stack.eventDispatcher?.runOnce();
709
+ const caughtUp = await getAllConsumerProgress(stack.db, [consumerName]);
710
+ expect(caughtUp[0]?.lag).toBe(0n);
711
+ expect(caughtUp[0]?.lastProcessedEventId).toBe(caughtUp[0]?.highWaterMark);
712
+ });
713
+
714
+ test("ctx.signal: long-running query handler honors abort from HTTP client", async () => {
715
+ // 50 chunks × 10ms = ~500ms total. Abort after ~80ms — handler
716
+ // should observe signal.aborted via throwIfAborted() inside the
717
+ // loop, throw, and the response surfaces an error. Without the
718
+ // signal honour, the handler would run all 500ms regardless.
719
+ const token = await stack.jwt.sign(admin);
720
+ const controller = new AbortController();
721
+ setTimeout(() => controller.abort(), 80);
722
+
723
+ const start = Date.now();
724
+ let outcome: "completed" | "aborted" | "error-response" = "completed";
725
+ try {
726
+ const res = await stack.app.request(
727
+ new Request("http://test.local/api/query", {
728
+ method: "POST",
729
+ headers: {
730
+ "Content-Type": "application/json",
731
+ Authorization: `Bearer ${token}`,
732
+ },
733
+ body: JSON.stringify({
734
+ type: "showcase:query:ops:slow-export",
735
+ payload: { chunks: 50 },
736
+ }),
737
+ signal: controller.signal,
738
+ }),
739
+ );
740
+ // If the server completed before abort landed, status is 200.
741
+ // If the handler observed abort and threw, the dispatcher wraps
742
+ // it as an internal error response.
743
+ if (res.status >= 400) outcome = "error-response";
744
+ } catch (e) {
745
+ // Outer fetch may surface AbortError when abort is observed at
746
+ // the transport layer.
747
+ if (e instanceof Error && e.name === "AbortError") outcome = "aborted";
748
+ else throw e;
749
+ }
750
+ const elapsed = Date.now() - start;
751
+
752
+ // Either way, we should be done well before the full 500ms — proves
753
+ // the handler did NOT run all 50 chunks. A handler ignoring signal
754
+ // would always take ≥500ms.
755
+ expect(elapsed).toBeLessThan(400);
756
+ expect(outcome).not.toBe("completed");
757
+ });
758
+
759
+ test("rate limit: invoice:pay caps at 5 per user per window, 6th returns 429", async () => {
760
+ // The handler declares `rateLimit: { per: "user", limit: 5, windowSeconds: 60 }`.
761
+ // Five calls within the window go through; the sixth surfaces a
762
+ // 429-shaped error response without running the handler body
763
+ // (and therefore without touching the event store).
764
+ const { id } = await stack.http.writeOk<{ id: string }>(
765
+ "showcase:write:invoice:create",
766
+ { customer: "RateLimited" },
767
+ admin,
768
+ );
769
+ await stack.http.writeOk(
770
+ "showcase:write:invoice:approve",
771
+ { id, amountCents: 100, approvedBy: "cfo" },
772
+ admin,
773
+ );
774
+
775
+ for (let i = 0; i < 5; i++) {
776
+ await stack.http.writeOk("showcase:write:invoice:pay", { id, amountCents: 100 }, admin);
777
+ }
778
+
779
+ // 6th call hits the cap. queryOk/writeOk would throw on a non-2xx
780
+ // response, hiding the wire body — go through `write` directly so
781
+ // we can assert the 429 + RateLimitError shape.
782
+ const blocked = await stack.http.write(
783
+ "showcase:write:invoice:pay",
784
+ { id, amountCents: 100 },
785
+ admin,
786
+ );
787
+ expect(blocked.status).toBe(429);
788
+ const body = (await blocked.json()) as {
789
+ error: { code: string; details?: { bucket?: string; limit?: number } };
790
+ };
791
+ expect(body.error.code).toBe("rate_limited");
792
+ expect(body.error.details?.limit).toBe(5);
793
+ expect(body.error.details?.bucket).toBe("user");
794
+ });
795
+ });
796
+ ```
797
+
798
+ ### src/feature.ts
799
+
800
+ ```ts
801
+ // Event Sourcing Showcase
802
+ //
803
+ // Production-pattern sample that exercises every Sprint-E Marten gold-
804
+ // standard API in one place:
805
+ //
806
+ // - r.defineEvent with { version, migrations } — evolving event shapes +
807
+ // upcast older payloads on read (declarative + sync/async transforms)
808
+ // - ctx.appendEvent — write domain events onto the aggregate stream
809
+ // - ctx.appendEvent with headers — Marten free key/value metadata
810
+ // - r.projection — single-stream read model (inline in the write TX)
811
+ // - r.multiStreamProjection — cross-aggregate async read model
812
+ // - ctx.loadAggregate with { asOf } — point-in-time aggregate state
813
+ // - ctx.archiveStream / ctx.restoreStream — Marten ArchiveStream
814
+ // - ctx.queryProjection — tenant-scoped read of a projection table
815
+ // - ctx.snapshotAggregate + ctx.loadAggregateWithSnapshot — perf path
816
+ // for aggregates with long event tails (O(1) vs O(N) reduce), incl.
817
+ // the auto-snapshot policy ({ snapshotEvery, snapshotVersion })
818
+ // - streamAllEventsByType — memory-bounded iteration for ops/export jobs
819
+ // - getAllProjectionProgress — projection lag for ops dashboards
820
+
821
+ import {
822
+ createEntity,
823
+ createTextField,
824
+ defineFeature,
825
+ defineProjectionQueryHandler,
826
+ typedPayload,
827
+ } from "@app/define";
828
+ import {
829
+ fetchOne,
830
+ incrementCounter,
831
+ insertOne,
832
+ updateMany,
833
+ } from "@cosmicdrift/kumiko-framework/bun-db";
834
+ import {
835
+ buildEntityTable,
836
+ createEventStoreExecutor,
837
+ integer,
838
+ table,
839
+ text,
840
+ uuid,
841
+ } from "@cosmicdrift/kumiko-framework/db";
842
+ import { z } from "zod";
843
+
844
+ // --- Reducer: shared by live + snapshot-aware query handlers ---
845
+
846
+ type InvoiceState = {
847
+ id: string | null;
848
+ customer: string | null;
849
+ status: string;
850
+ amountCents: number;
851
+ approvedBy: string | null;
852
+ paid: boolean;
853
+ };
854
+
855
+ // Record<string,unknown>-compatible alias — the snapshot reducer generic
856
+ // constrains state to that shape, so we hand it this view instead of the
857
+ // narrower InvoiceState to satisfy the bound without changing semantics.
858
+ type InvoiceStateRecord = Record<string, unknown> & InvoiceState;
859
+
860
+ const initialInvoiceState: InvoiceState = {
861
+ id: null,
862
+ customer: null,
863
+ status: "missing",
864
+ amountCents: 0,
865
+ approvedBy: null,
866
+ paid: false,
867
+ };
868
+
869
+ function reduceInvoice(state: InvoiceState, evt: { type: string; payload: unknown }): void {
870
+ if (evt.type === "showcase-invoice.created") {
871
+ const p = evt.payload as { id: string; customer: string; status: string };
872
+ state.id = p.id;
873
+ state.customer = p.customer;
874
+ state.status = p.status;
875
+ } else if (evt.type.endsWith(":event:invoice-approved")) {
876
+ const p = evt.payload as { amountCents: number; approvedBy: string };
877
+ state.status = "approved";
878
+ state.amountCents = p.amountCents;
879
+ state.approvedBy = p.approvedBy;
880
+ } else if (evt.type.endsWith(":event:invoice-paid")) {
881
+ state.status = "paid";
882
+ state.paid = true;
883
+ }
884
+ }
885
+
886
+ // --- Aggregate entity: invoice, stored as events only ---
887
+
888
+ export const invoiceEntity = createEntity({
889
+ table: "read_showcase_invoices",
890
+ fields: {
891
+ customer: createTextField({ personal: { of: "id" }, find: "none", required: true }),
892
+ status: createTextField({ personal: false, reason: "is_business_data", required: true }), // "draft" | "approved" | "paid" | "closed"
893
+ },
894
+ });
895
+
896
+ const invoiceTable = buildEntityTable("showcase-invoice", invoiceEntity);
897
+
898
+ // --- Projection 1: per-invoice detail (single-stream, inline) ---
899
+
900
+ export const invoiceDetailTable = table("read_showcase_invoice_detail", {
901
+ invoiceId: uuid("invoice_id").primaryKey(),
902
+ tenantId: uuid("tenant_id").notNull(),
903
+ customer: text("customer").notNull(),
904
+ status: text("status").notNull(),
905
+ amountCents: integer("amount_cents").notNull().default(0),
906
+ });
907
+
908
+ // --- Projection 2: per-customer revenue (multi-stream, async) ---
909
+
910
+ export const customerRevenueTable = table("read_showcase_customer_revenue", {
911
+ customer: text("customer").primaryKey(),
912
+ tenantId: uuid("tenant_id").notNull(),
913
+ paidInvoices: integer("paid_invoices").notNull().default(0),
914
+ totalCents: integer("total_cents").notNull().default(0),
915
+ });
916
+
917
+ // --- Reference data: approver display-name directory ---
918
+ // The async upcaster (invoice-acknowledged v1 → v2) reads from this table
919
+ // to enrich legacy events with a human-readable name. In production this
920
+ // would be a domain-owned read model; here it's a flat lookup table.
921
+ export const approverDirectoryTable = table("read_showcase_approver_directory", {
922
+ approverId: text("approver_id").primaryKey(),
923
+ displayName: text("display_name").notNull(),
924
+ });
925
+
926
+ // --- Feature ---
927
+
928
+ export const invoiceFeature = defineFeature("showcase", (r) => {
929
+ r.entity("showcase-invoice", invoiceEntity);
930
+
931
+ // Two domain events. "approved" is versioned — v1 stored `amount` as
932
+ // string, v2 uses `amountCents` integer. The DECLARATIVE migration form
933
+ // covers rename/default/map without an imperative function; the chain
934
+ // walks the upcast on read.
935
+ const approved = r.defineEvent(
936
+ "invoice-approved",
937
+ z.object({ amountCents: z.number().int(), approvedBy: z.string() }),
938
+ {
939
+ piiFields: "none",
940
+ version: 2,
941
+ migrations: [
942
+ {
943
+ fromVersion: 1,
944
+ toVersion: 2,
945
+ transform: {
946
+ rename: { amount: "amountCents" },
947
+ map: { amountCents: (v) => Math.round(Number.parseFloat(String(v)) * 100) },
948
+ },
949
+ },
950
+ ],
951
+ },
952
+ );
953
+
954
+ const paid = r.defineEvent("invoice-paid", z.object({ amountCents: z.number().int() }), {
955
+ piiFields: "none",
956
+ });
957
+
958
+ // Acknowledged event with an ASYNC upcaster: v1 had only the approverId,
959
+ // v2 carries the human-readable display name too. The migration looks the
960
+ // name up from the directory table at read time. This is Marten's
961
+ // AsyncOnlyEventUpcaster pattern — DB enrichment without rewriting the
962
+ // event log.
963
+ const acknowledged = r.defineEvent(
964
+ "invoice-acknowledged",
965
+ z.object({ approverId: z.string(), approverDisplayName: z.string() }),
966
+ {
967
+ piiFields: { approverDisplayName: { subjectField: "approverId" } },
968
+ version: 2,
969
+ migrations: [
970
+ {
971
+ fromVersion: 1,
972
+ toVersion: 2,
973
+ transform: async (payload, ctx) => {
974
+ const p = payload as { approverId: string };
975
+ const row = await fetchOne<{ displayName: string }>(ctx.db, approverDirectoryTable, {
976
+ approverId: p.approverId,
977
+ });
978
+ return {
979
+ approverId: p.approverId,
980
+ approverDisplayName: row?.displayName ?? `unknown:${p.approverId}`,
981
+ };
982
+ },
983
+ },
984
+ ],
985
+ },
986
+ );
987
+
988
+ // Single-stream projection: one row per invoice, reacts to the auto
989
+ // CRUD event + both domain events. Runs INLINE in the write TX.
990
+ r.projection({
991
+ name: "invoice-detail",
992
+ source: "showcase-invoice",
993
+ table: invoiceDetailTable,
994
+ apply: {
995
+ "showcase-invoice.created": async (event, tx) => {
996
+ const p = event.payload as { customer: string; status: string };
997
+ await insertOne(tx, invoiceDetailTable, {
998
+ invoiceId: event.aggregateId,
999
+ tenantId: event.tenantId,
1000
+ customer: p.customer,
1001
+ status: p.status,
1002
+ amountCents: 0,
1003
+ });
1004
+ },
1005
+ [approved.name]: async (event, tx) => {
1006
+ const p = typedPayload(event, approved);
1007
+ await updateMany(
1008
+ tx,
1009
+ invoiceDetailTable,
1010
+ { status: "approved", amountCents: p.amountCents },
1011
+ { invoiceId: event.aggregateId },
1012
+ );
1013
+ },
1014
+ [paid.name]: async (event, tx) => {
1015
+ await updateMany(
1016
+ tx,
1017
+ invoiceDetailTable,
1018
+ { status: "paid" },
1019
+ { invoiceId: event.aggregateId },
1020
+ );
1021
+ },
1022
+ },
1023
+ });
1024
+
1025
+ // Multi-stream projection: one row per customer. Fires ASYNC via the
1026
+ // event-dispatcher (runOnce in tests, NOTIFY/LISTEN in production).
1027
+ r.multiStreamProjection({
1028
+ name: "customer-revenue",
1029
+ table: customerRevenueTable,
1030
+ apply: {
1031
+ [paid.name]: async (event, tx) => {
1032
+ // Resolve the customer: pull it from the invoice-detail projection
1033
+ // that the inline projection just populated. Cross-projection reads
1034
+ // are fine inside apply() — we're at the read model layer.
1035
+ const detail = await fetchOne<{ customer: string }>(tx, invoiceDetailTable, {
1036
+ invoiceId: event.aggregateId,
1037
+ });
1038
+ if (!detail) return;
1039
+ const p = typedPayload(event, paid);
1040
+ await incrementCounter(
1041
+ tx,
1042
+ customerRevenueTable,
1043
+ {
1044
+ customer: detail.customer,
1045
+ tenantId: event.tenantId,
1046
+ paidInvoices: 1,
1047
+ totalCents: p.amountCents,
1048
+ },
1049
+ { paidInvoices: 1, totalCents: p.amountCents },
1050
+ { conflictKeys: ["customer"] },
1051
+ );
1052
+ },
1053
+ },
1054
+ });
1055
+
1056
+ const invoiceExecutor = createEventStoreExecutor(invoiceTable, invoiceEntity, {
1057
+ entityName: "showcase-invoice",
1058
+ });
1059
+
1060
+ // --- Write handlers ---
1061
+
1062
+ r.writeHandler(
1063
+ "invoice:create",
1064
+ z.object({ customer: z.string() }),
1065
+ async (event, ctx) =>
1066
+ invoiceExecutor.create(
1067
+ { customer: event.payload.customer, status: "draft" },
1068
+ event.user,
1069
+ ctx.db,
1070
+ ),
1071
+ { access: { roles: ["Admin"] } },
1072
+ );
1073
+
1074
+ r.writeHandler(
1075
+ "invoice:approve",
1076
+ z.object({
1077
+ id: z.uuid(),
1078
+ amountCents: z.number().int(),
1079
+ approvedBy: z.string(),
1080
+ // Optional ops metadata that the caller wants threaded into the
1081
+ // event for later filtering/auditing without bloating the payload.
1082
+ geoRegion: z.string().optional(),
1083
+ abTestBucket: z.string().optional(),
1084
+ }),
1085
+ async (event, ctx) => {
1086
+ const headers: Record<string, string> = {};
1087
+ if (event.payload.geoRegion) headers["geoRegion"] = event.payload.geoRegion;
1088
+ if (event.payload.abTestBucket) headers["abTestBucket"] = event.payload.abTestBucket;
1089
+ await ctx.appendEvent({
1090
+ aggregateId: event.payload.id,
1091
+ aggregateType: "showcase-invoice",
1092
+ type: approved.name,
1093
+ payload: { amountCents: event.payload.amountCents, approvedBy: event.payload.approvedBy },
1094
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
1095
+ });
1096
+ return { isSuccess: true as const, data: { id: event.payload.id } };
1097
+ },
1098
+ { access: { roles: ["Admin"] } },
1099
+ );
1100
+
1101
+ r.writeHandler(
1102
+ "invoice:acknowledge",
1103
+ z.object({ id: z.uuid(), approverId: z.string() }),
1104
+ async (event, ctx) => {
1105
+ const row = await fetchOne<{ displayName: string }>(ctx.db, approverDirectoryTable, {
1106
+ approverId: event.payload.approverId,
1107
+ });
1108
+ const approverDisplayName: string = row?.displayName ?? `unknown:${event.payload.approverId}`;
1109
+ await ctx.appendEvent({
1110
+ aggregateId: event.payload.id,
1111
+ aggregateType: "showcase-invoice",
1112
+ type: acknowledged.name,
1113
+ payload: { approverId: event.payload.approverId, approverDisplayName },
1114
+ });
1115
+ return { isSuccess: true as const, data: { id: event.payload.id } };
1116
+ },
1117
+ { access: { roles: ["Admin"] } },
1118
+ );
1119
+
1120
+ r.writeHandler(
1121
+ "invoice:pay",
1122
+ z.object({ id: z.uuid(), amountCents: z.number().int() }),
1123
+ async (event, ctx) => {
1124
+ await ctx.appendEvent({
1125
+ aggregateId: event.payload.id,
1126
+ aggregateType: "showcase-invoice",
1127
+ type: paid.name,
1128
+ payload: { amountCents: event.payload.amountCents },
1129
+ });
1130
+ return { isSuccess: true as const, data: { id: event.payload.id } };
1131
+ },
1132
+ {
1133
+ access: { roles: ["Admin"] },
1134
+ // Per-user cap: a single admin shouldn't fire more than 5 pay
1135
+ // operations per minute. Real production caps would be per
1136
+ // tenant + handler ("tenant+handler") to keep one admin from
1137
+ // monopolising the tenant's quota — kept simple here for the
1138
+ // sample. Demonstrates the fourth dispatcher gate (rate-limit
1139
+ // → access → validation → handler).
1140
+ rateLimit: { per: "user", limit: 5, windowSeconds: 60 },
1141
+ },
1142
+ );
1143
+
1144
+ r.writeHandler(
1145
+ "invoice:archive",
1146
+ z.object({ id: z.uuid() }),
1147
+ async (event, ctx) => {
1148
+ await ctx.archiveStream(event.payload.id, { aggregateType: "showcase-invoice" });
1149
+ return { isSuccess: true as const, data: { id: event.payload.id } };
1150
+ },
1151
+ { access: { roles: ["Admin"] } },
1152
+ );
1153
+
1154
+ // Persist a snapshot of the current state. In practice, features schedule
1155
+ // this from a lifecycle hook (every N events, every M minutes) — here it's
1156
+ // an explicit write-handler so the integration test can drive it.
1157
+ r.writeHandler(
1158
+ "invoice:take-snapshot",
1159
+ z.object({ id: z.uuid() }),
1160
+ async (event, ctx) => {
1161
+ // Rebuild the state from the current stream so the snapshot reflects
1162
+ // the committed event log, not an in-memory guess. Use the upcaster-
1163
+ // aware ctx.loadAggregate to stay consistent with read-time semantics.
1164
+ const events = await ctx.loadAggregate(event.payload.id);
1165
+ const state: InvoiceState = { ...initialInvoiceState };
1166
+ for (const evt of events) {
1167
+ reduceInvoice(state, evt);
1168
+ }
1169
+ const version = events.length > 0 ? (events[events.length - 1]?.version ?? 0) : 0;
1170
+ await ctx.snapshotAggregate({
1171
+ aggregateId: event.payload.id,
1172
+ aggregateType: "showcase-invoice",
1173
+ version,
1174
+ state: state as unknown as Record<string, unknown>,
1175
+ });
1176
+ return { isSuccess: true as const, data: { id: event.payload.id, snapshotVersion: version } };
1177
+ },
1178
+ { access: { roles: ["Admin"] } },
1179
+ );
1180
+
1181
+ // --- Query handlers ---
1182
+
1183
+ // Live aggregation via ctx.loadAggregate — reduces events into a state
1184
+ // snapshot. Supports asOf for point-in-time reads.
1185
+ r.queryHandler(
1186
+ "invoice:state",
1187
+ z.object({ id: z.uuid(), asOf: z.iso.datetime().optional() }),
1188
+ async (query, ctx) => {
1189
+ const events = await ctx.loadAggregate(query.payload.id, {
1190
+ ...(query.payload.asOf ? { asOf: Temporal.Instant.from(query.payload.asOf) } : {}),
1191
+ });
1192
+ const state: InvoiceState = { ...initialInvoiceState };
1193
+ for (const evt of events) {
1194
+ reduceInvoice(state, evt);
1195
+ }
1196
+ return state;
1197
+ },
1198
+ {
1199
+ access: {
1200
+ openToAll: {
1201
+ reason:
1202
+ "demo recipe: any signed-in user may read the event-sourced invoice state; " +
1203
+ "there is no per-user invoice ownership in this sample",
1204
+ },
1205
+ },
1206
+ },
1207
+ );
1208
+
1209
+ // Snapshot-aware fast path. Uses the latest snapshot if available and
1210
+ // only replays delta events past it. Identical state shape to
1211
+ // invoice:state — the two are interchangeable modulo the perf profile.
1212
+ r.queryHandler(
1213
+ "invoice:fast-state",
1214
+ z.object({ id: z.uuid() }),
1215
+ async (query, ctx) => {
1216
+ const result = await ctx.loadAggregateWithSnapshot<InvoiceStateRecord>(
1217
+ query.payload.id,
1218
+ (state, evt) => {
1219
+ const next: InvoiceStateRecord = { ...state };
1220
+ reduceInvoice(next as unknown as InvoiceState, evt);
1221
+ return next;
1222
+ },
1223
+ { ...initialInvoiceState } as InvoiceStateRecord,
1224
+ // Auto-snapshot policy: once 100+ delta events pile up past the last
1225
+ // snapshot, the read path persists a fresh one (best-effort). Bump
1226
+ // snapshotVersion whenever the reducer's state SHAPE changes — a
1227
+ // stale-shape snapshot is then ignored and rebuilt from the log.
1228
+ { snapshotEvery: 100, snapshotVersion: 1 },
1229
+ );
1230
+ return {
1231
+ state: result.state,
1232
+ version: result.version,
1233
+ snapshotHit: result.snapshotHit,
1234
+ };
1235
+ },
1236
+ {
1237
+ access: {
1238
+ openToAll: {
1239
+ reason:
1240
+ "demo recipe: any signed-in user may read the snapshot-accelerated invoice " +
1241
+ "state; there is no per-user invoice ownership in this sample",
1242
+ },
1243
+ },
1244
+ },
1245
+ );
1246
+
1247
+ // Read-model query via ctx.queryProjection — auto-tenant-scoped. The
1248
+ // helper collapses the zero-argument "return this projection" pattern
1249
+ // into a single call; returns { rows } so the response shape stays
1250
+ // consistent with other list handlers.
1251
+ r.queryHandler(
1252
+ defineProjectionQueryHandler("revenue:list", "showcase:projection:customer-revenue", {
1253
+ access: {
1254
+ openToAll: {
1255
+ reason:
1256
+ "demo recipe: any signed-in user may read the customer-revenue projection; " +
1257
+ "there is no per-user restriction in this sample",
1258
+ },
1259
+ },
1260
+ }),
1261
+ );
1262
+
1263
+ // Slow synthetic export — demonstrates ctx.signal honouring inside a
1264
+ // long-running handler loop. Real-world equivalents: PDF batch export,
1265
+ // bulk CSV download, multi-stream audit walk. Mobile clients that
1266
+ // navigate away mid-export should NOT keep the server busy producing
1267
+ // bytes nobody will read. The handler checks ctx.signal at every
1268
+ // iteration; on abort, throws and the dispatcher returns an error to
1269
+ // the (already-disconnected) client.
1270
+ r.queryHandler(
1271
+ "ops:slow-export",
1272
+ z.object({ chunks: z.number().int().min(1).max(1000) }),
1273
+ async (query, ctx) => {
1274
+ const accumulated: number[] = [];
1275
+ for (let i = 0; i < query.payload.chunks; i++) {
1276
+ ctx.signal?.throwIfAborted();
1277
+ // Fake "work per chunk". Realistic handler would assemble a row,
1278
+ // hit a remote service, etc.
1279
+ await new Promise((resolve) => setTimeout(resolve, 10));
1280
+ accumulated.push(i);
1281
+ }
1282
+ return { processed: accumulated.length };
1283
+ },
1284
+ { access: { roles: ["Admin"] } },
1285
+ );
1286
+ });
1287
+ ```