@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,279 @@
|
|
|
1
|
+
// backfillEventPiiEncryption (#799): pre-KMS plaintext events get re-
|
|
2
|
+
// encrypted in place — entity lifecycle payloads (created / updated
|
|
3
|
+
// changes+previous / forgotten previous) AND catalogued custom events.
|
|
4
|
+
// Pre-KMS-forgotten subjects (detectable only via their *.forgotten event)
|
|
5
|
+
// get [[erased]] instead of a freshly minted key. After the backfill,
|
|
6
|
+
// applyEntityEvent (the rebuild primitive) materializes ciphertext AND the
|
|
7
|
+
// blind-index column, so equality lookups keep working.
|
|
8
|
+
|
|
9
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import {
|
|
12
|
+
configureBlindIndexKey,
|
|
13
|
+
configurePiiSubjectKms,
|
|
14
|
+
InMemoryKmsAdapter,
|
|
15
|
+
isPiiCiphertext,
|
|
16
|
+
PII_ERASED_SENTINEL,
|
|
17
|
+
resetBlindIndexKeyForTests,
|
|
18
|
+
resetPiiSubjectKmsForTests,
|
|
19
|
+
} from "../../crypto";
|
|
20
|
+
import { applyEntityEvent } from "../../db/apply-entity-event";
|
|
21
|
+
import { backfillEventPiiEncryption } from "../../db/queries/backfill-pii";
|
|
22
|
+
import { asRawClient, fetchOne } from "../../db/query";
|
|
23
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
24
|
+
import { defineFeature } from "../../engine/define-feature";
|
|
25
|
+
import { createEntity, createTextField } from "../../engine/factories";
|
|
26
|
+
import { createRegistry } from "../../engine/registry";
|
|
27
|
+
import type { Registry, TenantId } from "../../engine/types";
|
|
28
|
+
import { createTestDb, type TestDb, unsafeCreateEntityTable } from "../../stack";
|
|
29
|
+
import { generateId } from "../../utils";
|
|
30
|
+
import { append, loadAggregate } from "../event-store";
|
|
31
|
+
import { createEventsTable } from "../events-schema";
|
|
32
|
+
|
|
33
|
+
const TENANT = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
34
|
+
const BIDX_KEY = Buffer.alloc(32, 5).toString("base64");
|
|
35
|
+
|
|
36
|
+
const contactEntity = createEntity({
|
|
37
|
+
fields: {
|
|
38
|
+
email: createTextField({ required: true, pii: true, lookupable: true }),
|
|
39
|
+
displayName: createTextField(),
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const contactTable = buildEntityTable("contact", contactEntity);
|
|
43
|
+
|
|
44
|
+
const crmFeature = defineFeature("crm", (r) => {
|
|
45
|
+
r.entity("contact", contactEntity);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const mailerFeature = defineFeature("mailer", (r) => {
|
|
49
|
+
r.defineEvent(
|
|
50
|
+
"ping",
|
|
51
|
+
z.object({ targetId: z.string().nullable(), address: z.string().nullable() }),
|
|
52
|
+
{ piiFields: { address: { subjectField: "targetId" } } },
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
let testDb: TestDb;
|
|
57
|
+
let registry: Registry;
|
|
58
|
+
let kms: InMemoryKmsAdapter;
|
|
59
|
+
|
|
60
|
+
async function appendPlain(
|
|
61
|
+
aggregateId: string,
|
|
62
|
+
aggregateType: string,
|
|
63
|
+
type: string,
|
|
64
|
+
payload: Record<string, unknown>,
|
|
65
|
+
expectedVersion = 0,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
await append(testDb.db, {
|
|
68
|
+
aggregateId,
|
|
69
|
+
aggregateType,
|
|
70
|
+
tenantId: TENANT,
|
|
71
|
+
expectedVersion,
|
|
72
|
+
type,
|
|
73
|
+
payload,
|
|
74
|
+
metadata: { userId: "system" },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
testDb = await createTestDb();
|
|
80
|
+
registry = createRegistry([crmFeature, mailerFeature]);
|
|
81
|
+
await createEventsTable(testDb.db);
|
|
82
|
+
await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await testDb.cleanup();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
const raw = asRawClient(testDb.db);
|
|
91
|
+
await raw.unsafe(`TRUNCATE "kumiko_events" RESTART IDENTITY`);
|
|
92
|
+
await raw.unsafe(`TRUNCATE "${contactTable.tableName}"`);
|
|
93
|
+
// Plaintext era: NO KMS while the legacy events are appended.
|
|
94
|
+
resetPiiSubjectKmsForTests();
|
|
95
|
+
resetBlindIndexKeyForTests();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
resetPiiSubjectKmsForTests();
|
|
100
|
+
resetBlindIndexKeyForTests();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function armKms(): void {
|
|
104
|
+
kms = new InMemoryKmsAdapter();
|
|
105
|
+
configurePiiSubjectKms(kms);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("backfillEventPiiEncryption", () => {
|
|
109
|
+
test("encrypts entity lifecycle payloads: created flat, updated changes+previous", async () => {
|
|
110
|
+
const c1 = generateId();
|
|
111
|
+
await appendPlain(c1, "contact", "contact.created", {
|
|
112
|
+
id: c1,
|
|
113
|
+
email: "old@x.com",
|
|
114
|
+
displayName: "Alice",
|
|
115
|
+
});
|
|
116
|
+
await appendPlain(
|
|
117
|
+
c1,
|
|
118
|
+
"contact",
|
|
119
|
+
"contact.updated",
|
|
120
|
+
{ changes: { email: "new@x.com" }, previous: { id: c1, email: "old@x.com" } },
|
|
121
|
+
1,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
armKms();
|
|
125
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
126
|
+
|
|
127
|
+
expect(result.failures).toEqual([]);
|
|
128
|
+
expect(result.updatedEvents).toBe(2);
|
|
129
|
+
expect(result.encryptedFields).toBe(3);
|
|
130
|
+
|
|
131
|
+
const events = await loadAggregate(testDb.db, c1, TENANT);
|
|
132
|
+
const created = events[0]?.payload as Record<string, unknown>;
|
|
133
|
+
expect(isPiiCiphertext(created["email"])).toBe(true);
|
|
134
|
+
expect(String(created["email"])).toContain(`user:${c1}`);
|
|
135
|
+
expect(created["displayName"]).toBe("Alice");
|
|
136
|
+
|
|
137
|
+
const updated = events[1]?.payload as {
|
|
138
|
+
changes: Record<string, unknown>;
|
|
139
|
+
previous: Record<string, unknown>;
|
|
140
|
+
};
|
|
141
|
+
expect(isPiiCiphertext(updated.changes["email"])).toBe(true);
|
|
142
|
+
expect(isPiiCiphertext(updated.previous["email"])).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("pre-KMS-forgotten aggregate gets [[erased]], not a fresh key", async () => {
|
|
146
|
+
const c2 = generateId();
|
|
147
|
+
await appendPlain(c2, "contact", "contact.created", { id: c2, email: "gone@x.com" });
|
|
148
|
+
await appendPlain(
|
|
149
|
+
c2,
|
|
150
|
+
"contact",
|
|
151
|
+
"contact.forgotten",
|
|
152
|
+
{ previous: { id: c2, email: "gone@x.com" } },
|
|
153
|
+
1,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
armKms();
|
|
157
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
158
|
+
|
|
159
|
+
expect(result.failures).toEqual([]);
|
|
160
|
+
expect(result.erasedFields).toBe(2);
|
|
161
|
+
const events = await loadAggregate(testDb.db, c2, TENANT);
|
|
162
|
+
const created = events[0]?.payload as Record<string, unknown>;
|
|
163
|
+
expect(created["email"]).toBe(PII_ERASED_SENTINEL);
|
|
164
|
+
const forgotten = events[1]?.payload as { previous: Record<string, unknown> };
|
|
165
|
+
expect(forgotten.previous["email"]).toBe(PII_ERASED_SENTINEL);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("catalogued custom events: encrypt under target's DEK; forgotten target → [[erased]]", async () => {
|
|
169
|
+
const forgottenUser = generateId();
|
|
170
|
+
await appendPlain(forgottenUser, "contact", "contact.created", {
|
|
171
|
+
id: forgottenUser,
|
|
172
|
+
email: "f@x.com",
|
|
173
|
+
});
|
|
174
|
+
await appendPlain(
|
|
175
|
+
forgottenUser,
|
|
176
|
+
"contact",
|
|
177
|
+
"contact.forgotten",
|
|
178
|
+
{ previous: { id: forgottenUser, email: "f@x.com" } },
|
|
179
|
+
1,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const p1 = generateId();
|
|
183
|
+
const p2 = generateId();
|
|
184
|
+
const p3 = generateId();
|
|
185
|
+
await appendPlain(p1, "ping", "mailer:event:ping", {
|
|
186
|
+
targetId: "u-7",
|
|
187
|
+
address: "u7@x.com",
|
|
188
|
+
});
|
|
189
|
+
await appendPlain(p2, "ping", "mailer:event:ping", {
|
|
190
|
+
targetId: forgottenUser,
|
|
191
|
+
address: "f@x.com",
|
|
192
|
+
});
|
|
193
|
+
await appendPlain(p3, "ping", "mailer:event:ping", { targetId: null, address: "ops@x.com" });
|
|
194
|
+
|
|
195
|
+
armKms();
|
|
196
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
197
|
+
|
|
198
|
+
expect(result.failures).toEqual([]);
|
|
199
|
+
const alive = (await loadAggregate(testDb.db, p1, TENANT))[0]?.payload as Record<
|
|
200
|
+
string,
|
|
201
|
+
unknown
|
|
202
|
+
>;
|
|
203
|
+
expect(isPiiCiphertext(alive["address"])).toBe(true);
|
|
204
|
+
expect(String(alive["address"])).toContain("user:u-7");
|
|
205
|
+
|
|
206
|
+
const erased = (await loadAggregate(testDb.db, p2, TENANT))[0]?.payload as Record<
|
|
207
|
+
string,
|
|
208
|
+
unknown
|
|
209
|
+
>;
|
|
210
|
+
expect(erased["address"]).toBe(PII_ERASED_SENTINEL);
|
|
211
|
+
|
|
212
|
+
// No subject → no key to shred; stays plaintext (documented rollout gap).
|
|
213
|
+
const system = (await loadAggregate(testDb.db, p3, TENANT))[0]?.payload as Record<
|
|
214
|
+
string,
|
|
215
|
+
unknown
|
|
216
|
+
>;
|
|
217
|
+
expect(system["address"]).toBe("ops@x.com");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("idempotent: second run updates nothing; dryRun writes nothing", async () => {
|
|
221
|
+
const c1 = generateId();
|
|
222
|
+
await appendPlain(c1, "contact", "contact.created", { id: c1, email: "a@x.com" });
|
|
223
|
+
|
|
224
|
+
armKms();
|
|
225
|
+
const dry = await backfillEventPiiEncryption(testDb.db, registry, { dryRun: true });
|
|
226
|
+
expect(dry.updatedEvents).toBe(1);
|
|
227
|
+
const untouched = (await loadAggregate(testDb.db, c1, TENANT))[0]?.payload as Record<
|
|
228
|
+
string,
|
|
229
|
+
unknown
|
|
230
|
+
>;
|
|
231
|
+
expect(untouched["email"]).toBe("a@x.com");
|
|
232
|
+
|
|
233
|
+
const first = await backfillEventPiiEncryption(testDb.db, registry);
|
|
234
|
+
expect(first.updatedEvents).toBe(1);
|
|
235
|
+
const second = await backfillEventPiiEncryption(testDb.db, registry);
|
|
236
|
+
expect(second.updatedEvents).toBe(0);
|
|
237
|
+
expect(second.failures).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("small batchSize pages through the estate completely", async () => {
|
|
241
|
+
const ids = [generateId(), generateId(), generateId(), generateId(), generateId()];
|
|
242
|
+
for (const id of ids) {
|
|
243
|
+
await appendPlain(id, "contact", "contact.created", { id, email: `${id}@x.com` });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
armKms();
|
|
247
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry, { batchSize: 2 });
|
|
248
|
+
expect(result.scannedEvents).toBe(5);
|
|
249
|
+
expect(result.updatedEvents).toBe(5);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("after backfill, applyEntityEvent (rebuild) materializes ciphertext + blind index", async () => {
|
|
253
|
+
const c1 = generateId();
|
|
254
|
+
await appendPlain(c1, "contact", "contact.created", { id: c1, email: "login@x.com" });
|
|
255
|
+
|
|
256
|
+
armKms();
|
|
257
|
+
configureBlindIndexKey(BIDX_KEY);
|
|
258
|
+
await backfillEventPiiEncryption(testDb.db, registry);
|
|
259
|
+
|
|
260
|
+
const events = await loadAggregate(testDb.db, c1, TENANT);
|
|
261
|
+
const created = events[0];
|
|
262
|
+
if (!created) throw new Error("missing created event");
|
|
263
|
+
await applyEntityEvent(created, contactTable, contactEntity, testDb.db);
|
|
264
|
+
|
|
265
|
+
const row = await fetchOne(testDb.db, contactTable, { id: c1 });
|
|
266
|
+
expect(isPiiCiphertext(row?.["email"])).toBe(true);
|
|
267
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
268
|
+
`SELECT "email_bidx" FROM "${contactTable.tableName}" WHERE "id" = $1`,
|
|
269
|
+
[c1],
|
|
270
|
+
)) as ReadonlyArray<Record<string, unknown>>;
|
|
271
|
+
expect(String(rawRows[0]?.["email_bidx"])).toStartWith("kumiko-bidx:v1:");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("throws without a configured KMS", async () => {
|
|
275
|
+
expect(backfillEventPiiEncryption(testDb.db, registry)).rejects.toThrow(
|
|
276
|
+
/requires a configured subject KMS/,
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -6,6 +6,7 @@ import { generateId as uuid } from "../../utils";
|
|
|
6
6
|
import {
|
|
7
7
|
append,
|
|
8
8
|
createEventsTable,
|
|
9
|
+
IdempotentAppendConflictError,
|
|
9
10
|
loadAggregate,
|
|
10
11
|
loadAggregateAsOf,
|
|
11
12
|
loadAllEventsByType,
|
|
@@ -59,6 +60,38 @@ describe("event-store: append + load", () => {
|
|
|
59
60
|
expect(events[0]?.metadata.userId).toBe(userA);
|
|
60
61
|
});
|
|
61
62
|
|
|
63
|
+
test("kumiko-framework#1490: subsequent append doesn't rely on globalThis.Temporal", async () => {
|
|
64
|
+
const aggregateId = uuid();
|
|
65
|
+
const first = await append(testDb.db, {
|
|
66
|
+
aggregateId,
|
|
67
|
+
aggregateType: "task",
|
|
68
|
+
tenantId: tenantA,
|
|
69
|
+
expectedVersion: 0,
|
|
70
|
+
type: "task.created",
|
|
71
|
+
payload: { title: "Buy milk" },
|
|
72
|
+
metadata: { userId: userA },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
76
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
77
|
+
try {
|
|
78
|
+
const second = await append(testDb.db, {
|
|
79
|
+
aggregateId,
|
|
80
|
+
aggregateType: "task",
|
|
81
|
+
tenantId: tenantA,
|
|
82
|
+
expectedVersion: first.version,
|
|
83
|
+
type: "task.updated",
|
|
84
|
+
payload: { title: "Buy oat milk" },
|
|
85
|
+
metadata: { userId: userA },
|
|
86
|
+
});
|
|
87
|
+
expect(second.version).toBe(2);
|
|
88
|
+
expect(second.createdAt).toBeDefined();
|
|
89
|
+
} finally {
|
|
90
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
91
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
62
95
|
test("subsequent appends increment version and are ordered", async () => {
|
|
63
96
|
const aggregateId = uuid();
|
|
64
97
|
const base = {
|
|
@@ -93,6 +126,143 @@ describe("event-store: append + load", () => {
|
|
|
93
126
|
});
|
|
94
127
|
});
|
|
95
128
|
|
|
129
|
+
describe("event-store: idempotency-key conflict", () => {
|
|
130
|
+
test("second append with a reused idempotencyKey (new aggregate+version) throws IdempotentAppendConflictError", async () => {
|
|
131
|
+
const first = uuid();
|
|
132
|
+
const second = uuid();
|
|
133
|
+
const key = uuid();
|
|
134
|
+
|
|
135
|
+
await append(testDb.db, {
|
|
136
|
+
aggregateId: first,
|
|
137
|
+
aggregateType: "task",
|
|
138
|
+
tenantId: tenantA,
|
|
139
|
+
expectedVersion: 0,
|
|
140
|
+
type: "task.created",
|
|
141
|
+
payload: { title: "Orig" },
|
|
142
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// A retried command that re-runs after the Redis idempotency guard
|
|
146
|
+
// missed its window: different aggregate, fresh expectedVersion=0 — the
|
|
147
|
+
// aggregate-version unique index has nothing to say about this pair, so
|
|
148
|
+
// only the idempotency-key index catches the duplicate.
|
|
149
|
+
await expect(
|
|
150
|
+
append(testDb.db, {
|
|
151
|
+
aggregateId: second,
|
|
152
|
+
aggregateType: "task",
|
|
153
|
+
tenantId: tenantA,
|
|
154
|
+
expectedVersion: 0,
|
|
155
|
+
type: "task.created",
|
|
156
|
+
payload: { title: "Retry" },
|
|
157
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
158
|
+
}),
|
|
159
|
+
).rejects.toThrow(IdempotentAppendConflictError);
|
|
160
|
+
|
|
161
|
+
const events = await loadAggregate(testDb.db, second, tenantA);
|
|
162
|
+
expect(events).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("subsequent-event append (expectedVersion > 0) also enforces the idempotency key", async () => {
|
|
166
|
+
const aggregateId = uuid();
|
|
167
|
+
const key = uuid();
|
|
168
|
+
|
|
169
|
+
await append(testDb.db, {
|
|
170
|
+
aggregateId,
|
|
171
|
+
aggregateType: "task",
|
|
172
|
+
tenantId: tenantA,
|
|
173
|
+
expectedVersion: 0,
|
|
174
|
+
type: "task.created",
|
|
175
|
+
payload: { title: "Orig" },
|
|
176
|
+
metadata: { userId: userA },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await append(testDb.db, {
|
|
180
|
+
aggregateId,
|
|
181
|
+
aggregateType: "task",
|
|
182
|
+
tenantId: tenantA,
|
|
183
|
+
expectedVersion: 1,
|
|
184
|
+
type: "task.updated",
|
|
185
|
+
payload: { title: "V2" },
|
|
186
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Retry of the v2 update: goes through insertSubsequentEventRow's raw
|
|
190
|
+
// INSERT ... SELECT ... WHERE EXISTS path, not insertFirstEvent — the
|
|
191
|
+
// idempotency index must catch it there too.
|
|
192
|
+
await expect(
|
|
193
|
+
append(testDb.db, {
|
|
194
|
+
aggregateId,
|
|
195
|
+
aggregateType: "task",
|
|
196
|
+
tenantId: tenantA,
|
|
197
|
+
expectedVersion: 2,
|
|
198
|
+
type: "task.updated",
|
|
199
|
+
payload: { title: "V3-retry" },
|
|
200
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
201
|
+
}),
|
|
202
|
+
).rejects.toThrow(IdempotentAppendConflictError);
|
|
203
|
+
|
|
204
|
+
const events = await loadAggregate(testDb.db, aggregateId, tenantA);
|
|
205
|
+
expect(events).toHaveLength(2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("same idempotencyKey on a different tenant does not conflict", async () => {
|
|
209
|
+
const key = uuid();
|
|
210
|
+
|
|
211
|
+
const a = await append(testDb.db, {
|
|
212
|
+
aggregateId: uuid(),
|
|
213
|
+
aggregateType: "task",
|
|
214
|
+
tenantId: tenantA,
|
|
215
|
+
expectedVersion: 0,
|
|
216
|
+
type: "task.created",
|
|
217
|
+
payload: {},
|
|
218
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
219
|
+
});
|
|
220
|
+
const b = await append(testDb.db, {
|
|
221
|
+
aggregateId: uuid(),
|
|
222
|
+
aggregateType: "task",
|
|
223
|
+
tenantId: tenantB,
|
|
224
|
+
expectedVersion: 0,
|
|
225
|
+
type: "task.created",
|
|
226
|
+
payload: {},
|
|
227
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(a.version).toBe(1);
|
|
231
|
+
expect(b.version).toBe(1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("omitting idempotencyKey allows unlimited appends, unchanged from before", async () => {
|
|
235
|
+
// Same tenant for both, unlike the different-aggregateId version this
|
|
236
|
+
// replaces — Postgres treats every NULL as distinct even in a plain
|
|
237
|
+
// unique index, so this does NOT exercise the partial index's `WHERE
|
|
238
|
+
// ... IS NOT NULL` clause specifically (that's provable only by a
|
|
239
|
+
// duplicate NON-null key, covered above). What this does pin: a fully-
|
|
240
|
+
// omitted key and an explicit `idempotencyKey: undefined` both serialize
|
|
241
|
+
// to a JSON-absent key (JSON.stringify drops undefined) and must behave
|
|
242
|
+
// identically, rather than one silently colliding on `"idempotencyKey":null`.
|
|
243
|
+
const omitted = await append(testDb.db, {
|
|
244
|
+
aggregateId: uuid(),
|
|
245
|
+
aggregateType: "task",
|
|
246
|
+
tenantId: tenantA,
|
|
247
|
+
expectedVersion: 0,
|
|
248
|
+
type: "task.created",
|
|
249
|
+
payload: {},
|
|
250
|
+
metadata: { userId: userA },
|
|
251
|
+
});
|
|
252
|
+
const explicitUndefined = await append(testDb.db, {
|
|
253
|
+
aggregateId: uuid(),
|
|
254
|
+
aggregateType: "task",
|
|
255
|
+
tenantId: tenantA,
|
|
256
|
+
expectedVersion: 0,
|
|
257
|
+
type: "task.created",
|
|
258
|
+
payload: {},
|
|
259
|
+
metadata: { userId: userA, idempotencyKey: undefined },
|
|
260
|
+
});
|
|
261
|
+
expect(omitted.version).toBe(1);
|
|
262
|
+
expect(explicitUndefined.version).toBe(1);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
96
266
|
describe("event-store: optimistic concurrency", () => {
|
|
97
267
|
test("wrong expectedVersion throws VersionConflictError (no write)", async () => {
|
|
98
268
|
const aggregateId = uuid();
|
|
@@ -456,6 +626,37 @@ describe("event-store: loadAllEventsByType", () => {
|
|
|
456
626
|
expect(all.map((e) => e.version)).toEqual([1, 2, 3, 4]);
|
|
457
627
|
expect(all.map((e) => (e.payload as { v: number }).v)).toEqual([0, 1, 2, 3]);
|
|
458
628
|
});
|
|
629
|
+
|
|
630
|
+
test("throws once rows exceed the given rowLimit", async () => {
|
|
631
|
+
for (let v = 0; v < 3; v++) {
|
|
632
|
+
await append(testDb.db, {
|
|
633
|
+
aggregateId: uuid(),
|
|
634
|
+
aggregateType: "task",
|
|
635
|
+
tenantId: tenantA,
|
|
636
|
+
expectedVersion: 0,
|
|
637
|
+
type: "task.created",
|
|
638
|
+
payload: { v },
|
|
639
|
+
metadata: { userId: userA },
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
await expect(loadAllEventsByType(testDb.db, "task", 2)).rejects.toThrow(/exceeds 2 rows/);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("does not throw when rows equal the given rowLimit", async () => {
|
|
646
|
+
for (let v = 0; v < 2; v++) {
|
|
647
|
+
await append(testDb.db, {
|
|
648
|
+
aggregateId: uuid(),
|
|
649
|
+
aggregateType: "task",
|
|
650
|
+
tenantId: tenantA,
|
|
651
|
+
expectedVersion: 0,
|
|
652
|
+
type: "task.created",
|
|
653
|
+
payload: { v },
|
|
654
|
+
metadata: { userId: userA },
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
const all = await loadAllEventsByType(testDb.db, "task", 2);
|
|
658
|
+
expect(all).toHaveLength(2);
|
|
659
|
+
});
|
|
459
660
|
});
|
|
460
661
|
|
|
461
662
|
describe("event-store: streamAllEventsByType (memory-bounded iteration)", () => {
|
|
@@ -48,8 +48,8 @@ describe("toStoredEvent", () => {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
test("Feld-Vollständigkeit: das Mapping deckt genau die StoredEvent-Keys ab", () => {
|
|
51
|
-
//
|
|
52
|
-
//
|
|
51
|
+
// Guards required fields staying in sync with StoredEvent (event-store.ts);
|
|
52
|
+
// optional fields consistently omitted on both sides must be added manually.
|
|
53
53
|
const expectedKeys: ReadonlyArray<keyof StoredEvent> = [
|
|
54
54
|
"id",
|
|
55
55
|
"aggregateId",
|
|
@@ -242,3 +242,89 @@ describe("snapshot-store — loadAggregateWithSnapshot", () => {
|
|
|
242
242
|
expect(archived.state.count).toBe(10);
|
|
243
243
|
});
|
|
244
244
|
});
|
|
245
|
+
|
|
246
|
+
describe("snapshot-store — auto-snapshot policy (snapshotEvery)", () => {
|
|
247
|
+
test("first load persists a snapshot at the folded version; second load hits it", async () => {
|
|
248
|
+
const aggId = await seedAggregate(250);
|
|
249
|
+
|
|
250
|
+
const first = await loadAggregateWithSnapshot<CounterState>(
|
|
251
|
+
bun.db,
|
|
252
|
+
aggId,
|
|
253
|
+
tenant,
|
|
254
|
+
reducer,
|
|
255
|
+
initial,
|
|
256
|
+
{ snapshotEvery: 100 },
|
|
257
|
+
);
|
|
258
|
+
expect(first.snapshotHit).toBe(false);
|
|
259
|
+
expect(first.version).toBe(250);
|
|
260
|
+
expect(first.state).toEqual(await loadFullState(aggId));
|
|
261
|
+
|
|
262
|
+
const stored = await loadLatestSnapshot<CounterState>(bun.db, aggId, tenant);
|
|
263
|
+
expect(stored?.version).toBe(250);
|
|
264
|
+
expect(stored?.snapshotVersion).toBe(1);
|
|
265
|
+
expect(stored?.aggregateType).toBe("counter");
|
|
266
|
+
|
|
267
|
+
const second = await loadAggregateWithSnapshot<CounterState>(
|
|
268
|
+
bun.db,
|
|
269
|
+
aggId,
|
|
270
|
+
tenant,
|
|
271
|
+
reducer,
|
|
272
|
+
initial,
|
|
273
|
+
{ snapshotEvery: 100 },
|
|
274
|
+
);
|
|
275
|
+
expect(second.snapshotHit).toBe(true);
|
|
276
|
+
expect(second.state).toEqual(first.state);
|
|
277
|
+
expect(second.version).toBe(250);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("fewer folded events than snapshotEvery → no snapshot written", async () => {
|
|
281
|
+
const aggId = await seedAggregate(10);
|
|
282
|
+
await loadAggregateWithSnapshot<CounterState>(bun.db, aggId, tenant, reducer, initial, {
|
|
283
|
+
snapshotEvery: 100,
|
|
284
|
+
});
|
|
285
|
+
expect(await loadLatestSnapshot(bun.db, aggId, tenant)).toBeNull();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("snapshotVersion bump ignores the stored snapshot and restamps on auto-save", async () => {
|
|
289
|
+
const aggId = await seedAggregate(120);
|
|
290
|
+
await loadAggregateWithSnapshot<CounterState>(bun.db, aggId, tenant, reducer, initial, {
|
|
291
|
+
snapshotEvery: 100,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Shape bump: the generation-1 snapshot must be ignored (full replay),
|
|
295
|
+
// and the auto-save restamps generation 2 at the same event version.
|
|
296
|
+
const bumped = await loadAggregateWithSnapshot<CounterState>(
|
|
297
|
+
bun.db,
|
|
298
|
+
aggId,
|
|
299
|
+
tenant,
|
|
300
|
+
reducer,
|
|
301
|
+
initial,
|
|
302
|
+
{ snapshotEvery: 100, snapshotVersion: 2 },
|
|
303
|
+
);
|
|
304
|
+
expect(bumped.snapshotHit).toBe(false);
|
|
305
|
+
expect(bumped.state).toEqual(await loadFullState(aggId));
|
|
306
|
+
|
|
307
|
+
const restamped = await loadLatestSnapshot<CounterState>(bun.db, aggId, tenant);
|
|
308
|
+
expect(restamped?.snapshotVersion).toBe(2);
|
|
309
|
+
expect(restamped?.version).toBe(120);
|
|
310
|
+
|
|
311
|
+
const third = await loadAggregateWithSnapshot<CounterState>(
|
|
312
|
+
bun.db,
|
|
313
|
+
aggId,
|
|
314
|
+
tenant,
|
|
315
|
+
reducer,
|
|
316
|
+
initial,
|
|
317
|
+
{ snapshotEvery: 100, snapshotVersion: 2 },
|
|
318
|
+
);
|
|
319
|
+
expect(third.snapshotHit).toBe(true);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("invalid snapshotEvery fails loud", async () => {
|
|
323
|
+
const aggId = await seedAggregate(1);
|
|
324
|
+
await expect(
|
|
325
|
+
loadAggregateWithSnapshot<CounterState>(bun.db, aggId, tenant, reducer, initial, {
|
|
326
|
+
snapshotEvery: 0,
|
|
327
|
+
}),
|
|
328
|
+
).rejects.toThrow(/snapshotEvery/);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Glob } from "bun";
|
|
3
|
+
|
|
4
|
+
// getUnscopedAggregateStreamMaxVersion has no tenant filter — a caller can use
|
|
5
|
+
// it to probe whether a foreign tenant's aggregate exists (see event-store.ts
|
|
6
|
+
// SECURITY doc). Restricted to known seed/system-internal callers; extend
|
|
7
|
+
// only for genuine new ones.
|
|
8
|
+
const RESTRICTED_SYMBOLS = ["getUnscopedAggregateStreamMaxVersion"];
|
|
9
|
+
|
|
10
|
+
const ALLOWED_FILES = new Set([
|
|
11
|
+
"packages/framework/src/event-store/event-store.ts",
|
|
12
|
+
"packages/framework/src/event-store/index.ts",
|
|
13
|
+
"packages/bundled-features/src/tenant/seeding.ts",
|
|
14
|
+
"packages/bundled-features/src/tier-engine/feature.ts",
|
|
15
|
+
"packages/framework/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const REPO_ROOT = `${import.meta.dir}/../../../../..`;
|
|
19
|
+
|
|
20
|
+
describe("unscoped stream primitives — caller allowlist", () => {
|
|
21
|
+
test("only seed/system-internal paths reference the existence-oracle primitives", async () => {
|
|
22
|
+
// Widened from {framework,bundled-features} — the primitives are exported
|
|
23
|
+
// from the public @cosmicdrift/kumiko-framework/event-store barrel, so any
|
|
24
|
+
// package (server-runtime, dispatcher-live, headless, renderer-web, cli, ...)
|
|
25
|
+
// can import and call them; the allowlist must cover all of them, not just
|
|
26
|
+
// the two packages that happened to have callers when this guard was written.
|
|
27
|
+
const glob = new Glob("packages/*/src/**/*.ts");
|
|
28
|
+
const matches = new Set<string>();
|
|
29
|
+
for await (const relPath of glob.scan({ cwd: REPO_ROOT })) {
|
|
30
|
+
const content = await Bun.file(`${REPO_ROOT}/${relPath}`).text();
|
|
31
|
+
// Strip full-line `//` comments before scanning — a bare
|
|
32
|
+
// `content.includes(symbol)` also matches a symbol only mentioned in
|
|
33
|
+
// prose (e.g. explaining why a helper isn't needed), which would force
|
|
34
|
+
// an unrelated file into the allowlist for a reference that isn't an
|
|
35
|
+
// actual import or call.
|
|
36
|
+
const code = content
|
|
37
|
+
.split("\n")
|
|
38
|
+
.filter((line) => !line.trim().startsWith("//"))
|
|
39
|
+
.join("\n");
|
|
40
|
+
const referencesSymbol = (symbol: string): boolean =>
|
|
41
|
+
new RegExp(`\\b${symbol}\\s*\\(`).test(code) ||
|
|
42
|
+
// Covers both `import { symbol } from "..."` and a re-export barrel
|
|
43
|
+
// (`export { symbol } from "./event-store"`) — a new barrel that
|
|
44
|
+
// surfaces the oracle primitive must widen the allowlist too.
|
|
45
|
+
new RegExp(`\\b(import|export)\\b[^;]*\\b${symbol}\\b`).test(code);
|
|
46
|
+
if (RESTRICTED_SYMBOLS.some(referencesSymbol)) {
|
|
47
|
+
matches.add(relPath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Positive control — proves the scan actually ran and found the known
|
|
52
|
+
// caller, not just that it (silently) found nothing.
|
|
53
|
+
expect(matches.has("packages/bundled-features/src/tenant/seeding.ts")).toBe(true);
|
|
54
|
+
|
|
55
|
+
const offenders = [...matches].filter((relPath) => !ALLOWED_FILES.has(relPath));
|
|
56
|
+
expect(offenders).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|