@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,166 @@
|
|
|
1
|
+
// F8 — pg-unique-violation auf entity-level-Indices wird sauber zu
|
|
2
|
+
// einer 409 UniqueViolationError gemapped, NICHT zu einer 500
|
|
3
|
+
// InternalError.
|
|
4
|
+
//
|
|
5
|
+
// Der event-store-Layer hatte das schon (Sprint 4d Patch:
|
|
6
|
+
// EventStoreVersionConflict-catch im executor.create/update). Aber
|
|
7
|
+
// app-level unique-Indices auf der Projection-Tabelle (z.B. (tenantId,
|
|
8
|
+
// email) auf User-Entity) liefen ohne mapping durch — krachten als
|
|
9
|
+
// pg-23505 InternalError. F8 schließt diese Lücke.
|
|
10
|
+
//
|
|
11
|
+
// **Test-Setup:** ein User-style entity mit composite-unique-Index
|
|
12
|
+
// (tenantId, email). Aggregate-id ist auto-generated UUID, kollidiert
|
|
13
|
+
// also nicht. Erst die projection-INSERT verletzt den Index. Ohne F8:
|
|
14
|
+
// 500. Mit F8: writeFailure(UniqueViolationError) → 409.
|
|
15
|
+
|
|
16
|
+
import { sql } from "drizzle-orm";
|
|
17
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
18
|
+
import { createEntity, createTextField } from "../../engine";
|
|
19
|
+
import { createEventsTable } from "../../event-store";
|
|
20
|
+
import { createEntityTable, createTestDb, type TestDb, TestUsers } from "../../stack";
|
|
21
|
+
import { createEventStoreExecutor } from "../event-store-executor";
|
|
22
|
+
import { buildDrizzleTable } from "../table-builder";
|
|
23
|
+
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
24
|
+
|
|
25
|
+
const userEntity = createEntity({
|
|
26
|
+
table: "read_unique_users",
|
|
27
|
+
fields: {
|
|
28
|
+
email: createTextField({ required: true }),
|
|
29
|
+
displayName: createTextField({ required: true }),
|
|
30
|
+
},
|
|
31
|
+
// softDelete=true damit wir den restore-Pfad pinnen können (siehe
|
|
32
|
+
// restore-Test unten — "kein 23505 möglich" claim).
|
|
33
|
+
softDelete: true,
|
|
34
|
+
// Composite-unique auf (tenantId, email) — typisches User-Pattern.
|
|
35
|
+
// Der unique-Index lebt auf der Projection, NICHT auf der events-
|
|
36
|
+
// Tabelle. Daher fängt der existing event-store-23505-catch (Sprint
|
|
37
|
+
// 4d) das nicht; das ist der Pfad den F8 abdeckt.
|
|
38
|
+
indexes: [
|
|
39
|
+
{ columns: ["tenantId", "email"], unique: true, name: "read_unique_users_tenant_email_uniq" },
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
const table = buildDrizzleTable("unique-user", userEntity);
|
|
43
|
+
const exec = createEventStoreExecutor(table, userEntity, { entityName: "unique-user" });
|
|
44
|
+
|
|
45
|
+
let testDb: TestDb;
|
|
46
|
+
let tdb: TenantDb;
|
|
47
|
+
const admin = TestUsers.admin;
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
testDb = await createTestDb();
|
|
51
|
+
await createEntityTable(testDb.db, userEntity, "unique-user");
|
|
52
|
+
await createEventsTable(testDb.db);
|
|
53
|
+
tdb = createTenantDb(testDb.db, admin.tenantId);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterAll(async () => {
|
|
57
|
+
await testDb.cleanup();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
await testDb.db.execute(sql`TRUNCATE kumiko_events, read_unique_users RESTART IDENTITY CASCADE`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// =============================================================================
|
|
65
|
+
// create — duplicate email → 409 unique_violation
|
|
66
|
+
// =============================================================================
|
|
67
|
+
|
|
68
|
+
describe("F8 — entity-level unique-violation auf create", () => {
|
|
69
|
+
test("zweiter create mit selber email → unique_violation 409 (nicht internal_error 500)", async () => {
|
|
70
|
+
const first = await exec.create(
|
|
71
|
+
{ email: "alice@example.com", displayName: "Alice 1" },
|
|
72
|
+
admin,
|
|
73
|
+
tdb,
|
|
74
|
+
);
|
|
75
|
+
expect(first.isSuccess).toBe(true);
|
|
76
|
+
|
|
77
|
+
const second = await exec.create(
|
|
78
|
+
{ email: "alice@example.com", displayName: "Alice 2" },
|
|
79
|
+
admin,
|
|
80
|
+
tdb,
|
|
81
|
+
);
|
|
82
|
+
expect(second.isSuccess).toBe(false);
|
|
83
|
+
if (second.isSuccess) return;
|
|
84
|
+
expect(second.error.code).toBe("unique_violation");
|
|
85
|
+
expect(second.error.httpStatus).toBe(409);
|
|
86
|
+
// constraintName aus dem PG-error durchgereicht — App-Code kann
|
|
87
|
+
// damit auf den richtigen field-name mappen.
|
|
88
|
+
const details = second.error.details as { constraintName?: string; entityName?: string };
|
|
89
|
+
expect(details.entityName).toBe("unique-user");
|
|
90
|
+
expect(details.constraintName).toBe("read_unique_users_tenant_email_uniq");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("DB-Beweis: nach 23505-conflict ist nur die erste Row in der Projection", async () => {
|
|
94
|
+
await exec.create({ email: "bob@example.com", displayName: "Bob 1" }, admin, tdb);
|
|
95
|
+
const second = await exec.create(
|
|
96
|
+
{ email: "bob@example.com", displayName: "Bob 2" },
|
|
97
|
+
admin,
|
|
98
|
+
tdb,
|
|
99
|
+
);
|
|
100
|
+
expect(second.isSuccess).toBe(false);
|
|
101
|
+
const rows = await testDb.db.select().from(table);
|
|
102
|
+
expect(rows).toHaveLength(1);
|
|
103
|
+
expect((rows[0] as { displayName: string }).displayName).toBe("Bob 1");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// =============================================================================
|
|
108
|
+
// update — change email to existing value → 409 unique_violation
|
|
109
|
+
// =============================================================================
|
|
110
|
+
|
|
111
|
+
describe("F8 — entity-level unique-violation auf update", () => {
|
|
112
|
+
test("update auf existing email-value → unique_violation 409", async () => {
|
|
113
|
+
const alice = await exec.create(
|
|
114
|
+
{ email: "alice@example.com", displayName: "Alice" },
|
|
115
|
+
admin,
|
|
116
|
+
tdb,
|
|
117
|
+
);
|
|
118
|
+
const bob = await exec.create({ email: "bob@example.com", displayName: "Bob" }, admin, tdb);
|
|
119
|
+
if (!alice.isSuccess || !bob.isSuccess) throw new Error("create failed in setup");
|
|
120
|
+
|
|
121
|
+
// Bob versucht Alice's email zu nehmen → kollidiert mit dem
|
|
122
|
+
// existing alice-row. Vor F8 wäre das ein internal_error 500
|
|
123
|
+
// gewesen.
|
|
124
|
+
const conflict = await exec.update(
|
|
125
|
+
{ id: bob.data.id, version: 1, changes: { email: "alice@example.com" } },
|
|
126
|
+
admin,
|
|
127
|
+
tdb,
|
|
128
|
+
);
|
|
129
|
+
expect(conflict.isSuccess).toBe(false);
|
|
130
|
+
if (conflict.isSuccess) return;
|
|
131
|
+
expect(conflict.error.code).toBe("unique_violation");
|
|
132
|
+
expect(conflict.error.httpStatus).toBe(409);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// restore — kein try-catch nötig (drift-pin: dokumentiert die Annahme)
|
|
138
|
+
// =============================================================================
|
|
139
|
+
|
|
140
|
+
describe("F8 — restore touch'd nur isDeleted, kein 23505-Pfad", () => {
|
|
141
|
+
test("restore einer soft-gedeleteten row mit unique-field läuft konfliktfrei durch", async () => {
|
|
142
|
+
// Audit-Annahme (advisor-Punkt verifiziert): restore mutiert nur
|
|
143
|
+
// isDeleted=false, kein unique-field-Touch. Der unique-Index ist
|
|
144
|
+
// global (kein partial-WHERE-NOT-isDeleted in framework's table-
|
|
145
|
+
// builder), also würde EIN paralleler create mit derselben email
|
|
146
|
+
// schon am unique-Index scheitern (F8-create-Pfad), bevor er den
|
|
147
|
+
// soft-deleted restore-Pfad konfliktfrei machen könnte.
|
|
148
|
+
//
|
|
149
|
+
// Dieser Test pinnt: restore allein wirft kein 23505. Wenn jemand
|
|
150
|
+
// morgen einen Pfad einbaut der restore mit field-changes
|
|
151
|
+
// kombiniert, fällt's hier auf — der Test war "soll konfliktfrei
|
|
152
|
+
// sein", die Annahme wird laut.
|
|
153
|
+
const alice = await exec.create(
|
|
154
|
+
{ email: "carol@example.com", displayName: "Carol" },
|
|
155
|
+
admin,
|
|
156
|
+
tdb,
|
|
157
|
+
);
|
|
158
|
+
if (!alice.isSuccess) throw new Error("create failed in setup");
|
|
159
|
+
|
|
160
|
+
const deleted = await exec.delete({ id: alice.data.id }, admin, tdb);
|
|
161
|
+
expect(deleted.isSuccess).toBe(true);
|
|
162
|
+
|
|
163
|
+
const restored = await exec.restore({ id: alice.data.id }, admin, tdb);
|
|
164
|
+
expect(restored.isSuccess).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// applyEntityEvent — die EINZIGE Schreib-Logik für r.entity-Tabellen aus
|
|
2
|
+
// Stored-Events. Beide Aufrufer benutzen sie:
|
|
3
|
+
//
|
|
4
|
+
// - createEventStoreExecutor (live, im Write-TX) — übergibt ein
|
|
5
|
+
// "live event" mit unstripped flatData/flatChanges als payload damit
|
|
6
|
+
// sensitive Felder in der Read-Tabelle landen, das Event-Log selbst
|
|
7
|
+
// bleibt aber stripped (siehe append-Site im Executor).
|
|
8
|
+
// - rebuildProjection via ImplicitProjection (replay, im Rebuild-TX) —
|
|
9
|
+
// übergibt das StoredEvent direkt; payload ist dort ohne sensitive,
|
|
10
|
+
// was bei Rebuild akzeptiert wird (sensitive-Drift durch GDPR-Strip
|
|
11
|
+
// ist als load-bearing Backlog-Item gepinnt — siehe
|
|
12
|
+
// docs/plans/architecture/migrations.md Sektion "Backlog (Welle 3+)"
|
|
13
|
+
// → "Sensitive-Field-Persistenz im Rebuild" für Optionen a/b/c).
|
|
14
|
+
//
|
|
15
|
+
// Live==Rebuild-Equivalence ist damit by-construction für alle Felder
|
|
16
|
+
// die NICHT als sensitive markiert sind — eine geänderte Schreib-Logik
|
|
17
|
+
// muss nur an EINER Stelle gepflegt werden, kein Sync-Contract mehr.
|
|
18
|
+
// Der load-bearing Test bleibt für non-sensitive-Drift in
|
|
19
|
+
// db/__tests__/implicit-projection-equivalence.integration.ts.
|
|
20
|
+
//
|
|
21
|
+
// Tenant-Isolation: applyEntityEvent erwartet einen rohen DbRunner (TX
|
|
22
|
+
// oder pool), KEINEN TenantDb-Wrapper. Schutz kommt aus zwei Quellen:
|
|
23
|
+
// 1. Live-Pfad ruft VOR der Schreibung loadById (tenant-scoped) für
|
|
24
|
+
// update/delete/restore — die aggregateId ist also schon tenant-
|
|
25
|
+
// validiert bevor wir hier ankommen.
|
|
26
|
+
// 2. Bei create wird tenantId explizit aus event.tenantId gesetzt, also
|
|
27
|
+
// nie über den TenantDb-Wrapper-Default abgeleitet.
|
|
28
|
+
// Damit ist der TenantDb-Wrapper-Loss in dieser Funktion funktional ohne
|
|
29
|
+
// Sicherheitslücke.
|
|
30
|
+
//
|
|
31
|
+
// Auto-Verben:
|
|
32
|
+
// <entity>.created → INSERT
|
|
33
|
+
// <entity>.updated → UPDATE WHERE id=aggregateId
|
|
34
|
+
// <entity>.deleted → soft-delete-UPDATE wenn entity.softDelete, sonst hard-DELETE
|
|
35
|
+
// <entity>.restored → undelete-UPDATE (nur bei softDelete sinnvoll)
|
|
36
|
+
//
|
|
37
|
+
// Domain-Events (r.defineEvent) auf demselben Aggregate werden hier NICHT
|
|
38
|
+
// behandelt — die liefen im Live-Pfad nie durch den Executor und müssen
|
|
39
|
+
// von expliziten r.projection-apply-Handlern oder r.multiStreamProjection
|
|
40
|
+
// behandelt werden. ImplicitProjection registriert daher nur die 4
|
|
41
|
+
// Auto-Verben.
|
|
42
|
+
//
|
|
43
|
+
// Return-Shape: ApplyResult mit `kind` + optionaler `row`.
|
|
44
|
+
// - "applied" → Schreibung lief durch. `row` enthält die geschriebene
|
|
45
|
+
// Row für create/update/soft-delete/restore. Bei hard-delete ist
|
|
46
|
+
// `row` null (DELETE-Statements geben keine returning-Row her).
|
|
47
|
+
// - "skipped" → Event ist kein Auto-Verb (Domain-Event auf demselben
|
|
48
|
+
// Aggregate). Caller no-op.
|
|
49
|
+
|
|
50
|
+
import { eq } from "drizzle-orm";
|
|
51
|
+
import type { EntityDefinition } from "../engine/types";
|
|
52
|
+
import { InternalError } from "../errors";
|
|
53
|
+
import type { StoredEvent } from "../event-store";
|
|
54
|
+
import type { DbRow, DbRunner } from "./connection";
|
|
55
|
+
import type { TableColumns } from "./dialect";
|
|
56
|
+
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: Drizzle-Tabellen sind generisch typed; framework code erasiert die Spalten-Union absichtlich.
|
|
58
|
+
type Table = TableColumns<any>;
|
|
59
|
+
|
|
60
|
+
export type AutoVerb = "created" | "updated" | "deleted" | "restored";
|
|
61
|
+
|
|
62
|
+
export type ApplyResult =
|
|
63
|
+
| { readonly kind: "applied"; readonly verb: AutoVerb; readonly row: DbRow | null }
|
|
64
|
+
| { readonly kind: "skipped" };
|
|
65
|
+
|
|
66
|
+
/** Parsed event.type → AutoVerb wenn das Event eines der 4 Auto-Verben
|
|
67
|
+
* auf dem gegebenen Aggregate ist. null sonst (Domain-Event). */
|
|
68
|
+
export function parseAutoVerb(event: StoredEvent): AutoVerb | null {
|
|
69
|
+
const prefix = `${event.aggregateType}.`;
|
|
70
|
+
if (!event.type.startsWith(prefix)) return null;
|
|
71
|
+
const verb = event.type.slice(prefix.length);
|
|
72
|
+
if (verb === "created" || verb === "updated" || verb === "deleted" || verb === "restored") {
|
|
73
|
+
return verb;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Idempotente Anwendung eines Auto-Events auf die Entity-Tabelle.
|
|
79
|
+
* Wird sowohl beim Live-Append (innerhalb der Write-TX) als auch beim
|
|
80
|
+
* Rebuild (innerhalb der Rebuild-TX) gerufen — identische Logik. */
|
|
81
|
+
export async function applyEntityEvent(
|
|
82
|
+
event: StoredEvent,
|
|
83
|
+
table: Table,
|
|
84
|
+
entity: EntityDefinition,
|
|
85
|
+
tx: DbRunner,
|
|
86
|
+
): Promise<ApplyResult> {
|
|
87
|
+
const verb = parseAutoVerb(event);
|
|
88
|
+
if (verb === null) return { kind: "skipped" };
|
|
89
|
+
const softDelete = entity.softDelete ?? false;
|
|
90
|
+
|
|
91
|
+
switch (verb) {
|
|
92
|
+
case "created": {
|
|
93
|
+
// tenantId-Resolution explizit, nicht via Spread-Reihenfolge:
|
|
94
|
+
// Live-Pfad nutzt tx=db.raw (kein TenantDb-Wrapper-Auto-Inject),
|
|
95
|
+
// beim Replay erst recht keiner. Default = event.tenantId; payload
|
|
96
|
+
// gewinnt NUR wenn gültig string mit length > 0 (seedTenantMembership-
|
|
97
|
+
// Pfad: Operator schreibt im Ziel-Tenant, Event im Operator-Tenant).
|
|
98
|
+
// Pinst durch db/__tests__/apply-entity-event-tenant.integration.ts.
|
|
99
|
+
//
|
|
100
|
+
// Fail-loud wenn payload.tenantId gesetzt aber invalid (leer/null/
|
|
101
|
+
// non-string): das ist tenant-isolation-kritisch — silent fallback
|
|
102
|
+
// auf event.tenantId würde eine Bug-payload in den Operator-Tenant
|
|
103
|
+
// schreiben statt zu failen, was Cross-Tenant-Datendrift erzeugt.
|
|
104
|
+
const payloadTenantId = event.payload["tenantId"];
|
|
105
|
+
let tenantId: string;
|
|
106
|
+
if (payloadTenantId === undefined) {
|
|
107
|
+
tenantId = event.tenantId;
|
|
108
|
+
} else if (typeof payloadTenantId === "string" && payloadTenantId.length > 0) {
|
|
109
|
+
tenantId = payloadTenantId;
|
|
110
|
+
} else {
|
|
111
|
+
throw new InternalError({
|
|
112
|
+
message: `applyEntityEvent: payload.tenantId set but invalid (${JSON.stringify(payloadTenantId)}). Tenant-isolation-kritisch: silent fallback auf event.tenantId würde Cross-Tenant-Drift erzeugen.`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const [row] = await tx
|
|
116
|
+
.insert(table)
|
|
117
|
+
.values({
|
|
118
|
+
...event.payload,
|
|
119
|
+
tenantId,
|
|
120
|
+
id: event.aggregateId,
|
|
121
|
+
version: event.version,
|
|
122
|
+
insertedAt: event.createdAt,
|
|
123
|
+
insertedById: event.createdBy,
|
|
124
|
+
})
|
|
125
|
+
.returning();
|
|
126
|
+
return { kind: "applied", verb, row: (row as DbRow | undefined) ?? null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "updated": {
|
|
130
|
+
// payload-Shape: { changes, previous } — siehe event-store-executor.ts.
|
|
131
|
+
const changes = (event.payload["changes"] ?? {}) as Record<string, unknown>; // @cast-boundary engine-payload
|
|
132
|
+
const [row] = await tx
|
|
133
|
+
.update(table)
|
|
134
|
+
.set({
|
|
135
|
+
...changes,
|
|
136
|
+
version: event.version,
|
|
137
|
+
modifiedAt: event.createdAt,
|
|
138
|
+
modifiedById: event.createdBy,
|
|
139
|
+
})
|
|
140
|
+
.where(eq(table["id"], event.aggregateId))
|
|
141
|
+
.returning();
|
|
142
|
+
return { kind: "applied", verb, row: (row as DbRow | undefined) ?? null };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case "deleted": {
|
|
146
|
+
if (softDelete) {
|
|
147
|
+
const [row] = await tx
|
|
148
|
+
.update(table)
|
|
149
|
+
.set({
|
|
150
|
+
isDeleted: true,
|
|
151
|
+
deletedAt: event.createdAt,
|
|
152
|
+
deletedById: event.createdBy,
|
|
153
|
+
version: event.version,
|
|
154
|
+
modifiedAt: event.createdAt,
|
|
155
|
+
modifiedById: event.createdBy,
|
|
156
|
+
})
|
|
157
|
+
.where(eq(table["id"], event.aggregateId))
|
|
158
|
+
.returning();
|
|
159
|
+
return { kind: "applied", verb, row: (row as DbRow | undefined) ?? null };
|
|
160
|
+
}
|
|
161
|
+
// Hard-Delete: DELETE-Statement gibt keine returning-Row her und
|
|
162
|
+
// der Live-Pfad nutzt eh `existing` (pre-delete-Snapshot) für die
|
|
163
|
+
// Response. Beim Replay ist das fine, der Caller braucht die Row
|
|
164
|
+
// nicht weiter.
|
|
165
|
+
await tx.delete(table).where(eq(table["id"], event.aggregateId));
|
|
166
|
+
return { kind: "applied", verb, row: null };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case "restored": {
|
|
170
|
+
// Restore ist nur bei softDelete sinnvoll. Hard-Delete-Entities sollten
|
|
171
|
+
// keine restored-Events erhalten — falls doch, defensive skip.
|
|
172
|
+
if (!softDelete) return { kind: "skipped" };
|
|
173
|
+
const [row] = await tx
|
|
174
|
+
.update(table)
|
|
175
|
+
.set({
|
|
176
|
+
isDeleted: false,
|
|
177
|
+
deletedAt: null,
|
|
178
|
+
deletedById: null,
|
|
179
|
+
version: event.version,
|
|
180
|
+
modifiedAt: event.createdAt,
|
|
181
|
+
modifiedById: event.createdBy,
|
|
182
|
+
})
|
|
183
|
+
.where(eq(table["id"], event.aggregateId))
|
|
184
|
+
.returning();
|
|
185
|
+
return { kind: "applied", verb, row: (row as DbRow | undefined) ?? null };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { and, eq, type SQL } from "drizzle-orm";
|
|
3
|
+
import { NotFoundError } from "../errors";
|
|
4
|
+
import type { DbConnection } from "./connection";
|
|
5
|
+
import type { TenantDb } from "./tenant-db";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generic constraint helper: asserts a value exists in a table.
|
|
9
|
+
* Returns a ready-to-return NotFoundError when the row is missing, or null
|
|
10
|
+
* when it exists. Callers typically use it with writeFailure:
|
|
11
|
+
*
|
|
12
|
+
* const missing = await assertExistsIn(db, orderTable, { field: "id", value: id });
|
|
13
|
+
* if (missing) return writeFailure(missing);
|
|
14
|
+
*
|
|
15
|
+
* Accepts both DbConnection and TenantDb. When using TenantDb, the automatic
|
|
16
|
+
* tenant filter is applied. Use tenantId option for explicit tenant filtering
|
|
17
|
+
* on raw DbConnection.
|
|
18
|
+
*/
|
|
19
|
+
export async function assertExistsIn(
|
|
20
|
+
db: DbConnection | TenantDb,
|
|
21
|
+
// biome-ignore lint/suspicious/noExplicitAny: Drizzle table types are dynamic
|
|
22
|
+
entity: any,
|
|
23
|
+
options: {
|
|
24
|
+
field: string;
|
|
25
|
+
value: unknown;
|
|
26
|
+
tenantId?: TenantId;
|
|
27
|
+
where?: Record<string, unknown>;
|
|
28
|
+
entityName?: string;
|
|
29
|
+
},
|
|
30
|
+
): Promise<NotFoundError | null> {
|
|
31
|
+
const conditions = [eq(entity[options.field], options.value)];
|
|
32
|
+
|
|
33
|
+
if (options.tenantId !== undefined) {
|
|
34
|
+
conditions.push(eq(entity["tenantId"], options.tenantId));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (options.where) {
|
|
38
|
+
for (const [key, val] of Object.entries(options.where)) {
|
|
39
|
+
conditions.push(eq(entity[key], val));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const [row] = await db
|
|
44
|
+
.select()
|
|
45
|
+
.from(entity)
|
|
46
|
+
.where(and(...conditions) as SQL);
|
|
47
|
+
|
|
48
|
+
if (!row) {
|
|
49
|
+
const entityName = options.entityName ?? String(options.field).replace(/Id$/, "");
|
|
50
|
+
return new NotFoundError(
|
|
51
|
+
entityName,
|
|
52
|
+
typeof options.value === "number" || typeof options.value === "string"
|
|
53
|
+
? options.value
|
|
54
|
+
: undefined,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Compound-Type Pipeline für den Executor.
|
|
2
|
+
//
|
|
3
|
+
// Ein Compound-Type ist ein Field das aus EINEM API-Object und MEHREREN
|
|
4
|
+
// DB-Spalten besteht. Beispiele:
|
|
5
|
+
// - locatedTimestamp: { at, tz, utc } ↔ <name>Utc + <name>Tz
|
|
6
|
+
// - money: { amount, currency } ↔ <name> + <name>Currency
|
|
7
|
+
// - (kommt) address: { street, zip, city } ↔ <name>Street + <name>Zip + <name>City
|
|
8
|
+
//
|
|
9
|
+
// Statt jeden Helper im Executor an 4 Stellen verschachtelt aufzurufen,
|
|
10
|
+
// pipeline-iert diese Funktion alle Compound-Type-Konvertierungen in
|
|
11
|
+
// einem Pass. Beim Hinzufügen eines neuen Compound-Types nur EINE Stelle
|
|
12
|
+
// erweitern (das Array hier), nicht alle Executor-Aufrufe.
|
|
13
|
+
|
|
14
|
+
import type { EntityDefinition } from "../engine/types";
|
|
15
|
+
import { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp";
|
|
16
|
+
import { flattenMoney, rehydrateMoney } from "./money";
|
|
17
|
+
|
|
18
|
+
type Converter = (
|
|
19
|
+
payload: Record<string, unknown>,
|
|
20
|
+
entity: EntityDefinition,
|
|
21
|
+
) => Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
// Reihenfolge ist egal solange die Konverter sich nicht gegenseitig
|
|
24
|
+
// überlappen (z.B. money darf nicht ein Feld berühren das locatedTimestamp
|
|
25
|
+
// schon erzeugt hat). Aktuell überlappen sie nicht — types sind disjunkt.
|
|
26
|
+
const FLATTENERS: readonly Converter[] = [flattenLocatedTimestamp, flattenMoney];
|
|
27
|
+
const REHYDRATORS: readonly Converter[] = [rehydrateLocatedTimestamp, rehydrateMoney];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* API-Form (combined) → DB-Form (flat). Wird vor jedem Insert/Update aufgerufen.
|
|
31
|
+
*/
|
|
32
|
+
export function flattenCompoundTypes(
|
|
33
|
+
payload: Record<string, unknown>,
|
|
34
|
+
entity: EntityDefinition,
|
|
35
|
+
): Record<string, unknown> {
|
|
36
|
+
return FLATTENERS.reduce((acc, fn) => fn(acc, entity), payload);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* DB-Form (flat) → API-Form (combined). Wird nach jedem Read aufgerufen.
|
|
41
|
+
*/
|
|
42
|
+
export function rehydrateCompoundTypes(
|
|
43
|
+
row: Record<string, unknown>,
|
|
44
|
+
entity: EntityDefinition,
|
|
45
|
+
): Record<string, unknown> {
|
|
46
|
+
return REHYDRATORS.reduce((acc, fn) => fn(acc, entity), row);
|
|
47
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
2
|
+
import postgres from "postgres";
|
|
3
|
+
import { readPositiveIntEnv } from "../utils/env-parse";
|
|
4
|
+
|
|
5
|
+
export type DbConnection = ReturnType<typeof drizzle>;
|
|
6
|
+
|
|
7
|
+
// Drizzle's transaction callback receives a tx handle with the same query API
|
|
8
|
+
// as the top-level DbConnection. Extracted via Parameters so we stay in sync
|
|
9
|
+
// with whatever Drizzle defines without hard-coding the internal type name.
|
|
10
|
+
export type DbTx = Parameters<Parameters<DbConnection["transaction"]>[0]>[0];
|
|
11
|
+
|
|
12
|
+
// Code paths that operate on either a connection or an active transaction
|
|
13
|
+
// (e.g. TenantDb, dispatcher pipeline) accept both.
|
|
14
|
+
export type DbRunner = DbConnection | DbTx;
|
|
15
|
+
|
|
16
|
+
// Dynamic Drizzle tables (buildDrizzleTable with `any` column schema) lose
|
|
17
|
+
// their per-column types at the Drizzle boundary. Query results come back as
|
|
18
|
+
// arbitrary records. `DbRow` marks those typing-loss sites so readers see the
|
|
19
|
+
// limitation without re-spelling `Record<string, unknown>` at every callsite.
|
|
20
|
+
// Use `DbRow` for rows read via dynamic tables; a concrete entity-row type
|
|
21
|
+
// is preferred whenever the table is statically typed.
|
|
22
|
+
export type DbRow = Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
// The raw postgres.js client. Exposed alongside the Drizzle wrapper so the
|
|
25
|
+
// event-dispatcher (or other components that need LISTEN / pg-specific
|
|
26
|
+
// features Drizzle doesn't surface) can subscribe without re-opening a
|
|
27
|
+
// connection from the URL.
|
|
28
|
+
export type PgClient = ReturnType<typeof postgres>;
|
|
29
|
+
|
|
30
|
+
// Connection-pool options — thin wrapper around the postgres.js fields the
|
|
31
|
+
// framework explicitly supports. Omitted keys fall back to postgres.js
|
|
32
|
+
// defaults (max=10, idle_timeout=PGIDLE_TIMEOUT env, connect_timeout=
|
|
33
|
+
// PGCONNECT_TIMEOUT env). See `docs/plans/architecture/scaling.md` for
|
|
34
|
+
// sizing guidance per deployment shape.
|
|
35
|
+
export type DbConnectionOptions = {
|
|
36
|
+
// Max concurrent connections in the pool. postgres.js defaults to 10 —
|
|
37
|
+
// fine for a single app process against a small DB. Multi-worker or
|
|
38
|
+
// high-concurrency API deploys should scale this with `num_workers *
|
|
39
|
+
// per-request-concurrency` and stay below the DB's own max_connections
|
|
40
|
+
// (typical managed postgres: 100–400).
|
|
41
|
+
readonly maxConnections?: number;
|
|
42
|
+
// Seconds before an idle connection is closed. Null/undefined → keep
|
|
43
|
+
// connections warm forever (postgres.js default when the env var is
|
|
44
|
+
// unset). Managed pgBouncer tiers usually want this explicitly set to
|
|
45
|
+
// something like 30–60 so a single burst doesn't hold connections
|
|
46
|
+
// indefinitely.
|
|
47
|
+
readonly idleTimeoutSeconds?: number;
|
|
48
|
+
// Seconds to wait while establishing a new connection. Fails the query
|
|
49
|
+
// with a timeout error rather than hanging indefinitely when the DB is
|
|
50
|
+
// unreachable — critical for `/health/ready` to actually flip to 503
|
|
51
|
+
// within its 2s probe budget.
|
|
52
|
+
readonly connectTimeoutSeconds?: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function createDbConnection(
|
|
56
|
+
url: string,
|
|
57
|
+
options: DbConnectionOptions = {},
|
|
58
|
+
): {
|
|
59
|
+
db: DbConnection;
|
|
60
|
+
client: PgClient;
|
|
61
|
+
close: () => Promise<void>;
|
|
62
|
+
} {
|
|
63
|
+
// Only forward fields the caller set — empty object otherwise preserves
|
|
64
|
+
// postgres.js's env-var-driven defaults (PGIDLE_TIMEOUT / PGCONNECT_TIMEOUT).
|
|
65
|
+
const pgOptions: Parameters<typeof postgres>[1] = {};
|
|
66
|
+
if (options.maxConnections !== undefined) pgOptions.max = options.maxConnections;
|
|
67
|
+
if (options.idleTimeoutSeconds !== undefined) pgOptions.idle_timeout = options.idleTimeoutSeconds;
|
|
68
|
+
if (options.connectTimeoutSeconds !== undefined) {
|
|
69
|
+
pgOptions.connect_timeout = options.connectTimeoutSeconds;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const client = postgres(url, pgOptions);
|
|
73
|
+
const db = drizzle(client);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
db,
|
|
77
|
+
client,
|
|
78
|
+
close: async () => {
|
|
79
|
+
await client.end();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Parse the supported env vars into a DbConnectionOptions object. Useful
|
|
85
|
+
// for a main.ts that wants to read DATABASE_POOL_MAX / DATABASE_POOL_
|
|
86
|
+
// IDLE_TIMEOUT / DATABASE_POOL_CONNECT_TIMEOUT without re-implementing
|
|
87
|
+
// the number-coercion + validation. Unrecognised / non-numeric values
|
|
88
|
+
// throw — misconfig surfaces at boot, not mid-request.
|
|
89
|
+
export function dbConnectionOptionsFromEnv(
|
|
90
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
91
|
+
): DbConnectionOptions {
|
|
92
|
+
const opts: DbConnectionOptions & {
|
|
93
|
+
maxConnections?: number;
|
|
94
|
+
idleTimeoutSeconds?: number;
|
|
95
|
+
connectTimeoutSeconds?: number;
|
|
96
|
+
} = {};
|
|
97
|
+
const max = readPositiveIntEnv(env, "DATABASE_POOL_MAX");
|
|
98
|
+
const idle = readPositiveIntEnv(env, "DATABASE_POOL_IDLE_TIMEOUT");
|
|
99
|
+
const connect = readPositiveIntEnv(env, "DATABASE_POOL_CONNECT_TIMEOUT");
|
|
100
|
+
if (max !== undefined) opts.maxConnections = max;
|
|
101
|
+
if (idle !== undefined) opts.idleTimeoutSeconds = idle;
|
|
102
|
+
if (connect !== undefined) opts.connectTimeoutSeconds = connect;
|
|
103
|
+
return opts;
|
|
104
|
+
}
|
package/src/db/cursor.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { EntityId, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { and, asc, desc, eq, gt, inArray, type SQL, sql } from "drizzle-orm";
|
|
3
|
+
import type { SelectQuery as PgSelect } from "./dialect";
|
|
4
|
+
|
|
5
|
+
export type CursorQueryOptions = {
|
|
6
|
+
tenantId: TenantId;
|
|
7
|
+
cursor?: string;
|
|
8
|
+
limit?: number;
|
|
9
|
+
filterIds?: readonly EntityId[];
|
|
10
|
+
sort?: string;
|
|
11
|
+
sortDirection?: "asc" | "desc";
|
|
12
|
+
extraWhere?: SQL;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type CursorResult<T> = {
|
|
16
|
+
rows: T[];
|
|
17
|
+
nextCursor: string | null;
|
|
18
|
+
/** Optional total row count — nur present wenn der Caller `totalCount: true`
|
|
19
|
+
* in der Query setzt. Pager-UI braucht's für "Page X of Y"; Infinite-
|
|
20
|
+
* Scroll und Default-Lists lassen den extra COUNT(*) weg. */
|
|
21
|
+
total?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// String-basiert damit sowohl UUIDs (Default seit Sprint F) als auch
|
|
25
|
+
// Integer-Auto-Increment-IDs (Legacy/Spezialfälle) durch denselben
|
|
26
|
+
// Cursor-Pfad laufen. Stable-Sort-Voraussetzung: die id-Spalte muss
|
|
27
|
+
// lexikografisch monoton zur Insertion-Order sein. UUIDv7 erfüllt das
|
|
28
|
+
// (time-ordered Prefix); UUIDv4 nicht — wer den nutzt, kriegt
|
|
29
|
+
// inkorrekte cursor-Reihenfolge, das ist erwartet (Default ist v7).
|
|
30
|
+
export function encodeCursor(id: string | number): string {
|
|
31
|
+
return Buffer.from(String(id)).toString("base64url");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function decodeCursor(cursor: string): string {
|
|
35
|
+
const decoded = Buffer.from(cursor, "base64url").toString();
|
|
36
|
+
if (decoded === "") throw new Error(`Invalid cursor: ${cursor}`);
|
|
37
|
+
return decoded;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function applyCursorQuery<T extends PgSelect>(
|
|
41
|
+
query: T,
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables lose column types
|
|
43
|
+
table: any,
|
|
44
|
+
options: CursorQueryOptions,
|
|
45
|
+
): T {
|
|
46
|
+
const conditions: SQL[] = [eq(table.tenantId, options.tenantId)];
|
|
47
|
+
|
|
48
|
+
if (table.isDeleted) {
|
|
49
|
+
conditions.push(eq(table.isDeleted, false));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (options.cursor) {
|
|
53
|
+
conditions.push(gt(table.id, decodeCursor(options.cursor)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (options.filterIds !== undefined) {
|
|
57
|
+
if (options.filterIds.length === 0) {
|
|
58
|
+
// No matching IDs — return empty result via raw `false`. Statisch
|
|
59
|
+
// false ist type-agnostisch (int-PK / uuid-PK egal); ein eq(id, "")
|
|
60
|
+
// oder eq(id, -1) würde je nach Spalten-Type einen Cast-Error
|
|
61
|
+
// werfen.
|
|
62
|
+
conditions.push(sql`false`);
|
|
63
|
+
} else {
|
|
64
|
+
conditions.push(inArray(table.id, options.filterIds as readonly string[]));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (options.extraWhere) {
|
|
69
|
+
conditions.push(options.extraWhere);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const limit = options.limit ?? 50;
|
|
73
|
+
|
|
74
|
+
let result = query.where(and(...conditions)).limit(limit);
|
|
75
|
+
|
|
76
|
+
if (options.sort && table[options.sort]) {
|
|
77
|
+
const column = table[options.sort];
|
|
78
|
+
result =
|
|
79
|
+
options.sortDirection === "desc" ? result.orderBy(desc(column)) : result.orderBy(asc(column));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result as T;
|
|
83
|
+
}
|