@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
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
|
+
import { requestContext } from "../api/request-context";
|
|
3
|
+
import {
|
|
4
|
+
collectPiiSubjectFields,
|
|
5
|
+
configuredPiiSubjectKms,
|
|
6
|
+
decryptPiiFieldValues,
|
|
7
|
+
encryptPiiFieldValues,
|
|
8
|
+
type KmsContext,
|
|
9
|
+
type LocalKeyKmsAdapter,
|
|
10
|
+
} from "../crypto";
|
|
11
|
+
import { executeRawQuery } from "../db/queries/raw-sql";
|
|
12
|
+
import type { WhereObject } from "../db/query";
|
|
13
|
+
import { shiftParams } from "../engine/ownership";
|
|
14
|
+
import type {
|
|
15
|
+
EntityDefinition,
|
|
16
|
+
EntityId,
|
|
17
|
+
FieldDefinition,
|
|
18
|
+
SessionUser,
|
|
19
|
+
TenantId,
|
|
20
|
+
} from "../engine/types";
|
|
21
|
+
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
22
|
+
import { UniqueViolationError, type WriteFailure, writeFailure } from "../errors";
|
|
23
|
+
import { ArchivedStreamError, type EventMetadata, isStreamArchived } from "../event-store";
|
|
24
|
+
import type { EntityCache } from "../pipeline/entity-cache";
|
|
25
|
+
import type { SearchAdapter } from "../search/types";
|
|
26
|
+
import type { EnvelopeCipher } from "../secrets/envelope-cipher";
|
|
27
|
+
import { assertUnreachable } from "../utils";
|
|
28
|
+
import { rehydrateCompoundTypes } from "./compound-types";
|
|
29
|
+
import type { DbRow } from "./connection";
|
|
30
|
+
import type { TableColumns } from "./dialect";
|
|
31
|
+
import {
|
|
32
|
+
collectEncryptedFieldNames,
|
|
33
|
+
decryptEntityFieldValues,
|
|
34
|
+
encryptEntityFieldValues,
|
|
35
|
+
resolveEntityFieldEncryption,
|
|
36
|
+
} from "./entity-field-encryption";
|
|
37
|
+
import type { EventStoreExecutorOptions } from "./event-store-executor";
|
|
38
|
+
import { constraintOf, isUniqueViolation } from "./pg-error";
|
|
39
|
+
import { toSnakeCase } from "./table-builder";
|
|
40
|
+
import type { TenantDb } from "./tenant-db";
|
|
41
|
+
|
|
42
|
+
// Shared context-building for the event-store-executor CRUD verbs (create/
|
|
43
|
+
// update/delete/forget/restore/list/detail — see event-store-executor-write.ts
|
|
44
|
+
// and event-store-executor-read.ts). Precomputes entity-derived state once
|
|
45
|
+
// per createEventStoreExecutor() call and bundles the crypto/ownership
|
|
46
|
+
// helpers the verbs need, so the verb modules don't each re-derive them.
|
|
47
|
+
|
|
48
|
+
// biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables
|
|
49
|
+
export type Table = TableColumns<any>;
|
|
50
|
+
|
|
51
|
+
// Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
|
|
52
|
+
// Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
|
|
53
|
+
// `op` ist auf {eq,ne,lt,gt,in} normalisiert; "in" mit empty-array ist
|
|
54
|
+
// explizit no-match.
|
|
55
|
+
export function buildFilterWhere(
|
|
56
|
+
field: string,
|
|
57
|
+
op: "eq" | "ne" | "lt" | "gt" | "in",
|
|
58
|
+
value: unknown,
|
|
59
|
+
): WhereObject | null {
|
|
60
|
+
switch (op) {
|
|
61
|
+
case "eq":
|
|
62
|
+
return { [field]: value };
|
|
63
|
+
case "ne":
|
|
64
|
+
return { [field]: { ne: value } };
|
|
65
|
+
case "lt":
|
|
66
|
+
return { [field]: { lt: value } };
|
|
67
|
+
case "gt":
|
|
68
|
+
return { [field]: { gt: value } };
|
|
69
|
+
case "in":
|
|
70
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
71
|
+
return { [field]: value };
|
|
72
|
+
}
|
|
73
|
+
return null; // no-match short-circuit
|
|
74
|
+
default:
|
|
75
|
+
return assertUnreachable(op, "filter op");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Returns the scalar default of a field, or undefined if the field's type
|
|
80
|
+
// doesn't carry a default or no default was declared. Only scalar types
|
|
81
|
+
// (text/number/boolean/select) support creation-time defaults — money/date/
|
|
82
|
+
// file/embedded fields don't.
|
|
83
|
+
function scalarDefault(field: FieldDefinition): unknown {
|
|
84
|
+
switch (field.type) {
|
|
85
|
+
case "text":
|
|
86
|
+
case "longText":
|
|
87
|
+
case "number":
|
|
88
|
+
case "boolean":
|
|
89
|
+
case "select":
|
|
90
|
+
return field.default;
|
|
91
|
+
default:
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
|
|
97
|
+
// (create + update Pfade) → WriteFailure(UniqueViolationError 409).
|
|
98
|
+
// Andere Errors propagieren via re-throw. Lokal extrahiert weil das
|
|
99
|
+
// Pattern an zwei Stellen im executor lebt — der Caller wrap't den
|
|
100
|
+
// applyEntityEvent-call in try-catch und delegiert das Mapping hierher.
|
|
101
|
+
//
|
|
102
|
+
// Returns WriteFailure on match, null otherwise (caller re-throws).
|
|
103
|
+
export function tryMapUniqueViolation(e: unknown, entityName: string): WriteFailure | null {
|
|
104
|
+
if (!isUniqueViolation(e)) return null;
|
|
105
|
+
const constraintName = constraintOf(e);
|
|
106
|
+
return writeFailure(
|
|
107
|
+
new UniqueViolationError(
|
|
108
|
+
{
|
|
109
|
+
entityName,
|
|
110
|
+
...(constraintName !== undefined && { constraintName }),
|
|
111
|
+
},
|
|
112
|
+
{ cause: e instanceof Error ? e : undefined },
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Build the metadata envelope for an append. userId always set; requestId +
|
|
118
|
+
// correlation + causation come from the AsyncLocalStorage request-context
|
|
119
|
+
// when present (e.g. HTTP request, MSP-apply, job run). requestId is a pure
|
|
120
|
+
// trace marker — HTTP-level retry idempotency runs separately via
|
|
121
|
+
// pipeline/idempotency.ts (Redis-cached response replay), so a single
|
|
122
|
+
// request can write N events freely without the events-table needing a
|
|
123
|
+
// uniqueness constraint.
|
|
124
|
+
export function buildEventMetadata(user: SessionUser): EventMetadata {
|
|
125
|
+
const reqCtx = requestContext.get();
|
|
126
|
+
return {
|
|
127
|
+
userId: String(user.id),
|
|
128
|
+
...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
|
|
129
|
+
...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
|
|
130
|
+
...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Lifecycle verbs the event-store-executor auto-emits. MSPs that react
|
|
135
|
+
// to entity creates/updates/etc should reference this helper instead of
|
|
136
|
+
// hardcoding the string — a future rename in the executor then surfaces
|
|
137
|
+
// as a type error at every call site rather than a silent miss.
|
|
138
|
+
export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
|
|
139
|
+
|
|
140
|
+
export function entityEventName(entityName: string, verb: EntityLifecycleVerb): string {
|
|
141
|
+
return `${entityName}.${verb}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type ExecutorContext = {
|
|
145
|
+
readonly table: Table;
|
|
146
|
+
readonly entity: EntityDefinition;
|
|
147
|
+
readonly entityName: string;
|
|
148
|
+
readonly entityCache?: EntityCache;
|
|
149
|
+
readonly searchAdapter?: SearchAdapter;
|
|
150
|
+
readonly softDelete: boolean;
|
|
151
|
+
readonly streamTenantFor: (user: SessionUser) => TenantId;
|
|
152
|
+
readonly idFilter: (id: EntityId) => WhereObject;
|
|
153
|
+
readonly loadById: (id: EntityId, db: TenantDb) => Promise<Record<string, unknown> | null>;
|
|
154
|
+
readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
|
|
155
|
+
readonly loadWithOwnership: (
|
|
156
|
+
db: TenantDb,
|
|
157
|
+
idWhere: WhereObject,
|
|
158
|
+
ownership:
|
|
159
|
+
| { kind: "pass" }
|
|
160
|
+
| { kind: "empty" }
|
|
161
|
+
| { kind: "sql"; sqlText: string; params: readonly unknown[] },
|
|
162
|
+
) => Promise<Record<string, unknown>[]>;
|
|
163
|
+
readonly encryptForStorage: (
|
|
164
|
+
row: Record<string, unknown>,
|
|
165
|
+
user: SessionUser,
|
|
166
|
+
opts?: { onlyKeys?: Iterable<string>; subjectSource?: Record<string, unknown> },
|
|
167
|
+
) => Promise<Record<string, unknown>>;
|
|
168
|
+
readonly decryptForRead: (row: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
169
|
+
readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
|
|
170
|
+
readonly stripSensitive: (
|
|
171
|
+
payload: Record<string, unknown> | undefined,
|
|
172
|
+
) => Record<string, unknown>;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export function buildExecutorContext(
|
|
176
|
+
table: Table,
|
|
177
|
+
entity: EntityDefinition,
|
|
178
|
+
options: EventStoreExecutorOptions,
|
|
179
|
+
): ExecutorContext {
|
|
180
|
+
const { searchAdapter, entityName, entityCache } = options;
|
|
181
|
+
const softDelete = entity.softDelete ?? false;
|
|
182
|
+
|
|
183
|
+
// Stream-tenant choke-point. A systemStream entity (tenant-independent, e.g.
|
|
184
|
+
// user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
|
|
185
|
+
// there. Everything else stays on the caller's tenant (byte-identical to the
|
|
186
|
+
// old hardcoded user.tenantId). Single source of truth for the stream key.
|
|
187
|
+
const streamTenantFor = (user: SessionUser): TenantId =>
|
|
188
|
+
entity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
|
|
189
|
+
|
|
190
|
+
// idType default (undefined) is now "uuid" — the ES-pivot made UUID the
|
|
191
|
+
// only valid aggregate-id type. Explicit `idType: "serial"` is the only
|
|
192
|
+
// shape that's incompatible with the event-store and still rejected.
|
|
193
|
+
if (entity.idType !== undefined && entity.idType !== "uuid") {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`event-store-executor requires entity "${entityName}" to declare idType: "uuid" — ` +
|
|
196
|
+
`got idType: "${entity.idType}". ` +
|
|
197
|
+
`The events-table keys aggregates by uuid(aggregate_id); non-UUID PKs would ` +
|
|
198
|
+
`require a schema split the framework does not currently support. ` +
|
|
199
|
+
`Fix: remove the \`idType\`-override from createEntity({...}) for "${entityName}" ` +
|
|
200
|
+
`(the default is "uuid"). The framework auto-assigns UUIDs on create — ` +
|
|
201
|
+
`you do not need to generate them yourself. ` +
|
|
202
|
+
`See docs/plans/architecture/event-sourcing-pivot.md (section "UUID-only aggregate IDs") for the full rationale.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Pre-compute defaults once so create() doesn't loop the entity every call.
|
|
207
|
+
const fieldDefaults: Record<string, unknown> = {};
|
|
208
|
+
for (const [name, field] of Object.entries(entity.fields)) {
|
|
209
|
+
const def = scalarDefault(field);
|
|
210
|
+
if (def !== undefined) fieldDefaults[name] = def;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Pre-compute the set of sensitive field names once. The event log stores
|
|
214
|
+
// these fields as table ciphertext (boot validates sensitive ⇒ pii |
|
|
215
|
+
// encrypted, #967) — the set only strips the caller-facing event echo so
|
|
216
|
+
// responses never carry the value (#820).
|
|
217
|
+
const sensitiveFields = new Set<string>();
|
|
218
|
+
for (const [name, field] of Object.entries(entity.fields)) {
|
|
219
|
+
if ("sensitive" in field && field.sensitive === true) {
|
|
220
|
+
sensitiveFields.add(name);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
225
|
+
const hasEncryptedFields = encryptedFields.size > 0;
|
|
226
|
+
|
|
227
|
+
const piiSubjectFields = collectPiiSubjectFields(entity);
|
|
228
|
+
const hasPiiFields = piiSubjectFields.length > 0;
|
|
229
|
+
|
|
230
|
+
function fieldCipher(): EnvelopeCipher {
|
|
231
|
+
if (options.encryption) return options.encryption;
|
|
232
|
+
return resolveEntityFieldEncryption();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// No adapter configured = crypto-shredding off; pii fields stay plaintext
|
|
236
|
+
// (pre-#724 behavior). The hard boot gate ships with the prod-grade
|
|
237
|
+
// PgKmsAdapter (phase E).
|
|
238
|
+
function piiKms(): LocalKeyKmsAdapter | undefined {
|
|
239
|
+
return options.kms ?? configuredPiiSubjectKms();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function kmsContextFor(user?: SessionUser): KmsContext {
|
|
243
|
+
return {
|
|
244
|
+
requestId: requestContext.get()?.requestId ?? "event-store-executor",
|
|
245
|
+
...(user && { tenantId: user.tenantId, userId: String(user.id) }),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Async on purpose: the envelope cipher wraps/unwraps DEKs via the
|
|
250
|
+
// MasterKeyProvider. Callers MUST await — a missed await here writes
|
|
251
|
+
// "[object Promise]" into the projection, which the Promise return
|
|
252
|
+
// types turn into a compile error at every call site.
|
|
253
|
+
async function encryptForStorage(
|
|
254
|
+
row: Record<string, unknown>,
|
|
255
|
+
user: SessionUser,
|
|
256
|
+
opts?: { onlyKeys?: Iterable<string>; subjectSource?: Record<string, unknown> },
|
|
257
|
+
): Promise<Record<string, unknown>> {
|
|
258
|
+
let out = row;
|
|
259
|
+
if (hasEncryptedFields) {
|
|
260
|
+
out = await encryptEntityFieldValues(out, encryptedFields, fieldCipher(), {
|
|
261
|
+
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const kms = piiKms();
|
|
265
|
+
if (hasPiiFields && kms) {
|
|
266
|
+
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
|
|
267
|
+
tenantId: user.tenantId,
|
|
268
|
+
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
269
|
+
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Mirror of encryptForStorage IN REVERSE — a field that carries both
|
|
276
|
+
// `encrypted: true` and a subject marker (userOwned/tenantOwned/pii) is
|
|
277
|
+
// written as PII(envelope(plaintext)) (envelope wraps first, PII wraps
|
|
278
|
+
// that as its outer layer). Decrypt must peel the OUTER layer first: PII
|
|
279
|
+
// unwrap before envelope decrypt, or the envelope cipher chokes on a
|
|
280
|
+
// still-PII-wrapped string (auth-mfa.totpSecret/recoveryCodes combine both
|
|
281
|
+
// markers; see pii-subject-encryption.integration.test.ts).
|
|
282
|
+
async function decryptForRead(row: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
283
|
+
let out = row;
|
|
284
|
+
const kms = piiKms();
|
|
285
|
+
if (hasPiiFields && kms) {
|
|
286
|
+
out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
|
|
287
|
+
}
|
|
288
|
+
if (hasEncryptedFields) {
|
|
289
|
+
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher());
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
|
|
295
|
+
if (Object.keys(fieldDefaults).length === 0) return payload;
|
|
296
|
+
const result: Record<string, unknown> = { ...payload };
|
|
297
|
+
for (const [name, def] of Object.entries(fieldDefaults)) {
|
|
298
|
+
if (result[name] === undefined) result[name] = def;
|
|
299
|
+
}
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function stripSensitive(payload: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
304
|
+
if (!payload) return {};
|
|
305
|
+
if (sensitiveFields.size === 0) return payload;
|
|
306
|
+
const result: Record<string, unknown> = {};
|
|
307
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
308
|
+
if (sensitiveFields.has(key)) continue;
|
|
309
|
+
result[key] = value;
|
|
310
|
+
}
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function idFilter(id: EntityId): WhereObject {
|
|
315
|
+
const filter: WhereObject = { id };
|
|
316
|
+
if (softDelete && table["isDeleted"]) filter["isDeleted"] = false;
|
|
317
|
+
return filter;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
|
|
321
|
+
const row = await db.fetchOne(table, idFilter(id));
|
|
322
|
+
if (!row) return null;
|
|
323
|
+
return await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Archive guard for the CRUD write paths. Archived streams are read-only —
|
|
327
|
+
// ctx.appendEvent (append-event-core) already enforces this, but the
|
|
328
|
+
// executor appends directly via append() and getStreamVersion() ignores
|
|
329
|
+
// the archive flag, so without this check a PATCH/DELETE on an archived
|
|
330
|
+
// entity would silently land an event and break the read-only contract
|
|
331
|
+
// (loadAggregate returns [] for the same stream). Throws ArchivedStreamError
|
|
332
|
+
// to mirror the appendEvent path exactly — same 500 + rolled-back tx.
|
|
333
|
+
// Creates skip this: a fresh UUID can't be archived, and a deterministic-id
|
|
334
|
+
// re-create onto an archived stream collides on the unique index →
|
|
335
|
+
// version_conflict, which already blocks the write.
|
|
336
|
+
async function assertStreamWritable(
|
|
337
|
+
db: TenantDb,
|
|
338
|
+
id: EntityId,
|
|
339
|
+
tenantId: TenantId,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
if (await isStreamArchived(db.raw, tenantId, String(id))) {
|
|
342
|
+
throw new ArchivedStreamError(tenantId, String(id));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// SELECT a row by id with the ownership clause applied at the DB layer.
|
|
347
|
+
// Detail() uses this both on cold path and as a cache-revalidation probe.
|
|
348
|
+
async function loadWithOwnership(
|
|
349
|
+
db: TenantDb,
|
|
350
|
+
idWhere: WhereObject,
|
|
351
|
+
ownership:
|
|
352
|
+
| { kind: "pass" }
|
|
353
|
+
| { kind: "empty" }
|
|
354
|
+
| { kind: "sql"; sqlText: string; params: readonly unknown[] },
|
|
355
|
+
): Promise<Record<string, unknown>[]> {
|
|
356
|
+
if (ownership.kind === "empty") return [];
|
|
357
|
+
if (ownership.kind === "pass") {
|
|
358
|
+
const row = await db.fetchOne(table, idWhere);
|
|
359
|
+
return row ? [row as Record<string, unknown>] : [];
|
|
360
|
+
}
|
|
361
|
+
// ownership has raw SQL — splice it into a raw query alongside the
|
|
362
|
+
// idFilter + tenant-filter that TenantDb would have added.
|
|
363
|
+
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
364
|
+
const colSql = (field: string): string =>
|
|
365
|
+
`"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
|
|
366
|
+
const whereParts: string[] = [];
|
|
367
|
+
const params: unknown[] = [];
|
|
368
|
+
if (table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
369
|
+
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
370
|
+
whereParts.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
|
|
371
|
+
}
|
|
372
|
+
for (const [field, value] of Object.entries(idWhere)) {
|
|
373
|
+
if (typeof value === "boolean") {
|
|
374
|
+
whereParts.push(`${colSql(field)} = ${value ? "TRUE" : "FALSE"}`);
|
|
375
|
+
} else {
|
|
376
|
+
params.push(value);
|
|
377
|
+
whereParts.push(`${colSql(field)} = $${params.length}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const shifted = shiftParams(ownership, params.length);
|
|
381
|
+
whereParts.push(shifted.sqlText);
|
|
382
|
+
for (const p of shifted.params) params.push(p);
|
|
383
|
+
const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
384
|
+
return [...(await executeRawQuery<Record<string, unknown>>(db.raw, sqlText, params))];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
table,
|
|
389
|
+
entity,
|
|
390
|
+
entityName,
|
|
391
|
+
entityCache,
|
|
392
|
+
searchAdapter,
|
|
393
|
+
softDelete,
|
|
394
|
+
streamTenantFor,
|
|
395
|
+
idFilter,
|
|
396
|
+
loadById,
|
|
397
|
+
assertStreamWritable,
|
|
398
|
+
loadWithOwnership,
|
|
399
|
+
encryptForStorage,
|
|
400
|
+
decryptForRead,
|
|
401
|
+
applyDefaults,
|
|
402
|
+
stripSensitive,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
|
+
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto";
|
|
3
|
+
import { executeRawQuery } from "../db/queries/raw-sql";
|
|
4
|
+
import { coerceRow, extractTableInfo } from "../db/query";
|
|
5
|
+
import { buildOwnershipClause, shiftParams } from "../engine/ownership";
|
|
6
|
+
import type { EntityId } from "../engine/types";
|
|
7
|
+
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
8
|
+
import { getStreamVersion } from "../event-store";
|
|
9
|
+
import { rehydrateCompoundTypes } from "./compound-types";
|
|
10
|
+
import { decodeCursor, encodeCursor } from "./cursor";
|
|
11
|
+
import type { EventStoreExecutor } from "./event-store-executor";
|
|
12
|
+
import { buildFilterWhere, type ExecutorContext } from "./event-store-executor-context";
|
|
13
|
+
import { toSnakeCase } from "./table-builder";
|
|
14
|
+
|
|
15
|
+
// The two read verbs (list/detail) of the event-store-executor. Split out
|
|
16
|
+
// of event-store-executor.ts (#1005, Welle 2) — behavior-preserving
|
|
17
|
+
// relocation, not a redesign: unchanged from the original, now behind an
|
|
18
|
+
// explicit ExecutorContext instead of the factory's local scope.
|
|
19
|
+
|
|
20
|
+
export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
|
|
21
|
+
const {
|
|
22
|
+
table,
|
|
23
|
+
entity,
|
|
24
|
+
entityName,
|
|
25
|
+
entityCache,
|
|
26
|
+
searchAdapter,
|
|
27
|
+
softDelete,
|
|
28
|
+
streamTenantFor,
|
|
29
|
+
idFilter,
|
|
30
|
+
loadWithOwnership,
|
|
31
|
+
decryptForRead,
|
|
32
|
+
encryptForStorage,
|
|
33
|
+
} = ctx;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
// list + detail are unchanged from crud-executor — projections are the
|
|
37
|
+
// read-model and serve these queries directly.
|
|
38
|
+
async list(payload, user, db, runtimeOptions) {
|
|
39
|
+
const limit = payload.limit ?? 50;
|
|
40
|
+
const offset = payload.offset ?? 0;
|
|
41
|
+
const totalCount = payload.totalCount === true;
|
|
42
|
+
|
|
43
|
+
// H.2 — entity-level read ownership. Decide before touching search or
|
|
44
|
+
// the DB: `empty` means there's no row the user could ever see, so
|
|
45
|
+
// skip both paths and return an empty page.
|
|
46
|
+
const ownership = buildOwnershipClause(user, entity.access?.read, table);
|
|
47
|
+
if (ownership.kind === "empty") {
|
|
48
|
+
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let filterIds: EntityId[] | undefined;
|
|
52
|
+
// Build-Time options.searchAdapter gewinnt; runtime-Override ist
|
|
53
|
+
// Fallback für die defaultEntityQueryHandler-Pipe (die nutzt den
|
|
54
|
+
// ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
|
|
55
|
+
// beim Definition-Time noch keinen Server-Context hat).
|
|
56
|
+
const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
|
|
57
|
+
if (payload.search && effectiveSearchAdapter && entityName) {
|
|
58
|
+
const results = await effectiveSearchAdapter.search(user.tenantId, payload.search, {
|
|
59
|
+
filterType: entityName,
|
|
60
|
+
});
|
|
61
|
+
filterIds = results.map((r) => r.entityId);
|
|
62
|
+
if (filterIds.length === 0) {
|
|
63
|
+
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Build the WHERE clause as raw SQL — ownership produces a
|
|
68
|
+
// parameterised fragment that we splice in alongside simple WhereObject
|
|
69
|
+
// conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
|
|
70
|
+
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
71
|
+
const whereSql: string[] = [];
|
|
72
|
+
const params: unknown[] = [];
|
|
73
|
+
const colSql = (field: string): string =>
|
|
74
|
+
`"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
|
|
75
|
+
|
|
76
|
+
// Tenant-Filter (replicates TenantDb's readWhere semantics).
|
|
77
|
+
if (table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
78
|
+
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
79
|
+
whereSql.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
|
|
80
|
+
}
|
|
81
|
+
if (softDelete && table["isDeleted"] && runtimeOptions?.includeDeleted !== true) {
|
|
82
|
+
whereSql.push(`${colSql("isDeleted")} = FALSE`);
|
|
83
|
+
}
|
|
84
|
+
if (payload.cursor) {
|
|
85
|
+
params.push(decodeCursor(payload.cursor));
|
|
86
|
+
whereSql.push(`${colSql("id")} > $${params.length}`);
|
|
87
|
+
}
|
|
88
|
+
if (filterIds) {
|
|
89
|
+
const placeholders = filterIds.map((id) => {
|
|
90
|
+
params.push(id);
|
|
91
|
+
return `$${params.length}`;
|
|
92
|
+
});
|
|
93
|
+
whereSql.push(`${colSql("id")} IN (${placeholders.join(", ")})`);
|
|
94
|
+
}
|
|
95
|
+
if (ownership.kind === "sql") {
|
|
96
|
+
const shifted = shiftParams(
|
|
97
|
+
{ sqlText: ownership.sqlText, params: ownership.params },
|
|
98
|
+
params.length,
|
|
99
|
+
);
|
|
100
|
+
whereSql.push(shifted.sqlText);
|
|
101
|
+
for (const p of shifted.params) params.push(p);
|
|
102
|
+
}
|
|
103
|
+
const applyFilter = (f: {
|
|
104
|
+
readonly field: string;
|
|
105
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
106
|
+
readonly value: unknown;
|
|
107
|
+
}): void => {
|
|
108
|
+
if (table[f.field] === undefined) {
|
|
109
|
+
// skip: unknown field — not a real column, drop the filter (injection guard)
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const screen = buildFilterWhere(f.field, f.op, f.value);
|
|
113
|
+
if (screen === null) {
|
|
114
|
+
whereSql.push("FALSE");
|
|
115
|
+
// skip: filter is unsatisfiable → emit FALSE, no params to bind
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
for (const [field, value] of Object.entries(screen)) {
|
|
119
|
+
if (Array.isArray(value)) {
|
|
120
|
+
const placeholders = value.map((v) => {
|
|
121
|
+
params.push(v);
|
|
122
|
+
return `$${params.length}`;
|
|
123
|
+
});
|
|
124
|
+
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
125
|
+
} else if (typeof value === "object" && value !== null) {
|
|
126
|
+
const opMap: Record<string, string> = {
|
|
127
|
+
gt: ">",
|
|
128
|
+
gte: ">=",
|
|
129
|
+
lt: "<",
|
|
130
|
+
lte: "<=",
|
|
131
|
+
ne: "<>",
|
|
132
|
+
};
|
|
133
|
+
for (const [opKey, opSym] of Object.entries(opMap)) {
|
|
134
|
+
if (!(opKey in value)) continue;
|
|
135
|
+
params.push((value as Record<string, unknown>)[opKey]);
|
|
136
|
+
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// Blind-Index-OR-Rewrite (#818), lock-step mit buildWhereClause
|
|
140
|
+
// in bun-db/query.ts — Equality auf lookupable-Feldern matcht
|
|
141
|
+
// Klartext-Arm ODER HMAC-Arm.
|
|
142
|
+
const bidxKey = configuredBlindIndexKey();
|
|
143
|
+
if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
|
|
144
|
+
params.push(value, computeBlindIndex(bidxKey, value));
|
|
145
|
+
whereSql.push(
|
|
146
|
+
`(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`,
|
|
147
|
+
);
|
|
148
|
+
} else {
|
|
149
|
+
params.push(value);
|
|
150
|
+
whereSql.push(`${colSql(field)} = $${params.length}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
if (payload.filter !== undefined) applyFilter(payload.filter);
|
|
156
|
+
if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
|
|
157
|
+
|
|
158
|
+
const orderByClause =
|
|
159
|
+
payload.sort && table[payload.sort]
|
|
160
|
+
? ` ORDER BY ${colSql(payload.sort)} ${payload.sortDirection === "desc" ? "DESC" : "ASC"}`
|
|
161
|
+
: "";
|
|
162
|
+
const useOffset = !payload.cursor && offset > 0;
|
|
163
|
+
const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
|
|
164
|
+
|
|
165
|
+
const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
|
|
166
|
+
const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
167
|
+
|
|
168
|
+
const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
|
|
169
|
+
// Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen.
|
|
170
|
+
// Coerce BEFORE decrypt: the raw SELECT * rows carry snake_case column
|
|
171
|
+
// names, while the encrypted/pii field lists are camelCase — decrypting
|
|
172
|
+
// first silently skipped every multi-word field (ciphertext leaked to
|
|
173
|
+
// the caller).
|
|
174
|
+
const tableInfo = extractTableInfo(table);
|
|
175
|
+
const encryptedRows = rawRows.map((r) =>
|
|
176
|
+
coerceRow(rehydrateCompoundTypes(r, entity), tableInfo),
|
|
177
|
+
);
|
|
178
|
+
const rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
|
|
179
|
+
|
|
180
|
+
// list rows carry the READ-ROW version (display-only), never an optimistic-lock
|
|
181
|
+
// base — edit flows reload via detail(), which reconciles the stream version.
|
|
182
|
+
// Cache the still-encrypted form: same at-rest guarantee as detail()'s
|
|
183
|
+
// encryptForStorage round-trip, without paying a re-encrypt.
|
|
184
|
+
if (entityCache && entityName && rows.length > 0) {
|
|
185
|
+
await entityCache.mset(
|
|
186
|
+
user.tenantId,
|
|
187
|
+
entityName,
|
|
188
|
+
encryptedRows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const lastRow = rows[rows.length - 1];
|
|
193
|
+
const nextCursor =
|
|
194
|
+
rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null; // @cast-boundary engine-payload
|
|
195
|
+
|
|
196
|
+
// total: extra COUNT(*) — nur wenn explizit angefordert (Pager-UI).
|
|
197
|
+
// Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
|
|
198
|
+
// wie der entsprechende WHERE — bei indexed columns billig genug.
|
|
199
|
+
// Bei Search-Path ist `total = filterIds.length` ohne extra Query.
|
|
200
|
+
let total: number | undefined;
|
|
201
|
+
if (totalCount) {
|
|
202
|
+
if (filterIds) {
|
|
203
|
+
total = filterIds.length;
|
|
204
|
+
} else {
|
|
205
|
+
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
206
|
+
const countRows = await executeRawQuery<{ count: number }>(db.raw, countSql, params);
|
|
207
|
+
total = countRows[0]?.count ?? 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { rows, nextCursor, ...(total !== undefined && { total }) };
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
async detail(payload, user, db) {
|
|
215
|
+
// H.2 — ownership check. `empty` → the user can never see this row
|
|
216
|
+
// regardless of its id. Return null (same shape as "not found", so a
|
|
217
|
+
// probing attacker can't distinguish "no access" from "doesn't exist").
|
|
218
|
+
const ownership = buildOwnershipClause(user, entity.access?.read, table);
|
|
219
|
+
if (ownership.kind === "empty") return null;
|
|
220
|
+
|
|
221
|
+
const idWhere = idFilter(payload.id);
|
|
222
|
+
|
|
223
|
+
// Stream-version authoritative (same policy as update/Block 0):
|
|
224
|
+
// ctx.appendEvent (lifecycle-writes like incident:post-update) bumps
|
|
225
|
+
// the stream WITHOUT touching row.version — a detail-read that hands
|
|
226
|
+
// out the stale row.version dooms the next CRUD update built on it
|
|
227
|
+
// (entityEdit loads detail.version as its optimistic-lock base) to a
|
|
228
|
+
// guaranteed version_conflict.
|
|
229
|
+
const withStreamVersion = async (
|
|
230
|
+
row: Record<string, unknown>,
|
|
231
|
+
): Promise<Record<string, unknown>> => {
|
|
232
|
+
const streamVersion = await getStreamVersion(
|
|
233
|
+
db.raw,
|
|
234
|
+
String(payload.id),
|
|
235
|
+
streamTenantFor(user),
|
|
236
|
+
);
|
|
237
|
+
return streamVersion > 0 ? { ...row, version: streamVersion } : row;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
if (entityCache && entityName) {
|
|
241
|
+
const cached = await entityCache.get(user.tenantId, entityName, payload.id);
|
|
242
|
+
if (cached) {
|
|
243
|
+
if (ownership.kind === "sql") {
|
|
244
|
+
// Re-check ownership predicate against the live row — the cache
|
|
245
|
+
// is keyed only by tenant + id, not by role.
|
|
246
|
+
const checkRows = await loadWithOwnership(db, idWhere, ownership);
|
|
247
|
+
if (checkRows.length === 0) return null;
|
|
248
|
+
}
|
|
249
|
+
// Cached rows are stored re-encrypted (see the `set` below) so an
|
|
250
|
+
// `encrypted` field's plaintext never sits in a second at-rest
|
|
251
|
+
// store (Redis) the field-encryption feature doesn't cover.
|
|
252
|
+
return withStreamVersion(await decryptForRead(cached));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const rows = await loadWithOwnership(db, idWhere, ownership);
|
|
257
|
+
const raw = rows[0];
|
|
258
|
+
if (!raw) return null;
|
|
259
|
+
const row = await decryptForRead(rehydrateCompoundTypes(raw, entity));
|
|
260
|
+
const rowInfo = extractTableInfo(table);
|
|
261
|
+
const coerced = coerceRow(row, rowInfo);
|
|
262
|
+
|
|
263
|
+
if (entityCache && entityName) {
|
|
264
|
+
await entityCache.set(
|
|
265
|
+
user.tenantId,
|
|
266
|
+
entityName,
|
|
267
|
+
payload.id,
|
|
268
|
+
await encryptForStorage(coerced, user),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return withStreamVersion(coerced);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|