@cosmicdrift/kumiko-framework 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -38
- package/package.json +21 -2
- package/src/__tests__/anonymous-access.integration.test.ts +185 -0
- package/src/__tests__/consumer-cli.integration.test.ts +172 -0
- package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
- package/src/__tests__/schema-cli.integration.test.ts +50 -0
- package/src/__tests__/store-table.integration.test.ts +93 -0
- package/src/api/__tests__/api.test.ts +373 -0
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
- package/src/api/__tests__/batch.integration.test.ts +101 -11
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +200 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/api-constants.ts +11 -0
- package/src/api/auth-middleware.ts +248 -40
- package/src/api/auth-routes.ts +576 -95
- package/src/api/index.ts +13 -4
- package/src/api/jwt.ts +170 -11
- package/src/api/pat-scope.ts +14 -0
- package/src/api/pii-leak-guard.ts +47 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +169 -2
- package/src/api/server.ts +112 -16
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/__tests__/write-brand.test.ts +21 -0
- package/src/bun-db/connection.ts +3 -3
- package/src/bun-db/index.ts +2 -0
- package/src/bun-db/query.ts +105 -32
- package/src/consumer-cli.ts +134 -0
- package/src/crypto/__tests__/blind-index.test.ts +130 -0
- package/src/crypto/__tests__/event-pii.test.ts +161 -0
- package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
- package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
- package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
- package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
- package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
- package/src/crypto/blind-index.ts +118 -0
- package/src/crypto/event-pii.ts +70 -0
- package/src/crypto/in-memory-kms-adapter.ts +50 -0
- package/src/crypto/index.ts +67 -0
- package/src/crypto/kms-adapter.ts +2 -0
- package/src/crypto/pg-kms-adapter.ts +295 -0
- package/src/crypto/pii-field-encryption.ts +248 -0
- package/src/crypto/request-kms-cache.ts +38 -0
- package/src/crypto/subject-resolver.ts +91 -0
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
- package/src/db/__tests__/blind-index.integration.test.ts +248 -0
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/collect-table-metas.test.ts +10 -10
- package/src/db/__tests__/config-seed.integration.test.ts +13 -5
- package/src/db/__tests__/dialect-instant.test.ts +1 -4
- package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
- package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
- package/src/db/__tests__/feature-table-sources.test.ts +34 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
- package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-generator.test.ts +21 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
- package/src/db/__tests__/rebuild-marker.test.ts +13 -3
- package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
- package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
- package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/apply-entity-event.ts +18 -14
- package/src/db/blind-index-cleanup.ts +55 -0
- package/src/db/bun-provider.ts +2 -2
- package/src/db/collect-table-metas.ts +6 -7
- package/src/db/config-seed.ts +9 -9
- package/src/db/connection.ts +7 -12
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +20 -26
- package/src/db/entity-field-encryption.ts +81 -24
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +63 -90
- package/src/db/event-store-executor-context.ts +404 -0
- package/src/db/event-store-executor-read.ts +275 -0
- package/src/db/event-store-executor-write.ts +644 -0
- package/src/db/event-store-executor.ts +28 -1155
- package/src/db/feature-table-sources.ts +6 -5
- package/src/db/index.ts +20 -3
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-generator.ts +39 -6
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +9 -1
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/backfill-pii.ts +277 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/queries/event-store.ts +126 -19
- package/src/db/queries/projection-rebuild.ts +22 -8
- package/src/db/queries/shadow-swap.ts +183 -0
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/query-api.ts +1 -0
- package/src/db/query.ts +1 -0
- package/src/db/rebuild-marker.ts +18 -2
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +257 -0
- package/src/db/schema-inspection.ts +1 -1
- package/src/db/table-builder.ts +102 -71
- package/src/db/tenant-db.ts +15 -53
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
- package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
- package/src/engine/__tests__/boot-validator.test.ts +219 -15
- package/src/engine/__tests__/build-app-schema.test.ts +82 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
- package/src/engine/__tests__/build-target.test.ts +3 -11
- package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
- package/src/engine/__tests__/config-helpers.test.ts +16 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +163 -1
- package/src/engine/__tests__/entity-handlers.test.ts +80 -0
- package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/factories-time.test.ts +2 -66
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
- package/src/engine/__tests__/feature-manifest.test.ts +31 -1
- package/src/engine/__tests__/field-access.test.ts +23 -1
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/nav.test.ts +86 -1
- package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
- package/src/engine/__tests__/post-query-hook.test.ts +7 -7
- package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
- package/src/engine/__tests__/registry.test.ts +144 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/screen.test.ts +147 -1
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
- package/src/engine/__tests__/store-table.test.ts +229 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
- package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
- package/src/engine/boot-validator/action-wiring.ts +149 -0
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/config-deps.ts +40 -4
- package/src/engine/boot-validator/entity-handler.ts +20 -21
- package/src/engine/boot-validator/entity-list-screens.ts +88 -0
- package/src/engine/boot-validator/gdpr-storage.ts +0 -20
- package/src/engine/boot-validator/i18n-keys.ts +58 -4
- package/src/engine/boot-validator/index.ts +33 -12
- package/src/engine/boot-validator/nav.ts +125 -0
- package/src/engine/boot-validator/pii-retention.ts +95 -10
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/build-app-schema.ts +16 -0
- package/src/engine/build-config-feature-schema.ts +44 -7
- package/src/engine/codemod/pipeline-codemod.ts +5 -5
- package/src/engine/config-helpers.ts +15 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/define-feature.ts +95 -947
- package/src/engine/define-handler.ts +28 -94
- package/src/engine/define-workflow.ts +1 -1
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/entity-handlers.ts +76 -11
- package/src/engine/extensions/tenant-data.ts +19 -0
- package/src/engine/extensions/user-data.ts +29 -2
- package/src/engine/factories.ts +8 -49
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
- package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
- package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
- package/src/engine/feature-ast/extractors/events.ts +322 -0
- package/src/engine/feature-ast/extractors/handlers.ts +234 -0
- package/src/engine/feature-ast/extractors/hooks.ts +243 -0
- package/src/engine/feature-ast/extractors/index.ts +34 -31
- package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
- package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/extractors/round6.ts +2 -36
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/index.ts +2 -4
- package/src/engine/feature-ast/parse.ts +130 -23
- package/src/engine/feature-ast/patch.ts +39 -50
- package/src/engine/feature-ast/patcher.ts +37 -38
- package/src/engine/feature-ast/patterns.ts +50 -62
- package/src/engine/feature-ast/render.ts +67 -44
- package/src/engine/feature-builder-state.ts +168 -0
- package/src/engine/feature-config-events-jobs.ts +403 -0
- package/src/engine/feature-entity-handlers.ts +208 -0
- package/src/engine/feature-manifest.ts +2 -1
- package/src/engine/feature-ui-extensions.ts +500 -0
- package/src/engine/field-access.ts +13 -2
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +16 -0
- package/src/engine/index.ts +23 -7
- package/src/engine/membership-roles.ts +13 -0
- package/src/engine/object-form.ts +27 -0
- package/src/engine/ownership.ts +26 -79
- package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
- package/src/engine/pattern-library/library.ts +44 -1152
- package/src/engine/pattern-library/mixed-schemas.ts +450 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +75 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/pipeline.ts +6 -11
- package/src/engine/registry-facade.ts +362 -0
- package/src/engine/registry-ingest.ts +478 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +642 -0
- package/src/engine/registry.ts +78 -1658
- package/src/engine/run-pipeline.ts +1 -1
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/soft-delete-cleanup.ts +6 -2
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -482
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -972
- package/src/engine/types/fields.ts +2 -675
- package/src/engine/types/handlers.ts +2 -774
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -54
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +86 -40
- package/src/engine/types/nav.ts +2 -63
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -138
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -574
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -132
- package/src/engine/types/workspace.ts +2 -49
- package/src/engine/validate-projection-allowlist.ts +6 -6
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
- package/src/entrypoint/index.ts +49 -6
- package/src/errors/classes.ts +22 -1
- package/src/errors/field-issue.ts +0 -3
- package/src/errors/index.ts +1 -1
- package/src/errors/write-error-info.ts +10 -22
- package/src/es-ops/README.md +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
- package/src/es-ops/context.ts +4 -2
- package/src/es-ops/types.ts +8 -1
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
- package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
- package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
- package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
- package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +64 -78
- package/src/event-store/events-schema.ts +11 -13
- package/src/event-store/index.ts +18 -3
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/event-store/snapshot.ts +63 -40
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/file-ref-entity.test.ts +8 -0
- package/src/files/__tests__/files.integration.test.ts +24 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/provider-resolver.test.ts +70 -0
- package/src/files/__tests__/write-stream.test.ts +13 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/file-ref-entity.ts +2 -2
- package/src/files/file-routes.ts +13 -0
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +27 -8
- package/src/files/provider-resolver.ts +31 -20
- package/src/files/types.ts +13 -55
- package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
- package/src/i18n/required-surface-keys.ts +120 -1
- package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
- package/src/jobs/job-runner.ts +82 -12
- package/src/logging/types.ts +1 -7
- package/src/migrations/pending-rebuilds.ts +1 -1
- package/src/observability/__tests__/observability.integration.test.ts +4 -1
- package/src/observability/__tests__/recording-tracer.test.ts +6 -4
- package/src/observability/index.ts +2 -0
- package/src/observability/noop-provider.ts +0 -9
- package/src/observability/recording-tracer.ts +0 -12
- package/src/observability/standard-metrics.ts +64 -2
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -64
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
- package/src/pipeline/dispatch-batch.ts +187 -0
- package/src/pipeline/dispatch-query.ts +165 -0
- package/src/pipeline/dispatch-shared.ts +826 -0
- package/src/pipeline/dispatch-stream.ts +90 -0
- package/src/pipeline/dispatch-write.ts +451 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +44 -1372
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +30 -2
- package/src/pipeline/event-dispatcher-admin.ts +293 -0
- package/src/pipeline/event-dispatcher-delivery.ts +305 -0
- package/src/pipeline/event-dispatcher.ts +85 -542
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/msp-rebuild.ts +42 -3
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/projection-rebuild.ts +105 -3
- package/src/pipeline/system-hooks.ts +144 -1
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +16 -32
- package/src/schema-cli.ts +76 -16
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
- package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
- package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
- package/src/search/index.ts +6 -0
- package/src/search/meilisearch-adapter.ts +13 -12
- package/src/search/reindex-entity.ts +177 -0
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/dek-cache.ts +26 -5
- package/src/secrets/envelope-cipher.ts +53 -0
- package/src/secrets/envelope.ts +5 -3
- package/src/secrets/index.ts +13 -1
- package/src/secrets/stored-envelope.ts +46 -0
- package/src/secrets/types.ts +2 -162
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +4 -3
- package/src/stack/redis.ts +8 -0
- package/src/stack/request-helper.ts +38 -2
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +249 -131
- package/src/testing/__tests__/late-bound.test.ts +32 -0
- package/src/testing/__tests__/wait-for.test.ts +59 -0
- package/src/testing/boot-validator-fixture.ts +121 -0
- package/src/testing/e2e-generator.ts +8 -0
- package/src/testing/file-provider-contract.ts +104 -0
- package/src/testing/handler-context.ts +3 -1
- package/src/testing/index.ts +5 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/mutable-master-key-provider.ts +29 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/__tests__/tz-dateline.test.ts +8 -10
- package/src/time/geo-tz.ts +1 -32
- package/src/time/index.ts +1 -0
- package/src/time/legacy-date.ts +18 -0
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +44 -85
- package/src/ui-types/app-schema.ts +12 -0
- package/src/ui-types/index.ts +20 -7
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- package/src/__tests__/raw-table.integration.test.ts +0 -116
- package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
- package/src/db/__tests__/encryption.test.ts +0 -39
- package/src/db/encryption.ts +0 -39
- package/src/db/row-helpers.ts +0 -4
- package/src/engine/__tests__/raw-table.test.ts +0 -150
- package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
- package/src/engine/feature-ast/extractors/round4.ts +0 -1366
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// #760 — poison-event quarantine during projection rebuild.
|
|
2
|
+
//
|
|
3
|
+
// A single historical event whose apply throws used to abort the WHOLE
|
|
4
|
+
// rebuild (tx rollback, status failed) with no recovery path short of
|
|
5
|
+
// hand-editing the event. With quarantine mode the rebuild confines each
|
|
6
|
+
// apply to a savepoint: the poison event is skipped, recorded into
|
|
7
|
+
// kumiko_rebuild_dead_letters, and the replay completes.
|
|
8
|
+
//
|
|
9
|
+
// Covers both rebuild flavors:
|
|
10
|
+
// - single-stream: RebuildDeps.errorPolicy.skipApplyErrors (per run)
|
|
11
|
+
// - MSP: MspErrorMode.rebuild.skipApplyErrors (per definition — declared
|
|
12
|
+
// API that was previously never honored by rebuildMultiStreamProjection)
|
|
13
|
+
// and both poison flavors:
|
|
14
|
+
// - JS throw (no SQL executed)
|
|
15
|
+
// - SQL error (aborts the tx → proves the savepoint is load-bearing)
|
|
16
|
+
|
|
17
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { integer as pgInteger, table as pgTable, uuid as pgUuid } from "../../db/dialect";
|
|
20
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
21
|
+
import { asRawClient } from "../../db/query";
|
|
22
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
23
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
24
|
+
import { createEntity, createTextField, defineApply, defineFeature } from "../../engine";
|
|
25
|
+
import type { ProjectionDefinition } from "../../engine/types";
|
|
26
|
+
import { append, createEventsTable } from "../../event-store";
|
|
27
|
+
import { listRebuildDeadLetters } from "../../event-store/rebuild-dead-letter";
|
|
28
|
+
import {
|
|
29
|
+
createProjectionStateTable,
|
|
30
|
+
getConsumerState,
|
|
31
|
+
getProjectionState,
|
|
32
|
+
rebuildMultiStreamProjection,
|
|
33
|
+
rebuildProjection,
|
|
34
|
+
} from "../../pipeline";
|
|
35
|
+
import {
|
|
36
|
+
setupTestStack,
|
|
37
|
+
type TestStack,
|
|
38
|
+
TestUsers,
|
|
39
|
+
unsafeCreateEntityTable,
|
|
40
|
+
unsafePushTables,
|
|
41
|
+
} from "../../stack";
|
|
42
|
+
|
|
43
|
+
// --- Fixtures ---
|
|
44
|
+
|
|
45
|
+
const POISON_JS = "poison-js";
|
|
46
|
+
const POISON_SQL = "poison-sql";
|
|
47
|
+
|
|
48
|
+
const itemEntity = createEntity({
|
|
49
|
+
table: "read_poison_items",
|
|
50
|
+
fields: {
|
|
51
|
+
groupId: createTextField({ required: true }),
|
|
52
|
+
name: createTextField({ required: true }),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const itemTable = buildEntityTable("poison-item", itemEntity);
|
|
56
|
+
|
|
57
|
+
const counterTable = pgTable("read_poison_counter", {
|
|
58
|
+
groupId: pgUuid("group_id").primaryKey(),
|
|
59
|
+
tenantId: pgUuid("tenant_id").notNull(),
|
|
60
|
+
itemCount: pgInteger("item_count").notNull().default(0),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const mspCounterTable = pgTable("read_poison_msp_counter", {
|
|
64
|
+
groupId: pgUuid("group_id").primaryKey(),
|
|
65
|
+
tenantId: pgUuid("tenant_id").notNull(),
|
|
66
|
+
itemCount: pgInteger("item_count").notNull().default(0),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
async function bump(tx: unknown, tableName: string, groupId: string, tenantId: string) {
|
|
70
|
+
await asRawClient(tx).unsafe(
|
|
71
|
+
`INSERT INTO "${tableName}" (group_id, tenant_id, item_count) VALUES ($1::uuid, $2::uuid, 1) ON CONFLICT (group_id) DO UPDATE SET item_count = "${tableName}".item_count + 1`,
|
|
72
|
+
[groupId, tenantId],
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Poison by payload marker. POISON_SQL runs a genuinely failing statement so
|
|
77
|
+
// the surrounding tx would be in 25P02 without the savepoint.
|
|
78
|
+
async function applyOrPoison(
|
|
79
|
+
tx: unknown,
|
|
80
|
+
tableName: string,
|
|
81
|
+
payload: { groupId: string; name: string },
|
|
82
|
+
tenantId: string,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
if (payload.name === POISON_JS) throw new Error("intentional js poison");
|
|
85
|
+
if (payload.name === POISON_SQL) {
|
|
86
|
+
await asRawClient(tx).unsafe(
|
|
87
|
+
`INSERT INTO "${tableName}" (group_id, tenant_id, item_count) VALUES ('not-a-uuid', $1::uuid, 1)`,
|
|
88
|
+
[tenantId],
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await bump(tx, tableName, payload.groupId, tenantId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type ItemPayload = { groupId: string; name: string };
|
|
96
|
+
|
|
97
|
+
const poisonProjection: ProjectionDefinition = {
|
|
98
|
+
name: "poison-counter",
|
|
99
|
+
source: "poison-item",
|
|
100
|
+
table: counterTable,
|
|
101
|
+
apply: {
|
|
102
|
+
"poison-item.created": defineApply<ItemPayload>(async (event, tx) => {
|
|
103
|
+
await applyOrPoison(tx, "read_poison_counter", event.payload, event.tenantId);
|
|
104
|
+
}),
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const MSP_EVENT_SHORT = "poison-noted";
|
|
109
|
+
|
|
110
|
+
const feature = defineFeature("poisontest", (r) => {
|
|
111
|
+
r.entity("poison-item", itemEntity);
|
|
112
|
+
r.projection(poisonProjection);
|
|
113
|
+
const noted = r.defineEvent(MSP_EVENT_SHORT, z.object({ groupId: z.uuid(), name: z.string() }));
|
|
114
|
+
r.multiStreamProjection({
|
|
115
|
+
name: "poison-msp-counter",
|
|
116
|
+
table: mspCounterTable,
|
|
117
|
+
errorMode: { rebuild: { skipApplyErrors: true } },
|
|
118
|
+
apply: {
|
|
119
|
+
[noted.name]: async (event, tx) => {
|
|
120
|
+
const p = event.payload as ItemPayload; // @cast-boundary engine-payload
|
|
121
|
+
await applyOrPoison(tx, "read_poison_msp_counter", p, event.tenantId);
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const admin = TestUsers.admin;
|
|
128
|
+
const PROJECTION = "poisontest:projection:poison-counter";
|
|
129
|
+
const MSP = "poisontest:projection:poison-msp-counter";
|
|
130
|
+
const MSP_EVENT = "poisontest:event:poison-noted";
|
|
131
|
+
const GROUP = "00000000-0000-4000-8000-00000000cafe";
|
|
132
|
+
|
|
133
|
+
let stack: TestStack;
|
|
134
|
+
let tdb: TenantDb;
|
|
135
|
+
|
|
136
|
+
const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "poison-item" });
|
|
137
|
+
|
|
138
|
+
beforeAll(async () => {
|
|
139
|
+
stack = await setupTestStack({ features: [feature] });
|
|
140
|
+
await unsafeCreateEntityTable(stack.db, itemEntity, "poison-item");
|
|
141
|
+
await createEventsTable(stack.db);
|
|
142
|
+
await createProjectionStateTable(stack.db);
|
|
143
|
+
await unsafePushTables(stack.db, {
|
|
144
|
+
poisonCounter: counterTable,
|
|
145
|
+
poisonMspCounter: mspCounterTable,
|
|
146
|
+
});
|
|
147
|
+
tdb = createTenantDb(stack.db, admin.tenantId);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
afterAll(async () => {
|
|
151
|
+
await stack.cleanup();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
beforeEach(async () => {
|
|
155
|
+
await asRawClient(stack.db).unsafe(
|
|
156
|
+
`TRUNCATE kumiko_events, read_poison_items, read_poison_counter, read_poison_msp_counter, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
157
|
+
);
|
|
158
|
+
await asRawClient(stack.db).unsafe(`DROP TABLE IF EXISTS kumiko_rebuild_dead_letters`);
|
|
159
|
+
if (stack.eventDispatcher) await stack.eventDispatcher.ensureRegistered();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
async function createItem(name: string): Promise<void> {
|
|
163
|
+
await executor.create({ groupId: GROUP, name }, admin, tdb);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function getCount(table: string): Promise<number | undefined> {
|
|
167
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
168
|
+
`SELECT item_count FROM "${table}" WHERE group_id = $1::uuid`,
|
|
169
|
+
[GROUP],
|
|
170
|
+
)) as ReadonlyArray<{ item_count: number }>;
|
|
171
|
+
return rows[0]?.item_count;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
describe("rebuildProjection — poison event, strict default (#760)", () => {
|
|
175
|
+
test("apply throw aborts the rebuild: status failed, no dead letters", async () => {
|
|
176
|
+
await createItem("good-1");
|
|
177
|
+
await createItem(POISON_JS);
|
|
178
|
+
await createItem("good-2");
|
|
179
|
+
|
|
180
|
+
await expect(
|
|
181
|
+
rebuildProjection(PROJECTION, { db: stack.db, registry: stack.registry }),
|
|
182
|
+
).rejects.toThrow("intentional js poison");
|
|
183
|
+
|
|
184
|
+
const state = await getProjectionState(stack.db, PROJECTION);
|
|
185
|
+
expect(state?.status).toBe("failed");
|
|
186
|
+
// Strict mode never provisions/records dead letters.
|
|
187
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
188
|
+
`SELECT to_regclass('public.kumiko_rebuild_dead_letters') AS t`,
|
|
189
|
+
)) as ReadonlyArray<{ t: string | null }>;
|
|
190
|
+
expect(rows[0]?.t).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("rebuildProjection — quarantine mode (#760)", () => {
|
|
195
|
+
test("js- and sql-poison events are skipped, recorded, and the rebuild completes", async () => {
|
|
196
|
+
await createItem("good-1");
|
|
197
|
+
await createItem(POISON_JS);
|
|
198
|
+
// sql-poison proves the savepoint: without it the failed statement puts
|
|
199
|
+
// the whole rebuild tx into 25P02 and every later apply fails too.
|
|
200
|
+
await createItem(POISON_SQL);
|
|
201
|
+
await createItem("good-2");
|
|
202
|
+
|
|
203
|
+
const result = await rebuildProjection(PROJECTION, {
|
|
204
|
+
db: stack.db,
|
|
205
|
+
registry: stack.registry,
|
|
206
|
+
errorPolicy: { skipApplyErrors: true },
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(result.eventsSkipped).toBe(2);
|
|
210
|
+
expect(result.eventsProcessed).toBe(4);
|
|
211
|
+
expect(await getCount("read_poison_counter")).toBe(2);
|
|
212
|
+
|
|
213
|
+
const state = await getProjectionState(stack.db, PROJECTION);
|
|
214
|
+
expect(state?.status).toBe("idle");
|
|
215
|
+
|
|
216
|
+
const deadLetters = await listRebuildDeadLetters(stack.db, { projectionName: PROJECTION });
|
|
217
|
+
expect(deadLetters).toHaveLength(2);
|
|
218
|
+
const messages = deadLetters.map((d) => d.errorMessage).sort();
|
|
219
|
+
expect(messages[0]).toContain("poison");
|
|
220
|
+
expect(deadLetters.every((d) => d.eventType === "poison-item.created")).toBe(true);
|
|
221
|
+
expect(deadLetters.every((d) => d.aggregateType === "poison-item")).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("clean run in quarantine mode: zero skipped, no dead letters", async () => {
|
|
225
|
+
await createItem("good-1");
|
|
226
|
+
await createItem("good-2");
|
|
227
|
+
|
|
228
|
+
const result = await rebuildProjection(PROJECTION, {
|
|
229
|
+
db: stack.db,
|
|
230
|
+
registry: stack.registry,
|
|
231
|
+
errorPolicy: { skipApplyErrors: true },
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(result.eventsSkipped).toBe(0);
|
|
235
|
+
expect(await getCount("read_poison_counter")).toBe(2);
|
|
236
|
+
expect(await listRebuildDeadLetters(stack.db, { projectionName: PROJECTION })).toHaveLength(0);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("rebuildMultiStreamProjection — errorMode.rebuild.skipApplyErrors (#760)", () => {
|
|
241
|
+
async function appendMspEvent(name: string, expectedVersion: number): Promise<void> {
|
|
242
|
+
await append(stack.db, {
|
|
243
|
+
aggregateId: GROUP,
|
|
244
|
+
aggregateType: "poison-note",
|
|
245
|
+
tenantId: admin.tenantId,
|
|
246
|
+
expectedVersion,
|
|
247
|
+
type: MSP_EVENT,
|
|
248
|
+
payload: { groupId: GROUP, name },
|
|
249
|
+
metadata: { userId: admin.id },
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
test("declared rebuild policy is honored: poison skipped + recorded, cursor advanced", async () => {
|
|
254
|
+
await appendMspEvent("good-1", 0);
|
|
255
|
+
await appendMspEvent(POISON_SQL, 1);
|
|
256
|
+
await appendMspEvent("good-2", 2);
|
|
257
|
+
|
|
258
|
+
const result = await rebuildMultiStreamProjection(MSP, {
|
|
259
|
+
db: stack.db,
|
|
260
|
+
registry: stack.registry,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(result.eventsSkipped).toBe(1);
|
|
264
|
+
expect(result.eventsProcessed).toBe(3);
|
|
265
|
+
expect(await getCount("read_poison_msp_counter")).toBe(2);
|
|
266
|
+
|
|
267
|
+
const deadLetters = await listRebuildDeadLetters(stack.db, { projectionName: MSP });
|
|
268
|
+
expect(deadLetters).toHaveLength(1);
|
|
269
|
+
expect(deadLetters[0]?.eventType).toBe(MSP_EVENT);
|
|
270
|
+
|
|
271
|
+
const consumer = await getConsumerState(stack.db, MSP);
|
|
272
|
+
expect(consumer?.lastProcessedEventId).toBe(result.lastProcessedEventId);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Issue #1038 — ctx.tryAppendEvent (savepoint-scoped append).
|
|
2
|
+
//
|
|
3
|
+
// Claims pinned here:
|
|
4
|
+
// 1. Happy path: first append on a fresh aggregate succeeds, returns
|
|
5
|
+
// { ok: true, event } with the stored event.
|
|
6
|
+
// 2. Non-poisoning: when tryAppendEvent loses a version-conflict race, the
|
|
7
|
+
// rest of the handler's transaction still commits (a write AFTER the
|
|
8
|
+
// call in the same handler is not rolled back) — this is the whole
|
|
9
|
+
// reason the primitive exists over a bare try/catch around
|
|
10
|
+
// unsafeAppendEvent (Bun.SQL/postgres.js abort the entire begin() on
|
|
11
|
+
// an uncaught statement error, SQLSTATE 25P02, even if the JS error is
|
|
12
|
+
// caught).
|
|
13
|
+
//
|
|
14
|
+
// The conflict branch can't be forced deterministically through the public
|
|
15
|
+
// interface — appendDomainEventCore reads the stream version fresh inside
|
|
16
|
+
// itself, so a lone sequential append never conflicts; only two concurrent
|
|
17
|
+
// transactions racing the same version produce the 23505. So claim 2 is
|
|
18
|
+
// tested as an invariant over concurrent writers (looped per house
|
|
19
|
+
// convention for probabilistic tests) rather than by asserting the conflict
|
|
20
|
+
// branch was hit on a specific call — depending on interleaving, Postgres
|
|
21
|
+
// may pick either writer as the winner.
|
|
22
|
+
|
|
23
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
26
|
+
import { asRawClient, selectMany } from "../../db/query";
|
|
27
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
28
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
29
|
+
import { loadAggregate } from "../../event-store";
|
|
30
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
31
|
+
|
|
32
|
+
const TAE_AGGREGATE_TYPE = "taeDoc";
|
|
33
|
+
|
|
34
|
+
// Marker entity — a plain CRUD write issued right after ctx.tryAppendEvent
|
|
35
|
+
// in the same handler/transaction. Its row landing (regardless of whether
|
|
36
|
+
// the append won or lost the race) is the non-poisoning proof.
|
|
37
|
+
const markerEntity = createEntity({
|
|
38
|
+
table: "tae_markers",
|
|
39
|
+
fields: {
|
|
40
|
+
note: createTextField({ required: true }),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const markerTable = buildEntityTable("taeMarker", markerEntity);
|
|
44
|
+
const markerExecutor = createEventStoreExecutor(markerTable, markerEntity, {
|
|
45
|
+
entityName: "taeMarker",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let stack: TestStack;
|
|
49
|
+
const admin = TestUsers.admin;
|
|
50
|
+
|
|
51
|
+
const tryAppendFeature = defineFeature("tae", (r) => {
|
|
52
|
+
r.entity("taeMarker", markerEntity);
|
|
53
|
+
const appended = r.defineEvent("appended", z.object({ note: z.string() }));
|
|
54
|
+
|
|
55
|
+
r.writeHandler(
|
|
56
|
+
"doc:try-append",
|
|
57
|
+
z.object({ aggregateId: z.uuid(), note: z.string() }),
|
|
58
|
+
async (event, ctx) => {
|
|
59
|
+
const result = await ctx.tryAppendEvent({
|
|
60
|
+
aggregateId: event.payload.aggregateId,
|
|
61
|
+
aggregateType: TAE_AGGREGATE_TYPE,
|
|
62
|
+
type: appended.name,
|
|
63
|
+
payload: { note: event.payload.note },
|
|
64
|
+
});
|
|
65
|
+
// Runs unconditionally — if tryAppendEvent's savepoint failed to
|
|
66
|
+
// confine the VersionConflictError, this write would fail too
|
|
67
|
+
// because the whole tx aborted (25P02).
|
|
68
|
+
const markerCreated = await markerExecutor.create(
|
|
69
|
+
{ note: event.payload.note },
|
|
70
|
+
event.user,
|
|
71
|
+
ctx.db,
|
|
72
|
+
);
|
|
73
|
+
if (!markerCreated.isSuccess) return markerCreated;
|
|
74
|
+
return {
|
|
75
|
+
isSuccess: true as const,
|
|
76
|
+
data: {
|
|
77
|
+
ok: result.ok,
|
|
78
|
+
version: result.ok ? result.event.version : null,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
{ access: { roles: ["Admin"] } },
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
beforeAll(async () => {
|
|
87
|
+
stack = await setupTestStack({ features: [tryAppendFeature] });
|
|
88
|
+
await unsafeCreateEntityTable(stack.db, markerEntity, "taeMarker");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterAll(async () => {
|
|
92
|
+
await stack.cleanup();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
await asRawClient(stack.db).unsafe(
|
|
97
|
+
`TRUNCATE kumiko_events, "${markerTable.tableName}" RESTART IDENTITY CASCADE`,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("Issue #1038 — ctx.tryAppendEvent", () => {
|
|
102
|
+
test("happy path: first append on a fresh aggregate returns { ok: true, event }", async () => {
|
|
103
|
+
const aggregateId = crypto.randomUUID();
|
|
104
|
+
|
|
105
|
+
const result = await stack.http.writeOk<{
|
|
106
|
+
ok: boolean;
|
|
107
|
+
version: number | null;
|
|
108
|
+
}>("tae:write:doc:try-append", { aggregateId, note: "hello" }, admin);
|
|
109
|
+
|
|
110
|
+
expect(result.ok).toBe(true);
|
|
111
|
+
expect(result.version).toBe(1);
|
|
112
|
+
|
|
113
|
+
const events = await loadAggregate(stack.db, aggregateId, admin.tenantId);
|
|
114
|
+
expect(events).toHaveLength(1);
|
|
115
|
+
expect(events[0]?.payload).toEqual({ note: "hello" });
|
|
116
|
+
|
|
117
|
+
const markers = await selectMany(stack.db, markerTable);
|
|
118
|
+
expect(markers).toHaveLength(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Probabilistic — looped per house convention (mehrere Durchläufe, z.B.
|
|
122
|
+
// 20x) since a genuine 23505 race depends on transaction-timing
|
|
123
|
+
// interleaving that a single run can't force deterministically.
|
|
124
|
+
const ITERATIONS = 20;
|
|
125
|
+
const CONCURRENCY = 3;
|
|
126
|
+
|
|
127
|
+
test(`non-poisoning invariant holds across ${ITERATIONS} concurrent-writer rounds`, async () => {
|
|
128
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
129
|
+
const aggregateId = crypto.randomUUID();
|
|
130
|
+
|
|
131
|
+
const responses = await Promise.all(
|
|
132
|
+
Array.from({ length: CONCURRENCY }, (_, n) =>
|
|
133
|
+
stack.http.write("tae:write:doc:try-append", { aggregateId, note: `writer-${n}` }, admin),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// No writer's transaction was poisoned by a losing append — every
|
|
138
|
+
// request completes as a normal 200, never a 500 from an aborted tx.
|
|
139
|
+
let okCount = 0;
|
|
140
|
+
for (const res of responses) {
|
|
141
|
+
expect(res.status).toBe(200);
|
|
142
|
+
const body = (await res.json()) as { isSuccess: boolean; data: { ok: boolean } };
|
|
143
|
+
expect(body.isSuccess).toBe(true);
|
|
144
|
+
if (body.data.ok) okCount++;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// One event per successful append — interleaving-independent
|
|
148
|
+
// invariant. Serialized (non-racing) writers can legitimately all
|
|
149
|
+
// succeed at different versions; only actual conflicts return
|
|
150
|
+
// { ok: false }, so events.length must track okCount, not a fixed 1.
|
|
151
|
+
const events = await loadAggregate(stack.db, aggregateId, admin.tenantId);
|
|
152
|
+
expect(events).toHaveLength(okCount);
|
|
153
|
+
expect(okCount).toBeGreaterThanOrEqual(1);
|
|
154
|
+
|
|
155
|
+
// Every writer's marker insert landed regardless of win/lose — the
|
|
156
|
+
// savepoint confined the losers' VersionConflictError instead of
|
|
157
|
+
// aborting their whole transaction.
|
|
158
|
+
const markers = await selectMany(stack.db, markerTable);
|
|
159
|
+
expect(markers).toHaveLength(CONCURRENCY);
|
|
160
|
+
|
|
161
|
+
await asRawClient(stack.db).unsafe(
|
|
162
|
+
`TRUNCATE kumiko_events, "${markerTable.tableName}" RESTART IDENTITY CASCADE`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { DbConnection } from "../db/connection";
|
|
2
|
+
import { transaction } from "../db/query";
|
|
3
|
+
import type { DeleteContext, SaveContext, SessionUser, WriteResult } from "../engine/types";
|
|
4
|
+
import { InternalError, toWriteErrorInfo, writeFailure } from "../errors";
|
|
5
|
+
import { createFallbackLogger } from "../logging/utils";
|
|
6
|
+
import { parseJsonSafe } from "../utils/safe-json";
|
|
7
|
+
import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared";
|
|
8
|
+
import { resolveDbSource } from "./dispatch-shared";
|
|
9
|
+
import { executeNestedWrite } from "./dispatch-write";
|
|
10
|
+
import {
|
|
11
|
+
type AfterCommitHook,
|
|
12
|
+
BatchRollback,
|
|
13
|
+
isLifecycleResult,
|
|
14
|
+
wrapToKumiko,
|
|
15
|
+
} from "./dispatcher-utils";
|
|
16
|
+
|
|
17
|
+
// Core batch logic extracted so write() and command() can reuse it
|
|
18
|
+
// (a single write = batch of one, running in its own transaction).
|
|
19
|
+
export async function runBatch(
|
|
20
|
+
ctx: DispatchContext,
|
|
21
|
+
commands: readonly BatchCommand[],
|
|
22
|
+
user: SessionUser,
|
|
23
|
+
requestId?: string,
|
|
24
|
+
): Promise<BatchResult> {
|
|
25
|
+
const { idempotency, lifecycle, appContext: context } = ctx;
|
|
26
|
+
if (commands.length === 0) {
|
|
27
|
+
return { isSuccess: true, results: [] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Idempotency: if the same requestId has already been processed, return the
|
|
31
|
+
// cached result without re-executing. The cache holds the full BatchResult.
|
|
32
|
+
if (requestId && idempotency) {
|
|
33
|
+
const cached = await idempotency.check(requestId);
|
|
34
|
+
if (cached) {
|
|
35
|
+
const parsed = parseJsonSafe<BatchResult | null>(cached, null);
|
|
36
|
+
if (parsed) return parsed;
|
|
37
|
+
// corrupted cache entry — treat as miss, let the request re-run
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Wrap return paths: cache the final result under requestId so retries get
|
|
42
|
+
// the same answer (both success and failure results are cached).
|
|
43
|
+
const finalize = async (result: BatchResult): Promise<BatchResult> => {
|
|
44
|
+
if (requestId && idempotency) {
|
|
45
|
+
await idempotency.store(requestId, result);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const afterCommitHooks: AfterCommitHook[] = [];
|
|
51
|
+
const results: WriteResult[] = [];
|
|
52
|
+
|
|
53
|
+
// Flush afterCommit hooks in parallel. Errors are logged, not rethrown:
|
|
54
|
+
// the writes are already committed, we can't undo them.
|
|
55
|
+
//
|
|
56
|
+
// Parallelisation is safe because afterCommit hooks are deferred side-
|
|
57
|
+
// effects (e.g. feature-level postSave hooks in afterCommit phase)
|
|
58
|
+
// that don't depend on each other — the in-transaction work already ran
|
|
59
|
+
// sequentially inside the lifecycle pipeline where ordering matters. If a
|
|
60
|
+
// future hook ever needs ordering, it should do its sequencing internally
|
|
61
|
+
// (one hook pushing multiple sub-calls) rather than relying on the
|
|
62
|
+
// flush-loop order.
|
|
63
|
+
const flushAfterCommit = async () => {
|
|
64
|
+
const logError = createFallbackLogger("dispatcher", context.log);
|
|
65
|
+
const outcomes = await Promise.allSettled(afterCommitHooks.map((hook) => hook()));
|
|
66
|
+
for (const outcome of outcomes) {
|
|
67
|
+
if (outcome.status === "rejected") {
|
|
68
|
+
const detail =
|
|
69
|
+
outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
|
70
|
+
logError.error("afterCommit hook failed", { error: detail });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Fires the batch-level system hooks with every successful save/delete
|
|
76
|
+
// context from this run. Called after flushAfterCommit so per-save hooks
|
|
77
|
+
// have all completed first; errors are isolated inside lifecycleHooks.
|
|
78
|
+
const flushBatchHooks = async () => {
|
|
79
|
+
try {
|
|
80
|
+
const saves: SaveContext[] = [];
|
|
81
|
+
const deletes: DeleteContext[] = [];
|
|
82
|
+
for (const r of results) {
|
|
83
|
+
if (!r.isSuccess) continue;
|
|
84
|
+
if (!isLifecycleResult(r.data)) continue;
|
|
85
|
+
if (r.data.kind === "save") saves.push(r.data);
|
|
86
|
+
else if (r.data.kind === "delete") deletes.push(r.data);
|
|
87
|
+
}
|
|
88
|
+
if (saves.length > 0 && lifecycle) await lifecycle.runPostSaveBatch(saves, context);
|
|
89
|
+
if (deletes.length > 0 && lifecycle) await lifecycle.runPostDeleteBatch(deletes, context);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// Batch hooks must never fail the batch — the commit already happened.
|
|
92
|
+
// Pass the raw error so the logger preserves stack + cause chain;
|
|
93
|
+
// collapsing to .message hides exactly what ops needs to debug.
|
|
94
|
+
const logError = createFallbackLogger("dispatcher", context.log);
|
|
95
|
+
logError.error("batch hook flush failed", { error: e });
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// batch() opens its own outer transaction — needs the top-level
|
|
100
|
+
// connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
|
|
101
|
+
const db = resolveDbSource(ctx, undefined) as DbConnection | undefined;
|
|
102
|
+
if (!db) {
|
|
103
|
+
// Without a DB connection there is no transaction to open. Fall back to
|
|
104
|
+
// sequential execution — useful for unit tests that don't touch the DB.
|
|
105
|
+
// Each command runs independently; a failure stops the batch.
|
|
106
|
+
for (let i = 0; i < commands.length; i++) {
|
|
107
|
+
const cmd = commands[i];
|
|
108
|
+
if (!cmd) continue;
|
|
109
|
+
const res = await executeNestedWrite(
|
|
110
|
+
ctx,
|
|
111
|
+
cmd.type,
|
|
112
|
+
cmd.payload,
|
|
113
|
+
user,
|
|
114
|
+
undefined,
|
|
115
|
+
afterCommitHooks,
|
|
116
|
+
);
|
|
117
|
+
results.push(res);
|
|
118
|
+
if (!res.isSuccess) {
|
|
119
|
+
// No tx means no rollback — but we still drop afterCommit hooks,
|
|
120
|
+
// matching the semantic "failure = side-effects don't fire".
|
|
121
|
+
return finalize({ isSuccess: false, error: res.error, failedIndex: i, results });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await flushAfterCommit();
|
|
125
|
+
await flushBatchHooks();
|
|
126
|
+
return finalize({ isSuccess: true, results });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await transaction(db, async (tx) => {
|
|
131
|
+
for (let i = 0; i < commands.length; i++) {
|
|
132
|
+
const cmd = commands[i];
|
|
133
|
+
if (!cmd) continue;
|
|
134
|
+
const res = await executeNestedWrite(
|
|
135
|
+
ctx,
|
|
136
|
+
cmd.type,
|
|
137
|
+
cmd.payload,
|
|
138
|
+
user,
|
|
139
|
+
tx,
|
|
140
|
+
afterCommitHooks,
|
|
141
|
+
);
|
|
142
|
+
results.push(res);
|
|
143
|
+
if (!res.isSuccess) {
|
|
144
|
+
throw new BatchRollback(i, res.error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (e instanceof BatchRollback) {
|
|
150
|
+
return finalize({
|
|
151
|
+
isSuccess: false,
|
|
152
|
+
error: e.failureError,
|
|
153
|
+
failedIndex: e.failedIndex,
|
|
154
|
+
results,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return finalize({
|
|
158
|
+
isSuccess: false,
|
|
159
|
+
error: toWriteErrorInfo(wrapToKumiko(e)),
|
|
160
|
+
failedIndex: results.length,
|
|
161
|
+
results,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Commit succeeded — fire deferred side-effects.
|
|
166
|
+
await flushAfterCommit();
|
|
167
|
+
await flushBatchHooks();
|
|
168
|
+
return finalize({ isSuccess: true, results });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Unwrap a BatchResult into a single WriteResult for write()/command().
|
|
172
|
+
// Picks the first result on success (the only one for a single write), the
|
|
173
|
+
// failing one on failure. Falls back to a synthetic error if the batch
|
|
174
|
+
// didn't produce any results (unexpected).
|
|
175
|
+
export function unwrapSingle(batchResult: BatchResult): WriteResult {
|
|
176
|
+
if (batchResult.isSuccess) {
|
|
177
|
+
return (
|
|
178
|
+
batchResult.results[0] ?? writeFailure(new InternalError({ message: "empty_batch_result" }))
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return (
|
|
182
|
+
batchResult.results[batchResult.failedIndex] ?? {
|
|
183
|
+
isSuccess: false,
|
|
184
|
+
error: batchResult.error,
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
}
|