@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,115 @@
|
|
|
1
|
+
import type { DbRunner } from "../db/connection";
|
|
2
|
+
import type {
|
|
3
|
+
AppendEventArgs,
|
|
4
|
+
AppendEventFn,
|
|
5
|
+
AppendEventUnsafeFn,
|
|
6
|
+
KumikoEventTypeMap,
|
|
7
|
+
Registry,
|
|
8
|
+
TenantId,
|
|
9
|
+
} from "../engine/types";
|
|
10
|
+
import { loadAggregate, loadAggregateAsOf, type StoredEvent } from "../event-store/event-store";
|
|
11
|
+
import { upcastStoredEvents } from "../event-store/upcaster";
|
|
12
|
+
import type { FileContext } from "../files/file-handle";
|
|
13
|
+
import { appendDomainEventCore } from "./append-event-core";
|
|
14
|
+
|
|
15
|
+
// Minimal, read+write surface handed to a MultiStreamProjection's apply()
|
|
16
|
+
// when it needs to produce follow-up events (saga / process-manager
|
|
17
|
+
// pattern). Keeps the MSP feature-decoupled: applies don't reach into
|
|
18
|
+
// handler-bridge (no query/write/writeAs), they just read the aggregate
|
|
19
|
+
// stream and append new events — Marten's session scope for projections.
|
|
20
|
+
//
|
|
21
|
+
// TMap propagates the strict event-type-map (see HandlerContext). Default
|
|
22
|
+
// matches the global KumikoEventTypeMap; runtime-pluggable callers route
|
|
23
|
+
// through appendEventUnsafe.
|
|
24
|
+
export type MultiStreamApplyContext<TMap extends object = KumikoEventTypeMap> = {
|
|
25
|
+
// Append a domain event onto an aggregate stream in the CURRENT tx.
|
|
26
|
+
// Schema-validated, archive-guarded, stream-version derived. Metadata
|
|
27
|
+
// inherits from the triggering event (correlationId) + requestContext
|
|
28
|
+
// (causationId is already set to the triggering event.id by the
|
|
29
|
+
// dispatcher wrap). Strict against KumikoEventTypeMap — same contract
|
|
30
|
+
// as HandlerContext.appendEvent (compile-time-validated payload).
|
|
31
|
+
readonly appendEvent: AppendEventFn<TMap>;
|
|
32
|
+
// Escape hatch for runtime-pluggable events without compile-time
|
|
33
|
+
// augmentation. Same runtime semantics; type-surface is `payload: unknown`.
|
|
34
|
+
readonly appendEventUnsafe: AppendEventUnsafeFn;
|
|
35
|
+
// Read an aggregate stream — useful when a saga needs to inspect the
|
|
36
|
+
// current state of a different aggregate before deciding what to emit.
|
|
37
|
+
readonly loadAggregate: (
|
|
38
|
+
aggregateId: string,
|
|
39
|
+
options?: { readonly asOf?: Temporal.Instant },
|
|
40
|
+
) => Promise<readonly StoredEvent[]>;
|
|
41
|
+
// Binary storage handle factory, mirrors AppContext.files. Present when
|
|
42
|
+
// the app booted with `files.storageProvider`; undefined otherwise.
|
|
43
|
+
// Post-processing MSPs (resize, EXIF-strip, virus-scan) read bytes via
|
|
44
|
+
// `ctx.files.ref(payload.storageKey).read()` and write derivates via
|
|
45
|
+
// `.derive("thumb").write(...)` — binaries never ride through events.
|
|
46
|
+
readonly files?: FileContext;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type MultiStreamApplyContextDeps = {
|
|
50
|
+
readonly registry: Registry;
|
|
51
|
+
// TX-scoped DbRunner — the same `tx` the applyFn receives as the 2nd
|
|
52
|
+
// arg. ctx.appendEvent + inline-projections run inside this tx so a
|
|
53
|
+
// throw rolls the whole hop back (consumer retries the triggering
|
|
54
|
+
// event on the next pass).
|
|
55
|
+
readonly db: DbRunner;
|
|
56
|
+
// tenantId + userId of the TRIGGERING event. appendEvent stamps these
|
|
57
|
+
// onto the new event so the causal chain stays tenant-consistent and
|
|
58
|
+
// the downstream audit-trail can reconstruct the acting principal.
|
|
59
|
+
readonly tenantId: TenantId;
|
|
60
|
+
readonly userId: string;
|
|
61
|
+
// MSP's owning feature (prefix of its qualified name). Enforced at
|
|
62
|
+
// emit-site: the MSP cannot ctx.appendEvent a type owned by another
|
|
63
|
+
// feature. Cross-feature reactions are fine inbound (this MSP is
|
|
64
|
+
// subscribed to events from any feature), but outbound appends must
|
|
65
|
+
// stay within the MSP's own feature.
|
|
66
|
+
readonly callerFeature?: string;
|
|
67
|
+
// Same FileContext the outer AppContext carries, passed through so
|
|
68
|
+
// MSP applies can reach binaries without another wiring indirection.
|
|
69
|
+
readonly files?: FileContext;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function createMultiStreamApplyContext(
|
|
73
|
+
deps: MultiStreamApplyContextDeps,
|
|
74
|
+
): MultiStreamApplyContext {
|
|
75
|
+
return {
|
|
76
|
+
...(deps.files ? { files: deps.files } : {}),
|
|
77
|
+
// @cast-boundary engine-bridge — concrete impl conforms to AppendEventFn overload
|
|
78
|
+
appendEvent: (async (args: AppendEventArgs) => {
|
|
79
|
+
await appendDomainEventCore(
|
|
80
|
+
{
|
|
81
|
+
registry: deps.registry,
|
|
82
|
+
db: deps.db,
|
|
83
|
+
tenantId: deps.tenantId,
|
|
84
|
+
userId: deps.userId,
|
|
85
|
+
callSiteLabel: "MSP-apply ctx.appendEvent",
|
|
86
|
+
...(deps.callerFeature && { callerFeature: deps.callerFeature }),
|
|
87
|
+
},
|
|
88
|
+
args,
|
|
89
|
+
);
|
|
90
|
+
}) as AppendEventFn,
|
|
91
|
+
appendEventUnsafe: async (args) => {
|
|
92
|
+
await appendDomainEventCore(
|
|
93
|
+
{
|
|
94
|
+
registry: deps.registry,
|
|
95
|
+
db: deps.db,
|
|
96
|
+
tenantId: deps.tenantId,
|
|
97
|
+
userId: deps.userId,
|
|
98
|
+
callSiteLabel: "MSP-apply ctx.appendEventUnsafe",
|
|
99
|
+
...(deps.callerFeature && { callerFeature: deps.callerFeature }),
|
|
100
|
+
},
|
|
101
|
+
args,
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
loadAggregate: async (aggregateId, options) => {
|
|
106
|
+
const events = options?.asOf
|
|
107
|
+
? await loadAggregateAsOf(deps.db, aggregateId, deps.tenantId, options.asOf)
|
|
108
|
+
: await loadAggregate(deps.db, aggregateId, deps.tenantId);
|
|
109
|
+
return upcastStoredEvents(events, deps.registry.getEventUpcasters(), {
|
|
110
|
+
db: deps.db,
|
|
111
|
+
tenantId: deps.tenantId,
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { asc, eq, getTableName, inArray, sql } from "drizzle-orm";
|
|
2
|
+
import type { DbConnection } from "../db/connection";
|
|
3
|
+
import type { Registry, TenantId } from "../engine/types";
|
|
4
|
+
import {
|
|
5
|
+
eventsTable,
|
|
6
|
+
getEventsHighWaterMark,
|
|
7
|
+
type StoredEvent,
|
|
8
|
+
upcastStoredEvent,
|
|
9
|
+
} from "../event-store";
|
|
10
|
+
import { emitProjectionRebuild } from "../observability/standard-metrics";
|
|
11
|
+
import type { Meter } from "../observability/types/metric";
|
|
12
|
+
import { projectionStateTable } from "./projection-state";
|
|
13
|
+
|
|
14
|
+
// Rebuild a projection from the event log.
|
|
15
|
+
//
|
|
16
|
+
// Mechanics:
|
|
17
|
+
// 1. Lock the projection's state row FOR UPDATE. Concurrent rebuild
|
|
18
|
+
// attempts of the same projection block here instead of racing.
|
|
19
|
+
// 2. Mark status = "rebuilding".
|
|
20
|
+
// 3. TRUNCATE the projection's backing table.
|
|
21
|
+
// 4. Stream events in chronological order, for every apply-key match
|
|
22
|
+
// invoke apply(event, tx). Event-by-event, so two projections of the
|
|
23
|
+
// same source stay semantically identical to the live pipeline.
|
|
24
|
+
// 5. Store the last processed event-id + mark status = "idle".
|
|
25
|
+
//
|
|
26
|
+
// All of that runs in ONE transaction. If apply throws partway through,
|
|
27
|
+
// Postgres rolls back everything — the old projection is still there,
|
|
28
|
+
// status goes back to "idle" via the outer catch, and lastError records
|
|
29
|
+
// what went wrong. A partial/empty projection is never observable.
|
|
30
|
+
//
|
|
31
|
+
// This is an ops-time operation. While a rebuild is in progress, live
|
|
32
|
+
// writes that touch the same projection will also try to insert into the
|
|
33
|
+
// TRUNCATE'd table, triggering either a serialization conflict or (for a
|
|
34
|
+
// new row after TRUNCATE) a noisy conflict. Intended behaviour: rebuild
|
|
35
|
+
// on a quiet entity, or during a deliberate write-pause.
|
|
36
|
+
//
|
|
37
|
+
// Scale limit: single-TX TRUNCATE + replay works as long as your
|
|
38
|
+
// maintenance window absorbs the replay. Effective ceiling depends on
|
|
39
|
+
// payload size, apply() cost, and DB load — measure before trusting it.
|
|
40
|
+
// Beyond that window, plan for a shadow-swap variant. For v1 that's
|
|
41
|
+
// documented as a known boundary in docs/projections.md.
|
|
42
|
+
|
|
43
|
+
export type RebuildResult = {
|
|
44
|
+
readonly projection: string;
|
|
45
|
+
readonly eventsProcessed: number;
|
|
46
|
+
readonly lastProcessedEventId: bigint;
|
|
47
|
+
readonly durationMs: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type RebuildDeps = {
|
|
51
|
+
readonly db: DbConnection;
|
|
52
|
+
readonly registry: Registry;
|
|
53
|
+
// Optional framework meter. When provided, the runner emits the two
|
|
54
|
+
// projection-rebuild metrics (duration histogram + events counter) on both
|
|
55
|
+
// success and failure paths — the Prometheus-facing surface. CLI callers
|
|
56
|
+
// can leave it undefined and rely on stdout feedback.
|
|
57
|
+
readonly meter?: Meter;
|
|
58
|
+
// Lightweight observation callback for tests that want to assert the
|
|
59
|
+
// RebuildResult without spinning up a full meter. Independent of `meter`.
|
|
60
|
+
readonly onMetrics?: (result: RebuildResult) => void;
|
|
61
|
+
// Cancellation. Checked before each event-apply. The transaction is
|
|
62
|
+
// rolled back on abort — a partial rebuild is never observable. Useful
|
|
63
|
+
// when an HTTP-triggered rebuild needs to honour client disconnect, or
|
|
64
|
+
// when a CLI/Job wraps the rebuild in its own AbortController for ops
|
|
65
|
+
// timeout enforcement.
|
|
66
|
+
readonly signal?: AbortSignal;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export async function rebuildProjection(
|
|
70
|
+
projectionName: string,
|
|
71
|
+
deps: RebuildDeps,
|
|
72
|
+
): Promise<RebuildResult> {
|
|
73
|
+
const { db, registry } = deps;
|
|
74
|
+
const projection = registry.getAllProjections().get(projectionName);
|
|
75
|
+
if (!projection) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Projection "${projectionName}" is not registered. Known: ${
|
|
78
|
+
[...registry.getAllProjections().keys()].join(", ") || "(none)"
|
|
79
|
+
}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sources = Array.isArray(projection.source) ? projection.source : [projection.source];
|
|
84
|
+
const startedAt = Date.now();
|
|
85
|
+
let eventsProcessed = 0;
|
|
86
|
+
let lastProcessedEventId = 0n;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await db.transaction(async (tx) => {
|
|
90
|
+
// Lock the state row. Use upsert so a never-rebuilt projection also
|
|
91
|
+
// gets a row. FOR UPDATE would need the row to exist — upsert-first
|
|
92
|
+
// keeps it idempotent.
|
|
93
|
+
await tx
|
|
94
|
+
.insert(projectionStateTable)
|
|
95
|
+
.values({ name: projectionName, status: "rebuilding" })
|
|
96
|
+
.onConflictDoUpdate({
|
|
97
|
+
target: projectionStateTable.name,
|
|
98
|
+
set: {
|
|
99
|
+
status: "rebuilding",
|
|
100
|
+
lastError: null,
|
|
101
|
+
updatedAt: sql`now()`,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Wipe the projection table. drizzle-orm's public API doesn't expose
|
|
106
|
+
// TRUNCATE, so we issue raw SQL — but `getTableName()` is the public
|
|
107
|
+
// accessor for the table's registered name, avoiding Symbol.for()
|
|
108
|
+
// internal lookups. The identifier is still quoted defensively.
|
|
109
|
+
const tableName = getTableName(projection.table);
|
|
110
|
+
await tx.execute(sql.raw(`TRUNCATE TABLE ${quoteIdent(tableName)}`));
|
|
111
|
+
|
|
112
|
+
// Stream events in chronological order for every source. The event
|
|
113
|
+
// type filter (inArray(type, validTypes)) prunes events the projection
|
|
114
|
+
// doesn't care about early — important when a single source has more
|
|
115
|
+
// event types than the projection subscribes to.
|
|
116
|
+
const subscribed = Object.keys(projection.apply);
|
|
117
|
+
if (subscribed.length === 0) {
|
|
118
|
+
// nothing to replay, just mark idle — projection exists but doesn't
|
|
119
|
+
// subscribe to any event types on its sources yet.
|
|
120
|
+
} else {
|
|
121
|
+
const events = (await tx
|
|
122
|
+
.select()
|
|
123
|
+
.from(eventsTable)
|
|
124
|
+
.where(
|
|
125
|
+
sql`${inArray(eventsTable.aggregateType, sources)} AND ${inArray(
|
|
126
|
+
eventsTable.type,
|
|
127
|
+
subscribed,
|
|
128
|
+
)}`,
|
|
129
|
+
)
|
|
130
|
+
.orderBy(asc(eventsTable.id))) as ReadonlyArray<typeof eventsTable.$inferSelect>;
|
|
131
|
+
|
|
132
|
+
// Upcasters run at read time: older stored payloads get walked
|
|
133
|
+
// through the registered r.eventMigration chain until their shape
|
|
134
|
+
// matches the current event version. An apply() written against the
|
|
135
|
+
// v3 shape stays oblivious to v1 payloads still on disk.
|
|
136
|
+
const upcasters = registry.getEventUpcasters();
|
|
137
|
+
for (const row of events) {
|
|
138
|
+
deps.signal?.throwIfAborted();
|
|
139
|
+
const raw: StoredEvent = {
|
|
140
|
+
id: String(row.id),
|
|
141
|
+
aggregateId: row.aggregateId,
|
|
142
|
+
aggregateType: row.aggregateType,
|
|
143
|
+
tenantId: row.tenantId,
|
|
144
|
+
version: row.version,
|
|
145
|
+
type: row.type,
|
|
146
|
+
eventVersion: row.eventVersion,
|
|
147
|
+
payload: row.payload,
|
|
148
|
+
metadata: row.metadata,
|
|
149
|
+
createdAt: row.createdAt,
|
|
150
|
+
createdBy: row.createdBy,
|
|
151
|
+
};
|
|
152
|
+
const storedEvent = await upcastStoredEvent(raw, upcasters, {
|
|
153
|
+
db: tx,
|
|
154
|
+
tenantId: row.tenantId as TenantId,
|
|
155
|
+
});
|
|
156
|
+
const applyFn = projection.apply[row.type];
|
|
157
|
+
// skip: apply-key validation ensures every subscribed type has a
|
|
158
|
+
// handler; defensive check against runtime-mutated registry
|
|
159
|
+
if (!applyFn) continue;
|
|
160
|
+
await applyFn(storedEvent, tx);
|
|
161
|
+
eventsProcessed++;
|
|
162
|
+
lastProcessedEventId = row.id;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Finalize state row.
|
|
167
|
+
await tx
|
|
168
|
+
.update(projectionStateTable)
|
|
169
|
+
.set({
|
|
170
|
+
lastProcessedEventId,
|
|
171
|
+
status: "idle",
|
|
172
|
+
lastRebuildAt: sql`now()`,
|
|
173
|
+
lastError: null,
|
|
174
|
+
updatedAt: sql`now()`,
|
|
175
|
+
})
|
|
176
|
+
.where(eq(projectionStateTable.name, projectionName));
|
|
177
|
+
});
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// Outer catch: TX has been rolled back by Postgres already. Record the
|
|
180
|
+
// failure in a SEPARATE write so ops can see what happened — the
|
|
181
|
+
// rolled-back status change is gone, so we write failed+error now.
|
|
182
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
183
|
+
await db
|
|
184
|
+
.insert(projectionStateTable)
|
|
185
|
+
.values({ name: projectionName, status: "failed", lastError: message })
|
|
186
|
+
.onConflictDoUpdate({
|
|
187
|
+
target: projectionStateTable.name,
|
|
188
|
+
set: { status: "failed", lastError: message, updatedAt: sql`now()` },
|
|
189
|
+
});
|
|
190
|
+
// Failure metric: duration until throw, 0 events "delivered" (the replayed
|
|
191
|
+
// rows were rolled back — counting them would overstate live delivery).
|
|
192
|
+
// success=false label distinguishes these in Prom dashboards.
|
|
193
|
+
if (deps.meter) {
|
|
194
|
+
emitProjectionRebuild(
|
|
195
|
+
deps.meter,
|
|
196
|
+
{ projection: projectionName, success: false },
|
|
197
|
+
(Date.now() - startedAt) / 1000,
|
|
198
|
+
0,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const result: RebuildResult = {
|
|
205
|
+
projection: projectionName,
|
|
206
|
+
eventsProcessed,
|
|
207
|
+
lastProcessedEventId,
|
|
208
|
+
durationMs: Date.now() - startedAt,
|
|
209
|
+
};
|
|
210
|
+
if (deps.meter) {
|
|
211
|
+
emitProjectionRebuild(
|
|
212
|
+
deps.meter,
|
|
213
|
+
{ projection: projectionName, success: true },
|
|
214
|
+
result.durationMs / 1000,
|
|
215
|
+
eventsProcessed,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
deps.onMetrics?.(result);
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Identifier quoting for raw TRUNCATE. Drizzle doesn't expose a safe helper
|
|
223
|
+
// for table-name interpolation in raw SQL; double-quote + escape double-quote
|
|
224
|
+
// matches Postgres identifier rules.
|
|
225
|
+
function quoteIdent(name: string): string {
|
|
226
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Read-only status for one projection. Returns null if the projection was
|
|
230
|
+
// registered but never rebuilt (no row yet).
|
|
231
|
+
export async function getProjectionState(
|
|
232
|
+
db: DbConnection,
|
|
233
|
+
projectionName: string,
|
|
234
|
+
): Promise<{
|
|
235
|
+
readonly name: string;
|
|
236
|
+
readonly status: string;
|
|
237
|
+
readonly lastProcessedEventId: bigint;
|
|
238
|
+
readonly lastRebuildAt: Temporal.Instant | null;
|
|
239
|
+
readonly lastError: string | null;
|
|
240
|
+
readonly updatedAt: Temporal.Instant;
|
|
241
|
+
} | null> {
|
|
242
|
+
const [row] = await db
|
|
243
|
+
.select()
|
|
244
|
+
.from(projectionStateTable)
|
|
245
|
+
.where(eq(projectionStateTable.name, projectionName));
|
|
246
|
+
if (!row) return null;
|
|
247
|
+
return {
|
|
248
|
+
name: row.name,
|
|
249
|
+
status: row.status,
|
|
250
|
+
lastProcessedEventId: row.lastProcessedEventId,
|
|
251
|
+
lastRebuildAt: row.lastRebuildAt,
|
|
252
|
+
lastError: row.lastError,
|
|
253
|
+
updatedAt: row.updatedAt,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// List every registered projection with its current state (if any).
|
|
258
|
+
// The registry is the source-of-truth for which projections exist; the
|
|
259
|
+
// state table holds per-projection rebuild info and may be sparse.
|
|
260
|
+
//
|
|
261
|
+
// Implicit-Projections (auto-registered pro r.entity, eine pro entity)
|
|
262
|
+
// werden default ausgefiltert — sie sind als rebuild-Ziele weiter mit
|
|
263
|
+
// `<feature>:projection:<entity>-entity` adressierbar, aber in `kumiko
|
|
264
|
+
// project list` würden sie das Bild dominieren ohne Mehrwert. Mit
|
|
265
|
+
// `{ includeImplicit: true }` opt-in einschalten.
|
|
266
|
+
export async function listProjectionsWithState(
|
|
267
|
+
db: DbConnection,
|
|
268
|
+
registry: Registry,
|
|
269
|
+
options: { readonly includeImplicit?: boolean } = {},
|
|
270
|
+
): Promise<
|
|
271
|
+
ReadonlyArray<{
|
|
272
|
+
readonly name: string;
|
|
273
|
+
readonly sources: readonly string[];
|
|
274
|
+
readonly status: string;
|
|
275
|
+
readonly lastProcessedEventId: bigint;
|
|
276
|
+
readonly lastRebuildAt: Temporal.Instant | null;
|
|
277
|
+
readonly lastError: string | null;
|
|
278
|
+
}>
|
|
279
|
+
> {
|
|
280
|
+
const projections = registry.getAllProjections();
|
|
281
|
+
const stateRows = await db.select().from(projectionStateTable);
|
|
282
|
+
const stateByName = new Map(stateRows.map((r) => [r.name, r]));
|
|
283
|
+
|
|
284
|
+
return [...projections.values()]
|
|
285
|
+
.filter((proj) => options.includeImplicit === true || !proj.isImplicit)
|
|
286
|
+
.map((proj) => {
|
|
287
|
+
const state = stateByName.get(proj.name);
|
|
288
|
+
const sources = Array.isArray(proj.source) ? proj.source : [proj.source];
|
|
289
|
+
return {
|
|
290
|
+
name: proj.name,
|
|
291
|
+
sources,
|
|
292
|
+
status: state?.status ?? "never-rebuilt",
|
|
293
|
+
lastProcessedEventId: state?.lastProcessedEventId ?? 0n,
|
|
294
|
+
lastRebuildAt: state?.lastRebuildAt ?? null,
|
|
295
|
+
lastError: state?.lastError ?? null,
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export type ProjectionProgress = {
|
|
301
|
+
readonly name: string;
|
|
302
|
+
readonly sources: readonly string[];
|
|
303
|
+
readonly status: string;
|
|
304
|
+
readonly lastProcessedEventId: bigint;
|
|
305
|
+
readonly lastRebuildAt: Temporal.Instant | null;
|
|
306
|
+
readonly lastError: string | null;
|
|
307
|
+
// Global MAX(events.id) at query time.
|
|
308
|
+
readonly highWaterMark: bigint;
|
|
309
|
+
// HWM - cursor. 0n when caught-up. Cannot be negative (that would mean
|
|
310
|
+
// the projection is ahead of HWM = bug). Used by ops dashboards to
|
|
311
|
+
// visualize projection lag.
|
|
312
|
+
readonly lag: bigint;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// Extended variant of listProjectionsWithState that also returns HWM and lag
|
|
316
|
+
// per projection. One extra cheap MAX-aggregate query — no additional
|
|
317
|
+
// roundtrip per projection. Programmatic callers (e.g. a Prometheus gauge
|
|
318
|
+
// exporter) can map the result directly to a `kumiko_projection_lag{name}`
|
|
319
|
+
// gauge.
|
|
320
|
+
export async function getAllProjectionProgress(
|
|
321
|
+
db: DbConnection,
|
|
322
|
+
registry: Registry,
|
|
323
|
+
): Promise<readonly ProjectionProgress[]> {
|
|
324
|
+
const [projections, highWaterMark] = await Promise.all([
|
|
325
|
+
listProjectionsWithState(db, registry),
|
|
326
|
+
getEventsHighWaterMark(db),
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
return projections.map((p) => ({
|
|
330
|
+
...p,
|
|
331
|
+
highWaterMark,
|
|
332
|
+
lag: highWaterMark - p.lastProcessedEventId,
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import type { DbConnection } from "../db/connection";
|
|
3
|
+
import { bigint, index, instant, table as pgTable, text } from "../db/dialect";
|
|
4
|
+
import { tableExists } from "../db/schema-inspection";
|
|
5
|
+
import { pushTables } from "../stack";
|
|
6
|
+
|
|
7
|
+
// Framework-level state for every registered projection. One row per qualified
|
|
8
|
+
// projection name. Written by the rebuild machinery; read by the CLI + any
|
|
9
|
+
// status dashboard. Lives alongside the events table as framework infra —
|
|
10
|
+
// user projection tables stay separate and user-owned.
|
|
11
|
+
//
|
|
12
|
+
// Columns:
|
|
13
|
+
// - name: projection's qualified name (feature:projection:shortname)
|
|
14
|
+
// - lastProcessedEventId: the bigserial `events.id` of the most recent
|
|
15
|
+
// event that was applied. Rebuild uses it as the cursor for what's
|
|
16
|
+
// done; live writes DON'T currently update it (synchronous apply means
|
|
17
|
+
// no meaningful lag, see projections-runner.ts). Once async apply lands
|
|
18
|
+
// in B.3+, this becomes the lag source.
|
|
19
|
+
// - status: "idle" (normal) | "rebuilding" (in-progress) | "failed"
|
|
20
|
+
// - lastRebuildAt: wall-clock time the last full rebuild finished
|
|
21
|
+
// - lastError: last error message when status = "failed" — rebuild sets
|
|
22
|
+
// this from the thrown message so ops can see it in `project status`
|
|
23
|
+
// last_processed_event_id uses a raw DEFAULT 0 instead of .default(0n) because
|
|
24
|
+
// drizzle-kit's JSON snapshot generator cannot serialise bigint literals —
|
|
25
|
+
// `TypeError: Do not know how to serialize a BigInt` bubbles through
|
|
26
|
+
// pushTables → generateMigration. `sql\`0\`` yields the same server-side
|
|
27
|
+
// default without ever putting a bigint in a generated-JSON path.
|
|
28
|
+
export const projectionStateTable = pgTable(
|
|
29
|
+
"kumiko_projections",
|
|
30
|
+
{
|
|
31
|
+
name: text("name").primaryKey(),
|
|
32
|
+
lastProcessedEventId: bigint("last_processed_event_id", { mode: "bigint" })
|
|
33
|
+
.notNull()
|
|
34
|
+
.default(sql`0`),
|
|
35
|
+
status: text("status").notNull().default("idle"),
|
|
36
|
+
lastRebuildAt: instant("last_rebuild_at", { precision: 3 }),
|
|
37
|
+
lastError: text("last_error"),
|
|
38
|
+
updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql`now()`),
|
|
39
|
+
},
|
|
40
|
+
(t) => ({
|
|
41
|
+
statusIdx: index("kumiko_projections_status_idx").on(t.status),
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
export const ProjectionStatuses = {
|
|
46
|
+
idle: "idle",
|
|
47
|
+
rebuilding: "rebuilding",
|
|
48
|
+
failed: "failed",
|
|
49
|
+
} as const;
|
|
50
|
+
export type ProjectionStatus = (typeof ProjectionStatuses)[keyof typeof ProjectionStatuses];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @deprecated Use `ProjectionStatuses` (object form) or the `ProjectionStatus`
|
|
54
|
+
* union type. Tuple alias kept for back-compat with callers that relied on
|
|
55
|
+
* the array form (`z.enum(...)`, runtime iteration) — scheduled for removal
|
|
56
|
+
* after downstream migration.
|
|
57
|
+
*/
|
|
58
|
+
export const PROJECTION_STATUSES = [
|
|
59
|
+
"idle",
|
|
60
|
+
"rebuilding",
|
|
61
|
+
"failed",
|
|
62
|
+
] as const satisfies readonly ProjectionStatus[];
|
|
63
|
+
|
|
64
|
+
// Idempotent table bootstrap. Called by setupTestStack (and createApp once
|
|
65
|
+
// that wires it up) — same pattern as createEventsTable. If the table is
|
|
66
|
+
// already there (second stack in same test DB, production boot after
|
|
67
|
+
// migration), skip cleanly.
|
|
68
|
+
export async function createProjectionStateTable(db: DbConnection): Promise<void> {
|
|
69
|
+
// skip: table already exists — bootstrap is called from multiple paths
|
|
70
|
+
if (await tableExists(db, "public.kumiko_projections")) return;
|
|
71
|
+
await pushTables(db, { kumikoProjections: projectionStateTable });
|
|
72
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { DbRunner } from "../db";
|
|
2
|
+
import type { HandlerContext, LifecycleResult, Registry } from "../engine/types";
|
|
3
|
+
import type { StoredEvent } from "../event-store";
|
|
4
|
+
|
|
5
|
+
// Run custom projections for a save or delete result. Lives INSIDE the
|
|
6
|
+
// transaction that appended the event — a throw from apply() rolls the event
|
|
7
|
+
// back along with any auto-projection write.
|
|
8
|
+
//
|
|
9
|
+
// Why in the pipeline, not in the executor:
|
|
10
|
+
// Executors used to take an optional `registry` per call. Every caller
|
|
11
|
+
// (crud-builder, manual handlers, seed scripts, future replay tools) had to
|
|
12
|
+
// remember to pass it — forgetting meant projections silently didn't fire.
|
|
13
|
+
// Putting the trigger here, keyed off the StoredEvent the executor surfaces
|
|
14
|
+
// on SaveContext/DeleteContext, closes that hole: every write that went
|
|
15
|
+
// through the dispatcher gets its projections, no opt-in needed.
|
|
16
|
+
//
|
|
17
|
+
// Contracts:
|
|
18
|
+
// - Projections receive the exact StoredEvent from the executor. If you
|
|
19
|
+
// hand-craft a SaveContext (tests, non-executor writes), just don't set
|
|
20
|
+
// `event` and the runner no-ops.
|
|
21
|
+
// - `tx`-scoped DbRunner is passed via the registered apply() — we reuse
|
|
22
|
+
// `ctx.db.raw`, which the dispatcher already scoped to the active tx.
|
|
23
|
+
// - Apply-function throws bubble up unchanged. The dispatcher wraps the
|
|
24
|
+
// whole lifecycle in a try/catch that rolls the tx back; the event is
|
|
25
|
+
// gone from the events table just like a rolled-back state change.
|
|
26
|
+
export async function runProjections(result: LifecycleResult, ctx: HandlerContext): Promise<void> {
|
|
27
|
+
// skip: hand-crafted result with no event — nothing to project
|
|
28
|
+
if (!result.event) return;
|
|
29
|
+
await runProjectionsForEvent(result.event, ctx.registry, ctx.db.raw);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Fire every projection whose source matches the event's aggregate type AND
|
|
33
|
+
// that declares an apply-handler for the event's type. Used by both the
|
|
34
|
+
// CRUD path (via runProjections) and the ctx.appendEvent path (domain events
|
|
35
|
+
// emitted inside a write handler). Keeping one function means an auto-event
|
|
36
|
+
// and a r.defineEvent-event land in the same inline-projection pipeline.
|
|
37
|
+
export async function runProjectionsForEvent(
|
|
38
|
+
event: StoredEvent,
|
|
39
|
+
registry: Registry,
|
|
40
|
+
tx: DbRunner,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const projections = registry.getProjectionsForSource(event.aggregateType);
|
|
43
|
+
// skip: no projection feeds off this entity — fast path for the common case
|
|
44
|
+
if (projections.length === 0) return;
|
|
45
|
+
for (const proj of projections) {
|
|
46
|
+
// ImplicitProjections existieren nur für rebuildProjection — der
|
|
47
|
+
// EventStoreExecutor schreibt im Live-Pfad bereits direkt in die
|
|
48
|
+
// Tabelle. Live-Apply der Implicit würde doppelt schreiben → unique
|
|
49
|
+
// key violation. Filter ist Pflicht.
|
|
50
|
+
if (proj.isImplicit) continue;
|
|
51
|
+
const applyFn = proj.apply[event.type];
|
|
52
|
+
// skip: this projection doesn't care about this event type
|
|
53
|
+
if (!applyFn) continue;
|
|
54
|
+
await applyFn(event, tx);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Central registry of all Redis key prefixes used by the framework.
|
|
2
|
+
// Prevents prefix collisions and makes key usage discoverable.
|
|
3
|
+
|
|
4
|
+
export const RedisKeys = {
|
|
5
|
+
idempotency: "kumiko:idempotency:",
|
|
6
|
+
eventDedup: "kumiko:event-dedup:",
|
|
7
|
+
entityCache: "kumiko:cache:",
|
|
8
|
+
lock: "kumiko:lock:",
|
|
9
|
+
events: "kumiko:events",
|
|
10
|
+
rateLimit: "kumiko:rl:",
|
|
11
|
+
} as const;
|