@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,287 @@
|
|
|
1
|
+
// Async event-dispatcher — the AsyncDaemon-pendant for post-commit consumers.
|
|
2
|
+
//
|
|
3
|
+
// What this proves:
|
|
4
|
+
// - A registered r.multiStreamProjection consumer gets called, in events.id
|
|
5
|
+
// order, for every event after the consumer's cursor.
|
|
6
|
+
// - The cursor advances: a second runOnce() with no new events is a no-op.
|
|
7
|
+
// - A handler that throws pauses ONLY that consumer; other consumers keep
|
|
8
|
+
// running independently.
|
|
9
|
+
// - maxAttempts dead-letter: repeated throws eventually mark the consumer
|
|
10
|
+
// status="dead", preserving lastError.
|
|
11
|
+
//
|
|
12
|
+
// Uses setupTestStack's registry-driven wiring so we exercise the same path
|
|
13
|
+
// production would take once ops wires CreateApp. No createEventDispatcher
|
|
14
|
+
// calls in the test — only the registry round-trip.
|
|
15
|
+
|
|
16
|
+
import { sql } from "drizzle-orm";
|
|
17
|
+
import { afterEach, beforeAll, 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 { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
24
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
25
|
+
import { defineFeature, type FeatureDefinition } from "../../engine";
|
|
26
|
+
import type { StoredEvent } from "../../event-store";
|
|
27
|
+
import { eventConsumerStateTable, getAllConsumerProgress, getConsumerState } from "../../pipeline";
|
|
28
|
+
import {
|
|
29
|
+
createEntityTable,
|
|
30
|
+
pushTables,
|
|
31
|
+
resetEventStore,
|
|
32
|
+
setupTestStack,
|
|
33
|
+
type TestStack,
|
|
34
|
+
TestUsers,
|
|
35
|
+
} from "../../stack";
|
|
36
|
+
import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
|
|
37
|
+
|
|
38
|
+
// --- Test fixtures ---
|
|
39
|
+
|
|
40
|
+
// A tiny state table a subscriber mutates so we can observe "the handler was
|
|
41
|
+
// called with this event" without relying on in-memory arrays — the state row
|
|
42
|
+
// survives even if the test framework resets process state.
|
|
43
|
+
const subscriberLogTable = drizzlePgTable("read_dispatcher_subscriber_log", {
|
|
44
|
+
id: drizzleUuid("id").primaryKey().defaultRandom(),
|
|
45
|
+
eventId: drizzleInteger("event_id").notNull(),
|
|
46
|
+
eventType: drizzleUuid("event_type"), // unused, kept to avoid another drizzle type import
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Per-test capture. The subscriber handlers push here; beforeEach resets.
|
|
50
|
+
type CapturedCall = { event: StoredEvent };
|
|
51
|
+
let captureA: CapturedCall[] = [];
|
|
52
|
+
let captureB: CapturedCall[] = [];
|
|
53
|
+
let throwOnEventId: string | null = null;
|
|
54
|
+
|
|
55
|
+
const testFeature: FeatureDefinition = defineFeature("dispatchertest", (r) => {
|
|
56
|
+
r.entity("widget", sharedWidgetEntity);
|
|
57
|
+
|
|
58
|
+
// MSP A: happy-path observer.
|
|
59
|
+
r.multiStreamProjection({
|
|
60
|
+
name: "observer-a",
|
|
61
|
+
apply: {
|
|
62
|
+
"widget.created": async (event) => {
|
|
63
|
+
captureA.push({ event });
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// MSP B: independent cursor + fault-injection hook. When `throwOnEventId`
|
|
69
|
+
// matches, throws → pauses B while A continues.
|
|
70
|
+
r.multiStreamProjection({
|
|
71
|
+
name: "observer-b",
|
|
72
|
+
apply: {
|
|
73
|
+
"widget.created": async (event) => {
|
|
74
|
+
if (throwOnEventId && event.id === throwOnEventId) {
|
|
75
|
+
throw new Error(`injected-failure-on-event-${event.id}`);
|
|
76
|
+
}
|
|
77
|
+
captureB.push({ event });
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const admin = TestUsers.admin;
|
|
84
|
+
let stack: TestStack;
|
|
85
|
+
let tdb: TenantDb;
|
|
86
|
+
|
|
87
|
+
const qnA = "dispatchertest:projection:observer-a";
|
|
88
|
+
const qnB = "dispatchertest:projection:observer-b";
|
|
89
|
+
|
|
90
|
+
const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
|
|
91
|
+
entityName: "widget",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
beforeAll(async () => {
|
|
95
|
+
stack = await setupTestStack({
|
|
96
|
+
features: [testFeature],
|
|
97
|
+
// Keep hooks off — we're testing the dispatcher, not the legacy postSave
|
|
98
|
+
// hook chain. SSE / search are irrelevant to cursor behaviour.
|
|
99
|
+
systemHooks: [],
|
|
100
|
+
});
|
|
101
|
+
await createEntityTable(stack.db, sharedWidgetEntity, "widget");
|
|
102
|
+
await pushTables(stack.db, { subscriberLog: subscriberLogTable });
|
|
103
|
+
tdb = createTenantDb(stack.db, admin.tenantId);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(async () => {
|
|
107
|
+
captureA = [];
|
|
108
|
+
captureB = [];
|
|
109
|
+
throwOnEventId = null;
|
|
110
|
+
// Wipe events + cursor state so each test starts at event.id=0 cleanly.
|
|
111
|
+
await resetEventStore(stack, ["read_widgets", "read_dispatcher_subscriber_log"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
async function appendWidget(name: string): Promise<void> {
|
|
115
|
+
await executor.create({ name }, admin, tdb);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Tests ---
|
|
119
|
+
|
|
120
|
+
describe("event-dispatcher — happy path", () => {
|
|
121
|
+
test("registered subscribers receive every event in id order", async () => {
|
|
122
|
+
await appendWidget("one");
|
|
123
|
+
await appendWidget("two");
|
|
124
|
+
await appendWidget("three");
|
|
125
|
+
|
|
126
|
+
// Before runOnce: nothing delivered, but state row pre-registered with
|
|
127
|
+
// cursor=0 (Sprint-E strict pre-reg — see EventDispatcher.start /
|
|
128
|
+
// ensureRegistered). Lazy-bootstrap in acquireConsumerState was removed
|
|
129
|
+
// because it opened a race against prune; the row is now guaranteed to
|
|
130
|
+
// exist from dispatcher boot.
|
|
131
|
+
expect(captureA).toHaveLength(0);
|
|
132
|
+
const preState = await getConsumerState(stack.db, qnA);
|
|
133
|
+
expect(preState?.lastProcessedEventId).toBe(0n);
|
|
134
|
+
expect(preState?.status).toBe("idle");
|
|
135
|
+
|
|
136
|
+
const result = await stack.eventDispatcher?.runOnce();
|
|
137
|
+
expect(result?.byConsumer[qnA]).toEqual({ processed: 3, failed: 0 });
|
|
138
|
+
expect(result?.byConsumer[qnB]).toEqual({ processed: 3, failed: 0 });
|
|
139
|
+
|
|
140
|
+
// Both consumers observed all three creates.
|
|
141
|
+
expect(captureA.map((c) => c.event.type)).toEqual([
|
|
142
|
+
"widget.created",
|
|
143
|
+
"widget.created",
|
|
144
|
+
"widget.created",
|
|
145
|
+
]);
|
|
146
|
+
expect(captureB.map((c) => c.event.type)).toEqual([
|
|
147
|
+
"widget.created",
|
|
148
|
+
"widget.created",
|
|
149
|
+
"widget.created",
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
// Cursor advanced independently for each consumer.
|
|
153
|
+
const stateA = await getConsumerState(stack.db, qnA);
|
|
154
|
+
const stateB = await getConsumerState(stack.db, qnB);
|
|
155
|
+
expect(stateA?.status).toBe("idle");
|
|
156
|
+
expect(stateA?.lastProcessedEventId).toBeGreaterThan(0n);
|
|
157
|
+
expect(stateB?.status).toBe("idle");
|
|
158
|
+
expect(stateB?.lastProcessedEventId).toEqual(stateA?.lastProcessedEventId);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("second runOnce with no new events is a no-op", async () => {
|
|
162
|
+
await appendWidget("one");
|
|
163
|
+
await stack.eventDispatcher?.runOnce();
|
|
164
|
+
expect(captureA).toHaveLength(1);
|
|
165
|
+
|
|
166
|
+
const second = await stack.eventDispatcher?.runOnce();
|
|
167
|
+
expect(second?.byConsumer[qnA]).toEqual({ processed: 0, failed: 0 });
|
|
168
|
+
// Still only one — consumer correctly saw "nothing new past my cursor".
|
|
169
|
+
expect(captureA).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("cursor advances only past successfully-consumed events", async () => {
|
|
173
|
+
await appendWidget("one");
|
|
174
|
+
const first = await stack.eventDispatcher?.runOnce();
|
|
175
|
+
const cursorAfterFirst = first?.byConsumer[qnA]?.processed;
|
|
176
|
+
expect(cursorAfterFirst).toBe(1);
|
|
177
|
+
|
|
178
|
+
await appendWidget("two");
|
|
179
|
+
await appendWidget("three");
|
|
180
|
+
const second = await stack.eventDispatcher?.runOnce();
|
|
181
|
+
// Exactly the two new ones — no replay of "one", no skip.
|
|
182
|
+
expect(second?.byConsumer[qnA]).toEqual({ processed: 2, failed: 0 });
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("event-dispatcher — isolation between consumers", () => {
|
|
187
|
+
test("a throwing subscriber halts ITS cursor only; others keep advancing", async () => {
|
|
188
|
+
await appendWidget("safe-1");
|
|
189
|
+
await appendWidget("poison");
|
|
190
|
+
await appendWidget("safe-3");
|
|
191
|
+
|
|
192
|
+
// Inject the throw on the SECOND event for observer-b.
|
|
193
|
+
// observer-a should see all three; observer-b should only see the first.
|
|
194
|
+
// Without isolation, a cross-consumer error would break both — we prove
|
|
195
|
+
// the per-consumer transaction boundary holds.
|
|
196
|
+
// Pre-registered state rows exist from boot (strict Sprint-E mode) — at
|
|
197
|
+
// this point they're at cursor=0 / status=idle for both observers.
|
|
198
|
+
const state = await stack.db
|
|
199
|
+
.select()
|
|
200
|
+
.from(eventConsumerStateTable)
|
|
201
|
+
.where(sql`${eventConsumerStateTable.name} = ${qnA}`);
|
|
202
|
+
expect(state).toHaveLength(1);
|
|
203
|
+
expect(state[0]?.lastProcessedEventId).toBe(0n);
|
|
204
|
+
|
|
205
|
+
// event.id is bigint, coerce to string for the check inside the handler
|
|
206
|
+
throwOnEventId = "2";
|
|
207
|
+
|
|
208
|
+
await stack.eventDispatcher?.runOnce();
|
|
209
|
+
|
|
210
|
+
// observer-a saw everything.
|
|
211
|
+
expect(captureA).toHaveLength(3);
|
|
212
|
+
// observer-b saw only the first event; the throw on event 2 stopped it.
|
|
213
|
+
expect(captureB).toHaveLength(1);
|
|
214
|
+
expect(captureB[0]?.event.payload["name"]).toBe("safe-1");
|
|
215
|
+
|
|
216
|
+
const stateA = await getConsumerState(stack.db, qnA);
|
|
217
|
+
const stateB = await getConsumerState(stack.db, qnB);
|
|
218
|
+
|
|
219
|
+
expect(stateA?.status).toBe("idle");
|
|
220
|
+
expect(stateA?.lastProcessedEventId).toBe(3n);
|
|
221
|
+
|
|
222
|
+
// B is still idle (not yet dead — only 1 attempt), cursor stopped at 1
|
|
223
|
+
// (the last successfully-processed event).
|
|
224
|
+
expect(stateB?.status).toBe("idle");
|
|
225
|
+
expect(stateB?.lastProcessedEventId).toBe(1n);
|
|
226
|
+
expect(stateB?.attempts).toBe(1);
|
|
227
|
+
expect(stateB?.lastError).toMatch(/injected-failure-on-event-2/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("repeated throws eventually mark the consumer dead", async () => {
|
|
231
|
+
await appendWidget("poison-start");
|
|
232
|
+
throwOnEventId = "1";
|
|
233
|
+
|
|
234
|
+
// Default maxAttempts = 10. Drive runOnce that many times to exhaust.
|
|
235
|
+
for (let i = 0; i < 10; i++) {
|
|
236
|
+
await stack.eventDispatcher?.runOnce();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const stateB = await getConsumerState(stack.db, qnB);
|
|
240
|
+
expect(stateB?.status).toBe("dead");
|
|
241
|
+
expect(stateB?.attempts).toBe(10);
|
|
242
|
+
expect(stateB?.lastError).toMatch(/injected-failure-on-event-1/);
|
|
243
|
+
|
|
244
|
+
// observer-a is unaffected.
|
|
245
|
+
expect(captureA).toHaveLength(1);
|
|
246
|
+
const stateA = await getConsumerState(stack.db, qnA);
|
|
247
|
+
expect(stateA?.status).toBe("idle");
|
|
248
|
+
expect(stateA?.lastProcessedEventId).toBe(1n);
|
|
249
|
+
|
|
250
|
+
// A dead consumer skips further passes even if new events arrive.
|
|
251
|
+
await appendWidget("after-death");
|
|
252
|
+
await stack.eventDispatcher?.runOnce();
|
|
253
|
+
// observer-a picked up the new event.
|
|
254
|
+
expect(captureA).toHaveLength(2);
|
|
255
|
+
// observer-b stayed dead — no further attempts.
|
|
256
|
+
const stateBAfter = await getConsumerState(stack.db, qnB);
|
|
257
|
+
expect(stateBAfter?.status).toBe("dead");
|
|
258
|
+
expect(stateBAfter?.attempts).toBe(10);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("getAllConsumerProgress — Ops-View für consumer lag", () => {
|
|
263
|
+
test("lag = highWaterMark - cursor pro consumer, caught-up = 0n", async () => {
|
|
264
|
+
// Caught-up state nach normal flow.
|
|
265
|
+
await appendWidget("one");
|
|
266
|
+
await appendWidget("two");
|
|
267
|
+
await stack.eventDispatcher?.runOnce();
|
|
268
|
+
|
|
269
|
+
const caughtUp = await getAllConsumerProgress(stack.db, [qnA, qnB]);
|
|
270
|
+
const a = caughtUp.find((c) => c.name === qnA);
|
|
271
|
+
const b = caughtUp.find((c) => c.name === qnB);
|
|
272
|
+
expect(a?.highWaterMark).toBe(2n);
|
|
273
|
+
expect(a?.lag).toBe(0n);
|
|
274
|
+
expect(b?.lag).toBe(0n);
|
|
275
|
+
|
|
276
|
+
// Three more events without runOnce → consumers fall behind.
|
|
277
|
+
await appendWidget("three");
|
|
278
|
+
await appendWidget("four");
|
|
279
|
+
await appendWidget("five");
|
|
280
|
+
|
|
281
|
+
const lagged = await getAllConsumerProgress(stack.db, [qnA, qnB]);
|
|
282
|
+
const aLag = lagged.find((c) => c.name === qnA);
|
|
283
|
+
expect(aLag?.highWaterMark).toBe(5n);
|
|
284
|
+
expect(aLag?.lastProcessedEventId).toBe(2n);
|
|
285
|
+
expect(aLag?.lag).toBe(3n);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// E.2 — Retention for the events-table. The claims pinned here:
|
|
2
|
+
//
|
|
3
|
+
// 1. aggregateTypes is REQUIRED. No default — the caller has to name
|
|
4
|
+
// what they're destroying.
|
|
5
|
+
// 2. The consumer-lag guard: if any ACTIVE consumer's cursor is below
|
|
6
|
+
// the largest candidate event id, pruning refuses with
|
|
7
|
+
// ConsumerLagError. Disabled consumers are ignored (ops parks them
|
|
8
|
+
// before a big prune).
|
|
9
|
+
// 3. Dry-run: reports the count, deletes nothing.
|
|
10
|
+
// 4. olderThanDays / olderThan convenience: both resolve to the same
|
|
11
|
+
// cutoff semantics (createdAt < cutoff).
|
|
12
|
+
|
|
13
|
+
import { eq, sql } from "drizzle-orm";
|
|
14
|
+
import { afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
15
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
16
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
17
|
+
import { defineFeature } from "../../engine";
|
|
18
|
+
import { eventsTable } from "../../event-store";
|
|
19
|
+
import {
|
|
20
|
+
ConsumerLagError,
|
|
21
|
+
disableConsumer,
|
|
22
|
+
eventConsumerStateTable,
|
|
23
|
+
pruneEvents,
|
|
24
|
+
} from "../../pipeline";
|
|
25
|
+
import {
|
|
26
|
+
createEntityTable,
|
|
27
|
+
resetEventStore,
|
|
28
|
+
setupTestStack,
|
|
29
|
+
type TestStack,
|
|
30
|
+
TestUsers,
|
|
31
|
+
} from "../../stack";
|
|
32
|
+
import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
|
|
33
|
+
import { generateId } from "../../utils";
|
|
34
|
+
|
|
35
|
+
// --- Fixture ---
|
|
36
|
+
|
|
37
|
+
const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
|
|
38
|
+
entityName: "widget",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const retentionFeature = defineFeature("retention", (r) => {
|
|
42
|
+
r.entity("widget", sharedWidgetEntity);
|
|
43
|
+
// A single MSP so the events-table writes but no consumer has advanced
|
|
44
|
+
// cursor by default (we drive cursor via runOnce in tests that care about
|
|
45
|
+
// the lag guard).
|
|
46
|
+
r.multiStreamProjection({
|
|
47
|
+
name: "observer",
|
|
48
|
+
apply: {
|
|
49
|
+
"widget.created": async () => {},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const admin = TestUsers.admin;
|
|
55
|
+
const observerQn = "retention:projection:observer";
|
|
56
|
+
let stack: TestStack;
|
|
57
|
+
let tdb: TenantDb;
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
stack = await setupTestStack({
|
|
61
|
+
features: [retentionFeature],
|
|
62
|
+
systemHooks: [],
|
|
63
|
+
});
|
|
64
|
+
await createEntityTable(stack.db, sharedWidgetEntity, "widget");
|
|
65
|
+
tdb = createTenantDb(stack.db, admin.tenantId);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(async () => {
|
|
69
|
+
await resetEventStore(stack, ["read_widgets"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Seed an aggregate event directly with a specific createdAt. Bypasses the
|
|
73
|
+
// executor so we can stamp events in the past for prune tests. Aggregate
|
|
74
|
+
// type defaults to "widget" — matches the test feature's entity type.
|
|
75
|
+
async function seedOldAggregateEvent(
|
|
76
|
+
createdAt: Temporal.Instant,
|
|
77
|
+
type: string,
|
|
78
|
+
aggregateType = "widget",
|
|
79
|
+
): Promise<bigint> {
|
|
80
|
+
const [row] = await stack.db
|
|
81
|
+
.insert(eventsTable)
|
|
82
|
+
.values({
|
|
83
|
+
aggregateId: generateId(),
|
|
84
|
+
aggregateType,
|
|
85
|
+
tenantId: admin.tenantId,
|
|
86
|
+
version: 1,
|
|
87
|
+
type,
|
|
88
|
+
payload: {},
|
|
89
|
+
metadata: { userId: admin.id },
|
|
90
|
+
createdAt,
|
|
91
|
+
createdBy: admin.id,
|
|
92
|
+
})
|
|
93
|
+
.returning({ id: eventsTable.id });
|
|
94
|
+
if (!row) throw new Error("seed failed");
|
|
95
|
+
return row.id;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function appendAggregateWidget(name: string): Promise<void> {
|
|
99
|
+
await executor.create({ name }, admin, tdb);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Tests ---
|
|
103
|
+
|
|
104
|
+
describe("E.2 — explicit-aggregateTypes pruning", () => {
|
|
105
|
+
test("aggregate-type events NOT named in aggregateTypes are untouched", async () => {
|
|
106
|
+
// Seed an "obsolete" aggregate type + a "widget" event, both aged.
|
|
107
|
+
const tenDaysAgo = Temporal.Now.instant().subtract({ hours: 240 });
|
|
108
|
+
const obsoleteId = await seedOldAggregateEvent(tenDaysAgo, "obsolete.v1", "obsolete");
|
|
109
|
+
const widgetId = await seedOldAggregateEvent(tenDaysAgo, "widget.legacy", "widget");
|
|
110
|
+
|
|
111
|
+
// Disable the single consumer so the lag guard doesn't interfere. The
|
|
112
|
+
// row was auto-registered by setupTestStack (strict Sprint-E mode);
|
|
113
|
+
// flip its status to disabled instead of inserting a duplicate.
|
|
114
|
+
await disableConsumer(stack.db, observerQn);
|
|
115
|
+
|
|
116
|
+
// Prune only the obsolete type — widget events survive.
|
|
117
|
+
const result = await pruneEvents(stack.db, {
|
|
118
|
+
olderThanDays: 7,
|
|
119
|
+
aggregateTypes: ["obsolete"],
|
|
120
|
+
});
|
|
121
|
+
expect(result.deletedCount).toBe(1);
|
|
122
|
+
expect(result.aggregateTypes).toEqual(["obsolete"]);
|
|
123
|
+
|
|
124
|
+
const remaining = await stack.db.select().from(eventsTable);
|
|
125
|
+
const ids = remaining.map((r) => r.id);
|
|
126
|
+
expect(ids).toContain(widgetId);
|
|
127
|
+
expect(ids).not.toContain(obsoleteId);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("named aggregate-type events older than the cutoff are deleted; fresh ones stay", async () => {
|
|
131
|
+
const tenDaysAgo = Temporal.Now.instant().subtract({ hours: 240 });
|
|
132
|
+
const freshId = await seedOldAggregateEvent(
|
|
133
|
+
Temporal.Now.instant(),
|
|
134
|
+
"obsolete.fresh",
|
|
135
|
+
"obsolete",
|
|
136
|
+
);
|
|
137
|
+
const staleId = await seedOldAggregateEvent(tenDaysAgo, "obsolete.stale", "obsolete");
|
|
138
|
+
|
|
139
|
+
// Disable the auto-registered consumer so the lag guard passes — the
|
|
140
|
+
// consumer is at cursor=0 and would otherwise block a prune that
|
|
141
|
+
// touches higher event ids.
|
|
142
|
+
await disableConsumer(stack.db, observerQn);
|
|
143
|
+
const result = await pruneEvents(stack.db, {
|
|
144
|
+
olderThanDays: 7,
|
|
145
|
+
aggregateTypes: ["obsolete"],
|
|
146
|
+
});
|
|
147
|
+
expect(result.deletedCount).toBe(1);
|
|
148
|
+
|
|
149
|
+
const remaining = await stack.db.select().from(eventsTable);
|
|
150
|
+
const ids = remaining.map((r) => r.id).sort();
|
|
151
|
+
expect(ids).toEqual([freshId]);
|
|
152
|
+
expect(ids.includes(staleId)).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("dry-run reports the count but deletes nothing", async () => {
|
|
156
|
+
const tenDaysAgo = Temporal.Now.instant().subtract({ hours: 240 });
|
|
157
|
+
await seedOldAggregateEvent(tenDaysAgo, "obsolete.drain", "obsolete");
|
|
158
|
+
|
|
159
|
+
await disableConsumer(stack.db, observerQn);
|
|
160
|
+
const result = await pruneEvents(stack.db, {
|
|
161
|
+
olderThanDays: 7,
|
|
162
|
+
aggregateTypes: ["obsolete"],
|
|
163
|
+
dryRun: true,
|
|
164
|
+
});
|
|
165
|
+
expect(result.deletedCount).toBe(1);
|
|
166
|
+
expect(result.dryRun).toBe(true);
|
|
167
|
+
|
|
168
|
+
const remaining = await stack.db.select().from(eventsTable);
|
|
169
|
+
expect(remaining).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("E.2 — consumer-lag guard", () => {
|
|
174
|
+
test("throws ConsumerLagError when an active consumer has not caught up", async () => {
|
|
175
|
+
// Append 3 aggregate events. Consumer will process only the first,
|
|
176
|
+
// stop advancing, and sit at cursor=1. Then we try to prune — the
|
|
177
|
+
// guard should see candidates up to id=3 and throw.
|
|
178
|
+
await appendAggregateWidget("one");
|
|
179
|
+
await appendAggregateWidget("two");
|
|
180
|
+
await appendAggregateWidget("three");
|
|
181
|
+
|
|
182
|
+
// Only let the first one through.
|
|
183
|
+
await stack.eventDispatcher?.runOnce();
|
|
184
|
+
// Force cursor to 1 so the guard sees "consumer at 1, max candidate 3".
|
|
185
|
+
await stack.db
|
|
186
|
+
.update(eventConsumerStateTable)
|
|
187
|
+
.set({ lastProcessedEventId: 1n, status: "idle" })
|
|
188
|
+
.where(eq(eventConsumerStateTable.name, observerQn));
|
|
189
|
+
|
|
190
|
+
// Age all three events past the cutoff.
|
|
191
|
+
await stack.db.execute(sql`UPDATE kumiko_events SET created_at = now() - interval '30 days'`);
|
|
192
|
+
|
|
193
|
+
await expect(
|
|
194
|
+
pruneEvents(stack.db, {
|
|
195
|
+
olderThanDays: 7,
|
|
196
|
+
aggregateTypes: ["widget"],
|
|
197
|
+
}),
|
|
198
|
+
).rejects.toThrow(ConsumerLagError);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("disabled consumers are ignored by the lag guard", async () => {
|
|
202
|
+
await appendAggregateWidget("solo");
|
|
203
|
+
await stack.eventDispatcher?.runOnce();
|
|
204
|
+
await disableConsumer(stack.db, observerQn);
|
|
205
|
+
|
|
206
|
+
// Cursor is at 1 but consumer is disabled — should be skipped.
|
|
207
|
+
await stack.db.execute(sql`UPDATE kumiko_events SET created_at = now() - interval '30 days'`);
|
|
208
|
+
|
|
209
|
+
const result = await pruneEvents(stack.db, {
|
|
210
|
+
olderThanDays: 7,
|
|
211
|
+
aggregateTypes: ["widget"],
|
|
212
|
+
});
|
|
213
|
+
expect(result.deletedCount).toBe(1);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("E.2 — empty sets and input validation", () => {
|
|
218
|
+
test("returns zero when nothing matches the cutoff", async () => {
|
|
219
|
+
const result = await pruneEvents(stack.db, {
|
|
220
|
+
olderThanDays: 365,
|
|
221
|
+
aggregateTypes: ["widget"],
|
|
222
|
+
});
|
|
223
|
+
expect(result.deletedCount).toBe(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("refuses call without olderThan or olderThanDays", async () => {
|
|
227
|
+
await expect(pruneEvents(stack.db, { aggregateTypes: ["widget"] })).rejects.toThrow(
|
|
228
|
+
/olderThan/,
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("refuses call without aggregateTypes", async () => {
|
|
233
|
+
// Typescript would catch this at compile time, but the runtime guard
|
|
234
|
+
// exists for JS callers and JSON-config-driven cron pipes.
|
|
235
|
+
await expect(
|
|
236
|
+
pruneEvents(stack.db, { olderThanDays: 7 } as unknown as Parameters<typeof pruneEvents>[1]),
|
|
237
|
+
).rejects.toThrow(/aggregateTypes/);
|
|
238
|
+
});
|
|
239
|
+
});
|