@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,409 @@
|
|
|
1
|
+
import type { DbRow } from "../db/connection";
|
|
2
|
+
import type {
|
|
3
|
+
AppContext,
|
|
4
|
+
DeleteContext,
|
|
5
|
+
HookPhase,
|
|
6
|
+
PostDeleteBatchHookFn,
|
|
7
|
+
PostDeleteHookFn,
|
|
8
|
+
PostSaveBatchHookFn,
|
|
9
|
+
PostSaveHookFn,
|
|
10
|
+
PreDeleteHookFn,
|
|
11
|
+
PreSaveHookFn,
|
|
12
|
+
Registry,
|
|
13
|
+
SaveContext,
|
|
14
|
+
} from "../engine/types";
|
|
15
|
+
import { HookPhases } from "../engine/types";
|
|
16
|
+
import { getFallbackTracer, type Tracer } from "../observability";
|
|
17
|
+
import type { EventDedup } from "./event-dedup";
|
|
18
|
+
|
|
19
|
+
function resolveTracer(context: AppContext): Tracer {
|
|
20
|
+
return context.tracer ?? getFallbackTracer();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SystemHookDef<TFn> = {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly priority: number;
|
|
26
|
+
readonly fn: TFn;
|
|
27
|
+
// Default: afterCommit (same as user-registered hooks).
|
|
28
|
+
// Set to "inTransaction" for DB-based side-effects (e.g. audit rows)
|
|
29
|
+
// that must roll back with the transaction.
|
|
30
|
+
readonly phase?: HookPhase;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type SystemHooks = {
|
|
34
|
+
readonly preSave?: readonly SystemHookDef<PreSaveHookFn>[];
|
|
35
|
+
readonly postSave?: readonly SystemHookDef<PostSaveHookFn>[];
|
|
36
|
+
// Runs once per dispatcher batch, after every per-save postSave hook and
|
|
37
|
+
// after flushAfterCommit — for adapters that amortise work over the whole
|
|
38
|
+
// batch (search indexBatch, bulk webhook fanout).
|
|
39
|
+
readonly postSaveBatch?: readonly SystemHookDef<PostSaveBatchHookFn>[];
|
|
40
|
+
readonly preDelete?: readonly SystemHookDef<PreDeleteHookFn>[];
|
|
41
|
+
readonly postDelete?: readonly SystemHookDef<PostDeleteHookFn>[];
|
|
42
|
+
readonly postDeleteBatch?: readonly SystemHookDef<PostDeleteBatchHookFn>[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type LifecycleHooks = {
|
|
46
|
+
runPreSave(
|
|
47
|
+
handlerName: string,
|
|
48
|
+
changes: Record<string, unknown>,
|
|
49
|
+
previous: Readonly<Record<string, unknown>>,
|
|
50
|
+
isNew: boolean,
|
|
51
|
+
context: AppContext,
|
|
52
|
+
): Promise<Record<string, unknown>>;
|
|
53
|
+
|
|
54
|
+
// Phase-aware: pass "inTransaction" to run only in-tx hooks during a batch
|
|
55
|
+
// transaction, then "afterCommit" after the transaction commits.
|
|
56
|
+
// Omitting phase runs all hooks (used by legacy call sites — to be removed).
|
|
57
|
+
runPostSave(
|
|
58
|
+
handlerName: string,
|
|
59
|
+
result: SaveContext,
|
|
60
|
+
context: AppContext,
|
|
61
|
+
phase?: HookPhase,
|
|
62
|
+
): Promise<void>;
|
|
63
|
+
|
|
64
|
+
runPreDelete(handlerName: string, payload: DeleteContext, context: AppContext): Promise<void>;
|
|
65
|
+
|
|
66
|
+
runPostDelete(
|
|
67
|
+
handlerName: string,
|
|
68
|
+
payload: DeleteContext,
|
|
69
|
+
context: AppContext,
|
|
70
|
+
phase?: HookPhase,
|
|
71
|
+
): Promise<void>;
|
|
72
|
+
|
|
73
|
+
// Fire the batch-level system hooks once per dispatcher batch, after all
|
|
74
|
+
// per-save hooks and the afterCommit flush. Errors are collected + logged,
|
|
75
|
+
// never rethrown — the writes are already committed.
|
|
76
|
+
runPostSaveBatch(results: readonly SaveContext[], context: AppContext): Promise<void>;
|
|
77
|
+
runPostDeleteBatch(payloads: readonly DeleteContext[], context: AppContext): Promise<void>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type LifecycleOptions = {
|
|
81
|
+
eventDedup?: EventDedup;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function createLifecycleHooks(
|
|
85
|
+
registry: Registry,
|
|
86
|
+
systemHooks: SystemHooks = {},
|
|
87
|
+
options: LifecycleOptions = {},
|
|
88
|
+
): LifecycleHooks {
|
|
89
|
+
const { eventDedup } = options;
|
|
90
|
+
|
|
91
|
+
function sortByPriority<T extends { priority: number }>(hooks: readonly T[]): T[] {
|
|
92
|
+
return [...hooks].sort((a, b) => a.priority - b.priority);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Shared hook execution: runs handler hooks → entity hooks → system hooks.
|
|
96
|
+
//
|
|
97
|
+
// Error handling depends on hookPhase:
|
|
98
|
+
// - inTransaction: errors THROW (roll back transaction)
|
|
99
|
+
// - afterCommit: errors are collected + logged (best-effort)
|
|
100
|
+
//
|
|
101
|
+
// Event dedup is only applied in afterCommit phase, because:
|
|
102
|
+
// - the key must not be consumed if the transaction later rolls back
|
|
103
|
+
// - in-tx hooks run once per commit, dedup there is redundant
|
|
104
|
+
async function runHookSet<TPayload>(opts: {
|
|
105
|
+
handlerName: string;
|
|
106
|
+
payload: TPayload;
|
|
107
|
+
context: AppContext;
|
|
108
|
+
entityName: string | undefined;
|
|
109
|
+
getHandlerHooks: (name: string) => readonly ((p: TPayload, c: AppContext) => Promise<void>)[];
|
|
110
|
+
getEntityHooks: (name: string) => readonly ((p: TPayload, c: AppContext) => Promise<void>)[];
|
|
111
|
+
systemHookDefs:
|
|
112
|
+
| readonly SystemHookDef<(p: TPayload, c: AppContext) => Promise<void>>[]
|
|
113
|
+
| undefined;
|
|
114
|
+
phaseLabel: string;
|
|
115
|
+
hookPhase: HookPhase;
|
|
116
|
+
}): Promise<void> {
|
|
117
|
+
const throwOnError = opts.hookPhase === HookPhases.inTransaction;
|
|
118
|
+
|
|
119
|
+
// Event dedup: only in afterCommit (see comment above).
|
|
120
|
+
//
|
|
121
|
+
// SEMANTICS: pre-claim dedup = "at-most-once". tryAcquire is called before
|
|
122
|
+
// the hook runs, so if the hook crashes mid-execution, the retry sees
|
|
123
|
+
// `acquired=false` and skips the rest. This is the right trade-off for
|
|
124
|
+
// best-effort side-effects like Search Index, SSE broadcast, Audit — a
|
|
125
|
+
// missed hook is preferable to a duplicate notification. Features that
|
|
126
|
+
// need at-least-once semantics must not rely on this path; use
|
|
127
|
+
// ctx.appendEvent + r.multiStreamProjection instead, which the
|
|
128
|
+
// event-dispatcher retries until the consumer advances.
|
|
129
|
+
if (eventDedup && opts.hookPhase === HookPhases.afterCommit) {
|
|
130
|
+
const eventId = buildEventId(opts.handlerName, opts.payload, opts.phaseLabel);
|
|
131
|
+
if (eventId) {
|
|
132
|
+
const acquired = await eventDedup.tryAcquire(eventId);
|
|
133
|
+
if (!acquired) {
|
|
134
|
+
opts.context.log?.debug(
|
|
135
|
+
`${opts.phaseLabel}: skipping ${opts.handlerName} — event ${eventId} already processed (dedup)`,
|
|
136
|
+
);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
// Missing id on a save/delete payload is unexpected — CrudExecutor
|
|
141
|
+
// always returns one. Log so we can spot framework/feature bugs where
|
|
142
|
+
// a handler emits a non-standard LifecycleResult and accidentally
|
|
143
|
+
// bypasses dedup.
|
|
144
|
+
opts.context.log?.warn(
|
|
145
|
+
`${opts.phaseLabel}: ${opts.handlerName} has no dedup id (payload missing \`id\`) — hook may run multiple times on retry`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const errors: Array<{ name: string; error: unknown }> = [];
|
|
151
|
+
const tracer = resolveTracer(opts.context);
|
|
152
|
+
|
|
153
|
+
// Common span attributes — populated per-hook below with source/name.
|
|
154
|
+
const baseAttrs = {
|
|
155
|
+
"kumiko.hook_type": opts.phaseLabel,
|
|
156
|
+
"kumiko.hook_phase": opts.hookPhase,
|
|
157
|
+
"kumiko.handler": opts.handlerName,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
for (const hook of opts.getHandlerHooks(opts.handlerName)) {
|
|
161
|
+
try {
|
|
162
|
+
await tracer.withSpan(
|
|
163
|
+
"kumiko.pipeline.hook",
|
|
164
|
+
{ attributes: { ...baseAttrs, "kumiko.hook_source": "handler" } },
|
|
165
|
+
() => hook(opts.payload, opts.context),
|
|
166
|
+
);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (throwOnError) throw e;
|
|
169
|
+
errors.push({ name: `handler:${opts.handlerName}`, error: e });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Shared runner for entity + system hook sets. In afterCommit phase they
|
|
174
|
+
// run in parallel (independent side-effects, errors are collected); in
|
|
175
|
+
// inTransaction phase they run sequentially (hooks share ctx.db and each
|
|
176
|
+
// writes must be observable to subsequent ones). `itemAttrs` lets the
|
|
177
|
+
// caller attach per-hook span attributes (e.g. the hook name).
|
|
178
|
+
async function runHooks<TItem>(
|
|
179
|
+
items: readonly TItem[],
|
|
180
|
+
itemAttrs: (item: TItem) => Record<string, string>,
|
|
181
|
+
errorName: (item: TItem) => string,
|
|
182
|
+
invoke: (item: TItem) => Promise<void>,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
// skip: no hooks to run for this phase/handler combo
|
|
185
|
+
if (items.length === 0) return;
|
|
186
|
+
const withSpan = (item: TItem) =>
|
|
187
|
+
tracer.withSpan(
|
|
188
|
+
"kumiko.pipeline.hook",
|
|
189
|
+
{ attributes: { ...baseAttrs, ...itemAttrs(item) } },
|
|
190
|
+
() => invoke(item),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (opts.hookPhase === HookPhases.afterCommit) {
|
|
194
|
+
const outcomes = await Promise.allSettled(items.map(withSpan));
|
|
195
|
+
for (let i = 0; i < outcomes.length; i++) {
|
|
196
|
+
const outcome = outcomes[i];
|
|
197
|
+
if (outcome?.status === "rejected") {
|
|
198
|
+
if (throwOnError) throw outcome.reason;
|
|
199
|
+
const item = items[i];
|
|
200
|
+
if (item !== undefined) errors.push({ name: errorName(item), error: outcome.reason });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
for (const item of items) {
|
|
205
|
+
try {
|
|
206
|
+
await withSpan(item);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
if (throwOnError) throw e;
|
|
209
|
+
errors.push({ name: errorName(item), error: e });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (opts.entityName) {
|
|
216
|
+
const entityName = opts.entityName;
|
|
217
|
+
await runHooks(
|
|
218
|
+
opts.getEntityHooks(entityName),
|
|
219
|
+
() => ({ "kumiko.hook_source": "entity", "kumiko.entity": entityName }),
|
|
220
|
+
() => `entity:${entityName}`,
|
|
221
|
+
(hook) => hook(opts.payload, opts.context),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (opts.systemHookDefs) {
|
|
226
|
+
const applicable = sortByPriority(opts.systemHookDefs).filter(
|
|
227
|
+
(h) => (h.phase ?? HookPhases.afterCommit) === opts.hookPhase,
|
|
228
|
+
);
|
|
229
|
+
await runHooks(
|
|
230
|
+
applicable,
|
|
231
|
+
(h) => ({ "kumiko.hook_source": "system", "kumiko.hook_name": h.name }),
|
|
232
|
+
(h) => h.name,
|
|
233
|
+
(h) => h.fn(opts.payload, opts.context),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (errors.length > 0) {
|
|
238
|
+
const log = opts.context.log;
|
|
239
|
+
const msg = `${opts.phaseLabel} errors for ${opts.handlerName}`;
|
|
240
|
+
const details = errors.map((e) => `${e.name}: ${e.error}`);
|
|
241
|
+
if (log) {
|
|
242
|
+
log.error(msg, { errors: details });
|
|
243
|
+
} else {
|
|
244
|
+
console.error(`[lifecycle] ${msg}:`, details);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
async runPreSave(handlerName, changes, previous, isNew, context) {
|
|
251
|
+
let currentChanges = changes;
|
|
252
|
+
const hookContext = { ...context, previous, isNew };
|
|
253
|
+
const eff = context.effectiveFeatures?.();
|
|
254
|
+
|
|
255
|
+
for (const hook of registry.getPreSaveHooks(handlerName, eff)) {
|
|
256
|
+
currentChanges = await hook(currentChanges, hookContext);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (systemHooks.preSave) {
|
|
260
|
+
for (const sysHook of sortByPriority(systemHooks.preSave)) {
|
|
261
|
+
currentChanges = await sysHook.fn(currentChanges, hookContext);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return currentChanges;
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async runPostSave(handlerName, result, context, phase = HookPhases.afterCommit) {
|
|
269
|
+
const eff = context.effectiveFeatures?.();
|
|
270
|
+
await runHookSet({
|
|
271
|
+
handlerName,
|
|
272
|
+
payload: result,
|
|
273
|
+
context,
|
|
274
|
+
entityName: result.entityName,
|
|
275
|
+
getHandlerHooks: (n) => registry.getPostSaveHooks(n, phase, eff),
|
|
276
|
+
getEntityHooks: (n) => registry.getEntityPostSaveHooks(n, phase, eff),
|
|
277
|
+
systemHookDefs: systemHooks.postSave,
|
|
278
|
+
phaseLabel: `postSave:${phase}`,
|
|
279
|
+
hookPhase: phase,
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
async runPreDelete(handlerName, payload, context) {
|
|
284
|
+
// preDelete hooks run in-transaction and throw on failure (not best-effort).
|
|
285
|
+
// They're used to check invariants before delete, so phase filter is "inTransaction".
|
|
286
|
+
const eff = context.effectiveFeatures?.();
|
|
287
|
+
for (const hook of registry.getPreDeleteHooks(handlerName, HookPhases.inTransaction, eff)) {
|
|
288
|
+
await hook(payload, context);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (payload.entityName) {
|
|
292
|
+
for (const hook of registry.getEntityPreDeleteHooks(
|
|
293
|
+
payload.entityName,
|
|
294
|
+
HookPhases.inTransaction,
|
|
295
|
+
eff,
|
|
296
|
+
)) {
|
|
297
|
+
await hook(payload, context);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (systemHooks.preDelete) {
|
|
302
|
+
for (const sysHook of sortByPriority(systemHooks.preDelete)) {
|
|
303
|
+
const sysHookPhase = sysHook.phase ?? HookPhases.inTransaction;
|
|
304
|
+
if (sysHookPhase !== HookPhases.inTransaction) continue;
|
|
305
|
+
await sysHook.fn(payload, context);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
async runPostDelete(handlerName, payload, context, phase = HookPhases.afterCommit) {
|
|
311
|
+
const eff = context.effectiveFeatures?.();
|
|
312
|
+
await runHookSet({
|
|
313
|
+
handlerName,
|
|
314
|
+
payload,
|
|
315
|
+
context,
|
|
316
|
+
entityName: payload.entityName,
|
|
317
|
+
getHandlerHooks: (n) => registry.getPostDeleteHooks(n, phase, eff),
|
|
318
|
+
getEntityHooks: (n) => registry.getEntityPostDeleteHooks(n, phase, eff),
|
|
319
|
+
systemHookDefs: systemHooks.postDelete,
|
|
320
|
+
phaseLabel: `postDelete:${phase}`,
|
|
321
|
+
hookPhase: phase,
|
|
322
|
+
});
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
async runPostSaveBatch(results, context) {
|
|
326
|
+
await runBatchHooks({
|
|
327
|
+
hooks: systemHooks.postSaveBatch,
|
|
328
|
+
payload: results,
|
|
329
|
+
context,
|
|
330
|
+
phaseLabel: "postSaveBatch",
|
|
331
|
+
});
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
async runPostDeleteBatch(payloads, context) {
|
|
335
|
+
await runBatchHooks({
|
|
336
|
+
hooks: systemHooks.postDeleteBatch,
|
|
337
|
+
payload: payloads,
|
|
338
|
+
context,
|
|
339
|
+
phaseLabel: "postDeleteBatch",
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Runs batch hooks in parallel. Errors are logged but never rethrown —
|
|
345
|
+
// batch hooks fire after commit, so there's nothing to roll back.
|
|
346
|
+
async function runBatchHooks<TPayload>(opts: {
|
|
347
|
+
hooks: readonly SystemHookDef<(p: TPayload, c: AppContext) => Promise<void>>[] | undefined;
|
|
348
|
+
payload: TPayload;
|
|
349
|
+
context: AppContext;
|
|
350
|
+
phaseLabel: string;
|
|
351
|
+
}): Promise<void> {
|
|
352
|
+
// skip: no batch hooks registered for this phase
|
|
353
|
+
if (!opts.hooks || opts.hooks.length === 0) return;
|
|
354
|
+
const tracer = resolveTracer(opts.context);
|
|
355
|
+
const baseAttrs = { "kumiko.hook_type": opts.phaseLabel };
|
|
356
|
+
|
|
357
|
+
const outcomes = await Promise.allSettled(
|
|
358
|
+
sortByPriority(opts.hooks).map((sysHook) =>
|
|
359
|
+
tracer.withSpan(
|
|
360
|
+
"kumiko.pipeline.hook",
|
|
361
|
+
{
|
|
362
|
+
attributes: {
|
|
363
|
+
...baseAttrs,
|
|
364
|
+
"kumiko.hook_source": "system",
|
|
365
|
+
"kumiko.hook_name": sysHook.name,
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
() => sysHook.fn(opts.payload, opts.context),
|
|
369
|
+
),
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const failures = outcomes
|
|
374
|
+
.map((o, i) => ({ outcome: o, name: opts.hooks?.[i]?.name ?? "unknown" }))
|
|
375
|
+
.filter(
|
|
376
|
+
(x): x is { outcome: PromiseRejectedResult; name: string } =>
|
|
377
|
+
x.outcome.status === "rejected",
|
|
378
|
+
);
|
|
379
|
+
// skip: all batch hooks succeeded, nothing to log
|
|
380
|
+
if (failures.length === 0) return;
|
|
381
|
+
|
|
382
|
+
const log = opts.context.log;
|
|
383
|
+
const msg = `${opts.phaseLabel} errors`;
|
|
384
|
+
const details = failures.map((f) => `${f.name}: ${f.outcome.reason}`);
|
|
385
|
+
if (log) log.error(msg, { errors: details });
|
|
386
|
+
else console.error(`[lifecycle] ${msg}:`, details);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Build a unique eventId from handler + entity identity + version + phase.
|
|
391
|
+
// version makes it unique per write (incremented on every update).
|
|
392
|
+
// Exported for unit tests — the warn-log path (null return) is otherwise
|
|
393
|
+
// unreachable through the normal LifecycleResult flow.
|
|
394
|
+
export function buildEventId(handlerName: string, payload: unknown, phase: string): string | null {
|
|
395
|
+
if (!payload || typeof payload !== "object") return null;
|
|
396
|
+
const p = payload as DbRow;
|
|
397
|
+
// Aggregate IDs are UUIDs (string) in this framework; legacy int-ids round-
|
|
398
|
+
// trip cleanly through String(). Anything else (undefined, null, object)
|
|
399
|
+
// means the payload doesn't carry a dedup-able identity. Also treat id=0
|
|
400
|
+
// and id="" as absent: serial PKs start at 1 and an empty string is never
|
|
401
|
+
// a valid aggregate id — safer to skip dedup than to collide on a sentinel.
|
|
402
|
+
const rawId = p["id"];
|
|
403
|
+
if (rawId === undefined || rawId === null) return null;
|
|
404
|
+
if (typeof rawId !== "string" && typeof rawId !== "number") return null;
|
|
405
|
+
if (rawId === 0 || rawId === "") return null;
|
|
406
|
+
const data = p["data"] as Record<string, unknown> | undefined; // @cast-boundary engine-payload
|
|
407
|
+
const version = data?.["version"] as number | undefined;
|
|
408
|
+
return `${handlerName}:${rawId}:${version ?? 0}:${phase}`;
|
|
409
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { and, asc, eq, getTableName, inArray, sql } from "drizzle-orm";
|
|
2
|
+
import type { DbConnection, DbRunner } from "../db/connection";
|
|
3
|
+
import type { Registry, TenantId } from "../engine/types";
|
|
4
|
+
import { InternalError } from "../errors";
|
|
5
|
+
import { eventsTable, type StoredEvent, upcastStoredEvent } from "../event-store";
|
|
6
|
+
import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store";
|
|
7
|
+
import { upcastStoredEvents } from "../event-store/upcaster";
|
|
8
|
+
import { emitProjectionRebuild } from "../observability/standard-metrics";
|
|
9
|
+
import type { Meter } from "../observability/types/metric";
|
|
10
|
+
import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
|
|
11
|
+
import type { MultiStreamApplyContext } from "./multi-stream-apply-context";
|
|
12
|
+
import type { RebuildResult } from "./projection-rebuild";
|
|
13
|
+
|
|
14
|
+
// Rebuild a multi-stream projection (MSP) from the event log. Symmetric to
|
|
15
|
+
// `rebuildProjection` for single-stream projections — same single-TX
|
|
16
|
+
// TRUNCATE+replay semantics — but wired against the dispatcher's consumer
|
|
17
|
+
// state row (cursor, not projection-state). MSPs are async-live and
|
|
18
|
+
// cursor-driven; rebuild resets the cursor to 0 and rematerializes the
|
|
19
|
+
// projection table in chronological event order.
|
|
20
|
+
//
|
|
21
|
+
// Why separate from rebuildProjection:
|
|
22
|
+
// - MSP apply signature includes a 3rd ctx arg (MultiStreamApplyContext
|
|
23
|
+
// for saga follow-ups). Rebuild passes a RESTRICTED ctx that allows
|
|
24
|
+
// loadAggregate but rejects appendEvent — the events a saga would emit
|
|
25
|
+
// already live in the log, replaying them would be a double-write.
|
|
26
|
+
// - Event selection is type-only (no aggregateType filter). MSPs subscribe
|
|
27
|
+
// by event-type; source aggregate is irrelevant.
|
|
28
|
+
// - State lives in kumiko_event_consumers (cursor row for the dispatcher),
|
|
29
|
+
// not in kumiko_projections.
|
|
30
|
+
//
|
|
31
|
+
// Side-effect-only MSPs (no `table`) cannot be rebuilt. Replaying would
|
|
32
|
+
// re-invoke the side-effect (webhook, notification, external sync) and
|
|
33
|
+
// produce duplicates by design. The function rejects up-front with a
|
|
34
|
+
// pointer at the consumer skip/restart ops surface.
|
|
35
|
+
//
|
|
36
|
+
// During the rebuild TX:
|
|
37
|
+
// - FOR UPDATE lock on the consumer row blocks concurrent live passes
|
|
38
|
+
// (SKIP LOCKED from the dispatcher backs off silently).
|
|
39
|
+
// - TRUNCATE the projection table.
|
|
40
|
+
// - Stream events matching apply-keys, invoke apply(event, tx, ctx).
|
|
41
|
+
// - Advance cursor to last processed event id, status=idle.
|
|
42
|
+
//
|
|
43
|
+
// Failure: outer catch writes status="dead" + lastError so ops sees the
|
|
44
|
+
// failure after the TX rolled back. Use restartConsumer to clear dead.
|
|
45
|
+
|
|
46
|
+
export type MspRebuildDeps = {
|
|
47
|
+
readonly db: DbConnection;
|
|
48
|
+
readonly registry: Registry;
|
|
49
|
+
// Optional framework meter; emits kumiko_projection_rebuild_* with a
|
|
50
|
+
// projection=<mspName> label (same metric namespace as single-stream —
|
|
51
|
+
// one rebuild series per projection, regardless of flavor).
|
|
52
|
+
readonly meter?: Meter;
|
|
53
|
+
// Test-hook — independent of `meter`, fires on success only.
|
|
54
|
+
readonly onMetrics?: (result: RebuildResult) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function createRebuildCtx(
|
|
58
|
+
registry: Registry,
|
|
59
|
+
db: DbRunner,
|
|
60
|
+
tenantId: TenantId,
|
|
61
|
+
): MultiStreamApplyContext {
|
|
62
|
+
// Both surfaces throw — rebuild MUST NOT emit. We share one impl.
|
|
63
|
+
const refuseAppend = async (args: { readonly type: string }) => {
|
|
64
|
+
throw new InternalError({
|
|
65
|
+
message: `rebuildMultiStreamProjection: ctx.appendEvent("${args.type}") is not supported during rebuild. The events your saga would emit already live in the event log — rebuild only derives read-model state. If you need to retroactively emit events, do so via a dedicated write-handler, not via the apply path.`,
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
appendEvent: refuseAppend as MultiStreamApplyContext["appendEvent"], // @cast-boundary engine-bridge
|
|
70
|
+
appendEventUnsafe: refuseAppend,
|
|
71
|
+
loadAggregate: async (aggregateId, options) => {
|
|
72
|
+
const events = options?.asOf
|
|
73
|
+
? await loadAggregateAsOf(db, aggregateId, tenantId, options.asOf)
|
|
74
|
+
: await loadAggregate(db, aggregateId, tenantId);
|
|
75
|
+
return upcastStoredEvents(events, registry.getEventUpcasters(), { db, tenantId });
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function rebuildMultiStreamProjection(
|
|
81
|
+
mspName: string,
|
|
82
|
+
deps: MspRebuildDeps,
|
|
83
|
+
): Promise<RebuildResult> {
|
|
84
|
+
const { db, registry } = deps;
|
|
85
|
+
const msp = registry.getAllMultiStreamProjections().get(mspName);
|
|
86
|
+
if (!msp) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`MultiStreamProjection "${mspName}" is not registered. Known: ${
|
|
89
|
+
[...registry.getAllMultiStreamProjections().keys()].join(", ") || "(none)"
|
|
90
|
+
}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (!msp.table) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`MultiStreamProjection "${mspName}" has no backing table — it is a pure side-effect consumer (webhooks, notifications, external sync). Rebuild would re-invoke those side-effects by replaying the log. For poison events use yarn kumiko consumer skip / restart; there is no analogous "rebuild" concept for side-effect sinks.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const startedAt = Date.now();
|
|
100
|
+
let eventsProcessed = 0;
|
|
101
|
+
let lastProcessedEventId = 0n;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await db.transaction(async (tx) => {
|
|
105
|
+
// Upsert + lock the consumer row. Rebuild always targets the
|
|
106
|
+
// SHARED-delivery shard: per-instance MSPs are side-effect-only (no
|
|
107
|
+
// table, so the guard above refuses them anyway), and rebuild's
|
|
108
|
+
// purpose is to rematerialize one persistent read-model, not fan
|
|
109
|
+
// out a local cache reset across instances. The FOR UPDATE on the
|
|
110
|
+
// next SELECT is what blocks concurrent rebuilds of the same MSP;
|
|
111
|
+
// live dispatcher passes use SKIP LOCKED on this row and will bail
|
|
112
|
+
// silently while we hold it.
|
|
113
|
+
await tx
|
|
114
|
+
.insert(eventConsumerStateTable)
|
|
115
|
+
.values({
|
|
116
|
+
name: mspName,
|
|
117
|
+
instanceId: SHARED_INSTANCE_SENTINEL,
|
|
118
|
+
lastProcessedEventId: 0n,
|
|
119
|
+
status: "idle",
|
|
120
|
+
})
|
|
121
|
+
.onConflictDoUpdate({
|
|
122
|
+
target: [eventConsumerStateTable.name, eventConsumerStateTable.instanceId],
|
|
123
|
+
set: {
|
|
124
|
+
lastProcessedEventId: 0n,
|
|
125
|
+
status: "idle",
|
|
126
|
+
attempts: 0,
|
|
127
|
+
lastError: null,
|
|
128
|
+
updatedAt: sql`now()`,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
await tx
|
|
132
|
+
.select()
|
|
133
|
+
.from(eventConsumerStateTable)
|
|
134
|
+
.where(
|
|
135
|
+
and(
|
|
136
|
+
eq(eventConsumerStateTable.name, mspName),
|
|
137
|
+
eq(eventConsumerStateTable.instanceId, SHARED_INSTANCE_SENTINEL),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
.for("update");
|
|
141
|
+
|
|
142
|
+
// msp.table is narrowed by the upfront guard; the assertion here is
|
|
143
|
+
// for TS inside the async closure (narrowing doesn't cross the
|
|
144
|
+
// transaction boundary).
|
|
145
|
+
const tableName = getTableName(msp.table as NonNullable<typeof msp.table>);
|
|
146
|
+
await tx.execute(sql.raw(`TRUNCATE TABLE ${quoteIdent(tableName)}`));
|
|
147
|
+
|
|
148
|
+
const subscribedTypes = Object.keys(msp.apply);
|
|
149
|
+
if (subscribedTypes.length > 0) {
|
|
150
|
+
const events = (await tx
|
|
151
|
+
.select()
|
|
152
|
+
.from(eventsTable)
|
|
153
|
+
.where(inArray(eventsTable.type, subscribedTypes))
|
|
154
|
+
.orderBy(asc(eventsTable.id))) as ReadonlyArray<typeof eventsTable.$inferSelect>;
|
|
155
|
+
|
|
156
|
+
const upcasters = registry.getEventUpcasters();
|
|
157
|
+
for (const row of events) {
|
|
158
|
+
const raw: StoredEvent = {
|
|
159
|
+
id: String(row.id),
|
|
160
|
+
aggregateId: row.aggregateId,
|
|
161
|
+
aggregateType: row.aggregateType,
|
|
162
|
+
tenantId: row.tenantId,
|
|
163
|
+
version: row.version,
|
|
164
|
+
type: row.type,
|
|
165
|
+
eventVersion: row.eventVersion,
|
|
166
|
+
payload: row.payload,
|
|
167
|
+
metadata: row.metadata,
|
|
168
|
+
createdAt: row.createdAt,
|
|
169
|
+
createdBy: row.createdBy,
|
|
170
|
+
};
|
|
171
|
+
const storedEvent = await upcastStoredEvent(raw, upcasters, {
|
|
172
|
+
db: tx,
|
|
173
|
+
tenantId: row.tenantId as TenantId,
|
|
174
|
+
});
|
|
175
|
+
const applyFn = msp.apply[row.type];
|
|
176
|
+
if (!applyFn) continue;
|
|
177
|
+
const rebuildCtx = createRebuildCtx(registry, tx, row.tenantId as TenantId);
|
|
178
|
+
await applyFn(storedEvent, tx, rebuildCtx);
|
|
179
|
+
eventsProcessed++;
|
|
180
|
+
lastProcessedEventId = row.id;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await tx
|
|
185
|
+
.update(eventConsumerStateTable)
|
|
186
|
+
.set({
|
|
187
|
+
lastProcessedEventId,
|
|
188
|
+
status: "idle",
|
|
189
|
+
attempts: 0,
|
|
190
|
+
lastError: null,
|
|
191
|
+
updatedAt: sql`now()`,
|
|
192
|
+
})
|
|
193
|
+
.where(
|
|
194
|
+
and(
|
|
195
|
+
eq(eventConsumerStateTable.name, mspName),
|
|
196
|
+
eq(eventConsumerStateTable.instanceId, SHARED_INSTANCE_SENTINEL),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
} catch (e) {
|
|
201
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
202
|
+
await db
|
|
203
|
+
.update(eventConsumerStateTable)
|
|
204
|
+
.set({ status: "dead", lastError: message, updatedAt: sql`now()` })
|
|
205
|
+
.where(
|
|
206
|
+
and(
|
|
207
|
+
eq(eventConsumerStateTable.name, mspName),
|
|
208
|
+
eq(eventConsumerStateTable.instanceId, SHARED_INSTANCE_SENTINEL),
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
if (deps.meter) {
|
|
212
|
+
emitProjectionRebuild(
|
|
213
|
+
deps.meter,
|
|
214
|
+
{ projection: mspName, success: false },
|
|
215
|
+
(Date.now() - startedAt) / 1000,
|
|
216
|
+
0,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
throw e;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const result: RebuildResult = {
|
|
223
|
+
projection: mspName,
|
|
224
|
+
eventsProcessed,
|
|
225
|
+
lastProcessedEventId,
|
|
226
|
+
durationMs: Date.now() - startedAt,
|
|
227
|
+
};
|
|
228
|
+
if (deps.meter) {
|
|
229
|
+
emitProjectionRebuild(
|
|
230
|
+
deps.meter,
|
|
231
|
+
{ projection: mspName, success: true },
|
|
232
|
+
result.durationMs / 1000,
|
|
233
|
+
eventsProcessed,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
deps.onMetrics?.(result);
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function quoteIdent(name: string): string {
|
|
241
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
242
|
+
}
|