@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,62 @@
|
|
|
1
|
+
// In-memory file provider for unit tests. Not for production: nothing
|
|
2
|
+
// persists across process restarts, and memory grows with every write.
|
|
3
|
+
//
|
|
4
|
+
// Factored out of test-files so any package can opt in (samples, downstream
|
|
5
|
+
// feature tests) without re-inventing a Map-backed mock.
|
|
6
|
+
|
|
7
|
+
import type { FileStorageProvider } from "./types";
|
|
8
|
+
|
|
9
|
+
export type InMemoryFileProvider = FileStorageProvider & {
|
|
10
|
+
// Test-only introspection: keys currently stored. Useful for assertions
|
|
11
|
+
// like `expect(provider.keys()).toContain("tenant/foo.jpg")`.
|
|
12
|
+
keys(): readonly string[];
|
|
13
|
+
// Test-only reset between cases. beforeEach-friendly.
|
|
14
|
+
clear(): void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type StoredEntry = {
|
|
18
|
+
readonly data: Uint8Array;
|
|
19
|
+
readonly mimeType?: string | undefined;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createInMemoryFileProvider(): InMemoryFileProvider {
|
|
23
|
+
const store = new Map<string, StoredEntry>();
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
async write(key, data, mimeType) {
|
|
27
|
+
// Copy the buffer so the caller can reuse/mutate theirs without
|
|
28
|
+
// aliasing the stored bytes. Cheap for tests, predictable semantics.
|
|
29
|
+
store.set(key, { data: new Uint8Array(data), mimeType });
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async read(key) {
|
|
33
|
+
const entry = store.get(key);
|
|
34
|
+
if (!entry) throw new Error(`in-memory file not found: ${key}`);
|
|
35
|
+
return new Uint8Array(entry.data);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async delete(key) {
|
|
39
|
+
store.delete(key);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async exists(key) {
|
|
43
|
+
return store.has(key);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Deterministic fake URL — encodes the key + expiry so tests can assert
|
|
47
|
+
// the route wired through without running a real presigner. Shape
|
|
48
|
+
// (memory://<key>?expires=<seconds>) intentionally differs from any real
|
|
49
|
+
// provider so leakage into production would be obvious at a glance.
|
|
50
|
+
async getSignedUrl(key, expiresInSeconds) {
|
|
51
|
+
return `memory://${key}?expires=${expiresInSeconds}`;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
keys() {
|
|
55
|
+
return Array.from(store.keys());
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
clear() {
|
|
59
|
+
store.clear();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type { FileContext, FileHandle } from "./file-handle";
|
|
2
|
+
// `createFileHandle` is an implementation detail — construct handles via
|
|
3
|
+
// `createFileContext(provider).ref(key)`, which is the AppContext surface.
|
|
4
|
+
export { createFileContext, deriveKey } from "./file-handle";
|
|
5
|
+
export { fileRefsTable } from "./file-ref-table";
|
|
6
|
+
export type {
|
|
7
|
+
FileAccessDecision,
|
|
8
|
+
FileAccessGuard,
|
|
9
|
+
FileRef,
|
|
10
|
+
FileRoutesOptions,
|
|
11
|
+
FileUploadedPayload,
|
|
12
|
+
} from "./file-routes";
|
|
13
|
+
export {
|
|
14
|
+
createFileRoutes,
|
|
15
|
+
FILE_UPLOADED_EVENT_TYPE,
|
|
16
|
+
fileUploadedEvent,
|
|
17
|
+
fileUploadedPayloadSchema,
|
|
18
|
+
} from "./file-routes";
|
|
19
|
+
export type { InMemoryFileProvider } from "./in-memory-provider";
|
|
20
|
+
export { createInMemoryFileProvider } from "./in-memory-provider";
|
|
21
|
+
export { createLocalProvider } from "./local-provider";
|
|
22
|
+
export { filesStorageTrackingFeature, tenantStorageUsageTable } from "./storage-tracking";
|
|
23
|
+
export type {
|
|
24
|
+
FileMetadata,
|
|
25
|
+
FileStorageProvider,
|
|
26
|
+
FileValidationOptions,
|
|
27
|
+
SignedUrlOptions,
|
|
28
|
+
} from "./types";
|
|
29
|
+
export { buildStorageKey, parseMaxSize, validateFile } from "./types";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { FileStorageProvider } from "./types";
|
|
4
|
+
|
|
5
|
+
// Local-filesystem backend — intended for dev + tests. Production deploys
|
|
6
|
+
// pick an object-store provider (S3/R2/…). mimeType is ignored here; the
|
|
7
|
+
// filesystem tracks no metadata beyond what the caller stores on FileRef.
|
|
8
|
+
export function createLocalProvider(basePath: string): FileStorageProvider {
|
|
9
|
+
return {
|
|
10
|
+
async write(key: string, data: Uint8Array, _mimeType?: string): Promise<void> {
|
|
11
|
+
const filePath = join(basePath, key);
|
|
12
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
13
|
+
await writeFile(filePath, data);
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
async read(key: string): Promise<Uint8Array> {
|
|
17
|
+
const filePath = join(basePath, key);
|
|
18
|
+
return readFile(filePath);
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
async delete(key: string): Promise<void> {
|
|
22
|
+
const filePath = join(basePath, key);
|
|
23
|
+
await rm(filePath, { force: true });
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async exists(key: string): Promise<boolean> {
|
|
27
|
+
try {
|
|
28
|
+
await stat(join(basePath, key));
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Tenant storage usage — counts bytes + files per tenant from the event log.
|
|
2
|
+
//
|
|
3
|
+
// Tracking-only for Phase 1: no hard limit, no upload gatekeeping. Apps read
|
|
4
|
+
// the row to decide what to do (show a warning, soft-throttle, bill, …).
|
|
5
|
+
// Enforcement is a conscious deferred call — we want production numbers
|
|
6
|
+
// before picking thresholds (see core-files.md, Architektur-Entscheidung 3).
|
|
7
|
+
//
|
|
8
|
+
// The MSP is packaged as its own opt-in feature so tests that don't care
|
|
9
|
+
// about storage metrics don't pay for the projection-table push or the
|
|
10
|
+
// consumer-cursor row. Apps that want it pass filesStorageTrackingFeature
|
|
11
|
+
// into createApp / setupTestStack alongside their domain features.
|
|
12
|
+
|
|
13
|
+
import { sql } from "drizzle-orm";
|
|
14
|
+
import { bigint, instant, integer, table as pgTable, uuid } from "../db/dialect";
|
|
15
|
+
import { defineFeature, typedPayload } from "../engine";
|
|
16
|
+
import { fileUploadedEvent } from "./file-routes";
|
|
17
|
+
|
|
18
|
+
// bigint in `mode: "number"` returns a JS number (safe up to 2^53 ≈ 9e15
|
|
19
|
+
// bytes ≈ 8 petabytes per tenant — large enough for any practical storage
|
|
20
|
+
// quota). Default "bigint" mode would hand back a bigint value, which
|
|
21
|
+
// arithmetic on Drizzle's sql`` template would still accept but forces
|
|
22
|
+
// callers to remember the type.
|
|
23
|
+
export const tenantStorageUsageTable = pgTable("read_tenant_storage_usage", {
|
|
24
|
+
tenantId: uuid("tenant_id").primaryKey(),
|
|
25
|
+
totalBytes: bigint("total_bytes", { mode: "number" }).notNull().default(0),
|
|
26
|
+
fileCount: integer("file_count").notNull().default(0),
|
|
27
|
+
lastUpdatedAt: instant("last_updated_at").default(sql`now()`).notNull(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const filesStorageTrackingFeature = defineFeature("files-storage-tracking", (r) => {
|
|
31
|
+
r.multiStreamProjection({
|
|
32
|
+
name: "tenant-storage-usage",
|
|
33
|
+
table: tenantStorageUsageTable,
|
|
34
|
+
apply: {
|
|
35
|
+
[fileUploadedEvent.name]: async (event, tx) => {
|
|
36
|
+
const payload = typedPayload(event, fileUploadedEvent);
|
|
37
|
+
|
|
38
|
+
// UPSERT: INSERT on first upload per tenant, otherwise atomic increment.
|
|
39
|
+
// The SQL increment guarantees correctness under concurrent dispatcher
|
|
40
|
+
// runs (shouldn't happen with a single consumer, but the invariant is
|
|
41
|
+
// free and cheap — no reason to rely on serial delivery).
|
|
42
|
+
await tx
|
|
43
|
+
.insert(tenantStorageUsageTable)
|
|
44
|
+
.values({
|
|
45
|
+
tenantId: event.tenantId,
|
|
46
|
+
totalBytes: payload.size,
|
|
47
|
+
fileCount: 1,
|
|
48
|
+
})
|
|
49
|
+
.onConflictDoUpdate({
|
|
50
|
+
target: tenantStorageUsageTable.tenantId,
|
|
51
|
+
set: {
|
|
52
|
+
totalBytes: sql`${tenantStorageUsageTable.totalBytes} + ${payload.size}`,
|
|
53
|
+
fileCount: sql`${tenantStorageUsageTable.fileCount} + 1`,
|
|
54
|
+
lastUpdatedAt: sql`NOW()`,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
export type FileMetadata = {
|
|
4
|
+
readonly fileName: string;
|
|
5
|
+
readonly mimeType: string;
|
|
6
|
+
readonly size: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// Options for `getSignedUrl`. `contentDisposition` lets the caller hint the
|
|
10
|
+
// browser to download-with-name vs inline-display (maps to ResponseContent-
|
|
11
|
+
// Disposition on S3). Keep the option-bag small and additive; provider impls
|
|
12
|
+
// that don't support a given hint should ignore it rather than error.
|
|
13
|
+
export type SignedUrlOptions = {
|
|
14
|
+
readonly contentDisposition?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Primitive storage contract: key+bytes in, bytes out. Metadata (fileName,
|
|
18
|
+
// mimeType, size) lives on the FileRef row — the provider only needs to
|
|
19
|
+
// shuttle bytes. `mimeType` on write() is a hint for providers that need a
|
|
20
|
+
// Content-Type header (S3/R2/…); local filesystems can ignore it.
|
|
21
|
+
//
|
|
22
|
+
// `getSignedUrl` is optional: object-store backends (S3/R2/GCS) implement it
|
|
23
|
+
// so clients can download directly from the provider after the server has
|
|
24
|
+
// checked access — offloads bandwidth and enables browser-native caching.
|
|
25
|
+
// Filesystem providers leave it undefined; the route then returns 501 and
|
|
26
|
+
// the client falls back to streaming via GET /files/:id. Callers must
|
|
27
|
+
// feature-detect via `typeof provider.getSignedUrl === "function"`.
|
|
28
|
+
export type FileStorageProvider = {
|
|
29
|
+
write(key: string, data: Uint8Array, mimeType?: string): Promise<void>;
|
|
30
|
+
read(key: string): Promise<Uint8Array>;
|
|
31
|
+
delete(key: string): Promise<void>;
|
|
32
|
+
exists(key: string): Promise<boolean>;
|
|
33
|
+
getSignedUrl?(key: string, expiresInSeconds: number, options?: SignedUrlOptions): Promise<string>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type FileValidationOptions = {
|
|
37
|
+
readonly maxSize?: string | undefined;
|
|
38
|
+
readonly accept?: readonly string[] | undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function parseMaxSize(maxSize: string): number {
|
|
42
|
+
const match = maxSize.match(/^(\d+)(kb|mb|gb)$/i);
|
|
43
|
+
if (!match) throw new Error(`Invalid maxSize format: "${maxSize}". Use e.g. "10mb", "500kb".`);
|
|
44
|
+
const value = Number(match[1]);
|
|
45
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
46
|
+
switch (unit) {
|
|
47
|
+
case "kb":
|
|
48
|
+
return value * 1024;
|
|
49
|
+
case "mb":
|
|
50
|
+
return value * 1024 * 1024;
|
|
51
|
+
case "gb":
|
|
52
|
+
return value * 1024 * 1024 * 1024;
|
|
53
|
+
default:
|
|
54
|
+
throw new Error(`Unknown unit: ${unit}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Extension → acceptable MIME-type whitelist. Guards against a client
|
|
59
|
+
// uploading e.g. name="x.jpg" with mimeType="application/pdf" to slip an
|
|
60
|
+
// executable past the extension-only check. Kept small & conservative — add
|
|
61
|
+
// entries on demand rather than importing a heavyweight mime DB.
|
|
62
|
+
const EXTENSION_MIME_WHITELIST: Record<string, readonly string[]> = {
|
|
63
|
+
jpg: ["image/jpeg", "image/jpg"],
|
|
64
|
+
jpeg: ["image/jpeg", "image/jpg"],
|
|
65
|
+
png: ["image/png"],
|
|
66
|
+
gif: ["image/gif"],
|
|
67
|
+
webp: ["image/webp"],
|
|
68
|
+
svg: ["image/svg+xml"],
|
|
69
|
+
pdf: ["application/pdf"],
|
|
70
|
+
txt: ["text/plain"],
|
|
71
|
+
csv: ["text/csv", "application/csv", "text/plain"],
|
|
72
|
+
json: ["application/json", "text/json"],
|
|
73
|
+
md: ["text/markdown", "text/plain"],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function validateFile(
|
|
77
|
+
metadata: FileMetadata,
|
|
78
|
+
options: FileValidationOptions,
|
|
79
|
+
): string | null {
|
|
80
|
+
if (options.maxSize) {
|
|
81
|
+
const maxBytes = parseMaxSize(options.maxSize);
|
|
82
|
+
if (metadata.size > maxBytes) {
|
|
83
|
+
return `file_too_large: ${metadata.size} bytes exceeds ${options.maxSize}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (options.accept && options.accept.length > 0) {
|
|
88
|
+
const ext = metadata.fileName.split(".").pop()?.toLowerCase();
|
|
89
|
+
if (!ext || !options.accept.includes(ext)) {
|
|
90
|
+
return `invalid_file_type: ".${ext}" is not in [${options.accept.join(", ")}]`;
|
|
91
|
+
}
|
|
92
|
+
// Extension passed the whitelist — now make sure the client-reported
|
|
93
|
+
// mimeType is consistent with that extension. Guards against MIME-spoofing:
|
|
94
|
+
// an attacker can't claim extension=jpg while actually uploading PDF bytes
|
|
95
|
+
// and having the mimeType reflect that.
|
|
96
|
+
const allowedMimes = EXTENSION_MIME_WHITELIST[ext];
|
|
97
|
+
if (allowedMimes && metadata.mimeType) {
|
|
98
|
+
const normalized = metadata.mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
|
|
99
|
+
if (!allowedMimes.includes(normalized)) {
|
|
100
|
+
return `mime_mismatch: extension ".${ext}" does not match mimeType "${metadata.mimeType}"`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildStorageKey(
|
|
109
|
+
tenantId: TenantId,
|
|
110
|
+
entityType: string,
|
|
111
|
+
entityId: number | string,
|
|
112
|
+
fieldName: string,
|
|
113
|
+
fileName: string,
|
|
114
|
+
uniqueId: string,
|
|
115
|
+
): string {
|
|
116
|
+
const ext = fileName.split(".").pop() ?? "bin";
|
|
117
|
+
return `${tenantId}/${entityType}/${entityId}/${fieldName}/${uniqueId}.${ext}`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
|
|
3
|
+
import { createI18n } from "../index";
|
|
4
|
+
|
|
5
|
+
describe("createI18n", () => {
|
|
6
|
+
const adminFeature = defineFeature("adminUsers", (r) => {
|
|
7
|
+
r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
|
|
8
|
+
r.translations({
|
|
9
|
+
keys: {
|
|
10
|
+
"nav.title": { de: "Benutzer", en: "Users" },
|
|
11
|
+
"field.email": { de: "E-Mail", en: "Email" },
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const profileFeature = defineFeature("userProfile", (r) => {
|
|
17
|
+
r.translations({
|
|
18
|
+
keys: {
|
|
19
|
+
"nav.title": { de: "Profil", en: "Profile" },
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("looks up translation by prefixed key and locale", () => {
|
|
25
|
+
const registry = createRegistry([adminFeature]);
|
|
26
|
+
const i18n = createI18n(registry, { defaultLocale: "de" });
|
|
27
|
+
|
|
28
|
+
// Keys are prefixed: featureName:key
|
|
29
|
+
expect(i18n.t("adminUsers:nav.title", "de")).toBe("Benutzer");
|
|
30
|
+
expect(i18n.t("adminUsers:nav.title", "en")).toBe("Users");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("falls back to default locale", () => {
|
|
34
|
+
const registry = createRegistry([adminFeature]);
|
|
35
|
+
const i18n = createI18n(registry, { defaultLocale: "de" });
|
|
36
|
+
|
|
37
|
+
expect(i18n.t("adminUsers:nav.title", "fr")).toBe("Benutzer");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns key if translation not found", () => {
|
|
41
|
+
const registry = createRegistry([adminFeature]);
|
|
42
|
+
const i18n = createI18n(registry, { defaultLocale: "de" });
|
|
43
|
+
|
|
44
|
+
expect(i18n.t("nonexistent.key", "de")).toBe("nonexistent.key");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("different features have separate namespaces (no collision)", () => {
|
|
48
|
+
const registry = createRegistry([adminFeature, profileFeature]);
|
|
49
|
+
const i18n = createI18n(registry, { defaultLocale: "de" });
|
|
50
|
+
|
|
51
|
+
// Same short key, different prefix — no collision
|
|
52
|
+
expect(i18n.t("adminUsers:nav.title", "de")).toBe("Benutzer");
|
|
53
|
+
expect(i18n.t("userProfile:nav.title", "de")).toBe("Profil");
|
|
54
|
+
expect(i18n.t("adminUsers:field.email", "de")).toBe("E-Mail");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("uses default locale when none specified", () => {
|
|
58
|
+
const registry = createRegistry([adminFeature]);
|
|
59
|
+
const i18n = createI18n(registry, { defaultLocale: "de" });
|
|
60
|
+
|
|
61
|
+
expect(i18n.t("adminUsers:nav.title")).toBe("Benutzer");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("getAllKeys returns prefixed translation keys", () => {
|
|
65
|
+
const registry = createRegistry([adminFeature]);
|
|
66
|
+
const i18n = createI18n(registry, { defaultLocale: "de" });
|
|
67
|
+
|
|
68
|
+
const keys = i18n.getAllKeys();
|
|
69
|
+
expect(keys).toContain("adminUsers:nav.title");
|
|
70
|
+
expect(keys).toContain("adminUsers:field.email");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Registry, TranslationKeys } from "../engine/types";
|
|
2
|
+
|
|
3
|
+
export type I18nOptions = {
|
|
4
|
+
defaultLocale: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type I18n = {
|
|
8
|
+
t(key: string, locale?: string): string;
|
|
9
|
+
getAllKeys(): string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function createI18n(registry: Registry, options: I18nOptions): I18n {
|
|
13
|
+
const translations: TranslationKeys = registry.getAllTranslations();
|
|
14
|
+
const { defaultLocale } = options;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
t(key: string, locale?: string): string {
|
|
18
|
+
const entry = translations[key];
|
|
19
|
+
if (!entry) return key;
|
|
20
|
+
|
|
21
|
+
const resolvedLocale = locale ?? defaultLocale;
|
|
22
|
+
return entry[resolvedLocale] ?? entry[defaultLocale] ?? key;
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
getAllKeys(): string[] {
|
|
26
|
+
return Object.keys(translations);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { buildServer, type JwtHelper } from "../../api";
|
|
5
|
+
import { createRegistry, defineFeature, type SessionUser } from "../../engine";
|
|
6
|
+
import { createTestDb, createTestRedis, type TestDb, type TestRedis, TestUsers } from "../../stack";
|
|
7
|
+
import { waitFor } from "../../testing";
|
|
8
|
+
import { createJobRunner, type JobRunner } from "../job-runner";
|
|
9
|
+
|
|
10
|
+
// --- Track job executions ---
|
|
11
|
+
|
|
12
|
+
const jobExecutions: Array<{ name: string; payload: Record<string, unknown> }> = [];
|
|
13
|
+
|
|
14
|
+
// --- Features ---
|
|
15
|
+
|
|
16
|
+
// Feature A: has a write handler "orders:create"
|
|
17
|
+
const ordersFeature = defineFeature("orders", (r) => {
|
|
18
|
+
r.writeHandler(
|
|
19
|
+
"orders:create",
|
|
20
|
+
z.object({ product: z.string(), amount: z.number() }),
|
|
21
|
+
async (event) => {
|
|
22
|
+
return {
|
|
23
|
+
isSuccess: true,
|
|
24
|
+
data: { id: 1, product: event.payload.product, amount: event.payload.amount },
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
{ access: { openToAll: true } },
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Feature B: has a job that triggers on "orders:write:orders:create" (prefixed)
|
|
32
|
+
const notificationsFeature = defineFeature("notifications", (r) => {
|
|
33
|
+
r.job(
|
|
34
|
+
"sendOrderConfirmation",
|
|
35
|
+
{ trigger: { on: "orders:write:orders:create" } },
|
|
36
|
+
async (payload) => {
|
|
37
|
+
jobExecutions.push({ name: "notifications:job:send-order-confirmation", payload });
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Feature C: has ANOTHER job on the same event — both should fire
|
|
43
|
+
const analyticsFeature = defineFeature("analytics", (r) => {
|
|
44
|
+
// Dummy handler so the trackUser job trigger has a valid target
|
|
45
|
+
r.writeHandler(
|
|
46
|
+
"users:create",
|
|
47
|
+
z.object({}),
|
|
48
|
+
async () => ({
|
|
49
|
+
isSuccess: true as const,
|
|
50
|
+
data: null,
|
|
51
|
+
}),
|
|
52
|
+
{ access: { openToAll: true } },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
r.job("trackOrder", { trigger: { on: "orders:write:orders:create" } }, async (payload) => {
|
|
56
|
+
jobExecutions.push({ name: "analytics:job:track-order", payload });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Job on a different event — should NOT fire on orders.create
|
|
60
|
+
r.job("trackUser", { trigger: { on: "analytics:write:users:create" } }, async (payload) => {
|
|
61
|
+
jobExecutions.push({ name: "analytics:job:track-user", payload });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// --- Setup ---
|
|
66
|
+
|
|
67
|
+
let testDb: TestDb;
|
|
68
|
+
let testRedis: TestRedis;
|
|
69
|
+
let app: Hono;
|
|
70
|
+
let jwt: JwtHelper;
|
|
71
|
+
let jobRunner: JobRunner;
|
|
72
|
+
|
|
73
|
+
const adminUser = TestUsers.admin;
|
|
74
|
+
const JWT_SECRET = "event-trigger-test-secret-minimum-32-chars!!";
|
|
75
|
+
|
|
76
|
+
beforeAll(async () => {
|
|
77
|
+
testDb = await createTestDb();
|
|
78
|
+
testRedis = await createTestRedis();
|
|
79
|
+
|
|
80
|
+
const registry = createRegistry([ordersFeature, notificationsFeature, analyticsFeature]);
|
|
81
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
82
|
+
|
|
83
|
+
jobRunner = createJobRunner({
|
|
84
|
+
registry,
|
|
85
|
+
context: {},
|
|
86
|
+
redisUrl,
|
|
87
|
+
consumerLane: "worker",
|
|
88
|
+
queueNamePrefix: `kumiko-event-trigger-test-${Date.now()}`,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const server = buildServer({
|
|
92
|
+
registry,
|
|
93
|
+
context: {},
|
|
94
|
+
jwtSecret: JWT_SECRET,
|
|
95
|
+
dispatcherOptions: { jobRunner },
|
|
96
|
+
});
|
|
97
|
+
app = server.app;
|
|
98
|
+
jwt = server.jwt;
|
|
99
|
+
|
|
100
|
+
await jobRunner.start();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterAll(async () => {
|
|
104
|
+
await jobRunner.stop();
|
|
105
|
+
await testDb.cleanup();
|
|
106
|
+
await testRedis.cleanup();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// --- Helpers ---
|
|
110
|
+
|
|
111
|
+
async function writeApi(user: SessionUser, type: string, payload: unknown) {
|
|
112
|
+
const token = await jwt.sign(user);
|
|
113
|
+
const res = await app.request("/api/write", {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
116
|
+
body: JSON.stringify({ type, payload }),
|
|
117
|
+
});
|
|
118
|
+
return res.json();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Tests ---
|
|
122
|
+
|
|
123
|
+
describe("event trigger: write handler fires matching jobs", () => {
|
|
124
|
+
test("orders.create triggers both notification and analytics jobs", async () => {
|
|
125
|
+
jobExecutions.length = 0;
|
|
126
|
+
|
|
127
|
+
const result = await writeApi(adminUser, "orders:write:orders:create", {
|
|
128
|
+
product: "Widget",
|
|
129
|
+
amount: 3,
|
|
130
|
+
});
|
|
131
|
+
expect(result.isSuccess).toBe(true);
|
|
132
|
+
|
|
133
|
+
// Wait for BullMQ to process
|
|
134
|
+
await waitFor(() => {
|
|
135
|
+
const notification = jobExecutions.find(
|
|
136
|
+
(e) => e.name === "notifications:job:send-order-confirmation",
|
|
137
|
+
);
|
|
138
|
+
const analytics = jobExecutions.find((e) => e.name === "analytics:job:track-order");
|
|
139
|
+
|
|
140
|
+
expect(notification).toBeDefined();
|
|
141
|
+
expect(notification?.payload["product"]).toBe("Widget");
|
|
142
|
+
expect(notification?.payload["amount"]).toBe(3);
|
|
143
|
+
|
|
144
|
+
expect(analytics).toBeDefined();
|
|
145
|
+
expect(analytics?.payload["product"]).toBe("Widget");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("unrelated jobs do NOT fire", async () => {
|
|
150
|
+
// analytics.trackUser listens on "users:create", not "orders:create"
|
|
151
|
+
const trackUser = jobExecutions.find((e) => e.name === "analytics:job:track-user");
|
|
152
|
+
expect(trackUser).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("multiple orders each trigger jobs independently", async () => {
|
|
156
|
+
jobExecutions.length = 0;
|
|
157
|
+
|
|
158
|
+
await writeApi(adminUser, "orders:write:orders:create", { product: "A", amount: 1 });
|
|
159
|
+
await writeApi(adminUser, "orders:write:orders:create", { product: "B", amount: 2 });
|
|
160
|
+
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
const notifications = jobExecutions.filter(
|
|
163
|
+
(e) => e.name === "notifications:job:send-order-confirmation",
|
|
164
|
+
);
|
|
165
|
+
expect(notifications.length).toBe(2);
|
|
166
|
+
|
|
167
|
+
const products = notifications.map((e) => e.payload["product"]);
|
|
168
|
+
expect(products).toContain("A");
|
|
169
|
+
expect(products).toContain("B");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|