@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,210 @@
|
|
|
1
|
+
import { and, desc, eq, sql } from "drizzle-orm";
|
|
2
|
+
import type { DbConnection, DbRunner } from "../db/connection";
|
|
3
|
+
import {
|
|
4
|
+
index,
|
|
5
|
+
instant,
|
|
6
|
+
integer,
|
|
7
|
+
jsonb,
|
|
8
|
+
table as pgTable,
|
|
9
|
+
primaryKey,
|
|
10
|
+
text,
|
|
11
|
+
uuid,
|
|
12
|
+
} from "../db/dialect";
|
|
13
|
+
import { tableExists } from "../db/schema-inspection";
|
|
14
|
+
import type { TenantId } from "../engine/types";
|
|
15
|
+
import { pushTables } from "../stack";
|
|
16
|
+
import { isStreamArchived } from "./archive";
|
|
17
|
+
import { loadEventsAfterVersion, type StoredEvent } from "./event-store";
|
|
18
|
+
|
|
19
|
+
// Marten-aligned snapshot store. A snapshot is a point-in-time materialised
|
|
20
|
+
// state of an aggregate at a specific version, cached so rehydrating the
|
|
21
|
+
// aggregate doesn't require replaying every historical event.
|
|
22
|
+
//
|
|
23
|
+
// Read path (loadAggregateWithSnapshot):
|
|
24
|
+
// 1. isStreamArchived? → honour same semantics as loadAggregate
|
|
25
|
+
// 2. loadLatestSnapshot → state + version N (or null)
|
|
26
|
+
// 3. loadEventsAfterVersion(aggregate, N) → only the delta
|
|
27
|
+
// 4. reducer(snapshot, delta) → current state
|
|
28
|
+
//
|
|
29
|
+
// Write path: feature authors opt in via ctx.snapshotAggregate. Policy
|
|
30
|
+
// (every N events, every M minutes, on-demand) is a feature-level decision
|
|
31
|
+
// — the framework only offers the storage primitive.
|
|
32
|
+
//
|
|
33
|
+
// Schema-migration policy: NO built-in snapshot versioning. A snapshot stores
|
|
34
|
+
// the aggregate state in the reducer's current shape. When the reducer's
|
|
35
|
+
// shape changes (added field, renamed property, moved compound), invalidate
|
|
36
|
+
// the cache — DELETE from kumiko_snapshots WHERE aggregate_type = '...'.
|
|
37
|
+
// The read path then falls back to full replay (which runs the upcaster
|
|
38
|
+
// chain on events) until the next snapshotAggregate call. Cheaper than a
|
|
39
|
+
// second migration mechanism; snapshots are a perf optimisation, not a
|
|
40
|
+
// source of truth.
|
|
41
|
+
//
|
|
42
|
+
// Upcaster interaction: the raw API (loadAggregateWithSnapshot below) does
|
|
43
|
+
// NOT apply the upcaster chain on delta events — same layering as raw
|
|
44
|
+
// loadAggregate. The Dispatcher wraps this into ctx.loadAggregateWithSnapshot
|
|
45
|
+
// and runs upcastStoredEvents on the delta before calling the reducer, so
|
|
46
|
+
// feature authors always see current-version payloads.
|
|
47
|
+
|
|
48
|
+
export const snapshotsTable = pgTable(
|
|
49
|
+
"kumiko_snapshots",
|
|
50
|
+
{
|
|
51
|
+
aggregateId: uuid("aggregate_id").notNull(),
|
|
52
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
53
|
+
// Kept even though (aggregate_id, version) is globally unique: the
|
|
54
|
+
// schema-migration invalidation mechanism (see file header) filters by
|
|
55
|
+
// aggregate_type, so storing it avoids a join on events just to
|
|
56
|
+
// invalidate snapshots.
|
|
57
|
+
aggregateType: text("aggregate_type").notNull(),
|
|
58
|
+
// The version covered by this snapshot. `loadEventsAfterVersion`
|
|
59
|
+
// returns events with version > this value.
|
|
60
|
+
version: integer("version").notNull(),
|
|
61
|
+
state: jsonb("state").$type<Record<string, unknown>>().notNull(),
|
|
62
|
+
createdAt: instant("created_at", { precision: 3 }).notNull().default(sql`now()`),
|
|
63
|
+
},
|
|
64
|
+
(t) => ({
|
|
65
|
+
pk: primaryKey({ columns: [t.aggregateId, t.version] }),
|
|
66
|
+
// Latest-snapshot lookup: WHERE aggregate_id = ? ORDER BY version DESC
|
|
67
|
+
// LIMIT 1. With this index the planner does one seek + backward scan
|
|
68
|
+
// instead of sort-and-limit.
|
|
69
|
+
latestIdx: index("kumiko_snapshots_latest_idx").on(t.aggregateId, t.tenantId, t.version),
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
export async function createSnapshotsTable(db: DbConnection): Promise<void> {
|
|
74
|
+
// skip: table already exists — idempotent boot + test-setup call
|
|
75
|
+
if (await tableExists(db, "public.kumiko_snapshots")) return;
|
|
76
|
+
await pushTables(db, { kumikoSnapshots: snapshotsTable });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type Snapshot<TState extends Record<string, unknown> = Record<string, unknown>> = {
|
|
80
|
+
readonly aggregateId: string;
|
|
81
|
+
readonly tenantId: TenantId;
|
|
82
|
+
readonly aggregateType: string;
|
|
83
|
+
readonly version: number;
|
|
84
|
+
readonly state: TState;
|
|
85
|
+
readonly createdAt: Temporal.Instant;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type SaveSnapshotArgs = {
|
|
89
|
+
readonly aggregateId: string;
|
|
90
|
+
readonly tenantId: TenantId;
|
|
91
|
+
readonly aggregateType: string;
|
|
92
|
+
readonly version: number;
|
|
93
|
+
readonly state: Record<string, unknown>;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Upsert-style save so re-snapshotting the same (aggregateId, version) is
|
|
97
|
+
// idempotent. Caller can retake a snapshot at the same version without
|
|
98
|
+
// bespoke error handling — useful when a feature's snapshot policy runs
|
|
99
|
+
// during a concurrent retake.
|
|
100
|
+
export async function saveSnapshot(db: DbRunner, args: SaveSnapshotArgs): Promise<void> {
|
|
101
|
+
await db
|
|
102
|
+
.insert(snapshotsTable)
|
|
103
|
+
.values({
|
|
104
|
+
aggregateId: args.aggregateId,
|
|
105
|
+
tenantId: args.tenantId,
|
|
106
|
+
aggregateType: args.aggregateType,
|
|
107
|
+
version: args.version,
|
|
108
|
+
state: args.state,
|
|
109
|
+
})
|
|
110
|
+
.onConflictDoUpdate({
|
|
111
|
+
target: [snapshotsTable.aggregateId, snapshotsTable.version],
|
|
112
|
+
set: {
|
|
113
|
+
state: args.state,
|
|
114
|
+
aggregateType: args.aggregateType,
|
|
115
|
+
createdAt: sql`now()`,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Latest snapshot lookup. Tenant filter is belt-and-suspenders — the
|
|
121
|
+
// aggregate_id should already scope uniquely, but an accidentally-reused
|
|
122
|
+
// UUID across tenants would otherwise silently leak.
|
|
123
|
+
export async function loadLatestSnapshot<
|
|
124
|
+
TState extends Record<string, unknown> = Record<string, unknown>,
|
|
125
|
+
>(db: DbRunner, aggregateId: string, tenantId: TenantId): Promise<Snapshot<TState> | null> {
|
|
126
|
+
const rows = await db
|
|
127
|
+
.select()
|
|
128
|
+
.from(snapshotsTable)
|
|
129
|
+
.where(and(eq(snapshotsTable.aggregateId, aggregateId), eq(snapshotsTable.tenantId, tenantId)))
|
|
130
|
+
.orderBy(desc(snapshotsTable.version))
|
|
131
|
+
.limit(1);
|
|
132
|
+
const row = rows[0];
|
|
133
|
+
if (!row) return null;
|
|
134
|
+
return {
|
|
135
|
+
aggregateId: row.aggregateId,
|
|
136
|
+
tenantId: row.tenantId,
|
|
137
|
+
aggregateType: row.aggregateType,
|
|
138
|
+
version: row.version,
|
|
139
|
+
state: row.state as TState,
|
|
140
|
+
createdAt: row.createdAt,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Reducer used to fold events onto a state. Kept narrow and pure — the
|
|
145
|
+
// caller supplies the shape and update rules. Mirrors the reducer shape
|
|
146
|
+
// feature authors already write for r.projection.apply.
|
|
147
|
+
export type SnapshotReducer<TState extends Record<string, unknown>> = (
|
|
148
|
+
state: TState,
|
|
149
|
+
event: StoredEvent,
|
|
150
|
+
) => TState;
|
|
151
|
+
|
|
152
|
+
export type LoadAggregateWithSnapshotResult<TState extends Record<string, unknown>> = {
|
|
153
|
+
readonly state: TState;
|
|
154
|
+
readonly version: number;
|
|
155
|
+
readonly snapshotHit: boolean;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export type LoadAggregateWithSnapshotOptions = {
|
|
159
|
+
// Opt-in: include archived streams in the rehydrate. Default false — same
|
|
160
|
+
// semantics as loadAggregate / loadAggregateAsOf. Archive check is a
|
|
161
|
+
// single indexed lookup, so the cost stays negligible on the hot path.
|
|
162
|
+
readonly includeArchived?: boolean;
|
|
163
|
+
// Optional upcaster step: every delta event goes through this transform
|
|
164
|
+
// BEFORE the reducer sees it. The dispatcher wires this up with
|
|
165
|
+
// r.eventMigration so feature code always sees current-version payloads.
|
|
166
|
+
// Async to support Marten-style AsyncOnlyEventUpcaster (DB lookups).
|
|
167
|
+
readonly upcastEvent?: (event: StoredEvent) => Promise<StoredEvent>;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Snapshot-aware rehydrate. Loads the latest snapshot (if any), applies
|
|
171
|
+
// events strictly newer than snapshot.version, and returns the fold.
|
|
172
|
+
// Callers that want strictly-event-sourced loading should stick with
|
|
173
|
+
// loadAggregate + reduce — this path exists for perf-critical aggregates.
|
|
174
|
+
//
|
|
175
|
+
// Archive behaviour mirrors loadAggregate: an archived stream returns
|
|
176
|
+
// `initial` with version=0, snapshotHit=false, unless
|
|
177
|
+
// { includeArchived: true } is passed. This keeps snapshot and raw
|
|
178
|
+
// loadAggregate interchangeable from the caller's point of view.
|
|
179
|
+
export async function loadAggregateWithSnapshot<TState extends Record<string, unknown>>(
|
|
180
|
+
db: DbRunner,
|
|
181
|
+
aggregateId: string,
|
|
182
|
+
tenantId: TenantId,
|
|
183
|
+
reducer: SnapshotReducer<TState>,
|
|
184
|
+
initial: TState,
|
|
185
|
+
options?: LoadAggregateWithSnapshotOptions,
|
|
186
|
+
): Promise<LoadAggregateWithSnapshotResult<TState>> {
|
|
187
|
+
if (!options?.includeArchived) {
|
|
188
|
+
const archived = await isStreamArchived(db, tenantId, aggregateId);
|
|
189
|
+
if (archived) {
|
|
190
|
+
return { state: initial, version: 0, snapshotHit: false };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const snapshot = await loadLatestSnapshot<TState>(db, aggregateId, tenantId);
|
|
194
|
+
const baseState = snapshot ? snapshot.state : initial;
|
|
195
|
+
const afterVersion = snapshot ? snapshot.version : 0;
|
|
196
|
+
const delta = await loadEventsAfterVersion(db, aggregateId, tenantId, afterVersion);
|
|
197
|
+
|
|
198
|
+
let state = baseState;
|
|
199
|
+
for (const event of delta) {
|
|
200
|
+
const effective = options?.upcastEvent ? await options.upcastEvent(event) : event;
|
|
201
|
+
state = reducer(state, effective);
|
|
202
|
+
}
|
|
203
|
+
const lastDelta = delta[delta.length - 1];
|
|
204
|
+
const latestVersion = lastDelta ? lastDelta.version : afterVersion;
|
|
205
|
+
return {
|
|
206
|
+
state,
|
|
207
|
+
version: latestVersion,
|
|
208
|
+
snapshotHit: snapshot !== null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Dead-letter storage for failed event upcasters.
|
|
2
|
+
//
|
|
3
|
+
// Background: upcastStoredEvent walks a stored event's payload through
|
|
4
|
+
// r.eventMigration transforms until it matches the current schema. A
|
|
5
|
+
// migration that throws (malformed legacy payload, DB-dependent
|
|
6
|
+
// enrichment that fails) propagates to the dispatcher and kills the
|
|
7
|
+
// pass — one bad event in a million can stall every projection behind
|
|
8
|
+
// it. The same applies to MSP rebuild.
|
|
9
|
+
//
|
|
10
|
+
// Quarantine mode captures the failure into `kumiko_upcaster_dead_letters`,
|
|
11
|
+
// lets the dispatcher skip the event, and surfaces the row count via
|
|
12
|
+
// ops tooling. Replay (re-apply the migration after a code fix) is a
|
|
13
|
+
// separate CLI step — not implemented here, tracked as follow-up.
|
|
14
|
+
|
|
15
|
+
import { bigint, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
16
|
+
import type { DbConnection, DbRunner } from "../db/connection";
|
|
17
|
+
import { tableExists } from "../db/schema-inspection";
|
|
18
|
+
import { pushTables } from "../stack";
|
|
19
|
+
import type { StoredEvent } from "./event-store";
|
|
20
|
+
|
|
21
|
+
export const upcasterDeadLetterTable = pgTable(
|
|
22
|
+
"kumiko_upcaster_dead_letters",
|
|
23
|
+
{
|
|
24
|
+
// Surrogate PK. We don't reuse eventId so a single event can land
|
|
25
|
+
// here multiple times (retry attempts across deploys before the fix
|
|
26
|
+
// lands) without unique-violation noise.
|
|
27
|
+
id: bigint("id", { mode: "bigint" }).primaryKey().generatedAlwaysAsIdentity(),
|
|
28
|
+
// StoredEvent.id is surfaced as `string` (bigint serialised for JSON
|
|
29
|
+
// safety). Storing as text keeps the round-trip identity without a
|
|
30
|
+
// coerce step at every write site.
|
|
31
|
+
eventId: text("event_id").notNull(),
|
|
32
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
33
|
+
aggregateId: text("aggregate_id").notNull(),
|
|
34
|
+
aggregateType: text("aggregate_type").notNull(),
|
|
35
|
+
eventType: text("event_type").notNull(),
|
|
36
|
+
fromVersion: integer("from_version").notNull(),
|
|
37
|
+
targetVersion: integer("target_version").notNull(),
|
|
38
|
+
errorMessage: text("error_message").notNull(),
|
|
39
|
+
originalPayload: jsonb("original_payload").notNull(),
|
|
40
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
41
|
+
},
|
|
42
|
+
(t) => ({
|
|
43
|
+
eventTypeIdx: index("upcaster_dead_letters_event_type_idx").on(t.eventType),
|
|
44
|
+
createdAtIdx: index("upcaster_dead_letters_created_at_idx").on(t.createdAt),
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Idempotent table-create. Called from setupTestStack for suites that
|
|
49
|
+
// exercise the quarantine path; production boot uses drizzle-kit push.
|
|
50
|
+
export async function createUpcasterDeadLetterTable(db: DbConnection): Promise<void> {
|
|
51
|
+
// skip: table already exists — bootstrap called from multiple paths
|
|
52
|
+
if (await tableExists(db, "public.kumiko_upcaster_dead_letters")) return;
|
|
53
|
+
await pushTables(db, { kumikoUpcasterDeadLetters: upcasterDeadLetterTable });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Writes a dead-letter row. Called by upcastStoredEvent when errorPolicy
|
|
57
|
+
// is "quarantine" and a transform threw. Returns the inserted row id —
|
|
58
|
+
// ops tooling uses it for correlate-and-replay flows.
|
|
59
|
+
export async function recordUpcasterDeadLetter(
|
|
60
|
+
db: DbRunner,
|
|
61
|
+
args: {
|
|
62
|
+
event: StoredEvent;
|
|
63
|
+
fromVersion: number;
|
|
64
|
+
targetVersion: number;
|
|
65
|
+
error: unknown;
|
|
66
|
+
},
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const message = args.error instanceof Error ? args.error.message : String(args.error);
|
|
69
|
+
await db.insert(upcasterDeadLetterTable).values({
|
|
70
|
+
eventId: args.event.id,
|
|
71
|
+
tenantId: args.event.tenantId,
|
|
72
|
+
aggregateId: args.event.aggregateId,
|
|
73
|
+
aggregateType: args.event.aggregateType,
|
|
74
|
+
eventType: args.event.type,
|
|
75
|
+
fromVersion: args.fromVersion,
|
|
76
|
+
targetVersion: args.targetVersion,
|
|
77
|
+
errorMessage: message,
|
|
78
|
+
originalPayload: args.event.payload,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type DeadLetterRow = {
|
|
83
|
+
readonly id: bigint;
|
|
84
|
+
readonly eventId: string;
|
|
85
|
+
readonly tenantId: string;
|
|
86
|
+
readonly aggregateId: string;
|
|
87
|
+
readonly aggregateType: string;
|
|
88
|
+
readonly eventType: string;
|
|
89
|
+
readonly fromVersion: number;
|
|
90
|
+
readonly targetVersion: number;
|
|
91
|
+
readonly errorMessage: string;
|
|
92
|
+
readonly originalPayload: Record<string, unknown>;
|
|
93
|
+
readonly createdAt: Date;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Ops-side query. Loads recent failures, optionally scoped by event-type
|
|
97
|
+
// to triage a single broken migration without pulling the full table.
|
|
98
|
+
export async function listDeadLetters(
|
|
99
|
+
db: DbConnection,
|
|
100
|
+
options: { eventType?: string; limit?: number } = {},
|
|
101
|
+
): Promise<readonly DeadLetterRow[]> {
|
|
102
|
+
const { desc, eq } = await import("drizzle-orm");
|
|
103
|
+
const limit = options.limit ?? 100;
|
|
104
|
+
const eventType = options.eventType;
|
|
105
|
+
const rows =
|
|
106
|
+
eventType !== undefined
|
|
107
|
+
? await db
|
|
108
|
+
.select()
|
|
109
|
+
.from(upcasterDeadLetterTable)
|
|
110
|
+
.where(eq(upcasterDeadLetterTable.eventType, eventType))
|
|
111
|
+
.orderBy(desc(upcasterDeadLetterTable.createdAt))
|
|
112
|
+
.limit(limit)
|
|
113
|
+
: await db
|
|
114
|
+
.select()
|
|
115
|
+
.from(upcasterDeadLetterTable)
|
|
116
|
+
.orderBy(desc(upcasterDeadLetterTable.createdAt))
|
|
117
|
+
.limit(limit);
|
|
118
|
+
return rows as readonly DeadLetterRow[];
|
|
119
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { DbRunner } from "../db";
|
|
2
|
+
import type { EventUpcastCtx, EventUpcastFn, TenantId } from "../engine/types";
|
|
3
|
+
import type { StoredEvent } from "./event-store";
|
|
4
|
+
import { recordUpcasterDeadLetter } from "./upcaster-dead-letter";
|
|
5
|
+
|
|
6
|
+
// Error-handling contract for the upcast pass.
|
|
7
|
+
//
|
|
8
|
+
// throw — legacy behaviour: the pass aborts, the dispatcher retries,
|
|
9
|
+
// a permanently broken payload eventually dead-letters at
|
|
10
|
+
// the consumer level after maxAttempts retries. Pick this
|
|
11
|
+
// when every event must land exactly once and "skip" is
|
|
12
|
+
// never acceptable.
|
|
13
|
+
//
|
|
14
|
+
// quarantine — the failing event is written to
|
|
15
|
+
// `kumiko_upcaster_dead_letters` with the error + original
|
|
16
|
+
// payload and REMOVED from the returned list. The
|
|
17
|
+
// dispatcher skips it cleanly; ops tooling replays after
|
|
18
|
+
// the code fix. Pick this for projections where a single
|
|
19
|
+
// unrenderable historic event shouldn't block the rest
|
|
20
|
+
// of the stream.
|
|
21
|
+
export type UpcasterErrorPolicy = "throw" | "quarantine";
|
|
22
|
+
|
|
23
|
+
export type UpcastOptions = {
|
|
24
|
+
readonly errorPolicy?: UpcasterErrorPolicy;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Event schema evolution (Marten-style upcaster). An event's stored payload
|
|
28
|
+
// stays immutable on disk; when a feature bumps the event version and
|
|
29
|
+
// registers step-wise r.eventMigration transforms, reads walk older events
|
|
30
|
+
// through the chain until the payload matches the current shape.
|
|
31
|
+
//
|
|
32
|
+
// Sync transforms cost O(version_gap) plain JSON rewrites — hot path on
|
|
33
|
+
// projection rebuild stays cheap. Async transforms (Marten's
|
|
34
|
+
// AsyncOnlyEventUpcaster) for DB-enrichment are supported via the same
|
|
35
|
+
// signature: return Promise<unknown>, the framework awaits unconditionally.
|
|
36
|
+
// Sync transforms still pay only the await-microtask overhead.
|
|
37
|
+
|
|
38
|
+
export type EventUpcasters = ReadonlyMap<
|
|
39
|
+
string,
|
|
40
|
+
{ readonly currentVersion: number; readonly chain: ReadonlyMap<number, EventUpcastFn> }
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
// Upcast a single stored event through however many registered migrations
|
|
44
|
+
// separate its stored eventVersion from the current schema version.
|
|
45
|
+
//
|
|
46
|
+
// Contract:
|
|
47
|
+
// - Event types with no registered upcaster pass through unchanged.
|
|
48
|
+
// - Event types whose stored version equals currentVersion pass through
|
|
49
|
+
// unchanged (fast path — hot on projection rebuild).
|
|
50
|
+
// - Gaps in the chain are a hard error. The registry validates chain
|
|
51
|
+
// completeness at boot, so this throw is a belt-and-suspenders signal
|
|
52
|
+
// that something wrote a version number the registry doesn't expect.
|
|
53
|
+
//
|
|
54
|
+
// `ctx` carries db + tenantId for async upcasters that need DB enrichment.
|
|
55
|
+
// Sync transforms ignore ctx entirely.
|
|
56
|
+
// Legacy throw-on-error API — preserved so existing callers (projection-
|
|
57
|
+
// rebuild, msp-rebuild, feature tests) stay unchanged. Returns a
|
|
58
|
+
// StoredEvent (never null); quarantine mode lives on the bulk helper.
|
|
59
|
+
export async function upcastStoredEvent(
|
|
60
|
+
event: StoredEvent,
|
|
61
|
+
upcasters: EventUpcasters,
|
|
62
|
+
ctx: EventUpcastCtx,
|
|
63
|
+
): Promise<StoredEvent> {
|
|
64
|
+
const result = await upcastStoredEventWithPolicy(event, upcasters, ctx, {
|
|
65
|
+
errorPolicy: "throw",
|
|
66
|
+
});
|
|
67
|
+
// `throw` mode can never return null — the catch-block rethrows. Narrow
|
|
68
|
+
// the type for callers without an `if (result === null)` check at every
|
|
69
|
+
// callsite.
|
|
70
|
+
if (result === null) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`unreachable: upcastStoredEvent with errorPolicy="throw" returned null for "${event.type}"`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Underlying policy-aware worker. Returns null when the transform threw
|
|
79
|
+
// AND errorPolicy="quarantine" — the event gets recorded in dead-letters
|
|
80
|
+
// and the bulk helper filters it out.
|
|
81
|
+
async function upcastStoredEventWithPolicy(
|
|
82
|
+
event: StoredEvent,
|
|
83
|
+
upcasters: EventUpcasters,
|
|
84
|
+
ctx: EventUpcastCtx,
|
|
85
|
+
options: UpcastOptions,
|
|
86
|
+
): Promise<StoredEvent | null> {
|
|
87
|
+
const info = upcasters.get(event.type);
|
|
88
|
+
if (!info) return event;
|
|
89
|
+
if (event.eventVersion >= info.currentVersion) return event;
|
|
90
|
+
|
|
91
|
+
let payload = event.payload as unknown;
|
|
92
|
+
let v = event.eventVersion;
|
|
93
|
+
const startVersion = event.eventVersion;
|
|
94
|
+
while (v < info.currentVersion) {
|
|
95
|
+
const transform = info.chain.get(v);
|
|
96
|
+
if (!transform) {
|
|
97
|
+
// Missing chain is a boot-validator bug, not a data problem —
|
|
98
|
+
// always throw regardless of policy so the gap gets fixed rather
|
|
99
|
+
// than silently rotting every affected event into dead-letters.
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Missing upcaster for event "${event.type}" v${v} → v${v + 1}. ` +
|
|
102
|
+
`The registry should have caught this at boot — check the eventUpcasterMap wiring.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
payload = await transform(payload, ctx);
|
|
107
|
+
v++;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (options.errorPolicy === "quarantine") {
|
|
110
|
+
await recordUpcasterDeadLetter(ctx.db, {
|
|
111
|
+
event,
|
|
112
|
+
fromVersion: startVersion,
|
|
113
|
+
targetVersion: info.currentVersion,
|
|
114
|
+
error: err,
|
|
115
|
+
});
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
...event,
|
|
123
|
+
payload: payload as Record<string, unknown>, // @cast-boundary engine-payload
|
|
124
|
+
eventVersion: v,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function upcastStoredEvents(
|
|
129
|
+
events: readonly StoredEvent[],
|
|
130
|
+
upcasters: EventUpcasters,
|
|
131
|
+
ctx: EventUpcastCtx,
|
|
132
|
+
options: UpcastOptions = {},
|
|
133
|
+
): Promise<readonly StoredEvent[]> {
|
|
134
|
+
// skip: no upcasters registered anywhere — common case when a project
|
|
135
|
+
// hasn't bumped any event version yet. Short-circuit keeps replay fast.
|
|
136
|
+
if (upcasters.size === 0) return events;
|
|
137
|
+
const results = await Promise.all(
|
|
138
|
+
events.map((e) => upcastStoredEventWithPolicy(e, upcasters, ctx, options)),
|
|
139
|
+
);
|
|
140
|
+
return results.filter((e): e is StoredEvent => e !== null);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Convenience builder for callers that have db + tenantId at hand and want
|
|
144
|
+
// to construct the ctx-arg without restating the field names everywhere.
|
|
145
|
+
export function makeUpcastCtx(db: DbRunner, tenantId: TenantId): EventUpcastCtx {
|
|
146
|
+
return { db, tenantId };
|
|
147
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildContentDispositionHeader,
|
|
4
|
+
encodeRFC5987,
|
|
5
|
+
toAsciiFallback,
|
|
6
|
+
} from "../content-disposition";
|
|
7
|
+
|
|
8
|
+
describe("toAsciiFallback", () => {
|
|
9
|
+
test("keeps ASCII letters, digits, dot, dash, underscore, parens", () => {
|
|
10
|
+
expect(toAsciiFallback("photo_2024-01.png")).toBe("photo_2024-01.png");
|
|
11
|
+
expect(toAsciiFallback("report(v2).pdf")).toBe("report(v2).pdf");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("collapses spaces, commas, and other non-safe chars to underscore", () => {
|
|
15
|
+
expect(toAsciiFallback("my photo, v2.png")).toBe("my_photo__v2.png");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("strips quote characters — the core injection protection", () => {
|
|
19
|
+
const evil = `safe.png"; filename*=utf-8''evil.exe`;
|
|
20
|
+
const out = toAsciiFallback(evil);
|
|
21
|
+
expect(out).not.toContain('"');
|
|
22
|
+
expect(out).not.toContain(";");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("strips backslash and path separators (directory-traversal guard)", () => {
|
|
26
|
+
// Dots survive (whitelisted); `/` and `\` collapse to underscore.
|
|
27
|
+
expect(toAsciiFallback("../../../etc/passwd")).toBe(".._.._.._etc_passwd");
|
|
28
|
+
expect(toAsciiFallback("C:\\Windows\\evil.exe")).toBe("C__Windows_evil.exe");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("collapses non-ASCII (unicode) to underscore — one char per code unit", () => {
|
|
32
|
+
// 測 + 試 = 2 BMP code units → 2 underscores, then `.png` passes through.
|
|
33
|
+
expect(toAsciiFallback("測試.png")).toBe("__.png");
|
|
34
|
+
expect(toAsciiFallback("café.pdf")).toBe("caf_.pdf");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("truncates at 100 chars to bound header size", () => {
|
|
38
|
+
const longName = `${"a".repeat(200)}.png`;
|
|
39
|
+
const out = toAsciiFallback(longName);
|
|
40
|
+
expect(out.length).toBe(100);
|
|
41
|
+
expect(out.startsWith("aaaa")).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns 'download' for empty input or when no alphanumerics survive", () => {
|
|
45
|
+
// Empty stripped.
|
|
46
|
+
expect(toAsciiFallback("")).toBe("download");
|
|
47
|
+
// All non-safe chars collapsed → 12 underscores → readable default.
|
|
48
|
+
expect(toAsciiFallback("@@@###$$$%%%")).toBe("download");
|
|
49
|
+
// Mix of symbols + dots (dots whitelisted) → still no alphanumerics.
|
|
50
|
+
expect(toAsciiFallback("@.#.$")).toBe("download");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("encodeRFC5987", () => {
|
|
55
|
+
test("passes pure ASCII through (letters, digits)", () => {
|
|
56
|
+
expect(encodeRFC5987("photo.png")).toBe("photo.png");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("percent-encodes UTF-8 bytes for non-ASCII", () => {
|
|
60
|
+
// 測 = UTF-8 E6 B8 AC → %E6%B8%AC
|
|
61
|
+
const out = encodeRFC5987("測");
|
|
62
|
+
expect(out).toBe("%E6%B8%AC");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("escapes the RFC-5987 extras that encodeURIComponent leaves alone", () => {
|
|
66
|
+
// encodeURIComponent doesn't escape ' ( ) * — RFC 5987 requires we do.
|
|
67
|
+
// Each char maps to its uppercase hex code.
|
|
68
|
+
expect(encodeRFC5987("a'b")).toBe("a%27b");
|
|
69
|
+
expect(encodeRFC5987("a(b)")).toBe("a%28b%29");
|
|
70
|
+
expect(encodeRFC5987("a*b")).toBe("a%2Ab");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("uses uppercase hex for consistency (matches RFC sample output)", () => {
|
|
74
|
+
expect(encodeRFC5987(" ")).toBe("%20");
|
|
75
|
+
expect(encodeRFC5987(";")).toBe("%3B");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("buildContentDispositionHeader", () => {
|
|
80
|
+
test("pure ASCII input produces both parameters", () => {
|
|
81
|
+
const header = buildContentDispositionHeader("photo.png");
|
|
82
|
+
expect(header).toBe(`attachment; filename="photo.png"; filename*=UTF-8''photo.png`);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("unicode input survives losslessly in filename*, stripped in fallback", () => {
|
|
86
|
+
const header = buildContentDispositionHeader("測試.png");
|
|
87
|
+
// 2 BMP code units → 2 underscores in fallback, then `.png`.
|
|
88
|
+
expect(header).toContain(`filename="__.png"`);
|
|
89
|
+
// filename* carries the full UTF-8 bytes percent-encoded.
|
|
90
|
+
expect(header).toContain("filename*=UTF-8''%E6%B8%AC%E8%A9%A6.png");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("injection attempt — header has exactly 3 semicolon-separated parts", () => {
|
|
94
|
+
// `"; filename*=utf-8''evil.exe` injection — sanitised header must
|
|
95
|
+
// still parse as a single attachment with exactly two parameters.
|
|
96
|
+
const header = buildContentDispositionHeader(`normal.png"; filename*=utf-8''evil.exe`);
|
|
97
|
+
const parts = header.split(";");
|
|
98
|
+
expect(parts).toHaveLength(3);
|
|
99
|
+
expect(parts[0]).toBe("attachment");
|
|
100
|
+
expect(parts[1]?.trim().startsWith("filename=")).toBe(true);
|
|
101
|
+
expect(parts[2]?.trim().startsWith("filename*=")).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("fallback never leaks unquoted double-quote", () => {
|
|
105
|
+
// Any quote inside filename="..." would close the string early and
|
|
106
|
+
// let the tail parse as new parameters. Proof: the fallback value
|
|
107
|
+
// (the chars between the first two quotes after "filename=") has
|
|
108
|
+
// no further quotes.
|
|
109
|
+
const header = buildContentDispositionHeader(`a"b"c.png`);
|
|
110
|
+
const match = header.match(/filename="([^"]*)"/);
|
|
111
|
+
expect(match).not.toBeNull();
|
|
112
|
+
expect(match?.[1]).not.toContain('"');
|
|
113
|
+
// All 3 quotes collapsed to underscore in the fallback.
|
|
114
|
+
expect(match?.[1]).toBe("a_b_c.png");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("empty filename falls back to 'download'", () => {
|
|
118
|
+
const header = buildContentDispositionHeader("");
|
|
119
|
+
expect(header).toContain(`filename="download"`);
|
|
120
|
+
// Empty filename*: encodeRFC5987("") → "", so filename*=UTF-8''
|
|
121
|
+
expect(header).toContain(`filename*=UTF-8''`);
|
|
122
|
+
});
|
|
123
|
+
});
|