@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,395 @@
|
|
|
1
|
+
// Schema-Drift-Detection für den Boot-Gate und die migrate-validate-CLI.
|
|
2
|
+
//
|
|
3
|
+
// Vergleicht den Drizzle-Migrations-Stand (committed im Repo unter
|
|
4
|
+
// drizzle/migrations/meta/) mit dem aktuellen DB-Stand. Drei Schichten:
|
|
5
|
+
//
|
|
6
|
+
// 1. Journal-vs-Applied: jeder Eintrag im _journal.json muss eine Zeile
|
|
7
|
+
// in __drizzle_migrations haben (= migrate apply lief vollständig).
|
|
8
|
+
// 2. Tables-Exist: jede Tabelle aus dem letzten Snapshot existiert.
|
|
9
|
+
// 3. Column-Diff: information_schema-Vergleich gegen Snapshot —
|
|
10
|
+
// missing-/extra-column, type-mismatch, nullability-mismatch. Fängt
|
|
11
|
+
// manuelle ALTER TABLEs in Prod sowie doppelte pgTable-Definitionen
|
|
12
|
+
// pro Tabelle (eine hand-written, eine via buildDrizzleTable), die
|
|
13
|
+
// stillschweigend gegen den Snapshot driften.
|
|
14
|
+
//
|
|
15
|
+
// Drizzle-kit's eigene Garantie: nach `migrate apply` ist der DB-Stand
|
|
16
|
+
// strukturell identisch mit dem letzten Snapshot. Schicht 3 catched
|
|
17
|
+
// alles was diese Garantie nachträglich bricht — schreibender Drittsystem,
|
|
18
|
+
// veraltete Code-Definitionen, vergessenes generate.
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { resolve } from "node:path";
|
|
22
|
+
import { sql } from "drizzle-orm";
|
|
23
|
+
import type { DbConnection } from "../db/connection";
|
|
24
|
+
import { tableExists } from "../db/schema-inspection";
|
|
25
|
+
import { parseJsonOrThrow } from "../utils/safe-json";
|
|
26
|
+
|
|
27
|
+
// --- Journal & Snapshot Loader ---
|
|
28
|
+
|
|
29
|
+
export type JournalEntry = {
|
|
30
|
+
readonly idx: number;
|
|
31
|
+
readonly version: string;
|
|
32
|
+
readonly when: number;
|
|
33
|
+
readonly tag: string;
|
|
34
|
+
readonly breakpoints: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type Journal = {
|
|
38
|
+
readonly version: string;
|
|
39
|
+
readonly dialect: string;
|
|
40
|
+
readonly entries: readonly JournalEntry[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function loadJournal(migrationsDir: string): Journal {
|
|
44
|
+
const journalPath = resolve(migrationsDir, "meta/_journal.json");
|
|
45
|
+
return parseJsonOrThrow<Journal>(readFileSync(journalPath, "utf-8"), `journal at ${journalPath}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Drizzle-Snapshot-Format. Eine Type für alle Read-Pfade — der
|
|
49
|
+
* Boot-Gate liest nur table-name+schema, projection-detection liest
|
|
50
|
+
* zusätzlich columns. Optional-typed `columns`-Field hält den Loader
|
|
51
|
+
* monomorph ohne zwei verschiedene Snapshot-Types. */
|
|
52
|
+
export type ColumnSpec = {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly type: string;
|
|
55
|
+
readonly notNull?: boolean;
|
|
56
|
+
readonly primaryKey?: boolean;
|
|
57
|
+
readonly default?: unknown;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type SnapshotTable = {
|
|
61
|
+
readonly schema: string;
|
|
62
|
+
readonly name: string;
|
|
63
|
+
readonly columns: Readonly<Record<string, ColumnSpec>>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type Snapshot = {
|
|
67
|
+
readonly tables: Readonly<Record<string, SnapshotTable>>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function loadSnapshot(snapshotPath: string): Snapshot {
|
|
71
|
+
return parseJsonOrThrow<Snapshot>(
|
|
72
|
+
readFileSync(snapshotPath, "utf-8"),
|
|
73
|
+
`snapshot at ${snapshotPath}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function snapshotPathForIdx(migrationsDir: string, idx: number): string {
|
|
78
|
+
return resolve(migrationsDir, "meta", `${String(idx).padStart(4, "0")}_snapshot.json`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Letzter Snapshot — der Stand der durch das jüngste Migration-File
|
|
82
|
+
* beschrieben ist. Wirft wenn das Journal leer ist (App ohne erste
|
|
83
|
+
* Migration). */
|
|
84
|
+
export function loadLatestSnapshot(migrationsDir: string): Snapshot {
|
|
85
|
+
const journal = loadJournal(migrationsDir);
|
|
86
|
+
const latest = journal.entries[journal.entries.length - 1];
|
|
87
|
+
if (!latest) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`loadLatestSnapshot: no entries in ${resolve(migrationsDir, "meta/_journal.json")}. ` +
|
|
90
|
+
`Run 'yarn kumiko migrate generate' first.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return loadSnapshot(snapshotPathForIdx(migrationsDir, latest.idx));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Vorletzter Snapshot — für Diff-Operationen. Returns null wenn
|
|
97
|
+
* weniger als 2 Einträge im Journal (Initial-Migration kann gegen
|
|
98
|
+
* nichts diff'en). */
|
|
99
|
+
export function loadPreviousSnapshot(migrationsDir: string): Snapshot | null {
|
|
100
|
+
const journal = loadJournal(migrationsDir);
|
|
101
|
+
if (journal.entries.length < 2) return null;
|
|
102
|
+
const previous = journal.entries[journal.entries.length - 2];
|
|
103
|
+
if (!previous) return null;
|
|
104
|
+
return loadSnapshot(snapshotPathForIdx(migrationsDir, previous.idx));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- DB-State Inspector ---
|
|
108
|
+
|
|
109
|
+
export type AppliedMigration = {
|
|
110
|
+
readonly hash: string;
|
|
111
|
+
readonly createdAt: number;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Liest die `__drizzle_migrations`-Tabelle. Wenn sie nicht existiert
|
|
115
|
+
* (frische DB, niemand hat bisher migrate apply gefahren) → leeres
|
|
116
|
+
* Array. Caller soll daraus "alle pending"-Drift ableiten.
|
|
117
|
+
*
|
|
118
|
+
* Drizzle-kit aktuell speichert in `drizzle.__drizzle_migrations`
|
|
119
|
+
* (eigenes Schema), Pre-0.20-Versionen in `public.__drizzle_migrations`.
|
|
120
|
+
* Wir prüfen beide Pfade und queryen den vorhandenen — keine
|
|
121
|
+
* hardcoded Schema-Annahme. */
|
|
122
|
+
export async function loadAppliedMigrations(db: DbConnection): Promise<AppliedMigration[]> {
|
|
123
|
+
const drizzleSchemaExists = await tableExists(db, "drizzle.__drizzle_migrations");
|
|
124
|
+
const publicSchemaExists = drizzleSchemaExists
|
|
125
|
+
? false
|
|
126
|
+
: await tableExists(db, "public.__drizzle_migrations");
|
|
127
|
+
if (!drizzleSchemaExists && !publicSchemaExists) return [];
|
|
128
|
+
// sql.identifier mit qualifiziertem Namen: erstes Argument = Schema,
|
|
129
|
+
// zweites = Tabellenname. Drizzle quotet beides defensiv.
|
|
130
|
+
const tableRef = drizzleSchemaExists
|
|
131
|
+
? sql`drizzle.__drizzle_migrations`
|
|
132
|
+
: sql`public.__drizzle_migrations`;
|
|
133
|
+
const rows = await db.execute<{ hash: string; created_at: bigint | number | null }>(sql`
|
|
134
|
+
SELECT hash, created_at
|
|
135
|
+
FROM ${tableRef}
|
|
136
|
+
ORDER BY id
|
|
137
|
+
`);
|
|
138
|
+
return rows.map((r) => ({
|
|
139
|
+
hash: r.hash,
|
|
140
|
+
createdAt: typeof r.created_at === "bigint" ? Number(r.created_at) : (r.created_at ?? 0),
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- Column-Diff (Welle 2 Boot-Gate Layer 3) ---
|
|
145
|
+
|
|
146
|
+
type DbColumnRow = {
|
|
147
|
+
readonly column_name: string;
|
|
148
|
+
readonly data_type: string;
|
|
149
|
+
readonly is_nullable: "YES" | "NO";
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** Liest information_schema.columns für eine Tabelle im public-Schema.
|
|
153
|
+
* Map by column_name. Default-Werte werden bewusst ausgelassen — die
|
|
154
|
+
* drift'en über drizzle-Versionen / PG-Reformulierungen hinweg ohne dass
|
|
155
|
+
* sich faktisch was ändert (z.B. `now()` vs `CURRENT_TIMESTAMP`). Type +
|
|
156
|
+
* notNull sind die belastbaren Vergleichs-Felder. */
|
|
157
|
+
async function loadDbColumns(
|
|
158
|
+
db: DbConnection,
|
|
159
|
+
tableName: string,
|
|
160
|
+
): Promise<ReadonlyMap<string, { type: string; notNull: boolean }>> {
|
|
161
|
+
const rows = await db.execute<DbColumnRow>(sql`
|
|
162
|
+
SELECT column_name, data_type, is_nullable
|
|
163
|
+
FROM information_schema.columns
|
|
164
|
+
WHERE table_schema = 'public' AND table_name = ${tableName}
|
|
165
|
+
`);
|
|
166
|
+
const map = new Map<string, { type: string; notNull: boolean }>();
|
|
167
|
+
for (const r of rows) {
|
|
168
|
+
map.set(r.column_name, {
|
|
169
|
+
type: normalizePgType(r.data_type),
|
|
170
|
+
notNull: r.is_nullable === "NO",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return map;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Normalize PG type-Strings auf Drizzle-Snapshot-Konvention. PG meldet
|
|
177
|
+
* "timestamp with time zone" für TIMESTAMPTZ, "character varying" für
|
|
178
|
+
* VARCHAR — Drizzle schreibt "timestamp with time zone" / "varchar" im
|
|
179
|
+
* Snapshot. Wir kollabieren auf einen kanonischen String. */
|
|
180
|
+
function normalizePgType(pgType: string): string {
|
|
181
|
+
switch (pgType) {
|
|
182
|
+
case "timestamp with time zone":
|
|
183
|
+
return "timestamp with time zone";
|
|
184
|
+
case "character varying":
|
|
185
|
+
return "varchar";
|
|
186
|
+
case "double precision":
|
|
187
|
+
return "double precision";
|
|
188
|
+
case "USER-DEFINED":
|
|
189
|
+
// Custom-types wie enums — kein clean diff möglich, akzeptieren wir
|
|
190
|
+
// als "irgendwas" und überspringen die Type-Prüfung.
|
|
191
|
+
return "USER-DEFINED";
|
|
192
|
+
default:
|
|
193
|
+
return pgType;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeSnapshotType(snapshotType: string): string {
|
|
198
|
+
// PostgreSQL meldet im information_schema kanonisierte data_type-Strings,
|
|
199
|
+
// Drizzle's snapshot kann mehrere äquivalente Schreibweisen produzieren:
|
|
200
|
+
//
|
|
201
|
+
// timestamptz → "timestamp with time zone"
|
|
202
|
+
// timestamp(3) with time zone → "timestamp with time zone"
|
|
203
|
+
// timestamp without time zone → unverändert
|
|
204
|
+
// bigserial → "bigint" (serial ist Macro für sequence + bigint)
|
|
205
|
+
// serial → "integer"
|
|
206
|
+
// smallserial → "smallint"
|
|
207
|
+
// varchar(N) → "character varying"
|
|
208
|
+
//
|
|
209
|
+
// Ohne diese Normalisierung produziert Layer-3 false-positives weil DB
|
|
210
|
+
// und Snapshot semantisch dieselbe Spalte unterschiedlich schreiben.
|
|
211
|
+
const lower = snapshotType.toLowerCase().replace(/\s+/g, " ").trim();
|
|
212
|
+
if (lower === "timestamptz" || lower.match(/^timestamp\(\d+\) with time zone$/)) {
|
|
213
|
+
return "timestamp with time zone";
|
|
214
|
+
}
|
|
215
|
+
if (lower === "bigserial") return "bigint";
|
|
216
|
+
if (lower === "serial") return "integer";
|
|
217
|
+
if (lower === "smallserial") return "smallint";
|
|
218
|
+
if (lower.startsWith("varchar")) return "character varying";
|
|
219
|
+
return lower;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Eine Differenz zwischen erwarteter (Snapshot) und tatsächlicher (DB)
|
|
223
|
+
* Spalten-Definition. */
|
|
224
|
+
export type ColumnIssue =
|
|
225
|
+
| { readonly kind: "missing-column"; readonly table: string; readonly column: string }
|
|
226
|
+
| { readonly kind: "extra-column"; readonly table: string; readonly column: string }
|
|
227
|
+
| {
|
|
228
|
+
readonly kind: "type-mismatch";
|
|
229
|
+
readonly table: string;
|
|
230
|
+
readonly column: string;
|
|
231
|
+
readonly expected: string;
|
|
232
|
+
readonly actual: string;
|
|
233
|
+
}
|
|
234
|
+
| {
|
|
235
|
+
readonly kind: "nullability-mismatch";
|
|
236
|
+
readonly table: string;
|
|
237
|
+
readonly column: string;
|
|
238
|
+
readonly expectedNotNull: boolean;
|
|
239
|
+
readonly actualNotNull: boolean;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
async function detectColumnIssues(
|
|
243
|
+
db: DbConnection,
|
|
244
|
+
snapshot: Snapshot,
|
|
245
|
+
existingTables: readonly string[],
|
|
246
|
+
): Promise<readonly ColumnIssue[]> {
|
|
247
|
+
const issues: ColumnIssue[] = [];
|
|
248
|
+
const existingSet = new Set(existingTables);
|
|
249
|
+
for (const t of Object.values(snapshot.tables)) {
|
|
250
|
+
const fullName = t.schema && t.schema.length > 0 ? `${t.schema}.${t.name}` : t.name;
|
|
251
|
+
if (!existingSet.has(fullName)) continue; // missing-table-Layer hat das schon
|
|
252
|
+
const dbCols = await loadDbColumns(db, t.name);
|
|
253
|
+
const snapCols = t.columns;
|
|
254
|
+
// Spalten die im Snapshot stehen, aber nicht in der DB sind.
|
|
255
|
+
for (const snapCol of Object.values(snapCols)) {
|
|
256
|
+
const dbCol = dbCols.get(snapCol.name);
|
|
257
|
+
if (!dbCol) {
|
|
258
|
+
issues.push({ kind: "missing-column", table: t.name, column: snapCol.name });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const expectedType = normalizeSnapshotType(snapCol.type);
|
|
262
|
+
// USER-DEFINED ist die PG-Antwort für enums — type-Vergleich wäre
|
|
263
|
+
// unzuverlässig (PG meldet keinen Enum-Namen über data_type). Skip.
|
|
264
|
+
if (dbCol.type !== "USER-DEFINED" && dbCol.type !== expectedType) {
|
|
265
|
+
issues.push({
|
|
266
|
+
kind: "type-mismatch",
|
|
267
|
+
table: t.name,
|
|
268
|
+
column: snapCol.name,
|
|
269
|
+
expected: expectedType,
|
|
270
|
+
actual: dbCol.type,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const expectedNotNull = snapCol.notNull === true || snapCol.primaryKey === true;
|
|
274
|
+
if (dbCol.notNull !== expectedNotNull) {
|
|
275
|
+
issues.push({
|
|
276
|
+
kind: "nullability-mismatch",
|
|
277
|
+
table: t.name,
|
|
278
|
+
column: snapCol.name,
|
|
279
|
+
expectedNotNull,
|
|
280
|
+
actualNotNull: dbCol.notNull,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Spalten die in der DB sind, aber nicht im Snapshot — vermutlich
|
|
285
|
+
// manueller ALTER TABLE in Prod. Reportet als extra-column.
|
|
286
|
+
const snapDbNames = new Set(Object.values(snapCols).map((c) => c.name));
|
|
287
|
+
for (const dbColName of dbCols.keys()) {
|
|
288
|
+
if (!snapDbNames.has(dbColName)) {
|
|
289
|
+
issues.push({ kind: "extra-column", table: t.name, column: dbColName });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return issues;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// --- Drift Report ---
|
|
297
|
+
|
|
298
|
+
export type DriftReport = {
|
|
299
|
+
readonly ok: boolean;
|
|
300
|
+
readonly pendingMigrations: readonly JournalEntry[];
|
|
301
|
+
readonly missingTables: readonly string[];
|
|
302
|
+
readonly columnIssues: readonly ColumnIssue[];
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
export async function detectDrift(db: DbConnection, migrationsDir: string): Promise<DriftReport> {
|
|
306
|
+
const journal = loadJournal(migrationsDir);
|
|
307
|
+
const applied = await loadAppliedMigrations(db);
|
|
308
|
+
|
|
309
|
+
// Heuristik: Drizzle's `__drizzle_migrations` enthält keine Reihenfolge-
|
|
310
|
+
// Information die direkt zu journal.tag matched. Praktisch: nach jeder
|
|
311
|
+
// erfolgreichen `migrate apply` ist applied.length === entries.length.
|
|
312
|
+
// Wenn Count abweicht → pending.
|
|
313
|
+
const pendingMigrations =
|
|
314
|
+
applied.length < journal.entries.length ? journal.entries.slice(applied.length) : [];
|
|
315
|
+
|
|
316
|
+
const snapshot = loadLatestSnapshot(migrationsDir);
|
|
317
|
+
// Drizzle's snapshot schreibt `schema: ""` für public — to_regclass
|
|
318
|
+
// ohne Schema-Prefix resolved ebenfalls in public, also passt empty.
|
|
319
|
+
const expectedTables = Object.values(snapshot.tables).map((t) =>
|
|
320
|
+
t.schema && t.schema.length > 0 ? `${t.schema}.${t.name}` : t.name,
|
|
321
|
+
);
|
|
322
|
+
const exists = await Promise.all(expectedTables.map((q) => tableExists(db, q)));
|
|
323
|
+
const missingTables = expectedTables.filter((_, i) => !exists[i]);
|
|
324
|
+
const existingTables = expectedTables.filter((_, i) => exists[i]);
|
|
325
|
+
|
|
326
|
+
// Layer 3: Column-Diff für die Tables die existieren. Pending Migrations
|
|
327
|
+
// skippen wir — die DB ist ohnehin in einem Zwischenzustand.
|
|
328
|
+
const columnIssues =
|
|
329
|
+
pendingMigrations.length === 0 ? await detectColumnIssues(db, snapshot, existingTables) : [];
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
ok: pendingMigrations.length === 0 && missingTables.length === 0 && columnIssues.length === 0,
|
|
333
|
+
pendingMigrations,
|
|
334
|
+
missingTables,
|
|
335
|
+
columnIssues,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function formatDriftReport(report: DriftReport): string {
|
|
340
|
+
if (report.ok) return "Schema is current.";
|
|
341
|
+
const lines: string[] = ["Schema drift detected:"];
|
|
342
|
+
if (report.pendingMigrations.length > 0) {
|
|
343
|
+
lines.push(` ${report.pendingMigrations.length} unapplied migration(s):`);
|
|
344
|
+
for (const m of report.pendingMigrations) {
|
|
345
|
+
lines.push(` - ${m.tag}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (report.missingTables.length > 0) {
|
|
349
|
+
lines.push(` ${report.missingTables.length} missing table(s):`);
|
|
350
|
+
for (const t of report.missingTables) {
|
|
351
|
+
lines.push(` - ${t}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (report.columnIssues.length > 0) {
|
|
355
|
+
lines.push(` ${report.columnIssues.length} column issue(s):`);
|
|
356
|
+
for (const issue of report.columnIssues) {
|
|
357
|
+
switch (issue.kind) {
|
|
358
|
+
case "missing-column":
|
|
359
|
+
lines.push(` - ${issue.table}.${issue.column}: missing in DB`);
|
|
360
|
+
break;
|
|
361
|
+
case "extra-column":
|
|
362
|
+
lines.push(` - ${issue.table}.${issue.column}: not in snapshot`);
|
|
363
|
+
break;
|
|
364
|
+
case "type-mismatch":
|
|
365
|
+
lines.push(
|
|
366
|
+
` - ${issue.table}.${issue.column}: type ${issue.actual} (expected ${issue.expected})`,
|
|
367
|
+
);
|
|
368
|
+
break;
|
|
369
|
+
case "nullability-mismatch":
|
|
370
|
+
lines.push(
|
|
371
|
+
` - ${issue.table}.${issue.column}: nullable=${!issue.actualNotNull} (expected nullable=${!issue.expectedNotNull})`,
|
|
372
|
+
);
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
lines.push("");
|
|
378
|
+
lines.push("Run 'yarn kumiko migrate apply' to bring the DB up-to-date.");
|
|
379
|
+
return lines.join("\n");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Throws SchemaDriftError mit human-readable message wenn Drift. */
|
|
383
|
+
export async function assertSchemaCurrent(db: DbConnection, migrationsDir: string): Promise<void> {
|
|
384
|
+
const report = await detectDrift(db, migrationsDir);
|
|
385
|
+
if (!report.ok) throw new SchemaDriftError(formatDriftReport(report), report);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export class SchemaDriftError extends Error {
|
|
389
|
+
readonly report: DriftReport;
|
|
390
|
+
constructor(message: string, report: DriftReport) {
|
|
391
|
+
super(message);
|
|
392
|
+
this.name = "SchemaDriftError";
|
|
393
|
+
this.report = report;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createConsoleProvider } from "../console-provider";
|
|
3
|
+
|
|
4
|
+
function makeProvider() {
|
|
5
|
+
const lines: string[] = [];
|
|
6
|
+
const provider = createConsoleProvider({
|
|
7
|
+
writer: { log: (l) => lines.push(l) },
|
|
8
|
+
});
|
|
9
|
+
return { provider, lines };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("ConsoleProvider", () => {
|
|
13
|
+
it("prints the full span tree once root ends", async () => {
|
|
14
|
+
const { provider, lines } = makeProvider();
|
|
15
|
+
await provider.tracer.withSpan("http.request", async () => {
|
|
16
|
+
await provider.tracer.withSpan("db.query", async (span) => {
|
|
17
|
+
span.setAttribute("db.table", "orders");
|
|
18
|
+
});
|
|
19
|
+
await provider.tracer.withSpan("redis.cmd", async () => {});
|
|
20
|
+
});
|
|
21
|
+
expect(lines).toHaveLength(1);
|
|
22
|
+
const output = lines[0]!;
|
|
23
|
+
expect(output).toContain("http.request");
|
|
24
|
+
expect(output).toContain("db.query");
|
|
25
|
+
expect(output).toContain("redis.cmd");
|
|
26
|
+
expect(output).toContain("db.table=orders");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("marks errored spans with [ERR]", async () => {
|
|
30
|
+
const { provider, lines } = makeProvider();
|
|
31
|
+
await expect(
|
|
32
|
+
provider.tracer.withSpan("http.request", async () => {
|
|
33
|
+
throw new Error("boom");
|
|
34
|
+
}),
|
|
35
|
+
).rejects.toThrow("boom");
|
|
36
|
+
expect(lines[0]).toContain("[ERR]");
|
|
37
|
+
expect(lines[0]).toContain("!exception=Error: boom");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("emits metric events as log lines", () => {
|
|
41
|
+
const { provider, lines } = makeProvider();
|
|
42
|
+
provider.meter.registerMetric({
|
|
43
|
+
name: "kumiko_orders_created_total",
|
|
44
|
+
type: "counter",
|
|
45
|
+
});
|
|
46
|
+
provider.meter.counter("kumiko_orders_created_total").inc();
|
|
47
|
+
expect(lines[0]).toContain("counter.inc");
|
|
48
|
+
expect(lines[0]).toContain("kumiko_orders_created_total");
|
|
49
|
+
expect(lines[0]).toContain("value=1");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("renders nested tree with correct hierarchy", async () => {
|
|
53
|
+
const { provider, lines } = makeProvider();
|
|
54
|
+
await provider.tracer.withSpan("http.request", async () => {
|
|
55
|
+
await provider.tracer.withSpan("kumiko.dispatcher.handler", async () => {
|
|
56
|
+
await provider.tracer.withSpan("db.query", async () => {});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
const out = lines[0]!;
|
|
60
|
+
const httpIdx = out.indexOf("http.request");
|
|
61
|
+
const dispatcherIdx = out.indexOf("kumiko.dispatcher.handler");
|
|
62
|
+
const dbIdx = out.indexOf("db.query");
|
|
63
|
+
expect(httpIdx).toBeGreaterThanOrEqual(0);
|
|
64
|
+
expect(dispatcherIdx).toBeGreaterThan(httpIdx);
|
|
65
|
+
expect(dbIdx).toBeGreaterThan(dispatcherIdx);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildMetricName, validateLabelKey, validateMetricName } from "../metric-validator";
|
|
3
|
+
|
|
4
|
+
describe("validateMetricName", () => {
|
|
5
|
+
describe("counter", () => {
|
|
6
|
+
it("accepts _total suffix", () => {
|
|
7
|
+
expect(() => validateMetricName("orders_created_total", "counter")).not.toThrow();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("rejects missing _total suffix", () => {
|
|
11
|
+
expect(() => validateMetricName("orders_created", "counter")).toThrow(
|
|
12
|
+
/must end with "_total"/,
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("rejects camelCase", () => {
|
|
17
|
+
expect(() => validateMetricName("ordersCreatedTotal", "counter")).toThrow(/snake_case/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("rejects leading digit", () => {
|
|
21
|
+
expect(() => validateMetricName("1_orders_total", "counter")).toThrow(/snake_case/);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("histogram", () => {
|
|
26
|
+
it("accepts _seconds suffix", () => {
|
|
27
|
+
expect(() => validateMetricName("http_request_duration_seconds", "histogram")).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("accepts _bytes suffix", () => {
|
|
31
|
+
expect(() => validateMetricName("http_request_body_bytes", "histogram")).not.toThrow();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("accepts custom domain unit (_eur)", () => {
|
|
35
|
+
expect(() => validateMetricName("orders_value_eur", "histogram")).not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects _total suffix", () => {
|
|
39
|
+
expect(() => validateMetricName("http_request_total", "histogram")).toThrow(
|
|
40
|
+
/must not end with "_total"/,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects single word without unit", () => {
|
|
45
|
+
expect(() => validateMetricName("duration", "histogram")).toThrow(/needs a unit suffix/);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("gauge", () => {
|
|
50
|
+
it("accepts plain noun", () => {
|
|
51
|
+
expect(() => validateMetricName("db_pool_active_connections", "gauge")).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects _total suffix", () => {
|
|
55
|
+
expect(() => validateMetricName("active_sessions_total", "gauge")).toThrow(/_total/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects _seconds suffix (suggests histogram)", () => {
|
|
59
|
+
expect(() => validateMetricName("request_duration_seconds", "gauge")).toThrow(/histogram/i);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("buildMetricName", () => {
|
|
65
|
+
it("prefixes with kumiko_<feature>_", () => {
|
|
66
|
+
expect(buildMetricName("orders", "created_total")).toBe("kumiko_orders_created_total");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects non-snake_case feature name", () => {
|
|
70
|
+
expect(() => buildMetricName("Orders", "created_total")).toThrow(/snake_case/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("validateLabelKey", () => {
|
|
75
|
+
it("accepts snake_case", () => {
|
|
76
|
+
expect(() => validateLabelKey("error_class")).not.toThrow();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("rejects camelCase", () => {
|
|
80
|
+
expect(() => validateLabelKey("errorClass")).toThrow(/snake_case/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects reserved Prometheus keys", () => {
|
|
84
|
+
expect(() => validateLabelKey("le")).toThrow(/reserved/);
|
|
85
|
+
expect(() => validateLabelKey("__name__")).toThrow(/snake_case/);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createNoopProvider } from "../noop-provider";
|
|
3
|
+
|
|
4
|
+
describe("NoopProvider", () => {
|
|
5
|
+
it("provides noop tracer with startSpan", () => {
|
|
6
|
+
const p = createNoopProvider();
|
|
7
|
+
const span = p.tracer.startSpan("test");
|
|
8
|
+
expect(span.name).toBe("test");
|
|
9
|
+
expect(span.traceId).toBe("");
|
|
10
|
+
expect(span.ended).toBe(false);
|
|
11
|
+
span.setAttribute("foo", "bar");
|
|
12
|
+
span.end();
|
|
13
|
+
expect(span.ended).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("withSpan runs fn and returns its value", async () => {
|
|
17
|
+
const p = createNoopProvider();
|
|
18
|
+
const result = await p.tracer.withSpan("op", async (span) => {
|
|
19
|
+
expect(span.name).toBe("op");
|
|
20
|
+
return 42;
|
|
21
|
+
});
|
|
22
|
+
expect(result).toBe(42);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("withSpan ends span on thrown error", async () => {
|
|
26
|
+
const p = createNoopProvider();
|
|
27
|
+
let capturedSpan: { ended: boolean } | undefined;
|
|
28
|
+
await expect(
|
|
29
|
+
p.tracer.withSpan("boom", async (span) => {
|
|
30
|
+
capturedSpan = span;
|
|
31
|
+
throw new Error("boom");
|
|
32
|
+
}),
|
|
33
|
+
).rejects.toThrow("boom");
|
|
34
|
+
expect(capturedSpan?.ended).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("getActiveSpan returns undefined (noop doesn't propagate)", () => {
|
|
38
|
+
const p = createNoopProvider();
|
|
39
|
+
expect(p.tracer.getActiveSpan()).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("registerMetric rejects duplicates", () => {
|
|
43
|
+
const p = createNoopProvider();
|
|
44
|
+
p.meter.registerMetric({
|
|
45
|
+
name: "kumiko_test_total",
|
|
46
|
+
type: "counter",
|
|
47
|
+
});
|
|
48
|
+
expect(() =>
|
|
49
|
+
p.meter.registerMetric({
|
|
50
|
+
name: "kumiko_test_total",
|
|
51
|
+
type: "counter",
|
|
52
|
+
}),
|
|
53
|
+
).toThrow(/already registered/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("meter returns typed handles for registered metrics", () => {
|
|
57
|
+
const p = createNoopProvider();
|
|
58
|
+
p.meter.registerMetric({ name: "kumiko_test_total", type: "counter" });
|
|
59
|
+
p.meter.registerMetric({ name: "kumiko_test_duration_seconds", type: "histogram" });
|
|
60
|
+
p.meter.registerMetric({ name: "kumiko_test_pool", type: "gauge" });
|
|
61
|
+
|
|
62
|
+
expect(() => p.meter.counter("kumiko_test_total").inc()).not.toThrow();
|
|
63
|
+
expect(() => p.meter.histogram("kumiko_test_duration_seconds").observe(0.5)).not.toThrow();
|
|
64
|
+
expect(() => p.meter.gauge("kumiko_test_pool").set(10)).not.toThrow();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("meter rejects wrong type lookup", () => {
|
|
68
|
+
const p = createNoopProvider();
|
|
69
|
+
p.meter.registerMetric({ name: "kumiko_test_total", type: "counter" });
|
|
70
|
+
expect(() => p.meter.histogram("kumiko_test_total")).toThrow(/not registered or wrong type/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("meter rejects unknown metric", () => {
|
|
74
|
+
const p = createNoopProvider();
|
|
75
|
+
expect(() => p.meter.counter("kumiko_nothing_total")).toThrow(/not registered/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("shutdown resolves", async () => {
|
|
79
|
+
const p = createNoopProvider();
|
|
80
|
+
await expect(p.shutdown()).resolves.toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|