@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,220 @@
|
|
|
1
|
+
// Dispatcher lifecycle + observability pins:
|
|
2
|
+
//
|
|
3
|
+
// 1. buildServer returns a live eventDispatcher when consumers are wired.
|
|
4
|
+
// 2. dispatcher.start() delivers without explicit runOnce; a handler
|
|
5
|
+
// slower than pollIntervalMs doesn't queue overlapping passes
|
|
6
|
+
// (passInFlight serialisation).
|
|
7
|
+
// 3. kumiko_event_consumer_lag_events is emitted per pass.
|
|
8
|
+
//
|
|
9
|
+
// History: this file originally also tested r.postEvent's tenant-scoped
|
|
10
|
+
// ctx.db wrap (E.1 "wiring"). Those tests were removed with r.postEvent in
|
|
11
|
+
// E.2 — MSP apply runs against a raw DbRunner and propagates event.tenantId
|
|
12
|
+
// via payload, not via a wrapped DB handle. Tenant-isolation-via-MSP is
|
|
13
|
+
// tested in multi-stream-projection.integration.ts.
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
16
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
17
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
18
|
+
import { defineFeature } from "../../engine";
|
|
19
|
+
import type { StoredEvent } from "../../event-store";
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_SENSITIVE_CONFIG,
|
|
22
|
+
type MetricEvent,
|
|
23
|
+
type ObservabilityProvider,
|
|
24
|
+
RecordingMeter,
|
|
25
|
+
RecordingTracer,
|
|
26
|
+
} from "../../observability";
|
|
27
|
+
import {
|
|
28
|
+
createEntityTable,
|
|
29
|
+
resetEventStore,
|
|
30
|
+
setupTestStack,
|
|
31
|
+
type TestStack,
|
|
32
|
+
TestUsers,
|
|
33
|
+
} from "../../stack";
|
|
34
|
+
import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
|
|
35
|
+
|
|
36
|
+
// --- Test fixtures ---
|
|
37
|
+
|
|
38
|
+
const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
|
|
39
|
+
entityName: "widget",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Capture what the handler sees so we can assert on delivery. Reset in
|
|
43
|
+
// afterEach.
|
|
44
|
+
type Observation = {
|
|
45
|
+
event: StoredEvent;
|
|
46
|
+
};
|
|
47
|
+
let observations: Observation[] = [];
|
|
48
|
+
// A handler that sleeps a controllable amount of time. Drives the
|
|
49
|
+
// slow-handler / passInFlight test.
|
|
50
|
+
let slowHandlerDelayMs = 0;
|
|
51
|
+
let slowHandlerInvocations: Array<{ start: number; end: number }> = [];
|
|
52
|
+
|
|
53
|
+
const wiringFeature = defineFeature("wiring", (r) => {
|
|
54
|
+
r.entity("widget", sharedWidgetEntity);
|
|
55
|
+
|
|
56
|
+
r.multiStreamProjection({
|
|
57
|
+
name: "observer",
|
|
58
|
+
apply: {
|
|
59
|
+
"widget.created": async (event) => {
|
|
60
|
+
observations.push({ event });
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
r.multiStreamProjection({
|
|
66
|
+
name: "slow-observer",
|
|
67
|
+
apply: {
|
|
68
|
+
"widget.created": async () => {
|
|
69
|
+
const start = Date.now();
|
|
70
|
+
if (slowHandlerDelayMs > 0) {
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, slowHandlerDelayMs));
|
|
72
|
+
}
|
|
73
|
+
slowHandlerInvocations.push({ start, end: Date.now() });
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const admin = TestUsers.admin;
|
|
80
|
+
let stack: TestStack;
|
|
81
|
+
let tdb: TenantDb;
|
|
82
|
+
|
|
83
|
+
beforeAll(async () => {
|
|
84
|
+
stack = await setupTestStack({
|
|
85
|
+
features: [wiringFeature],
|
|
86
|
+
systemHooks: [],
|
|
87
|
+
});
|
|
88
|
+
await createEntityTable(stack.db, sharedWidgetEntity, "widget");
|
|
89
|
+
tdb = createTenantDb(stack.db, admin.tenantId);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(async () => {
|
|
93
|
+
observations = [];
|
|
94
|
+
slowHandlerDelayMs = 0;
|
|
95
|
+
slowHandlerInvocations = [];
|
|
96
|
+
await resetEventStore(stack, ["read_widgets"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
async function appendWidget(name: string): Promise<void> {
|
|
100
|
+
await executor.create({ name }, admin, tdb);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- Tests ---
|
|
104
|
+
|
|
105
|
+
describe("E.1 — buildServer event-dispatcher wiring", () => {
|
|
106
|
+
test("stack.eventDispatcher is wired when consumers exist", () => {
|
|
107
|
+
// Regression guard against the D.5 bug where the outbox wiring was
|
|
108
|
+
// removed and the dispatcher wiring wasn't added back.
|
|
109
|
+
expect(stack.eventDispatcher).toBeDefined();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("E.1 — .start() lifecycle + slow handler", () => {
|
|
114
|
+
test("started dispatcher delivers events without an explicit runOnce", async () => {
|
|
115
|
+
await stack.eventDispatcher?.start();
|
|
116
|
+
try {
|
|
117
|
+
await appendWidget("started-delivery");
|
|
118
|
+
|
|
119
|
+
// pollIntervalMs in the test-stack is 50ms. Give the timer a few
|
|
120
|
+
// ticks to observe the event.
|
|
121
|
+
await waitFor(() => observations.length >= 1, 2000);
|
|
122
|
+
expect(observations).toHaveLength(1);
|
|
123
|
+
expect(observations[0]?.event.payload["name"]).toBe("started-delivery");
|
|
124
|
+
} finally {
|
|
125
|
+
await stack.eventDispatcher?.stop();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("slow handler doesn't queue overlapping passes (passInFlight serialises)", async () => {
|
|
130
|
+
// 250ms handler >> 50ms pollIntervalMs — without passInFlight, the
|
|
131
|
+
// setInterval would start a new pass every 50ms on top of the one in
|
|
132
|
+
// flight. passInFlight must coalesce them. We verify: no two passes
|
|
133
|
+
// ran concurrently.
|
|
134
|
+
slowHandlerDelayMs = 250;
|
|
135
|
+
|
|
136
|
+
await stack.eventDispatcher?.start();
|
|
137
|
+
try {
|
|
138
|
+
await appendWidget("slow-1");
|
|
139
|
+
await appendWidget("slow-2");
|
|
140
|
+
await appendWidget("slow-3");
|
|
141
|
+
|
|
142
|
+
// Wait until all 3 slow-observer invocations have completed.
|
|
143
|
+
await waitFor(() => slowHandlerInvocations.length >= 3, 5000);
|
|
144
|
+
|
|
145
|
+
// Check: no invocation overlapped with the next — every pass
|
|
146
|
+
// finished before the following one started. passInFlight does
|
|
147
|
+
// its job.
|
|
148
|
+
const sorted = [...slowHandlerInvocations].sort((a, b) => a.start - b.start);
|
|
149
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
150
|
+
const prev = sorted[i - 1];
|
|
151
|
+
const curr = sorted[i];
|
|
152
|
+
if (!prev || !curr) continue;
|
|
153
|
+
expect(curr.start).toBeGreaterThanOrEqual(prev.end);
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
await stack.eventDispatcher?.stop();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("E.1 — consumer-lag metric", () => {
|
|
162
|
+
test("kumiko_event_consumer_lag_events is emitted per pass", async () => {
|
|
163
|
+
// Build a dedicated stack with a RecordingMeter so we can read back
|
|
164
|
+
// exactly which gauge events the dispatcher emitted.
|
|
165
|
+
const metricEvents: MetricEvent[] = [];
|
|
166
|
+
const meter = new RecordingMeter((e) => metricEvents.push(e));
|
|
167
|
+
const tracer = new RecordingTracer({
|
|
168
|
+
sensitiveConfig: DEFAULT_SENSITIVE_CONFIG,
|
|
169
|
+
onSpanEnd: () => {},
|
|
170
|
+
});
|
|
171
|
+
const recordingProvider: ObservabilityProvider = {
|
|
172
|
+
name: "recording",
|
|
173
|
+
meter,
|
|
174
|
+
tracer,
|
|
175
|
+
shutdown: async () => {},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const recStack = await setupTestStack({
|
|
179
|
+
features: [wiringFeature],
|
|
180
|
+
systemHooks: [],
|
|
181
|
+
observability: recordingProvider,
|
|
182
|
+
});
|
|
183
|
+
try {
|
|
184
|
+
await createEntityTable(recStack.db, sharedWidgetEntity, "widget");
|
|
185
|
+
const recTdb = createTenantDb(recStack.db, admin.tenantId);
|
|
186
|
+
await executor.create({ name: "lag-check" }, admin, recTdb);
|
|
187
|
+
|
|
188
|
+
await recStack.eventDispatcher?.runOnce();
|
|
189
|
+
|
|
190
|
+
const lagGauges = metricEvents.filter(
|
|
191
|
+
(e) => e.type === "gauge.set" && e.name === "kumiko_event_consumer_lag_events",
|
|
192
|
+
);
|
|
193
|
+
expect(lagGauges.length).toBeGreaterThan(0);
|
|
194
|
+
// The cursor should be at head after a single pass: lag == 0.
|
|
195
|
+
const lastPerConsumer = new Map<string, MetricEvent>();
|
|
196
|
+
for (const ev of lagGauges) {
|
|
197
|
+
const consumer = (ev.labels?.["consumer"] ?? "") as string;
|
|
198
|
+
lastPerConsumer.set(consumer, ev);
|
|
199
|
+
}
|
|
200
|
+
for (const ev of lastPerConsumer.values()) {
|
|
201
|
+
expect(ev.value).toBe(0);
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
await recStack.cleanup();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// --- Helpers ---
|
|
210
|
+
|
|
211
|
+
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
|
212
|
+
const start = Date.now();
|
|
213
|
+
while (Date.now() - start < timeoutMs) {
|
|
214
|
+
if (predicate()) return;
|
|
215
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
216
|
+
}
|
|
217
|
+
if (!predicate()) {
|
|
218
|
+
throw new Error(`waitFor: predicate never became true within ${timeoutMs}ms`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// E.5 — Multi-instance claims. The whole reason the dispatcher was designed
|
|
2
|
+
// around SELECT FOR UPDATE SKIP LOCKED is that in production there are N
|
|
3
|
+
// dispatcher processes, not 1. If two dispatchers try to advance the same
|
|
4
|
+
// consumer in parallel, SKIP LOCKED must guarantee: **exactly one** drives
|
|
5
|
+
// the pass, the other no-ops, zero duplicate delivery.
|
|
6
|
+
//
|
|
7
|
+
// The existing event-dispatcher.integration.ts runs everything single-
|
|
8
|
+
// instance. These tests pin the cross-process claims:
|
|
9
|
+
//
|
|
10
|
+
// 1. Two dispatchers on the same DB + same consumer name: handler is
|
|
11
|
+
// called exactly once per event (no duplicate delivery).
|
|
12
|
+
// 2. Two dispatchers with different consumer names: both progress
|
|
13
|
+
// independently — one slow consumer doesn't starve the other.
|
|
14
|
+
// 3. A consumer joining with 2000 events in the backlog catches up
|
|
15
|
+
// across multiple passes without replaying events or exploding.
|
|
16
|
+
//
|
|
17
|
+
// Note: these tests assert on behaviour that is not observable in
|
|
18
|
+
// single-instance runs. Any regression in the locking strategy shows up
|
|
19
|
+
// here first.
|
|
20
|
+
|
|
21
|
+
import { afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
22
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
23
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
24
|
+
import { defineFeature } from "../../engine";
|
|
25
|
+
import { eventsTable, type StoredEvent } from "../../event-store";
|
|
26
|
+
import {
|
|
27
|
+
createEventDispatcher,
|
|
28
|
+
type EventConsumer,
|
|
29
|
+
type EventDispatcher,
|
|
30
|
+
getConsumerState,
|
|
31
|
+
} from "../../pipeline";
|
|
32
|
+
import {
|
|
33
|
+
createEntityTable,
|
|
34
|
+
resetEventStore,
|
|
35
|
+
setupTestStack,
|
|
36
|
+
type TestStack,
|
|
37
|
+
TestUsers,
|
|
38
|
+
} from "../../stack";
|
|
39
|
+
import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
|
|
40
|
+
import { generateId } from "../../utils";
|
|
41
|
+
|
|
42
|
+
// --- Fixture ---
|
|
43
|
+
|
|
44
|
+
const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
|
|
45
|
+
entityName: "widget",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// A trivial feature — the dispatchers built by the tests use the same
|
|
49
|
+
// consumer name ("multi:consumer:echo") via direct createEventDispatcher
|
|
50
|
+
// calls (no r.multiStreamProjection registration on this stack, since the
|
|
51
|
+
// test-stack would then auto-wire a subscriber we don't want in the
|
|
52
|
+
// multi-instance setup).
|
|
53
|
+
const multiFeature = defineFeature("multi", (r) => {
|
|
54
|
+
r.entity("widget", sharedWidgetEntity);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const admin = TestUsers.admin;
|
|
58
|
+
let stack: TestStack;
|
|
59
|
+
let tdb: TenantDb;
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
stack = await setupTestStack({
|
|
63
|
+
features: [multiFeature],
|
|
64
|
+
systemHooks: [],
|
|
65
|
+
});
|
|
66
|
+
await createEntityTable(stack.db, sharedWidgetEntity, "widget");
|
|
67
|
+
tdb = createTenantDb(stack.db, admin.tenantId);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(async () => {
|
|
71
|
+
await resetEventStore(stack, ["read_widgets"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
async function appendWidget(name: string): Promise<void> {
|
|
75
|
+
await executor.create({ name }, admin, tdb);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Bulk-seed N widget.created events directly into the events table.
|
|
79
|
+
// Used by the backlog test where the seed phase would otherwise dominate
|
|
80
|
+
// runtime (2000 sequential executor.create = 2000 DB round-trips).
|
|
81
|
+
// The dispatcher only reads from events — bypassing the projections-table
|
|
82
|
+
// write is safe here; we're testing cursor catch-up, not the executor.
|
|
83
|
+
async function bulkSeedWidgetCreated(count: number, namePrefix: string): Promise<void> {
|
|
84
|
+
const rows = Array.from({ length: count }, (_, i) => ({
|
|
85
|
+
aggregateId: generateId(),
|
|
86
|
+
aggregateType: "widget",
|
|
87
|
+
tenantId: admin.tenantId,
|
|
88
|
+
version: 1,
|
|
89
|
+
type: "widget.created",
|
|
90
|
+
payload: { name: `${namePrefix}${i}` },
|
|
91
|
+
metadata: { userId: admin.id },
|
|
92
|
+
createdBy: admin.id,
|
|
93
|
+
}));
|
|
94
|
+
await stack.db.insert(eventsTable).values(rows);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildDispatcherWith(consumers: readonly EventConsumer[]): EventDispatcher {
|
|
98
|
+
return createEventDispatcher({
|
|
99
|
+
db: stack.db,
|
|
100
|
+
consumers,
|
|
101
|
+
context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
|
|
102
|
+
// Tight batch + poll so the test doesn't hinge on timing in .start();
|
|
103
|
+
// we drive everything through runOnce() for determinism.
|
|
104
|
+
batchSize: 200,
|
|
105
|
+
pollIntervalMs: 5000,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Tests ---
|
|
110
|
+
|
|
111
|
+
describe("E.5 — SKIP LOCKED: exactly-once delivery across dispatchers", () => {
|
|
112
|
+
test("two dispatchers, same consumer name: each event delivered exactly once", async () => {
|
|
113
|
+
// Shared name — both dispatchers race for the same row in
|
|
114
|
+
// kumiko_event_consumers. SKIP LOCKED must ensure only one wins.
|
|
115
|
+
const name = "multi:consumer:echo-same";
|
|
116
|
+
const seen: StoredEvent[] = [];
|
|
117
|
+
|
|
118
|
+
// Two consumers with the SAME name but distinct capture sides —
|
|
119
|
+
// mimics two different processes running the same subscriber code.
|
|
120
|
+
const consumerA: EventConsumer = {
|
|
121
|
+
name,
|
|
122
|
+
handler: async (event) => {
|
|
123
|
+
seen.push(event);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const consumerB: EventConsumer = { ...consumerA };
|
|
127
|
+
|
|
128
|
+
const dispA = buildDispatcherWith([consumerA]);
|
|
129
|
+
const dispB = buildDispatcherWith([consumerB]);
|
|
130
|
+
// Strict pre-reg: both dispatchers share the same consumer name so
|
|
131
|
+
// ON-CONFLICT-DO-NOTHING collapses to a single row — which is exactly
|
|
132
|
+
// the shared state the SKIP-LOCKED race is about.
|
|
133
|
+
await dispA.ensureRegistered();
|
|
134
|
+
await dispB.ensureRegistered();
|
|
135
|
+
|
|
136
|
+
// Seed a known, large-enough batch.
|
|
137
|
+
const count = 30;
|
|
138
|
+
for (let i = 0; i < count; i++) {
|
|
139
|
+
await appendWidget(`event-${i}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Race both dispatchers. One will acquire the lock; the other's
|
|
143
|
+
// SELECT FOR UPDATE SKIP LOCKED returns nothing and it bails out of
|
|
144
|
+
// this pass. Run a handful of passes so stragglers (if any) get
|
|
145
|
+
// another shot.
|
|
146
|
+
for (let pass = 0; pass < 5; pass++) {
|
|
147
|
+
await Promise.all([dispA.runOnce(), dispB.runOnce()]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Each of the 30 events should appear exactly once across the
|
|
151
|
+
// combined `seen` array. Duplicate delivery would show up as >30.
|
|
152
|
+
expect(seen).toHaveLength(count);
|
|
153
|
+
const names = seen.map((e) => e.payload["name"]).sort();
|
|
154
|
+
const expected = Array.from({ length: count }, (_, i) => `event-${i}`).sort();
|
|
155
|
+
expect(names).toEqual(expected);
|
|
156
|
+
|
|
157
|
+
const finalState = await getConsumerState(stack.db, name);
|
|
158
|
+
expect(finalState?.lastProcessedEventId).toBe(BigInt(count));
|
|
159
|
+
expect(finalState?.status).toBe("idle");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("different consumer names: one slow consumer does not starve the other", async () => {
|
|
163
|
+
// Two independent consumer rows, both driven on the same DB via two
|
|
164
|
+
// dispatcher instances. A slow handler on one side must not block the
|
|
165
|
+
// fast side.
|
|
166
|
+
const fastName = "multi:consumer:fast";
|
|
167
|
+
const slowName = "multi:consumer:slow";
|
|
168
|
+
const fastSeen: StoredEvent[] = [];
|
|
169
|
+
const slowSeen: StoredEvent[] = [];
|
|
170
|
+
|
|
171
|
+
const fast: EventConsumer = {
|
|
172
|
+
name: fastName,
|
|
173
|
+
handler: async (event) => {
|
|
174
|
+
fastSeen.push(event);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
const slow: EventConsumer = {
|
|
178
|
+
name: slowName,
|
|
179
|
+
handler: async (event) => {
|
|
180
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
181
|
+
slowSeen.push(event);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const dispA = buildDispatcherWith([fast]);
|
|
186
|
+
const dispB = buildDispatcherWith([slow]);
|
|
187
|
+
await dispA.ensureRegistered();
|
|
188
|
+
await dispB.ensureRegistered();
|
|
189
|
+
|
|
190
|
+
const count = 10;
|
|
191
|
+
for (let i = 0; i < count; i++) {
|
|
192
|
+
await appendWidget(`concurrent-${i}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A tight race: fast dispatcher finishes its pass quickly; slow
|
|
196
|
+
// dispatcher is still processing. Neither should see the other's
|
|
197
|
+
// events (different consumer names = different rows, different
|
|
198
|
+
// cursors).
|
|
199
|
+
await Promise.all([dispA.runOnce(), dispB.runOnce()]);
|
|
200
|
+
|
|
201
|
+
expect(fastSeen).toHaveLength(count);
|
|
202
|
+
expect(slowSeen).toHaveLength(count);
|
|
203
|
+
|
|
204
|
+
const fastState = await getConsumerState(stack.db, fastName);
|
|
205
|
+
const slowState = await getConsumerState(stack.db, slowName);
|
|
206
|
+
expect(fastState?.lastProcessedEventId).toBe(BigInt(count));
|
|
207
|
+
expect(slowState?.lastProcessedEventId).toBe(BigInt(count));
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("E.5 — cursor-lag catch-up", () => {
|
|
212
|
+
test("a consumer joining with a 500-event backlog catches up across multiple passes", async () => {
|
|
213
|
+
const name = "multi:consumer:late-joiner";
|
|
214
|
+
const seen: StoredEvent[] = [];
|
|
215
|
+
const consumer: EventConsumer = {
|
|
216
|
+
name,
|
|
217
|
+
handler: async (event) => {
|
|
218
|
+
seen.push(event);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Seed events BEFORE the consumer first runs. Matches a deploy scenario
|
|
223
|
+
// where a new subscriber is added to a feature and starts from cursor=0
|
|
224
|
+
// against a populated events table. 500 events × batchSize=100 = 5 passes
|
|
225
|
+
// — still "multiple" and exercises the cursor-advance loop.
|
|
226
|
+
const count = 500;
|
|
227
|
+
await bulkSeedWidgetCreated(count, "backlog-");
|
|
228
|
+
// State row does not exist yet — this dispatcher is constructed inside
|
|
229
|
+
// the test, not via setupTestStack's auto-ensureRegistered.
|
|
230
|
+
expect(await getConsumerState(stack.db, name)).toBeNull();
|
|
231
|
+
|
|
232
|
+
const disp = createEventDispatcher({
|
|
233
|
+
db: stack.db,
|
|
234
|
+
consumers: [consumer],
|
|
235
|
+
context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
|
|
236
|
+
batchSize: 100,
|
|
237
|
+
pollIntervalMs: 5000,
|
|
238
|
+
});
|
|
239
|
+
// Strict pre-reg before the first pass — mirrors a production boot
|
|
240
|
+
// where start() runs before any runOnce(). Post-ensureRegistered the
|
|
241
|
+
// cursor row exists at 0 with the 500-event backlog ahead.
|
|
242
|
+
await disp.ensureRegistered();
|
|
243
|
+
const bootState = await getConsumerState(stack.db, name);
|
|
244
|
+
expect(bootState?.lastProcessedEventId).toBe(0n);
|
|
245
|
+
|
|
246
|
+
// batchSize = 100 → 5 passes cover 500 events. Run 8 to leave headroom;
|
|
247
|
+
// the 6th+ passes should be no-ops.
|
|
248
|
+
for (let pass = 0; pass < 8; pass++) {
|
|
249
|
+
const result = await disp.runOnce();
|
|
250
|
+
if (result.byConsumer[name]?.processed === 0) break;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// All events delivered, in order, exactly once.
|
|
254
|
+
expect(seen).toHaveLength(count);
|
|
255
|
+
for (let i = 0; i < count; i++) {
|
|
256
|
+
expect(seen[i]?.payload["name"]).toBe(`backlog-${i}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const finalState = await getConsumerState(stack.db, name);
|
|
260
|
+
expect(finalState?.lastProcessedEventId).toBe(BigInt(count));
|
|
261
|
+
expect(finalState?.status).toBe("idle");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Welle 2.7 — per-instance delivery. Inverse of the shared test above:
|
|
266
|
+
// with delivery="per-instance", each dispatcher gets its OWN cursor row
|
|
267
|
+
// (via instance_id), so both dispatchers MUST deliver every event. SSE
|
|
268
|
+
// broadcast in split-deploy is the canonical use-case.
|
|
269
|
+
describe("Welle 2.7 — per-instance delivery: every dispatcher sees every event", () => {
|
|
270
|
+
test("two dispatchers with different instanceIds, same consumer name: both deliver every event", async () => {
|
|
271
|
+
const name = "multi:consumer:per-instance-echo";
|
|
272
|
+
const seenA: StoredEvent[] = [];
|
|
273
|
+
const seenB: StoredEvent[] = [];
|
|
274
|
+
|
|
275
|
+
const makeConsumer = (seen: StoredEvent[]): EventConsumer => ({
|
|
276
|
+
name,
|
|
277
|
+
delivery: "per-instance",
|
|
278
|
+
handler: async (event) => {
|
|
279
|
+
seen.push(event);
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const dispA = createEventDispatcher({
|
|
284
|
+
db: stack.db,
|
|
285
|
+
consumers: [makeConsumer(seenA)],
|
|
286
|
+
context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
|
|
287
|
+
instanceId: "instance-A",
|
|
288
|
+
batchSize: 200,
|
|
289
|
+
pollIntervalMs: 5000,
|
|
290
|
+
});
|
|
291
|
+
const dispB = createEventDispatcher({
|
|
292
|
+
db: stack.db,
|
|
293
|
+
consumers: [makeConsumer(seenB)],
|
|
294
|
+
context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
|
|
295
|
+
instanceId: "instance-B",
|
|
296
|
+
batchSize: 200,
|
|
297
|
+
pollIntervalMs: 5000,
|
|
298
|
+
});
|
|
299
|
+
await dispA.ensureRegistered();
|
|
300
|
+
await dispB.ensureRegistered();
|
|
301
|
+
|
|
302
|
+
const count = 20;
|
|
303
|
+
for (let i = 0; i < count; i++) {
|
|
304
|
+
await appendWidget(`pi-${i}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Run both dispatchers. Unlike shared delivery (race → exactly one
|
|
308
|
+
// wins), per-instance means both cursors advance independently.
|
|
309
|
+
await Promise.all([dispA.runOnce(), dispB.runOnce()]);
|
|
310
|
+
|
|
311
|
+
expect(seenA).toHaveLength(count);
|
|
312
|
+
expect(seenB).toHaveLength(count);
|
|
313
|
+
|
|
314
|
+
// Each instance has its own row with its own cursor.
|
|
315
|
+
const stateA = await getConsumerState(stack.db, name, "instance-A");
|
|
316
|
+
const stateB = await getConsumerState(stack.db, name, "instance-B");
|
|
317
|
+
expect(stateA?.lastProcessedEventId).toBe(BigInt(count));
|
|
318
|
+
expect(stateB?.lastProcessedEventId).toBe(BigInt(count));
|
|
319
|
+
expect(stateA?.instanceId).toBe("instance-A");
|
|
320
|
+
expect(stateB?.instanceId).toBe("instance-B");
|
|
321
|
+
|
|
322
|
+
// The shared sentinel row MUST NOT exist — per-instance consumers
|
|
323
|
+
// never write the default shard. If a bug ever routed per-instance
|
|
324
|
+
// writes to `__shared__`, this would silently collapse N instances'
|
|
325
|
+
// cursors into one and regress to shared semantics.
|
|
326
|
+
const stateShared = await getConsumerState(stack.db, name);
|
|
327
|
+
expect(stateShared).toBeNull();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("mixed delivery: shared consumer stays exactly-once, per-instance consumer delivers to every dispatcher", async () => {
|
|
331
|
+
const sharedName = "multi:consumer:mixed-shared";
|
|
332
|
+
const perInstanceName = "multi:consumer:mixed-per-instance";
|
|
333
|
+
|
|
334
|
+
const sharedSeen: StoredEvent[] = [];
|
|
335
|
+
const perInstA: StoredEvent[] = [];
|
|
336
|
+
const perInstB: StoredEvent[] = [];
|
|
337
|
+
|
|
338
|
+
// Shared consumer registered on BOTH dispatchers — SKIP LOCKED on the
|
|
339
|
+
// one sentinel row means exactly one of them wins each event.
|
|
340
|
+
const sharedA: EventConsumer = {
|
|
341
|
+
name: sharedName,
|
|
342
|
+
handler: async (e) => void sharedSeen.push(e),
|
|
343
|
+
};
|
|
344
|
+
const sharedB: EventConsumer = { ...sharedA };
|
|
345
|
+
|
|
346
|
+
const perInstanceA: EventConsumer = {
|
|
347
|
+
name: perInstanceName,
|
|
348
|
+
delivery: "per-instance",
|
|
349
|
+
handler: async (e) => void perInstA.push(e),
|
|
350
|
+
};
|
|
351
|
+
const perInstanceB: EventConsumer = {
|
|
352
|
+
name: perInstanceName,
|
|
353
|
+
delivery: "per-instance",
|
|
354
|
+
handler: async (e) => void perInstB.push(e),
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const dispA = createEventDispatcher({
|
|
358
|
+
db: stack.db,
|
|
359
|
+
consumers: [sharedA, perInstanceA],
|
|
360
|
+
context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
|
|
361
|
+
instanceId: "mixed-A",
|
|
362
|
+
batchSize: 200,
|
|
363
|
+
pollIntervalMs: 5000,
|
|
364
|
+
});
|
|
365
|
+
const dispB = createEventDispatcher({
|
|
366
|
+
db: stack.db,
|
|
367
|
+
consumers: [sharedB, perInstanceB],
|
|
368
|
+
context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
|
|
369
|
+
instanceId: "mixed-B",
|
|
370
|
+
batchSize: 200,
|
|
371
|
+
pollIntervalMs: 5000,
|
|
372
|
+
});
|
|
373
|
+
await dispA.ensureRegistered();
|
|
374
|
+
await dispB.ensureRegistered();
|
|
375
|
+
|
|
376
|
+
const count = 15;
|
|
377
|
+
for (let i = 0; i < count; i++) {
|
|
378
|
+
await appendWidget(`mix-${i}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Multiple pass rounds so slow-loser of the SKIP-LOCKED race on the
|
|
382
|
+
// shared consumer still gets a chance to run if the fast-winner left
|
|
383
|
+
// events behind.
|
|
384
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
385
|
+
await Promise.all([dispA.runOnce(), dispB.runOnce()]);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Shared: total across both sides == count (exactly-once globally).
|
|
389
|
+
expect(sharedSeen).toHaveLength(count);
|
|
390
|
+
|
|
391
|
+
// Per-instance: each side gets the FULL set.
|
|
392
|
+
expect(perInstA).toHaveLength(count);
|
|
393
|
+
expect(perInstB).toHaveLength(count);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("creating a dispatcher with a per-instance consumer but no instanceId throws at construction", () => {
|
|
397
|
+
expect(() =>
|
|
398
|
+
createEventDispatcher({
|
|
399
|
+
db: stack.db,
|
|
400
|
+
consumers: [
|
|
401
|
+
{
|
|
402
|
+
name: "multi:consumer:no-instance-id",
|
|
403
|
+
delivery: "per-instance",
|
|
404
|
+
handler: async () => {},
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
|
|
408
|
+
// instanceId deliberately omitted
|
|
409
|
+
}),
|
|
410
|
+
).toThrow(/delivery="per-instance".+instanceId/);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("instanceId equal to the reserved sentinel is rejected at construction", () => {
|
|
414
|
+
expect(() =>
|
|
415
|
+
createEventDispatcher({
|
|
416
|
+
db: stack.db,
|
|
417
|
+
consumers: [{ name: "x", handler: async () => {} }],
|
|
418
|
+
context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
|
|
419
|
+
instanceId: "__shared__",
|
|
420
|
+
}),
|
|
421
|
+
).toThrow(/reserved sentinel/);
|
|
422
|
+
});
|
|
423
|
+
});
|