@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,281 @@
|
|
|
1
|
+
// Runde 3 / C.2a — ctx.fetchForWriting (Marten FetchForWriting equivalent).
|
|
2
|
+
//
|
|
3
|
+
// Claims pinned here:
|
|
4
|
+
// 1. Returns the current stream (upcasted) + its version.
|
|
5
|
+
// 2. expectedVersion mismatch throws VersionConflictError BEFORE any write.
|
|
6
|
+
// 3. appendOne reuses the handle's aggregateId/aggregateType + inherits
|
|
7
|
+
// the stream version internally — a sequence of appendOne calls writes
|
|
8
|
+
// consecutive versions without re-reading the DB.
|
|
9
|
+
|
|
10
|
+
import { sql } from "drizzle-orm";
|
|
11
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
14
|
+
import { buildDrizzleTable } from "../../db/table-builder";
|
|
15
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
16
|
+
import { UnprocessableError, writeFailure } from "../../errors";
|
|
17
|
+
import { loadAggregate } from "../../event-store";
|
|
18
|
+
import { createEntityTable, setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
19
|
+
|
|
20
|
+
// --- Feature ---
|
|
21
|
+
|
|
22
|
+
const cartEntity = createEntity({
|
|
23
|
+
table: "read_f4w_carts",
|
|
24
|
+
fields: {
|
|
25
|
+
customer: createTextField({ required: true }),
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const cartTable = buildDrizzleTable("f4wCart", cartEntity);
|
|
30
|
+
|
|
31
|
+
const cartFeature = defineFeature("f4w", (r) => {
|
|
32
|
+
r.entity("f4wCart", cartEntity);
|
|
33
|
+
|
|
34
|
+
const itemAdded = r.defineEvent("itemAdded", z.object({ sku: z.string(), qty: z.number() }));
|
|
35
|
+
const checkedOut = r.defineEvent("checkedOut", z.object({ totalCents: z.number() }));
|
|
36
|
+
|
|
37
|
+
const cartExecutor = createEventStoreExecutor(cartTable, cartEntity, {
|
|
38
|
+
entityName: "f4wCart",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Root: create a cart.
|
|
42
|
+
r.writeHandler(
|
|
43
|
+
"cart:create",
|
|
44
|
+
z.object({ customer: z.string() }),
|
|
45
|
+
async (event, ctx) => cartExecutor.create(event.payload, event.user, ctx.db),
|
|
46
|
+
{ access: { roles: ["Admin"] } },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Fetch handle, inspect events, append one atom.
|
|
50
|
+
r.writeHandler(
|
|
51
|
+
"cart:add-item",
|
|
52
|
+
z.object({ id: z.uuid(), sku: z.string(), qty: z.number() }),
|
|
53
|
+
async (event, ctx) => {
|
|
54
|
+
const stream = await ctx.fetchForWriting({
|
|
55
|
+
aggregateId: event.payload.id,
|
|
56
|
+
aggregateType: "f4wCart",
|
|
57
|
+
});
|
|
58
|
+
// Business rule probe: if already checked out, refuse.
|
|
59
|
+
const alreadyDone = stream.events.some((e) => e.type === checkedOut.name);
|
|
60
|
+
if (alreadyDone) {
|
|
61
|
+
return writeFailure(new UnprocessableError("already_checked_out"));
|
|
62
|
+
}
|
|
63
|
+
await stream.appendOne({
|
|
64
|
+
type: itemAdded.name,
|
|
65
|
+
payload: { sku: event.payload.sku, qty: event.payload.qty },
|
|
66
|
+
});
|
|
67
|
+
return { isSuccess: true as const, data: { version: stream.version } };
|
|
68
|
+
},
|
|
69
|
+
{ access: { roles: ["Admin"] } },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Fetch + multi-append in one handler (proves local version bumping).
|
|
73
|
+
r.writeHandler(
|
|
74
|
+
"cart:bulk-add",
|
|
75
|
+
z.object({ id: z.uuid(), skus: z.array(z.string()) }),
|
|
76
|
+
async (event, ctx) => {
|
|
77
|
+
const stream = await ctx.fetchForWriting({
|
|
78
|
+
aggregateId: event.payload.id,
|
|
79
|
+
aggregateType: "f4wCart",
|
|
80
|
+
});
|
|
81
|
+
for (const sku of event.payload.skus) {
|
|
82
|
+
await stream.appendOne({ type: itemAdded.name, payload: { sku, qty: 1 } });
|
|
83
|
+
}
|
|
84
|
+
return { isSuccess: true as const, data: { finalVersion: stream.version } };
|
|
85
|
+
},
|
|
86
|
+
{ access: { roles: ["Admin"] } },
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Drives the cart to checked-out state — lets the add-item handler's
|
|
90
|
+
// business-rule branch be exercised in a test.
|
|
91
|
+
r.writeHandler(
|
|
92
|
+
"cart:checkout",
|
|
93
|
+
z.object({ id: z.uuid(), totalCents: z.number() }),
|
|
94
|
+
async (event, ctx) => {
|
|
95
|
+
const stream = await ctx.fetchForWriting({
|
|
96
|
+
aggregateId: event.payload.id,
|
|
97
|
+
aggregateType: "f4wCart",
|
|
98
|
+
});
|
|
99
|
+
await stream.appendOne({
|
|
100
|
+
type: checkedOut.name,
|
|
101
|
+
payload: { totalCents: event.payload.totalCents },
|
|
102
|
+
});
|
|
103
|
+
return { isSuccess: true as const, data: {} };
|
|
104
|
+
},
|
|
105
|
+
{ access: { roles: ["Admin"] } },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Fetch with expectedVersion — OCC gate for external callers.
|
|
109
|
+
r.writeHandler(
|
|
110
|
+
"cart:add-with-occ",
|
|
111
|
+
z.object({
|
|
112
|
+
id: z.uuid(),
|
|
113
|
+
expectedVersion: z.number(),
|
|
114
|
+
sku: z.string(),
|
|
115
|
+
}),
|
|
116
|
+
async (event, ctx) => {
|
|
117
|
+
const stream = await ctx.fetchForWriting({
|
|
118
|
+
aggregateId: event.payload.id,
|
|
119
|
+
aggregateType: "f4wCart",
|
|
120
|
+
expectedVersion: event.payload.expectedVersion,
|
|
121
|
+
});
|
|
122
|
+
await stream.appendOne({
|
|
123
|
+
type: itemAdded.name,
|
|
124
|
+
payload: { sku: event.payload.sku, qty: 1 },
|
|
125
|
+
});
|
|
126
|
+
return { isSuccess: true as const, data: {} };
|
|
127
|
+
},
|
|
128
|
+
{ access: { roles: ["Admin"] } },
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// --- Stack ---
|
|
133
|
+
|
|
134
|
+
let stack: TestStack;
|
|
135
|
+
const admin = TestUsers.admin;
|
|
136
|
+
|
|
137
|
+
beforeAll(async () => {
|
|
138
|
+
stack = await setupTestStack({ features: [cartFeature], systemHooks: [] });
|
|
139
|
+
await createEntityTable(stack.db, cartEntity, "f4wCart");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterAll(async () => {
|
|
143
|
+
await stack.cleanup();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterEach(async () => {
|
|
147
|
+
await stack.db.execute(
|
|
148
|
+
sql`TRUNCATE kumiko_events, read_f4w_carts, kumiko_event_consumers RESTART IDENTITY CASCADE`,
|
|
149
|
+
);
|
|
150
|
+
await stack.eventDispatcher?.ensureRegistered();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// --- Tests ---
|
|
154
|
+
|
|
155
|
+
describe("Runde 3 / C.2a — ctx.fetchForWriting", () => {
|
|
156
|
+
test("returns current stream + version; appendOne advances the stream", async () => {
|
|
157
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
158
|
+
"f4w:write:cart:create",
|
|
159
|
+
{ customer: "alice" },
|
|
160
|
+
admin,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const result = await stack.http.writeOk<{ version: number }>(
|
|
164
|
+
"f4w:write:cart:add-item",
|
|
165
|
+
{ id: created.id, sku: "apple", qty: 3 },
|
|
166
|
+
admin,
|
|
167
|
+
);
|
|
168
|
+
// CRUD create = v1, appendOne = v2.
|
|
169
|
+
expect(result.version).toBe(2);
|
|
170
|
+
|
|
171
|
+
const events = await loadAggregate(stack.db, created.id, admin.tenantId);
|
|
172
|
+
expect(events.map((e) => e.type)).toEqual(["f4wCart.created", "f4w:event:item-added"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("multi-appendOne in one handler writes consecutive versions without re-reading", async () => {
|
|
176
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
177
|
+
"f4w:write:cart:create",
|
|
178
|
+
{ customer: "bob" },
|
|
179
|
+
admin,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const result = await stack.http.writeOk<{ finalVersion: number }>(
|
|
183
|
+
"f4w:write:cart:bulk-add",
|
|
184
|
+
{ id: created.id, skus: ["a", "b", "c"] },
|
|
185
|
+
admin,
|
|
186
|
+
);
|
|
187
|
+
// CRUD = v1, then three appendOne = v2, v3, v4. Handle reports v4.
|
|
188
|
+
expect(result.finalVersion).toBe(4);
|
|
189
|
+
|
|
190
|
+
const events = await loadAggregate(stack.db, created.id, admin.tenantId);
|
|
191
|
+
expect(events.map((e) => e.version)).toEqual([1, 2, 3, 4]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("expectedVersion mismatch throws VersionConflictError before any write lands", async () => {
|
|
195
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
196
|
+
"f4w:write:cart:create",
|
|
197
|
+
{ customer: "carol" },
|
|
198
|
+
admin,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Stream is at v1 (the create event). Caller thinks it's at v0 → conflict.
|
|
202
|
+
const res = await stack.http.write(
|
|
203
|
+
"f4w:write:cart:add-with-occ",
|
|
204
|
+
{ id: created.id, expectedVersion: 0, sku: "pear" },
|
|
205
|
+
admin,
|
|
206
|
+
);
|
|
207
|
+
const body = (await res.json()) as { error?: { code?: string } };
|
|
208
|
+
expect(body.error?.code).toBe("version_conflict");
|
|
209
|
+
|
|
210
|
+
// Stream untouched — only the original create event is present.
|
|
211
|
+
const events = await loadAggregate(stack.db, created.id, admin.tenantId);
|
|
212
|
+
expect(events).toHaveLength(1);
|
|
213
|
+
expect(events[0]?.type).toBe("f4wCart.created");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("expectedVersion match lets the append proceed", async () => {
|
|
217
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
218
|
+
"f4w:write:cart:create",
|
|
219
|
+
{ customer: "dave" },
|
|
220
|
+
admin,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const res = await stack.http.write(
|
|
224
|
+
"f4w:write:cart:add-with-occ",
|
|
225
|
+
{ id: created.id, expectedVersion: 1, sku: "peach" },
|
|
226
|
+
admin,
|
|
227
|
+
);
|
|
228
|
+
expect(res.status).toBe(200);
|
|
229
|
+
|
|
230
|
+
const events = await loadAggregate(stack.db, created.id, admin.tenantId);
|
|
231
|
+
expect(events).toHaveLength(2);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("handle.events reflects business state — happy path keeps working", async () => {
|
|
235
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
236
|
+
"f4w:write:cart:create",
|
|
237
|
+
{ customer: "eve" },
|
|
238
|
+
admin,
|
|
239
|
+
);
|
|
240
|
+
// First add — no checked-out yet, rule-probe passes.
|
|
241
|
+
const first = await stack.http.write(
|
|
242
|
+
"f4w:write:cart:add-item",
|
|
243
|
+
{ id: created.id, sku: "plum", qty: 1 },
|
|
244
|
+
admin,
|
|
245
|
+
);
|
|
246
|
+
expect(first.status).toBe(200);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("business-rule probe on handle.events: after checkout, add-item refuses", async () => {
|
|
250
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
251
|
+
"f4w:write:cart:create",
|
|
252
|
+
{ customer: "frank" },
|
|
253
|
+
admin,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Checkout lands a `checkedOut` event on the stream.
|
|
257
|
+
const checkoutRes = await stack.http.write(
|
|
258
|
+
"f4w:write:cart:checkout",
|
|
259
|
+
{ id: created.id, totalCents: 4200 },
|
|
260
|
+
admin,
|
|
261
|
+
);
|
|
262
|
+
expect(checkoutRes.status).toBe(200);
|
|
263
|
+
|
|
264
|
+
// Now add-item should observe checkedOut via stream.events and refuse.
|
|
265
|
+
const res = await stack.http.write(
|
|
266
|
+
"f4w:write:cart:add-item",
|
|
267
|
+
{ id: created.id, sku: "late-banana", qty: 1 },
|
|
268
|
+
admin,
|
|
269
|
+
);
|
|
270
|
+
const body = (await res.json()) as { isSuccess: boolean; error?: { code?: string } };
|
|
271
|
+
expect(body.isSuccess).toBe(false);
|
|
272
|
+
expect(body.error?.code).toBe("unprocessable");
|
|
273
|
+
|
|
274
|
+
// Stream untouched by the refused add — only create + checkedOut remain.
|
|
275
|
+
const events = await loadAggregate(stack.db, created.id, admin.tenantId);
|
|
276
|
+
const types = events.map((e) => e.type);
|
|
277
|
+
expect(types).toContain("f4wCart.created");
|
|
278
|
+
expect(types).toContain("f4w:event:checked-out");
|
|
279
|
+
expect(types).not.toContain("f4w:event:item-added");
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
createEntity,
|
|
5
|
+
createRegistry,
|
|
6
|
+
createTextField,
|
|
7
|
+
defineFeature,
|
|
8
|
+
type PostSaveHookFn,
|
|
9
|
+
type PreSaveHookFn,
|
|
10
|
+
type SaveContext,
|
|
11
|
+
} from "../../engine";
|
|
12
|
+
import { buildEventId, createLifecycleHooks, type SystemHooks } from "../lifecycle-pipeline";
|
|
13
|
+
|
|
14
|
+
function makeRegistry(hooks?: { preSave?: PreSaveHookFn[]; postSave?: PostSaveHookFn[] }) {
|
|
15
|
+
const feature = defineFeature("test", (r) => {
|
|
16
|
+
r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
|
|
17
|
+
// Dummy handler so hook targets resolve (boot validation requires it)
|
|
18
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
19
|
+
access: { openToAll: true },
|
|
20
|
+
});
|
|
21
|
+
if (hooks?.preSave) {
|
|
22
|
+
for (const h of hooks.preSave) r.hook("preSave", "user", h);
|
|
23
|
+
}
|
|
24
|
+
if (hooks?.postSave) {
|
|
25
|
+
for (const h of hooks.postSave) r.hook("postSave", "user", h);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
return createRegistry([feature]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const savectx: SaveContext = {
|
|
32
|
+
kind: "save",
|
|
33
|
+
id: 1,
|
|
34
|
+
data: { email: "test@test.de", tenantId: "00000000-0000-4000-8000-000000000001" },
|
|
35
|
+
changes: { email: "test@test.de" },
|
|
36
|
+
previous: {},
|
|
37
|
+
isNew: true,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// --- PreSave pipeline ---
|
|
41
|
+
|
|
42
|
+
describe("runPreSave", () => {
|
|
43
|
+
test("runs feature hooks in order", async () => {
|
|
44
|
+
const calls: string[] = [];
|
|
45
|
+
const registry = makeRegistry({
|
|
46
|
+
preSave: [
|
|
47
|
+
async (changes) => {
|
|
48
|
+
calls.push("a");
|
|
49
|
+
return changes;
|
|
50
|
+
},
|
|
51
|
+
async (changes) => {
|
|
52
|
+
calls.push("b");
|
|
53
|
+
return changes;
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const pipeline = createLifecycleHooks(registry);
|
|
59
|
+
await pipeline.runPreSave("test:write:user", { email: "x" }, {}, true, {});
|
|
60
|
+
expect(calls).toEqual(["a", "b"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("feature hooks can modify changes", async () => {
|
|
64
|
+
const registry = makeRegistry({
|
|
65
|
+
preSave: [
|
|
66
|
+
async (changes) => ({ ...changes, email: (changes["email"] as string).toLowerCase() }),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const pipeline = createLifecycleHooks(registry);
|
|
71
|
+
const result = await pipeline.runPreSave(
|
|
72
|
+
"test:write:user",
|
|
73
|
+
{ email: "MARC@TEST.DE" },
|
|
74
|
+
{},
|
|
75
|
+
true,
|
|
76
|
+
{},
|
|
77
|
+
);
|
|
78
|
+
expect(result["email"]).toBe("marc@test.de");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("system hooks run after feature hooks", async () => {
|
|
82
|
+
const calls: string[] = [];
|
|
83
|
+
const registry = makeRegistry({
|
|
84
|
+
preSave: [
|
|
85
|
+
async (changes) => {
|
|
86
|
+
calls.push("feature");
|
|
87
|
+
return changes;
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const systemHooks: SystemHooks = {
|
|
93
|
+
preSave: [
|
|
94
|
+
{
|
|
95
|
+
name: "sys",
|
|
96
|
+
priority: 1000,
|
|
97
|
+
fn: async (changes) => {
|
|
98
|
+
calls.push("system");
|
|
99
|
+
return changes;
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
106
|
+
await pipeline.runPreSave("test:write:user", {}, {}, true, {});
|
|
107
|
+
expect(calls).toEqual(["feature", "system"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("system hooks sorted by priority", async () => {
|
|
111
|
+
const calls: string[] = [];
|
|
112
|
+
const registry = makeRegistry();
|
|
113
|
+
|
|
114
|
+
const systemHooks: SystemHooks = {
|
|
115
|
+
preSave: [
|
|
116
|
+
{
|
|
117
|
+
name: "b",
|
|
118
|
+
priority: 2000,
|
|
119
|
+
fn: async (changes) => {
|
|
120
|
+
calls.push("b");
|
|
121
|
+
return changes;
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "a",
|
|
126
|
+
priority: 1000,
|
|
127
|
+
fn: async (changes) => {
|
|
128
|
+
calls.push("a");
|
|
129
|
+
return changes;
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
136
|
+
await pipeline.runPreSave("test:write:user", {}, {}, true, {});
|
|
137
|
+
expect(calls).toEqual(["a", "b"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("preSave abort stops pipeline", async () => {
|
|
141
|
+
const registry = makeRegistry({
|
|
142
|
+
preSave: [
|
|
143
|
+
async () => {
|
|
144
|
+
throw new Error("blocked");
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const pipeline = createLifecycleHooks(registry);
|
|
150
|
+
await expect(pipeline.runPreSave("test:write:user", {}, {}, true, {})).rejects.toThrow(
|
|
151
|
+
"blocked",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// --- PostSave pipeline ---
|
|
157
|
+
|
|
158
|
+
describe("runPostSave", () => {
|
|
159
|
+
test("runs feature hooks then system hooks", async () => {
|
|
160
|
+
const calls: string[] = [];
|
|
161
|
+
const registry = makeRegistry({
|
|
162
|
+
postSave: [
|
|
163
|
+
async () => {
|
|
164
|
+
calls.push("feature");
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const systemHooks: SystemHooks = {
|
|
170
|
+
postSave: [
|
|
171
|
+
{
|
|
172
|
+
name: "search",
|
|
173
|
+
priority: 1000,
|
|
174
|
+
fn: async () => {
|
|
175
|
+
calls.push("search");
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "sse",
|
|
180
|
+
priority: 1001,
|
|
181
|
+
fn: async () => {
|
|
182
|
+
calls.push("sse");
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "audit",
|
|
187
|
+
priority: 1002,
|
|
188
|
+
fn: async () => {
|
|
189
|
+
calls.push("audit");
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
196
|
+
await pipeline.runPostSave("test:write:user", savectx, {});
|
|
197
|
+
expect(calls).toEqual(["feature", "search", "sse", "audit"]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("postSave errors don't throw — logged and continued", async () => {
|
|
201
|
+
const calls: string[] = [];
|
|
202
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
203
|
+
|
|
204
|
+
const registry = makeRegistry({
|
|
205
|
+
postSave: [
|
|
206
|
+
async () => {
|
|
207
|
+
throw new Error("feature-fail");
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const systemHooks: SystemHooks = {
|
|
213
|
+
postSave: [
|
|
214
|
+
{
|
|
215
|
+
name: "search",
|
|
216
|
+
priority: 1000,
|
|
217
|
+
fn: async () => {
|
|
218
|
+
calls.push("search-ran");
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
225
|
+
// Should not throw
|
|
226
|
+
await pipeline.runPostSave("test:write:user", savectx, {});
|
|
227
|
+
|
|
228
|
+
// System hook still ran despite feature hook failure
|
|
229
|
+
expect(calls).toContain("search-ran");
|
|
230
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
231
|
+
consoleSpy.mockRestore();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("system hook failure doesn't block other system hooks", async () => {
|
|
235
|
+
const calls: string[] = [];
|
|
236
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
237
|
+
|
|
238
|
+
const registry = makeRegistry();
|
|
239
|
+
|
|
240
|
+
const systemHooks: SystemHooks = {
|
|
241
|
+
postSave: [
|
|
242
|
+
{
|
|
243
|
+
name: "search",
|
|
244
|
+
priority: 1000,
|
|
245
|
+
fn: async () => {
|
|
246
|
+
throw new Error("meili-down");
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: "sse",
|
|
251
|
+
priority: 1001,
|
|
252
|
+
fn: async () => {
|
|
253
|
+
calls.push("sse-ran");
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "audit",
|
|
258
|
+
priority: 1002,
|
|
259
|
+
fn: async () => {
|
|
260
|
+
calls.push("audit-ran");
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
267
|
+
await pipeline.runPostSave("test:write:user", savectx, {});
|
|
268
|
+
|
|
269
|
+
expect(calls).toEqual(["sse-ran", "audit-ran"]);
|
|
270
|
+
consoleSpy.mockRestore();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// --- Phase routing ---
|
|
275
|
+
//
|
|
276
|
+
// runPostSave takes a phase parameter and fires ONLY hooks matching that phase.
|
|
277
|
+
// Error semantics also differ per phase:
|
|
278
|
+
// - inTransaction hooks throw on error (to roll back the transaction)
|
|
279
|
+
// - afterCommit hooks are best-effort (errors are logged, never thrown)
|
|
280
|
+
|
|
281
|
+
describe("runPostSave phase routing", () => {
|
|
282
|
+
test("runs only hooks matching the given phase", async () => {
|
|
283
|
+
const calls: string[] = [];
|
|
284
|
+
const feature = defineFeature("phases", (r) => {
|
|
285
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
286
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
287
|
+
access: { openToAll: true },
|
|
288
|
+
});
|
|
289
|
+
r.hook(
|
|
290
|
+
"postSave",
|
|
291
|
+
"user",
|
|
292
|
+
async () => {
|
|
293
|
+
calls.push("inTx");
|
|
294
|
+
},
|
|
295
|
+
{ phase: "inTransaction" },
|
|
296
|
+
);
|
|
297
|
+
r.hook("postSave", "user", async () => {
|
|
298
|
+
calls.push("afterCommit");
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
const registry = createRegistry([feature]);
|
|
302
|
+
const pipeline = createLifecycleHooks(registry);
|
|
303
|
+
|
|
304
|
+
await pipeline.runPostSave("phases:write:user", savectx, {}, "inTransaction");
|
|
305
|
+
expect(calls).toEqual(["inTx"]);
|
|
306
|
+
|
|
307
|
+
calls.length = 0;
|
|
308
|
+
await pipeline.runPostSave("phases:write:user", savectx, {}, "afterCommit");
|
|
309
|
+
expect(calls).toEqual(["afterCommit"]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("inTransaction phase: hook errors throw (to roll back)", async () => {
|
|
313
|
+
const feature = defineFeature("phases", (r) => {
|
|
314
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
315
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
316
|
+
access: { openToAll: true },
|
|
317
|
+
});
|
|
318
|
+
r.hook(
|
|
319
|
+
"postSave",
|
|
320
|
+
"user",
|
|
321
|
+
async () => {
|
|
322
|
+
throw new Error("inTx-hook-boom");
|
|
323
|
+
},
|
|
324
|
+
{ phase: "inTransaction" },
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
const registry = createRegistry([feature]);
|
|
328
|
+
const pipeline = createLifecycleHooks(registry);
|
|
329
|
+
|
|
330
|
+
await expect(
|
|
331
|
+
pipeline.runPostSave("phases:write:user", savectx, {}, "inTransaction"),
|
|
332
|
+
).rejects.toThrow("inTx-hook-boom");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("afterCommit phase: hook errors are logged, never thrown", async () => {
|
|
336
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
337
|
+
const afterRan: string[] = [];
|
|
338
|
+
const feature = defineFeature("phases", (r) => {
|
|
339
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
340
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
341
|
+
access: { openToAll: true },
|
|
342
|
+
});
|
|
343
|
+
r.hook("postSave", "user", async () => {
|
|
344
|
+
throw new Error("afterCommit-boom");
|
|
345
|
+
});
|
|
346
|
+
r.hook("postSave", "user", async () => {
|
|
347
|
+
afterRan.push("second");
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
const registry = createRegistry([feature]);
|
|
351
|
+
const pipeline = createLifecycleHooks(registry);
|
|
352
|
+
|
|
353
|
+
// Must not throw — errors are swallowed + logged
|
|
354
|
+
await pipeline.runPostSave("phases:write:user", savectx, {}, "afterCommit");
|
|
355
|
+
|
|
356
|
+
// Subsequent hooks still fire (failure in one hook doesn't block the rest)
|
|
357
|
+
expect(afterRan).toEqual(["second"]);
|
|
358
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
359
|
+
consoleSpy.mockRestore();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("system hooks respect their phase setting", async () => {
|
|
363
|
+
const feature = defineFeature("phases", (r) => {
|
|
364
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
365
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
366
|
+
access: { openToAll: true },
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
const registry = createRegistry([feature]);
|
|
370
|
+
|
|
371
|
+
const calls: string[] = [];
|
|
372
|
+
const systemHooks: SystemHooks = {
|
|
373
|
+
postSave: [
|
|
374
|
+
{
|
|
375
|
+
name: "audit",
|
|
376
|
+
priority: 1002,
|
|
377
|
+
phase: "inTransaction",
|
|
378
|
+
fn: async () => {
|
|
379
|
+
calls.push("audit");
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: "sse",
|
|
384
|
+
priority: 1001,
|
|
385
|
+
phase: "afterCommit",
|
|
386
|
+
fn: async () => {
|
|
387
|
+
calls.push("sse");
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
};
|
|
392
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
393
|
+
|
|
394
|
+
await pipeline.runPostSave("phases:write:user", savectx, {}, "inTransaction");
|
|
395
|
+
expect(calls).toEqual(["audit"]);
|
|
396
|
+
|
|
397
|
+
calls.length = 0;
|
|
398
|
+
await pipeline.runPostSave("phases:write:user", savectx, {}, "afterCommit");
|
|
399
|
+
expect(calls).toEqual(["sse"]);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe("buildEventId — dedup key construction", () => {
|
|
404
|
+
test("includes handler, id, version and phase when payload is complete", () => {
|
|
405
|
+
const payload = { id: 42, data: { version: 3 } };
|
|
406
|
+
expect(buildEventId("users:write:user:create", payload, "postSave:afterCommit")).toBe(
|
|
407
|
+
"users:write:user:create:42:3:postSave:afterCommit",
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("falls back to version 0 when payload has no data.version", () => {
|
|
412
|
+
const payload = { id: 42 };
|
|
413
|
+
expect(buildEventId("handler", payload, "phase")).toBe("handler:42:0:phase");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("returns null when payload is not an object (no dedup possible)", () => {
|
|
417
|
+
expect(buildEventId("handler", null, "phase")).toBeNull();
|
|
418
|
+
expect(buildEventId("handler", undefined, "phase")).toBeNull();
|
|
419
|
+
expect(buildEventId("handler", "string", "phase")).toBeNull();
|
|
420
|
+
expect(buildEventId("handler", 123, "phase")).toBeNull();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("returns null when payload has no id — triggers the warn-log path in runHookSet", () => {
|
|
424
|
+
expect(buildEventId("handler", {}, "phase")).toBeNull();
|
|
425
|
+
expect(buildEventId("handler", { data: { version: 5 } }, "phase")).toBeNull();
|
|
426
|
+
// id=0 is also treated as absent: serial PKs start at 1, so 0 means
|
|
427
|
+
// "never inserted" — safer to skip dedup than to collide on a sentinel.
|
|
428
|
+
expect(buildEventId("handler", { id: 0 }, "phase")).toBeNull();
|
|
429
|
+
});
|
|
430
|
+
});
|