@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,1325 @@
|
|
|
1
|
+
import { applyEntityEvent } from "../db/apply-entity-event";
|
|
2
|
+
import { buildDrizzleTable } from "../db/table-builder";
|
|
3
|
+
import { buildMetricName, validateMetricName } from "../observability";
|
|
4
|
+
import { type QnType, qualifyEntityName } from "./qualified-name";
|
|
5
|
+
import type {
|
|
6
|
+
AuthClaimsHookDef,
|
|
7
|
+
ClaimKeyDefinition,
|
|
8
|
+
ConfigKeyDefinition,
|
|
9
|
+
EntityDefinition,
|
|
10
|
+
EntityRelations,
|
|
11
|
+
EventDef,
|
|
12
|
+
EventUpcastFn,
|
|
13
|
+
FeatureDefinition,
|
|
14
|
+
FeatureMetricDef,
|
|
15
|
+
HookPhase,
|
|
16
|
+
JobDefinition,
|
|
17
|
+
MultiStreamProjectionDefinition,
|
|
18
|
+
NavDefinition,
|
|
19
|
+
NotificationDefinition,
|
|
20
|
+
OwnedFn,
|
|
21
|
+
PhasedHook,
|
|
22
|
+
PostDeleteHookFn,
|
|
23
|
+
PostSaveHookFn,
|
|
24
|
+
PreDeleteHookFn,
|
|
25
|
+
PreQueryHookFn,
|
|
26
|
+
PreSaveHookFn,
|
|
27
|
+
ProjectionDefinition,
|
|
28
|
+
QueryHandlerDef,
|
|
29
|
+
ReferenceDataDef,
|
|
30
|
+
RegistrarExtensionDef,
|
|
31
|
+
RegistrarExtensionRegistration,
|
|
32
|
+
Registry,
|
|
33
|
+
RelationDefinition,
|
|
34
|
+
ScreenDefinition,
|
|
35
|
+
SecretKeyDefinition,
|
|
36
|
+
TranslationKeys,
|
|
37
|
+
WorkspaceDefinition,
|
|
38
|
+
WriteHandlerDef,
|
|
39
|
+
} from "./types";
|
|
40
|
+
import { HookPhases } from "./types";
|
|
41
|
+
import { resolveName } from "./types/handlers";
|
|
42
|
+
|
|
43
|
+
type IncomingRelation = {
|
|
44
|
+
sourceEntity: string;
|
|
45
|
+
relationName: string;
|
|
46
|
+
relation: RelationDefinition;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const IMPLICIT_PROJECTION_SUFFIX = "-entity" as const;
|
|
50
|
+
|
|
51
|
+
// Pro r.entity-Registration eine ImplicitProjection mit auto-generierten
|
|
52
|
+
// apply-Handlern für die 4 Auto-Verben. Live-Pfad geht durch
|
|
53
|
+
// EventStoreExecutor und schreibt direkt in die Tabelle; rebuildProjection
|
|
54
|
+
// nutzt diese Definition um aus Events zu replayen. Beide rufen dieselbe
|
|
55
|
+
// applyEntityEvent-Funktion → Live==Rebuild by-construction (verstärkt
|
|
56
|
+
// durch implicit-projection-equivalence.integration.ts).
|
|
57
|
+
function buildImplicitProjection(
|
|
58
|
+
featureName: string,
|
|
59
|
+
entityName: string,
|
|
60
|
+
entity: EntityDefinition,
|
|
61
|
+
qualify: typeof qualifyEntityName,
|
|
62
|
+
): ProjectionDefinition {
|
|
63
|
+
const name = qualify(featureName, "projection", `${entityName}${IMPLICIT_PROJECTION_SUFFIX}`);
|
|
64
|
+
const drizzleTable = buildDrizzleTable(entityName, entity);
|
|
65
|
+
// applyEntityEvent gibt ApplyResult zurück; SingleStreamApplyFn erwartet
|
|
66
|
+
// Promise<void>. Im rebuild-Pfad ist die Row irrelevant — wir discarden.
|
|
67
|
+
const handler = async (
|
|
68
|
+
event: Parameters<ProjectionDefinition["apply"][string]>[0],
|
|
69
|
+
tx: Parameters<ProjectionDefinition["apply"][string]>[1],
|
|
70
|
+
): Promise<void> => {
|
|
71
|
+
await applyEntityEvent(event, drizzleTable, entity, tx);
|
|
72
|
+
};
|
|
73
|
+
const apply: Record<string, ProjectionDefinition["apply"][string]> = {
|
|
74
|
+
[`${entityName}.created`]: handler,
|
|
75
|
+
[`${entityName}.updated`]: handler,
|
|
76
|
+
[`${entityName}.deleted`]: handler,
|
|
77
|
+
};
|
|
78
|
+
// Restore-Verb existiert nur für softDelete-Entities. Hard-Delete-
|
|
79
|
+
// Entities sollten keine restored-Events produzieren — würden sie es
|
|
80
|
+
// doch, würde applyEntityEvent intern als no-op laufen, aber wir
|
|
81
|
+
// registrieren den Handler gar nicht erst.
|
|
82
|
+
if (entity.softDelete) {
|
|
83
|
+
apply[`${entityName}.restored`] = handler;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
name,
|
|
87
|
+
source: entityName,
|
|
88
|
+
table: drizzleTable,
|
|
89
|
+
apply,
|
|
90
|
+
isImplicit: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// This is where the magic happens. By "magic" I mean: precomputed maps.
|
|
95
|
+
// I build everything once at boot (hooks, relations, searchable fields, ...)
|
|
96
|
+
// so nothing has to iterate over objects at runtime. O(1) instead of O(n*m).
|
|
97
|
+
export function createRegistry(features: readonly FeatureDefinition[]): Registry {
|
|
98
|
+
const featureMap = new Map<string, FeatureDefinition>();
|
|
99
|
+
const entityMap = new Map<string, EntityDefinition>();
|
|
100
|
+
const relationMap = new Map<string, Record<string, RelationDefinition>>();
|
|
101
|
+
const writeHandlerMap = new Map<string, WriteHandlerDef>();
|
|
102
|
+
const queryHandlerMap = new Map<string, QueryHandlerDef>();
|
|
103
|
+
// Hook storage. Every entry carries its owning feature (on the OwnedFn /
|
|
104
|
+
// PhasedHook shape), so the lifecycle pipeline can skip hooks whose
|
|
105
|
+
// feature is globally disabled without a parallel bookkeeping map.
|
|
106
|
+
// featureName === "*" = always fire (extension-provided invariants).
|
|
107
|
+
const preSaveHooks = new Map<string, OwnedFn<PreSaveHookFn>[]>();
|
|
108
|
+
const postSaveHooks = new Map<string, PhasedHook<PostSaveHookFn>[]>();
|
|
109
|
+
const preDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
|
|
110
|
+
const postDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
|
|
111
|
+
const preQueryHooks = new Map<string, OwnedFn<PreQueryHookFn>[]>();
|
|
112
|
+
// Entity hooks — keyed by entity name, NOT prefixed
|
|
113
|
+
const entityPostSaveHooks = new Map<string, PhasedHook<PostSaveHookFn>[]>();
|
|
114
|
+
const entityPreDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
|
|
115
|
+
const entityPostDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
|
|
116
|
+
const configKeyMap = new Map<string, ConfigKeyDefinition>();
|
|
117
|
+
const jobMap = new Map<string, JobDefinition>();
|
|
118
|
+
const notificationMap = new Map<string, NotificationDefinition>();
|
|
119
|
+
const notificationFeatureMap = new Map<string, string>(); // qualifiedName → featureName
|
|
120
|
+
const eventMap = new Map<string, EventDef>();
|
|
121
|
+
// Schema-migration chain per qualified event name. Built at boot after all
|
|
122
|
+
// features are ingested, then exposed via getEventUpcasters(). Readers of
|
|
123
|
+
// the events-table (projection rebuild, future aggregate loaders) walk the
|
|
124
|
+
// chain to upcast stored payloads to the current shape at read time.
|
|
125
|
+
const eventUpcasterMap = new Map<
|
|
126
|
+
string,
|
|
127
|
+
{ readonly currentVersion: number; readonly chain: ReadonlyMap<number, EventUpcastFn> }
|
|
128
|
+
>();
|
|
129
|
+
// Handler → entity mapping (populated from entities + handler name convention)
|
|
130
|
+
const handlerEntityMap = new Map<string, string>();
|
|
131
|
+
// Handler → feature mapping (for systemScope check)
|
|
132
|
+
const handlerFeatureMap = new Map<string, string>();
|
|
133
|
+
const extensionMap = new Map<string, RegistrarExtensionDef>();
|
|
134
|
+
const extensionUsages: RegistrarExtensionRegistration[] = [];
|
|
135
|
+
const allReferenceData: ReferenceDataDef[] = [];
|
|
136
|
+
const mergedTranslations: Record<string, Record<string, string>> = {};
|
|
137
|
+
// Metric registry — keyed by fully qualified name (kumiko_<feature>_<short>).
|
|
138
|
+
// Boot-time validation rejects bad names; dashboards then safely rely on shape.
|
|
139
|
+
const metricMap = new Map<string, FeatureMetricDef & { readonly featureName: string }>();
|
|
140
|
+
// Feature-declared secrets. Keyed by qualified name ("<feature>:<short>").
|
|
141
|
+
// The map is the source of truth for ops-UIs, the rotation job, and any
|
|
142
|
+
// boot validation that wants to reject a secrets.get for an unknown key.
|
|
143
|
+
const secretKeyMap = new Map<string, SecretKeyDefinition>();
|
|
144
|
+
// Projections — full list keyed by qualified name AND a source-entity index
|
|
145
|
+
// the executor consults on every write. Index is precomputed so the hot path
|
|
146
|
+
// does a single Map.get, never a scan.
|
|
147
|
+
const projectionMap = new Map<string, ProjectionDefinition>();
|
|
148
|
+
const projectionsBySource = new Map<string, ProjectionDefinition[]>();
|
|
149
|
+
// Multi-stream projections — cross-aggregate, async via event-dispatcher.
|
|
150
|
+
// One qualified name per MSP; each becomes its own EventConsumer with a
|
|
151
|
+
// dedicated cursor in kumiko_event_consumers.
|
|
152
|
+
const multiStreamProjectionMap = new Map<string, MultiStreamProjectionDefinition>();
|
|
153
|
+
// qualified-MSP-name → owning-feature name. Used by the event-dispatcher
|
|
154
|
+
// to pause consumers whose feature is globally disabled.
|
|
155
|
+
const multiStreamProjectionFeatureMap = new Map<string, string>();
|
|
156
|
+
// Auth-claims hooks — tagged with featureName so the login resolver can
|
|
157
|
+
// auto-prefix each hook's returned keys with "<feature>:".
|
|
158
|
+
const authClaimsHooks: AuthClaimsHookDef[] = [];
|
|
159
|
+
// Feature-declared claim keys. Keyed by qualified name ("<feature>:<short>").
|
|
160
|
+
// Used by readClaim callers to introspect; the resolver reads it via the
|
|
161
|
+
// declaredKeys set on each AuthClaimsHookDef (pre-built per feature below).
|
|
162
|
+
const claimKeyMap = new Map<string, ClaimKeyDefinition>();
|
|
163
|
+
// Screens — keyed by qualified name ("<feature>:screen:<id>"). One map for
|
|
164
|
+
// lookup + a parallel featureMap so the nav-resolver can gate screens by
|
|
165
|
+
// effective-features without scanning. `screensByEntity` pre-groups the
|
|
166
|
+
// entity-bound screens (entityList / entityEdit) by their entity name so
|
|
167
|
+
// ui-core's Schema-driven view-model builders don't need to scan
|
|
168
|
+
// getAllScreens() for every render.
|
|
169
|
+
const screenMap = new Map<string, ScreenDefinition>();
|
|
170
|
+
const screenFeatureMap = new Map<string, string>();
|
|
171
|
+
const screensByEntity = new Map<string, ScreenDefinition[]>();
|
|
172
|
+
// Nav entries — same shape as screenMap. Tree assembly happens in ui-core
|
|
173
|
+
// at render time; the engine just stores the flat list and its owners.
|
|
174
|
+
// `navsByParent` pre-groups children by their parent's QN so
|
|
175
|
+
// resolveNavigation does O(n) passes, not O(n²) parent-filters. Top-level
|
|
176
|
+
// entries (no parent) sit in the separate `topLevelNavs` list.
|
|
177
|
+
const navMap = new Map<string, NavDefinition>();
|
|
178
|
+
const navFeatureMap = new Map<string, string>();
|
|
179
|
+
const navsByParent = new Map<string, NavDefinition[]>();
|
|
180
|
+
const topLevelNavs: NavDefinition[] = [];
|
|
181
|
+
|
|
182
|
+
// Workspaces — stored verbatim, plus a parallel feature-owner map and a
|
|
183
|
+
// pre-computed nav-membership map. Membership merges two sources at boot:
|
|
184
|
+
// 1. r.workspace({ nav: [...] }) — explicit list on the workspace
|
|
185
|
+
// 2. r.nav({ workspaces: [...] }) — self-assignment on the nav entry
|
|
186
|
+
// Order matters for the switcher: workspace-declared QNs come first (in
|
|
187
|
+
// declaration order), then nav-self-assigned ones (in registration order).
|
|
188
|
+
// Duplicates are deduped — a nav entry listed in both shows up once.
|
|
189
|
+
const workspaceMap = new Map<string, WorkspaceDefinition>();
|
|
190
|
+
const workspaceFeatureMap = new Map<string, string>();
|
|
191
|
+
const navsByWorkspace = new Map<string, string[]>();
|
|
192
|
+
let defaultWorkspace: WorkspaceDefinition | undefined;
|
|
193
|
+
|
|
194
|
+
// Local alias for readability — `qualifyEntityName` is the shared helper
|
|
195
|
+
// from qualified-name.ts, also used by validateBoot to keep ingest and
|
|
196
|
+
// validation in lockstep on the qualification rule.
|
|
197
|
+
const qualify = qualifyEntityName;
|
|
198
|
+
|
|
199
|
+
// Filter hooks by phase and/or owning feature.
|
|
200
|
+
//
|
|
201
|
+
// - `phase === undefined` → any phase passes.
|
|
202
|
+
// - `effectiveFeatures === undefined` → ownership filter disabled.
|
|
203
|
+
// - hook.featureName === "*" or undefined → always passes ownership filter.
|
|
204
|
+
// "*" is reserved for extension-provided hooks that are invariant
|
|
205
|
+
// plumbing, not opt-in feature logic.
|
|
206
|
+
function filterByPhase<TFn>(
|
|
207
|
+
list: readonly PhasedHook<TFn>[] | undefined,
|
|
208
|
+
phase: HookPhase | undefined,
|
|
209
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
210
|
+
): readonly TFn[] {
|
|
211
|
+
if (!list || list.length === 0) return [];
|
|
212
|
+
const result: TFn[] = [];
|
|
213
|
+
for (const entry of list) {
|
|
214
|
+
if (phase !== undefined && entry.phase !== phase) continue;
|
|
215
|
+
if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
|
|
216
|
+
result.push(entry.fn);
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Same ownership rule as filterByPhase, but for unphased hook lists
|
|
222
|
+
// (preSave, preQuery). Returns the raw fns ready for the lifecycle runner.
|
|
223
|
+
function filterOwned<TFn>(
|
|
224
|
+
list: readonly OwnedFn<TFn>[] | undefined,
|
|
225
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
226
|
+
): readonly TFn[] {
|
|
227
|
+
if (!list || list.length === 0) return [];
|
|
228
|
+
const result: TFn[] = [];
|
|
229
|
+
for (const entry of list) {
|
|
230
|
+
if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
|
|
231
|
+
result.push(entry.fn);
|
|
232
|
+
}
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function ownerEnabled(
|
|
237
|
+
owner: string | undefined,
|
|
238
|
+
effectiveFeatures: ReadonlySet<string> | undefined,
|
|
239
|
+
): boolean {
|
|
240
|
+
if (!effectiveFeatures) return true;
|
|
241
|
+
if (owner === undefined || owner === "*") return true;
|
|
242
|
+
return effectiveFeatures.has(owner);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Merge hooks without prefix (entity hooks). featureName is already on
|
|
246
|
+
// every hook entry (set by defineFeature), so there's no parallel
|
|
247
|
+
// bookkeeping — just append.
|
|
248
|
+
function mergeHookList<T>(
|
|
249
|
+
map: Map<string, T[]>,
|
|
250
|
+
source: Readonly<Record<string, readonly T[]>>,
|
|
251
|
+
): void {
|
|
252
|
+
for (const [name, fns] of Object.entries(source)) {
|
|
253
|
+
const existing = map.get(name) ?? [];
|
|
254
|
+
existing.push(...fns);
|
|
255
|
+
map.set(name, existing);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Merge hooks with feature prefix (handler hooks).
|
|
260
|
+
// Hook keys are handler QNs — hooks don't get their own QN, they're keyed by the handler they target.
|
|
261
|
+
// The hookQnType indicates whether the targeted handler is a write or query handler.
|
|
262
|
+
function mergeHookListQualified<T>(
|
|
263
|
+
map: Map<string, T[]>,
|
|
264
|
+
source: Readonly<Record<string, readonly T[]>>,
|
|
265
|
+
featureName: string,
|
|
266
|
+
hookQnType: QnType,
|
|
267
|
+
): void {
|
|
268
|
+
for (const [name, fns] of Object.entries(source)) {
|
|
269
|
+
const qualified = qualify(featureName, hookQnType, name);
|
|
270
|
+
const existing = map.get(qualified) ?? [];
|
|
271
|
+
existing.push(...fns);
|
|
272
|
+
map.set(qualified, existing);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (const feature of features) {
|
|
277
|
+
if (featureMap.has(feature.name)) {
|
|
278
|
+
throw new Error(`Duplicate feature: "${feature.name}"`);
|
|
279
|
+
}
|
|
280
|
+
featureMap.set(feature.name, feature);
|
|
281
|
+
|
|
282
|
+
// Entities: NOT prefixed — entity names must be globally unique
|
|
283
|
+
for (const [name, entity] of Object.entries(feature.entities)) {
|
|
284
|
+
if (entityMap.has(name)) {
|
|
285
|
+
throw new Error(`Duplicate entity: "${name}" (registered by multiple features)`);
|
|
286
|
+
}
|
|
287
|
+
entityMap.set(name, entity);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Relations: entityName (not prefixed)
|
|
291
|
+
for (const [entityName, rels] of Object.entries(feature.relations)) {
|
|
292
|
+
const existing = relationMap.get(entityName) ?? {};
|
|
293
|
+
for (const [relName, relDef] of Object.entries(rels)) {
|
|
294
|
+
if (existing[relName]) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Duplicate relation: "${entityName}.${relName}" (registered by multiple features)`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
existing[relName] = relDef;
|
|
300
|
+
}
|
|
301
|
+
relationMap.set(entityName, existing);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Write handlers: scope:write:name
|
|
305
|
+
for (const [name, handler] of Object.entries(feature.writeHandlers)) {
|
|
306
|
+
const qualified = qualify(feature.name, "write", name);
|
|
307
|
+
if (writeHandlerMap.has(qualified)) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Duplicate write handler: "${qualified}" (registered by multiple features)`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
writeHandlerMap.set(qualified, { ...handler, name: qualified });
|
|
313
|
+
handlerFeatureMap.set(qualified, feature.name);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Query handlers: scope:query:name
|
|
317
|
+
for (const [name, handler] of Object.entries(feature.queryHandlers)) {
|
|
318
|
+
const qualified = qualify(feature.name, "query", name);
|
|
319
|
+
if (queryHandlerMap.has(qualified)) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Duplicate query handler: "${qualified}" (registered by multiple features)`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
queryHandlerMap.set(qualified, { ...handler, name: qualified });
|
|
325
|
+
handlerFeatureMap.set(qualified, feature.name);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Config keys: scope:config:name
|
|
329
|
+
for (const [key, keyDef] of Object.entries(feature.configKeys)) {
|
|
330
|
+
const qualifiedKey = qualify(feature.name, "config", key);
|
|
331
|
+
if (configKeyMap.has(qualifiedKey)) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`Duplicate config key: "${qualifiedKey}" (registered by multiple features)`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
configKeyMap.set(qualifiedKey, keyDef);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Jobs: scope:job:name
|
|
340
|
+
for (const [name, jobDef] of Object.entries(feature.jobs)) {
|
|
341
|
+
const qualifiedName = qualify(feature.name, "job", name);
|
|
342
|
+
if (jobMap.has(qualifiedName)) {
|
|
343
|
+
throw new Error(`Duplicate job: "${qualifiedName}" (registered by multiple features)`);
|
|
344
|
+
}
|
|
345
|
+
// runIn runtime-check. TS's JobRunIn = Exclude<RunIn, "both"> already
|
|
346
|
+
// rejects "both" at compile time, but dynamically-constructed jobs
|
|
347
|
+
// (serialized config, plugin authors using `as any`) could slip it
|
|
348
|
+
// past the type system. Fail loud — "both" for jobs would mean "fan
|
|
349
|
+
// out to both lane-queues", which over-delivers; the routing assumes
|
|
350
|
+
// exactly one target queue per dispatch.
|
|
351
|
+
// @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
|
|
352
|
+
const runIn = (jobDef as { runIn?: unknown }).runIn;
|
|
353
|
+
if (runIn !== undefined && runIn !== "api" && runIn !== "worker") {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`Invalid runIn "${String(runIn)}" on job "${qualifiedName}" — jobs must be pinned to a single lane ("api" or "worker"). "both" is not allowed because BullMQ queues are lane-scoped.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
jobMap.set(qualifiedName, { ...jobDef, name: qualifiedName });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Notifications: scope:notify:name
|
|
362
|
+
for (const [name, notifDef] of Object.entries(feature.notifications)) {
|
|
363
|
+
const qualifiedName = qualify(feature.name, "notify", name);
|
|
364
|
+
notificationMap.set(qualifiedName, {
|
|
365
|
+
...notifDef,
|
|
366
|
+
name: qualifiedName,
|
|
367
|
+
trigger: { on: notifDef.trigger.on },
|
|
368
|
+
});
|
|
369
|
+
notificationFeatureMap.set(qualifiedName, feature.name);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Events: scope:event:name. Migrations stay keyed by feature+short-name
|
|
373
|
+
// in the FeatureDefinition and get stitched into the eventUpcasterMap
|
|
374
|
+
// below (after ALL features are ingested) so cross-feature validation has
|
|
375
|
+
// the complete picture.
|
|
376
|
+
for (const [eventName, eventDef] of Object.entries(feature.events)) {
|
|
377
|
+
const qualified = qualify(feature.name, "event", eventName);
|
|
378
|
+
eventMap.set(qualified, { ...eventDef, name: qualified });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Translations prefixed with featureName: (i18next namespace convention)
|
|
382
|
+
for (const [key, value] of Object.entries(feature.translations)) {
|
|
383
|
+
mergedTranslations[`${feature.name}:${key}`] = value;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Lifecycle hooks: keyed by handler QN. featureName rides along on each
|
|
387
|
+
// hook entry — defineFeature sets it, the registry just appends.
|
|
388
|
+
// Save/delete hooks target write handlers, query hooks target query handlers.
|
|
389
|
+
mergeHookListQualified(preSaveHooks, feature.hooks.preSave, feature.name, "write");
|
|
390
|
+
mergeHookListQualified(postSaveHooks, feature.hooks.postSave, feature.name, "write");
|
|
391
|
+
mergeHookListQualified(preDeleteHooks, feature.hooks.preDelete, feature.name, "write");
|
|
392
|
+
mergeHookListQualified(postDeleteHooks, feature.hooks.postDelete, feature.name, "write");
|
|
393
|
+
mergeHookListQualified(preQueryHooks, feature.hooks.preQuery, feature.name, "query");
|
|
394
|
+
|
|
395
|
+
// Entity hooks: NOT prefixed, keyed by entity name
|
|
396
|
+
mergeHookList(entityPostSaveHooks, feature.entityHooks.postSave);
|
|
397
|
+
mergeHookList(entityPreDeleteHooks, feature.entityHooks.preDelete);
|
|
398
|
+
mergeHookList(entityPostDeleteHooks, feature.entityHooks.postDelete);
|
|
399
|
+
|
|
400
|
+
// Registrar extensions: collect definitions and usages
|
|
401
|
+
for (const [extName, extDef] of Object.entries(feature.registrarExtensions)) {
|
|
402
|
+
if (extensionMap.has(extName)) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`Duplicate registrar extension: "${extName}" (registered by multiple features)`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
extensionMap.set(extName, extDef);
|
|
408
|
+
}
|
|
409
|
+
extensionUsages.push(...feature.extensionUsages);
|
|
410
|
+
allReferenceData.push(...feature.referenceData);
|
|
411
|
+
|
|
412
|
+
// Metrics: validate + qualify per feature. Collisions across features are
|
|
413
|
+
// rejected here — two features can't both register "created_total" under
|
|
414
|
+
// different shapes (labels/type) because the resulting fully qualified
|
|
415
|
+
// names differ, but same short+feature combo would already fail in
|
|
416
|
+
// defineFeature. This loop catches cross-feature/extension edge cases.
|
|
417
|
+
for (const [shortName, def] of Object.entries(feature.metrics)) {
|
|
418
|
+
const fullName = buildMetricName(feature.name, shortName);
|
|
419
|
+
validateMetricName(fullName, def.type);
|
|
420
|
+
if (metricMap.has(fullName)) {
|
|
421
|
+
throw new Error(
|
|
422
|
+
`[Kumiko Observability] Metric "${fullName}" registered multiple times ` +
|
|
423
|
+
`(Feature: ${feature.name}). Metric names must be globally unique.`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
metricMap.set(fullName, { ...def, featureName: feature.name });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Secret keys: already qualified during defineFeature (same "<feature>:<short>"
|
|
430
|
+
// convention used elsewhere). Reject cross-feature duplicates — extensions
|
|
431
|
+
// could theoretically register on another feature's namespace.
|
|
432
|
+
for (const def of Object.values(feature.secretKeys)) {
|
|
433
|
+
if (secretKeyMap.has(def.qualifiedName)) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`[Kumiko Secrets] Secret key "${def.qualifiedName}" registered multiple times. ` +
|
|
436
|
+
"Secret names must be globally unique across features.",
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
secretKeyMap.set(def.qualifiedName, def);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Projections: qualified by feature name. Build the source-entity index so
|
|
443
|
+
// the event-store-executor can fetch matching projections in O(1) per write.
|
|
444
|
+
for (const [projName, projDef] of Object.entries(feature.projections)) {
|
|
445
|
+
const qualified = qualify(feature.name, "projection", projName);
|
|
446
|
+
if (projectionMap.has(qualified)) {
|
|
447
|
+
throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
|
|
448
|
+
}
|
|
449
|
+
const stored = { ...projDef, name: qualified };
|
|
450
|
+
projectionMap.set(qualified, stored);
|
|
451
|
+
const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
|
|
452
|
+
for (const src of sources) {
|
|
453
|
+
const existing = projectionsBySource.get(src) ?? [];
|
|
454
|
+
existing.push(stored);
|
|
455
|
+
projectionsBySource.set(src, existing);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Multi-stream projections: qualified + stored for later wiring into
|
|
460
|
+
// event-dispatcher. Namespace is shared with single-stream projections —
|
|
461
|
+
// defineFeature already catches name collisions inside one feature, but
|
|
462
|
+
// we also guard the cross-feature case here.
|
|
463
|
+
for (const [mspName, mspDef] of Object.entries(feature.multiStreamProjections)) {
|
|
464
|
+
const qualified = qualify(feature.name, "projection", mspName);
|
|
465
|
+
if (projectionMap.has(qualified) || multiStreamProjectionMap.has(qualified)) {
|
|
466
|
+
throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
|
|
467
|
+
}
|
|
468
|
+
// runIn runtime-check. TS's RunIn union already enforces the three
|
|
469
|
+
// values at compile time; this guards dynamically-constructed MSPs
|
|
470
|
+
// (config-driven, plugin authors) that could slip a typo through.
|
|
471
|
+
// @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
|
|
472
|
+
const mspRunIn = (mspDef as { runIn?: unknown }).runIn;
|
|
473
|
+
if (
|
|
474
|
+
mspRunIn !== undefined &&
|
|
475
|
+
mspRunIn !== "api" &&
|
|
476
|
+
mspRunIn !== "worker" &&
|
|
477
|
+
mspRunIn !== "both"
|
|
478
|
+
) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
`Invalid runIn "${String(mspRunIn)}" on MSP "${qualified}" — must be "api", "worker", or "both".`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
multiStreamProjectionMap.set(qualified, { ...mspDef, name: qualified });
|
|
484
|
+
multiStreamProjectionFeatureMap.set(qualified, feature.name);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Claim keys: aggregated by qualified name. Two features cannot collide
|
|
488
|
+
// here (qualified by feature name), but we still guard for explicit
|
|
489
|
+
// correctness — the only way to hit this is a hand-built FeatureDefinition
|
|
490
|
+
// bypassing defineFeature's per-feature duplicate check.
|
|
491
|
+
const declaredShortNames = new Set<string>();
|
|
492
|
+
for (const def of Object.values(feature.claimKeys)) {
|
|
493
|
+
if (claimKeyMap.has(def.qualifiedName)) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`[Kumiko ClaimKeys] Claim key "${def.qualifiedName}" registered multiple times. ` +
|
|
496
|
+
"Claim short-names must be globally unique across features.",
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
claimKeyMap.set(def.qualifiedName, def);
|
|
500
|
+
declaredShortNames.add(def.shortName);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Screens: qualified + stored. Uniqueness per-feature is enforced in
|
|
504
|
+
// defineFeature; cross-feature collisions are impossible because the
|
|
505
|
+
// qualified name includes the feature-prefix. The separate featureMap
|
|
506
|
+
// entry lets the nav resolver pause screens owned by disabled features
|
|
507
|
+
// in O(1) without walking every screen.
|
|
508
|
+
for (const [screenId, screenDef] of Object.entries(feature.screens)) {
|
|
509
|
+
const qualified = qualify(feature.name, "screen", screenId);
|
|
510
|
+
// Stored version overwrites `id` with the qualified name so callers
|
|
511
|
+
// never need a reverse index (NavDef → qn) during tree-walking.
|
|
512
|
+
// Same pattern as writeHandlerMap/projectionMap/multiStreamProjectionMap
|
|
513
|
+
// (see `{ ...def, name: qualified }` above). Feature-side
|
|
514
|
+
// `feature.screens[shortId]` keeps the short id — only the registry
|
|
515
|
+
// surface flips.
|
|
516
|
+
const stored = { ...screenDef, id: qualified };
|
|
517
|
+
screenMap.set(qualified, stored);
|
|
518
|
+
screenFeatureMap.set(qualified, feature.name);
|
|
519
|
+
// entity-Index nur für Screens die direkt an einer Entity hängen.
|
|
520
|
+
// entityList/entityEdit haben `entity`; custom + actionForm haben
|
|
521
|
+
// keinen entity-Bezug (custom ist opaque, actionForm hat inline
|
|
522
|
+
// fields ohne Entity-Reference).
|
|
523
|
+
if (stored.type === "entityList" || stored.type === "entityEdit") {
|
|
524
|
+
const existing = screensByEntity.get(stored.entity) ?? [];
|
|
525
|
+
existing.push(stored);
|
|
526
|
+
screensByEntity.set(stored.entity, existing);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Nav entries: same qualification pattern as screens. The parent/screen
|
|
531
|
+
// refs are boot-validated below (after all features are ingested, so
|
|
532
|
+
// cross-feature parents can resolve). parent-index is built in the same
|
|
533
|
+
// loop because `parent` refers to a qualified name that doesn't need
|
|
534
|
+
// resolution — just string equality with whatever's in the target
|
|
535
|
+
// entry's QN.
|
|
536
|
+
for (const [navId, navDef] of Object.entries(feature.navs)) {
|
|
537
|
+
const qualified = qualify(feature.name, "nav", navId);
|
|
538
|
+
// See screens above — stored version carries the qualified id so
|
|
539
|
+
// resolveNavigation can recurse via getNavsByParent(child.id) without
|
|
540
|
+
// hand-building a reverse index.
|
|
541
|
+
const stored = { ...navDef, id: qualified };
|
|
542
|
+
navMap.set(qualified, stored);
|
|
543
|
+
navFeatureMap.set(qualified, feature.name);
|
|
544
|
+
if (stored.parent === undefined) {
|
|
545
|
+
topLevelNavs.push(stored);
|
|
546
|
+
} else {
|
|
547
|
+
const existing = navsByParent.get(stored.parent) ?? [];
|
|
548
|
+
existing.push(stored);
|
|
549
|
+
navsByParent.set(stored.parent, existing);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Workspaces: same qualification pattern as nav/screen. Step one stores
|
|
554
|
+
// the workspace itself + its explicit nav list; step two (after every
|
|
555
|
+
// feature has been ingested) folds nav-self-assigned QNs into the same
|
|
556
|
+
// member list. Doing it in two passes keeps cross-feature workspace
|
|
557
|
+
// refs valid — a nav entry can self-assign to a workspace whose feature
|
|
558
|
+
// hasn't been ingested yet.
|
|
559
|
+
for (const [wsId, wsDef] of Object.entries(feature.workspaces)) {
|
|
560
|
+
const qualified = qualify(feature.name, "workspace", wsId);
|
|
561
|
+
const stored = { ...wsDef, id: qualified };
|
|
562
|
+
workspaceMap.set(qualified, stored);
|
|
563
|
+
workspaceFeatureMap.set(qualified, feature.name);
|
|
564
|
+
// Seed the membership list with the workspace's explicit nav refs in
|
|
565
|
+
// declaration order. Boot-validator checks the QNs resolve.
|
|
566
|
+
navsByWorkspace.set(qualified, [...(stored.nav ?? [])]);
|
|
567
|
+
if (stored.default === true) {
|
|
568
|
+
// Boot-validator enforces uniqueness; here we just remember the
|
|
569
|
+
// first one and let validateBoot complain if there's a second.
|
|
570
|
+
if (defaultWorkspace === undefined) {
|
|
571
|
+
defaultWorkspace = stored;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Auth-claims hooks: order of registration is preserved. Feature name is
|
|
577
|
+
// captured alongside so the resolver can apply the auto-prefix at merge
|
|
578
|
+
// time — the feature author never ships pre-prefixed keys.
|
|
579
|
+
//
|
|
580
|
+
// If the feature declared ANY claim keys, every hook from that feature
|
|
581
|
+
// gets the declaredShortNames set attached. The resolver uses it to warn
|
|
582
|
+
// on undeclared inner-keys (typo / rename drift). Features that don't
|
|
583
|
+
// declare claimKeys skip the check entirely — it's opt-in.
|
|
584
|
+
const declaredKeys = declaredShortNames.size > 0 ? declaredShortNames : undefined;
|
|
585
|
+
for (const fn of feature.authClaimsHooks) {
|
|
586
|
+
authClaimsHooks.push({
|
|
587
|
+
featureName: feature.name,
|
|
588
|
+
fn,
|
|
589
|
+
...(declaredKeys && { declaredKeys }),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Pass 2 for workspaces: fold any nav-self-assigned QNs into their
|
|
595
|
+
// workspace's member list. We can do this safely now that every feature
|
|
596
|
+
// (and therefore every workspace) is in workspaceMap. Cross-feature refs
|
|
597
|
+
// — a nav from feature A self-assigning to a workspace from feature B —
|
|
598
|
+
// resolve here because B's workspace was registered in pass 1 above.
|
|
599
|
+
// Dedup: a nav entry that's also in r.workspace({ nav: [...] }) shouldn't
|
|
600
|
+
// appear twice. Boot-validator catches dangling workspace ids.
|
|
601
|
+
for (const [navQn, navDef] of navMap) {
|
|
602
|
+
if (!navDef.workspaces || navDef.workspaces.length === 0) continue;
|
|
603
|
+
for (const wsQn of navDef.workspaces) {
|
|
604
|
+
const members = navsByWorkspace.get(wsQn);
|
|
605
|
+
if (members === undefined) continue; // dangling — boot-validator reports
|
|
606
|
+
if (!members.includes(navQn)) members.push(navQn);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Build handler → entity mapping from feature declarations (filled by tryMapEntity
|
|
611
|
+
// in defineFeature via the "entityName:verb" colon convention).
|
|
612
|
+
// Must happen before extension processing since extension preSave hooks need entity mappings.
|
|
613
|
+
for (const feature of features) {
|
|
614
|
+
for (const [handlerName, entityName] of Object.entries(feature.handlerEntityMappings)) {
|
|
615
|
+
const writeQn = qualify(feature.name, "write", handlerName);
|
|
616
|
+
const queryQn = qualify(feature.name, "query", handlerName);
|
|
617
|
+
if (writeHandlerMap.has(writeQn)) {
|
|
618
|
+
handlerEntityMap.set(writeQn, entityName);
|
|
619
|
+
}
|
|
620
|
+
if (queryHandlerMap.has(queryQn)) {
|
|
621
|
+
handlerEntityMap.set(queryQn, entityName);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Process extension usages: call onRegister, apply extendSchema, register hooks
|
|
627
|
+
for (const usage of extensionUsages) {
|
|
628
|
+
const ext = extensionMap.get(usage.extensionName);
|
|
629
|
+
if (!ext) continue;
|
|
630
|
+
|
|
631
|
+
if (ext.onRegister) {
|
|
632
|
+
ext.onRegister(usage.entityName, usage.options);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// extendSchema: merge extra fields into entity definition
|
|
636
|
+
if (ext.extendSchema) {
|
|
637
|
+
const entity = entityMap.get(usage.entityName);
|
|
638
|
+
if (entity) {
|
|
639
|
+
const extraFields = ext.extendSchema(usage.entityName);
|
|
640
|
+
const merged = { ...entity, fields: { ...entity.fields, ...extraFields } };
|
|
641
|
+
entityMap.set(usage.entityName, merged);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Extension hooks → entity hooks (fire for all writes on the entity).
|
|
646
|
+
// Extensions default to afterCommit phase (same default as r.hook).
|
|
647
|
+
//
|
|
648
|
+
// Owner "*" = always-enabled, not gated by feature-toggles. Extensions
|
|
649
|
+
// are plumbing (e.g. ownership) — the feature that declared them might
|
|
650
|
+
// itself be toggleable, but the extension-hook is conceptually part of
|
|
651
|
+
// the entity's invariants. If future requirements need extension hooks
|
|
652
|
+
// to also be gated, store the registering-feature on
|
|
653
|
+
// RegistrarExtensionRegistration and use that here.
|
|
654
|
+
const extOwner = "*";
|
|
655
|
+
if (ext.hooks) {
|
|
656
|
+
if (ext.hooks.postSave) {
|
|
657
|
+
const existing = entityPostSaveHooks.get(usage.entityName) ?? [];
|
|
658
|
+
existing.push({
|
|
659
|
+
fn: ext.hooks.postSave,
|
|
660
|
+
phase: HookPhases.afterCommit,
|
|
661
|
+
featureName: extOwner,
|
|
662
|
+
});
|
|
663
|
+
entityPostSaveHooks.set(usage.entityName, existing);
|
|
664
|
+
}
|
|
665
|
+
if (ext.hooks.preDelete) {
|
|
666
|
+
const existing = entityPreDeleteHooks.get(usage.entityName) ?? [];
|
|
667
|
+
existing.push({
|
|
668
|
+
fn: ext.hooks.preDelete,
|
|
669
|
+
phase: HookPhases.afterCommit,
|
|
670
|
+
featureName: extOwner,
|
|
671
|
+
});
|
|
672
|
+
entityPreDeleteHooks.set(usage.entityName, existing);
|
|
673
|
+
}
|
|
674
|
+
if (ext.hooks.postDelete) {
|
|
675
|
+
const existing = entityPostDeleteHooks.get(usage.entityName) ?? [];
|
|
676
|
+
existing.push({
|
|
677
|
+
fn: ext.hooks.postDelete,
|
|
678
|
+
phase: HookPhases.afterCommit,
|
|
679
|
+
featureName: extOwner,
|
|
680
|
+
});
|
|
681
|
+
entityPostDeleteHooks.set(usage.entityName, existing);
|
|
682
|
+
}
|
|
683
|
+
// preSave on extensions: store as handler hook for all CRUD handlers of this entity
|
|
684
|
+
if (ext.hooks.preSave) {
|
|
685
|
+
// Find all write handlers that belong to this entity via handlerEntityMap
|
|
686
|
+
for (const qualifiedHandler of writeHandlerMap.keys()) {
|
|
687
|
+
if (handlerEntityMap.get(qualifiedHandler) === usage.entityName) {
|
|
688
|
+
const existing = preSaveHooks.get(qualifiedHandler) ?? [];
|
|
689
|
+
existing.push({ fn: ext.hooks.preSave, featureName: extOwner });
|
|
690
|
+
preSaveHooks.set(qualifiedHandler, existing);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Precompute: searchable/sortable fields, search includes, incoming relations
|
|
698
|
+
const searchableFieldsCache = new Map<string, readonly string[]>();
|
|
699
|
+
const sortableFieldsCache = new Map<string, readonly string[]>();
|
|
700
|
+
const searchIncludesCache = new Map<string, ReadonlyMap<string, readonly string[]>>();
|
|
701
|
+
const incomingRelationsCache = new Map<string, IncomingRelation[]>();
|
|
702
|
+
|
|
703
|
+
for (const [name, entity] of entityMap) {
|
|
704
|
+
const searchable: string[] = [];
|
|
705
|
+
const sortable: string[] = [];
|
|
706
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
707
|
+
if (field.type === "text" && field.searchable === true) searchable.push(fieldName);
|
|
708
|
+
if (field.type === "text" && field.sortable === true) sortable.push(fieldName);
|
|
709
|
+
if (field.type === "embedded") {
|
|
710
|
+
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
711
|
+
if (subField.searchable === true) searchable.push(`${fieldName}_${subName}`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
searchableFieldsCache.set(name, searchable);
|
|
716
|
+
sortableFieldsCache.set(name, sortable);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Implicit-Projection pro r.entity. Macht die Entity-Tabelle rebaubar
|
|
720
|
+
// ohne dass Apps eine explizite r.projection schreiben müssen.
|
|
721
|
+
// Naming-Convention: `<feature>:projection:<entityName>-entity` — der
|
|
722
|
+
// "-entity"-Suffix unterscheidet implicit von explicit-Projections und
|
|
723
|
+
// vermeidet Kollisionen wenn jemand z.B. eine Cross-Aggregate-Projection
|
|
724
|
+
// mit Entity-Name registriert.
|
|
725
|
+
for (const feature of features) {
|
|
726
|
+
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
727
|
+
const def = buildImplicitProjection(feature.name, entityName, entity, qualify);
|
|
728
|
+
if (projectionMap.has(def.name)) {
|
|
729
|
+
throw new Error(
|
|
730
|
+
`Implicit projection "${def.name}" kollidiert mit einer explizit registrierten r.projection. ` +
|
|
731
|
+
`Implicit-Projections werden für jede r.entity mit "-entity"-Suffix angelegt — ` +
|
|
732
|
+
`benenne deine explicit projection um (z.B. "<entity>-summary") um die Kollision aufzulösen.`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
projectionMap.set(def.name, def);
|
|
736
|
+
const existing = projectionsBySource.get(entityName) ?? [];
|
|
737
|
+
existing.push(def);
|
|
738
|
+
projectionsBySource.set(entityName, existing);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
for (const [entityName, rels] of relationMap) {
|
|
743
|
+
const includes = new Map<string, readonly string[]>();
|
|
744
|
+
for (const [relName, rel] of Object.entries(rels)) {
|
|
745
|
+
if ((rel.type === "belongsTo" || rel.type === "manyToMany") && rel.searchInclude?.length) {
|
|
746
|
+
includes.set(relName, rel.searchInclude);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
searchIncludesCache.set(entityName, includes);
|
|
750
|
+
|
|
751
|
+
// Build reverse index for incoming relations
|
|
752
|
+
for (const [relName, rel] of Object.entries(rels)) {
|
|
753
|
+
const existing = incomingRelationsCache.get(rel.target) ?? [];
|
|
754
|
+
existing.push({ sourceEntity: entityName, relationName: relName, relation: rel });
|
|
755
|
+
incomingRelationsCache.set(rel.target, existing);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Validate: handlers in features with field-access rules must be entity-mapped.
|
|
760
|
+
// Without entity mapping, field-level access checks are silently skipped (security gap).
|
|
761
|
+
// Convention: "entityName.action" = entity-bound (must resolve), "action" = standalone (no filter).
|
|
762
|
+
for (const feature of features) {
|
|
763
|
+
if (!hasFieldAccessRules(feature)) continue;
|
|
764
|
+
|
|
765
|
+
// Write handlers: ALL must be entity-mapped (security-critical, writes need field-access checks)
|
|
766
|
+
for (const handlerName of Object.keys(feature.writeHandlers)) {
|
|
767
|
+
const qualified = qualify(feature.name, "write", handlerName);
|
|
768
|
+
if (!handlerEntityMap.has(qualified)) {
|
|
769
|
+
throw new Error(
|
|
770
|
+
`Write handler "${qualified}" is not mapped to any entity, but feature "${feature.name}" has field-level access rules. ` +
|
|
771
|
+
`Name must follow "entity:action" convention (e.g. "user:create") so field-access checks apply.`,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Query handlers: only those with a dash must resolve (typo protection).
|
|
777
|
+
// No dash = standalone query (dashboard, stats) — intentionally not entity-bound.
|
|
778
|
+
for (const handlerName of Object.keys(feature.queryHandlers)) {
|
|
779
|
+
if (!handlerName.includes(":")) continue;
|
|
780
|
+
const qualified = qualify(feature.name, "query", handlerName);
|
|
781
|
+
if (!handlerEntityMap.has(qualified)) {
|
|
782
|
+
throw new Error(
|
|
783
|
+
`Query handler "${qualified}" looks entity-bound but no matching entity exists. ` +
|
|
784
|
+
`Either fix the entity name, or use a name without colons for standalone queries.`,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Validate: all relation targets must reference existing entities
|
|
791
|
+
for (const [entityName, rels] of relationMap) {
|
|
792
|
+
for (const [relName, rel] of Object.entries(rels)) {
|
|
793
|
+
if (!entityMap.has(rel.target)) {
|
|
794
|
+
throw new Error(
|
|
795
|
+
`Relation "${entityName}.${relName}" targets entity "${rel.target}" which does not exist`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Build + validate event upcaster chains. Run AFTER all features are
|
|
802
|
+
// ingested so r.eventMigration calls can reference events from any
|
|
803
|
+
// feature (same feature in practice, but the check stays lax for future
|
|
804
|
+
// cross-feature event packs).
|
|
805
|
+
for (const feature of features) {
|
|
806
|
+
for (const [shortName, migrations] of Object.entries(feature.eventMigrations)) {
|
|
807
|
+
const qualified = qualify(feature.name, "event", shortName);
|
|
808
|
+
const eventDef = eventMap.get(qualified);
|
|
809
|
+
if (!eventDef) {
|
|
810
|
+
throw new Error(
|
|
811
|
+
`Feature "${feature.name}" registered r.eventMigration for "${shortName}" ` +
|
|
812
|
+
`but no r.defineEvent exists for that name. Register the event first.`,
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
for (const m of migrations) {
|
|
816
|
+
if (m.toVersion > eventDef.version) {
|
|
817
|
+
throw new Error(
|
|
818
|
+
`Feature "${feature.name}" has r.eventMigration("${shortName}", ${m.fromVersion}, ${m.toVersion}) ` +
|
|
819
|
+
`but r.defineEvent declares only version ${eventDef.version}. ` +
|
|
820
|
+
`Bump the version in defineEvent to at least ${m.toVersion}, or remove the migration.`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Stitch the upcaster chain per qualified event. At this point, gaps in
|
|
828
|
+
// the chain (e.g. defineEvent version=3 but only a 1→2 migration exists)
|
|
829
|
+
// are hard errors — they would silently hand a v2-shape payload to a
|
|
830
|
+
// consumer expecting v3 at runtime, which is the class of bug upcasters
|
|
831
|
+
// are supposed to prevent.
|
|
832
|
+
for (const [qualified, eventDef] of eventMap) {
|
|
833
|
+
const chainMap = new Map<number, EventUpcastFn>();
|
|
834
|
+
// Locate the feature that owns this event (to pick up its migrations).
|
|
835
|
+
for (const feature of features) {
|
|
836
|
+
for (const [shortName, migs] of Object.entries(feature.eventMigrations)) {
|
|
837
|
+
const candidateQn = qualify(feature.name, "event", shortName);
|
|
838
|
+
if (candidateQn !== qualified) continue;
|
|
839
|
+
for (const m of migs) chainMap.set(m.fromVersion, m.transform);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (eventDef.version > 1) {
|
|
843
|
+
for (let v = 1; v < eventDef.version; v++) {
|
|
844
|
+
if (!chainMap.has(v)) {
|
|
845
|
+
throw new Error(
|
|
846
|
+
`Event "${qualified}" declares version ${eventDef.version} but no migration ` +
|
|
847
|
+
`covers the step v${v} → v${v + 1}. Register r.eventMigration("${qualified.split(":").pop() ?? qualified}", ${v}, ${v + 1}, transform) ` +
|
|
848
|
+
`so stored v${v} payloads can be upcast on read.`,
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
eventUpcasterMap.set(qualified, {
|
|
854
|
+
currentVersion: eventDef.version,
|
|
855
|
+
chain: chainMap,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Validate: every projection's source must reference a registered entity.
|
|
860
|
+
// A typo ("unti" instead of "unit") would otherwise be a silent no-op —
|
|
861
|
+
// the projection is stored but never fires because no aggregateType ever
|
|
862
|
+
// matches. Fail at boot so the feature author sees it immediately.
|
|
863
|
+
//
|
|
864
|
+
// Same guard extends to apply-keys: a handler for "unit.creatd" (missing
|
|
865
|
+
// 'e') would silently never fire. Valid apply-keys are the auto-generated
|
|
866
|
+
// CRUD types per source entity PLUS every domain event registered via
|
|
867
|
+
// r.defineEvent — an apply-handler for a domain event is how a projection
|
|
868
|
+
// reacts to ctx.appendEvent writes on the same aggregate stream.
|
|
869
|
+
const AUTO_EVENT_VERBS = ["created", "updated", "deleted", "restored"] as const;
|
|
870
|
+
const allDomainEventNames = new Set(eventMap.keys());
|
|
871
|
+
for (const [projName, projDef] of projectionMap) {
|
|
872
|
+
const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
|
|
873
|
+
const validEventTypes = new Set<string>();
|
|
874
|
+
// Two source-modes are legal:
|
|
875
|
+
//
|
|
876
|
+
// (a) Registered entity (r.entity(src, ...)) — the "normal" case:
|
|
877
|
+
// auto-lifecycle events `<src>.created/.updated/.deleted/.restored`
|
|
878
|
+
// fire when the event-store-executor writes, and any domain-event
|
|
879
|
+
// (r.defineEvent) appended onto an aggregate of that type is
|
|
880
|
+
// observable too.
|
|
881
|
+
//
|
|
882
|
+
// (b) Events-only source — no r.entity registered, but at least one
|
|
883
|
+
// apply-key must be a domain-event (not a CRUD-verb on the source
|
|
884
|
+
// name). Use-case: features that own an append-only event-stream
|
|
885
|
+
// without a CRUD lifecycle, e.g. `deliveryAttempt` (each call to
|
|
886
|
+
// the delivery-service produces one event on a fresh aggregate)
|
|
887
|
+
// or `jobRun` (BullMQ-callback-driven lifecycle, no executor).
|
|
888
|
+
// A "Shape-Anchor"-entity is no longer needed for this case.
|
|
889
|
+
const isEventsOnlySource = !sources.every((src) => entityMap.has(src));
|
|
890
|
+
for (const src of sources) {
|
|
891
|
+
if (entityMap.has(src)) {
|
|
892
|
+
for (const verb of AUTO_EVENT_VERBS) validEventTypes.add(`${src}.${verb}`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// Domain events are valid apply-keys for any projection. They arrive via
|
|
896
|
+
// ctx.appendEvent on a specific aggregate — the runtime matches by event
|
|
897
|
+
// type, so a projection can observe domain events whose aggregate matches
|
|
898
|
+
// one of its declared sources.
|
|
899
|
+
for (const domainEvt of allDomainEventNames) validEventTypes.add(domainEvt);
|
|
900
|
+
|
|
901
|
+
// In events-only mode, at least one apply-key MUST be a domain-event —
|
|
902
|
+
// otherwise the source is simply a typo (no events will ever fire).
|
|
903
|
+
if (isEventsOnlySource) {
|
|
904
|
+
const hasAnyDomainEvent = Object.keys(projDef.apply).some((k) => allDomainEventNames.has(k));
|
|
905
|
+
if (!hasAnyDomainEvent) {
|
|
906
|
+
const unregistered = sources.filter((src) => !entityMap.has(src));
|
|
907
|
+
throw new Error(
|
|
908
|
+
`Projection "${projName}" declares source(s) [${unregistered.join(", ")}] that are not registered entities, ` +
|
|
909
|
+
`and has no domain-event apply-keys. This is either a typo or a missing r.defineEvent registration. ` +
|
|
910
|
+
`Events-only projections need at least one apply-key from r.defineEvent; ` +
|
|
911
|
+
`CRUD-style projections need r.entity("${unregistered[0]}", ...).`,
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
for (const applyKey of Object.keys(projDef.apply)) {
|
|
917
|
+
if (!validEventTypes.has(applyKey)) {
|
|
918
|
+
throw new Error(
|
|
919
|
+
`Projection "${projName}" has an apply handler for "${applyKey}" but no such event ` +
|
|
920
|
+
`type exists for its source(s) [${sources.join(", ")}]. ` +
|
|
921
|
+
`Valid types: ${[...validEventTypes].join(", ")}. ` +
|
|
922
|
+
`Check for a typo — auto-verbs follow "<entity>.<verb>"; ` +
|
|
923
|
+
`domain events follow "<feature>:event:<short-name>" (see r.defineEvent).`,
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Validate: all required features must be registered
|
|
930
|
+
for (const feature of features) {
|
|
931
|
+
for (const required of feature.requires) {
|
|
932
|
+
if (!featureMap.has(required)) {
|
|
933
|
+
throw new Error(
|
|
934
|
+
`Feature "${feature.name}" requires feature "${required}" which is not registered`,
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Resolve notification triggers and register postSave hooks
|
|
941
|
+
// Done after all features are registered so cross-feature triggers work
|
|
942
|
+
const allHandlerNames = new Set([...writeHandlerMap.keys(), ...queryHandlerMap.keys()]);
|
|
943
|
+
for (const [qualifiedName, notifDef] of notificationMap) {
|
|
944
|
+
// Both maps are populated in lockstep — same key-set by construction.
|
|
945
|
+
const featureName = notificationFeatureMap.get(qualifiedName) as string;
|
|
946
|
+
// I'll try the easy path first: if the trigger is already a fully qualified QN
|
|
947
|
+
// (cross-feature), I take it as-is. Otherwise I qualify with the own feature —
|
|
948
|
+
// as a write handler first (the common case), then as a query. If nothing
|
|
949
|
+
// matches by then, it was a typo and I'll say so.
|
|
950
|
+
let triggerOn: string;
|
|
951
|
+
if (allHandlerNames.has(notifDef.trigger.on)) {
|
|
952
|
+
triggerOn = notifDef.trigger.on;
|
|
953
|
+
} else {
|
|
954
|
+
// Try as write handler first (most common), then query
|
|
955
|
+
const writeQn = qualify(featureName, "write", notifDef.trigger.on);
|
|
956
|
+
const queryQn = qualify(featureName, "query", notifDef.trigger.on);
|
|
957
|
+
if (allHandlerNames.has(writeQn)) {
|
|
958
|
+
triggerOn = writeQn;
|
|
959
|
+
} else if (allHandlerNames.has(queryQn)) {
|
|
960
|
+
triggerOn = queryQn;
|
|
961
|
+
} else {
|
|
962
|
+
throw new Error(
|
|
963
|
+
`Notification "${qualifiedName}" triggers on "${notifDef.trigger.on}" ` +
|
|
964
|
+
`but no handler with that name exists. ` +
|
|
965
|
+
`Tried: "${notifDef.trigger.on}", "${writeQn}", and "${queryQn}"`,
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
// Update the stored definition with resolved trigger
|
|
970
|
+
notificationMap.set(qualifiedName, { ...notifDef, trigger: { on: triggerOn } });
|
|
971
|
+
|
|
972
|
+
if (!postSaveHooks.has(triggerOn)) postSaveHooks.set(triggerOn, []);
|
|
973
|
+
postSaveHooks.get(triggerOn)?.push({
|
|
974
|
+
phase: HookPhases.afterCommit,
|
|
975
|
+
featureName,
|
|
976
|
+
fn: async (result, context) => {
|
|
977
|
+
if (!context.notify) {
|
|
978
|
+
context.log?.debug(
|
|
979
|
+
`notification ${qualifiedName}: skipping — no notify function configured on context`,
|
|
980
|
+
);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const to = notifDef.recipient(result);
|
|
984
|
+
if (to === null) {
|
|
985
|
+
context.log?.debug(
|
|
986
|
+
`notification ${qualifiedName}: skipping — recipient resolver returned null for result ${result.id}`,
|
|
987
|
+
);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const data = notifDef.data(result);
|
|
991
|
+
await context.notify(qualifiedName, { to, data });
|
|
992
|
+
},
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Validate: lifecycle hook targets must reference existing handlers
|
|
997
|
+
const allHandlers = allHandlerNames;
|
|
998
|
+
const lifecycleHookMaps = [
|
|
999
|
+
{ map: preSaveHooks, phase: "preSave" },
|
|
1000
|
+
{ map: postSaveHooks, phase: "postSave" },
|
|
1001
|
+
{ map: preDeleteHooks, phase: "preDelete" },
|
|
1002
|
+
{ map: postDeleteHooks, phase: "postDelete" },
|
|
1003
|
+
{ map: preQueryHooks, phase: "preQuery" },
|
|
1004
|
+
] as const;
|
|
1005
|
+
|
|
1006
|
+
// I'd rather warn you now at boot than have you open a ticket three weeks from now
|
|
1007
|
+
// saying "my hook isn't firing". One typo in the target and the thing goes silent.
|
|
1008
|
+
for (const { map, phase } of lifecycleHookMaps) {
|
|
1009
|
+
for (const hookTarget of map.keys()) {
|
|
1010
|
+
if (!allHandlers.has(hookTarget)) {
|
|
1011
|
+
throw new Error(
|
|
1012
|
+
`${phase} hook targets "${hookTarget}" but no handler with that name exists. ` +
|
|
1013
|
+
`Check for typos — the hook will never fire.`,
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Validate: job event triggers must reference existing handlers.
|
|
1020
|
+
// Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
|
|
1021
|
+
// auch wenn nur einer fehlt fail-fast.
|
|
1022
|
+
for (const [jobName, jobDef] of jobMap) {
|
|
1023
|
+
if (!("on" in jobDef.trigger)) continue;
|
|
1024
|
+
const triggerOn = jobDef.trigger.on;
|
|
1025
|
+
const triggers = Array.isArray(triggerOn) ? triggerOn : [triggerOn];
|
|
1026
|
+
for (const t of triggers) {
|
|
1027
|
+
const rawName = resolveName(t);
|
|
1028
|
+
if (allHandlers.has(rawName)) continue;
|
|
1029
|
+
throw new Error(
|
|
1030
|
+
`Job "${jobName}" triggers on "${rawName}" but no handler with that name exists`,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Validate: extension usages must reference existing extensions
|
|
1036
|
+
for (const usage of extensionUsages) {
|
|
1037
|
+
if (!extensionMap.has(usage.extensionName)) {
|
|
1038
|
+
throw new Error(
|
|
1039
|
+
`Extension usage "${usage.extensionName}" on entity "${usage.entityName}" references an extension that does not exist`,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Pre-compute: any handler with a rateLimit option? Keeps the boot
|
|
1045
|
+
// path able to short-circuit the RateLimitResolver wiring (and its
|
|
1046
|
+
// Lua-script registration on Redis) when nobody opted in.
|
|
1047
|
+
const hasRateLimitedHandlerCached = (() => {
|
|
1048
|
+
for (const h of writeHandlerMap.values()) if (h.rateLimit !== undefined) return true;
|
|
1049
|
+
for (const h of queryHandlerMap.values()) if (h.rateLimit !== undefined) return true;
|
|
1050
|
+
return false;
|
|
1051
|
+
})();
|
|
1052
|
+
|
|
1053
|
+
return {
|
|
1054
|
+
features: featureMap,
|
|
1055
|
+
|
|
1056
|
+
getFeature(name: string): FeatureDefinition | undefined {
|
|
1057
|
+
return featureMap.get(name);
|
|
1058
|
+
},
|
|
1059
|
+
|
|
1060
|
+
hasRateLimitedHandler(): boolean {
|
|
1061
|
+
return hasRateLimitedHandlerCached;
|
|
1062
|
+
},
|
|
1063
|
+
|
|
1064
|
+
getEntity(name: string): EntityDefinition | undefined {
|
|
1065
|
+
return entityMap.get(name);
|
|
1066
|
+
},
|
|
1067
|
+
|
|
1068
|
+
getWriteHandler(name: string): WriteHandlerDef | undefined {
|
|
1069
|
+
return writeHandlerMap.get(name);
|
|
1070
|
+
},
|
|
1071
|
+
|
|
1072
|
+
getQueryHandler(name: string): QueryHandlerDef | undefined {
|
|
1073
|
+
return queryHandlerMap.get(name);
|
|
1074
|
+
},
|
|
1075
|
+
|
|
1076
|
+
getSearchableFields(entityName: string): readonly string[] {
|
|
1077
|
+
return searchableFieldsCache.get(entityName) ?? [];
|
|
1078
|
+
},
|
|
1079
|
+
|
|
1080
|
+
getSortableFields(entityName: string): readonly string[] {
|
|
1081
|
+
return sortableFieldsCache.get(entityName) ?? [];
|
|
1082
|
+
},
|
|
1083
|
+
|
|
1084
|
+
getRelations(entityName: string): EntityRelations {
|
|
1085
|
+
return (relationMap.get(entityName) ?? {}) as EntityRelations;
|
|
1086
|
+
},
|
|
1087
|
+
|
|
1088
|
+
getSearchIncludes(entityName: string): ReadonlyMap<string, readonly string[]> {
|
|
1089
|
+
return searchIncludesCache.get(entityName) ?? new Map();
|
|
1090
|
+
},
|
|
1091
|
+
|
|
1092
|
+
getIncomingRelations(entityName: string): readonly IncomingRelation[] {
|
|
1093
|
+
return incomingRelationsCache.get(entityName) ?? [];
|
|
1094
|
+
},
|
|
1095
|
+
|
|
1096
|
+
getPreSaveHooks(
|
|
1097
|
+
name: string,
|
|
1098
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
1099
|
+
): readonly PreSaveHookFn[] {
|
|
1100
|
+
return filterOwned(preSaveHooks.get(name), effectiveFeatures);
|
|
1101
|
+
},
|
|
1102
|
+
|
|
1103
|
+
getPostSaveHooks(
|
|
1104
|
+
name: string,
|
|
1105
|
+
phase?: HookPhase,
|
|
1106
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
1107
|
+
): readonly PostSaveHookFn[] {
|
|
1108
|
+
return filterByPhase(postSaveHooks.get(name), phase, effectiveFeatures);
|
|
1109
|
+
},
|
|
1110
|
+
|
|
1111
|
+
getPreDeleteHooks(
|
|
1112
|
+
name: string,
|
|
1113
|
+
phase?: HookPhase,
|
|
1114
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
1115
|
+
): readonly PreDeleteHookFn[] {
|
|
1116
|
+
return filterByPhase(preDeleteHooks.get(name), phase, effectiveFeatures);
|
|
1117
|
+
},
|
|
1118
|
+
|
|
1119
|
+
getPostDeleteHooks(
|
|
1120
|
+
name: string,
|
|
1121
|
+
phase?: HookPhase,
|
|
1122
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
1123
|
+
): readonly PostDeleteHookFn[] {
|
|
1124
|
+
return filterByPhase(postDeleteHooks.get(name), phase, effectiveFeatures);
|
|
1125
|
+
},
|
|
1126
|
+
|
|
1127
|
+
getPreQueryHooks(
|
|
1128
|
+
name: string,
|
|
1129
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
1130
|
+
): readonly PreQueryHookFn[] {
|
|
1131
|
+
return filterOwned(preQueryHooks.get(name), effectiveFeatures);
|
|
1132
|
+
},
|
|
1133
|
+
|
|
1134
|
+
// Entity hooks — fire for all writes on an entity
|
|
1135
|
+
getEntityPostSaveHooks(
|
|
1136
|
+
entityName: string,
|
|
1137
|
+
phase?: HookPhase,
|
|
1138
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
1139
|
+
): readonly PostSaveHookFn[] {
|
|
1140
|
+
return filterByPhase(entityPostSaveHooks.get(entityName), phase, effectiveFeatures);
|
|
1141
|
+
},
|
|
1142
|
+
|
|
1143
|
+
getEntityPreDeleteHooks(
|
|
1144
|
+
entityName: string,
|
|
1145
|
+
phase?: HookPhase,
|
|
1146
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
1147
|
+
): readonly PreDeleteHookFn[] {
|
|
1148
|
+
return filterByPhase(entityPreDeleteHooks.get(entityName), phase, effectiveFeatures);
|
|
1149
|
+
},
|
|
1150
|
+
|
|
1151
|
+
getEntityPostDeleteHooks(
|
|
1152
|
+
entityName: string,
|
|
1153
|
+
phase?: HookPhase,
|
|
1154
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
1155
|
+
): readonly PostDeleteHookFn[] {
|
|
1156
|
+
return filterByPhase(entityPostDeleteHooks.get(entityName), phase, effectiveFeatures);
|
|
1157
|
+
},
|
|
1158
|
+
|
|
1159
|
+
getAllTranslations(): TranslationKeys {
|
|
1160
|
+
return mergedTranslations;
|
|
1161
|
+
},
|
|
1162
|
+
|
|
1163
|
+
getHandlerEntity(qualifiedHandler: string): string | undefined {
|
|
1164
|
+
return handlerEntityMap.get(qualifiedHandler);
|
|
1165
|
+
},
|
|
1166
|
+
|
|
1167
|
+
isHandlerSystemScoped(qualifiedHandler: string): boolean {
|
|
1168
|
+
const featureName = handlerFeatureMap.get(qualifiedHandler);
|
|
1169
|
+
if (!featureName) return false;
|
|
1170
|
+
return featureMap.get(featureName)?.systemScope ?? false;
|
|
1171
|
+
},
|
|
1172
|
+
|
|
1173
|
+
getHandlerFeature(qualifiedHandler: string): string | undefined {
|
|
1174
|
+
return handlerFeatureMap.get(qualifiedHandler);
|
|
1175
|
+
},
|
|
1176
|
+
|
|
1177
|
+
getAllMetrics() {
|
|
1178
|
+
return metricMap;
|
|
1179
|
+
},
|
|
1180
|
+
|
|
1181
|
+
getAllSecretKeys(): ReadonlyMap<string, SecretKeyDefinition> {
|
|
1182
|
+
return secretKeyMap;
|
|
1183
|
+
},
|
|
1184
|
+
|
|
1185
|
+
getSecretKey(qualifiedName: string): SecretKeyDefinition | undefined {
|
|
1186
|
+
return secretKeyMap.get(qualifiedName);
|
|
1187
|
+
},
|
|
1188
|
+
|
|
1189
|
+
getConfigKey(qualifiedKey: string): ConfigKeyDefinition | undefined {
|
|
1190
|
+
return configKeyMap.get(qualifiedKey);
|
|
1191
|
+
},
|
|
1192
|
+
|
|
1193
|
+
getAllConfigKeys(): ReadonlyMap<string, ConfigKeyDefinition> {
|
|
1194
|
+
return configKeyMap;
|
|
1195
|
+
},
|
|
1196
|
+
|
|
1197
|
+
getJob(qualifiedName: string): JobDefinition | undefined {
|
|
1198
|
+
return jobMap.get(qualifiedName);
|
|
1199
|
+
},
|
|
1200
|
+
|
|
1201
|
+
getAllJobs(): ReadonlyMap<string, JobDefinition> {
|
|
1202
|
+
return jobMap;
|
|
1203
|
+
},
|
|
1204
|
+
|
|
1205
|
+
getEvent(qualifiedName: string): EventDef | undefined {
|
|
1206
|
+
return eventMap.get(qualifiedName);
|
|
1207
|
+
},
|
|
1208
|
+
|
|
1209
|
+
getEventUpcasters() {
|
|
1210
|
+
return eventUpcasterMap;
|
|
1211
|
+
},
|
|
1212
|
+
|
|
1213
|
+
getExtension(name: string): RegistrarExtensionDef | undefined {
|
|
1214
|
+
return extensionMap.get(name);
|
|
1215
|
+
},
|
|
1216
|
+
|
|
1217
|
+
getExtensionUsages(extensionName: string): readonly RegistrarExtensionRegistration[] {
|
|
1218
|
+
return extensionUsages.filter((u) => u.extensionName === extensionName);
|
|
1219
|
+
},
|
|
1220
|
+
|
|
1221
|
+
getAllNotifications(): ReadonlyMap<string, NotificationDefinition> {
|
|
1222
|
+
return notificationMap;
|
|
1223
|
+
},
|
|
1224
|
+
|
|
1225
|
+
getAllReferenceData(): readonly ReferenceDataDef[] {
|
|
1226
|
+
return allReferenceData;
|
|
1227
|
+
},
|
|
1228
|
+
|
|
1229
|
+
getProjectionsForSource(entityName: string): readonly ProjectionDefinition[] {
|
|
1230
|
+
return projectionsBySource.get(entityName) ?? [];
|
|
1231
|
+
},
|
|
1232
|
+
|
|
1233
|
+
getAllProjections(): ReadonlyMap<string, ProjectionDefinition> {
|
|
1234
|
+
return projectionMap;
|
|
1235
|
+
},
|
|
1236
|
+
|
|
1237
|
+
getAllMultiStreamProjections(): ReadonlyMap<string, MultiStreamProjectionDefinition> {
|
|
1238
|
+
return multiStreamProjectionMap;
|
|
1239
|
+
},
|
|
1240
|
+
|
|
1241
|
+
getMultiStreamProjectionFeature(qualifiedName: string): string | undefined {
|
|
1242
|
+
return multiStreamProjectionFeatureMap.get(qualifiedName);
|
|
1243
|
+
},
|
|
1244
|
+
|
|
1245
|
+
getAuthClaimsHooks(): readonly AuthClaimsHookDef[] {
|
|
1246
|
+
return authClaimsHooks;
|
|
1247
|
+
},
|
|
1248
|
+
|
|
1249
|
+
getAllClaimKeys(): ReadonlyMap<string, ClaimKeyDefinition> {
|
|
1250
|
+
return claimKeyMap;
|
|
1251
|
+
},
|
|
1252
|
+
|
|
1253
|
+
getClaimKey(qualifiedName: string): ClaimKeyDefinition | undefined {
|
|
1254
|
+
return claimKeyMap.get(qualifiedName);
|
|
1255
|
+
},
|
|
1256
|
+
|
|
1257
|
+
getAllScreens(): ReadonlyMap<string, ScreenDefinition> {
|
|
1258
|
+
return screenMap;
|
|
1259
|
+
},
|
|
1260
|
+
|
|
1261
|
+
getScreen(qualifiedName: string): ScreenDefinition | undefined {
|
|
1262
|
+
return screenMap.get(qualifiedName);
|
|
1263
|
+
},
|
|
1264
|
+
|
|
1265
|
+
getScreenFeature(qualifiedName: string): string | undefined {
|
|
1266
|
+
return screenFeatureMap.get(qualifiedName);
|
|
1267
|
+
},
|
|
1268
|
+
|
|
1269
|
+
getScreensByEntity(entityName: string): readonly ScreenDefinition[] {
|
|
1270
|
+
return screensByEntity.get(entityName) ?? [];
|
|
1271
|
+
},
|
|
1272
|
+
|
|
1273
|
+
getAllNavs(): ReadonlyMap<string, NavDefinition> {
|
|
1274
|
+
return navMap;
|
|
1275
|
+
},
|
|
1276
|
+
|
|
1277
|
+
getNav(qualifiedName: string): NavDefinition | undefined {
|
|
1278
|
+
return navMap.get(qualifiedName);
|
|
1279
|
+
},
|
|
1280
|
+
|
|
1281
|
+
getNavFeature(qualifiedName: string): string | undefined {
|
|
1282
|
+
return navFeatureMap.get(qualifiedName);
|
|
1283
|
+
},
|
|
1284
|
+
|
|
1285
|
+
getNavsByParent(parentQualifiedName: string): readonly NavDefinition[] {
|
|
1286
|
+
return navsByParent.get(parentQualifiedName) ?? [];
|
|
1287
|
+
},
|
|
1288
|
+
|
|
1289
|
+
getTopLevelNavs(): readonly NavDefinition[] {
|
|
1290
|
+
return topLevelNavs;
|
|
1291
|
+
},
|
|
1292
|
+
|
|
1293
|
+
getAllWorkspaces(): ReadonlyMap<string, WorkspaceDefinition> {
|
|
1294
|
+
return workspaceMap;
|
|
1295
|
+
},
|
|
1296
|
+
|
|
1297
|
+
getWorkspace(qualifiedName: string): WorkspaceDefinition | undefined {
|
|
1298
|
+
return workspaceMap.get(qualifiedName);
|
|
1299
|
+
},
|
|
1300
|
+
|
|
1301
|
+
getWorkspaceFeature(qualifiedName: string): string | undefined {
|
|
1302
|
+
return workspaceFeatureMap.get(qualifiedName);
|
|
1303
|
+
},
|
|
1304
|
+
|
|
1305
|
+
getWorkspaceNavs(workspaceQualifiedName: string): readonly string[] {
|
|
1306
|
+
return navsByWorkspace.get(workspaceQualifiedName) ?? [];
|
|
1307
|
+
},
|
|
1308
|
+
|
|
1309
|
+
getDefaultWorkspace(): WorkspaceDefinition | undefined {
|
|
1310
|
+
return defaultWorkspace;
|
|
1311
|
+
},
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Returns true if any entity in the feature has field-level access rules (read or write). */
|
|
1316
|
+
function hasFieldAccessRules(feature: FeatureDefinition): boolean {
|
|
1317
|
+
for (const entity of Object.values(feature.entities)) {
|
|
1318
|
+
for (const field of Object.values(entity.fields)) {
|
|
1319
|
+
if (field.access?.read?.length || field.access?.write?.length) {
|
|
1320
|
+
return true;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return false;
|
|
1325
|
+
}
|