@cosmicdrift/kumiko-framework 0.1.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/README.md +159 -0
- package/package.json +91 -0
- package/src/__tests__/anonymous-access.integration.ts +325 -0
- package/src/__tests__/error-contract.integration.ts +435 -0
- package/src/__tests__/field-access.integration.ts +269 -0
- package/src/__tests__/full-stack.integration.ts +914 -0
- package/src/__tests__/ownership.integration.ts +449 -0
- package/src/__tests__/reference-data.integration.ts +198 -0
- package/src/__tests__/transition-guard.integration.ts +340 -0
- package/src/api/__tests__/api.test.ts +337 -0
- package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
- package/src/api/__tests__/batch.integration.ts +404 -0
- package/src/api/__tests__/body-limit.test.ts +88 -0
- package/src/api/__tests__/csrf-middleware.test.ts +97 -0
- package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
- package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
- package/src/api/__tests__/nested-write.integration.ts +213 -0
- package/src/api/__tests__/readiness.test.ts +76 -0
- package/src/api/__tests__/request-id-middleware.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +58 -0
- package/src/api/__tests__/sse-route.test.ts +112 -0
- package/src/api/anonymous-cookie.ts +60 -0
- package/src/api/api-constants.ts +64 -0
- package/src/api/auth-middleware.ts +418 -0
- package/src/api/auth-routes.ts +982 -0
- package/src/api/csrf-middleware.ts +77 -0
- package/src/api/index.ts +31 -0
- package/src/api/jwt.ts +66 -0
- package/src/api/observability-middleware.ts +89 -0
- package/src/api/readiness.ts +132 -0
- package/src/api/request-context.ts +49 -0
- package/src/api/request-id-middleware.ts +50 -0
- package/src/api/route-registrars.ts +195 -0
- package/src/api/routes.ts +135 -0
- package/src/api/server.ts +640 -0
- package/src/api/sse-broker.ts +71 -0
- package/src/api/sse-route.ts +62 -0
- package/src/api/tokens.ts +16 -0
- package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
- package/src/db/__tests__/compound-types.test.ts +114 -0
- package/src/db/__tests__/connection-options.test.ts +68 -0
- package/src/db/__tests__/cursor.test.ts +41 -0
- package/src/db/__tests__/db-helpers.test.ts +369 -0
- package/src/db/__tests__/dialect-instant.test.ts +50 -0
- package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
- package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
- package/src/db/__tests__/encryption.test.ts +39 -0
- package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
- package/src/db/__tests__/event-store-executor.integration.ts +235 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
- package/src/db/__tests__/located-timestamp.test.ts +184 -0
- package/src/db/__tests__/money.test.ts +199 -0
- package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
- package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
- package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
- package/src/db/__tests__/row-helpers.test.ts +59 -0
- package/src/db/__tests__/schema-migration.integration.ts +273 -0
- package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
- package/src/db/__tests__/table-builder-required.test.ts +216 -0
- package/src/db/__tests__/tenant-db.integration.ts +606 -0
- package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
- package/src/db/apply-entity-event.ts +188 -0
- package/src/db/assert-exists-in.ts +59 -0
- package/src/db/compound-types.ts +47 -0
- package/src/db/connection.ts +104 -0
- package/src/db/cursor.ts +83 -0
- package/src/db/dialect.ts +109 -0
- package/src/db/eagerload.ts +174 -0
- package/src/db/encryption.ts +39 -0
- package/src/db/event-store-executor.ts +906 -0
- package/src/db/index.ts +55 -0
- package/src/db/located-timestamp.ts +114 -0
- package/src/db/money.ts +120 -0
- package/src/db/pg-error.ts +46 -0
- package/src/db/reference-data.ts +77 -0
- package/src/db/row-helpers.ts +53 -0
- package/src/db/schema-inspection.ts +25 -0
- package/src/db/table-builder.ts +475 -0
- package/src/db/tenant-db.ts +434 -0
- package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
- package/src/engine/__tests__/boot-validator.test.ts +1865 -0
- package/src/engine/__tests__/build-app-schema.test.ts +154 -0
- package/src/engine/__tests__/claim-keys.test.ts +274 -0
- package/src/engine/__tests__/config-helpers.test.ts +236 -0
- package/src/engine/__tests__/effective-features.test.ts +86 -0
- package/src/engine/__tests__/engine.test.ts +1461 -0
- package/src/engine/__tests__/entity-handlers.test.ts +274 -0
- package/src/engine/__tests__/event-helpers.test.ts +68 -0
- package/src/engine/__tests__/extends-registrar.test.ts +159 -0
- package/src/engine/__tests__/factories-long-text.test.ts +84 -0
- package/src/engine/__tests__/factories-time.test.ts +158 -0
- package/src/engine/__tests__/field-predicates.test.ts +48 -0
- package/src/engine/__tests__/hook-phases.test.ts +132 -0
- package/src/engine/__tests__/identifiers.test.ts +35 -0
- package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
- package/src/engine/__tests__/nav.test.ts +267 -0
- package/src/engine/__tests__/ownership.test.ts +421 -0
- package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
- package/src/engine/__tests__/projection-helpers.test.ts +62 -0
- package/src/engine/__tests__/projection.test.ts +191 -0
- package/src/engine/__tests__/qualified-name.test.ts +264 -0
- package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
- package/src/engine/__tests__/run-in.test.ts +38 -0
- package/src/engine/__tests__/schema-builder.test.ts +380 -0
- package/src/engine/__tests__/screen.test.ts +408 -0
- package/src/engine/__tests__/state-machine.test.ts +148 -0
- package/src/engine/__tests__/system-user.test.ts +57 -0
- package/src/engine/__tests__/validation-hooks.test.ts +71 -0
- package/src/engine/access.ts +23 -0
- package/src/engine/boot-validator.ts +1528 -0
- package/src/engine/build-app-schema.ts +125 -0
- package/src/engine/config-helpers.ts +115 -0
- package/src/engine/constants.ts +85 -0
- package/src/engine/create-app.ts +98 -0
- package/src/engine/define-feature.ts +702 -0
- package/src/engine/define-handler.ts +78 -0
- package/src/engine/define-roles.ts +19 -0
- package/src/engine/effective-features.ts +87 -0
- package/src/engine/entity-handlers.ts +364 -0
- package/src/engine/event-helpers.ts +73 -0
- package/src/engine/factories.ts +328 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
- package/src/engine/feature-ast/extractors.ts +2562 -0
- package/src/engine/feature-ast/index.ts +105 -0
- package/src/engine/feature-ast/parse.ts +369 -0
- package/src/engine/feature-ast/patch.ts +525 -0
- package/src/engine/feature-ast/patcher.ts +518 -0
- package/src/engine/feature-ast/patterns.ts +434 -0
- package/src/engine/feature-ast/render.ts +602 -0
- package/src/engine/feature-ast/source-location.ts +45 -0
- package/src/engine/field-access.ts +120 -0
- package/src/engine/index.ts +254 -0
- package/src/engine/ownership.ts +337 -0
- package/src/engine/parse-ref-target.ts +22 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
- package/src/engine/pattern-library/index.ts +24 -0
- package/src/engine/pattern-library/library.ts +1117 -0
- package/src/engine/pattern-library/types.ts +255 -0
- package/src/engine/projection-helpers.ts +85 -0
- package/src/engine/qualified-name.ts +122 -0
- package/src/engine/read-claim.ts +31 -0
- package/src/engine/registry.ts +1325 -0
- package/src/engine/resolve-config-or-param.ts +153 -0
- package/src/engine/run-in.ts +29 -0
- package/src/engine/schema-builder.ts +175 -0
- package/src/engine/screen-filter-ops.ts +51 -0
- package/src/engine/state-machine.ts +70 -0
- package/src/engine/system-user.ts +32 -0
- package/src/engine/types/config.ts +306 -0
- package/src/engine/types/event-type-map.ts +37 -0
- package/src/engine/types/feature.ts +574 -0
- package/src/engine/types/fields.ts +422 -0
- package/src/engine/types/handlers.ts +742 -0
- package/src/engine/types/hooks.ts +142 -0
- package/src/engine/types/http-route.ts +54 -0
- package/src/engine/types/identifiers.ts +47 -0
- package/src/engine/types/index.ts +208 -0
- package/src/engine/types/nav.ts +46 -0
- package/src/engine/types/projection.ts +132 -0
- package/src/engine/types/relations.ts +51 -0
- package/src/engine/types/screen.ts +452 -0
- package/src/engine/types/workspace.ts +42 -0
- package/src/engine/validation.ts +33 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
- package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
- package/src/entrypoint/index.ts +442 -0
- package/src/errors/__tests__/classes.test.ts +371 -0
- package/src/errors/__tests__/write-failures.test.ts +109 -0
- package/src/errors/classes.ts +249 -0
- package/src/errors/i18n/de.yaml +83 -0
- package/src/errors/i18n/en.yaml +80 -0
- package/src/errors/index.ts +41 -0
- package/src/errors/kumiko-error.ts +67 -0
- package/src/errors/reasons.ts +36 -0
- package/src/errors/serialize.ts +136 -0
- package/src/errors/transition-details.ts +30 -0
- package/src/errors/write-error-info.ts +123 -0
- package/src/errors/zod-bridge.ts +49 -0
- package/src/event-store/__tests__/admin-api.integration.ts +361 -0
- package/src/event-store/__tests__/event-store.integration.ts +584 -0
- package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
- package/src/event-store/__tests__/perf.integration.ts +255 -0
- package/src/event-store/__tests__/snapshot.integration.ts +267 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
- package/src/event-store/__tests__/upcaster.integration.ts +460 -0
- package/src/event-store/admin-api.ts +257 -0
- package/src/event-store/archive.ts +106 -0
- package/src/event-store/errors.ts +35 -0
- package/src/event-store/event-store.ts +405 -0
- package/src/event-store/events-schema.ts +90 -0
- package/src/event-store/index.ts +50 -0
- package/src/event-store/snapshot.ts +210 -0
- package/src/event-store/upcaster-dead-letter.ts +119 -0
- package/src/event-store/upcaster.ts +147 -0
- package/src/files/__tests__/content-disposition.test.ts +123 -0
- package/src/files/__tests__/file-field-column.integration.ts +103 -0
- package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
- package/src/files/__tests__/file-handle.test.ts +122 -0
- package/src/files/__tests__/files.integration.ts +830 -0
- package/src/files/__tests__/storage-tracking.integration.ts +153 -0
- package/src/files/content-disposition.ts +55 -0
- package/src/files/file-handle.ts +63 -0
- package/src/files/file-ref-table.ts +22 -0
- package/src/files/file-routes.ts +353 -0
- package/src/files/in-memory-provider.ts +62 -0
- package/src/files/index.ts +29 -0
- package/src/files/local-provider.ts +35 -0
- package/src/files/storage-tracking.ts +60 -0
- package/src/files/types.ts +118 -0
- package/src/i18n/__tests__/i18n.test.ts +72 -0
- package/src/i18n/index.ts +29 -0
- package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
- package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
- package/src/jobs/__tests__/jobs.integration.ts +566 -0
- package/src/jobs/index.ts +2 -0
- package/src/jobs/job-runner.ts +574 -0
- package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
- package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
- package/src/lifecycle/index.ts +13 -0
- package/src/lifecycle/lifecycle.ts +160 -0
- package/src/lifecycle/signal-handlers.ts +62 -0
- package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/pino-logger.ts +64 -0
- package/src/logging/types.ts +7 -0
- package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
- package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
- package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
- package/src/migrations/index.ts +28 -0
- package/src/migrations/projection-detection.ts +149 -0
- package/src/migrations/rebuild-marker.ts +64 -0
- package/src/migrations/schema-drift.ts +395 -0
- package/src/observability/__tests__/console-provider.test.ts +67 -0
- package/src/observability/__tests__/metric-validator.test.ts +87 -0
- package/src/observability/__tests__/noop-provider.test.ts +82 -0
- package/src/observability/__tests__/observability.integration.ts +559 -0
- package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
- package/src/observability/__tests__/recording-meter.test.ts +101 -0
- package/src/observability/__tests__/recording-tracer.test.ts +110 -0
- package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
- package/src/observability/console-provider.ts +130 -0
- package/src/observability/context.ts +26 -0
- package/src/observability/fallback.ts +34 -0
- package/src/observability/ids.ts +25 -0
- package/src/observability/index.ts +79 -0
- package/src/observability/metric-validator.ts +86 -0
- package/src/observability/metrics-handle.ts +56 -0
- package/src/observability/noop-provider.ts +146 -0
- package/src/observability/prometheus-meter.ts +284 -0
- package/src/observability/recording-meter.ts +156 -0
- package/src/observability/recording-tracer.ts +198 -0
- package/src/observability/redis-wrapper.ts +132 -0
- package/src/observability/sensitive-filter.ts +108 -0
- package/src/observability/standard-metrics.ts +213 -0
- package/src/observability/types/index.ts +29 -0
- package/src/observability/types/metric.ts +56 -0
- package/src/observability/types/provider.ts +32 -0
- package/src/observability/types/span.ts +64 -0
- package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
- package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
- package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
- package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
- package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
- package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
- package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
- package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
- package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
- package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
- package/src/pipeline/append-event-core.ts +117 -0
- package/src/pipeline/auth-claims-resolver.ts +103 -0
- package/src/pipeline/cascade-handler.ts +113 -0
- package/src/pipeline/dispatcher.ts +1585 -0
- package/src/pipeline/distributed-lock.ts +37 -0
- package/src/pipeline/entity-cache.ts +113 -0
- package/src/pipeline/event-consumer-state.ts +108 -0
- package/src/pipeline/event-dedup.ts +23 -0
- package/src/pipeline/event-dispatcher.ts +1016 -0
- package/src/pipeline/event-retention.ts +154 -0
- package/src/pipeline/idempotency.ts +76 -0
- package/src/pipeline/index.ts +66 -0
- package/src/pipeline/lifecycle-pipeline.ts +409 -0
- package/src/pipeline/msp-rebuild.ts +242 -0
- package/src/pipeline/multi-stream-apply-context.ts +115 -0
- package/src/pipeline/projection-rebuild.ts +334 -0
- package/src/pipeline/projection-state.ts +72 -0
- package/src/pipeline/projections-runner.ts +56 -0
- package/src/pipeline/redis-keys.ts +11 -0
- package/src/pipeline/system-hooks.ts +190 -0
- package/src/random/__tests__/generate.test.ts +149 -0
- package/src/random/generate.ts +141 -0
- package/src/random/index.ts +8 -0
- package/src/random/words.ts +392 -0
- package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
- package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
- package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
- package/src/rate-limit/bucket.ts +36 -0
- package/src/rate-limit/index.ts +14 -0
- package/src/rate-limit/middleware.ts +152 -0
- package/src/rate-limit/resolver.ts +267 -0
- package/src/redis/__tests__/redis-options.test.ts +54 -0
- package/src/redis/index.ts +74 -0
- package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
- package/src/search/__tests__/search-adapter.test.ts +256 -0
- package/src/search/in-memory-adapter.ts +123 -0
- package/src/search/index.ts +12 -0
- package/src/search/meilisearch-adapter.ts +106 -0
- package/src/search/types.ts +39 -0
- package/src/secrets/__tests__/dek-cache.test.ts +213 -0
- package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
- package/src/secrets/__tests__/envelope.test.ts +74 -0
- package/src/secrets/__tests__/leak-guard.test.ts +92 -0
- package/src/secrets/__tests__/rotation.test.ts +149 -0
- package/src/secrets/dek-cache.ts +116 -0
- package/src/secrets/env-master-key-provider.ts +162 -0
- package/src/secrets/envelope.ts +55 -0
- package/src/secrets/index.ts +19 -0
- package/src/secrets/leak-guard.ts +87 -0
- package/src/secrets/rotation.ts +34 -0
- package/src/secrets/types.ts +107 -0
- package/src/stack/db.ts +104 -0
- package/src/stack/event-collector.ts +23 -0
- package/src/stack/index.ts +32 -0
- package/src/stack/redis.ts +44 -0
- package/src/stack/request-helper.ts +168 -0
- package/src/stack/table-helpers.ts +104 -0
- package/src/stack/test-stack.ts +357 -0
- package/src/stack/test-users.ts +37 -0
- package/src/testing/__tests__/e2e-generator.test.ts +230 -0
- package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
- package/src/testing/access-assertions.ts +15 -0
- package/src/testing/assertions.ts +35 -0
- package/src/testing/e2e-generator.ts +465 -0
- package/src/testing/expect-error.ts +25 -0
- package/src/testing/handler-context.ts +125 -0
- package/src/testing/http-cookies.ts +52 -0
- package/src/testing/index.ts +41 -0
- package/src/testing/late-bound.ts +39 -0
- package/src/testing/mutable-master-key-provider.ts +31 -0
- package/src/testing/observability-recorder.ts +54 -0
- package/src/testing/shared-entities.ts +49 -0
- package/src/testing/utils.ts +1 -0
- package/src/testing/wait-for.ts +31 -0
- package/src/time/__tests__/polyfill.test.ts +73 -0
- package/src/time/__tests__/tz-context.test.ts +121 -0
- package/src/time/index.ts +21 -0
- package/src/time/polyfill.ts +70 -0
- package/src/time/tz-context.ts +107 -0
- package/src/ui-types/app-schema.ts +57 -0
- package/src/ui-types/index.ts +65 -0
- package/src/utils/__tests__/assert.test.ts +17 -0
- package/src/utils/__tests__/env-parse.test.ts +54 -0
- package/src/utils/assert.ts +18 -0
- package/src/utils/env-parse.ts +16 -0
- package/src/utils/ids.ts +16 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/safe-json.ts +30 -0
- package/src/utils/serialization.ts +7 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// B2 — ctx.loadAggregate HTTP-surface for live aggregation + asOf.
|
|
2
|
+
//
|
|
3
|
+
// Marten's AggregateStreamAsync<T>(id[, version|timestamp]) in TypeScript
|
|
4
|
+
// shape: ctx.loadAggregate(id[, { asOf }]). A queryHandler reduces the
|
|
5
|
+
// returned events into whatever domain-state shape the feature wants.
|
|
6
|
+
// Events are upcasted by the dispatcher, so the reducer sees the current
|
|
7
|
+
// payload shape even for old v1 events.
|
|
8
|
+
|
|
9
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
12
|
+
import { buildDrizzleTable } from "../../db/table-builder";
|
|
13
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
14
|
+
import { append, loadAggregate as loadAggregateRaw } from "../../event-store";
|
|
15
|
+
import {
|
|
16
|
+
createEntityTable,
|
|
17
|
+
resetEventStore,
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
TestUsers,
|
|
21
|
+
} from "../../stack";
|
|
22
|
+
|
|
23
|
+
// --- Fixture entity ---
|
|
24
|
+
|
|
25
|
+
const invoiceEntity = createEntity({
|
|
26
|
+
table: "read_asof_invoices",
|
|
27
|
+
fields: {
|
|
28
|
+
customer: createTextField({ required: true }),
|
|
29
|
+
status: createTextField({ required: true }),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const invoiceTable = buildDrizzleTable("asof-invoice", invoiceEntity);
|
|
33
|
+
|
|
34
|
+
// --- Feature ---
|
|
35
|
+
|
|
36
|
+
const asOfFeature = defineFeature("asoftest", (r) => {
|
|
37
|
+
r.entity("asof-invoice", invoiceEntity);
|
|
38
|
+
|
|
39
|
+
// Two domain events at different versions. v1→v2 migration bumps the
|
|
40
|
+
// "amount" field from string to integer cents, same pattern as B1.
|
|
41
|
+
const approved = r.defineEvent(
|
|
42
|
+
"approved",
|
|
43
|
+
z.object({ amount: z.number().int(), approvedBy: z.string() }),
|
|
44
|
+
{ version: 2 },
|
|
45
|
+
);
|
|
46
|
+
r.eventMigration("approved", 1, 2, (payload) => {
|
|
47
|
+
const p = payload as { amount: string; approvedBy: string };
|
|
48
|
+
return {
|
|
49
|
+
amount: Math.round(Number.parseFloat(p.amount) * 100),
|
|
50
|
+
approvedBy: p.approvedBy,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const executor = createEventStoreExecutor(invoiceTable, invoiceEntity, {
|
|
55
|
+
entityName: "asof-invoice",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
r.writeHandler(
|
|
59
|
+
"invoice:create",
|
|
60
|
+
z.object({ customer: z.string(), status: z.string() }),
|
|
61
|
+
async (event, ctx) => executor.create(event.payload, event.user, ctx.db),
|
|
62
|
+
{ access: { roles: ["Admin"] } },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
r.writeHandler(
|
|
66
|
+
"invoice:approve",
|
|
67
|
+
z.object({ id: z.uuid(), amount: z.number().int(), approvedBy: z.string() }),
|
|
68
|
+
async (event, ctx) => {
|
|
69
|
+
await ctx.appendEventUnsafe({
|
|
70
|
+
aggregateId: event.payload.id,
|
|
71
|
+
aggregateType: "asof-invoice",
|
|
72
|
+
type: approved.name,
|
|
73
|
+
payload: { amount: event.payload.amount, approvedBy: event.payload.approvedBy },
|
|
74
|
+
});
|
|
75
|
+
return { isSuccess: true as const, data: { id: event.payload.id } };
|
|
76
|
+
},
|
|
77
|
+
{ access: { roles: ["Admin"] } },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Query handler — reduces raw events into a derived shape via
|
|
81
|
+
// ctx.loadAggregate. Exposes live aggregation over HTTP.
|
|
82
|
+
r.queryHandler(
|
|
83
|
+
"invoice:state",
|
|
84
|
+
z.object({
|
|
85
|
+
id: z.uuid(),
|
|
86
|
+
asOf: z.iso.datetime().optional(),
|
|
87
|
+
}),
|
|
88
|
+
async (query, ctx) => {
|
|
89
|
+
const events = await ctx.loadAggregate(query.payload.id, {
|
|
90
|
+
...(query.payload.asOf ? { asOf: Temporal.Instant.from(query.payload.asOf) } : {}),
|
|
91
|
+
});
|
|
92
|
+
// Simple reducer: collect created + approved facts, ignore the rest.
|
|
93
|
+
const state: {
|
|
94
|
+
id: string | null;
|
|
95
|
+
customer: string | null;
|
|
96
|
+
status: string;
|
|
97
|
+
approved: boolean;
|
|
98
|
+
approvedAmountCents: number | null;
|
|
99
|
+
approvedBy: string | null;
|
|
100
|
+
} = {
|
|
101
|
+
id: null,
|
|
102
|
+
customer: null,
|
|
103
|
+
status: "unknown",
|
|
104
|
+
approved: false,
|
|
105
|
+
approvedAmountCents: null,
|
|
106
|
+
approvedBy: null,
|
|
107
|
+
};
|
|
108
|
+
for (const evt of events) {
|
|
109
|
+
if (evt.type === "asof-invoice.created") {
|
|
110
|
+
const p = evt.payload as { id: string; customer: string; status: string };
|
|
111
|
+
state.id = p.id;
|
|
112
|
+
state.customer = p.customer;
|
|
113
|
+
state.status = p.status;
|
|
114
|
+
} else if (evt.type === approved.name) {
|
|
115
|
+
const p = evt.payload as { amount: number; approvedBy: string };
|
|
116
|
+
state.approved = true;
|
|
117
|
+
state.approvedAmountCents = p.amount;
|
|
118
|
+
state.approvedBy = p.approvedBy;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return state;
|
|
122
|
+
},
|
|
123
|
+
{ access: { openToAll: true } },
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// --- Test stack ---
|
|
128
|
+
|
|
129
|
+
let stack: TestStack;
|
|
130
|
+
const admin = TestUsers.admin;
|
|
131
|
+
|
|
132
|
+
beforeAll(async () => {
|
|
133
|
+
stack = await setupTestStack({ features: [asOfFeature], systemHooks: [] });
|
|
134
|
+
await createEntityTable(stack.db, invoiceEntity, "asof-invoice");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
afterAll(async () => {
|
|
138
|
+
await stack.cleanup();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
afterEach(async () => {
|
|
142
|
+
await resetEventStore(stack, ["read_asof_invoices"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// --- Tests ---
|
|
146
|
+
|
|
147
|
+
describe("ctx.loadAggregate via queryHandler — Marten AggregateStreamAsync equivalent", () => {
|
|
148
|
+
test("reduces created + approved events into current state", async () => {
|
|
149
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
150
|
+
"asoftest:write:invoice:create",
|
|
151
|
+
{ customer: "Acme", status: "draft" },
|
|
152
|
+
admin,
|
|
153
|
+
);
|
|
154
|
+
await stack.http.writeOk(
|
|
155
|
+
"asoftest:write:invoice:approve",
|
|
156
|
+
{ id: created.id, amount: 1500, approvedBy: "boss" },
|
|
157
|
+
admin,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const state = await stack.http.queryOk<{
|
|
161
|
+
id: string;
|
|
162
|
+
customer: string;
|
|
163
|
+
status: string;
|
|
164
|
+
approved: boolean;
|
|
165
|
+
approvedAmountCents: number | null;
|
|
166
|
+
approvedBy: string | null;
|
|
167
|
+
}>("asoftest:query:invoice:state", { id: created.id }, admin);
|
|
168
|
+
|
|
169
|
+
expect(state.customer).toBe("Acme");
|
|
170
|
+
expect(state.approved).toBe(true);
|
|
171
|
+
expect(state.approvedAmountCents).toBe(1500);
|
|
172
|
+
expect(state.approvedBy).toBe("boss");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("asOf excludes events after the given timestamp — Marten point-in-time read", async () => {
|
|
176
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
177
|
+
"asoftest:write:invoice:create",
|
|
178
|
+
{ customer: "TimeTraveler", status: "draft" },
|
|
179
|
+
admin,
|
|
180
|
+
);
|
|
181
|
+
// Capture the event timestamp from the events-table for a precise cutoff.
|
|
182
|
+
// A too-small offset risks clock granularity flakes — we rely on the
|
|
183
|
+
// millisecond-precision timestamp column.
|
|
184
|
+
const preApprove = new Date();
|
|
185
|
+
// Make sure the next event's createdAt is strictly after the cutoff.
|
|
186
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
187
|
+
await stack.http.writeOk(
|
|
188
|
+
"asoftest:write:invoice:approve",
|
|
189
|
+
{ id: created.id, amount: 9999, approvedBy: "late" },
|
|
190
|
+
admin,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// "Current" state sees the approval.
|
|
194
|
+
const now = await stack.http.queryOk<{ approved: boolean }>(
|
|
195
|
+
"asoftest:query:invoice:state",
|
|
196
|
+
{ id: created.id },
|
|
197
|
+
admin,
|
|
198
|
+
);
|
|
199
|
+
expect(now.approved).toBe(true);
|
|
200
|
+
|
|
201
|
+
// asOf preApprove: the approval is in the future, not yet visible.
|
|
202
|
+
const past = await stack.http.queryOk<{ approved: boolean; status: string }>(
|
|
203
|
+
"asoftest:query:invoice:state",
|
|
204
|
+
{ id: created.id, asOf: preApprove.toISOString() },
|
|
205
|
+
admin,
|
|
206
|
+
);
|
|
207
|
+
expect(past.approved).toBe(false);
|
|
208
|
+
expect(past.status).toBe("draft");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("payloads are upcasted — v1-on-disk events reach the reducer as v2", async () => {
|
|
212
|
+
// Appending directly at eventVersion=1 (older shape) simulates data
|
|
213
|
+
// that predates the current event version. The reducer is written
|
|
214
|
+
// against v2 (integer cents) — without upcasting it would blow up or
|
|
215
|
+
// produce garbage.
|
|
216
|
+
const invoiceId = "00000000-0000-4000-8000-000000000042";
|
|
217
|
+
await stack.db.transaction(async (tx) => {
|
|
218
|
+
await tx.insert(invoiceTable).values({
|
|
219
|
+
id: invoiceId,
|
|
220
|
+
tenantId: admin.tenantId,
|
|
221
|
+
customer: "LegacyCo",
|
|
222
|
+
status: "imported",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Initial "created" event at v1 of that too — but asofInvoice.created
|
|
226
|
+
// has no migration, its eventVersion is irrelevant here.
|
|
227
|
+
await append(tx, {
|
|
228
|
+
aggregateId: invoiceId,
|
|
229
|
+
aggregateType: "asof-invoice",
|
|
230
|
+
tenantId: admin.tenantId,
|
|
231
|
+
expectedVersion: 0,
|
|
232
|
+
type: "asof-invoice.created",
|
|
233
|
+
payload: { id: invoiceId, customer: "LegacyCo", status: "imported" },
|
|
234
|
+
metadata: { userId: admin.id },
|
|
235
|
+
});
|
|
236
|
+
await append(tx, {
|
|
237
|
+
aggregateId: invoiceId,
|
|
238
|
+
aggregateType: "asof-invoice",
|
|
239
|
+
tenantId: admin.tenantId,
|
|
240
|
+
expectedVersion: 1,
|
|
241
|
+
type: "asoftest:event:approved",
|
|
242
|
+
eventVersion: 1,
|
|
243
|
+
payload: { amount: "42.50", approvedBy: "legacy" },
|
|
244
|
+
metadata: { userId: admin.id },
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const state = await stack.http.queryOk<{
|
|
249
|
+
approved: boolean;
|
|
250
|
+
approvedAmountCents: number | null;
|
|
251
|
+
approvedBy: string | null;
|
|
252
|
+
}>("asoftest:query:invoice:state", { id: invoiceId }, admin);
|
|
253
|
+
|
|
254
|
+
// "42.50" EUR → 4250 cents after the v1→v2 upcaster runs on read.
|
|
255
|
+
expect(state.approved).toBe(true);
|
|
256
|
+
expect(state.approvedAmountCents).toBe(4250);
|
|
257
|
+
expect(state.approvedBy).toBe("legacy");
|
|
258
|
+
|
|
259
|
+
// Raw reader (no upcasting) still sees the original shape on disk —
|
|
260
|
+
// upcasting is a read-time transform, writes stay immutable.
|
|
261
|
+
const raw = await loadAggregateRaw(stack.db, invoiceId, admin.tenantId);
|
|
262
|
+
const rawApproved = raw.find((e) => e.type === "asoftest:event:approved");
|
|
263
|
+
expect(rawApproved?.eventVersion).toBe(1);
|
|
264
|
+
expect(rawApproved?.payload).toEqual({ amount: "42.50", approvedBy: "legacy" });
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Runde 2 / C.4 — MSP errorMode.continuous.skipApplyErrors
|
|
2
|
+
//
|
|
3
|
+
// Default (strict) behaviour: a throwing apply retries up to maxAttempts,
|
|
4
|
+
// then the consumer status flips to "dead" and delivery pauses. Correct for
|
|
5
|
+
// read-models that must stay consistent.
|
|
6
|
+
//
|
|
7
|
+
// For best-effort sinks (notifications, webhooks, metrics fan-out) a single
|
|
8
|
+
// bad event shouldn't stall the whole consumer. skipApplyErrors=true logs the
|
|
9
|
+
// error on the skip counter, advances the cursor, and keeps delivering. The
|
|
10
|
+
// consumer stays "idle".
|
|
11
|
+
|
|
12
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
13
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
14
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
15
|
+
import { defineFeature } from "../../engine";
|
|
16
|
+
import { getConsumerState } from "../../pipeline";
|
|
17
|
+
import {
|
|
18
|
+
createEntityTable,
|
|
19
|
+
resetEventStore,
|
|
20
|
+
setupTestStack,
|
|
21
|
+
type TestStack,
|
|
22
|
+
TestUsers,
|
|
23
|
+
} from "../../stack";
|
|
24
|
+
import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
|
|
25
|
+
|
|
26
|
+
// --- Feature ---
|
|
27
|
+
|
|
28
|
+
const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
|
|
29
|
+
entityName: "widget",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Two MSPs reacting to the same event type:
|
|
33
|
+
// - strict: default behavior, poison event kills the consumer
|
|
34
|
+
// - lenient: skipApplyErrors, poison event gets skipped, delivery continues
|
|
35
|
+
// Poison selector is by payload.name so each test can inject a bad event
|
|
36
|
+
// at a known position in the stream.
|
|
37
|
+
const POISON_MARKER = "poison";
|
|
38
|
+
|
|
39
|
+
const strictObserved: string[] = [];
|
|
40
|
+
const lenientObserved: string[] = [];
|
|
41
|
+
|
|
42
|
+
const z2Feature = defineFeature("errmode", (r) => {
|
|
43
|
+
r.entity("widget", sharedWidgetEntity);
|
|
44
|
+
|
|
45
|
+
r.multiStreamProjection({
|
|
46
|
+
name: "strict",
|
|
47
|
+
apply: {
|
|
48
|
+
"widget.created": async (event) => {
|
|
49
|
+
const name = event.payload["name"] as string;
|
|
50
|
+
if (name === POISON_MARKER) throw new Error("boom-strict");
|
|
51
|
+
strictObserved.push(name);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
// errorMode omitted → default strict
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
r.multiStreamProjection({
|
|
58
|
+
name: "lenient",
|
|
59
|
+
apply: {
|
|
60
|
+
"widget.created": async (event) => {
|
|
61
|
+
const name = event.payload["name"] as string;
|
|
62
|
+
if (name === POISON_MARKER) throw new Error("boom-lenient");
|
|
63
|
+
lenientObserved.push(name);
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
errorMode: { continuous: { skipApplyErrors: true } },
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// --- Stack ---
|
|
71
|
+
|
|
72
|
+
let stack: TestStack;
|
|
73
|
+
let tdb: TenantDb;
|
|
74
|
+
const admin = TestUsers.admin;
|
|
75
|
+
const strictQn = "errmode:projection:strict";
|
|
76
|
+
const lenientQn = "errmode:projection:lenient";
|
|
77
|
+
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
stack = await setupTestStack({
|
|
80
|
+
features: [z2Feature],
|
|
81
|
+
systemHooks: [],
|
|
82
|
+
});
|
|
83
|
+
await createEntityTable(stack.db, sharedWidgetEntity, "widget");
|
|
84
|
+
tdb = createTenantDb(stack.db, admin.tenantId);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
await stack.cleanup();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
strictObserved.length = 0;
|
|
93
|
+
lenientObserved.length = 0;
|
|
94
|
+
await resetEventStore(stack, ["read_widgets"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
async function appendWidget(name: string): Promise<void> {
|
|
98
|
+
await executor.create({ name }, admin, tdb);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Tests ---
|
|
102
|
+
|
|
103
|
+
describe("Runde 2 / C.4 — MSP skipApplyErrors", () => {
|
|
104
|
+
test("default strict: poison event halts the consumer, cursor stops at predecessor", async () => {
|
|
105
|
+
await appendWidget("a");
|
|
106
|
+
await appendWidget(POISON_MARKER);
|
|
107
|
+
await appendWidget("c"); // would only be delivered AFTER the poison is resolved
|
|
108
|
+
|
|
109
|
+
// Drive the dispatcher hard enough to exhaust maxAttempts on the poison.
|
|
110
|
+
for (let i = 0; i < 15; i++) {
|
|
111
|
+
await stack.eventDispatcher?.runOnce();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Strict consumer saw "a" but nothing past it — the poison blocked it.
|
|
115
|
+
expect(strictObserved).toEqual(["a"]);
|
|
116
|
+
const state = await getConsumerState(stack.db, strictQn);
|
|
117
|
+
expect(state?.status).toBe("dead");
|
|
118
|
+
expect(state?.lastError).toMatch(/boom-strict/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("lenient: poison event is skipped, cursor advances, later events are delivered", async () => {
|
|
122
|
+
await appendWidget("a");
|
|
123
|
+
await appendWidget(POISON_MARKER);
|
|
124
|
+
await appendWidget("c");
|
|
125
|
+
|
|
126
|
+
await stack.eventDispatcher?.runOnce();
|
|
127
|
+
|
|
128
|
+
// Lenient consumer skipped the poison and saw "c".
|
|
129
|
+
expect(lenientObserved).toEqual(["a", "c"]);
|
|
130
|
+
// State stays idle — no dead-letter, no retry.
|
|
131
|
+
const state = await getConsumerState(stack.db, lenientQn);
|
|
132
|
+
expect(state?.status).toBe("idle");
|
|
133
|
+
// Cursor advanced past all three events (latest event id = 3).
|
|
134
|
+
expect(state?.lastProcessedEventId).toBe(3n);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("lenient: multiple poison events in a row all get skipped", async () => {
|
|
138
|
+
await appendWidget(POISON_MARKER);
|
|
139
|
+
await appendWidget(POISON_MARKER);
|
|
140
|
+
await appendWidget("survivor");
|
|
141
|
+
|
|
142
|
+
await stack.eventDispatcher?.runOnce();
|
|
143
|
+
|
|
144
|
+
expect(lenientObserved).toEqual(["survivor"]);
|
|
145
|
+
const state = await getConsumerState(stack.db, lenientQn);
|
|
146
|
+
expect(state?.status).toBe("idle");
|
|
147
|
+
expect(state?.lastProcessedEventId).toBe(3n);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Runde 3 / C.2b — MSP-apply ctx.appendEvent cascades (saga / process-manager).
|
|
2
|
+
//
|
|
3
|
+
// Claims pinned here:
|
|
4
|
+
// 1. MSP-apply receives an optional 3rd ctx arg with appendEvent + loadAggregate.
|
|
5
|
+
// 2. ctx.appendEvent from inside apply writes a follow-up event into the
|
|
6
|
+
// CURRENT transaction on the aggregate stream the caller picks.
|
|
7
|
+
// 3. The follow-up event inherits correlationId from the triggering event
|
|
8
|
+
// and records causationId = triggering event.id.
|
|
9
|
+
// 4. A second MSP reacting to the follow-up appends its own event, completing
|
|
10
|
+
// a three-hop causal chain (order.placed → order.confirmed → order.shipped).
|
|
11
|
+
// 5. ctx.loadAggregate reads the triggering stream's full history.
|
|
12
|
+
|
|
13
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
16
|
+
import { buildDrizzleTable } from "../../db/table-builder";
|
|
17
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
18
|
+
import { eventsTable } from "../../event-store";
|
|
19
|
+
import {
|
|
20
|
+
createEntityTable,
|
|
21
|
+
resetEventStore,
|
|
22
|
+
setupTestStack,
|
|
23
|
+
type TestStack,
|
|
24
|
+
TestUsers,
|
|
25
|
+
} from "../../stack";
|
|
26
|
+
|
|
27
|
+
// --- Feature ---
|
|
28
|
+
|
|
29
|
+
const orderEntity = createEntity({
|
|
30
|
+
table: "read_mmh_orders",
|
|
31
|
+
fields: {
|
|
32
|
+
item: createTextField({ required: true }),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const orderTable = buildDrizzleTable("mmh-order", orderEntity);
|
|
37
|
+
|
|
38
|
+
// Snapshot what each MSP-apply observed via ctx.loadAggregate.
|
|
39
|
+
const confirmLoadCounts: number[] = [];
|
|
40
|
+
|
|
41
|
+
const mmhFeature = defineFeature("mmh", (r) => {
|
|
42
|
+
r.entity("mmh-order", orderEntity);
|
|
43
|
+
|
|
44
|
+
const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }));
|
|
45
|
+
const confirmed = r.defineEvent("confirmed", z.object({ orderId: z.uuid() }));
|
|
46
|
+
const shipped = r.defineEvent("shipped", z.object({ orderId: z.uuid() }));
|
|
47
|
+
|
|
48
|
+
const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
|
|
49
|
+
entityName: "mmh-order",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
r.writeHandler(
|
|
53
|
+
"order:place",
|
|
54
|
+
z.object({ item: z.string() }),
|
|
55
|
+
async (event, ctx) => {
|
|
56
|
+
const created = await orderExecutor.create({ item: event.payload.item }, event.user, ctx.db);
|
|
57
|
+
if (!created.isSuccess) return created;
|
|
58
|
+
await ctx.appendEventUnsafe({
|
|
59
|
+
aggregateId: String(created.data.id),
|
|
60
|
+
aggregateType: "mmh-order",
|
|
61
|
+
type: placed.name,
|
|
62
|
+
payload: { orderId: String(created.data.id) },
|
|
63
|
+
});
|
|
64
|
+
return created;
|
|
65
|
+
},
|
|
66
|
+
{ access: { roles: ["Admin"] } },
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Hop 1: placed → confirmed. Uses ctx.loadAggregate to observe the current
|
|
70
|
+
// stream before deciding + ctx.appendEvent to cascade.
|
|
71
|
+
r.multiStreamProjection({
|
|
72
|
+
name: "confirm-on-placed",
|
|
73
|
+
apply: {
|
|
74
|
+
[placed.name]: async (event, _tx, ctx) => {
|
|
75
|
+
if (!ctx) throw new Error("MSP-apply ctx missing — regression of C.2b wiring");
|
|
76
|
+
const history = await ctx.loadAggregate(event.aggregateId);
|
|
77
|
+
confirmLoadCounts.push(history.length);
|
|
78
|
+
await ctx.appendEventUnsafe({
|
|
79
|
+
aggregateId: event.aggregateId,
|
|
80
|
+
aggregateType: "mmh-order",
|
|
81
|
+
type: confirmed.name,
|
|
82
|
+
payload: { orderId: event.aggregateId },
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Hop 2: confirmed → shipped.
|
|
89
|
+
r.multiStreamProjection({
|
|
90
|
+
name: "ship-on-confirmed",
|
|
91
|
+
apply: {
|
|
92
|
+
[confirmed.name]: async (event, _tx, ctx) => {
|
|
93
|
+
if (!ctx) throw new Error("MSP-apply ctx missing — regression of C.2b wiring");
|
|
94
|
+
await ctx.appendEventUnsafe({
|
|
95
|
+
aggregateId: event.aggregateId,
|
|
96
|
+
aggregateType: "mmh-order",
|
|
97
|
+
type: shipped.name,
|
|
98
|
+
payload: { orderId: event.aggregateId },
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// --- Stack ---
|
|
106
|
+
|
|
107
|
+
let stack: TestStack;
|
|
108
|
+
const admin = TestUsers.admin;
|
|
109
|
+
|
|
110
|
+
beforeAll(async () => {
|
|
111
|
+
stack = await setupTestStack({ features: [mmhFeature], systemHooks: [] });
|
|
112
|
+
await createEntityTable(stack.db, orderEntity, "mmh-order");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterAll(async () => {
|
|
116
|
+
await stack.cleanup();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(async () => {
|
|
120
|
+
confirmLoadCounts.length = 0;
|
|
121
|
+
await resetEventStore(stack, ["read_mmh_orders"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// --- Helpers ---
|
|
125
|
+
|
|
126
|
+
async function postWrite(correlationId: string, item: string) {
|
|
127
|
+
return stack.http.writeWithHeaders("mmh:write:order:place", { item }, admin, {
|
|
128
|
+
"X-Correlation-ID": correlationId,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function drainUntilShipped(aggregateId: string, maxPasses = 10): Promise<void> {
|
|
133
|
+
for (let i = 0; i < maxPasses; i++) {
|
|
134
|
+
await stack.eventDispatcher?.runOnce();
|
|
135
|
+
const rows = await stack.db.select().from(eventsTable);
|
|
136
|
+
if (rows.some((r) => r.aggregateId === aggregateId && r.type === "mmh:event:shipped")) return;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`drainUntilShipped: never saw shipped event for ${aggregateId}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Tests ---
|
|
142
|
+
|
|
143
|
+
describe("Runde 3 / C.2b — MSP-apply ctx cascades", () => {
|
|
144
|
+
test("single-hop: apply appends a follow-up on the triggering aggregate", async () => {
|
|
145
|
+
const res = await postWrite("hop-1", "widget");
|
|
146
|
+
expect(res.status).toBe(200);
|
|
147
|
+
|
|
148
|
+
await stack.eventDispatcher?.runOnce();
|
|
149
|
+
|
|
150
|
+
const rows = await stack.db.select().from(eventsTable);
|
|
151
|
+
const types = rows.map((r) => r.type).sort();
|
|
152
|
+
// Order: CRUD create, placed, confirmed (hop 1 fired).
|
|
153
|
+
expect(types).toContain("mmh-order.created");
|
|
154
|
+
expect(types).toContain("mmh:event:placed");
|
|
155
|
+
expect(types).toContain("mmh:event:confirmed");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("three-hop chain: all events share correlationId, causation traces placed→confirmed→shipped", async () => {
|
|
159
|
+
await postWrite("chain-xyz", "rotor");
|
|
160
|
+
|
|
161
|
+
// Find the aggregateId from the first placed event.
|
|
162
|
+
await stack.eventDispatcher?.runOnce();
|
|
163
|
+
const placedRow = (await stack.db.select().from(eventsTable)).find(
|
|
164
|
+
(r) => r.type === "mmh:event:placed",
|
|
165
|
+
);
|
|
166
|
+
expect(placedRow).toBeDefined();
|
|
167
|
+
const aggregateId = placedRow?.aggregateId as string;
|
|
168
|
+
|
|
169
|
+
await drainUntilShipped(aggregateId);
|
|
170
|
+
|
|
171
|
+
const rows = (await stack.db.select().from(eventsTable))
|
|
172
|
+
.filter((r) => r.aggregateId === aggregateId)
|
|
173
|
+
.sort((a, b) => Number(a.id - b.id));
|
|
174
|
+
|
|
175
|
+
const placed = rows.find((r) => r.type === "mmh:event:placed");
|
|
176
|
+
const confirmed = rows.find((r) => r.type === "mmh:event:confirmed");
|
|
177
|
+
const shipped = rows.find((r) => r.type === "mmh:event:shipped");
|
|
178
|
+
|
|
179
|
+
expect(placed).toBeDefined();
|
|
180
|
+
expect(confirmed).toBeDefined();
|
|
181
|
+
expect(shipped).toBeDefined();
|
|
182
|
+
|
|
183
|
+
const placedMeta = placed?.metadata as {
|
|
184
|
+
correlationId?: string;
|
|
185
|
+
causationId?: string;
|
|
186
|
+
};
|
|
187
|
+
const confirmedMeta = confirmed?.metadata as {
|
|
188
|
+
correlationId?: string;
|
|
189
|
+
causationId?: string;
|
|
190
|
+
};
|
|
191
|
+
const shippedMeta = shipped?.metadata as {
|
|
192
|
+
correlationId?: string;
|
|
193
|
+
causationId?: string;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// All three carry the same correlationId.
|
|
197
|
+
expect(placedMeta.correlationId).toBe("chain-xyz");
|
|
198
|
+
expect(confirmedMeta.correlationId).toBe("chain-xyz");
|
|
199
|
+
expect(shippedMeta.correlationId).toBe("chain-xyz");
|
|
200
|
+
|
|
201
|
+
// Causation chain: placed is root; each later hop points back to the
|
|
202
|
+
// event that triggered its MSP.
|
|
203
|
+
expect(placedMeta.causationId).toBeUndefined();
|
|
204
|
+
expect(confirmedMeta.causationId).toBe(String(placed?.id));
|
|
205
|
+
expect(shippedMeta.causationId).toBe(String(confirmed?.id));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("ctx.loadAggregate inside apply sees the stream as it was when the trigger event landed", async () => {
|
|
209
|
+
await postWrite("load-chk", "sprocket");
|
|
210
|
+
await stack.eventDispatcher?.runOnce();
|
|
211
|
+
|
|
212
|
+
// The confirm-apply loads the stream on each invocation. At the time
|
|
213
|
+
// the placed event fires, the stream has exactly 2 events: the CRUD
|
|
214
|
+
// create + placed itself.
|
|
215
|
+
expect(confirmLoadCounts).toEqual([2]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("tenant isolation: ctx.appendEvent writes into the triggering event's tenant", async () => {
|
|
219
|
+
await postWrite("tenant-iso", "gear");
|
|
220
|
+
await stack.eventDispatcher?.runOnce();
|
|
221
|
+
|
|
222
|
+
// Every event written on this chain belongs to the admin's tenant.
|
|
223
|
+
const rows = await stack.db.select().from(eventsTable);
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
expect(row.tenantId).toBe(admin.tenantId);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|