@cosmicdrift/kumiko-framework 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/package.json +91 -0
- package/src/__tests__/anonymous-access.integration.ts +325 -0
- package/src/__tests__/error-contract.integration.ts +435 -0
- package/src/__tests__/field-access.integration.ts +269 -0
- package/src/__tests__/full-stack.integration.ts +914 -0
- package/src/__tests__/ownership.integration.ts +449 -0
- package/src/__tests__/reference-data.integration.ts +198 -0
- package/src/__tests__/transition-guard.integration.ts +340 -0
- package/src/api/__tests__/api.test.ts +337 -0
- package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
- package/src/api/__tests__/batch.integration.ts +404 -0
- package/src/api/__tests__/body-limit.test.ts +88 -0
- package/src/api/__tests__/csrf-middleware.test.ts +97 -0
- package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
- package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
- package/src/api/__tests__/nested-write.integration.ts +213 -0
- package/src/api/__tests__/readiness.test.ts +76 -0
- package/src/api/__tests__/request-id-middleware.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +58 -0
- package/src/api/__tests__/sse-route.test.ts +112 -0
- package/src/api/anonymous-cookie.ts +60 -0
- package/src/api/api-constants.ts +64 -0
- package/src/api/auth-middleware.ts +418 -0
- package/src/api/auth-routes.ts +982 -0
- package/src/api/csrf-middleware.ts +77 -0
- package/src/api/index.ts +31 -0
- package/src/api/jwt.ts +66 -0
- package/src/api/observability-middleware.ts +89 -0
- package/src/api/readiness.ts +132 -0
- package/src/api/request-context.ts +49 -0
- package/src/api/request-id-middleware.ts +50 -0
- package/src/api/route-registrars.ts +195 -0
- package/src/api/routes.ts +135 -0
- package/src/api/server.ts +640 -0
- package/src/api/sse-broker.ts +71 -0
- package/src/api/sse-route.ts +62 -0
- package/src/api/tokens.ts +16 -0
- package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
- package/src/db/__tests__/compound-types.test.ts +114 -0
- package/src/db/__tests__/connection-options.test.ts +68 -0
- package/src/db/__tests__/cursor.test.ts +41 -0
- package/src/db/__tests__/db-helpers.test.ts +369 -0
- package/src/db/__tests__/dialect-instant.test.ts +50 -0
- package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
- package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
- package/src/db/__tests__/encryption.test.ts +39 -0
- package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
- package/src/db/__tests__/event-store-executor.integration.ts +235 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
- package/src/db/__tests__/located-timestamp.test.ts +184 -0
- package/src/db/__tests__/money.test.ts +199 -0
- package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
- package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
- package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
- package/src/db/__tests__/row-helpers.test.ts +59 -0
- package/src/db/__tests__/schema-migration.integration.ts +273 -0
- package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
- package/src/db/__tests__/table-builder-required.test.ts +216 -0
- package/src/db/__tests__/tenant-db.integration.ts +606 -0
- package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
- package/src/db/apply-entity-event.ts +188 -0
- package/src/db/assert-exists-in.ts +59 -0
- package/src/db/compound-types.ts +47 -0
- package/src/db/connection.ts +104 -0
- package/src/db/cursor.ts +83 -0
- package/src/db/dialect.ts +109 -0
- package/src/db/eagerload.ts +174 -0
- package/src/db/encryption.ts +39 -0
- package/src/db/event-store-executor.ts +906 -0
- package/src/db/index.ts +55 -0
- package/src/db/located-timestamp.ts +114 -0
- package/src/db/money.ts +120 -0
- package/src/db/pg-error.ts +46 -0
- package/src/db/reference-data.ts +77 -0
- package/src/db/row-helpers.ts +53 -0
- package/src/db/schema-inspection.ts +25 -0
- package/src/db/table-builder.ts +475 -0
- package/src/db/tenant-db.ts +434 -0
- package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
- package/src/engine/__tests__/boot-validator.test.ts +1865 -0
- package/src/engine/__tests__/build-app-schema.test.ts +154 -0
- package/src/engine/__tests__/claim-keys.test.ts +274 -0
- package/src/engine/__tests__/config-helpers.test.ts +236 -0
- package/src/engine/__tests__/effective-features.test.ts +86 -0
- package/src/engine/__tests__/engine.test.ts +1461 -0
- package/src/engine/__tests__/entity-handlers.test.ts +274 -0
- package/src/engine/__tests__/event-helpers.test.ts +68 -0
- package/src/engine/__tests__/extends-registrar.test.ts +159 -0
- package/src/engine/__tests__/factories-long-text.test.ts +84 -0
- package/src/engine/__tests__/factories-time.test.ts +158 -0
- package/src/engine/__tests__/field-predicates.test.ts +48 -0
- package/src/engine/__tests__/hook-phases.test.ts +132 -0
- package/src/engine/__tests__/identifiers.test.ts +35 -0
- package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
- package/src/engine/__tests__/nav.test.ts +267 -0
- package/src/engine/__tests__/ownership.test.ts +421 -0
- package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
- package/src/engine/__tests__/projection-helpers.test.ts +62 -0
- package/src/engine/__tests__/projection.test.ts +191 -0
- package/src/engine/__tests__/qualified-name.test.ts +264 -0
- package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
- package/src/engine/__tests__/run-in.test.ts +38 -0
- package/src/engine/__tests__/schema-builder.test.ts +380 -0
- package/src/engine/__tests__/screen.test.ts +408 -0
- package/src/engine/__tests__/state-machine.test.ts +148 -0
- package/src/engine/__tests__/system-user.test.ts +57 -0
- package/src/engine/__tests__/validation-hooks.test.ts +71 -0
- package/src/engine/access.ts +23 -0
- package/src/engine/boot-validator.ts +1528 -0
- package/src/engine/build-app-schema.ts +125 -0
- package/src/engine/config-helpers.ts +115 -0
- package/src/engine/constants.ts +85 -0
- package/src/engine/create-app.ts +98 -0
- package/src/engine/define-feature.ts +702 -0
- package/src/engine/define-handler.ts +78 -0
- package/src/engine/define-roles.ts +19 -0
- package/src/engine/effective-features.ts +87 -0
- package/src/engine/entity-handlers.ts +364 -0
- package/src/engine/event-helpers.ts +73 -0
- package/src/engine/factories.ts +328 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
- package/src/engine/feature-ast/extractors.ts +2562 -0
- package/src/engine/feature-ast/index.ts +105 -0
- package/src/engine/feature-ast/parse.ts +369 -0
- package/src/engine/feature-ast/patch.ts +525 -0
- package/src/engine/feature-ast/patcher.ts +518 -0
- package/src/engine/feature-ast/patterns.ts +434 -0
- package/src/engine/feature-ast/render.ts +602 -0
- package/src/engine/feature-ast/source-location.ts +45 -0
- package/src/engine/field-access.ts +120 -0
- package/src/engine/index.ts +254 -0
- package/src/engine/ownership.ts +337 -0
- package/src/engine/parse-ref-target.ts +22 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
- package/src/engine/pattern-library/index.ts +24 -0
- package/src/engine/pattern-library/library.ts +1117 -0
- package/src/engine/pattern-library/types.ts +255 -0
- package/src/engine/projection-helpers.ts +85 -0
- package/src/engine/qualified-name.ts +122 -0
- package/src/engine/read-claim.ts +31 -0
- package/src/engine/registry.ts +1325 -0
- package/src/engine/resolve-config-or-param.ts +153 -0
- package/src/engine/run-in.ts +29 -0
- package/src/engine/schema-builder.ts +175 -0
- package/src/engine/screen-filter-ops.ts +51 -0
- package/src/engine/state-machine.ts +70 -0
- package/src/engine/system-user.ts +32 -0
- package/src/engine/types/config.ts +306 -0
- package/src/engine/types/event-type-map.ts +37 -0
- package/src/engine/types/feature.ts +574 -0
- package/src/engine/types/fields.ts +422 -0
- package/src/engine/types/handlers.ts +742 -0
- package/src/engine/types/hooks.ts +142 -0
- package/src/engine/types/http-route.ts +54 -0
- package/src/engine/types/identifiers.ts +47 -0
- package/src/engine/types/index.ts +208 -0
- package/src/engine/types/nav.ts +46 -0
- package/src/engine/types/projection.ts +132 -0
- package/src/engine/types/relations.ts +51 -0
- package/src/engine/types/screen.ts +452 -0
- package/src/engine/types/workspace.ts +42 -0
- package/src/engine/validation.ts +33 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
- package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
- package/src/entrypoint/index.ts +442 -0
- package/src/errors/__tests__/classes.test.ts +371 -0
- package/src/errors/__tests__/write-failures.test.ts +109 -0
- package/src/errors/classes.ts +249 -0
- package/src/errors/i18n/de.yaml +83 -0
- package/src/errors/i18n/en.yaml +80 -0
- package/src/errors/index.ts +41 -0
- package/src/errors/kumiko-error.ts +67 -0
- package/src/errors/reasons.ts +36 -0
- package/src/errors/serialize.ts +136 -0
- package/src/errors/transition-details.ts +30 -0
- package/src/errors/write-error-info.ts +123 -0
- package/src/errors/zod-bridge.ts +49 -0
- package/src/event-store/__tests__/admin-api.integration.ts +361 -0
- package/src/event-store/__tests__/event-store.integration.ts +584 -0
- package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
- package/src/event-store/__tests__/perf.integration.ts +255 -0
- package/src/event-store/__tests__/snapshot.integration.ts +267 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
- package/src/event-store/__tests__/upcaster.integration.ts +460 -0
- package/src/event-store/admin-api.ts +257 -0
- package/src/event-store/archive.ts +106 -0
- package/src/event-store/errors.ts +35 -0
- package/src/event-store/event-store.ts +405 -0
- package/src/event-store/events-schema.ts +90 -0
- package/src/event-store/index.ts +50 -0
- package/src/event-store/snapshot.ts +210 -0
- package/src/event-store/upcaster-dead-letter.ts +119 -0
- package/src/event-store/upcaster.ts +147 -0
- package/src/files/__tests__/content-disposition.test.ts +123 -0
- package/src/files/__tests__/file-field-column.integration.ts +103 -0
- package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
- package/src/files/__tests__/file-handle.test.ts +122 -0
- package/src/files/__tests__/files.integration.ts +830 -0
- package/src/files/__tests__/storage-tracking.integration.ts +153 -0
- package/src/files/content-disposition.ts +55 -0
- package/src/files/file-handle.ts +63 -0
- package/src/files/file-ref-table.ts +22 -0
- package/src/files/file-routes.ts +353 -0
- package/src/files/in-memory-provider.ts +62 -0
- package/src/files/index.ts +29 -0
- package/src/files/local-provider.ts +35 -0
- package/src/files/storage-tracking.ts +60 -0
- package/src/files/types.ts +118 -0
- package/src/i18n/__tests__/i18n.test.ts +72 -0
- package/src/i18n/index.ts +29 -0
- package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
- package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
- package/src/jobs/__tests__/jobs.integration.ts +566 -0
- package/src/jobs/index.ts +2 -0
- package/src/jobs/job-runner.ts +574 -0
- package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
- package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
- package/src/lifecycle/index.ts +13 -0
- package/src/lifecycle/lifecycle.ts +160 -0
- package/src/lifecycle/signal-handlers.ts +62 -0
- package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/pino-logger.ts +64 -0
- package/src/logging/types.ts +7 -0
- package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
- package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
- package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
- package/src/migrations/index.ts +28 -0
- package/src/migrations/projection-detection.ts +149 -0
- package/src/migrations/rebuild-marker.ts +64 -0
- package/src/migrations/schema-drift.ts +395 -0
- package/src/observability/__tests__/console-provider.test.ts +67 -0
- package/src/observability/__tests__/metric-validator.test.ts +87 -0
- package/src/observability/__tests__/noop-provider.test.ts +82 -0
- package/src/observability/__tests__/observability.integration.ts +559 -0
- package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
- package/src/observability/__tests__/recording-meter.test.ts +101 -0
- package/src/observability/__tests__/recording-tracer.test.ts +110 -0
- package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
- package/src/observability/console-provider.ts +130 -0
- package/src/observability/context.ts +26 -0
- package/src/observability/fallback.ts +34 -0
- package/src/observability/ids.ts +25 -0
- package/src/observability/index.ts +79 -0
- package/src/observability/metric-validator.ts +86 -0
- package/src/observability/metrics-handle.ts +56 -0
- package/src/observability/noop-provider.ts +146 -0
- package/src/observability/prometheus-meter.ts +284 -0
- package/src/observability/recording-meter.ts +156 -0
- package/src/observability/recording-tracer.ts +198 -0
- package/src/observability/redis-wrapper.ts +132 -0
- package/src/observability/sensitive-filter.ts +108 -0
- package/src/observability/standard-metrics.ts +213 -0
- package/src/observability/types/index.ts +29 -0
- package/src/observability/types/metric.ts +56 -0
- package/src/observability/types/provider.ts +32 -0
- package/src/observability/types/span.ts +64 -0
- package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
- package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
- package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
- package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
- package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
- package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
- package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
- package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
- package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
- package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
- package/src/pipeline/append-event-core.ts +117 -0
- package/src/pipeline/auth-claims-resolver.ts +103 -0
- package/src/pipeline/cascade-handler.ts +113 -0
- package/src/pipeline/dispatcher.ts +1585 -0
- package/src/pipeline/distributed-lock.ts +37 -0
- package/src/pipeline/entity-cache.ts +113 -0
- package/src/pipeline/event-consumer-state.ts +108 -0
- package/src/pipeline/event-dedup.ts +23 -0
- package/src/pipeline/event-dispatcher.ts +1016 -0
- package/src/pipeline/event-retention.ts +154 -0
- package/src/pipeline/idempotency.ts +76 -0
- package/src/pipeline/index.ts +66 -0
- package/src/pipeline/lifecycle-pipeline.ts +409 -0
- package/src/pipeline/msp-rebuild.ts +242 -0
- package/src/pipeline/multi-stream-apply-context.ts +115 -0
- package/src/pipeline/projection-rebuild.ts +334 -0
- package/src/pipeline/projection-state.ts +72 -0
- package/src/pipeline/projections-runner.ts +56 -0
- package/src/pipeline/redis-keys.ts +11 -0
- package/src/pipeline/system-hooks.ts +190 -0
- package/src/random/__tests__/generate.test.ts +149 -0
- package/src/random/generate.ts +141 -0
- package/src/random/index.ts +8 -0
- package/src/random/words.ts +392 -0
- package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
- package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
- package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
- package/src/rate-limit/bucket.ts +36 -0
- package/src/rate-limit/index.ts +14 -0
- package/src/rate-limit/middleware.ts +152 -0
- package/src/rate-limit/resolver.ts +267 -0
- package/src/redis/__tests__/redis-options.test.ts +54 -0
- package/src/redis/index.ts +74 -0
- package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
- package/src/search/__tests__/search-adapter.test.ts +256 -0
- package/src/search/in-memory-adapter.ts +123 -0
- package/src/search/index.ts +12 -0
- package/src/search/meilisearch-adapter.ts +106 -0
- package/src/search/types.ts +39 -0
- package/src/secrets/__tests__/dek-cache.test.ts +213 -0
- package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
- package/src/secrets/__tests__/envelope.test.ts +74 -0
- package/src/secrets/__tests__/leak-guard.test.ts +92 -0
- package/src/secrets/__tests__/rotation.test.ts +149 -0
- package/src/secrets/dek-cache.ts +116 -0
- package/src/secrets/env-master-key-provider.ts +162 -0
- package/src/secrets/envelope.ts +55 -0
- package/src/secrets/index.ts +19 -0
- package/src/secrets/leak-guard.ts +87 -0
- package/src/secrets/rotation.ts +34 -0
- package/src/secrets/types.ts +107 -0
- package/src/stack/db.ts +104 -0
- package/src/stack/event-collector.ts +23 -0
- package/src/stack/index.ts +32 -0
- package/src/stack/redis.ts +44 -0
- package/src/stack/request-helper.ts +168 -0
- package/src/stack/table-helpers.ts +104 -0
- package/src/stack/test-stack.ts +357 -0
- package/src/stack/test-users.ts +37 -0
- package/src/testing/__tests__/e2e-generator.test.ts +230 -0
- package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
- package/src/testing/access-assertions.ts +15 -0
- package/src/testing/assertions.ts +35 -0
- package/src/testing/e2e-generator.ts +465 -0
- package/src/testing/expect-error.ts +25 -0
- package/src/testing/handler-context.ts +125 -0
- package/src/testing/http-cookies.ts +52 -0
- package/src/testing/index.ts +41 -0
- package/src/testing/late-bound.ts +39 -0
- package/src/testing/mutable-master-key-provider.ts +31 -0
- package/src/testing/observability-recorder.ts +54 -0
- package/src/testing/shared-entities.ts +49 -0
- package/src/testing/utils.ts +1 -0
- package/src/testing/wait-for.ts +31 -0
- package/src/time/__tests__/polyfill.test.ts +73 -0
- package/src/time/__tests__/tz-context.test.ts +121 -0
- package/src/time/index.ts +21 -0
- package/src/time/polyfill.ts +70 -0
- package/src/time/tz-context.ts +107 -0
- package/src/ui-types/app-schema.ts +57 -0
- package/src/ui-types/index.ts +65 -0
- package/src/utils/__tests__/assert.test.ts +17 -0
- package/src/utils/__tests__/env-parse.test.ts +54 -0
- package/src/utils/assert.ts +18 -0
- package/src/utils/env-parse.ts +16 -0
- package/src/utils/ids.ts +16 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/safe-json.ts +30 -0
- package/src/utils/serialization.ts +7 -0
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
import { and, asc, desc, eq, gt, inArray, lt, ne, type SQL, sql } from "drizzle-orm";
|
|
2
|
+
import { requestContext } from "../api/request-context";
|
|
3
|
+
import { checkWriteFieldOwnership } from "../engine/field-access";
|
|
4
|
+
import {
|
|
5
|
+
buildOwnershipClause,
|
|
6
|
+
userCanCreateFieldRow,
|
|
7
|
+
userCanWriteFieldRow,
|
|
8
|
+
} from "../engine/ownership";
|
|
9
|
+
import type {
|
|
10
|
+
DeleteContext,
|
|
11
|
+
EntityDefinition,
|
|
12
|
+
EntityId,
|
|
13
|
+
FieldDefinition,
|
|
14
|
+
SaveContext,
|
|
15
|
+
SessionUser,
|
|
16
|
+
WriteResult,
|
|
17
|
+
} from "../engine/types";
|
|
18
|
+
import {
|
|
19
|
+
VersionConflictError as FrameworkVersionConflict,
|
|
20
|
+
InternalError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
UniqueViolationError,
|
|
23
|
+
UnprocessableError,
|
|
24
|
+
type WriteFailure,
|
|
25
|
+
writeFailure,
|
|
26
|
+
} from "../errors";
|
|
27
|
+
import {
|
|
28
|
+
append,
|
|
29
|
+
type EventMetadata,
|
|
30
|
+
VersionConflictError as EventStoreVersionConflict,
|
|
31
|
+
getStreamVersion,
|
|
32
|
+
} from "../event-store";
|
|
33
|
+
import type { EntityCache } from "../pipeline/entity-cache";
|
|
34
|
+
import type { SearchAdapter } from "../search/types";
|
|
35
|
+
import { generateId } from "../utils";
|
|
36
|
+
import { applyEntityEvent } from "./apply-entity-event";
|
|
37
|
+
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
38
|
+
import type { DbRow } from "./connection";
|
|
39
|
+
import { decodeCursor, encodeCursor } from "./cursor";
|
|
40
|
+
import type { TableColumns } from "./dialect";
|
|
41
|
+
import type { CursorResult } from "./index";
|
|
42
|
+
import { constraintOf, isUniqueViolation } from "./pg-error";
|
|
43
|
+
import type { TenantDb } from "./tenant-db";
|
|
44
|
+
|
|
45
|
+
// biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables
|
|
46
|
+
type Table = TableColumns<any>;
|
|
47
|
+
|
|
48
|
+
// Screen-Filter (Tier 2.7c) — Op-Mapping zur Drizzle-WHERE-Clause.
|
|
49
|
+
// Lebt isoliert hier (statt inline im list-Body) damit der einzige
|
|
50
|
+
// Wire-Boundary `as never`-Cast lokal bleibt: payload.filter.value ist
|
|
51
|
+
// `unknown` (Wire-Boundary), Drizzle's eq/ne/lt/gt/inArray verlangen
|
|
52
|
+
// den Column-Type. Type-Mismatch wirft erst der PostgreSQL-Driver zur
|
|
53
|
+
// Laufzeit; Author hat über `filterable: true` + Boot-Validator op-
|
|
54
|
+
// vs-Type-Compat ohnehin Kontrolle was reinkommt.
|
|
55
|
+
//
|
|
56
|
+
// Empty-array IN ist explizit "no match" (SQL false), nicht "match all".
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: Drizzle-Column ist generic; siehe oben.
|
|
58
|
+
function buildFilterCondition(col: any, op: "eq" | "ne" | "lt" | "gt" | "in", value: unknown): SQL {
|
|
59
|
+
switch (op) {
|
|
60
|
+
case "eq":
|
|
61
|
+
return eq(col, value as never); // @cast-boundary db-operator
|
|
62
|
+
case "ne":
|
|
63
|
+
return ne(col, value as never); // @cast-boundary db-operator
|
|
64
|
+
case "lt":
|
|
65
|
+
return lt(col, value as never); // @cast-boundary db-operator
|
|
66
|
+
case "gt":
|
|
67
|
+
return gt(col, value as never); // @cast-boundary db-operator
|
|
68
|
+
case "in":
|
|
69
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
70
|
+
return inArray(col, value as never); // @cast-boundary db-operator
|
|
71
|
+
}
|
|
72
|
+
return sql`false`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Returns the scalar default of a field, or undefined if the field's type
|
|
77
|
+
// doesn't carry a default or no default was declared. Only scalar types
|
|
78
|
+
// (text/number/boolean/select) support creation-time defaults — money/date/
|
|
79
|
+
// file/embedded fields don't.
|
|
80
|
+
function scalarDefault(field: FieldDefinition): unknown {
|
|
81
|
+
switch (field.type) {
|
|
82
|
+
case "text":
|
|
83
|
+
case "longText":
|
|
84
|
+
case "number":
|
|
85
|
+
case "boolean":
|
|
86
|
+
case "select":
|
|
87
|
+
return field.default;
|
|
88
|
+
default:
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Lifecycle verbs the event-store-executor auto-emits. MSPs that react
|
|
94
|
+
// to entity creates/updates/etc should reference this helper instead of
|
|
95
|
+
// hardcoding the string — a future rename in the executor then surfaces
|
|
96
|
+
// as a type error at every call site rather than a silent miss.
|
|
97
|
+
export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored";
|
|
98
|
+
|
|
99
|
+
export function entityEventName(entityName: string, verb: EntityLifecycleVerb): string {
|
|
100
|
+
return `${entityName}.${verb}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type EventStoreExecutorOptions = {
|
|
104
|
+
searchAdapter?: SearchAdapter;
|
|
105
|
+
entityName: string; // required — the aggregateType marker on every event
|
|
106
|
+
entityCache?: EntityCache;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
|
|
110
|
+
// (create + update Pfade) → WriteFailure(UniqueViolationError 409).
|
|
111
|
+
// Andere Errors propagieren via re-throw. Lokal extrahiert weil das
|
|
112
|
+
// Pattern an zwei Stellen im executor lebt — der Caller wrap't den
|
|
113
|
+
// applyEntityEvent-call in try-catch und delegiert das Mapping hierher.
|
|
114
|
+
//
|
|
115
|
+
// Returns WriteFailure on match, null otherwise (caller re-throws).
|
|
116
|
+
function tryMapUniqueViolation(e: unknown, entityName: string): WriteFailure | null {
|
|
117
|
+
if (!isUniqueViolation(e)) return null;
|
|
118
|
+
const constraintName = constraintOf(e);
|
|
119
|
+
return writeFailure(
|
|
120
|
+
new UniqueViolationError(
|
|
121
|
+
{
|
|
122
|
+
entityName,
|
|
123
|
+
...(constraintName !== undefined && { constraintName }),
|
|
124
|
+
},
|
|
125
|
+
{ cause: e instanceof Error ? e : undefined },
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Build the metadata envelope for an append. userId always set; requestId +
|
|
131
|
+
// correlation + causation come from the AsyncLocalStorage request-context
|
|
132
|
+
// when present (e.g. HTTP request, MSP-apply, job run). requestId is a pure
|
|
133
|
+
// trace marker — HTTP-level retry idempotency runs separately via
|
|
134
|
+
// pipeline/idempotency.ts (Redis-cached response replay), so a single
|
|
135
|
+
// request can write N events freely without the events-table needing a
|
|
136
|
+
// uniqueness constraint.
|
|
137
|
+
function buildEventMetadata(user: SessionUser): EventMetadata {
|
|
138
|
+
const reqCtx = requestContext.get();
|
|
139
|
+
return {
|
|
140
|
+
userId: String(user.id),
|
|
141
|
+
...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
|
|
142
|
+
...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
|
|
143
|
+
...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The executor writes events + auto-projection (entity table) in one TX.
|
|
148
|
+
// It no longer knows about user projections — those are driven by the
|
|
149
|
+
// pipeline, which reads the StoredEvent surfaced on SaveContext/DeleteContext
|
|
150
|
+
// and iterates the registry itself. Executor-level `registry` options were
|
|
151
|
+
// removed to close the silent-bypass hole where a caller forgetting to pass
|
|
152
|
+
// one would skip projections without any signal.
|
|
153
|
+
export type EventStoreExecutor = {
|
|
154
|
+
create: (
|
|
155
|
+
payload: Record<string, unknown>,
|
|
156
|
+
user: SessionUser,
|
|
157
|
+
db: TenantDb,
|
|
158
|
+
) => Promise<WriteResult<SaveContext>>;
|
|
159
|
+
|
|
160
|
+
update: (
|
|
161
|
+
payload: { id: EntityId; version?: number | undefined; changes: Record<string, unknown> },
|
|
162
|
+
user: SessionUser,
|
|
163
|
+
db: TenantDb,
|
|
164
|
+
options?: { skipOptimisticLock?: boolean },
|
|
165
|
+
) => Promise<WriteResult<SaveContext>>;
|
|
166
|
+
|
|
167
|
+
delete: (
|
|
168
|
+
payload: { id: EntityId },
|
|
169
|
+
user: SessionUser,
|
|
170
|
+
db: TenantDb,
|
|
171
|
+
) => Promise<WriteResult<DeleteContext>>;
|
|
172
|
+
|
|
173
|
+
restore: (
|
|
174
|
+
payload: { id: EntityId },
|
|
175
|
+
user: SessionUser,
|
|
176
|
+
db: TenantDb,
|
|
177
|
+
) => Promise<WriteResult<SaveContext>>;
|
|
178
|
+
|
|
179
|
+
list: (
|
|
180
|
+
payload: {
|
|
181
|
+
cursor?: string | undefined;
|
|
182
|
+
limit?: number | undefined;
|
|
183
|
+
search?: string | undefined;
|
|
184
|
+
sort?: string | undefined;
|
|
185
|
+
sortDirection?: "asc" | "desc" | undefined;
|
|
186
|
+
offset?: number | undefined;
|
|
187
|
+
totalCount?: boolean | undefined;
|
|
188
|
+
filter?:
|
|
189
|
+
| {
|
|
190
|
+
readonly field: string;
|
|
191
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
192
|
+
readonly value: unknown;
|
|
193
|
+
}
|
|
194
|
+
| undefined;
|
|
195
|
+
},
|
|
196
|
+
user: SessionUser,
|
|
197
|
+
db: TenantDb,
|
|
198
|
+
/** Tier 2.7e Audit-Fix: per-Call SearchAdapter Override. Wenn der
|
|
199
|
+
* Executor beim Build keinen SearchAdapter via Options bekommen
|
|
200
|
+
* hat (defaultEntityQueryHandler-Pfad), kann der Caller (Handler)
|
|
201
|
+
* hier zur Runtime einen aus ctx.searchAdapter durchreichen.
|
|
202
|
+
* options.searchAdapter (build-time) gewinnt — runtime-Override
|
|
203
|
+
* ist Fallback für die default-Wrapper. */
|
|
204
|
+
runtimeOptions?: { readonly searchAdapter?: SearchAdapter },
|
|
205
|
+
) => Promise<CursorResult<Record<string, unknown>>>;
|
|
206
|
+
|
|
207
|
+
detail: (
|
|
208
|
+
payload: { id: EntityId },
|
|
209
|
+
user: SessionUser,
|
|
210
|
+
db: TenantDb,
|
|
211
|
+
) => Promise<Record<string, unknown> | null>;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export function createEventStoreExecutor(
|
|
215
|
+
table: Table,
|
|
216
|
+
entity: EntityDefinition,
|
|
217
|
+
options: EventStoreExecutorOptions,
|
|
218
|
+
): EventStoreExecutor {
|
|
219
|
+
const { searchAdapter, entityName, entityCache } = options;
|
|
220
|
+
const softDelete = entity.softDelete ?? false;
|
|
221
|
+
|
|
222
|
+
// idType default (undefined) is now "uuid" — the ES-pivot made UUID the
|
|
223
|
+
// only valid aggregate-id type. Explicit `idType: "serial"` is the only
|
|
224
|
+
// shape that's incompatible with the event-store and still rejected.
|
|
225
|
+
if (entity.idType !== undefined && entity.idType !== "uuid") {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`event-store-executor requires entity "${entityName}" to declare idType: "uuid" — ` +
|
|
228
|
+
`got idType: "${entity.idType}". ` +
|
|
229
|
+
`The events-table keys aggregates by uuid(aggregate_id); non-UUID PKs would ` +
|
|
230
|
+
`require a schema split the framework does not currently support. ` +
|
|
231
|
+
`Fix: remove the \`idType\`-override from createEntity({...}) for "${entityName}" ` +
|
|
232
|
+
`(the default is "uuid"). The framework auto-assigns UUIDs on create — ` +
|
|
233
|
+
`you do not need to generate them yourself. ` +
|
|
234
|
+
`See docs/plans/architecture/event-sourcing-pivot.md (section "UUID-only aggregate IDs") for the full rationale.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Pre-compute defaults once so create() doesn't loop the entity every call.
|
|
239
|
+
const fieldDefaults: Record<string, unknown> = {};
|
|
240
|
+
for (const [name, field] of Object.entries(entity.fields)) {
|
|
241
|
+
const def = scalarDefault(field);
|
|
242
|
+
if (def !== undefined) fieldDefaults[name] = def;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Pre-compute the set of sensitive field names once. Every event payload
|
|
246
|
+
// (create data, update changes + previous, delete previous, restore
|
|
247
|
+
// previous) strips these before writing to the immutable event log. Keeps
|
|
248
|
+
// GDPR right-to-be-forgotten tractable — only entity rows hold the
|
|
249
|
+
// sensitive data, and entity rows can be deleted / re-encrypted.
|
|
250
|
+
const sensitiveFields = new Set<string>();
|
|
251
|
+
for (const [name, field] of Object.entries(entity.fields)) {
|
|
252
|
+
if ("sensitive" in field && field.sensitive === true) {
|
|
253
|
+
sensitiveFields.add(name);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
|
|
258
|
+
if (Object.keys(fieldDefaults).length === 0) return payload;
|
|
259
|
+
const result: Record<string, unknown> = { ...payload };
|
|
260
|
+
for (const [name, def] of Object.entries(fieldDefaults)) {
|
|
261
|
+
if (result[name] === undefined) result[name] = def;
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function stripSensitive(payload: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
267
|
+
if (!payload) return {};
|
|
268
|
+
if (sensitiveFields.size === 0) return payload;
|
|
269
|
+
const result: Record<string, unknown> = {};
|
|
270
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
271
|
+
if (sensitiveFields.has(key)) continue;
|
|
272
|
+
result[key] = value;
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function idFilter(id: EntityId) {
|
|
278
|
+
const conditions = [eq(table["id"], id)];
|
|
279
|
+
if (softDelete && table["isDeleted"]) {
|
|
280
|
+
conditions.push(eq(table["isDeleted"], false));
|
|
281
|
+
}
|
|
282
|
+
// Drizzle's variadic `and()` is typed `SQL | undefined`; conditions is
|
|
283
|
+
// guaranteed non-empty above (we pushed at least one).
|
|
284
|
+
return and(...conditions) as SQL;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
|
|
288
|
+
const [row] = await db.select().from(table).where(idFilter(id));
|
|
289
|
+
if (!row) return null;
|
|
290
|
+
return rehydrateCompoundTypes(row as DbRow, entity);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
async create(payload, user, db) {
|
|
295
|
+
// Respect an explicit id in the payload (seed pattern, SCIM import). Without
|
|
296
|
+
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
297
|
+
// event payload so defaults + downstream consumers don't see a redundant id field.
|
|
298
|
+
const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined;
|
|
299
|
+
const aggregateId = explicitId ?? generateId();
|
|
300
|
+
const { id: _id, ...payloadWithoutId } = payload;
|
|
301
|
+
const data = applyDefaults(payloadWithoutId);
|
|
302
|
+
|
|
303
|
+
// H.2 — entity-level write-ownership on create. No oldRow exists, so
|
|
304
|
+
// only the new row is checked. No Straddle concern for creates.
|
|
305
|
+
if (!userCanCreateFieldRow(user, entity.access?.write, data)) {
|
|
306
|
+
return writeFailure(
|
|
307
|
+
new UnprocessableError("ownership_denied", {
|
|
308
|
+
i18nKey: "errors.ownershipDenied",
|
|
309
|
+
details: { scope: "entity", entityName, action: "create", userId: user.id },
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Field-level write-ownership on create — mirror of entity-level but
|
|
315
|
+
// per declared field. Role-level was already checked by the
|
|
316
|
+
// dispatcher; here we enforce ownership-rules against the new row.
|
|
317
|
+
const fieldDeniedCreate = checkWriteFieldOwnership(entity, data, user);
|
|
318
|
+
if (fieldDeniedCreate) {
|
|
319
|
+
return writeFailure(
|
|
320
|
+
new UnprocessableError("ownership_denied", {
|
|
321
|
+
i18nKey: "errors.ownershipDenied",
|
|
322
|
+
details: {
|
|
323
|
+
scope: "field",
|
|
324
|
+
entityName,
|
|
325
|
+
action: "create",
|
|
326
|
+
field: fieldDeniedCreate,
|
|
327
|
+
userId: user.id,
|
|
328
|
+
},
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
|
|
334
|
+
// dieselbe Pipeline. Caller schickt combined API-Form, Framework
|
|
335
|
+
// speichert flat DB-Form. Siehe db/compound-types.ts.
|
|
336
|
+
const flatData = flattenCompoundTypes(data, entity);
|
|
337
|
+
|
|
338
|
+
// 1. Append event (same TX as the projection write — both must succeed
|
|
339
|
+
// or both roll back; the dispatcher wraps both in one transaction).
|
|
340
|
+
// Sensitive fields are stripped from the event payload; the entity
|
|
341
|
+
// row below still receives the full data.
|
|
342
|
+
//
|
|
343
|
+
// `expectedVersion: 0` heißt: stream existiert noch nicht. Bei
|
|
344
|
+
// deterministic-aggregate-id-Patterns (z.B. uuidv5(tenantId|naturalKey))
|
|
345
|
+
// ist es legitim dass create kollidiert — selbe id, schon vorhandener
|
|
346
|
+
// stream → version_conflict statt internal_error. Update hat den
|
|
347
|
+
// selben catch (siehe line 493+).
|
|
348
|
+
let event: Awaited<ReturnType<typeof append>>;
|
|
349
|
+
try {
|
|
350
|
+
event = await append(db.raw, {
|
|
351
|
+
aggregateId,
|
|
352
|
+
aggregateType: entityName,
|
|
353
|
+
tenantId: user.tenantId,
|
|
354
|
+
expectedVersion: 0,
|
|
355
|
+
type: entityEventName(entityName, "created"),
|
|
356
|
+
payload: stripSensitive(flatData),
|
|
357
|
+
metadata: buildEventMetadata(user),
|
|
358
|
+
});
|
|
359
|
+
} catch (e) {
|
|
360
|
+
if (e instanceof EventStoreVersionConflict) {
|
|
361
|
+
// Try to look up the real stream-version for the diagnostic — but
|
|
362
|
+
// wrap defensively: when `append` raised the unique-violation, the
|
|
363
|
+
// current TX is already aborted, and a second query on the same
|
|
364
|
+
// runner would re-throw "current transaction is aborted". Update-
|
|
365
|
+
// path doesn't have this problem (it queries getStreamVersion
|
|
366
|
+
// BEFORE the try-block). Falling back to a sentinel keeps the
|
|
367
|
+
// version_conflict mapping reliable; the actual current version
|
|
368
|
+
// is recoverable client-side via a fresh detail-query if needed.
|
|
369
|
+
let currentVersion = -1;
|
|
370
|
+
try {
|
|
371
|
+
currentVersion = await getStreamVersion(db.raw, aggregateId, user.tenantId);
|
|
372
|
+
} catch {
|
|
373
|
+
// Aborted TX or any lookup failure — keep the sentinel.
|
|
374
|
+
}
|
|
375
|
+
return writeFailure(
|
|
376
|
+
new FrameworkVersionConflict({
|
|
377
|
+
entityId: aggregateId,
|
|
378
|
+
expectedVersion: 0,
|
|
379
|
+
currentVersion,
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
throw e;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 2. Update projection via applyEntityEvent — derselbe Code-Pfad den
|
|
387
|
+
// rebuildProjection für Replay nutzt → Live==Rebuild by-construction.
|
|
388
|
+
// Wir bauen ein "live event" mit unstripped flatData (damit sensitive
|
|
389
|
+
// Felder in der Read-Tabelle landen, aber nicht im Event-Log).
|
|
390
|
+
//
|
|
391
|
+
// F8-Patch: app-level unique-violations (z.B. (tenantId, email)
|
|
392
|
+
// auf User-Entity, (tenantId, slug) auf Article) werfen pg-23505
|
|
393
|
+
// aus der projection-INSERT. Ohne den catch propagiert das als
|
|
394
|
+
// unhandled exception → 500 internal_error. Map auf
|
|
395
|
+
// UniqueViolationError 409 damit Designer/Frontend einen sauberen
|
|
396
|
+
// "duplicate" zeigen können statt cryptic "internal server error".
|
|
397
|
+
const liveEvent = { ...event, payload: flatData };
|
|
398
|
+
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
399
|
+
try {
|
|
400
|
+
result = await applyEntityEvent(liveEvent, table, entity, db.raw);
|
|
401
|
+
} catch (e) {
|
|
402
|
+
const mapped = tryMapUniqueViolation(e, entityName);
|
|
403
|
+
if (mapped) return mapped;
|
|
404
|
+
throw e;
|
|
405
|
+
}
|
|
406
|
+
if (result.kind !== "applied" || result.row === null) {
|
|
407
|
+
return writeFailure(new InternalError({ message: "projection insert returned no row" }));
|
|
408
|
+
}
|
|
409
|
+
const row = result.row;
|
|
410
|
+
// Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
|
|
411
|
+
// Compound-Types in einem Pass.
|
|
412
|
+
const projection = rehydrateCompoundTypes(row as DbRow, entity) as DbRow;
|
|
413
|
+
|
|
414
|
+
if (entityCache && entityName) {
|
|
415
|
+
await entityCache.del(user.tenantId, entityName, aggregateId);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
isSuccess: true,
|
|
420
|
+
data: {
|
|
421
|
+
kind: "save",
|
|
422
|
+
id: aggregateId,
|
|
423
|
+
data: projection,
|
|
424
|
+
changes: data,
|
|
425
|
+
previous: {},
|
|
426
|
+
isNew: true,
|
|
427
|
+
entityName,
|
|
428
|
+
event,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
async update(payload, user, db, updateOptions) {
|
|
434
|
+
const previous = await loadById(payload.id, db);
|
|
435
|
+
if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
436
|
+
|
|
437
|
+
// H.2 — entity-level write-ownership on update. Load old row (already
|
|
438
|
+
// done above), build post-change row via shallow merge. Straddle-safe
|
|
439
|
+
// multi-role check: at least one role must accept BOTH old and new —
|
|
440
|
+
// prevents the attack where role A passes old, role B passes new and
|
|
441
|
+
// aggregation would wrongly allow a row-grab.
|
|
442
|
+
const mergedNew: Record<string, unknown> = { ...previous, ...payload.changes };
|
|
443
|
+
if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
|
|
444
|
+
return writeFailure(
|
|
445
|
+
new UnprocessableError("ownership_denied", {
|
|
446
|
+
i18nKey: "errors.ownershipDenied",
|
|
447
|
+
details: {
|
|
448
|
+
scope: "entity",
|
|
449
|
+
entityName,
|
|
450
|
+
action: "update",
|
|
451
|
+
userId: user.id,
|
|
452
|
+
entityId: payload.id,
|
|
453
|
+
},
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Field-level write-ownership on update — this is the path the
|
|
459
|
+
// dispatcher could not evaluate (no oldRow). Now that we have
|
|
460
|
+
// `previous`, we can run the ownership rules per field against both
|
|
461
|
+
// sides and reject individual fields the user isn't entitled to
|
|
462
|
+
// touch on this specific row.
|
|
463
|
+
const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, user, previous);
|
|
464
|
+
if (fieldDeniedUpdate) {
|
|
465
|
+
return writeFailure(
|
|
466
|
+
new UnprocessableError("ownership_denied", {
|
|
467
|
+
i18nKey: "errors.ownershipDenied",
|
|
468
|
+
details: {
|
|
469
|
+
scope: "field",
|
|
470
|
+
entityName,
|
|
471
|
+
action: "update",
|
|
472
|
+
field: fieldDeniedUpdate,
|
|
473
|
+
userId: user.id,
|
|
474
|
+
entityId: payload.id,
|
|
475
|
+
},
|
|
476
|
+
}),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Stream-version is authoritative, not row.version. `ctx.appendEvent`
|
|
481
|
+
// can bump the stream between CRUD writes (domain event on the same
|
|
482
|
+
// aggregate); a stale row.version here would make the next CRUD write
|
|
483
|
+
// trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
|
|
484
|
+
// with version_conflict.
|
|
485
|
+
const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
|
|
486
|
+
if (!updateOptions?.skipOptimisticLock) {
|
|
487
|
+
if (payload.version === undefined) {
|
|
488
|
+
return writeFailure(
|
|
489
|
+
new FrameworkVersionConflict({
|
|
490
|
+
entityId: payload.id,
|
|
491
|
+
expectedVersion: 0,
|
|
492
|
+
currentVersion,
|
|
493
|
+
}),
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (currentVersion !== payload.version) {
|
|
497
|
+
return writeFailure(
|
|
498
|
+
new FrameworkVersionConflict({
|
|
499
|
+
entityId: payload.id,
|
|
500
|
+
expectedVersion: payload.version,
|
|
501
|
+
currentVersion,
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
// Compound-Types Auto-Convert (alle in einem Pass).
|
|
509
|
+
const flatChanges = flattenCompoundTypes(payload.changes, entity);
|
|
510
|
+
|
|
511
|
+
// The event payload carries BOTH `changes` (what the user asked for) AND
|
|
512
|
+
// `previous` (the pre-update row). Cross-aggregate projections need the
|
|
513
|
+
// previous value to decrement/undo when a parent-FK moves — without it
|
|
514
|
+
// you'd have to snapshot-and-diff on every apply, and replays would
|
|
515
|
+
// break. Storage cost is acceptable (rows are bounded), correctness is
|
|
516
|
+
// not negotiable. Sensitive fields are stripped from BOTH halves so
|
|
517
|
+
// they never reach the immutable event log.
|
|
518
|
+
const event = await append(db.raw, {
|
|
519
|
+
aggregateId: String(payload.id),
|
|
520
|
+
aggregateType: entityName,
|
|
521
|
+
tenantId: user.tenantId,
|
|
522
|
+
expectedVersion: currentVersion,
|
|
523
|
+
type: entityEventName(entityName, "updated"),
|
|
524
|
+
payload: {
|
|
525
|
+
changes: stripSensitive(flatChanges),
|
|
526
|
+
previous: stripSensitive(previous),
|
|
527
|
+
},
|
|
528
|
+
metadata: buildEventMetadata(user),
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Live==Rebuild via applyEntityEvent: live-event mit unstripped
|
|
532
|
+
// flatChanges damit sensitive Felder in der Read-Tabelle landen.
|
|
533
|
+
//
|
|
534
|
+
// F8-Patch: dasselbe unique-violation-handling wie im create-Pfad
|
|
535
|
+
// — ein update das einen unique-Index verletzt (z.B. email-update
|
|
536
|
+
// auf einen schon-existierenden Wert) wird mit 409 unique_violation
|
|
537
|
+
// statt 500 internal_error rückgemeldet.
|
|
538
|
+
const liveEvent = {
|
|
539
|
+
...event,
|
|
540
|
+
payload: { changes: flatChanges, previous },
|
|
541
|
+
};
|
|
542
|
+
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
543
|
+
try {
|
|
544
|
+
result = await applyEntityEvent(liveEvent, table, entity, db.raw);
|
|
545
|
+
} catch (e) {
|
|
546
|
+
const mapped = tryMapUniqueViolation(e, entityName);
|
|
547
|
+
if (mapped) return mapped;
|
|
548
|
+
throw e;
|
|
549
|
+
}
|
|
550
|
+
if (result.kind !== "applied" || result.row === null) {
|
|
551
|
+
return writeFailure(new InternalError({ message: "projection update returned no row" }));
|
|
552
|
+
}
|
|
553
|
+
const row = result.row;
|
|
554
|
+
const data = rehydrateCompoundTypes(row as DbRow, entity) as DbRow;
|
|
555
|
+
|
|
556
|
+
if (entityCache && entityName) {
|
|
557
|
+
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
isSuccess: true,
|
|
562
|
+
data: {
|
|
563
|
+
kind: "save",
|
|
564
|
+
id: data["id"] as EntityId,
|
|
565
|
+
data,
|
|
566
|
+
changes: payload.changes,
|
|
567
|
+
previous,
|
|
568
|
+
isNew: false,
|
|
569
|
+
entityName,
|
|
570
|
+
event,
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
} catch (e) {
|
|
574
|
+
// The pre-check above eliminates the common stale-version case; this
|
|
575
|
+
// branch catches the narrow race where two writers both read version=N
|
|
576
|
+
// and both pass the local check — the unique index on (aggregate_id,
|
|
577
|
+
// version) serializes them, one wins, the other lands here.
|
|
578
|
+
if (e instanceof EventStoreVersionConflict) {
|
|
579
|
+
return writeFailure(
|
|
580
|
+
new FrameworkVersionConflict({
|
|
581
|
+
entityId: payload.id,
|
|
582
|
+
expectedVersion: payload.version ?? 0,
|
|
583
|
+
currentVersion,
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
throw e;
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
async delete(payload, user, db) {
|
|
592
|
+
const existing = await loadById(payload.id, db);
|
|
593
|
+
if (!existing) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
594
|
+
|
|
595
|
+
// H.2 — entity-level write-ownership on delete. Only the pre-delete
|
|
596
|
+
// row matters (there's no "new" row for a delete); passing existing
|
|
597
|
+
// twice to userCanWriteFieldRow makes the Straddle check trivial
|
|
598
|
+
// (same row on both sides) while keeping the multi-role-atomic shape.
|
|
599
|
+
if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
|
|
600
|
+
return writeFailure(
|
|
601
|
+
new UnprocessableError("ownership_denied", {
|
|
602
|
+
i18nKey: "errors.ownershipDenied",
|
|
603
|
+
details: {
|
|
604
|
+
scope: "entity",
|
|
605
|
+
entityName,
|
|
606
|
+
action: "delete",
|
|
607
|
+
userId: user.id,
|
|
608
|
+
entityId: payload.id,
|
|
609
|
+
},
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Stream-version authoritative (see update() for rationale).
|
|
615
|
+
const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
|
|
616
|
+
|
|
617
|
+
// Deletes carry the full pre-delete row as `previous`. That's what
|
|
618
|
+
// projections and downstream consumers need to reverse any aggregates —
|
|
619
|
+
// a `{}`-payload delete would make cross-aggregate projections impossible
|
|
620
|
+
// to rebuild from the event log alone. Sensitive fields are stripped.
|
|
621
|
+
const event = await append(db.raw, {
|
|
622
|
+
aggregateId: String(payload.id),
|
|
623
|
+
aggregateType: entityName,
|
|
624
|
+
tenantId: user.tenantId,
|
|
625
|
+
expectedVersion: currentVersion,
|
|
626
|
+
type: entityEventName(entityName, "deleted"),
|
|
627
|
+
payload: { previous: stripSensitive(existing) },
|
|
628
|
+
metadata: buildEventMetadata(user),
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// Live==Rebuild via applyEntityEvent. Delete-Operation hat keine
|
|
632
|
+
// sensitive-Drift weil das Event-Payload nur `previous` ist und das
|
|
633
|
+
// wird vom soft/hard-delete-Code gar nicht in die Tabelle geschrieben
|
|
634
|
+
// (nur isDeleted/deletedAt/version-Bump). Live + Replay schreiben
|
|
635
|
+
// dasselbe — kein payload-override nötig.
|
|
636
|
+
const deleteResult = await applyEntityEvent(event, table, entity, db.raw);
|
|
637
|
+
if (deleteResult.kind !== "applied") {
|
|
638
|
+
return writeFailure(
|
|
639
|
+
new InternalError({ message: "projection delete: applyEntityEvent skipped" }),
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (entityCache && entityName) {
|
|
644
|
+
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return {
|
|
648
|
+
isSuccess: true,
|
|
649
|
+
data: { kind: "delete", id: payload.id, data: existing, entityName, event },
|
|
650
|
+
};
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
async restore(payload, user, db) {
|
|
654
|
+
if (!softDelete) {
|
|
655
|
+
return writeFailure(
|
|
656
|
+
new UnprocessableError("soft_delete_not_enabled", {
|
|
657
|
+
i18nKey: "errors.softDeleteNotEnabled",
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const [row] = await db.select().from(table).where(eq(table["id"], payload.id));
|
|
663
|
+
if (!row) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
664
|
+
const data = row as DbRow;
|
|
665
|
+
if (!data["isDeleted"]) {
|
|
666
|
+
return writeFailure(
|
|
667
|
+
new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" }),
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// H.2 — entity-level write-ownership on restore. Same shape as delete:
|
|
672
|
+
// only the stored row matters. Stored row carries pre-soft-delete
|
|
673
|
+
// teamId/... fields, so the ownership predicate still applies cleanly.
|
|
674
|
+
if (!userCanWriteFieldRow(user, entity.access?.write, data, data)) {
|
|
675
|
+
return writeFailure(
|
|
676
|
+
new UnprocessableError("ownership_denied", {
|
|
677
|
+
i18nKey: "errors.ownershipDenied",
|
|
678
|
+
details: {
|
|
679
|
+
scope: "entity",
|
|
680
|
+
entityName,
|
|
681
|
+
action: "restore",
|
|
682
|
+
userId: user.id,
|
|
683
|
+
entityId: payload.id,
|
|
684
|
+
},
|
|
685
|
+
}),
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Stream-version authoritative (see update() for rationale).
|
|
690
|
+
const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
|
|
691
|
+
// Restore carries the soft-deleted snapshot as `previous` — mirror of
|
|
692
|
+
// delete for symmetry. Projections that decremented on delete use
|
|
693
|
+
// `previous` to re-increment on restore without re-querying the entity
|
|
694
|
+
// table. Sensitive fields are stripped.
|
|
695
|
+
const event = await append(db.raw, {
|
|
696
|
+
aggregateId: String(payload.id),
|
|
697
|
+
aggregateType: entityName,
|
|
698
|
+
tenantId: user.tenantId,
|
|
699
|
+
expectedVersion: currentVersion,
|
|
700
|
+
type: entityEventName(entityName, "restored"),
|
|
701
|
+
payload: { previous: stripSensitive(data) },
|
|
702
|
+
metadata: buildEventMetadata(user),
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// Live==Rebuild via applyEntityEvent. Restore schreibt nur isDeleted=
|
|
706
|
+
// false + version-Bump in die Tabelle — keine sensitive-Drift, daher
|
|
707
|
+
// kein payload-override nötig.
|
|
708
|
+
const restoreResult = await applyEntityEvent(event, table, entity, db.raw);
|
|
709
|
+
if (restoreResult.kind !== "applied" || restoreResult.row === null) {
|
|
710
|
+
return writeFailure(new InternalError({ message: "projection restore returned no row" }));
|
|
711
|
+
}
|
|
712
|
+
const restored = restoreResult.row;
|
|
713
|
+
|
|
714
|
+
if (entityCache && entityName) {
|
|
715
|
+
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Read-Side Auto-Convert für Compound-Types (parallel zu update/list).
|
|
719
|
+
const restoredHydrated = rehydrateCompoundTypes(restored as DbRow, entity) as DbRow;
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
isSuccess: true,
|
|
723
|
+
data: {
|
|
724
|
+
kind: "save",
|
|
725
|
+
id: payload.id,
|
|
726
|
+
data: restoredHydrated,
|
|
727
|
+
changes: { isDeleted: false },
|
|
728
|
+
previous: data,
|
|
729
|
+
isNew: false,
|
|
730
|
+
entityName,
|
|
731
|
+
event,
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
},
|
|
735
|
+
|
|
736
|
+
// list + detail are unchanged from crud-executor — projections are the
|
|
737
|
+
// read-model and serve these queries directly.
|
|
738
|
+
async list(payload, user, db, runtimeOptions) {
|
|
739
|
+
const limit = payload.limit ?? 50;
|
|
740
|
+
const offset = payload.offset ?? 0;
|
|
741
|
+
const totalCount = payload.totalCount === true;
|
|
742
|
+
|
|
743
|
+
// H.2 — entity-level read ownership. Decide before touching search or
|
|
744
|
+
// the DB: `empty` means there's no row the user could ever see, so
|
|
745
|
+
// skip both paths and return an empty page.
|
|
746
|
+
const ownership = buildOwnershipClause(user, entity.access?.read, table);
|
|
747
|
+
if (ownership.kind === "empty") {
|
|
748
|
+
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
let filterIds: EntityId[] | undefined;
|
|
752
|
+
// Build-Time options.searchAdapter gewinnt; runtime-Override ist
|
|
753
|
+
// Fallback für die defaultEntityQueryHandler-Pipe (die nutzt den
|
|
754
|
+
// ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
|
|
755
|
+
// beim Definition-Time noch keinen Server-Context hat).
|
|
756
|
+
const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
|
|
757
|
+
if (payload.search && effectiveSearchAdapter && entityName) {
|
|
758
|
+
const results = await effectiveSearchAdapter.search(user.tenantId, payload.search, {
|
|
759
|
+
filterType: entityName,
|
|
760
|
+
});
|
|
761
|
+
filterIds = results.map((r) => r.entityId);
|
|
762
|
+
if (filterIds.length === 0) {
|
|
763
|
+
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const conditions: SQL[] = [];
|
|
768
|
+
if (softDelete && table["isDeleted"]) {
|
|
769
|
+
conditions.push(eq(table["isDeleted"], false));
|
|
770
|
+
}
|
|
771
|
+
// Cursor und Offset schließen sich aus: Cursor ist DB-stable (gt id),
|
|
772
|
+
// Offset ist für klassische Page-Navigation. Wenn beide gesetzt sind,
|
|
773
|
+
// gewinnt Cursor — Caller hätte eh nicht gleichzeitig beide nutzen
|
|
774
|
+
// sollen, das pinnt die Verteidigung.
|
|
775
|
+
if (payload.cursor) {
|
|
776
|
+
conditions.push(gt(table["id"], decodeCursor(payload.cursor)));
|
|
777
|
+
}
|
|
778
|
+
if (filterIds) {
|
|
779
|
+
conditions.push(inArray(table["id"], filterIds));
|
|
780
|
+
}
|
|
781
|
+
if (ownership.kind === "sql") {
|
|
782
|
+
conditions.push(ownership.sql);
|
|
783
|
+
}
|
|
784
|
+
// Screen-Filter (Tier 2.7c) — Boot-Validator hat field-Existenz
|
|
785
|
+
// + filterable + op-vs-Type-Compat schon gepinnt. Runtime-Defense:
|
|
786
|
+
// undefined-column → silent skip (kein Crash). Op-Mapping läuft
|
|
787
|
+
// durch buildFilterCondition() — da lebt auch der einzige
|
|
788
|
+
// `as never`-Cast (Wire-Boundary).
|
|
789
|
+
if (payload.filter !== undefined) {
|
|
790
|
+
const col = table[payload.filter.field];
|
|
791
|
+
if (col !== undefined) {
|
|
792
|
+
conditions.push(buildFilterCondition(col, payload.filter.op, payload.filter.value));
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const whereClause = conditions.length > 0 ? (and(...conditions) as SQL) : undefined;
|
|
797
|
+
let query = whereClause
|
|
798
|
+
? db.select().from(table).where(whereClause)
|
|
799
|
+
: db.select().from(table);
|
|
800
|
+
|
|
801
|
+
query = query.limit(limit);
|
|
802
|
+
// Offset NUR wenn kein Cursor — sonst kombinieren wir zwei
|
|
803
|
+
// Pagination-Schemes und der Caller bekommt unverhoffte Skips.
|
|
804
|
+
if (!payload.cursor && offset > 0) {
|
|
805
|
+
query = query.offset(offset);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (payload.sort && table[payload.sort]) {
|
|
809
|
+
const column = table[payload.sort];
|
|
810
|
+
query =
|
|
811
|
+
payload.sortDirection === "desc"
|
|
812
|
+
? query.orderBy(desc(column))
|
|
813
|
+
: query.orderBy(asc(column));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const rawRows = (await query) as Record<string, unknown>[]; // @cast-boundary engine-payload
|
|
817
|
+
// Read-Side rehydrate pro Row. Cache speichert die hydrated Form,
|
|
818
|
+
// damit Cache-Hits dieselbe API-Form liefern.
|
|
819
|
+
const rows = rawRows.map((r) => rehydrateCompoundTypes(r, entity));
|
|
820
|
+
|
|
821
|
+
if (entityCache && entityName && rows.length > 0) {
|
|
822
|
+
await entityCache.mset(
|
|
823
|
+
user.tenantId,
|
|
824
|
+
entityName,
|
|
825
|
+
rows.map((r) => ({ id: r["id"] as EntityId, data: r })),
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const lastRow = rows[rows.length - 1];
|
|
830
|
+
const nextCursor =
|
|
831
|
+
rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null;
|
|
832
|
+
|
|
833
|
+
// total: extra COUNT(*) — nur wenn explizit angefordert (Pager-UI).
|
|
834
|
+
// Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
|
|
835
|
+
// wie der entsprechende WHERE — bei indexed columns billig genug.
|
|
836
|
+
// Bei Search-Path ist `total = filterIds.length` ohne extra Query.
|
|
837
|
+
let total: number | undefined;
|
|
838
|
+
if (totalCount) {
|
|
839
|
+
if (filterIds) {
|
|
840
|
+
total = filterIds.length;
|
|
841
|
+
} else {
|
|
842
|
+
const countQuery = whereClause
|
|
843
|
+
? db.select({ count: sql<number>`count(*)::int` }).from(table).where(whereClause)
|
|
844
|
+
: db.select({ count: sql<number>`count(*)::int` }).from(table);
|
|
845
|
+
const countRow = (await countQuery) as Array<{ count: number }>;
|
|
846
|
+
total = countRow[0]?.count ?? 0;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return { rows, nextCursor, ...(total !== undefined && { total }) };
|
|
851
|
+
},
|
|
852
|
+
|
|
853
|
+
async detail(payload, user, db) {
|
|
854
|
+
// H.2 — ownership check. `empty` → the user can never see this row
|
|
855
|
+
// regardless of its id. Return null (same shape as "not found", so a
|
|
856
|
+
// probing attacker can't distinguish "no access" from "doesn't exist").
|
|
857
|
+
const ownership = buildOwnershipClause(user, entity.access?.read, table);
|
|
858
|
+
if (ownership.kind === "empty") return null;
|
|
859
|
+
|
|
860
|
+
if (entityCache && entityName) {
|
|
861
|
+
const cached = await entityCache.get(user.tenantId, entityName, payload.id);
|
|
862
|
+
if (cached) {
|
|
863
|
+
// Even with a cache hit the ownership predicate must hold. The
|
|
864
|
+
// cache is keyed only by tenant + id, not by role, so a cached
|
|
865
|
+
// row may be visible to caller A but not caller B — re-check
|
|
866
|
+
// per request.
|
|
867
|
+
if (ownership.kind === "sql") {
|
|
868
|
+
// Reuse the clause by querying the row with it. Cheaper than
|
|
869
|
+
// SQL-parsing the predicate: just re-issue detail-by-id with
|
|
870
|
+
// the ownership-AND and see if the DB returns it. idFilter()
|
|
871
|
+
// handles the soft-delete guard.
|
|
872
|
+
const checked = await db
|
|
873
|
+
.select()
|
|
874
|
+
.from(table)
|
|
875
|
+
.where(and(idFilter(payload.id), ownership.sql) as SQL)
|
|
876
|
+
.limit(1);
|
|
877
|
+
if (checked.length === 0) return null;
|
|
878
|
+
}
|
|
879
|
+
return cached;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Cold path: load the row with the ownership predicate applied so the
|
|
884
|
+
// DB does the filtering (cheaper than load-then-filter-in-JS). Reuse
|
|
885
|
+
// idFilter() — it handles the soft-delete guard consistently with
|
|
886
|
+
// loadById(), which we can't just call directly because it doesn't
|
|
887
|
+
// thread the ownership clause.
|
|
888
|
+
const baseFilter = idFilter(payload.id);
|
|
889
|
+
const whereClause =
|
|
890
|
+
ownership.kind === "sql" ? (and(baseFilter, ownership.sql) as SQL) : baseFilter;
|
|
891
|
+
const rows = (await db.select().from(table).where(whereClause).limit(1)) as Record<
|
|
892
|
+
string,
|
|
893
|
+
unknown
|
|
894
|
+
>[];
|
|
895
|
+
const raw = rows[0];
|
|
896
|
+
if (!raw) return null;
|
|
897
|
+
const row = rehydrateCompoundTypes(raw, entity);
|
|
898
|
+
|
|
899
|
+
if (entityCache && entityName) {
|
|
900
|
+
await entityCache.set(user.tenantId, entityName, payload.id, row);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return row;
|
|
904
|
+
},
|
|
905
|
+
};
|
|
906
|
+
}
|