@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,368 @@
|
|
|
1
|
+
// MSP-Rebuild — symmetric to projection-rebuild.integration.ts, exercises
|
|
2
|
+
// the MultiStreamProjection rebuild path:
|
|
3
|
+
//
|
|
4
|
+
// 1. Live → drain → read-model X; rebuild → read-model X (idempotent).
|
|
5
|
+
// 2. Corrupt read-model after live drain → rebuild restores the correct
|
|
6
|
+
// state (load-bearing "read-models are rebuildable" claim).
|
|
7
|
+
// 3. Side-effect MSP (no table) → rebuild rejects with a clear error.
|
|
8
|
+
// 4. Saga-style MSP emitting ctx.appendEvent → rebuild rejects when the
|
|
9
|
+
// apply reaches appendEvent (replaying events that already live in
|
|
10
|
+
// the log would be a double-write).
|
|
11
|
+
// 5. cursor is advanced to the last processed event after rebuild so
|
|
12
|
+
// live dispatcher passes don't redeliver what rebuild consumed.
|
|
13
|
+
//
|
|
14
|
+
// Deliberately separate from projection-rebuild.integration.ts because MSPs
|
|
15
|
+
// carry a different state row (kumiko_event_consumers, not kumiko_projections)
|
|
16
|
+
// and a different apply signature (3rd ctx arg).
|
|
17
|
+
|
|
18
|
+
import { eq, sql } from "drizzle-orm";
|
|
19
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { integer as pgInteger, table as pgTable, uuid as pgUuid } from "../../db/dialect";
|
|
22
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
23
|
+
import { buildDrizzleTable } from "../../db/table-builder";
|
|
24
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
25
|
+
import {
|
|
26
|
+
eventConsumerStateTable,
|
|
27
|
+
getConsumerState,
|
|
28
|
+
rebuildMultiStreamProjection,
|
|
29
|
+
} from "../../pipeline";
|
|
30
|
+
import {
|
|
31
|
+
createEntityTable,
|
|
32
|
+
resetEventStore,
|
|
33
|
+
setupTestStack,
|
|
34
|
+
type TestStack,
|
|
35
|
+
TestUsers,
|
|
36
|
+
} from "../../stack";
|
|
37
|
+
|
|
38
|
+
// --- Fixtures: two aggregates feeding one MSP + two cornered MSPs ---
|
|
39
|
+
|
|
40
|
+
const invoiceEntity = createEntity({
|
|
41
|
+
table: "read_mspreb_invoices",
|
|
42
|
+
fields: { customer: createTextField({ required: true }) },
|
|
43
|
+
});
|
|
44
|
+
const invoiceTable = buildDrizzleTable("msp-reb-invoice", invoiceEntity);
|
|
45
|
+
|
|
46
|
+
const paymentEntity = createEntity({
|
|
47
|
+
table: "read_mspreb_payments",
|
|
48
|
+
fields: { customer: createTextField({ required: true }) },
|
|
49
|
+
});
|
|
50
|
+
const paymentTable = buildDrizzleTable("msp-reb-payment", paymentEntity);
|
|
51
|
+
|
|
52
|
+
// Main read-model: running balance per customer.
|
|
53
|
+
const balanceTable = pgTable("read_mspreb_balance", {
|
|
54
|
+
customer: pgUuid("customer").primaryKey(),
|
|
55
|
+
tenantId: pgUuid("tenant_id").notNull(),
|
|
56
|
+
invoicesCents: pgInteger("invoices_cents").notNull().default(0),
|
|
57
|
+
paymentsCents: pgInteger("payments_cents").notNull().default(0),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Saga-MSP read-model — never actually written, exists to satisfy the
|
|
61
|
+
// `table` requirement. The apply below calls ctx.appendEvent; during
|
|
62
|
+
// rebuild that call must throw.
|
|
63
|
+
const sagaStateTable = pgTable("read_mspreb_saga_state", {
|
|
64
|
+
id: pgUuid("id").primaryKey(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const feature = defineFeature("mspreb", (r) => {
|
|
68
|
+
r.entity("msp-reb-invoice", invoiceEntity);
|
|
69
|
+
r.entity("msp-reb-payment", paymentEntity);
|
|
70
|
+
|
|
71
|
+
const invoiceBilled = r.defineEvent(
|
|
72
|
+
"invoice-billed",
|
|
73
|
+
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
74
|
+
);
|
|
75
|
+
const paymentReceived = r.defineEvent(
|
|
76
|
+
"payment-received",
|
|
77
|
+
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
78
|
+
);
|
|
79
|
+
const escalationTriggered = r.defineEvent(
|
|
80
|
+
"escalation-triggered",
|
|
81
|
+
z.object({ customer: z.uuid() }),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// 1) Main rebuildable MSP — table materialized from two event types.
|
|
85
|
+
r.multiStreamProjection({
|
|
86
|
+
name: "customer-balance",
|
|
87
|
+
table: balanceTable,
|
|
88
|
+
apply: {
|
|
89
|
+
[invoiceBilled.name]: async (event, tx) => {
|
|
90
|
+
const p = event.payload as { customer: string; cents: number };
|
|
91
|
+
await tx
|
|
92
|
+
.insert(balanceTable)
|
|
93
|
+
.values({
|
|
94
|
+
customer: p.customer,
|
|
95
|
+
tenantId: event.tenantId,
|
|
96
|
+
invoicesCents: p.cents,
|
|
97
|
+
paymentsCents: 0,
|
|
98
|
+
})
|
|
99
|
+
.onConflictDoUpdate({
|
|
100
|
+
target: balanceTable.customer,
|
|
101
|
+
set: { invoicesCents: sql`${balanceTable.invoicesCents} + ${p.cents}` },
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
[paymentReceived.name]: async (event, tx) => {
|
|
105
|
+
const p = event.payload as { customer: string; cents: number };
|
|
106
|
+
await tx
|
|
107
|
+
.insert(balanceTable)
|
|
108
|
+
.values({
|
|
109
|
+
customer: p.customer,
|
|
110
|
+
tenantId: event.tenantId,
|
|
111
|
+
invoicesCents: 0,
|
|
112
|
+
paymentsCents: p.cents,
|
|
113
|
+
})
|
|
114
|
+
.onConflictDoUpdate({
|
|
115
|
+
target: balanceTable.customer,
|
|
116
|
+
set: { paymentsCents: sql`${balanceTable.paymentsCents} + ${p.cents}` },
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// 2) Side-effect-only MSP: no table. rebuild must reject.
|
|
123
|
+
r.multiStreamProjection({
|
|
124
|
+
name: "webhook-sink",
|
|
125
|
+
apply: {
|
|
126
|
+
[invoiceBilled.name]: async () => {
|
|
127
|
+
// would post to an external webhook; the test never exercises live
|
|
128
|
+
// delivery of this one — only the rebuild rejection path.
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// 3) Saga-style MSP: has a table (so rebuild is allowed to start) but
|
|
134
|
+
// the apply calls ctx.appendEvent. Rebuild must throw when it reaches
|
|
135
|
+
// that call.
|
|
136
|
+
r.multiStreamProjection({
|
|
137
|
+
name: "saga-emitter",
|
|
138
|
+
table: sagaStateTable,
|
|
139
|
+
apply: {
|
|
140
|
+
[invoiceBilled.name]: async (event, _tx, ctx) => {
|
|
141
|
+
const p = event.payload as { customer: string };
|
|
142
|
+
await ctx.appendEventUnsafe({
|
|
143
|
+
aggregateId: p.customer,
|
|
144
|
+
aggregateType: "msp-reb-invoice",
|
|
145
|
+
type: escalationTriggered.name,
|
|
146
|
+
payload: { customer: p.customer },
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const invoiceExecutor = createEventStoreExecutor(invoiceTable, invoiceEntity, {
|
|
153
|
+
entityName: "msp-reb-invoice",
|
|
154
|
+
});
|
|
155
|
+
const paymentExecutor = createEventStoreExecutor(paymentTable, paymentEntity, {
|
|
156
|
+
entityName: "msp-reb-payment",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
r.writeHandler(
|
|
160
|
+
"invoice:bill",
|
|
161
|
+
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
162
|
+
async (event, ctx) => {
|
|
163
|
+
const res = await invoiceExecutor.create(
|
|
164
|
+
{ customer: event.payload.customer },
|
|
165
|
+
event.user,
|
|
166
|
+
ctx.db,
|
|
167
|
+
);
|
|
168
|
+
if (!res.isSuccess) return res;
|
|
169
|
+
await ctx.appendEventUnsafe({
|
|
170
|
+
aggregateId: String(res.data.id),
|
|
171
|
+
aggregateType: "msp-reb-invoice",
|
|
172
|
+
type: invoiceBilled.name,
|
|
173
|
+
payload: event.payload,
|
|
174
|
+
});
|
|
175
|
+
return res;
|
|
176
|
+
},
|
|
177
|
+
{ access: { roles: ["Admin"] } },
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
r.writeHandler(
|
|
181
|
+
"payment:receive",
|
|
182
|
+
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
183
|
+
async (event, ctx) => {
|
|
184
|
+
const res = await paymentExecutor.create(
|
|
185
|
+
{ customer: event.payload.customer },
|
|
186
|
+
event.user,
|
|
187
|
+
ctx.db,
|
|
188
|
+
);
|
|
189
|
+
if (!res.isSuccess) return res;
|
|
190
|
+
await ctx.appendEventUnsafe({
|
|
191
|
+
aggregateId: String(res.data.id),
|
|
192
|
+
aggregateType: "msp-reb-payment",
|
|
193
|
+
type: paymentReceived.name,
|
|
194
|
+
payload: event.payload,
|
|
195
|
+
});
|
|
196
|
+
return res;
|
|
197
|
+
},
|
|
198
|
+
{ access: { roles: ["Admin"] } },
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const BALANCE_MSP = "mspreb:projection:customer-balance";
|
|
203
|
+
const WEBHOOK_MSP = "mspreb:projection:webhook-sink";
|
|
204
|
+
const SAGA_MSP = "mspreb:projection:saga-emitter";
|
|
205
|
+
|
|
206
|
+
const admin = TestUsers.admin;
|
|
207
|
+
let stack: TestStack;
|
|
208
|
+
|
|
209
|
+
beforeAll(async () => {
|
|
210
|
+
stack = await setupTestStack({
|
|
211
|
+
features: [feature],
|
|
212
|
+
systemHooks: [],
|
|
213
|
+
});
|
|
214
|
+
await createEntityTable(stack.db, invoiceEntity, "msp-reb-invoice");
|
|
215
|
+
await createEntityTable(stack.db, paymentEntity, "msp-reb-payment");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
afterAll(async () => {
|
|
219
|
+
await stack.cleanup();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
afterEach(async () => {
|
|
223
|
+
// Disable the saga MSP before truncating so its live pass doesn't land on
|
|
224
|
+
// a half-torn-down state between tests. The webhook MSP is idempotent for
|
|
225
|
+
// our purposes — it has no state to leak.
|
|
226
|
+
await resetEventStore(stack, [
|
|
227
|
+
"read_mspreb_invoices",
|
|
228
|
+
"read_mspreb_payments",
|
|
229
|
+
"read_mspreb_balance",
|
|
230
|
+
"read_mspreb_saga_state",
|
|
231
|
+
]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// --- Helpers ---
|
|
235
|
+
|
|
236
|
+
async function runFullDispatcher(): Promise<void> {
|
|
237
|
+
// Drain the dispatcher twice — first pass delivers new events, second pass
|
|
238
|
+
// confirms cursor was saved (no redelivery).
|
|
239
|
+
await stack.eventDispatcher?.runOnce();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- Tests ---
|
|
243
|
+
|
|
244
|
+
describe("rebuildMultiStreamProjection — rebuildable read-model", () => {
|
|
245
|
+
test("rebuild from live-produced log re-materializes the exact same state", async () => {
|
|
246
|
+
const alice = "00000000-0000-4000-8000-000000000a01";
|
|
247
|
+
const bob = "00000000-0000-4000-8000-000000000b02";
|
|
248
|
+
// Disable the saga MSP for this test — it runs on the same event types
|
|
249
|
+
// and would trip its own ctx.appendEvent path during live delivery
|
|
250
|
+
// (which is fine in production, but noise here).
|
|
251
|
+
await stack.db
|
|
252
|
+
.update(eventConsumerStateTable)
|
|
253
|
+
.set({ status: "disabled", updatedAt: sql`now()` })
|
|
254
|
+
.where(eq(eventConsumerStateTable.name, SAGA_MSP));
|
|
255
|
+
|
|
256
|
+
await stack.http.writeOk("mspreb:write:invoice:bill", { customer: alice, cents: 10_00 }, admin);
|
|
257
|
+
await stack.http.writeOk("mspreb:write:invoice:bill", { customer: alice, cents: 5_00 }, admin);
|
|
258
|
+
await stack.http.writeOk(
|
|
259
|
+
"mspreb:write:payment:receive",
|
|
260
|
+
{ customer: alice, cents: 3_00 },
|
|
261
|
+
admin,
|
|
262
|
+
);
|
|
263
|
+
await stack.http.writeOk("mspreb:write:invoice:bill", { customer: bob, cents: 7_50 }, admin);
|
|
264
|
+
await runFullDispatcher();
|
|
265
|
+
|
|
266
|
+
const liveRows = await stack.db.select().from(balanceTable).orderBy(balanceTable.customer);
|
|
267
|
+
const aliceLive = liveRows.find((r) => r.customer === alice);
|
|
268
|
+
const bobLive = liveRows.find((r) => r.customer === bob);
|
|
269
|
+
expect(aliceLive).toMatchObject({ invoicesCents: 15_00, paymentsCents: 3_00 });
|
|
270
|
+
expect(bobLive).toMatchObject({ invoicesCents: 7_50, paymentsCents: 0 });
|
|
271
|
+
|
|
272
|
+
// Rebuild — the table is TRUNCATEd inside the rebuild TX, then events
|
|
273
|
+
// are replayed in-order. Final state must equal the live state.
|
|
274
|
+
const result = await rebuildMultiStreamProjection(BALANCE_MSP, {
|
|
275
|
+
db: stack.db,
|
|
276
|
+
registry: stack.registry,
|
|
277
|
+
});
|
|
278
|
+
expect(result.projection).toBe(BALANCE_MSP);
|
|
279
|
+
expect(result.eventsProcessed).toBe(4); // 2 invoices + 1 payment + 1 invoice
|
|
280
|
+
expect(result.lastProcessedEventId).toBeGreaterThan(0n);
|
|
281
|
+
|
|
282
|
+
const rebuiltRows = await stack.db.select().from(balanceTable).orderBy(balanceTable.customer);
|
|
283
|
+
expect(rebuiltRows).toEqual(liveRows);
|
|
284
|
+
|
|
285
|
+
// Consumer cursor is at head after rebuild — the live dispatcher should
|
|
286
|
+
// NOT redeliver those events on its next pass.
|
|
287
|
+
const state = await getConsumerState(stack.db, BALANCE_MSP);
|
|
288
|
+
expect(state?.status).toBe("idle");
|
|
289
|
+
expect(state?.lastProcessedEventId).toBe(result.lastProcessedEventId);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("rebuild after table corruption restores the correct state", async () => {
|
|
293
|
+
const carol = "00000000-0000-4000-8000-000000000c03";
|
|
294
|
+
await stack.db
|
|
295
|
+
.update(eventConsumerStateTable)
|
|
296
|
+
.set({ status: "disabled", updatedAt: sql`now()` })
|
|
297
|
+
.where(eq(eventConsumerStateTable.name, SAGA_MSP));
|
|
298
|
+
await stack.http.writeOk("mspreb:write:invoice:bill", { customer: carol, cents: 42_00 }, admin);
|
|
299
|
+
await runFullDispatcher();
|
|
300
|
+
|
|
301
|
+
// Corrupt the read-model — simulate a buggy apply() landing bad numbers.
|
|
302
|
+
await stack.db
|
|
303
|
+
.update(balanceTable)
|
|
304
|
+
.set({ invoicesCents: -999, paymentsCents: 999 })
|
|
305
|
+
.where(eq(balanceTable.customer, carol));
|
|
306
|
+
|
|
307
|
+
await rebuildMultiStreamProjection(BALANCE_MSP, {
|
|
308
|
+
db: stack.db,
|
|
309
|
+
registry: stack.registry,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const [row] = await stack.db
|
|
313
|
+
.select()
|
|
314
|
+
.from(balanceTable)
|
|
315
|
+
.where(eq(balanceTable.customer, carol));
|
|
316
|
+
expect(row).toMatchObject({ invoicesCents: 42_00, paymentsCents: 0 });
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("rebuildMultiStreamProjection — guard rails", () => {
|
|
321
|
+
test("side-effect MSP (no table) is rejected with a clear error", async () => {
|
|
322
|
+
await expect(
|
|
323
|
+
rebuildMultiStreamProjection(WEBHOOK_MSP, {
|
|
324
|
+
db: stack.db,
|
|
325
|
+
registry: stack.registry,
|
|
326
|
+
}),
|
|
327
|
+
).rejects.toThrow(/no backing table|side-effect/i);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("unknown MSP name lists the known ones in the error", async () => {
|
|
331
|
+
await expect(
|
|
332
|
+
rebuildMultiStreamProjection("does:not:exist", {
|
|
333
|
+
db: stack.db,
|
|
334
|
+
registry: stack.registry,
|
|
335
|
+
}),
|
|
336
|
+
).rejects.toThrow(/not registered/i);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("saga MSP using ctx.appendEvent fails rebuild at the first appendEvent call", async () => {
|
|
340
|
+
const dave = "00000000-0000-4000-8000-000000000d04";
|
|
341
|
+
// Disable the saga in live passes so we control when the apply runs.
|
|
342
|
+
await stack.db
|
|
343
|
+
.update(eventConsumerStateTable)
|
|
344
|
+
.set({ status: "disabled", updatedAt: sql`now()` })
|
|
345
|
+
.where(eq(eventConsumerStateTable.name, SAGA_MSP));
|
|
346
|
+
await stack.http.writeOk("mspreb:write:invoice:bill", { customer: dave, cents: 1_00 }, admin);
|
|
347
|
+
// Put the consumer back to idle so rebuild doesn't treat it as "just
|
|
348
|
+
// disabled on purpose" — rebuild is opinionated about WHEN it refuses,
|
|
349
|
+
// not about the consumer's live-status.
|
|
350
|
+
await stack.db
|
|
351
|
+
.update(eventConsumerStateTable)
|
|
352
|
+
.set({ status: "idle", updatedAt: sql`now()` })
|
|
353
|
+
.where(eq(eventConsumerStateTable.name, SAGA_MSP));
|
|
354
|
+
|
|
355
|
+
await expect(
|
|
356
|
+
rebuildMultiStreamProjection(SAGA_MSP, {
|
|
357
|
+
db: stack.db,
|
|
358
|
+
registry: stack.registry,
|
|
359
|
+
}),
|
|
360
|
+
).rejects.toThrow(/appendEvent.*not supported during rebuild/);
|
|
361
|
+
|
|
362
|
+
// Failure path: outer catch wrote status="dead" + lastError — ops sees
|
|
363
|
+
// the break after the TX rolled back.
|
|
364
|
+
const state = await getConsumerState(stack.db, SAGA_MSP);
|
|
365
|
+
expect(state?.status).toBe("dead");
|
|
366
|
+
expect(state?.lastError).toMatch(/appendEvent/);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// C3 — r.multiStreamProjection (Marten-aligned, async-only).
|
|
2
|
+
//
|
|
3
|
+
// The cross-aggregate read model. A single MSP reacts to events from many
|
|
4
|
+
// streams, groups by an identity the apply handler extracts from the
|
|
5
|
+
// payload, and materializes into one projection table. Runs async via the
|
|
6
|
+
// event-dispatcher — at-least-once delivery, strictly ordered by events.id
|
|
7
|
+
// per MSP consumer, dead-letters on repeated handler failures.
|
|
8
|
+
|
|
9
|
+
import { eq, sql } from "drizzle-orm";
|
|
10
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { integer as pgInteger, table as pgTable, uuid as pgUuid } from "../../db/dialect";
|
|
13
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
14
|
+
import { buildDrizzleTable } from "../../db/table-builder";
|
|
15
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
16
|
+
import {
|
|
17
|
+
createEntityTable,
|
|
18
|
+
createTestUser,
|
|
19
|
+
resetEventStore,
|
|
20
|
+
setupTestStack,
|
|
21
|
+
type TestStack,
|
|
22
|
+
TestUsers,
|
|
23
|
+
} from "../../stack";
|
|
24
|
+
|
|
25
|
+
// --- Two aggregate types that feed one MSP ---
|
|
26
|
+
|
|
27
|
+
const shipmentEntity = createEntity({
|
|
28
|
+
table: "read_msp_shipments",
|
|
29
|
+
fields: { customer: createTextField({ required: true }) },
|
|
30
|
+
});
|
|
31
|
+
const shipmentTable = buildDrizzleTable("msp-shipment", shipmentEntity);
|
|
32
|
+
|
|
33
|
+
const refundEntity = createEntity({
|
|
34
|
+
table: "read_msp_refunds",
|
|
35
|
+
fields: { customer: createTextField({ required: true }) },
|
|
36
|
+
});
|
|
37
|
+
const refundTable = buildDrizzleTable("msp-refund", refundEntity);
|
|
38
|
+
|
|
39
|
+
// Cross-cutting MSP: one row per customer, sums shipments − refunds. Key
|
|
40
|
+
// differences from a single-stream projection:
|
|
41
|
+
// - feeds off TWO aggregate types (shipment + refund), no shared entity
|
|
42
|
+
// - identity key (customer UUID) lives in the event payload, not
|
|
43
|
+
// aggregate_id — extracted inside the apply handler
|
|
44
|
+
const customerBalanceTable = pgTable("read_msp_customer_balance", {
|
|
45
|
+
customer: pgUuid("customer").primaryKey(),
|
|
46
|
+
tenantId: pgUuid("tenant_id").notNull(),
|
|
47
|
+
shipments: pgInteger("shipments").notNull().default(0),
|
|
48
|
+
refunds: pgInteger("refunds").notNull().default(0),
|
|
49
|
+
netCents: pgInteger("net_cents").notNull().default(0),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const mspFeature = defineFeature("msptest", (r) => {
|
|
53
|
+
r.entity("msp-shipment", shipmentEntity);
|
|
54
|
+
r.entity("msp-refund", refundEntity);
|
|
55
|
+
|
|
56
|
+
const shipmentBilled = r.defineEvent(
|
|
57
|
+
"shipment-billed",
|
|
58
|
+
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
59
|
+
);
|
|
60
|
+
const refundIssued = r.defineEvent(
|
|
61
|
+
"refund-issued",
|
|
62
|
+
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
r.multiStreamProjection({
|
|
66
|
+
name: "customer-balance",
|
|
67
|
+
table: customerBalanceTable,
|
|
68
|
+
apply: {
|
|
69
|
+
[shipmentBilled.name]: async (event, tx) => {
|
|
70
|
+
const p = event.payload as { customer: string; cents: number };
|
|
71
|
+
await tx
|
|
72
|
+
.insert(customerBalanceTable)
|
|
73
|
+
.values({
|
|
74
|
+
customer: p.customer,
|
|
75
|
+
tenantId: event.tenantId,
|
|
76
|
+
shipments: 1,
|
|
77
|
+
refunds: 0,
|
|
78
|
+
netCents: p.cents,
|
|
79
|
+
})
|
|
80
|
+
.onConflictDoUpdate({
|
|
81
|
+
target: customerBalanceTable.customer,
|
|
82
|
+
set: {
|
|
83
|
+
shipments: sql`${customerBalanceTable.shipments} + 1`,
|
|
84
|
+
netCents: sql`${customerBalanceTable.netCents} + ${p.cents}`,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
[refundIssued.name]: async (event, tx) => {
|
|
89
|
+
const p = event.payload as { customer: string; cents: number };
|
|
90
|
+
await tx
|
|
91
|
+
.insert(customerBalanceTable)
|
|
92
|
+
.values({
|
|
93
|
+
customer: p.customer,
|
|
94
|
+
tenantId: event.tenantId,
|
|
95
|
+
shipments: 0,
|
|
96
|
+
refunds: 1,
|
|
97
|
+
netCents: -p.cents,
|
|
98
|
+
})
|
|
99
|
+
.onConflictDoUpdate({
|
|
100
|
+
target: customerBalanceTable.customer,
|
|
101
|
+
set: {
|
|
102
|
+
refunds: sql`${customerBalanceTable.refunds} + 1`,
|
|
103
|
+
netCents: sql`${customerBalanceTable.netCents} - ${p.cents}`,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const shipmentExecutor = createEventStoreExecutor(shipmentTable, shipmentEntity, {
|
|
111
|
+
entityName: "msp-shipment",
|
|
112
|
+
});
|
|
113
|
+
const refundExecutor = createEventStoreExecutor(refundTable, refundEntity, {
|
|
114
|
+
entityName: "msp-refund",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
r.writeHandler(
|
|
118
|
+
"shipment:bill",
|
|
119
|
+
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
120
|
+
async (event, ctx) => {
|
|
121
|
+
const res = await shipmentExecutor.create(
|
|
122
|
+
{ customer: event.payload.customer },
|
|
123
|
+
event.user,
|
|
124
|
+
ctx.db,
|
|
125
|
+
);
|
|
126
|
+
if (!res.isSuccess) return res;
|
|
127
|
+
await ctx.appendEventUnsafe({
|
|
128
|
+
aggregateId: String(res.data.id),
|
|
129
|
+
aggregateType: "msp-shipment",
|
|
130
|
+
type: shipmentBilled.name,
|
|
131
|
+
payload: { customer: event.payload.customer, cents: event.payload.cents },
|
|
132
|
+
});
|
|
133
|
+
return res;
|
|
134
|
+
},
|
|
135
|
+
{ access: { roles: ["Admin"] } },
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
r.writeHandler(
|
|
139
|
+
"refund:issue",
|
|
140
|
+
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
141
|
+
async (event, ctx) => {
|
|
142
|
+
const res = await refundExecutor.create(
|
|
143
|
+
{ customer: event.payload.customer },
|
|
144
|
+
event.user,
|
|
145
|
+
ctx.db,
|
|
146
|
+
);
|
|
147
|
+
if (!res.isSuccess) return res;
|
|
148
|
+
await ctx.appendEventUnsafe({
|
|
149
|
+
aggregateId: String(res.data.id),
|
|
150
|
+
aggregateType: "msp-refund",
|
|
151
|
+
type: refundIssued.name,
|
|
152
|
+
payload: { customer: event.payload.customer, cents: event.payload.cents },
|
|
153
|
+
});
|
|
154
|
+
return res;
|
|
155
|
+
},
|
|
156
|
+
{ access: { roles: ["Admin"] } },
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
let stack: TestStack;
|
|
161
|
+
const admin = TestUsers.admin;
|
|
162
|
+
|
|
163
|
+
beforeAll(async () => {
|
|
164
|
+
stack = await setupTestStack({ features: [mspFeature], systemHooks: [] });
|
|
165
|
+
await createEntityTable(stack.db, shipmentEntity, "msp-shipment");
|
|
166
|
+
await createEntityTable(stack.db, refundEntity, "msp-refund");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterAll(async () => {
|
|
170
|
+
await stack.cleanup();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
afterEach(async () => {
|
|
174
|
+
await resetEventStore(stack, [
|
|
175
|
+
"read_msp_shipments",
|
|
176
|
+
"read_msp_refunds",
|
|
177
|
+
"read_msp_customer_balance",
|
|
178
|
+
]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("r.multiStreamProjection — Marten MultiStreamProjection equivalent", () => {
|
|
182
|
+
test("events from two aggregate types roll up into one customer row", async () => {
|
|
183
|
+
const customerA = "00000000-0000-4000-8000-000000000a11";
|
|
184
|
+
const customerB = "00000000-0000-4000-8000-000000000b22";
|
|
185
|
+
|
|
186
|
+
await stack.http.writeOk(
|
|
187
|
+
"msptest:write:shipment:bill",
|
|
188
|
+
{ customer: customerA, cents: 1000 },
|
|
189
|
+
admin,
|
|
190
|
+
);
|
|
191
|
+
await stack.http.writeOk(
|
|
192
|
+
"msptest:write:shipment:bill",
|
|
193
|
+
{ customer: customerA, cents: 500 },
|
|
194
|
+
admin,
|
|
195
|
+
);
|
|
196
|
+
await stack.http.writeOk(
|
|
197
|
+
"msptest:write:refund:issue",
|
|
198
|
+
{ customer: customerA, cents: 200 },
|
|
199
|
+
admin,
|
|
200
|
+
);
|
|
201
|
+
await stack.http.writeOk(
|
|
202
|
+
"msptest:write:shipment:bill",
|
|
203
|
+
{ customer: customerB, cents: 300 },
|
|
204
|
+
admin,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Drain the dispatcher — MSPs run async.
|
|
208
|
+
await stack.eventDispatcher?.runOnce();
|
|
209
|
+
|
|
210
|
+
const rows = await stack.db
|
|
211
|
+
.select()
|
|
212
|
+
.from(customerBalanceTable)
|
|
213
|
+
.orderBy(customerBalanceTable.customer);
|
|
214
|
+
const byCustomer = new Map(rows.map((r) => [r.customer, r]));
|
|
215
|
+
|
|
216
|
+
expect(byCustomer.get(customerA)).toMatchObject({
|
|
217
|
+
shipments: 2,
|
|
218
|
+
refunds: 1,
|
|
219
|
+
netCents: 1300, // 1000 + 500 - 200
|
|
220
|
+
});
|
|
221
|
+
expect(byCustomer.get(customerB)).toMatchObject({
|
|
222
|
+
shipments: 1,
|
|
223
|
+
refunds: 0,
|
|
224
|
+
netCents: 300,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("MSP consumer owns a cursor — second runOnce is a no-op when caught up", async () => {
|
|
229
|
+
const cust = "00000000-0000-4000-8000-000000000c33";
|
|
230
|
+
await stack.http.writeOk("msptest:write:shipment:bill", { customer: cust, cents: 42 }, admin);
|
|
231
|
+
|
|
232
|
+
const pass1 = await stack.eventDispatcher?.runOnce();
|
|
233
|
+
const pass2 = await stack.eventDispatcher?.runOnce();
|
|
234
|
+
|
|
235
|
+
// The MSP consumer processed the single event once; the cursor then
|
|
236
|
+
// holds it, so pass2 sees zero events for that consumer.
|
|
237
|
+
const mspName = "msptest:projection:customer-balance";
|
|
238
|
+
expect(pass1?.byConsumer[mspName]?.processed).toBeGreaterThanOrEqual(1);
|
|
239
|
+
expect(pass2?.byConsumer[mspName]?.processed ?? 0).toBe(0);
|
|
240
|
+
|
|
241
|
+
// Row state is stable across the no-op pass.
|
|
242
|
+
const [row] = await stack.db
|
|
243
|
+
.select()
|
|
244
|
+
.from(customerBalanceTable)
|
|
245
|
+
.where(eq(customerBalanceTable.customer, cust));
|
|
246
|
+
expect(row?.shipments).toBe(1);
|
|
247
|
+
expect(row?.netCents).toBe(42);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("MSP apply receives event.tenantId correctly across tenants (isolation pin)", async () => {
|
|
251
|
+
// Regression pin: MSP apply does NOT get a tenant-scoped TenantDb (the
|
|
252
|
+
// old r.postEvent wrap). Instead the apply handler reads event.tenantId
|
|
253
|
+
// and writes it into the projection row. Verify that two tenants feeding
|
|
254
|
+
// the same MSP land in distinct rows carrying their own tenantId — not
|
|
255
|
+
// cross-tenant leaks, not hardcoded wrong tenant.
|
|
256
|
+
const otherAdmin = createTestUser({
|
|
257
|
+
id: 77,
|
|
258
|
+
roles: ["Admin"],
|
|
259
|
+
tenantId: "00000000-0000-4000-8000-000000000099",
|
|
260
|
+
});
|
|
261
|
+
const customerAlpha = "00000000-0000-4000-8000-000000000aa1";
|
|
262
|
+
const customerBeta = "00000000-0000-4000-8000-000000000bb2";
|
|
263
|
+
|
|
264
|
+
await stack.http.writeOk(
|
|
265
|
+
"msptest:write:shipment:bill",
|
|
266
|
+
{ customer: customerAlpha, cents: 1000 },
|
|
267
|
+
admin,
|
|
268
|
+
);
|
|
269
|
+
await stack.http.writeOk(
|
|
270
|
+
"msptest:write:shipment:bill",
|
|
271
|
+
{ customer: customerBeta, cents: 2000 },
|
|
272
|
+
otherAdmin,
|
|
273
|
+
);
|
|
274
|
+
await stack.eventDispatcher?.runOnce();
|
|
275
|
+
|
|
276
|
+
const rows = await stack.db
|
|
277
|
+
.select()
|
|
278
|
+
.from(customerBalanceTable)
|
|
279
|
+
.orderBy(customerBalanceTable.customer);
|
|
280
|
+
const alpha = rows.find((r) => r.customer === customerAlpha);
|
|
281
|
+
const beta = rows.find((r) => r.customer === customerBeta);
|
|
282
|
+
|
|
283
|
+
expect(alpha).toBeDefined();
|
|
284
|
+
expect(beta).toBeDefined();
|
|
285
|
+
expect(alpha?.tenantId).toBe(admin.tenantId);
|
|
286
|
+
expect(beta?.tenantId).toBe(otherAdmin.tenantId);
|
|
287
|
+
expect(alpha?.netCents).toBe(1000);
|
|
288
|
+
expect(beta?.netCents).toBe(2000);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("events the MSP does not subscribe to pass through untouched", async () => {
|
|
292
|
+
// A pure CRUD create (mspShipment.created) is not in the MSP's apply
|
|
293
|
+
// map — the handler should ignore it without throwing, even though the
|
|
294
|
+
// dispatcher still routes it past the consumer.
|
|
295
|
+
const cust = "00000000-0000-4000-8000-000000000d44";
|
|
296
|
+
await stack.http.writeOk("msptest:write:shipment:bill", { customer: cust, cents: 77 }, admin);
|
|
297
|
+
await stack.eventDispatcher?.runOnce();
|
|
298
|
+
|
|
299
|
+
// Only the shipment-billed event was folded in; the auto "created"
|
|
300
|
+
// event was silently skipped.
|
|
301
|
+
const [row] = await stack.db
|
|
302
|
+
.select()
|
|
303
|
+
.from(customerBalanceTable)
|
|
304
|
+
.where(eq(customerBalanceTable.customer, cust));
|
|
305
|
+
expect(row?.shipments).toBe(1);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("r.multiStreamProjection — registrar validation", () => {
|
|
310
|
+
test("empty apply map is rejected", () => {
|
|
311
|
+
expect(() =>
|
|
312
|
+
defineFeature("mspbad", (r) => {
|
|
313
|
+
r.entity("msp-shipment", shipmentEntity);
|
|
314
|
+
r.multiStreamProjection({
|
|
315
|
+
name: "empty",
|
|
316
|
+
table: customerBalanceTable,
|
|
317
|
+
apply: {},
|
|
318
|
+
});
|
|
319
|
+
}),
|
|
320
|
+
).toThrow(/no apply handlers/);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("name collision with single-stream projection is rejected", () => {
|
|
324
|
+
expect(() =>
|
|
325
|
+
defineFeature("mspcollision", (r) => {
|
|
326
|
+
r.entity("msp-shipment", shipmentEntity);
|
|
327
|
+
r.projection({
|
|
328
|
+
name: "shared",
|
|
329
|
+
source: "msp-shipment",
|
|
330
|
+
table: customerBalanceTable,
|
|
331
|
+
apply: { "msp-shipment.created": async () => {} },
|
|
332
|
+
});
|
|
333
|
+
r.multiStreamProjection({
|
|
334
|
+
name: "shared",
|
|
335
|
+
table: customerBalanceTable,
|
|
336
|
+
apply: { "msptest:event:shipment-billed": async () => {} },
|
|
337
|
+
});
|
|
338
|
+
}),
|
|
339
|
+
).toThrow(/already registered/);
|
|
340
|
+
});
|
|
341
|
+
});
|