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