@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,277 @@
|
|
|
1
|
+
// One-time backfill for pre-KMS plaintext PII in kumiko_events (#799).
|
|
2
|
+
//
|
|
3
|
+
// Crypto-shredding (#724/#818) only covers NEW writes — events appended
|
|
4
|
+
// before a KMS was configured still carry plaintext (user.created email,
|
|
5
|
+
// delivery attempt recipientAddress, job payloads). This tool re-encrypts
|
|
6
|
+
// them in place, per field, under the owning subject's DEK:
|
|
7
|
+
//
|
|
8
|
+
// - entity lifecycle events (<entity>.created/updated/deleted/forgotten/
|
|
9
|
+
// restored) for every entity with PII subject annotations
|
|
10
|
+
// - custom events from the event-PII catalog (r.defineEvent piiFields)
|
|
11
|
+
//
|
|
12
|
+
// Already-forgotten subjects must NOT get a fresh key minted for their old
|
|
13
|
+
// plaintext — three erased-detection layers write [[erased]] instead:
|
|
14
|
+
// 1. KeyErasedError from the KMS (subject forgotten in the KMS era)
|
|
15
|
+
// 2. the event's own aggregate has a *.forgotten event (pre-KMS forget)
|
|
16
|
+
// 3. the resolved user subject's id has a *.forgotten event (custom
|
|
17
|
+
// events referencing a pre-KMS-forgotten user)
|
|
18
|
+
//
|
|
19
|
+
// Idempotent: ciphertext and sentinel values pass through untouched — a
|
|
20
|
+
// second run reports 0 updates. One failing event does not abort the run;
|
|
21
|
+
// failures are collected and reported (fail-loud at the caller).
|
|
22
|
+
//
|
|
23
|
+
// Snapshots of touched aggregates are dropped (they may cache plaintext);
|
|
24
|
+
// the next snapshotting load recreates them. AFTER a run, rebuild the
|
|
25
|
+
// affected projections — applyEntityEvent materializes ciphertext AND the
|
|
26
|
+
// blind-index columns, which keeps equality lookups (login by email) alive.
|
|
27
|
+
|
|
28
|
+
import { asRawClient } from "../../bun-db";
|
|
29
|
+
import { configuredEventPiiCatalog } from "../../crypto/event-pii";
|
|
30
|
+
import type { KmsContext, LocalKeyKmsAdapter, SubjectId } from "../../crypto/kms-adapter";
|
|
31
|
+
import { KeyErasedError } from "../../crypto/kms-adapter";
|
|
32
|
+
import {
|
|
33
|
+
configuredPiiSubjectKms,
|
|
34
|
+
encryptPiiValueForSubject,
|
|
35
|
+
isPiiCiphertext,
|
|
36
|
+
PII_ERASED_SENTINEL,
|
|
37
|
+
} from "../../crypto/pii-field-encryption";
|
|
38
|
+
import { collectPiiSubjectFields, resolveSubjectForField } from "../../crypto/subject-resolver";
|
|
39
|
+
import type { EntityDefinition, Registry, TenantId } from "../../engine/types";
|
|
40
|
+
import type { DbRunner } from "../connection";
|
|
41
|
+
|
|
42
|
+
const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
|
|
43
|
+
|
|
44
|
+
export type PiiBackfillFailure = {
|
|
45
|
+
readonly eventId: string;
|
|
46
|
+
readonly reason: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type PiiBackfillResult = {
|
|
50
|
+
readonly scannedEvents: number;
|
|
51
|
+
readonly updatedEvents: number;
|
|
52
|
+
readonly encryptedFields: number;
|
|
53
|
+
readonly erasedFields: number;
|
|
54
|
+
readonly deletedSnapshots: number;
|
|
55
|
+
readonly failures: readonly PiiBackfillFailure[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type PiiBackfillOptions = {
|
|
59
|
+
readonly batchSize?: number;
|
|
60
|
+
// Scan + count only, write nothing.
|
|
61
|
+
readonly dryRun?: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type EventRow = {
|
|
65
|
+
readonly id: bigint | string;
|
|
66
|
+
readonly aggregate_id: string;
|
|
67
|
+
readonly aggregate_type: string;
|
|
68
|
+
readonly tenant_id: string;
|
|
69
|
+
readonly type: string;
|
|
70
|
+
readonly payload: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type FieldOutcome = "unchanged" | "encrypted" | "erased";
|
|
74
|
+
|
|
75
|
+
export async function backfillEventPiiEncryption(
|
|
76
|
+
db: DbRunner,
|
|
77
|
+
registry: Registry,
|
|
78
|
+
options: PiiBackfillOptions = {},
|
|
79
|
+
): Promise<PiiBackfillResult> {
|
|
80
|
+
const kms = configuredPiiSubjectKms();
|
|
81
|
+
if (!kms) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"backfillEventPiiEncryption requires a configured subject KMS — boot with " +
|
|
84
|
+
"runProdApp({ kms }) / configurePiiSubjectKms(adapter) before running the backfill.",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const batchSize = options.batchSize ?? 500;
|
|
88
|
+
const raw = asRawClient(db);
|
|
89
|
+
const kmsCtx: KmsContext = { requestId: "pii-backfill" };
|
|
90
|
+
|
|
91
|
+
const entityTargets = new Map<
|
|
92
|
+
string,
|
|
93
|
+
{ readonly entity: EntityDefinition; readonly piiFields: readonly string[] }
|
|
94
|
+
>();
|
|
95
|
+
for (const [name, entity] of registry.getAllEntities()) {
|
|
96
|
+
const piiFields = collectPiiSubjectFields(entity);
|
|
97
|
+
if (piiFields.length > 0) entityTargets.set(name, { entity, piiFields });
|
|
98
|
+
}
|
|
99
|
+
const eventCatalog = configuredEventPiiCatalog();
|
|
100
|
+
|
|
101
|
+
const aggregateTypes = [...entityTargets.keys()];
|
|
102
|
+
const catalogTypes = [...eventCatalog.keys()];
|
|
103
|
+
const result = {
|
|
104
|
+
scannedEvents: 0,
|
|
105
|
+
updatedEvents: 0,
|
|
106
|
+
encryptedFields: 0,
|
|
107
|
+
erasedFields: 0,
|
|
108
|
+
deletedSnapshots: 0,
|
|
109
|
+
failures: [] as PiiBackfillFailure[],
|
|
110
|
+
};
|
|
111
|
+
if (aggregateTypes.length === 0 && catalogTypes.length === 0) return result;
|
|
112
|
+
|
|
113
|
+
// Pre-KMS forgets left no key tombstone — the *.forgotten event on the
|
|
114
|
+
// stream is the only durable marker. Collect once; aggregate_id doubles
|
|
115
|
+
// as the user id for user-subject lookups.
|
|
116
|
+
const forgottenRows = (await raw.unsafe(
|
|
117
|
+
`SELECT DISTINCT "aggregate_id" FROM "kumiko_events" WHERE "type" LIKE '%.forgotten'`,
|
|
118
|
+
)) as ReadonlyArray<{ aggregate_id: string }>;
|
|
119
|
+
const forgottenAggregates = new Set(forgottenRows.map((r) => r.aggregate_id));
|
|
120
|
+
|
|
121
|
+
const touchedAggregates = new Set<string>();
|
|
122
|
+
let cursor = "0";
|
|
123
|
+
|
|
124
|
+
for (;;) {
|
|
125
|
+
const rows = (await raw.unsafe(
|
|
126
|
+
`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
|
|
127
|
+
FROM "kumiko_events"
|
|
128
|
+
WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
|
|
129
|
+
ORDER BY "id" ASC
|
|
130
|
+
LIMIT $4`,
|
|
131
|
+
[aggregateTypes, catalogTypes, cursor, batchSize],
|
|
132
|
+
)) as ReadonlyArray<EventRow>;
|
|
133
|
+
if (rows.length === 0) break;
|
|
134
|
+
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
result.scannedEvents++;
|
|
137
|
+
try {
|
|
138
|
+
const outcome = await transformEvent(row);
|
|
139
|
+
if (outcome === null) continue;
|
|
140
|
+
result.encryptedFields += outcome.encrypted;
|
|
141
|
+
result.erasedFields += outcome.erased;
|
|
142
|
+
if (!options.dryRun) {
|
|
143
|
+
await raw.unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
|
|
144
|
+
JSON.stringify(outcome.payload),
|
|
145
|
+
row.id,
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
result.updatedEvents++;
|
|
149
|
+
touchedAggregates.add(row.aggregate_id);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
result.failures.push({
|
|
152
|
+
eventId: String(row.id),
|
|
153
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const last = rows[rows.length - 1];
|
|
158
|
+
if (last === undefined) break;
|
|
159
|
+
cursor = String(last.id);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Snapshots may cache the plaintext state of touched aggregates.
|
|
163
|
+
if (!options.dryRun && touchedAggregates.size > 0) {
|
|
164
|
+
const deleted = (await raw.unsafe(
|
|
165
|
+
`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`,
|
|
166
|
+
[[...touchedAggregates]],
|
|
167
|
+
)) as ReadonlyArray<unknown>;
|
|
168
|
+
result.deletedSnapshots = deleted.length;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
|
|
173
|
+
async function transformEvent(
|
|
174
|
+
row: EventRow,
|
|
175
|
+
): Promise<{ payload: Record<string, unknown>; encrypted: number; erased: number } | null> {
|
|
176
|
+
const counters = { encrypted: 0, erased: 0 };
|
|
177
|
+
const payload = structuredClone(row.payload);
|
|
178
|
+
|
|
179
|
+
const catalogFields = eventCatalog.get(row.type);
|
|
180
|
+
if (catalogFields) {
|
|
181
|
+
for (const [field, spec] of Object.entries(catalogFields)) {
|
|
182
|
+
const subjectId = payload[spec.subjectField];
|
|
183
|
+
if (typeof subjectId !== "string" || subjectId.length === 0) continue;
|
|
184
|
+
const outcome = await encryptField(payload, field, { kind: "user", userId: subjectId });
|
|
185
|
+
bump(outcome);
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
const target = entityTargets.get(row.aggregate_type);
|
|
189
|
+
if (!target || !isLifecycleEventOf(row.type, row.aggregate_type)) return null;
|
|
190
|
+
const sections = lifecycleSections(payload);
|
|
191
|
+
for (const section of sections) {
|
|
192
|
+
// Update-changes may carry a pii field without its owner field —
|
|
193
|
+
// resolve subjects from the merged view; aggregate_id backs the
|
|
194
|
+
// self-subject when a section lacks the id column.
|
|
195
|
+
const subjectSource: Record<string, unknown> = {
|
|
196
|
+
id: row.aggregate_id,
|
|
197
|
+
...Object.assign({}, ...sections),
|
|
198
|
+
...section,
|
|
199
|
+
};
|
|
200
|
+
for (const field of target.piiFields) {
|
|
201
|
+
const subject = resolveSubjectForField(target.entity, field, subjectSource, {
|
|
202
|
+
// @cast-boundary db-read — tenant_id column is the branded TenantId
|
|
203
|
+
tenantId: row.tenant_id as TenantId,
|
|
204
|
+
});
|
|
205
|
+
if (subject === null) continue;
|
|
206
|
+
const outcome = await encryptField(section, field, subject);
|
|
207
|
+
bump(outcome);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (counters.encrypted === 0 && counters.erased === 0) return null;
|
|
213
|
+
return { payload, ...counters };
|
|
214
|
+
|
|
215
|
+
function bump(outcome: FieldOutcome): void {
|
|
216
|
+
if (outcome === "encrypted") counters.encrypted++;
|
|
217
|
+
if (outcome === "erased") counters.erased++;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function encryptField(
|
|
221
|
+
section: Record<string, unknown>,
|
|
222
|
+
field: string,
|
|
223
|
+
subject: SubjectId,
|
|
224
|
+
): Promise<FieldOutcome> {
|
|
225
|
+
const value = section[field];
|
|
226
|
+
if (value === null || value === undefined) return "unchanged";
|
|
227
|
+
if (typeof value !== "string") return "unchanged";
|
|
228
|
+
if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) return "unchanged";
|
|
229
|
+
if (isForgottenSubject(subject, row.aggregate_id)) {
|
|
230
|
+
section[field] = PII_ERASED_SENTINEL;
|
|
231
|
+
return "erased";
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
section[field] = await encryptPiiValueForSubject(
|
|
235
|
+
kms as LocalKeyKmsAdapter,
|
|
236
|
+
subject,
|
|
237
|
+
value,
|
|
238
|
+
kmsCtx,
|
|
239
|
+
field,
|
|
240
|
+
);
|
|
241
|
+
return "encrypted";
|
|
242
|
+
} catch (e) {
|
|
243
|
+
if (e instanceof KeyErasedError) {
|
|
244
|
+
section[field] = PII_ERASED_SENTINEL;
|
|
245
|
+
return "erased";
|
|
246
|
+
}
|
|
247
|
+
throw e;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isForgottenSubject(subject: SubjectId, aggregateId: string): boolean {
|
|
253
|
+
if (forgottenAggregates.has(aggregateId)) return true;
|
|
254
|
+
return subject.kind === "user" && forgottenAggregates.has(subject.userId);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isLifecycleEventOf(eventType: string, aggregateType: string): boolean {
|
|
259
|
+
if (!eventType.startsWith(`${aggregateType}.`)) return false;
|
|
260
|
+
const verb = eventType.slice(aggregateType.length + 1);
|
|
261
|
+
return (LIFECYCLE_VERBS as readonly string[]).includes(verb);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// created events carry the fields flat; updated carries { changes, previous };
|
|
265
|
+
// deleted/forgotten/restored carry { previous }. Returned sections are the
|
|
266
|
+
// mutable objects INSIDE the payload clone — encryptField writes in place.
|
|
267
|
+
function lifecycleSections(payload: Record<string, unknown>): Record<string, unknown>[] {
|
|
268
|
+
const sections: Record<string, unknown>[] = [];
|
|
269
|
+
if (isRecord(payload["changes"])) sections.push(payload["changes"]);
|
|
270
|
+
if (isRecord(payload["previous"])) sections.push(payload["previous"]);
|
|
271
|
+
if (sections.length === 0) sections.push(payload);
|
|
272
|
+
return sections;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
276
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
277
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AnyDb } from "../query";
|
|
2
|
+
import { asRawClient } from "../query";
|
|
3
|
+
import { quoteTableIdent } from "./table-ops";
|
|
4
|
+
|
|
5
|
+
// Generic DDL helpers used on the prod-boot path (stack/table-helpers.ts's
|
|
6
|
+
// unsafePushTables, event-consumer-state.ts's multi-instance backfill) —
|
|
7
|
+
// split out of queries/test-stack.ts so a prod-boot import doesn't point at
|
|
8
|
+
// a module named for test-only concerns (truncate/create/drop-database).
|
|
9
|
+
|
|
10
|
+
export async function executeDdlStatement(db: AnyDb, sqlText: string): Promise<void> {
|
|
11
|
+
await asRawClient(db).unsafe(sqlText);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function alterTableAddColumn(
|
|
15
|
+
db: AnyDb,
|
|
16
|
+
tableName: string,
|
|
17
|
+
columnName: string,
|
|
18
|
+
columnType: string,
|
|
19
|
+
defaultClause: string,
|
|
20
|
+
notNull: string,
|
|
21
|
+
// table-helpers.ts's unmanaged-table sync relies on the plain form
|
|
22
|
+
// throwing when a column already exists with a different shape than
|
|
23
|
+
// EntityTableMeta expects — that's how it surfaces drift. Callers that
|
|
24
|
+
// just need an idempotent, race-safe backfill (e.g. event-consumer-state's
|
|
25
|
+
// multi-instance boot path, #1362) opt in explicitly.
|
|
26
|
+
ifNotExists = false,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
await asRawClient(db).unsafe(
|
|
29
|
+
`ALTER TABLE ${quoteTableIdent(tableName)} ADD COLUMN ${ifNotExists ? "IF NOT EXISTS " : ""}${quoteTableIdent(columnName)} ${columnType}${defaultClause}${notNull}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function createIndexIfNotExists(
|
|
34
|
+
db: AnyDb,
|
|
35
|
+
indexKind: "UNIQUE INDEX" | "INDEX",
|
|
36
|
+
indexName: string,
|
|
37
|
+
tableName: string,
|
|
38
|
+
columnList: string,
|
|
39
|
+
whereSql?: string,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const where = whereSql !== undefined ? ` WHERE ${whereSql}` : "";
|
|
42
|
+
await asRawClient(db).unsafe(
|
|
43
|
+
`CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})${where}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -47,6 +47,7 @@ export type ConsumerDeliveryOutcome = {
|
|
|
47
47
|
readonly attempts: number;
|
|
48
48
|
readonly lastError: string | null;
|
|
49
49
|
readonly deadLettered: boolean;
|
|
50
|
+
readonly processed: number;
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
export async function updateConsumerDeliveryOutcome(
|
|
@@ -55,19 +56,26 @@ export async function updateConsumerDeliveryOutcome(
|
|
|
55
56
|
instanceId: string,
|
|
56
57
|
outcome: ConsumerDeliveryOutcome,
|
|
57
58
|
): Promise<void> {
|
|
59
|
+
// Advancing the cursor proves this pass wasn't poison — reset the re-arm
|
|
60
|
+
// budget so an unrelated failure down the line gets its own fresh 3
|
|
61
|
+
// chances instead of inheriting a partially-spent counter from a past,
|
|
62
|
+
// already-resolved outage.
|
|
63
|
+
const resetRearmCount = outcome.processed > 0;
|
|
58
64
|
await asRawClient(db).unsafe(
|
|
59
65
|
`UPDATE "kumiko_event_consumers" SET
|
|
60
66
|
"last_processed_event_id" = $1,
|
|
61
67
|
"attempts" = $2,
|
|
62
68
|
"status" = $3,
|
|
63
69
|
"last_error" = $4,
|
|
70
|
+
"rearm_count" = CASE WHEN $5 THEN 0 ELSE "rearm_count" END,
|
|
64
71
|
"updated_at" = now()
|
|
65
|
-
WHERE "name" = $
|
|
72
|
+
WHERE "name" = $6 AND "instance_id" = $7`,
|
|
66
73
|
[
|
|
67
74
|
outcome.cursor,
|
|
68
75
|
outcome.attempts,
|
|
69
76
|
outcome.deadLettered ? "dead" : "idle",
|
|
70
77
|
outcome.lastError,
|
|
78
|
+
resetRearmCount,
|
|
71
79
|
name,
|
|
72
80
|
instanceId,
|
|
73
81
|
],
|
|
@@ -81,7 +89,7 @@ export async function updateConsumerStatusReturning(
|
|
|
81
89
|
status: "idle" | "disabled",
|
|
82
90
|
): Promise<Record<string, unknown> | undefined> {
|
|
83
91
|
const rows = (await asRawClient(db).unsafe(
|
|
84
|
-
`UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "updated_at" = now()
|
|
92
|
+
`UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "rearm_count" = 0, "updated_at" = now()
|
|
85
93
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
86
94
|
RETURNING *`,
|
|
87
95
|
[status, name, instanceId],
|
|
@@ -101,6 +109,7 @@ export async function advanceConsumerPastEventReturning(
|
|
|
101
109
|
"status" = 'idle',
|
|
102
110
|
"attempts" = 0,
|
|
103
111
|
"last_error" = NULL,
|
|
112
|
+
"rearm_count" = 0,
|
|
104
113
|
"updated_at" = now()
|
|
105
114
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
106
115
|
RETURNING *`,
|
|
@@ -168,3 +177,27 @@ export async function markConsumerRebuildFailed(
|
|
|
168
177
|
[errorMessage, name, instanceId],
|
|
169
178
|
);
|
|
170
179
|
}
|
|
180
|
+
|
|
181
|
+
// Auto-revive a dead consumer once its cooldown has elapsed (called from
|
|
182
|
+
// acquireConsumerState — not an ops action, so no "requireConsumerRow"
|
|
183
|
+
// precondition like restartConsumer/enableConsumer). Increments rearm_count
|
|
184
|
+
// so the caller can enforce a lifetime cap on automatic revivals; manual
|
|
185
|
+
// restartConsumer()/enableConsumer() reset it back to 0.
|
|
186
|
+
export async function rearmDeadConsumer(
|
|
187
|
+
db: AnyDb,
|
|
188
|
+
name: string,
|
|
189
|
+
instanceId: string,
|
|
190
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
191
|
+
const rows = (await asRawClient(db).unsafe(
|
|
192
|
+
`UPDATE "kumiko_event_consumers" SET
|
|
193
|
+
"status" = 'idle',
|
|
194
|
+
"attempts" = 0,
|
|
195
|
+
"last_error" = NULL,
|
|
196
|
+
"rearm_count" = "rearm_count" + 1,
|
|
197
|
+
"updated_at" = now()
|
|
198
|
+
WHERE "name" = $1 AND "instance_id" = $2
|
|
199
|
+
RETURNING *`,
|
|
200
|
+
[name, instanceId],
|
|
201
|
+
)) as ReadonlyArray<Record<string, unknown>>;
|
|
202
|
+
return rows[0];
|
|
203
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
constraintOf,
|
|
3
|
+
isLockNotAvailable,
|
|
4
|
+
isTableAlreadyExists,
|
|
5
|
+
isUniqueViolation,
|
|
6
|
+
} from "../pg-error";
|
|
1
7
|
import type { AnyDb } from "../query";
|
|
2
8
|
import { asRawClient } from "../query";
|
|
3
9
|
|
|
@@ -6,6 +12,106 @@ export async function notifyPgChannel(db: AnyDb, channel: string): Promise<void>
|
|
|
6
12
|
await asRawClient(db).unsafe(`SELECT pg_notify($1, '')`, [channel]);
|
|
7
13
|
}
|
|
8
14
|
|
|
15
|
+
// Tenant-scoped partial unique index over metadata.idempotencyKey.
|
|
16
|
+
// Expression index straight on the jsonb column — no dedicated key column,
|
|
17
|
+
// so it needs no INSERT-path change and covers admin-api's raw appends too
|
|
18
|
+
// (same metadata jsonb). CREATE ... IF NOT EXISTS makes this safe to call
|
|
19
|
+
// on every boot, same "ensure" pattern as ensureSnapshotVersionColumn: heals
|
|
20
|
+
// installs that predate the index without a table rebuild.
|
|
21
|
+
//
|
|
22
|
+
// CONCURRENTLY (not a plain CREATE): a non-concurrent build takes a SHARE
|
|
23
|
+
// lock for the full table scan on kumiko_events — the hottest table in the
|
|
24
|
+
// framework — blocking every append() for however long that scan takes on
|
|
25
|
+
// an existing installation's event history. CONCURRENTLY avoids that at the
|
|
26
|
+
// cost of needing to tolerate two failure modes a plain build doesn't have.
|
|
27
|
+
// Neither CREATE nor DROP ... CONCURRENTLY may run inside a transaction —
|
|
28
|
+
// no caller of this function (dev-server, schema-cli.ts, stack/db.ts) may
|
|
29
|
+
// wrap it in one, or Postgres raises 25001.
|
|
30
|
+
// undefined = index doesn't exist at all (nothing to drop, CREATE below
|
|
31
|
+
// handles it); false = exists but INVALID (crashed mid-build, needs DROP +
|
|
32
|
+
// rebuild); true = exists and valid.
|
|
33
|
+
async function indexValidity(client: ReturnType<typeof asRawClient>): Promise<boolean | undefined> {
|
|
34
|
+
const rows = await client.unsafe(
|
|
35
|
+
`SELECT i.indisvalid FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid ` +
|
|
36
|
+
`WHERE c.relname = 'events_idempotency_uq' AND i.indrelid = '"kumiko_events"'::regclass`,
|
|
37
|
+
);
|
|
38
|
+
return (rows[0] as { indisvalid?: boolean } | undefined)?.indisvalid;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function ensureIdempotencyKeyIndex(db: AnyDb): Promise<void> {
|
|
42
|
+
const client = asRawClient(db);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// 1) A prior CONCURRENTLY build that got killed mid-flight (crash, deploy
|
|
46
|
+
// restart) leaves an INVALID index: the catalog entry exists, so
|
|
47
|
+
// IF NOT EXISTS below would silently skip forever, but the index is
|
|
48
|
+
// incomplete and not plannable for queries — it does NOT mean the
|
|
49
|
+
// constraint enforces nothing; Postgres keeps maintaining an INVALID
|
|
50
|
+
// index on every insert, it just refuses to use it for planning.
|
|
51
|
+
// Detect + rebuild it. Scoped to kumiko_events specifically (indrelid),
|
|
52
|
+
// not just the relname, so a same-named index in another schema can't
|
|
53
|
+
// false-positive this DROP.
|
|
54
|
+
if ((await indexValidity(client)) === false) {
|
|
55
|
+
await client.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS "events_idempotency_uq"`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await client.unsafe(
|
|
59
|
+
`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "events_idempotency_uq" ON "kumiko_events" ` +
|
|
60
|
+
`("tenant_id", (("metadata"->>'idempotencyKey'))) ` +
|
|
61
|
+
`WHERE "metadata"->>'idempotencyKey' IS NOT NULL`,
|
|
62
|
+
);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (isBenignConcurrentIndexBuildRace(e)) {
|
|
65
|
+
// "Benign" only means the losing side of a race, not that the index
|
|
66
|
+
// actually landed valid — a lock_timeout on the CREATE (55P03) backs
|
|
67
|
+
// off the same way a genuine duplicate-build race does, but leaves no
|
|
68
|
+
// valid index at all. Re-check before declaring victory instead of
|
|
69
|
+
// trusting the error class alone.
|
|
70
|
+
// skip: sibling pod already built a valid index — this race loser is done.
|
|
71
|
+
if ((await indexValidity(client)) === true) return;
|
|
72
|
+
console.warn(
|
|
73
|
+
`ensureIdempotencyKeyIndex: backed off on a "benign" race (${String(e)}) but ` +
|
|
74
|
+
`"events_idempotency_uq" is still missing/invalid afterward — likely a ` +
|
|
75
|
+
`lock_timeout during CREATE INDEX CONCURRENTLY, not an actual winner/loser race.`,
|
|
76
|
+
);
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
throw duplicateIdempotencyKeyErrorOr(e);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Two pods booting concurrently against the same DB (rolling deploy): both
|
|
84
|
+
// see the index missing/invalid and both start a DROP/CREATE CONCURRENTLY
|
|
85
|
+
// build. The loser typically does NOT get the plain duplicate-relation
|
|
86
|
+
// no-op IF NOT EXISTS normally gives — it can instead see a unique-violation
|
|
87
|
+
// on Postgres' own pg_class catalog insert (23505, constraint
|
|
88
|
+
// pg_class_relname_nsp_index) or a lock-not-available (55P03) from the
|
|
89
|
+
// racing DDL. Both are benign: the other pod's build wins and this one just
|
|
90
|
+
// backs off. A 23505 against "events_idempotency_uq" itself is NOT this
|
|
91
|
+
// race — see duplicateIdempotencyKeyErrorOr.
|
|
92
|
+
function isBenignConcurrentIndexBuildRace(e: unknown): boolean {
|
|
93
|
+
if (isTableAlreadyExists(e) || isLockNotAvailable(e)) return true;
|
|
94
|
+
return isUniqueViolation(e) && constraintOf(e) === "pg_class_relname_nsp_index";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A 23505 against "events_idempotency_uq" means real duplicate
|
|
98
|
+
// metadata->>'idempotencyKey' values for the same tenant already exist —
|
|
99
|
+
// CONCURRENTLY still enforces uniqueness on live inserts against the
|
|
100
|
+
// not-yet-valid index. That needs an operator to find + resolve the
|
|
101
|
+
// duplicates, not a crash-loop on every subsequent boot, so re-throw a
|
|
102
|
+
// distinguishable error instead of the raw driver error.
|
|
103
|
+
function duplicateIdempotencyKeyErrorOr(e: unknown): unknown {
|
|
104
|
+
if (isUniqueViolation(e) && constraintOf(e) === "events_idempotency_uq") {
|
|
105
|
+
return new Error(
|
|
106
|
+
"ensureIdempotencyKeyIndex: duplicate metadata->>'idempotencyKey' values exist for at least " +
|
|
107
|
+
"one tenant in kumiko_events — CREATE UNIQUE INDEX CONCURRENTLY cannot complete. Find and " +
|
|
108
|
+
"resolve the duplicate idempotencyKey rows, then restart to retry.",
|
|
109
|
+
{ cause: e },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return e;
|
|
113
|
+
}
|
|
114
|
+
|
|
9
115
|
export type SubsequentEventInsertParams = {
|
|
10
116
|
readonly aggregateId: string;
|
|
11
117
|
readonly aggregateType: string;
|
|
@@ -85,22 +191,6 @@ export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string):
|
|
|
85
191
|
return rows[0]?.v ?? 0;
|
|
86
192
|
}
|
|
87
193
|
|
|
88
|
-
/** tenant_id the aggregate's events were written under — no membership/tenant
|
|
89
|
-
* filter. A r.systemScope() aggregate (e.g. user) lives in whichever tenant
|
|
90
|
-
* its creating executor used, which need not be a tenant the subject holds a
|
|
91
|
-
* membership in. Returns null for unknown streams. */
|
|
92
|
-
export async function selectAggregateStreamTenant(
|
|
93
|
-
db: AnyDb,
|
|
94
|
-
aggregateId: string,
|
|
95
|
-
aggregateType: string,
|
|
96
|
-
): Promise<string | null> {
|
|
97
|
-
const rows = (await asRawClient(db).unsafe(
|
|
98
|
-
`SELECT "tenant_id" AS t FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = $2 ORDER BY "version" LIMIT 1`,
|
|
99
|
-
[aggregateId, aggregateType],
|
|
100
|
-
)) as ReadonlyArray<{ t: string | null }>;
|
|
101
|
-
return rows[0]?.t ?? null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
194
|
export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
|
|
105
195
|
const rows = (await asRawClient(db).unsafe(
|
|
106
196
|
`SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`,
|
|
@@ -135,18 +225,35 @@ export type SaveSnapshotParams = {
|
|
|
135
225
|
// Plain object — see SubsequentEventInsertParams on why pre-stringified
|
|
136
226
|
// JSON double-encodes under Bun.SQL's ::jsonb binding.
|
|
137
227
|
readonly state: Record<string, unknown>;
|
|
228
|
+
readonly snapshotVersion: number;
|
|
138
229
|
};
|
|
139
230
|
|
|
231
|
+
// kumiko_snapshots predates snapshot_version — idempotent heal for existing
|
|
232
|
+
// installs, run from the same ensure path as table creation.
|
|
233
|
+
export async function ensureSnapshotVersionColumn(db: AnyDb): Promise<void> {
|
|
234
|
+
await asRawClient(db).unsafe(
|
|
235
|
+
`ALTER TABLE "kumiko_snapshots" ADD COLUMN IF NOT EXISTS "snapshot_version" integer NOT NULL DEFAULT 1`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
140
239
|
export async function upsertSnapshot(db: AnyDb, params: SaveSnapshotParams): Promise<void> {
|
|
141
240
|
await asRawClient(db).unsafe(
|
|
142
241
|
`INSERT INTO "kumiko_snapshots"
|
|
143
|
-
("aggregate_id", "tenant_id", "aggregate_type", "version", "state")
|
|
144
|
-
VALUES ($1, $2, $3, $4, $5::jsonb)
|
|
242
|
+
("aggregate_id", "tenant_id", "aggregate_type", "version", "state", "snapshot_version")
|
|
243
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, $6)
|
|
145
244
|
ON CONFLICT ("aggregate_id", "version") DO UPDATE SET
|
|
146
245
|
"state" = $5::jsonb,
|
|
147
246
|
"aggregate_type" = $3,
|
|
247
|
+
"snapshot_version" = $6,
|
|
148
248
|
"created_at" = now()`,
|
|
149
|
-
[
|
|
249
|
+
[
|
|
250
|
+
params.aggregateId,
|
|
251
|
+
params.tenantId,
|
|
252
|
+
params.aggregateType,
|
|
253
|
+
params.version,
|
|
254
|
+
params.state,
|
|
255
|
+
params.snapshotVersion,
|
|
256
|
+
],
|
|
150
257
|
);
|
|
151
258
|
}
|
|
152
259
|
|
|
@@ -23,12 +23,19 @@ export async function selectEventsForProjectionRebuildBatch(
|
|
|
23
23
|
afterId: bigint,
|
|
24
24
|
limit: number,
|
|
25
25
|
): Promise<ReadonlyArray<Record<string, unknown>>> {
|
|
26
|
+
// Archived streams don't replay (Marten-aligned): their aggregates are
|
|
27
|
+
// frozen ops-tombstones — replaying them would resurrect rows (or, for
|
|
28
|
+
// stranded duplicate-aggregates like fw#832, collide on unique indexes).
|
|
26
29
|
return (await asRawClient(db).unsafe(
|
|
27
|
-
`SELECT * FROM "kumiko_events"
|
|
28
|
-
WHERE "aggregate_type" = ANY($1::text[])
|
|
29
|
-
AND "type" = ANY($2::text[])
|
|
30
|
-
AND "id" > $3
|
|
31
|
-
|
|
30
|
+
`SELECT * FROM "kumiko_events" e
|
|
31
|
+
WHERE e."aggregate_type" = ANY($1::text[])
|
|
32
|
+
AND e."type" = ANY($2::text[])
|
|
33
|
+
AND e."id" > $3
|
|
34
|
+
AND NOT EXISTS (
|
|
35
|
+
SELECT 1 FROM "kumiko_archived_streams" a
|
|
36
|
+
WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
|
|
37
|
+
)
|
|
38
|
+
ORDER BY e."id" ASC
|
|
32
39
|
LIMIT $4`,
|
|
33
40
|
[aggregateTypes, eventTypes, afterId, limit],
|
|
34
41
|
)) as ReadonlyArray<Record<string, unknown>>;
|
|
@@ -43,10 +50,17 @@ export async function countSubscribedEvents(
|
|
|
43
50
|
aggregateTypes: readonly string[],
|
|
44
51
|
eventTypes: readonly string[],
|
|
45
52
|
): Promise<bigint> {
|
|
53
|
+
// Same archived-streams exclusion as the batch query — the #443 recompute
|
|
54
|
+
// compares this count against applied events; a filter mismatch would make
|
|
55
|
+
// every rebuild with an archived stream loop the full-re-replay forever.
|
|
46
56
|
const rows = (await asRawClient(db).unsafe(
|
|
47
|
-
`SELECT count(*)::bigint AS n FROM "kumiko_events"
|
|
48
|
-
WHERE "aggregate_type" = ANY($1::text[])
|
|
49
|
-
AND "type" = ANY($2::text[])
|
|
57
|
+
`SELECT count(*)::bigint AS n FROM "kumiko_events" e
|
|
58
|
+
WHERE e."aggregate_type" = ANY($1::text[])
|
|
59
|
+
AND e."type" = ANY($2::text[])
|
|
60
|
+
AND NOT EXISTS (
|
|
61
|
+
SELECT 1 FROM "kumiko_archived_streams" a
|
|
62
|
+
WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
|
|
63
|
+
)`,
|
|
50
64
|
[aggregateTypes, eventTypes],
|
|
51
65
|
)) as ReadonlyArray<{ n: bigint | string | number | null }>;
|
|
52
66
|
const raw = rows[0]?.n;
|