@cosmicdrift/kumiko-framework 1.0.0 → 2.0.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 +9 -38
- package/package.json +21 -2
- package/src/__tests__/anonymous-access.integration.test.ts +185 -0
- package/src/__tests__/consumer-cli.integration.test.ts +172 -0
- package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
- package/src/__tests__/schema-cli.integration.test.ts +50 -0
- package/src/__tests__/store-table.integration.test.ts +93 -0
- package/src/api/__tests__/api.test.ts +373 -0
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
- package/src/api/__tests__/batch.integration.test.ts +101 -11
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +200 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/api-constants.ts +11 -0
- package/src/api/auth-middleware.ts +248 -40
- package/src/api/auth-routes.ts +576 -95
- package/src/api/index.ts +13 -4
- package/src/api/jwt.ts +170 -11
- package/src/api/pat-scope.ts +14 -0
- package/src/api/pii-leak-guard.ts +47 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +169 -2
- package/src/api/server.ts +112 -16
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/__tests__/write-brand.test.ts +21 -0
- package/src/bun-db/connection.ts +3 -3
- package/src/bun-db/index.ts +2 -0
- package/src/bun-db/query.ts +105 -32
- package/src/consumer-cli.ts +134 -0
- package/src/crypto/__tests__/blind-index.test.ts +130 -0
- package/src/crypto/__tests__/event-pii.test.ts +161 -0
- package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
- package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
- package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
- package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
- package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
- package/src/crypto/blind-index.ts +118 -0
- package/src/crypto/event-pii.ts +70 -0
- package/src/crypto/in-memory-kms-adapter.ts +50 -0
- package/src/crypto/index.ts +67 -0
- package/src/crypto/kms-adapter.ts +2 -0
- package/src/crypto/pg-kms-adapter.ts +295 -0
- package/src/crypto/pii-field-encryption.ts +248 -0
- package/src/crypto/request-kms-cache.ts +38 -0
- package/src/crypto/subject-resolver.ts +91 -0
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
- package/src/db/__tests__/blind-index.integration.test.ts +248 -0
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/collect-table-metas.test.ts +10 -10
- package/src/db/__tests__/config-seed.integration.test.ts +13 -5
- package/src/db/__tests__/dialect-instant.test.ts +1 -4
- package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
- package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
- package/src/db/__tests__/feature-table-sources.test.ts +34 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
- package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-generator.test.ts +21 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
- package/src/db/__tests__/rebuild-marker.test.ts +13 -3
- package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
- package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
- package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/apply-entity-event.ts +18 -14
- package/src/db/blind-index-cleanup.ts +55 -0
- package/src/db/bun-provider.ts +2 -2
- package/src/db/collect-table-metas.ts +6 -7
- package/src/db/config-seed.ts +9 -9
- package/src/db/connection.ts +7 -12
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +20 -26
- package/src/db/entity-field-encryption.ts +81 -24
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +63 -90
- package/src/db/event-store-executor-context.ts +404 -0
- package/src/db/event-store-executor-read.ts +275 -0
- package/src/db/event-store-executor-write.ts +644 -0
- package/src/db/event-store-executor.ts +28 -1155
- package/src/db/feature-table-sources.ts +6 -5
- package/src/db/index.ts +20 -3
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-generator.ts +39 -6
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +9 -1
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/backfill-pii.ts +277 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/queries/event-store.ts +126 -19
- package/src/db/queries/projection-rebuild.ts +22 -8
- package/src/db/queries/shadow-swap.ts +183 -0
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/query-api.ts +1 -0
- package/src/db/query.ts +1 -0
- package/src/db/rebuild-marker.ts +18 -2
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +257 -0
- package/src/db/schema-inspection.ts +1 -1
- package/src/db/table-builder.ts +102 -71
- package/src/db/tenant-db.ts +15 -53
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
- package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
- package/src/engine/__tests__/boot-validator.test.ts +219 -15
- package/src/engine/__tests__/build-app-schema.test.ts +82 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
- package/src/engine/__tests__/build-target.test.ts +3 -11
- package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
- package/src/engine/__tests__/config-helpers.test.ts +16 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +163 -1
- package/src/engine/__tests__/entity-handlers.test.ts +80 -0
- package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/factories-time.test.ts +2 -66
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
- package/src/engine/__tests__/feature-manifest.test.ts +31 -1
- package/src/engine/__tests__/field-access.test.ts +23 -1
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/nav.test.ts +86 -1
- package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
- package/src/engine/__tests__/post-query-hook.test.ts +7 -7
- package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
- package/src/engine/__tests__/registry.test.ts +144 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/screen.test.ts +147 -1
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
- package/src/engine/__tests__/store-table.test.ts +229 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
- package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
- package/src/engine/boot-validator/action-wiring.ts +149 -0
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/config-deps.ts +40 -4
- package/src/engine/boot-validator/entity-handler.ts +20 -21
- package/src/engine/boot-validator/entity-list-screens.ts +88 -0
- package/src/engine/boot-validator/gdpr-storage.ts +0 -20
- package/src/engine/boot-validator/i18n-keys.ts +58 -4
- package/src/engine/boot-validator/index.ts +33 -12
- package/src/engine/boot-validator/nav.ts +125 -0
- package/src/engine/boot-validator/pii-retention.ts +95 -10
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/build-app-schema.ts +16 -0
- package/src/engine/build-config-feature-schema.ts +44 -7
- package/src/engine/codemod/pipeline-codemod.ts +5 -5
- package/src/engine/config-helpers.ts +15 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/define-feature.ts +95 -947
- package/src/engine/define-handler.ts +28 -94
- package/src/engine/define-workflow.ts +1 -1
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/entity-handlers.ts +76 -11
- package/src/engine/extensions/tenant-data.ts +19 -0
- package/src/engine/extensions/user-data.ts +29 -2
- package/src/engine/factories.ts +8 -49
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
- package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
- package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
- package/src/engine/feature-ast/extractors/events.ts +322 -0
- package/src/engine/feature-ast/extractors/handlers.ts +234 -0
- package/src/engine/feature-ast/extractors/hooks.ts +243 -0
- package/src/engine/feature-ast/extractors/index.ts +34 -31
- package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
- package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/extractors/round6.ts +2 -36
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/index.ts +2 -4
- package/src/engine/feature-ast/parse.ts +130 -23
- package/src/engine/feature-ast/patch.ts +39 -50
- package/src/engine/feature-ast/patcher.ts +37 -38
- package/src/engine/feature-ast/patterns.ts +50 -62
- package/src/engine/feature-ast/render.ts +67 -44
- package/src/engine/feature-builder-state.ts +168 -0
- package/src/engine/feature-config-events-jobs.ts +403 -0
- package/src/engine/feature-entity-handlers.ts +208 -0
- package/src/engine/feature-manifest.ts +2 -1
- package/src/engine/feature-ui-extensions.ts +500 -0
- package/src/engine/field-access.ts +13 -2
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +16 -0
- package/src/engine/index.ts +23 -7
- package/src/engine/membership-roles.ts +13 -0
- package/src/engine/object-form.ts +27 -0
- package/src/engine/ownership.ts +26 -79
- package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
- package/src/engine/pattern-library/library.ts +44 -1152
- package/src/engine/pattern-library/mixed-schemas.ts +450 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +75 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/pipeline.ts +6 -11
- package/src/engine/registry-facade.ts +362 -0
- package/src/engine/registry-ingest.ts +478 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +642 -0
- package/src/engine/registry.ts +78 -1658
- package/src/engine/run-pipeline.ts +1 -1
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/soft-delete-cleanup.ts +6 -2
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -482
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -972
- package/src/engine/types/fields.ts +2 -675
- package/src/engine/types/handlers.ts +2 -774
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -54
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +86 -40
- package/src/engine/types/nav.ts +2 -63
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -138
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -574
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -132
- package/src/engine/types/workspace.ts +2 -49
- package/src/engine/validate-projection-allowlist.ts +6 -6
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
- package/src/entrypoint/index.ts +49 -6
- package/src/errors/classes.ts +22 -1
- package/src/errors/field-issue.ts +0 -3
- package/src/errors/index.ts +1 -1
- package/src/errors/write-error-info.ts +10 -22
- package/src/es-ops/README.md +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
- package/src/es-ops/context.ts +4 -2
- package/src/es-ops/types.ts +8 -1
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
- package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
- package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
- package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
- package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +64 -78
- package/src/event-store/events-schema.ts +11 -13
- package/src/event-store/index.ts +18 -3
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/event-store/snapshot.ts +63 -40
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/file-ref-entity.test.ts +8 -0
- package/src/files/__tests__/files.integration.test.ts +24 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/provider-resolver.test.ts +70 -0
- package/src/files/__tests__/write-stream.test.ts +13 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/file-ref-entity.ts +2 -2
- package/src/files/file-routes.ts +13 -0
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +27 -8
- package/src/files/provider-resolver.ts +31 -20
- package/src/files/types.ts +13 -55
- package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
- package/src/i18n/required-surface-keys.ts +120 -1
- package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
- package/src/jobs/job-runner.ts +82 -12
- package/src/logging/types.ts +1 -7
- package/src/migrations/pending-rebuilds.ts +1 -1
- package/src/observability/__tests__/observability.integration.test.ts +4 -1
- package/src/observability/__tests__/recording-tracer.test.ts +6 -4
- package/src/observability/index.ts +2 -0
- package/src/observability/noop-provider.ts +0 -9
- package/src/observability/recording-tracer.ts +0 -12
- package/src/observability/standard-metrics.ts +64 -2
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -64
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
- package/src/pipeline/dispatch-batch.ts +187 -0
- package/src/pipeline/dispatch-query.ts +165 -0
- package/src/pipeline/dispatch-shared.ts +826 -0
- package/src/pipeline/dispatch-stream.ts +90 -0
- package/src/pipeline/dispatch-write.ts +451 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +44 -1372
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +30 -2
- package/src/pipeline/event-dispatcher-admin.ts +293 -0
- package/src/pipeline/event-dispatcher-delivery.ts +305 -0
- package/src/pipeline/event-dispatcher.ts +85 -542
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/msp-rebuild.ts +42 -3
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/projection-rebuild.ts +105 -3
- package/src/pipeline/system-hooks.ts +144 -1
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +16 -32
- package/src/schema-cli.ts +76 -16
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
- package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
- package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
- package/src/search/index.ts +6 -0
- package/src/search/meilisearch-adapter.ts +13 -12
- package/src/search/reindex-entity.ts +177 -0
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/dek-cache.ts +26 -5
- package/src/secrets/envelope-cipher.ts +53 -0
- package/src/secrets/envelope.ts +5 -3
- package/src/secrets/index.ts +13 -1
- package/src/secrets/stored-envelope.ts +46 -0
- package/src/secrets/types.ts +2 -162
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +4 -3
- package/src/stack/redis.ts +8 -0
- package/src/stack/request-helper.ts +38 -2
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +249 -131
- package/src/testing/__tests__/late-bound.test.ts +32 -0
- package/src/testing/__tests__/wait-for.test.ts +59 -0
- package/src/testing/boot-validator-fixture.ts +121 -0
- package/src/testing/e2e-generator.ts +8 -0
- package/src/testing/file-provider-contract.ts +104 -0
- package/src/testing/handler-context.ts +3 -1
- package/src/testing/index.ts +5 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/mutable-master-key-provider.ts +29 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/__tests__/tz-dateline.test.ts +8 -10
- package/src/time/geo-tz.ts +1 -32
- package/src/time/index.ts +1 -0
- package/src/time/legacy-date.ts +18 -0
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +44 -85
- package/src/ui-types/app-schema.ts +12 -0
- package/src/ui-types/index.ts +20 -7
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- package/src/__tests__/raw-table.integration.test.ts +0 -116
- package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
- package/src/db/__tests__/encryption.test.ts +0 -39
- package/src/db/encryption.ts +0 -39
- package/src/db/row-helpers.ts +0 -4
- package/src/engine/__tests__/raw-table.test.ts +0 -150
- package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
- package/src/engine/feature-ast/extractors/round4.ts +0 -1366
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
// expressed in meta (hand-added in a migration) is not reconstructed, and a
|
|
19
19
|
// partial index whose WHERE the renderer can't express is rejected up-front.
|
|
20
20
|
|
|
21
|
+
import type { DbConnection, DbTx } from "../connection";
|
|
21
22
|
import type { EntityTableMeta } from "../entity-table-meta";
|
|
22
23
|
import { type AnyDb, asEntityTableMeta, asRawClient } from "../query";
|
|
23
24
|
import { renderTableDdl } from "../render-ddl";
|
|
25
|
+
import { columnNamesOf, tableExists } from "../schema-inspection";
|
|
24
26
|
import { quoteTableIdent } from "./table-ops";
|
|
25
27
|
|
|
26
28
|
export const PROJECTION_REBUILD_SCHEMA = "kumiko_rebuild";
|
|
@@ -68,6 +70,41 @@ export function rebuildMetaOrThrow(table: unknown, projectionName: string): Enti
|
|
|
68
70
|
return meta;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
// Fence against a rebuild running with a registry that does not match the
|
|
74
|
+
// migrated live schema (#835): during a rolling deploy, a pod still running
|
|
75
|
+
// the previous build can pick up an async rebuild job; its shadow — built from
|
|
76
|
+
// the stale EntityTableMeta — would swap away a freshly-migrated column
|
|
77
|
+
// (recurrence class of #494). Compares COLUMN NAMES only; a type-/nullability-
|
|
78
|
+
// only drift passes (schema regression, not data loss — the boot gate of the
|
|
79
|
+
// next deploy catches it). A missing live table is fine: nothing to wipe.
|
|
80
|
+
// Must run BEFORE buildShadowTable; columnNamesOf pins table_schema='public',
|
|
81
|
+
// so the shadow search_path could not redirect it anyway.
|
|
82
|
+
export async function assertLiveColumnsMatchMeta(
|
|
83
|
+
db: DbConnection | DbTx,
|
|
84
|
+
meta: EntityTableMeta,
|
|
85
|
+
projectionName: string,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
// skip: no live table yet — nothing a stale-meta shadow could wipe
|
|
88
|
+
if (!(await tableExists(db, `public.${meta.tableName}`))) return;
|
|
89
|
+
const live = await columnNamesOf(db, meta.tableName);
|
|
90
|
+
const metaNames = new Set(meta.columns.map((c) => c.name));
|
|
91
|
+
const onlyLive = [...live].filter((c) => !metaNames.has(c));
|
|
92
|
+
const onlyMeta = [...metaNames].filter((c) => !live.has(c));
|
|
93
|
+
// skip: column sets match — this process's registry is in sync with the migrated table
|
|
94
|
+
if (onlyLive.length === 0 && onlyMeta.length === 0) return;
|
|
95
|
+
const detail = [
|
|
96
|
+
onlyLive.length > 0 ? `live-only: ${onlyLive.join(", ")}` : "",
|
|
97
|
+
onlyMeta.length > 0 ? `meta-only: ${onlyMeta.join(", ")}` : "",
|
|
98
|
+
]
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join("; ");
|
|
101
|
+
throw new Error(
|
|
102
|
+
`projection-rebuild "${projectionName}": columns of live table "${meta.tableName}" do not match this process's EntityTableMeta (${detail}). ` +
|
|
103
|
+
"Rebuilding would swap away the difference. Likely cause: this pod runs a build whose registry is behind (or ahead of) the applied migrations — rolling deploy in progress? — or DDL was applied by hand. " +
|
|
104
|
+
"Rebuild aborted; retry from a pod whose code matches the migrated schema.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
71
108
|
// Runs INSIDE the rebuild tx, AFTER the state/consumer row lock is taken.
|
|
72
109
|
// Points search_path at the shadow schema (SET LOCAL → auto-reset on commit or
|
|
73
110
|
// rollback), drops any leftover shadow from a crashed run, then builds the
|
|
@@ -112,6 +149,152 @@ export async function fenceLiveTable(
|
|
|
112
149
|
await raw.unsafe(`LOCK TABLE public.${quoteTableIdent(tableName)} IN ACCESS EXCLUSIVE MODE`);
|
|
113
150
|
}
|
|
114
151
|
|
|
152
|
+
// Ids reported when the swap is aborted — enough to locate the ghost rows
|
|
153
|
+
// without dumping an unbounded set into the log.
|
|
154
|
+
const UNREACHABLE_SAMPLE_LIMIT = 20;
|
|
155
|
+
|
|
156
|
+
// Runs INSIDE the rebuild tx, under the fence, before swapShadowIntoLive. A live
|
|
157
|
+
// row whose aggregate id has NO event in the projection's source streams is
|
|
158
|
+
// UNREACHABLE: no replay can ever reconstruct it, so the swap would silently
|
|
159
|
+
// drop it. That is the #498 ghost — a row direct-inserted without ever emitting
|
|
160
|
+
// a .created event. The static CI guard cannot see it in data that already
|
|
161
|
+
// exists in production, or on table identifiers it couldn't resolve; this
|
|
162
|
+
// catches it at cutover and aborts (tx rolls back, live untouched).
|
|
163
|
+
//
|
|
164
|
+
// Deliberately NARROW — event EXISTENCE only, not a column or row-vs-shadow
|
|
165
|
+
// diff. The framework legitimately makes live diverge from a fresh replay in
|
|
166
|
+
// several SHIPPED ways, none of which is drift:
|
|
167
|
+
// - a blind-index column recomputed to NULL after the subject's key is
|
|
168
|
+
// shredded (GDPR erase) — the NULL is the intended end state;
|
|
169
|
+
// - a `sensitive` column stripped from the event log by design;
|
|
170
|
+
// - an archived stream that stops replaying (fw#832) — the row's wipe is the
|
|
171
|
+
// intended tombstone behavior, reported via backfill's `failed` list;
|
|
172
|
+
// - a legacy column direct-written before its handler emitted events, healed
|
|
173
|
+
// by the #494 backfill-then-rebuild flow.
|
|
174
|
+
// Checking event existence INCLUDING archived streams leaves every one of them
|
|
175
|
+
// alone: those rows all have a real event, so they are not ghosts. Column-level
|
|
176
|
+
// drift is a SEPARATE, non-blocking check — see countColumnDrift below (#916,
|
|
177
|
+
// resolves the #722 open question: observe, don't block).
|
|
178
|
+
//
|
|
179
|
+
// Implicit projections only (caller-gated). aggregate_id and the entity id are
|
|
180
|
+
// both uuid, so the anti-join probes the events index without a cast.
|
|
181
|
+
export async function assertNoUnreachableLiveRows(
|
|
182
|
+
tx: AnyDb,
|
|
183
|
+
projectionName: string,
|
|
184
|
+
tableName: string,
|
|
185
|
+
aggregateTypes: readonly string[],
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
// skip: no source streams → no events could back any row anyway; a rebuild
|
|
188
|
+
// of a subscription-less projection swaps an empty shadow (handled upstream).
|
|
189
|
+
if (aggregateTypes.length === 0) return;
|
|
190
|
+
const raw = asRawClient(tx);
|
|
191
|
+
const t = quoteTableIdent(tableName);
|
|
192
|
+
const ghosts = await raw.unsafe<{ id: unknown }>(
|
|
193
|
+
`SELECT l."id" FROM public.${t} l
|
|
194
|
+
WHERE NOT EXISTS (
|
|
195
|
+
SELECT 1 FROM "kumiko_events" e
|
|
196
|
+
WHERE e."aggregate_id" = l."id" AND e."aggregate_type" = ANY($1::text[])
|
|
197
|
+
)
|
|
198
|
+
LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`,
|
|
199
|
+
[aggregateTypes],
|
|
200
|
+
);
|
|
201
|
+
// skip: every live row has a backing event — nothing unreachable, swap is safe
|
|
202
|
+
if (ghosts.length === 0) return;
|
|
203
|
+
const ids = ghosts.map((r) => String(r.id));
|
|
204
|
+
const countLabel =
|
|
205
|
+
ids.length === UNREACHABLE_SAMPLE_LIMIT ? `${ids.length}+` : String(ids.length);
|
|
206
|
+
throw new Error(
|
|
207
|
+
`projection-rebuild "${projectionName}": ${countLabel} live rows in "${tableName}" have no ` +
|
|
208
|
+
`event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
|
|
209
|
+
`would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
|
|
210
|
+
`without emitting a .created event. Fix: register the table with r.storeTable(meta, ` +
|
|
211
|
+
`{ reason }) to opt out of rebuild, or emit the missing events. See ` +
|
|
212
|
+
`docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Columns ignored by countColumnDrift — the one PROVABLY legitimate class of
|
|
217
|
+
// live-vs-shadow divergence. A blind-index column (`<field>_bidx`) is
|
|
218
|
+
// recomputed to NULL on GDPR key-shredding; the NULL is the intended end
|
|
219
|
+
// state, not drift. Everything else that legitimately diverges (archived
|
|
220
|
+
// streams, #494 backfill) either never reaches this comparison (archived rows
|
|
221
|
+
// are absent from the shadow entirely, see swapShadowIntoLive) or IS real
|
|
222
|
+
// column drift that the #494 backfill-then-rebuild flow relies on replay to
|
|
223
|
+
// heal — reporting it (without blocking) is correct, not a false positive.
|
|
224
|
+
const COLUMN_DRIFT_SAMPLE_LIMIT = 20;
|
|
225
|
+
|
|
226
|
+
export type ColumnDriftResult = {
|
|
227
|
+
readonly rowCount: number;
|
|
228
|
+
// Capped sample of "<id>.<column>" pairs for log/ops triage.
|
|
229
|
+
readonly sample: readonly string[];
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Runs INSIDE the rebuild tx, in the same slot as assertNoUnreachableLiveRows
|
|
233
|
+
// (after replay settles, before swapShadowIntoLive). Non-blocking counterpart
|
|
234
|
+
// to the ghost-row guard: reports live rows whose column values differ from
|
|
235
|
+
// the freshly-replayed shadow, WITHOUT aborting the swap (#916, resolves the
|
|
236
|
+
// #722 open question in favor of observe-not-block).
|
|
237
|
+
//
|
|
238
|
+
// Why non-blocking: a legacy column direct-written before its handler emitted
|
|
239
|
+
// events (#494) diverges from replay by design — that divergence is exactly
|
|
240
|
+
// what the backfill-then-rebuild flow relies on replay to heal. Failing hard
|
|
241
|
+
// here would make rebuild mutually exclusive with that shipped healing path.
|
|
242
|
+
// There is no reliable metadata to distinguish "#494 healing in progress" from
|
|
243
|
+
// "someone else corrupted this row" short of an open-ended per-column policy
|
|
244
|
+
// blocklist — wrong-by-default whenever a class is missed. So: surface it,
|
|
245
|
+
// don't police it. The caller logs the result; ops decides.
|
|
246
|
+
//
|
|
247
|
+
// Caveat: sensitive CUSTOM fields still diverge until #972 (Subject-DEK
|
|
248
|
+
// design) — regular sensitive fields carry event-payload ciphertext parity
|
|
249
|
+
// post-#973 and don't drift. Both are reported the same as any other column
|
|
250
|
+
// drift; this is deliberate (see module comment above), not an oversight.
|
|
251
|
+
//
|
|
252
|
+
// Relies on assertLiveColumnsMatchMeta having already run: live/shadow/meta
|
|
253
|
+
// column sets are known to match, so the diff can walk meta.columns directly.
|
|
254
|
+
export async function countColumnDrift(
|
|
255
|
+
tx: AnyDb,
|
|
256
|
+
tableName: string,
|
|
257
|
+
meta: EntityTableMeta,
|
|
258
|
+
): Promise<ColumnDriftResult> {
|
|
259
|
+
const comparable = meta.columns.filter((c) => c.primaryKey !== true && !c.name.endsWith("_bidx"));
|
|
260
|
+
// skip: nothing to compare (id-only or all-bidx table) — no drift is possible
|
|
261
|
+
if (comparable.length === 0) return { rowCount: 0, sample: [] };
|
|
262
|
+
const t = quoteTableIdent(tableName);
|
|
263
|
+
const raw = asRawClient(tx);
|
|
264
|
+
const driftCte = `WITH drifted AS (
|
|
265
|
+
SELECT l."id" AS id, string_agg(diff.col, ',') AS drifted_columns
|
|
266
|
+
FROM public.${t} l
|
|
267
|
+
JOIN ${SCHEMA_IDENT}.${t} s ON s."id" = l."id"
|
|
268
|
+
CROSS JOIN LATERAL (
|
|
269
|
+
VALUES ${comparable
|
|
270
|
+
.map(
|
|
271
|
+
(c) =>
|
|
272
|
+
`('${c.name}', l.${quoteTableIdent(c.name)} IS DISTINCT FROM s.${quoteTableIdent(c.name)})`,
|
|
273
|
+
)
|
|
274
|
+
.join(", ")}
|
|
275
|
+
) AS diff(col, differs)
|
|
276
|
+
WHERE diff.differs
|
|
277
|
+
GROUP BY l."id"
|
|
278
|
+
)`;
|
|
279
|
+
// Two passes over `drifted`: an unbounded COUNT for the true total (replay
|
|
280
|
+
// already scanned every row this run, so a second scan here is cheap by
|
|
281
|
+
// comparison) plus a capped sample for the log. rowCount must NEVER be
|
|
282
|
+
// min(actual, LIMIT) — that would silently understate severity to ops.
|
|
283
|
+
const totalRows = await raw.unsafe<{ total: string }>(
|
|
284
|
+
`${driftCte} SELECT count(*)::text AS total FROM drifted`,
|
|
285
|
+
);
|
|
286
|
+
const rowCount = Number(totalRows[0]?.total ?? "0");
|
|
287
|
+
// skip: no drift — nothing to sample
|
|
288
|
+
if (rowCount === 0) return { rowCount: 0, sample: [] };
|
|
289
|
+
const rows = await raw.unsafe<{ id: unknown; drifted_columns: string }>(
|
|
290
|
+
`${driftCte} SELECT id, drifted_columns FROM drifted LIMIT ${COLUMN_DRIFT_SAMPLE_LIMIT}`,
|
|
291
|
+
);
|
|
292
|
+
const sample = rows.flatMap((r) =>
|
|
293
|
+
r.drifted_columns.split(",").map((col) => `${String(r.id)}.${col}`),
|
|
294
|
+
);
|
|
295
|
+
return { rowCount, sample };
|
|
296
|
+
}
|
|
297
|
+
|
|
115
298
|
// Atomic swap, INSIDE the rebuild tx, AFTER replay. Schema-qualified so the
|
|
116
299
|
// active shadow search_path can't redirect them. DROP without CASCADE: if any
|
|
117
300
|
// object depends on the live table the swap fails loud and the whole rebuild
|
|
@@ -2,36 +2,10 @@ import type { AnyDb } from "../query";
|
|
|
2
2
|
import { asRawClient } from "../query";
|
|
3
3
|
import { quoteTableIdent } from "./table-ops";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export async function alterTableAddColumn(
|
|
10
|
-
db: AnyDb,
|
|
11
|
-
tableName: string,
|
|
12
|
-
columnName: string,
|
|
13
|
-
columnType: string,
|
|
14
|
-
defaultClause: string,
|
|
15
|
-
notNull: string,
|
|
16
|
-
): Promise<void> {
|
|
17
|
-
await asRawClient(db).unsafe(
|
|
18
|
-
`ALTER TABLE ${quoteTableIdent(tableName)} ADD COLUMN ${quoteTableIdent(columnName)} ${columnType}${defaultClause}${notNull}`,
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export async function createIndexIfNotExists(
|
|
23
|
-
db: AnyDb,
|
|
24
|
-
indexKind: "UNIQUE INDEX" | "INDEX",
|
|
25
|
-
indexName: string,
|
|
26
|
-
tableName: string,
|
|
27
|
-
columnList: string,
|
|
28
|
-
whereSql?: string,
|
|
29
|
-
): Promise<void> {
|
|
30
|
-
const where = whereSql !== undefined ? ` WHERE ${whereSql}` : "";
|
|
31
|
-
await asRawClient(db).unsafe(
|
|
32
|
-
`CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})${where}`,
|
|
33
|
-
);
|
|
34
|
-
}
|
|
5
|
+
// Re-exported for back-compat — the generic DDL helpers moved to ./ddl so
|
|
6
|
+
// the prod-boot path (stack/table-helpers.ts, pipeline/event-consumer-state.ts)
|
|
7
|
+
// doesn't import from a module named for test-only concerns.
|
|
8
|
+
export { alterTableAddColumn, createIndexIfNotExists, executeDdlStatement } from "./ddl";
|
|
35
9
|
|
|
36
10
|
export async function truncateTablesRestartIdentity(
|
|
37
11
|
db: AnyDb,
|
package/src/db/query-api.ts
CHANGED
package/src/db/query.ts
CHANGED
package/src/db/rebuild-marker.ts
CHANGED
|
@@ -32,12 +32,28 @@ function markerPathFor(migrationsDir: string, migrationId: string): string {
|
|
|
32
32
|
return join(migrationsDir, `${migrationId}.rebuild.json`);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
// Only managed tables (event-stream derivatives) get rebuild markers — unmanaged
|
|
35
|
+
// Only managed tables (event-stream derivatives) get rebuild markers — unmanaged
|
|
36
|
+
// carry real data, never rebuilt from events; sorted+deduped for stable PR diff.
|
|
37
|
+
//
|
|
38
|
+
// A changed table needs a rebuild ONLY when the generated SQL RECREATES it
|
|
39
|
+
// (managedChangeRequiresRecreate: drop/NOT-NULL-w/o-default/unique-index/type-
|
|
40
|
+
// or nullability-change) — then the table is emptied and must be re-derived from
|
|
41
|
+
// events. A pure additive nullable column is an in-place `ADD COLUMN` ALTER that
|
|
42
|
+
// already brings the table to the target state (same logic #181 applied to
|
|
43
|
+
// index-/default-only changes). Rebuilding it anyway is wasted replay AND — the
|
|
44
|
+
// bug this closes — a co-triggered rebuild can drop the fresh column (rebuild
|
|
45
|
+
// runs from the rebuilding process's registry meta; on a rolling deploy an older
|
|
46
|
+
// pod's meta lacks the column → shadow-swap wipes it → phantom migration + boot-
|
|
47
|
+
// drift crash; see 0008_add_pending_deletion_request_id / #494 / #835).
|
|
48
|
+
//
|
|
49
|
+
// If a NEW additive column genuinely needs value-backfill from historical events
|
|
50
|
+
// (a column DERIVED from existing event fields), opt in explicitly by hand-adding
|
|
51
|
+
// a `NNNN_<name>.rebuild.json` next to the migration (readRebuildMarker reads it).
|
|
36
52
|
export function rebuildTablesFromDiff(diff: SchemaDiff): readonly string[] {
|
|
37
53
|
const names = new Set<string>();
|
|
38
54
|
for (const t of diff.changedTables) {
|
|
39
55
|
if (t.nextMeta.source !== "managed") continue;
|
|
40
|
-
if (
|
|
56
|
+
if (managedChangeRequiresRecreate(t)) names.add(t.tableName);
|
|
41
57
|
}
|
|
42
58
|
for (const t of diff.newTables) {
|
|
43
59
|
if (t.source === "managed") names.add(t.tableName);
|
package/src/db/reference-data.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
1
2
|
import { fetchOne, insertOne, updateMany } from "../db/query";
|
|
2
3
|
import type { ReferenceDataDef } from "../engine/types";
|
|
3
4
|
import { SYSTEM_TENANT_ID } from "../engine/types";
|
|
@@ -7,10 +8,8 @@ import type { TableColumns } from "./dialect";
|
|
|
7
8
|
// biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables
|
|
8
9
|
type Table = TableColumns<any>;
|
|
9
10
|
|
|
10
|
-
const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
|
|
11
|
-
|
|
12
11
|
function hasColumn(table: Table, field: string): boolean {
|
|
13
|
-
const cols =
|
|
12
|
+
const cols = table[KUMIKO_COLUMNS_SYMBOL];
|
|
14
13
|
if (typeof cols !== "object" || cols === null) return false;
|
|
15
14
|
return field in (cols as Record<string, unknown>);
|
|
16
15
|
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Build-time, DB-free replay: reads the checked-in `kumiko/migrations/*.sql`
|
|
2
|
+
// files in sequence order and reconstructs the table/column shape they
|
|
3
|
+
// actually produce — then that gets diffed against `.snapshot.json`.
|
|
4
|
+
//
|
|
5
|
+
// Catches the class of bug where a migration file's *content* silently
|
|
6
|
+
// drifts from what its filename/snapshot-entry claims (e.g. a copy-paste
|
|
7
|
+
// from an earlier migration): `kumiko schema validate`'s other checks only
|
|
8
|
+
// compare ENTITY_METAS ↔ snapshot, never the committed SQL bytes against
|
|
9
|
+
// either. Reuses `loadMigrationsFromDir`'s statement-splitting so the replay
|
|
10
|
+
// sees exactly what the real runner would execute.
|
|
11
|
+
|
|
12
|
+
import type { Snapshot } from "./migrate-generator";
|
|
13
|
+
import { loadMigrationsFromDir } from "./migrate-runner";
|
|
14
|
+
|
|
15
|
+
// Migration files comment out destructive ops (DROP TABLE/COLUMN) as
|
|
16
|
+
// `-- DESTRUCTIVE: <stmt>; -- uncomment + ensure backup` so the real
|
|
17
|
+
// migrate-runner never executes them unattended. For replay purposes the
|
|
18
|
+
// snapshot represents the INTENDED end state, so a commented-out drop must
|
|
19
|
+
// still count as applied here — otherwise a table/column the snapshot
|
|
20
|
+
// already omits shows up as "unexpected" forever. splitSqlStatements (the
|
|
21
|
+
// real runner's splitter) strips `--`-comments outright, which would erase
|
|
22
|
+
// these markers before they ever reach applyStatement.
|
|
23
|
+
const DESTRUCTIVE_MARKER = /^--\s*DESTRUCTIVE:\s*(.+?;)/i;
|
|
24
|
+
|
|
25
|
+
function expandDestructiveMarkers(sqlText: string): string {
|
|
26
|
+
return sqlText
|
|
27
|
+
.split("\n")
|
|
28
|
+
.map((line) => DESTRUCTIVE_MARKER.exec(line.trim())?.[1] ?? line)
|
|
29
|
+
.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ReplayedTable = {
|
|
33
|
+
readonly columns: ReadonlySet<string>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ReplayedSchema = ReadonlyMap<string, ReplayedTable>;
|
|
37
|
+
|
|
38
|
+
// Splits a parenthesized column-list body on top-level commas — depth-aware
|
|
39
|
+
// so commas inside `numeric(10,2)` or `DEFAULT gen_random_uuid()` don't
|
|
40
|
+
// fracture a column definition.
|
|
41
|
+
function splitTopLevel(body: string): readonly string[] {
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
let depth = 0;
|
|
44
|
+
let current = "";
|
|
45
|
+
for (const ch of body) {
|
|
46
|
+
if (ch === "(") depth++;
|
|
47
|
+
if (ch === ")") depth--;
|
|
48
|
+
if (ch === "," && depth === 0) {
|
|
49
|
+
parts.push(current);
|
|
50
|
+
current = "";
|
|
51
|
+
} else {
|
|
52
|
+
current += ch;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (current.trim().length > 0) parts.push(current);
|
|
56
|
+
return parts;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Identifiers in hand-written migrations aren't always quoted (the generator
|
|
60
|
+
// always quotes, but the header explicitly invites hand-editing) — optional
|
|
61
|
+
// quotes so `CREATE TABLE foo (...)` parses the same as `CREATE TABLE "foo" (...)`.
|
|
62
|
+
const IDENT = `"?([^"\\s(;,.]+)"?`;
|
|
63
|
+
|
|
64
|
+
function parseColumnNames(body: string): Set<string> {
|
|
65
|
+
const columns = new Set<string>();
|
|
66
|
+
for (const part of splitTopLevel(body)) {
|
|
67
|
+
const trimmed = part.trim();
|
|
68
|
+
if (/^(CONSTRAINT|PRIMARY|UNIQUE|CHECK|FOREIGN|EXCLUDE|LIKE)\b/i.test(trimmed)) continue; // table-constraint or LIKE clause, not a column
|
|
69
|
+
const match = trimmed.match(new RegExp(`^${IDENT}`));
|
|
70
|
+
if (match?.[1] !== undefined) columns.add(match[1]);
|
|
71
|
+
}
|
|
72
|
+
return columns;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Clauses that can appear inside an ALTER TABLE body which do NOT change the
|
|
76
|
+
// table/column shape this replay tracks (presence only, not types/
|
|
77
|
+
// constraints/RLS/ownership) — recognized explicitly so they don't fall
|
|
78
|
+
// through to the fail-loud check as "unparsed". Deliberately does NOT
|
|
79
|
+
// include RENAME TO / RENAME COLUMN: those DO change identity in a way this
|
|
80
|
+
// replay can't track, so they must keep failing loud.
|
|
81
|
+
const SHAPE_NEUTRAL_ALTER_CLAUSE_RE = new RegExp(
|
|
82
|
+
[
|
|
83
|
+
`ALTER COLUMN\\s+${IDENT}\\s+TYPE\\b`, // #1085 int/bigint-catchup fixes
|
|
84
|
+
`ALTER COLUMN\\s+${IDENT}\\s+(SET|DROP)\\s+NOT NULL\\b`,
|
|
85
|
+
`ALTER COLUMN\\s+${IDENT}\\s+(SET DEFAULT\\b|DROP DEFAULT\\b)`,
|
|
86
|
+
`(ADD|DROP)\\s+CONSTRAINT\\s+${IDENT}`,
|
|
87
|
+
`\\b(ENABLE|DISABLE)\\s+ROW LEVEL SECURITY\\b`,
|
|
88
|
+
`^OWNER TO\\b`,
|
|
89
|
+
].join("|"),
|
|
90
|
+
"gi",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
function applyStatement(
|
|
94
|
+
schema: Map<string, { columns: Set<string> }>,
|
|
95
|
+
statement: string,
|
|
96
|
+
context: { readonly file: string },
|
|
97
|
+
): void {
|
|
98
|
+
const create = statement.match(
|
|
99
|
+
new RegExp(`^CREATE TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}\\s*\\(([\\s\\S]*)\\);?\\s*$`, "i"),
|
|
100
|
+
);
|
|
101
|
+
if (create?.[2] !== undefined && create[3] !== undefined) {
|
|
102
|
+
const hasIfNotExists = create[1] !== undefined;
|
|
103
|
+
// A real Postgres CREATE TABLE IF NOT EXISTS is a no-op when the table
|
|
104
|
+
// already exists — treating it as an overwrite here loses any columns
|
|
105
|
+
// an earlier ALTER TABLE added in between (0001 CREATE, 0002 ALTER ADD
|
|
106
|
+
// COLUMN, 0003 an accidental copy-paste of 0001) and reports a false
|
|
107
|
+
// column-drift for exactly the copy-paste bug this replay exists to
|
|
108
|
+
// catch. A bare CREATE TABLE (no IF NOT EXISTS) still overwrites — the
|
|
109
|
+
// explicit recreate path (migrate-generator.ts) always DROPs first, so
|
|
110
|
+
// reaching a second CREATE for the same name there is itself already
|
|
111
|
+
// the bug the replay should surface via the resulting drift.
|
|
112
|
+
if (!(hasIfNotExists && schema.has(create[2]))) {
|
|
113
|
+
schema.set(create[2], { columns: parseColumnNames(create[3]) });
|
|
114
|
+
}
|
|
115
|
+
// skip: CREATE TABLE fully handled above, no other clause can also match
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const dropTable = statement.match(new RegExp(`^DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i"));
|
|
120
|
+
if (dropTable?.[1] !== undefined) {
|
|
121
|
+
schema.delete(dropTable[1]);
|
|
122
|
+
// skip: DROP TABLE fully handled above, no other clause can also match
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A single ALTER TABLE statement can carry multiple comma-separated
|
|
127
|
+
// ADD/DROP COLUMN clauses (e.g. migration 0007_fix-secrets-table-columns'
|
|
128
|
+
// three-column fix in one statement) — matchAll over the whole body
|
|
129
|
+
// instead of matching only the first clause, in statement order so an
|
|
130
|
+
// add-then-drop of the same column (unusual, but not impossible) resolves
|
|
131
|
+
// correctly.
|
|
132
|
+
const alterTable = statement.match(
|
|
133
|
+
new RegExp(`^ALTER TABLE\\s+${IDENT}\\s+([\\s\\S]*?);?\\s*$`, "i"),
|
|
134
|
+
);
|
|
135
|
+
const alterTableName = alterTable?.[1];
|
|
136
|
+
const alterBody = alterTable?.[2];
|
|
137
|
+
if (alterTableName !== undefined && alterBody !== undefined) {
|
|
138
|
+
const table = schema.get(alterTableName) ?? { columns: new Set<string>() };
|
|
139
|
+
schema.set(alterTableName, table);
|
|
140
|
+
const clauseRe = new RegExp(
|
|
141
|
+
`(ADD|DROP)\\s+COLUMN\\s+(?:IF (?:NOT )?EXISTS\\s+)?${IDENT}`,
|
|
142
|
+
"gi",
|
|
143
|
+
);
|
|
144
|
+
let matchedAClause = false;
|
|
145
|
+
for (const [, verb, name] of alterBody.matchAll(clauseRe)) {
|
|
146
|
+
if (verb === undefined || name === undefined) continue;
|
|
147
|
+
matchedAClause = true;
|
|
148
|
+
if (verb.toUpperCase() === "ADD") table.columns.add(name);
|
|
149
|
+
else table.columns.delete(name);
|
|
150
|
+
}
|
|
151
|
+
// Shape-neutral clauses (ALTER COLUMN TYPE, SET/DROP NOT NULL, SET/DROP
|
|
152
|
+
// DEFAULT, ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER
|
|
153
|
+
// TO) — none add/remove/rename a column, so this replay (which only
|
|
154
|
+
// tracks column presence) correctly has nothing to do for them.
|
|
155
|
+
if (alterBody.match(SHAPE_NEUTRAL_ALTER_CLAUSE_RE)) matchedAClause = true;
|
|
156
|
+
// An ALTER TABLE that matched the outer "ALTER TABLE <name> <body>" shape
|
|
157
|
+
// but whose body contains no recognized clause (e.g. RENAME TO/RENAME
|
|
158
|
+
// COLUMN, which DO change identity) would otherwise silently no-op here
|
|
159
|
+
// — fall through to the fail-loud check below instead of returning, so
|
|
160
|
+
// it's reported rather than vanishing.
|
|
161
|
+
// skip: at least one recognized clause matched — this ALTER TABLE is
|
|
162
|
+
// fully handled, nothing left to do.
|
|
163
|
+
if (matchedAClause) return;
|
|
164
|
+
}
|
|
165
|
+
// else: CREATE INDEX and everything else don't change the table/column
|
|
166
|
+
// shape this replay tracks — but a statement that clearly INTENDED to
|
|
167
|
+
// touch a table's shape (starts with CREATE/ALTER/DROP TABLE) and matched
|
|
168
|
+
// none of the recognized patterns above must fail loud, not vanish
|
|
169
|
+
// silently. Concretely this is RENAME TO / RENAME COLUMN (identity change
|
|
170
|
+
// this replay can't track) or genuinely unparsed hand-written DDL — either
|
|
171
|
+
// way a misleading missing-table/column-drift report is worse than
|
|
172
|
+
// pointing at the actual unparsed statement.
|
|
173
|
+
if (/^(CREATE|ALTER|DROP)\s+TABLE\b/i.test(statement)) {
|
|
174
|
+
const prefix = statement.slice(0, 200).replace(/\s+/g, " ").trim();
|
|
175
|
+
throw new Error(
|
|
176
|
+
`replayMigrationsDir: unparsed table-DDL statement in ${context.file} — ` +
|
|
177
|
+
`starts with CREATE/ALTER/DROP TABLE but matched none of the replay's ` +
|
|
178
|
+
`recognized patterns (CREATE TABLE, DROP TABLE, ALTER TABLE ADD/DROP ` +
|
|
179
|
+
`COLUMN, ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, ` +
|
|
180
|
+
`ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER TO — ` +
|
|
181
|
+
`optionally-quoted identifiers). Likely RENAME TO/RENAME COLUMN (real ` +
|
|
182
|
+
`identity change, not trackable here) or genuinely unparsed hand-written ` +
|
|
183
|
+
`DDL. Statement: ${prefix}${statement.length > 200 ? "…" : ""}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Reads `<migrationsDir>/*.sql` in sequence order and replays every
|
|
189
|
+
// CREATE/ALTER/DROP TABLE statement to reconstruct the resulting schema.
|
|
190
|
+
// Reuses the real runner's file-discovery + statement-splitting
|
|
191
|
+
// (loadMigrationsFromDir) instead of a second copy, so any future change to
|
|
192
|
+
// sub-directory handling, numeric sort order, or the .sql filter can't
|
|
193
|
+
// silently drift between the runner and this replay (#1522/9).
|
|
194
|
+
export function replayMigrationsDir(migrationsDir: string): ReplayedSchema {
|
|
195
|
+
const schema = new Map<string, { columns: Set<string> }>();
|
|
196
|
+
const migrations = loadMigrationsFromDir(migrationsDir, expandDestructiveMarkers);
|
|
197
|
+
for (const migration of migrations) {
|
|
198
|
+
for (const statement of migration.statements) {
|
|
199
|
+
applyStatement(schema, statement, { file: migration.id });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return schema;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export type ReplayMismatch = {
|
|
206
|
+
readonly tableName: string;
|
|
207
|
+
readonly kind: "missing-table" | "unexpected-table" | "column-drift";
|
|
208
|
+
readonly detail: string;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Compares what the migration files actually produce (`replayed`) against
|
|
212
|
+
// what `.snapshot.json` claims (`snapshot`) — the check that would have
|
|
213
|
+
// caught kumiko-studio's 0016 misgeneration (snapshot correct, SQL wrong).
|
|
214
|
+
export function diffReplayAgainstSnapshot(
|
|
215
|
+
replayed: ReplayedSchema,
|
|
216
|
+
snapshot: Snapshot,
|
|
217
|
+
): readonly ReplayMismatch[] {
|
|
218
|
+
const mismatches: ReplayMismatch[] = [];
|
|
219
|
+
const snapshotTableNames = new Set(snapshot.tables.map((t) => t.tableName));
|
|
220
|
+
|
|
221
|
+
for (const meta of snapshot.tables) {
|
|
222
|
+
const table = replayed.get(meta.tableName);
|
|
223
|
+
if (!table) {
|
|
224
|
+
mismatches.push({
|
|
225
|
+
tableName: meta.tableName,
|
|
226
|
+
kind: "missing-table",
|
|
227
|
+
detail: `snapshot expects "${meta.tableName}" but no migration file creates it`,
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const expected = new Set(meta.columns.map((c) => c.name));
|
|
232
|
+
const missing = [...expected].filter((c) => !table.columns.has(c));
|
|
233
|
+
const extra = [...table.columns].filter((c) => !expected.has(c));
|
|
234
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
235
|
+
const parts: string[] = [];
|
|
236
|
+
if (missing.length > 0) parts.push(`missing columns: ${missing.join(", ")}`);
|
|
237
|
+
if (extra.length > 0) parts.push(`unexpected columns: ${extra.join(", ")}`);
|
|
238
|
+
mismatches.push({
|
|
239
|
+
tableName: meta.tableName,
|
|
240
|
+
kind: "column-drift",
|
|
241
|
+
detail: parts.join("; "),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const tableName of replayed.keys()) {
|
|
247
|
+
if (!snapshotTableNames.has(tableName)) {
|
|
248
|
+
mismatches.push({
|
|
249
|
+
tableName,
|
|
250
|
+
kind: "unexpected-table",
|
|
251
|
+
detail: `migrations create "${tableName}" but .snapshot.json has no entry for it`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return mismatches;
|
|
257
|
+
}
|
|
@@ -13,7 +13,7 @@ function resolveUnsafeClient(db: DbConnection | DbTx): UnsafeFn {
|
|
|
13
13
|
};
|
|
14
14
|
const client = dbAny.$client ?? dbAny.session?.client ?? dbAny;
|
|
15
15
|
const fn = (client as { unsafe?: UnsafeFn }).unsafe;
|
|
16
|
-
if (fn
|
|
16
|
+
if (typeof fn !== "function") {
|
|
17
17
|
throw new Error(
|
|
18
18
|
"resolveUnsafeClient: no `.unsafe(sql, params)` fn on the db connection " +
|
|
19
19
|
"(checked $client, session.client, db itself) — schema-inspection needs the raw postgres escape hatch.",
|