@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,34 @@
|
|
|
1
|
+
// KEK rotation: unwrap the DEK with the OLD kekVersion, re-wrap with the
|
|
2
|
+
// CURRENT one. The ciphertext never changes — this is why envelope
|
|
3
|
+
// encryption makes rotation affordable on large tables.
|
|
4
|
+
|
|
5
|
+
import type { Envelope, MasterKeyProvider } from "./types";
|
|
6
|
+
|
|
7
|
+
// Re-wrap the DEK of a single envelope so it references the current KEK
|
|
8
|
+
// version. No-op when the envelope is already current (caller-side this
|
|
9
|
+
// means "filter WHERE kekVersion != currentVersion" before calling).
|
|
10
|
+
export async function rewrapDek(
|
|
11
|
+
envelope: Envelope,
|
|
12
|
+
provider: MasterKeyProvider,
|
|
13
|
+
): Promise<Envelope> {
|
|
14
|
+
if (envelope.kekVersion === provider.currentVersion()) {
|
|
15
|
+
return envelope;
|
|
16
|
+
}
|
|
17
|
+
// Unwrap with the old KEK. Provider must still know the old version
|
|
18
|
+
// (keyring contains both until ops retires the old KEK).
|
|
19
|
+
const dek = await provider.unwrapDek(envelope.encryptedDek, envelope.kekVersion);
|
|
20
|
+
try {
|
|
21
|
+
const { encryptedDek, kekVersion } = await provider.wrapDek(dek);
|
|
22
|
+
return {
|
|
23
|
+
ciphertext: envelope.ciphertext,
|
|
24
|
+
iv: envelope.iv,
|
|
25
|
+
authTag: envelope.authTag,
|
|
26
|
+
encryptedDek,
|
|
27
|
+
kekVersion,
|
|
28
|
+
};
|
|
29
|
+
} finally {
|
|
30
|
+
// Zero the unwrapped DEK — it was held in plaintext only long enough
|
|
31
|
+
// to wrap it again.
|
|
32
|
+
dek.fill(0);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Envelope Encryption types. Separating DEK (per-value) from KEK (central)
|
|
2
|
+
// is what makes key rotation cheap: on rotation we only re-wrap the small
|
|
3
|
+
// encryptedDek, never touch the ciphertext.
|
|
4
|
+
|
|
5
|
+
import type { TenantId } from "../engine";
|
|
6
|
+
|
|
7
|
+
// Plaintext-secret wrapper (branded). Carries the actual string internally
|
|
8
|
+
// but the nominal typing stops it from landing in an HTTP response by
|
|
9
|
+
// accident — a response-serializer guard + the reveal() cost make the leak
|
|
10
|
+
// intentional. Framework code that sees `Secret<string>` knows the caller
|
|
11
|
+
// has already gone through the audited ctx.secrets.get path.
|
|
12
|
+
//
|
|
13
|
+
// The brand is a real (non-registered) Symbol so it exists at runtime for
|
|
14
|
+
// isSecret() without clashing with user-land symbols of the same name.
|
|
15
|
+
const SecretBrand: unique symbol = Symbol("kumiko.secret");
|
|
16
|
+
|
|
17
|
+
export type Secret<T = string> = {
|
|
18
|
+
readonly [SecretBrand]: true;
|
|
19
|
+
readonly reveal: () => T;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Implementation helper — bundled-features uses this to wrap a plaintext after
|
|
23
|
+
// decryption. Kept in the framework so both sides share one canonical brand.
|
|
24
|
+
export function createSecret<T>(value: T): Secret<T> {
|
|
25
|
+
return {
|
|
26
|
+
[SecretBrand]: true as const,
|
|
27
|
+
reveal: () => value,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// True for any object carrying the Secret brand. Used by the response guard
|
|
32
|
+
// to reject leaks before serialization.
|
|
33
|
+
export function isSecret(v: unknown): v is Secret<unknown> {
|
|
34
|
+
return typeof v === "object" && v !== null && SecretBrand in v;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Per-read audit context. Populated by requireSecretsContext() wrapper so
|
|
38
|
+
// handlers don't need to pass userId/handlerName manually on every call.
|
|
39
|
+
// Undefined for framework-internal reads (rotation job, tests) — the audit
|
|
40
|
+
// table stays a "who touched this credential" log, not a crash-report sink.
|
|
41
|
+
export type SecretAuditContext = {
|
|
42
|
+
readonly userId: string;
|
|
43
|
+
readonly handlerName: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Feature code can pass either the raw qualified-name string or a typed
|
|
47
|
+
// handle returned by r.secret. The handle form is safer — renaming the
|
|
48
|
+
// r.secret call updates all references through the import graph.
|
|
49
|
+
export type SecretKeyRef = string | { readonly name: string };
|
|
50
|
+
|
|
51
|
+
// The ctx.secrets contract. Concrete implementation lives in bundled-features
|
|
52
|
+
// (createSecretsContext) where the DB and MasterKeyProvider are known. This
|
|
53
|
+
// lean interface is what the framework's HandlerContext carries so engine
|
|
54
|
+
// code can talk about it without pulling in bundled-features.
|
|
55
|
+
export interface SecretsContext {
|
|
56
|
+
get(
|
|
57
|
+
tenantId: TenantId,
|
|
58
|
+
key: SecretKeyRef,
|
|
59
|
+
auditCtx?: SecretAuditContext,
|
|
60
|
+
): Promise<Secret<string> | undefined>;
|
|
61
|
+
set(
|
|
62
|
+
tenantId: TenantId,
|
|
63
|
+
key: SecretKeyRef,
|
|
64
|
+
value: string,
|
|
65
|
+
opts?: { redact?: (plaintext: string) => string; hint?: string; updatedBy?: string },
|
|
66
|
+
): Promise<void>;
|
|
67
|
+
delete(tenantId: TenantId, key: SecretKeyRef, opts?: { deletedBy?: string }): Promise<boolean>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type Envelope = {
|
|
71
|
+
// AES-256-GCM ciphertext of the plaintext, keyed with a DEK.
|
|
72
|
+
readonly ciphertext: Buffer;
|
|
73
|
+
// GCM nonce (12 bytes). Generated fresh per encryption.
|
|
74
|
+
readonly iv: Buffer;
|
|
75
|
+
// GCM auth tag (16 bytes). Guarantees the ciphertext wasn't tampered.
|
|
76
|
+
readonly authTag: Buffer;
|
|
77
|
+
// DEK wrapped with the current KEK. Decryption needs provider.unwrapDek
|
|
78
|
+
// with the kekVersion to recover the DEK.
|
|
79
|
+
readonly encryptedDek: Buffer;
|
|
80
|
+
// Which KEK version was used to wrap the DEK. On rotation, rows with old
|
|
81
|
+
// versions still decrypt — the provider keeps a keyring of historical KEKs.
|
|
82
|
+
readonly kekVersion: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// The contract a KEK backend must fulfil. The framework sees only this
|
|
86
|
+
// interface; concrete implementations live in separate packages
|
|
87
|
+
// (@cosmicdrift/kumiko-secrets-vault, @cosmicdrift/kumiko-secrets-aws-kms, ...). The default is
|
|
88
|
+
// EnvMasterKeyProvider which reads keys from environment variables.
|
|
89
|
+
export interface MasterKeyProvider {
|
|
90
|
+
// Wrap a fresh DEK with the current KEK. Returns the wrapped bytes + the
|
|
91
|
+
// KEK version used — the version ends up in the Envelope so decryption
|
|
92
|
+
// later knows which KEK to ask for.
|
|
93
|
+
wrapDek(dek: Buffer): Promise<{ encryptedDek: Buffer; kekVersion: number }>;
|
|
94
|
+
|
|
95
|
+
// Unwrap a previously-wrapped DEK. During rotation the provider must
|
|
96
|
+
// accept older kekVersion values (2-version window minimum), otherwise
|
|
97
|
+
// old rows become unreadable.
|
|
98
|
+
unwrapDek(encryptedDek: Buffer, kekVersion: number): Promise<Buffer>;
|
|
99
|
+
|
|
100
|
+
// Which KEK version new wraps use. Rotation flips this to a new value
|
|
101
|
+
// and older-version reads continue to work until rows are re-wrapped.
|
|
102
|
+
currentVersion(): number;
|
|
103
|
+
|
|
104
|
+
// Health check: can the provider talk to its backend? Used by
|
|
105
|
+
// /health/ready. Cheap probe, no KEK material read.
|
|
106
|
+
isAvailable(): Promise<boolean>;
|
|
107
|
+
}
|
package/src/stack/db.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
2
|
+
import postgres from "postgres";
|
|
3
|
+
import { generateId } from "../utils";
|
|
4
|
+
|
|
5
|
+
function requireEnv(name: string): string {
|
|
6
|
+
const value = process.env[name];
|
|
7
|
+
if (!value) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
`Missing required env var: ${name}. Copy .env.example to .env and fill in values.`,
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TestDb = {
|
|
16
|
+
db: ReturnType<typeof drizzle>;
|
|
17
|
+
client: ReturnType<typeof postgres>;
|
|
18
|
+
dbName: string;
|
|
19
|
+
cleanup: () => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type CreateTestDbOptions = {
|
|
23
|
+
/** Override TEST_DATABASE_URL. Rare — mostly for tests that want a
|
|
24
|
+
* non-default Postgres (e.g. a read-replica probe). */
|
|
25
|
+
readonly baseUrl?: string;
|
|
26
|
+
/** Use a specific DB name instead of the default
|
|
27
|
+
* `kumiko_test_<8chars>`. Combined with `persistent: true`, lets a
|
|
28
|
+
* dev server keep state across restarts. Must be a legal Postgres
|
|
29
|
+
* identifier — the caller is responsible for matching the usual
|
|
30
|
+
* [a-z_0-9]+ shape. */
|
|
31
|
+
readonly dbName?: string;
|
|
32
|
+
/** When true, cleanup() is a no-op and the DB survives. Also
|
|
33
|
+
* changes CREATE DATABASE to IF-NOT-EXISTS semantics so restarts
|
|
34
|
+
* reuse the same storage. Default false (test contract: fresh DB
|
|
35
|
+
* per call, dropped on cleanup). */
|
|
36
|
+
readonly persistent?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Accepts a baseUrl string (legacy shorthand used by most tests) OR an
|
|
41
|
+
* options object. The string form is kept because thousands of tests
|
|
42
|
+
* call `createTestDb()` with no args; only dev-server and niche tests
|
|
43
|
+
* need the options form.
|
|
44
|
+
*/
|
|
45
|
+
export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<TestDb> {
|
|
46
|
+
const opts: CreateTestDbOptions = typeof arg === "string" ? { baseUrl: arg } : (arg ?? {});
|
|
47
|
+
const url = opts.baseUrl ?? requireEnv("TEST_DATABASE_URL");
|
|
48
|
+
// slice(-8) — the last 8 hex chars of a UUIDv7 are pure random (the
|
|
49
|
+
// front 48 bits are a timestamp, which would collide across workers
|
|
50
|
+
// that start within the same millisecond).
|
|
51
|
+
const dbName = opts.dbName ?? `kumiko_test_${generateId().slice(-8)}`;
|
|
52
|
+
const adminUrl = url.replace(/\/[^/]+$/, "/postgres");
|
|
53
|
+
|
|
54
|
+
const adminClient = postgres(adminUrl);
|
|
55
|
+
try {
|
|
56
|
+
if (opts.persistent) {
|
|
57
|
+
// Postgres has no CREATE DATABASE IF NOT EXISTS; emulate with a
|
|
58
|
+
// catalog probe so restarts are idempotent.
|
|
59
|
+
const existing = await adminClient<{ exists: boolean }[]>`
|
|
60
|
+
SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = ${dbName}) AS exists
|
|
61
|
+
`;
|
|
62
|
+
if (!existing[0]?.exists) {
|
|
63
|
+
await adminClient.unsafe(`CREATE DATABASE "${dbName}"`);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
await adminClient.unsafe(`CREATE DATABASE "${dbName}"`);
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
await adminClient.end();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const testUrl = url.replace(/\/[^/]+$/, `/${dbName}`);
|
|
73
|
+
const client = postgres(testUrl);
|
|
74
|
+
const db = drizzle(client);
|
|
75
|
+
|
|
76
|
+
// Every ES-entity writes events; auto-create the events table so tests that
|
|
77
|
+
// go straight to createTestDb (not setupTestStack) also work out of the box.
|
|
78
|
+
// In persistent mode this is idempotent: createEventsTable emits IF NOT
|
|
79
|
+
// EXISTS so a second boot is a no-op.
|
|
80
|
+
const { createEventsTable } = await import("../event-store");
|
|
81
|
+
await createEventsTable(db);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
db,
|
|
85
|
+
client,
|
|
86
|
+
dbName,
|
|
87
|
+
cleanup: async () => {
|
|
88
|
+
await client.end();
|
|
89
|
+
// Persistent mode: dev-server owns the DB lifecycle — don't drop
|
|
90
|
+
// on process exit. `yarn kumiko clean-test-dbs` is the escape
|
|
91
|
+
// hatch when you really want to start over.
|
|
92
|
+
if (!opts.persistent) {
|
|
93
|
+
const admin = postgres(adminUrl);
|
|
94
|
+
try {
|
|
95
|
+
await admin.unsafe(`DROP DATABASE IF EXISTS "${dbName}"`);
|
|
96
|
+
} finally {
|
|
97
|
+
await admin.end();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export { requireEnv };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SseEvent } from "../api/sse-broker";
|
|
2
|
+
import type { SaveContext } from "../engine/types";
|
|
3
|
+
|
|
4
|
+
export type EventCollector = {
|
|
5
|
+
readonly sse: SseEvent[];
|
|
6
|
+
readonly postSave: SaveContext[];
|
|
7
|
+
/** Clears all collected events — call in beforeEach for per-test isolation */
|
|
8
|
+
reset(): void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function createEventCollector(): EventCollector {
|
|
12
|
+
const sse: SseEvent[] = [];
|
|
13
|
+
const postSave: SaveContext[] = [];
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
sse,
|
|
17
|
+
postSave,
|
|
18
|
+
reset() {
|
|
19
|
+
sse.length = 0;
|
|
20
|
+
postSave.length = 0;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Runtime-safe Stack-Builder. Was hier liegt, wird vom dev-server zum Hochfahren
|
|
2
|
+
// einer kompletten Kumiko-Instanz genutzt — DB, Redis, Hono-App, Dispatcher,
|
|
3
|
+
// SSE-Broker. Die Files heißen historisch `test*` (createTestDb,
|
|
4
|
+
// setupTestStack, TestUsers, …), bedienen aber heute Dev- UND Test-Code: das
|
|
5
|
+
// ist genau derselbe Hochfahr-Pfad, nur einmal mit ephemeral-DB (test) und
|
|
6
|
+
// einmal mit persistent-DB (dev).
|
|
7
|
+
//
|
|
8
|
+
// Wichtig: dieses Modul darf KEINE vitest-Imports enthalten und keine
|
|
9
|
+
// Vitest-only Helper transitiv ziehen — sonst crasht jedes Tooling, das den
|
|
10
|
+
// dev-server unter Node lädt (drizzle-kit, build-scripts).
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
type CreateTestDbOptions,
|
|
14
|
+
createTestDb,
|
|
15
|
+
type TestDb,
|
|
16
|
+
} from "./db";
|
|
17
|
+
export { createEventCollector, type EventCollector } from "./event-collector";
|
|
18
|
+
export { createTestRedis, type TestRedis } from "./redis";
|
|
19
|
+
export { createRequestHelper, type RequestHelper } from "./request-helper";
|
|
20
|
+
export {
|
|
21
|
+
createEntityTable,
|
|
22
|
+
ensureEntityTable,
|
|
23
|
+
pushTables,
|
|
24
|
+
resetEventStore,
|
|
25
|
+
} from "./table-helpers";
|
|
26
|
+
export { setupTestStack, type TestStack, type TestStackOptions } from "./test-stack";
|
|
27
|
+
export {
|
|
28
|
+
createTestUser,
|
|
29
|
+
TestUsers,
|
|
30
|
+
testTenantId,
|
|
31
|
+
testUserId,
|
|
32
|
+
} from "./test-users";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { generateId } from "../utils";
|
|
2
|
+
import { requireEnv } from "./db";
|
|
3
|
+
|
|
4
|
+
export type TestRedis = {
|
|
5
|
+
redis: import("ioredis").default;
|
|
6
|
+
/** Delete every key this test created (prefix-scoped). Replaces the old
|
|
7
|
+
* `redis.flushdb()` — that wiped other parallel tests' BullMQ state. */
|
|
8
|
+
flushNamespace: () => Promise<void>;
|
|
9
|
+
cleanup: () => Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function createTestRedis(): Promise<TestRedis> {
|
|
13
|
+
const Redis = (await import("ioredis")).default;
|
|
14
|
+
const redisUrl = requireEnv("REDIS_URL");
|
|
15
|
+
// Every test gets a per-file key prefix on a shared DB (no DB-pool-of-15
|
|
16
|
+
// round-robin). Collisions at birthday-paradox rates are gone — the
|
|
17
|
+
// prefix space is unbounded. See Track B.3 in docs/plans/tests-refactor.
|
|
18
|
+
const prefix = `kt:${generateId().slice(-8)}:`;
|
|
19
|
+
const redis = new Redis(redisUrl, { keyPrefix: prefix });
|
|
20
|
+
|
|
21
|
+
async function flushNamespace(): Promise<void> {
|
|
22
|
+
// Open a prefix-less client for the scan — ioredis' keyPrefix is applied
|
|
23
|
+
// per-command but SCAN's returned keys are full names, so managing the
|
|
24
|
+
// del set with the prefix already on the connection is error-prone.
|
|
25
|
+
const raw = new Redis(redisUrl);
|
|
26
|
+
try {
|
|
27
|
+
const stream = raw.scanStream({ match: `${prefix}*`, count: 500 });
|
|
28
|
+
const keys: string[] = [];
|
|
29
|
+
for await (const batch of stream) keys.push(...batch);
|
|
30
|
+
if (keys.length > 0) await raw.del(...keys);
|
|
31
|
+
} finally {
|
|
32
|
+
raw.disconnect();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
redis,
|
|
38
|
+
flushNamespace,
|
|
39
|
+
cleanup: async () => {
|
|
40
|
+
await flushNamespace();
|
|
41
|
+
redis.disconnect();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import type { JwtHelper } from "../api/jwt";
|
|
3
|
+
import type { SessionUser } from "../engine/types";
|
|
4
|
+
|
|
5
|
+
export type BatchCommand = { type: string; payload: unknown };
|
|
6
|
+
|
|
7
|
+
export type RequestHelper = {
|
|
8
|
+
write: (
|
|
9
|
+
type: string,
|
|
10
|
+
payload: unknown,
|
|
11
|
+
user: SessionUser,
|
|
12
|
+
requestId?: string,
|
|
13
|
+
) => Promise<Response>;
|
|
14
|
+
query: (type: string, payload: unknown, user: SessionUser) => Promise<Response>;
|
|
15
|
+
command: (type: string, payload: unknown, user: SessionUser) => Promise<Response>;
|
|
16
|
+
batch: (
|
|
17
|
+
commands: readonly BatchCommand[],
|
|
18
|
+
user: SessionUser,
|
|
19
|
+
requestId?: string,
|
|
20
|
+
) => Promise<Response>;
|
|
21
|
+
raw: (
|
|
22
|
+
method: string,
|
|
23
|
+
path: string,
|
|
24
|
+
body?: unknown,
|
|
25
|
+
headers?: Record<string, string>,
|
|
26
|
+
) => Promise<Response>;
|
|
27
|
+
|
|
28
|
+
/** write + json + assert isSuccess — returns data directly */
|
|
29
|
+
writeOk: <T = Record<string, unknown>>(
|
|
30
|
+
type: string,
|
|
31
|
+
payload: unknown,
|
|
32
|
+
user: SessionUser,
|
|
33
|
+
requestId?: string,
|
|
34
|
+
) => Promise<T>;
|
|
35
|
+
/** write + json + assert isSuccess === false — returns the structured
|
|
36
|
+
* WriteErrorInfo with `httpStatus` filled in from the HTTP response. */
|
|
37
|
+
writeErr: (
|
|
38
|
+
type: string,
|
|
39
|
+
payload: unknown,
|
|
40
|
+
user: SessionUser,
|
|
41
|
+
) => Promise<import("../errors").WriteErrorInfo>;
|
|
42
|
+
/** query + json — returns data directly */
|
|
43
|
+
queryOk: <T = unknown>(type: string, payload: unknown, user: SessionUser) => Promise<T>;
|
|
44
|
+
|
|
45
|
+
/** write + additional HTTP headers (e.g. X-Correlation-ID). Returns the
|
|
46
|
+
* raw Response so callers can assert on status + headers + body as needed. */
|
|
47
|
+
writeWithHeaders: (
|
|
48
|
+
type: string,
|
|
49
|
+
payload: unknown,
|
|
50
|
+
user: SessionUser,
|
|
51
|
+
extraHeaders: Record<string, string>,
|
|
52
|
+
) => Promise<Response>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function createRequestHelper(app: Hono, jwt: JwtHelper): RequestHelper {
|
|
56
|
+
async function authHeader(user: SessionUser): Promise<Record<string, string>> {
|
|
57
|
+
const token = await jwt.sign(user);
|
|
58
|
+
return { Authorization: `Bearer ${token}` };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function req(
|
|
62
|
+
method: string,
|
|
63
|
+
path: string,
|
|
64
|
+
body?: unknown,
|
|
65
|
+
headers?: Record<string, string>,
|
|
66
|
+
): Promise<Response> {
|
|
67
|
+
const init: RequestInit = {
|
|
68
|
+
method,
|
|
69
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
70
|
+
};
|
|
71
|
+
if (body) init.body = JSON.stringify(body);
|
|
72
|
+
return app.request(path, init);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function writeRaw(
|
|
76
|
+
type: string,
|
|
77
|
+
payload: unknown,
|
|
78
|
+
user: SessionUser,
|
|
79
|
+
requestId?: string,
|
|
80
|
+
): Promise<Response> {
|
|
81
|
+
const headers = await authHeader(user);
|
|
82
|
+
return req("POST", "/api/write", { type, payload, requestId }, headers);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function queryRaw(type: string, payload: unknown, user: SessionUser): Promise<Response> {
|
|
86
|
+
const headers = await authHeader(user);
|
|
87
|
+
return req("POST", "/api/query", { type, payload }, headers);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
write: writeRaw,
|
|
92
|
+
query: queryRaw,
|
|
93
|
+
|
|
94
|
+
async command(type, payload, user) {
|
|
95
|
+
const headers = await authHeader(user);
|
|
96
|
+
return req("POST", "/api/command", { type, payload }, headers);
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async batch(commands, user, requestId) {
|
|
100
|
+
const headers = await authHeader(user);
|
|
101
|
+
return req("POST", "/api/batch", { commands, requestId }, headers);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
raw: req,
|
|
105
|
+
|
|
106
|
+
async writeOk<T = Record<string, unknown>>(
|
|
107
|
+
type: string,
|
|
108
|
+
payload: unknown,
|
|
109
|
+
user: SessionUser,
|
|
110
|
+
requestId?: string,
|
|
111
|
+
): Promise<T> {
|
|
112
|
+
const res = await writeRaw(type, payload, user, requestId);
|
|
113
|
+
// wire-body shape direkt nach JSON.parse — Caller-Code prüft danach
|
|
114
|
+
// selber ob isSuccess/error/data tatsächlich da sind.
|
|
115
|
+
const body = (await res.json()) as {
|
|
116
|
+
isSuccess?: boolean;
|
|
117
|
+
data?: unknown;
|
|
118
|
+
error?: { code?: string } | string;
|
|
119
|
+
};
|
|
120
|
+
// Success path still has { isSuccess: true, data }. Error responses now
|
|
121
|
+
// follow the error-contract shape { error: { code, i18nKey, ... } } with
|
|
122
|
+
// a 4xx/5xx status — no isSuccess flag. Detect either.
|
|
123
|
+
if (body.isSuccess !== true) {
|
|
124
|
+
const code =
|
|
125
|
+
(typeof body.error === "object" ? body.error?.code : undefined) ??
|
|
126
|
+
(typeof body.error === "string" ? body.error : "unknown");
|
|
127
|
+
throw new Error(`Expected write "${type}" to succeed but got error: ${code}`);
|
|
128
|
+
}
|
|
129
|
+
return body.data as T;
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async writeErr(
|
|
133
|
+
type: string,
|
|
134
|
+
payload: unknown,
|
|
135
|
+
user: SessionUser,
|
|
136
|
+
): Promise<import("../errors").WriteErrorInfo> {
|
|
137
|
+
const res = await writeRaw(type, payload, user);
|
|
138
|
+
const body = (await res.json()) as {
|
|
139
|
+
isSuccess?: boolean;
|
|
140
|
+
error?: Omit<import("../errors").WriteErrorInfo, "httpStatus">;
|
|
141
|
+
};
|
|
142
|
+
if (body.isSuccess === true) {
|
|
143
|
+
throw new Error(`Expected write "${type}" to fail but it succeeded`);
|
|
144
|
+
}
|
|
145
|
+
const wire = body.error;
|
|
146
|
+
if (!wire || typeof wire !== "object" || typeof wire.code !== "string") {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Expected error response for "${type}" but got unexpected shape: ${JSON.stringify(body)}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
// The wire body doesn't carry httpStatus (it would be redundant with
|
|
152
|
+
// the HTTP response status). Fill it in from res.status so callers can
|
|
153
|
+
// assert against either code OR status without a second request round.
|
|
154
|
+
return { ...wire, httpStatus: res.status };
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async queryOk<T = unknown>(type: string, payload: unknown, user: SessionUser): Promise<T> {
|
|
158
|
+
const res = await queryRaw(type, payload, user);
|
|
159
|
+
const body = (await res.json()) as { data: unknown };
|
|
160
|
+
return body.data as T;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async writeWithHeaders(type, payload, user, extraHeaders) {
|
|
164
|
+
const authHeaders = await authHeader(user);
|
|
165
|
+
return req("POST", "/api/write", { type, payload }, { ...authHeaders, ...extraHeaders });
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { generateDrizzleJson, generateMigration } from "drizzle-kit/api";
|
|
2
|
+
import { getTableName, sql } from "drizzle-orm";
|
|
3
|
+
import type { PgTable } from "drizzle-orm/pg-core";
|
|
4
|
+
import type { drizzle } from "drizzle-orm/postgres-js";
|
|
5
|
+
import { tableExists } from "../db/schema-inspection";
|
|
6
|
+
import { buildDrizzleTable, toTableName } from "../db/table-builder";
|
|
7
|
+
import type { TestStack } from "./test-stack";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Syncs a Drizzle table to the database via drizzle-kit migration.
|
|
11
|
+
* No manual SQL — Drizzle generates CREATE/ALTER TABLE statements.
|
|
12
|
+
* Strict: raises a postgres `relation already exists` (42P07) error if
|
|
13
|
+
* the table is already there. Use `ensureEntityTable` for idempotent
|
|
14
|
+
* boot paths.
|
|
15
|
+
*/
|
|
16
|
+
export async function createEntityTable(
|
|
17
|
+
db: ReturnType<typeof drizzle>,
|
|
18
|
+
entity: import("../engine/types").EntityDefinition,
|
|
19
|
+
entityName?: string,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const table = buildDrizzleTable(entityName ?? "entity", entity);
|
|
22
|
+
await pushTables(db, { [entityName ?? "entity"]: table });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Idempotent variant of `createEntityTable`: checks whether the entity's
|
|
27
|
+
* table already exists and skips creation if so. Schema-drift is *not*
|
|
28
|
+
* detected — if the table is there but has the wrong columns, that's
|
|
29
|
+
* the caller's problem (the dev-server contract is "drop the DB by
|
|
30
|
+
* hand when you change the schema"). Tests should use
|
|
31
|
+
* `createEntityTable` instead, since they rely on fresh DBs.
|
|
32
|
+
*/
|
|
33
|
+
export async function ensureEntityTable(
|
|
34
|
+
db: ReturnType<typeof drizzle>,
|
|
35
|
+
entity: import("../engine/types").EntityDefinition,
|
|
36
|
+
entityName?: string,
|
|
37
|
+
): Promise<boolean> {
|
|
38
|
+
const resolvedName = entity.table ?? toTableName(entityName ?? "entity");
|
|
39
|
+
if (await tableExists(db, `public.${resolvedName}`)) return false;
|
|
40
|
+
await createEntityTable(db, entity, entityName);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pushes Drizzle table definitions to the database.
|
|
46
|
+
* Uses drizzle-kit's generateDrizzleJson + generateMigration to produce SQL,
|
|
47
|
+
* then executes it. Same SQL that `drizzle-kit push` would generate.
|
|
48
|
+
*
|
|
49
|
+
* @param prevTables - Previous table definitions (for ALTER TABLE scenarios).
|
|
50
|
+
* If omitted, assumes empty DB (CREATE TABLE).
|
|
51
|
+
*/
|
|
52
|
+
export async function pushTables(
|
|
53
|
+
db: ReturnType<typeof drizzle>,
|
|
54
|
+
tables: Record<string, unknown>,
|
|
55
|
+
prevTables?: Record<string, unknown>,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const prevJson = generateDrizzleJson(prevTables ?? {});
|
|
58
|
+
const targetJson = generateDrizzleJson(tables);
|
|
59
|
+
const statements = await generateMigration(prevJson, targetJson);
|
|
60
|
+
for (const stmt of statements) {
|
|
61
|
+
await db.execute(sql.raw(stmt));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Wipes event store + framework-state + the given feature read-models in
|
|
67
|
+
* one TRUNCATE, then re-registers the event-consumer state rows. Used in
|
|
68
|
+
* test beforeEach-hooks to return the stack to a clean slate without
|
|
69
|
+
* rebuilding it.
|
|
70
|
+
*
|
|
71
|
+
* Fixed list of framework tables (kumiko_events, kumiko_event_consumers,
|
|
72
|
+
* kumiko_archived_streams, kumiko_snapshots, kumiko_projections) is always
|
|
73
|
+
* included — any event-sourced test setup needs those cleared. The
|
|
74
|
+
* `extraTables` arg covers the feature's own read-model tables that would
|
|
75
|
+
* otherwise accumulate rows across tests.
|
|
76
|
+
*
|
|
77
|
+
* Accepts either a Drizzle PgTable (for locally-defined tables: getTableName
|
|
78
|
+
* extracts the SQL name) or a plain string (for SQL names whose Drizzle
|
|
79
|
+
* reference lives in another module and importing it for the TRUNCATE
|
|
80
|
+
* alone would be overkill). Both round-trip to the same TRUNCATE list.
|
|
81
|
+
*
|
|
82
|
+
* Pre-existing code duplicates this block 30+ times, each with its own
|
|
83
|
+
* list of extras. The helper collapses that to a one-liner per test and
|
|
84
|
+
* lets a future change to the framework-table set (e.g. adding a new
|
|
85
|
+
* consumer-state table) ripple through without touching every suite.
|
|
86
|
+
*/
|
|
87
|
+
export async function resetEventStore(
|
|
88
|
+
stack: TestStack,
|
|
89
|
+
extraTables: readonly (PgTable | string)[] = [],
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
const frameworkTables = [
|
|
92
|
+
"kumiko_events",
|
|
93
|
+
"kumiko_event_consumers",
|
|
94
|
+
"kumiko_archived_streams",
|
|
95
|
+
"kumiko_snapshots",
|
|
96
|
+
"kumiko_projections",
|
|
97
|
+
];
|
|
98
|
+
const extraNames = extraTables.map((t) => (typeof t === "string" ? t : getTableName(t)));
|
|
99
|
+
const allTables = [...frameworkTables, ...extraNames];
|
|
100
|
+
await stack.db.execute(sql.raw(`TRUNCATE ${allTables.join(", ")} RESTART IDENTITY CASCADE`));
|
|
101
|
+
if (stack.eventDispatcher) {
|
|
102
|
+
await stack.eventDispatcher.ensureRegistered();
|
|
103
|
+
}
|
|
104
|
+
}
|