@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,147 @@
|
|
|
1
|
+
// Projection-Rebuild Performance — NOT a perf gate, a "not-broken" gate.
|
|
2
|
+
//
|
|
3
|
+
// Asserts the current rebuildProjection() pipeline (registry + state-table
|
|
4
|
+
// + status-lifecycle wrapper) still moves bulk events at a sane rate. The
|
|
5
|
+
// real performance number is what we observe in isolation: 14–15k events/s
|
|
6
|
+
// on this hardware. The threshold below is intentionally loose because
|
|
7
|
+
// vitest runs integration suites in parallel — other files hammer the same
|
|
8
|
+
// Postgres at the same time, and an I/O-bound rebuild shares bandwidth.
|
|
9
|
+
//
|
|
10
|
+
// Threshold: 5000 events/s. Picked so a 2× regression on a real bottleneck
|
|
11
|
+
// (e.g. accidental N+1 in the apply-loop, missing index on events.id, a
|
|
12
|
+
// stray await in the hot path) trips the test, while normal suite-load
|
|
13
|
+
// jitter does not. If this ever flakes in CI, drop to 3000 — the goal is
|
|
14
|
+
// "catastrophic regression detector", not "perf SLO".
|
|
15
|
+
|
|
16
|
+
import { sql } from "drizzle-orm";
|
|
17
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
18
|
+
import {
|
|
19
|
+
integer as drizzleInteger,
|
|
20
|
+
table as drizzlePgTable,
|
|
21
|
+
uuid as drizzleUuid,
|
|
22
|
+
} from "../../db/dialect";
|
|
23
|
+
import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
|
|
24
|
+
import type { ProjectionDefinition } from "../../engine/types";
|
|
25
|
+
import { createEventsTable } from "../../event-store";
|
|
26
|
+
import { createProjectionStateTable, rebuildProjection } from "../../pipeline";
|
|
27
|
+
import { createTestDb, pushTables, type TestDb, TestUsers } from "../../stack";
|
|
28
|
+
import { generateId as uuid } from "../../utils";
|
|
29
|
+
|
|
30
|
+
// Counter projection: every task.created bumps a counter, every
|
|
31
|
+
// task.updated is a no-op. Enough to exercise the apply path —
|
|
32
|
+
// rebuild cost is dominated by event iteration + apply dispatch,
|
|
33
|
+
// not the projection state shape.
|
|
34
|
+
const taskCountTable = drizzlePgTable("read_perf_rebuild_task_count", {
|
|
35
|
+
tenantId: drizzleUuid("tenant_id").primaryKey(),
|
|
36
|
+
count: drizzleInteger("count").notNull().default(0),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const taskCountProjection: ProjectionDefinition = {
|
|
40
|
+
name: "task-count",
|
|
41
|
+
source: "task",
|
|
42
|
+
table: taskCountTable,
|
|
43
|
+
apply: {
|
|
44
|
+
"task.created": async (event, tx) => {
|
|
45
|
+
await tx
|
|
46
|
+
.insert(taskCountTable)
|
|
47
|
+
.values({ tenantId: event.tenantId, count: 1 })
|
|
48
|
+
.onConflictDoUpdate({
|
|
49
|
+
target: taskCountTable.tenantId,
|
|
50
|
+
set: { count: sql`${taskCountTable.count} + 1` },
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
"task.updated": async (_event, _tx) => {
|
|
54
|
+
// No-op apply — measuring event-iteration overhead, not per-event
|
|
55
|
+
// DB roundtrips. 10k events/s with one row-update per event would
|
|
56
|
+
// be an I/O-bound test, not a rebuild-throughput test.
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const taskEntity = createEntity({
|
|
62
|
+
table: "perf_rebuild_tasks",
|
|
63
|
+
fields: { title: createTextField({ required: true }) },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const feature = defineFeature("perfrebuild", (r) => {
|
|
67
|
+
r.entity("task", taskEntity);
|
|
68
|
+
r.projection(taskCountProjection);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const admin = TestUsers.admin;
|
|
72
|
+
let testDb: TestDb;
|
|
73
|
+
const registry = createRegistry([feature]);
|
|
74
|
+
const qualifiedProjectionName = "perfrebuild:projection:task-count";
|
|
75
|
+
|
|
76
|
+
beforeAll(async () => {
|
|
77
|
+
testDb = await createTestDb();
|
|
78
|
+
await createEventsTable(testDb.db);
|
|
79
|
+
await createProjectionStateTable(testDb.db);
|
|
80
|
+
await pushTables(testDb.db, { perf_rebuild_task_count: taskCountTable });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await testDb.cleanup();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
await testDb.db.execute(
|
|
89
|
+
sql`TRUNCATE kumiko_events, read_perf_rebuild_task_count, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Bulk-seed via SQL — sequential append() calls would take minutes.
|
|
94
|
+
// Measures rebuild throughput on a finished stream, not the seed phase.
|
|
95
|
+
// Produces count aggregates × depth events per aggregate.
|
|
96
|
+
async function seedEvents(count: number, depth: number): Promise<void> {
|
|
97
|
+
const userId = uuid();
|
|
98
|
+
// v1 creates
|
|
99
|
+
await testDb.db.execute(sql`
|
|
100
|
+
INSERT INTO kumiko_events (aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
|
|
101
|
+
SELECT gen_random_uuid(), 'task', ${admin.tenantId}::uuid, 1, 'task.created',
|
|
102
|
+
jsonb_build_object('title', 'Task ' || gs.n),
|
|
103
|
+
jsonb_build_object('userId', ${userId}::text),
|
|
104
|
+
${userId}::text
|
|
105
|
+
FROM generate_series(1, ${count}) AS gs(n);
|
|
106
|
+
`);
|
|
107
|
+
// v2..depth updates
|
|
108
|
+
for (let v = 2; v <= depth; v++) {
|
|
109
|
+
await testDb.db.execute(sql`
|
|
110
|
+
INSERT INTO kumiko_events (aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
|
|
111
|
+
SELECT e.aggregate_id, 'task', ${admin.tenantId}::uuid, ${v}, 'task.updated',
|
|
112
|
+
jsonb_build_object('title', 'Task v' || ${v}),
|
|
113
|
+
jsonb_build_object('userId', ${userId}::text),
|
|
114
|
+
${userId}::text
|
|
115
|
+
FROM kumiko_events e
|
|
116
|
+
WHERE e.aggregate_type = 'task' AND e.version = ${v - 1};
|
|
117
|
+
`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe("rebuildProjection performance — Gate A", () => {
|
|
122
|
+
test("rebuild rate >= 3k events/sec under suite-parallel-load (10000 events)", async () => {
|
|
123
|
+
// 2000 aggregates × 5 events = 10000 events
|
|
124
|
+
await seedEvents(2000, 5);
|
|
125
|
+
|
|
126
|
+
const start = performance.now();
|
|
127
|
+
const result = await rebuildProjection(qualifiedProjectionName, {
|
|
128
|
+
db: testDb.db,
|
|
129
|
+
registry,
|
|
130
|
+
});
|
|
131
|
+
const durationMs = performance.now() - start;
|
|
132
|
+
|
|
133
|
+
expect(result.eventsProcessed).toBe(10_000);
|
|
134
|
+
const rate = result.eventsProcessed / (durationMs / 1000);
|
|
135
|
+
console.log(
|
|
136
|
+
` Rebuild: ${result.eventsProcessed} events in ${durationMs.toFixed(1)}ms = ${Math.round(rate)} events/s`,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Budget 3k events/s under suite-parallel-load. Isolated runs on dev
|
|
140
|
+
// hardware see ~14k events/s; parallel-load drops it 3-4x (Docker-PG
|
|
141
|
+
// contention, Vitest worker concurrency). The gate catches real
|
|
142
|
+
// regressions (~40% drop to <2k) without daily false positives. If
|
|
143
|
+
// you see this flake below 3k, profile `rebuildProjection` — don't
|
|
144
|
+
// just lower the budget further.
|
|
145
|
+
expect(rate).toBeGreaterThanOrEqual(3_000);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
// Projection rebuild — the load-bearing claim of the whole projections API.
|
|
2
|
+
// "Projections are rebuildable read-models" has to actually work: replaying
|
|
3
|
+
// the event log must produce the exact same state as live apply().
|
|
4
|
+
//
|
|
5
|
+
// Tests here:
|
|
6
|
+
// - rebuild from empty state matches live-applied state
|
|
7
|
+
// - rebuild after data-corruption fixes the projection
|
|
8
|
+
// - rebuild preserves atomicity (throw mid-replay → status=failed + old
|
|
9
|
+
// rows intact)
|
|
10
|
+
// - status lifecycle (idle → rebuilding → idle on success, → failed on throw)
|
|
11
|
+
// - never-rebuilt projection has sensible default state
|
|
12
|
+
|
|
13
|
+
import { eq, sql } from "drizzle-orm";
|
|
14
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
15
|
+
import {
|
|
16
|
+
integer as drizzleInteger,
|
|
17
|
+
table as drizzlePgTable,
|
|
18
|
+
uuid as drizzleUuid,
|
|
19
|
+
} from "../../db/dialect";
|
|
20
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
21
|
+
import { buildDrizzleTable } from "../../db/table-builder";
|
|
22
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
23
|
+
import {
|
|
24
|
+
createEntity,
|
|
25
|
+
createRegistry,
|
|
26
|
+
createTextField,
|
|
27
|
+
defineApply,
|
|
28
|
+
defineFeature,
|
|
29
|
+
} from "../../engine";
|
|
30
|
+
import type { ProjectionDefinition } from "../../engine/types";
|
|
31
|
+
import { createEventsTable } from "../../event-store";
|
|
32
|
+
import {
|
|
33
|
+
createProjectionStateTable,
|
|
34
|
+
getAllProjectionProgress,
|
|
35
|
+
getProjectionState,
|
|
36
|
+
listProjectionsWithState,
|
|
37
|
+
rebuildProjection,
|
|
38
|
+
} from "../../pipeline";
|
|
39
|
+
import { createEntityTable, createTestDb, pushTables, type TestDb, TestUsers } from "../../stack";
|
|
40
|
+
|
|
41
|
+
// --- Test fixtures ---
|
|
42
|
+
|
|
43
|
+
const itemEntity = createEntity({
|
|
44
|
+
table: "read_rebuild_items",
|
|
45
|
+
fields: {
|
|
46
|
+
groupId: createTextField({ required: true }),
|
|
47
|
+
name: createTextField({ required: true }),
|
|
48
|
+
},
|
|
49
|
+
softDelete: true,
|
|
50
|
+
});
|
|
51
|
+
const itemTable = buildDrizzleTable("rebuild-item", itemEntity);
|
|
52
|
+
|
|
53
|
+
const itemsPerGroupTable = drizzlePgTable("read_rebuild_items_per_group", {
|
|
54
|
+
groupId: drizzleUuid("group_id").primaryKey(),
|
|
55
|
+
tenantId: drizzleUuid("tenant_id").notNull(),
|
|
56
|
+
itemCount: drizzleInteger("item_count").notNull().default(0),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
async function bump(tx: unknown, groupId: string, tenantId: string, delta: number): Promise<void> {
|
|
60
|
+
// biome-ignore lint/suspicious/noExplicitAny: tx is DbRunner
|
|
61
|
+
await (tx as any)
|
|
62
|
+
.insert(itemsPerGroupTable)
|
|
63
|
+
.values({ groupId, tenantId, itemCount: delta })
|
|
64
|
+
.onConflictDoUpdate({
|
|
65
|
+
target: itemsPerGroupTable.groupId,
|
|
66
|
+
set: { itemCount: sql`${itemsPerGroupTable.itemCount} + ${delta}` },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type ItemCreated = { groupId: string };
|
|
71
|
+
type ItemRestoreOrDelete = { previous: { groupId: string } };
|
|
72
|
+
|
|
73
|
+
const itemsPerGroupProjection: ProjectionDefinition = {
|
|
74
|
+
name: "items-per-group",
|
|
75
|
+
source: "rebuild-item",
|
|
76
|
+
table: itemsPerGroupTable,
|
|
77
|
+
apply: {
|
|
78
|
+
"rebuild-item.created": defineApply<ItemCreated>(async (event, tx) => {
|
|
79
|
+
await bump(tx, event.payload.groupId, event.tenantId, 1);
|
|
80
|
+
}),
|
|
81
|
+
"rebuild-item.deleted": defineApply<ItemRestoreOrDelete>(async (event, tx) => {
|
|
82
|
+
await bump(tx, event.payload.previous.groupId, event.tenantId, -1);
|
|
83
|
+
}),
|
|
84
|
+
"rebuild-item.restored": defineApply<ItemRestoreOrDelete>(async (event, tx) => {
|
|
85
|
+
await bump(tx, event.payload.previous.groupId, event.tenantId, 1);
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const feature = defineFeature("rebuildtest", (r) => {
|
|
91
|
+
r.entity("rebuild-item", itemEntity);
|
|
92
|
+
r.projection(itemsPerGroupProjection);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const admin = TestUsers.admin;
|
|
96
|
+
let testDb: TestDb;
|
|
97
|
+
let tdb: TenantDb;
|
|
98
|
+
const registry = createRegistry([feature]);
|
|
99
|
+
const qualifiedProjectionName = "rebuildtest:projection:items-per-group";
|
|
100
|
+
|
|
101
|
+
// Drizzle identifier for the executor.
|
|
102
|
+
const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "rebuild-item" });
|
|
103
|
+
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
testDb = await createTestDb();
|
|
106
|
+
await createEntityTable(testDb.db, itemEntity, "rebuild-item");
|
|
107
|
+
await createEventsTable(testDb.db);
|
|
108
|
+
await createProjectionStateTable(testDb.db);
|
|
109
|
+
await pushTables(testDb.db, { rebuildItemsPerGroup: itemsPerGroupTable });
|
|
110
|
+
tdb = createTenantDb(testDb.db, admin.tenantId);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterAll(async () => {
|
|
114
|
+
await testDb.cleanup();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
beforeEach(async () => {
|
|
118
|
+
await testDb.db.execute(
|
|
119
|
+
sql`TRUNCATE kumiko_events, read_rebuild_items, read_rebuild_items_per_group, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// --- Live-apply helper: use the dispatcher pipeline so projections fire.
|
|
124
|
+
// For rebuild-only tests we can bypass live apply and just append events
|
|
125
|
+
// directly — the point of rebuild is to reconstruct state from events alone.
|
|
126
|
+
|
|
127
|
+
async function appendCreatedEvent(groupId: string, name: string): Promise<void> {
|
|
128
|
+
// Use the executor directly — this fires events + the entity row, but
|
|
129
|
+
// NOT the projection (pipeline not wired). Perfect for "live has no
|
|
130
|
+
// projection state, rebuild reconstructs it" scenarios.
|
|
131
|
+
await executor.create({ groupId, name }, admin, tdb);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function getCount(groupId: string): Promise<number | undefined> {
|
|
135
|
+
const [row] = await testDb.db
|
|
136
|
+
.select()
|
|
137
|
+
.from(itemsPerGroupTable)
|
|
138
|
+
.where(eq(itemsPerGroupTable.groupId, groupId));
|
|
139
|
+
return row?.itemCount;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe("rebuildProjection — happy path", () => {
|
|
143
|
+
test("replays events and produces correct counter state", async () => {
|
|
144
|
+
const group = "00000000-0000-4000-8000-000000000001";
|
|
145
|
+
await appendCreatedEvent(group, "item1");
|
|
146
|
+
await appendCreatedEvent(group, "item2");
|
|
147
|
+
await appendCreatedEvent(group, "item3");
|
|
148
|
+
|
|
149
|
+
// Projection table is empty — pipeline wasn't wired in these writes.
|
|
150
|
+
expect(await getCount(group)).toBeUndefined();
|
|
151
|
+
|
|
152
|
+
const result = await rebuildProjection(qualifiedProjectionName, {
|
|
153
|
+
db: testDb.db,
|
|
154
|
+
registry,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(result.projection).toBe(qualifiedProjectionName);
|
|
158
|
+
expect(result.eventsProcessed).toBe(3);
|
|
159
|
+
expect(result.lastProcessedEventId).toBeGreaterThan(0n);
|
|
160
|
+
|
|
161
|
+
// Counter now reflects all three creates.
|
|
162
|
+
expect(await getCount(group)).toBe(3);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("rebuild wipes existing state before replay (no double-count)", async () => {
|
|
166
|
+
const group = "00000000-0000-4000-8000-000000000002";
|
|
167
|
+
await appendCreatedEvent(group, "a");
|
|
168
|
+
await appendCreatedEvent(group, "b");
|
|
169
|
+
|
|
170
|
+
// Seed the projection table with a stale/wrong value.
|
|
171
|
+
await testDb.db
|
|
172
|
+
.insert(itemsPerGroupTable)
|
|
173
|
+
.values({ groupId: group, tenantId: admin.tenantId, itemCount: 999 });
|
|
174
|
+
|
|
175
|
+
const result = await rebuildProjection(qualifiedProjectionName, {
|
|
176
|
+
db: testDb.db,
|
|
177
|
+
registry,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(result.eventsProcessed).toBe(2);
|
|
181
|
+
// Not 999+2, not 999 — TRUNCATE + replay.
|
|
182
|
+
expect(await getCount(group)).toBe(2);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("handles events across multiple groups and aggregate IDs", async () => {
|
|
186
|
+
const groupA = "00000000-0000-4000-8000-000000000010";
|
|
187
|
+
const groupB = "00000000-0000-4000-8000-000000000011";
|
|
188
|
+
|
|
189
|
+
await appendCreatedEvent(groupA, "a1");
|
|
190
|
+
await appendCreatedEvent(groupB, "b1");
|
|
191
|
+
await appendCreatedEvent(groupA, "a2");
|
|
192
|
+
await appendCreatedEvent(groupA, "a3");
|
|
193
|
+
await appendCreatedEvent(groupB, "b2");
|
|
194
|
+
|
|
195
|
+
const result = await rebuildProjection(qualifiedProjectionName, {
|
|
196
|
+
db: testDb.db,
|
|
197
|
+
registry,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(result.eventsProcessed).toBe(5);
|
|
201
|
+
expect(await getCount(groupA)).toBe(3);
|
|
202
|
+
expect(await getCount(groupB)).toBe(2);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("rebuild on empty event log is a no-op with 0 events processed", async () => {
|
|
206
|
+
const result = await rebuildProjection(qualifiedProjectionName, {
|
|
207
|
+
db: testDb.db,
|
|
208
|
+
registry,
|
|
209
|
+
});
|
|
210
|
+
expect(result.eventsProcessed).toBe(0);
|
|
211
|
+
expect(result.lastProcessedEventId).toBe(0n);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("rebuildProjection — state table lifecycle", () => {
|
|
216
|
+
test("writes state row with status=idle + lastRebuildAt after success", async () => {
|
|
217
|
+
const group = "00000000-0000-4000-8000-000000000020";
|
|
218
|
+
await appendCreatedEvent(group, "one");
|
|
219
|
+
|
|
220
|
+
// Before: no state row.
|
|
221
|
+
expect(await getProjectionState(testDb.db, qualifiedProjectionName)).toBeNull();
|
|
222
|
+
|
|
223
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
224
|
+
|
|
225
|
+
const state = await getProjectionState(testDb.db, qualifiedProjectionName);
|
|
226
|
+
expect(state?.status).toBe("idle");
|
|
227
|
+
expect(state?.lastProcessedEventId).toBeGreaterThan(0n);
|
|
228
|
+
expect(state?.lastRebuildAt).not.toBeNull();
|
|
229
|
+
expect(state?.lastError).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("subsequent rebuild overwrites state row (status + timestamp)", async () => {
|
|
233
|
+
const group = "00000000-0000-4000-8000-000000000021";
|
|
234
|
+
await appendCreatedEvent(group, "first");
|
|
235
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
236
|
+
const first = await getProjectionState(testDb.db, qualifiedProjectionName);
|
|
237
|
+
|
|
238
|
+
// Wait a tick so timestamp difference is visible.
|
|
239
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
240
|
+
await appendCreatedEvent(group, "second");
|
|
241
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
242
|
+
|
|
243
|
+
const second = await getProjectionState(testDb.db, qualifiedProjectionName);
|
|
244
|
+
if (!first?.lastRebuildAt || !second?.lastRebuildAt) throw new Error("missing lastRebuildAt");
|
|
245
|
+
expect(Temporal.Instant.compare(second.lastRebuildAt, first.lastRebuildAt)).toBeGreaterThan(0);
|
|
246
|
+
expect(second?.lastProcessedEventId).toBeGreaterThan(first?.lastProcessedEventId ?? 0n);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("rebuildProjection — error path", () => {
|
|
251
|
+
test("apply throw rolls TRUNCATE + partial replay back, marks status=failed", async () => {
|
|
252
|
+
const group = "00000000-0000-4000-8000-000000000030";
|
|
253
|
+
await appendCreatedEvent(group, "keeper-1");
|
|
254
|
+
await appendCreatedEvent(group, "keeper-2");
|
|
255
|
+
|
|
256
|
+
// First rebuild succeeds — leaves counter at 2.
|
|
257
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
258
|
+
expect(await getCount(group)).toBe(2);
|
|
259
|
+
|
|
260
|
+
// Construct a broken registry where apply("rebuild-item.created") throws.
|
|
261
|
+
const brokenFeature = defineFeature("brokentest", (r) => {
|
|
262
|
+
r.entity("rebuild-item", itemEntity);
|
|
263
|
+
r.projection({
|
|
264
|
+
...itemsPerGroupProjection,
|
|
265
|
+
name: "items-per-group",
|
|
266
|
+
apply: {
|
|
267
|
+
"rebuild-item.created": async () => {
|
|
268
|
+
throw new Error("boom");
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
const brokenRegistry = createRegistry([brokenFeature]);
|
|
274
|
+
const brokenName = "brokentest:projection:items-per-group";
|
|
275
|
+
|
|
276
|
+
// Rebuild throws.
|
|
277
|
+
await expect(
|
|
278
|
+
rebuildProjection(brokenName, { db: testDb.db, registry: brokenRegistry }),
|
|
279
|
+
).rejects.toThrow("boom");
|
|
280
|
+
|
|
281
|
+
// Old counter rows are gone (TRUNCATE is inside the TX but this is a
|
|
282
|
+
// DIFFERENT projection). Verify our original projection's rows WERE
|
|
283
|
+
// preserved because the broken rebuild targets a different name.
|
|
284
|
+
expect(await getCount(group)).toBe(2);
|
|
285
|
+
|
|
286
|
+
// State of the broken projection is "failed" with the error message.
|
|
287
|
+
const state = await getProjectionState(testDb.db, brokenName);
|
|
288
|
+
expect(state?.status).toBe("failed");
|
|
289
|
+
expect(state?.lastError).toContain("boom");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("broken rebuild of EXISTING projection keeps OLD rows intact", async () => {
|
|
293
|
+
const group = "00000000-0000-4000-8000-000000000031";
|
|
294
|
+
await appendCreatedEvent(group, "a");
|
|
295
|
+
await appendCreatedEvent(group, "b");
|
|
296
|
+
await appendCreatedEvent(group, "c");
|
|
297
|
+
|
|
298
|
+
// First rebuild leaves counter at 3.
|
|
299
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
300
|
+
expect(await getCount(group)).toBe(3);
|
|
301
|
+
|
|
302
|
+
// Now attempt a rebuild with a broken apply under the SAME projection name.
|
|
303
|
+
const brokenFeature = defineFeature("rebuildtest", (r) => {
|
|
304
|
+
r.entity("rebuild-item", itemEntity);
|
|
305
|
+
r.projection({
|
|
306
|
+
...itemsPerGroupProjection,
|
|
307
|
+
apply: {
|
|
308
|
+
"rebuild-item.created": async () => {
|
|
309
|
+
throw new Error("poisoned");
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
const brokenRegistry = createRegistry([brokenFeature]);
|
|
315
|
+
|
|
316
|
+
await expect(
|
|
317
|
+
rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry: brokenRegistry }),
|
|
318
|
+
).rejects.toThrow("poisoned");
|
|
319
|
+
|
|
320
|
+
// CRITICAL: the old counter rows survive. TRUNCATE happened INSIDE the
|
|
321
|
+
// transaction, so the rollback restored them. Without this the rebuild
|
|
322
|
+
// would be worse than not rebuilding at all.
|
|
323
|
+
expect(await getCount(group)).toBe(3);
|
|
324
|
+
|
|
325
|
+
// State reflects the failure.
|
|
326
|
+
const state = await getProjectionState(testDb.db, qualifiedProjectionName);
|
|
327
|
+
expect(state?.status).toBe("failed");
|
|
328
|
+
expect(state?.lastError).toContain("poisoned");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("unknown projection name throws with helpful message", async () => {
|
|
332
|
+
await expect(rebuildProjection("nonexistent", { db: testDb.db, registry })).rejects.toThrow(
|
|
333
|
+
/not registered/,
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe("listProjectionsWithState", () => {
|
|
339
|
+
test("lists every registered projection with combined state info", async () => {
|
|
340
|
+
// Before any rebuild: state field indicates never-rebuilt.
|
|
341
|
+
const before = await listProjectionsWithState(testDb.db, registry);
|
|
342
|
+
expect(before).toHaveLength(1);
|
|
343
|
+
expect(before[0]?.name).toBe(qualifiedProjectionName);
|
|
344
|
+
expect(before[0]?.status).toBe("never-rebuilt");
|
|
345
|
+
expect(before[0]?.sources).toEqual(["rebuild-item"]);
|
|
346
|
+
|
|
347
|
+
// After rebuild: status reflects DB state.
|
|
348
|
+
await appendCreatedEvent("00000000-0000-4000-8000-000000000040", "x");
|
|
349
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
350
|
+
|
|
351
|
+
const after = await listProjectionsWithState(testDb.db, registry);
|
|
352
|
+
expect(after[0]?.status).toBe("idle");
|
|
353
|
+
expect(after[0]?.lastRebuildAt).not.toBeNull();
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("getAllProjectionProgress", () => {
|
|
358
|
+
test("computes lag = highWaterMark - cursor for caught-up projection", async () => {
|
|
359
|
+
// Empty event-log → HWM=0n, lag=0n, projection never-rebuilt → cursor=0n.
|
|
360
|
+
const empty = await getAllProjectionProgress(testDb.db, registry);
|
|
361
|
+
expect(empty[0]?.highWaterMark).toBe(0n);
|
|
362
|
+
expect(empty[0]?.lag).toBe(0n);
|
|
363
|
+
|
|
364
|
+
// Seed some events but skip rebuild → HWM advances, cursor stays 0n,
|
|
365
|
+
// lag = HWM. This is the "behind" state an ops dashboard sees before
|
|
366
|
+
// someone triggers a rebuild.
|
|
367
|
+
await appendCreatedEvent("00000000-0000-4000-8000-000000000060", "a");
|
|
368
|
+
await appendCreatedEvent("00000000-0000-4000-8000-000000000061", "b");
|
|
369
|
+
await appendCreatedEvent("00000000-0000-4000-8000-000000000062", "c");
|
|
370
|
+
|
|
371
|
+
const behind = await getAllProjectionProgress(testDb.db, registry);
|
|
372
|
+
expect(behind[0]?.highWaterMark).toBe(3n);
|
|
373
|
+
expect(behind[0]?.lastProcessedEventId).toBe(0n);
|
|
374
|
+
expect(behind[0]?.lag).toBe(3n);
|
|
375
|
+
|
|
376
|
+
// Nach rebuild: cursor = HWM, lag wieder 0.
|
|
377
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
378
|
+
const caughtUp = await getAllProjectionProgress(testDb.db, registry);
|
|
379
|
+
expect(caughtUp[0]?.highWaterMark).toBe(3n);
|
|
380
|
+
expect(caughtUp[0]?.lastProcessedEventId).toBe(3n);
|
|
381
|
+
expect(caughtUp[0]?.lag).toBe(0n);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe("rebuildProjection — metrics callback", () => {
|
|
386
|
+
test("invokes onMetrics with the RebuildResult on success", async () => {
|
|
387
|
+
const group = "00000000-0000-4000-8000-000000000050";
|
|
388
|
+
await appendCreatedEvent(group, "a");
|
|
389
|
+
await appendCreatedEvent(group, "b");
|
|
390
|
+
|
|
391
|
+
const calls: Array<{
|
|
392
|
+
projection: string;
|
|
393
|
+
eventsProcessed: number;
|
|
394
|
+
durationMs: number;
|
|
395
|
+
}> = [];
|
|
396
|
+
await rebuildProjection(qualifiedProjectionName, {
|
|
397
|
+
db: testDb.db,
|
|
398
|
+
registry,
|
|
399
|
+
onMetrics: (r) =>
|
|
400
|
+
calls.push({
|
|
401
|
+
projection: r.projection,
|
|
402
|
+
eventsProcessed: r.eventsProcessed,
|
|
403
|
+
durationMs: r.durationMs,
|
|
404
|
+
}),
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
expect(calls).toHaveLength(1);
|
|
408
|
+
expect(calls[0]?.projection).toBe(qualifiedProjectionName);
|
|
409
|
+
expect(calls[0]?.eventsProcessed).toBe(2);
|
|
410
|
+
expect(calls[0]?.durationMs).toBeGreaterThanOrEqual(0);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("rebuildProjection — meter emission", () => {
|
|
415
|
+
test("emits success=true metric + events counter on happy path", async () => {
|
|
416
|
+
const { RecordingMeter } = await import("../../observability/recording-meter");
|
|
417
|
+
const { registerStandardMetrics } = await import("../../observability/standard-metrics");
|
|
418
|
+
|
|
419
|
+
const group = "00000000-0000-4000-8000-000000000060";
|
|
420
|
+
await appendCreatedEvent(group, "a");
|
|
421
|
+
await appendCreatedEvent(group, "b");
|
|
422
|
+
await appendCreatedEvent(group, "c");
|
|
423
|
+
|
|
424
|
+
const events: Array<{
|
|
425
|
+
type: string;
|
|
426
|
+
name: string;
|
|
427
|
+
value: number;
|
|
428
|
+
labels: Record<string, string | number> | undefined;
|
|
429
|
+
}> = [];
|
|
430
|
+
const meter = new RecordingMeter((e) =>
|
|
431
|
+
events.push({
|
|
432
|
+
type: e.type,
|
|
433
|
+
name: e.name,
|
|
434
|
+
value: e.value,
|
|
435
|
+
labels: e.labels as Record<string, string | number> | undefined,
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
registerStandardMetrics(meter);
|
|
439
|
+
|
|
440
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry, meter });
|
|
441
|
+
|
|
442
|
+
const duration = events.find((e) => e.name === "kumiko_projection_rebuild_duration_seconds");
|
|
443
|
+
expect(duration).toBeDefined();
|
|
444
|
+
expect(duration?.type).toBe("histogram.observe");
|
|
445
|
+
expect(duration?.labels?.["projection"]).toBe(qualifiedProjectionName);
|
|
446
|
+
expect(duration?.labels?.["success"]).toBe("true");
|
|
447
|
+
expect(duration?.value).toBeGreaterThanOrEqual(0);
|
|
448
|
+
|
|
449
|
+
const counter = events.find((e) => e.name === "kumiko_projection_rebuild_events_total");
|
|
450
|
+
expect(counter).toBeDefined();
|
|
451
|
+
expect(counter?.type).toBe("counter.inc");
|
|
452
|
+
expect(counter?.value).toBe(3);
|
|
453
|
+
expect(counter?.labels?.["projection"]).toBe(qualifiedProjectionName);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("emits success=false metric when apply throws", async () => {
|
|
457
|
+
const { RecordingMeter } = await import("../../observability/recording-meter");
|
|
458
|
+
const { registerStandardMetrics } = await import("../../observability/standard-metrics");
|
|
459
|
+
|
|
460
|
+
const group = "00000000-0000-4000-8000-000000000061";
|
|
461
|
+
await appendCreatedEvent(group, "a");
|
|
462
|
+
|
|
463
|
+
const brokenFeature = defineFeature("failmeter", (r) => {
|
|
464
|
+
r.entity("rebuild-item", itemEntity);
|
|
465
|
+
r.projection({
|
|
466
|
+
...itemsPerGroupProjection,
|
|
467
|
+
apply: {
|
|
468
|
+
"rebuild-item.created": async () => {
|
|
469
|
+
throw new Error("metric-failure-probe");
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
const brokenRegistry = createRegistry([brokenFeature]);
|
|
475
|
+
|
|
476
|
+
const events: Array<{
|
|
477
|
+
type: string;
|
|
478
|
+
name: string;
|
|
479
|
+
value: number;
|
|
480
|
+
labels: Record<string, string | number> | undefined;
|
|
481
|
+
}> = [];
|
|
482
|
+
const meter = new RecordingMeter((e) =>
|
|
483
|
+
events.push({
|
|
484
|
+
type: e.type,
|
|
485
|
+
name: e.name,
|
|
486
|
+
value: e.value,
|
|
487
|
+
labels: e.labels as Record<string, string | number> | undefined,
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
490
|
+
registerStandardMetrics(meter);
|
|
491
|
+
|
|
492
|
+
await expect(
|
|
493
|
+
rebuildProjection("failmeter:projection:items-per-group", {
|
|
494
|
+
db: testDb.db,
|
|
495
|
+
registry: brokenRegistry,
|
|
496
|
+
meter,
|
|
497
|
+
}),
|
|
498
|
+
).rejects.toThrow("metric-failure-probe");
|
|
499
|
+
|
|
500
|
+
const duration = events.find((e) => e.name === "kumiko_projection_rebuild_duration_seconds");
|
|
501
|
+
expect(duration).toBeDefined();
|
|
502
|
+
expect(duration?.labels?.["success"]).toBe("false");
|
|
503
|
+
expect(duration?.labels?.["projection"]).toBe("failmeter:projection:items-per-group");
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
describe("rebuildProjection — cancellation", () => {
|
|
508
|
+
test("pre-aborted signal: rebuild throws, TRUNCATE rolls back, projection state preserved", async () => {
|
|
509
|
+
// Setup: events on the log + a clean rebuild → projection has known
|
|
510
|
+
// counter state. Then call rebuildProjection with a pre-aborted
|
|
511
|
+
// controller. The first throwIfAborted() inside the apply loop
|
|
512
|
+
// throws, the TX rolls back, and the projection row from the prior
|
|
513
|
+
// good rebuild is still there.
|
|
514
|
+
//
|
|
515
|
+
// Why pre-aborted instead of mid-replay: the apply hook is wired in
|
|
516
|
+
// the projection definition at registry-build-time, so injecting
|
|
517
|
+
// "abort after event N" requires a separate registered projection.
|
|
518
|
+
// This test pins the rollback semantics — a separate test would be
|
|
519
|
+
// needed to exercise mid-loop abort, but the rollback path is the
|
|
520
|
+
// same code so the value-add is small.
|
|
521
|
+
const group = "00000000-0000-4000-8000-0000000000c1";
|
|
522
|
+
for (let i = 0; i < 10; i++) {
|
|
523
|
+
await appendCreatedEvent(group, `cancel-${i}`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
await rebuildProjection(qualifiedProjectionName, {
|
|
527
|
+
db: testDb.db,
|
|
528
|
+
registry,
|
|
529
|
+
});
|
|
530
|
+
const before = await getCount(group);
|
|
531
|
+
expect(before).toBe(10);
|
|
532
|
+
|
|
533
|
+
const controller = new AbortController();
|
|
534
|
+
controller.abort();
|
|
535
|
+
|
|
536
|
+
let thrown: unknown;
|
|
537
|
+
try {
|
|
538
|
+
await rebuildProjection(qualifiedProjectionName, {
|
|
539
|
+
db: testDb.db,
|
|
540
|
+
registry,
|
|
541
|
+
signal: controller.signal,
|
|
542
|
+
});
|
|
543
|
+
} catch (e) {
|
|
544
|
+
thrown = e;
|
|
545
|
+
}
|
|
546
|
+
expect((thrown as Error).name).toBe("AbortError");
|
|
547
|
+
|
|
548
|
+
const after = await getCount(group);
|
|
549
|
+
expect(after).toBe(before);
|
|
550
|
+
});
|
|
551
|
+
});
|