@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
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Queue } from "bullmq";
|
|
3
|
+
import { z } from "zod";
|
|
2
4
|
import { requestContext } from "../../api/request-context";
|
|
3
5
|
import { createRegistry, defineFeature } from "../../engine";
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
+
import type {
|
|
7
|
+
AppContext,
|
|
8
|
+
ConfigAccessor,
|
|
9
|
+
ConfigAccessorFactory,
|
|
10
|
+
NotifyFactory,
|
|
11
|
+
Registry,
|
|
12
|
+
TenantId,
|
|
13
|
+
} from "../../engine/types";
|
|
14
|
+
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
15
|
+
import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
|
|
6
16
|
import { sleep, waitFor } from "../../testing";
|
|
7
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createJobRunner,
|
|
19
|
+
type JobLogEntry,
|
|
20
|
+
type JobMeta,
|
|
21
|
+
type JobRunner,
|
|
22
|
+
type JobRunnerOptions,
|
|
23
|
+
} from "../job-runner";
|
|
8
24
|
|
|
9
25
|
// --- Shared state ---
|
|
10
26
|
|
|
@@ -98,12 +114,42 @@ const testFeature = defineFeature("test", (r) => {
|
|
|
98
114
|
await sleep(500);
|
|
99
115
|
},
|
|
100
116
|
);
|
|
117
|
+
r.writeHandler(
|
|
118
|
+
"capped-event",
|
|
119
|
+
z.object({ n: z.number() }),
|
|
120
|
+
async (event) => ({ isSuccess: true, data: { n: event.payload.n } }),
|
|
121
|
+
{ access: { openToAll: true } },
|
|
122
|
+
);
|
|
123
|
+
// Event-triggered twin — exercises handleEvent's maxPerTenant guard
|
|
124
|
+
// (dispatch and handleEvent share isOverPerTenantLimit).
|
|
125
|
+
r.job(
|
|
126
|
+
"perTenantLimitedEvent",
|
|
127
|
+
{
|
|
128
|
+
trigger: { on: "test:write:capped-event" },
|
|
129
|
+
concurrency: "parallel",
|
|
130
|
+
maxPerTenant: 2,
|
|
131
|
+
},
|
|
132
|
+
async (payload) => {
|
|
133
|
+
jobLog.push({
|
|
134
|
+
name: "test:job:per-tenant-limited-event",
|
|
135
|
+
payload,
|
|
136
|
+
timestamp: Date.now(),
|
|
137
|
+
});
|
|
138
|
+
await sleep(500);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
101
141
|
|
|
102
142
|
// Job that fails
|
|
103
143
|
r.job("failingJob", { trigger: { manual: true }, retries: 1 }, async () => {
|
|
104
144
|
throw new Error("intentional failure");
|
|
105
145
|
});
|
|
106
146
|
|
|
147
|
+
// perTenant fan-out — used to assert missing getActiveTenantIds fails
|
|
148
|
+
// the wrapper job without killing the worker.
|
|
149
|
+
r.job("perTenantFanout", { trigger: { manual: true }, perTenant: true }, async (payload) => {
|
|
150
|
+
jobLog.push({ name: "test:job:per-tenant-fanout", payload, timestamp: Date.now() });
|
|
151
|
+
});
|
|
152
|
+
|
|
107
153
|
// Correlation propagation probe — records the requestContext it sees at
|
|
108
154
|
// handler-time so tests can assert the scheduling request's correlationId
|
|
109
155
|
// made it through BullMQ into the worker process.
|
|
@@ -119,6 +165,42 @@ const testFeature = defineFeature("test", (r) => {
|
|
|
119
165
|
timestamp: Date.now(),
|
|
120
166
|
});
|
|
121
167
|
});
|
|
168
|
+
// Exercises createJobLogger (info/warn/error/debug/child) — otherwise those
|
|
169
|
+
// one-liners stay uncovered even though every job builds a logger.
|
|
170
|
+
r.job("logProbe", { trigger: { manual: true } }, async (payload, ctx) => {
|
|
171
|
+
expect(ctx.log).toBeDefined();
|
|
172
|
+
const log = ctx.log!;
|
|
173
|
+
log.info("log-probe-info", { n: payload["n"] });
|
|
174
|
+
log.warn("log-probe-warn");
|
|
175
|
+
log.error("log-probe-error", { ok: false });
|
|
176
|
+
log.debug("log-probe-debug");
|
|
177
|
+
log.child({ probe: true }).info("log-probe-child");
|
|
178
|
+
jobLog.push({ name: "test:job:log-probe", payload, timestamp: Date.now() });
|
|
179
|
+
});
|
|
180
|
+
// job-runner must build ctx.files the same way dispatch-shared.ts does for
|
|
181
|
+
// write-handlers/MSPs — regression guard for #1520 (ctx.files was never
|
|
182
|
+
// materialized for jobs, so ctx.files.ref(...).read() threw for every job).
|
|
183
|
+
r.job("filesProbe", { trigger: { manual: true } }, async (payload, ctx) => {
|
|
184
|
+
const content = await ctx.files!.ref(payload["key"] as string).read();
|
|
185
|
+
jobLog.push({
|
|
186
|
+
name: "test:job:files-probe",
|
|
187
|
+
payload: { text: new TextDecoder().decode(content) },
|
|
188
|
+
timestamp: Date.now(),
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// job-runner must build ctx.notify/ctx.config the same way dispatch-shared.ts
|
|
193
|
+
// does for write-handlers — regression guard for #1532 (only ctx.files got
|
|
194
|
+
// the same fix; ctx.notify/ctx.config stayed undefined for every job).
|
|
195
|
+
r.job("notifyProbe", { trigger: { manual: true } }, async (payload, ctx) => {
|
|
196
|
+
await ctx.notify!("test-notification", { to: payload["toUserId"] as string });
|
|
197
|
+
jobLog.push({ name: "test:job:notify-probe", payload, timestamp: Date.now() });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
r.job("configProbe", { trigger: { manual: true } }, async (_payload, ctx) => {
|
|
201
|
+
const value = await ctx.config!("test:config:probe-key");
|
|
202
|
+
jobLog.push({ name: "test:job:config-probe", payload: { value }, timestamp: Date.now() });
|
|
203
|
+
});
|
|
122
204
|
});
|
|
123
205
|
|
|
124
206
|
beforeAll(async () => {
|
|
@@ -133,6 +215,7 @@ afterAll(async () => {
|
|
|
133
215
|
// Helper to create a runner, run tests, then stop
|
|
134
216
|
async function withRunner(
|
|
135
217
|
fn: (runner: JobRunner, registry: Registry) => Promise<void>,
|
|
218
|
+
opts?: Partial<JobRunnerOptions>,
|
|
136
219
|
): Promise<void> {
|
|
137
220
|
const registry = createRegistry([testFeature]);
|
|
138
221
|
const context: AppContext = {};
|
|
@@ -145,6 +228,7 @@ async function withRunner(
|
|
|
145
228
|
redisUrl,
|
|
146
229
|
consumerLane: "worker",
|
|
147
230
|
queueNamePrefix,
|
|
231
|
+
...opts,
|
|
148
232
|
});
|
|
149
233
|
|
|
150
234
|
try {
|
|
@@ -228,6 +312,75 @@ describe("scenario 3: manual trigger", () => {
|
|
|
228
312
|
});
|
|
229
313
|
});
|
|
230
314
|
|
|
315
|
+
describe("scenario 3b: ctx.files resolves through the file provider", () => {
|
|
316
|
+
test("job handler can read back a file via ctx.files.ref(key)", async () => {
|
|
317
|
+
clearLog();
|
|
318
|
+
const provider = createInMemoryFileProvider();
|
|
319
|
+
await provider.write("probe/hello.txt", new TextEncoder().encode("hello from files probe"));
|
|
320
|
+
|
|
321
|
+
await withRunner(
|
|
322
|
+
async (runner) => {
|
|
323
|
+
await runner.dispatch("test:job:files-probe", { key: "probe/hello.txt" });
|
|
324
|
+
await waitFor(() => {
|
|
325
|
+
const entries = jobLog.filter((e) => e.name === "test:job:files-probe");
|
|
326
|
+
expect(entries.length).toBe(1);
|
|
327
|
+
expect(entries[0]?.payload["text"]).toBe("hello from files probe");
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
{ context: { _fileProviderResolver: () => Promise.resolve(provider) } },
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("scenario 3c: ctx.notify resolves through the notify factory (#1532)", () => {
|
|
336
|
+
test("job handler can call ctx.notify and the factory is bound to the job's system user + tenant", async () => {
|
|
337
|
+
clearLog();
|
|
338
|
+
const calls: Array<{ readonly notifierTenantId: string; readonly notificationType: string }> =
|
|
339
|
+
[];
|
|
340
|
+
const notifyFactory: NotifyFactory = (_user, tenantId) => async (notificationType) => {
|
|
341
|
+
calls.push({ notifierTenantId: tenantId, notificationType });
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
await withRunner(
|
|
345
|
+
async (runner) => {
|
|
346
|
+
await runner.dispatch("test:job:notify-probe", { toUserId: "u1" });
|
|
347
|
+
await waitFor(() => {
|
|
348
|
+
const entries = jobLog.filter((e) => e.name === "test:job:notify-probe");
|
|
349
|
+
expect(entries.length).toBe(1);
|
|
350
|
+
});
|
|
351
|
+
expect(calls).toHaveLength(1);
|
|
352
|
+
expect(calls[0]?.notificationType).toBe("test-notification");
|
|
353
|
+
},
|
|
354
|
+
{ context: { _notifyFactory: notifyFactory } },
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("scenario 3d: ctx.config resolves through the config accessor factory (#1532)", () => {
|
|
360
|
+
test("job handler can call ctx.config and the factory receives a tenant-scoped db", async () => {
|
|
361
|
+
clearLog();
|
|
362
|
+
let receivedDb: unknown;
|
|
363
|
+
const configAccessorFactory: ConfigAccessorFactory = (deps) => {
|
|
364
|
+
receivedDb = deps.db;
|
|
365
|
+
// Stub accessor: only the string-key overload is exercised here.
|
|
366
|
+
return (async () => "probe-value") as ConfigAccessor;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
await withRunner(
|
|
370
|
+
async (runner) => {
|
|
371
|
+
await runner.dispatch("test:job:config-probe", {});
|
|
372
|
+
await waitFor(() => {
|
|
373
|
+
const entries = jobLog.filter((e) => e.name === "test:job:config-probe");
|
|
374
|
+
expect(entries.length).toBe(1);
|
|
375
|
+
expect(entries[0]?.payload["value"]).toBe("probe-value");
|
|
376
|
+
});
|
|
377
|
+
expect(receivedDb).toBeDefined();
|
|
378
|
+
},
|
|
379
|
+
{ context: { _configAccessorFactory: configAccessorFactory, db: {} as AppContext["db"] } },
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
231
384
|
// --- Concurrency modes ---
|
|
232
385
|
|
|
233
386
|
describe("concurrency: parallel", () => {
|
|
@@ -410,6 +563,40 @@ describe("concurrency: debounce", () => {
|
|
|
410
563
|
});
|
|
411
564
|
});
|
|
412
565
|
|
|
566
|
+
describe("concurrency: replace", () => {
|
|
567
|
+
test("enqueuer-only: each dispatch removes prior waiting peers", async () => {
|
|
568
|
+
// No consumer → jobs stay waiting. The replace branch walks getWaiting()
|
|
569
|
+
// and removes same-name peers before add — so three dispatches leave one.
|
|
570
|
+
const queueNamePrefix = `kumiko-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
571
|
+
await withRunner(
|
|
572
|
+
async (runner) => {
|
|
573
|
+
const id1 = await runner.dispatch("test:job:replace-job", { n: 1 });
|
|
574
|
+
const id2 = await runner.dispatch("test:job:replace-job", { n: 2 });
|
|
575
|
+
const id3 = await runner.dispatch("test:job:replace-job", { n: 3 });
|
|
576
|
+
expect(id1).toBeDefined();
|
|
577
|
+
expect(id2).toBeDefined();
|
|
578
|
+
expect(id3).toBeDefined();
|
|
579
|
+
expect(id1).not.toBe("skipped");
|
|
580
|
+
expect(id3).not.toBe(id1);
|
|
581
|
+
|
|
582
|
+
const queue = new Queue(`${queueNamePrefix}-worker`, {
|
|
583
|
+
connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
|
|
584
|
+
});
|
|
585
|
+
try {
|
|
586
|
+
const waiting = (await queue.getWaiting()).filter(
|
|
587
|
+
(job) => job.name === "test:job:replace-job",
|
|
588
|
+
);
|
|
589
|
+
expect(waiting).toHaveLength(1);
|
|
590
|
+
expect(waiting[0]?.data.n).toBe(3);
|
|
591
|
+
} finally {
|
|
592
|
+
await queue.close();
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
{ consumerLane: undefined, queueNamePrefix },
|
|
596
|
+
);
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
413
600
|
describe("concurrency: maxPerTenant", () => {
|
|
414
601
|
test("max=2: third dispatch for same tenant returns skipped, other tenant unaffected", async () => {
|
|
415
602
|
clearLog();
|
|
@@ -473,6 +660,48 @@ describe("concurrency: maxPerTenant", () => {
|
|
|
473
660
|
|
|
474
661
|
// --- Correlation propagation ---
|
|
475
662
|
|
|
663
|
+
describe("job logger", () => {
|
|
664
|
+
test("handler can call info/warn/error/debug/child without throw", async () => {
|
|
665
|
+
clearLog();
|
|
666
|
+
await withRunner(async (runner) => {
|
|
667
|
+
await runner.dispatch("test:job:log-probe", { n: 1 });
|
|
668
|
+
await waitFor(() => {
|
|
669
|
+
expect(jobLog.some((e) => e.name === "test:job:log-probe")).toBe(true);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Inspects the actual createJobLogger output instead of only asserting the
|
|
675
|
+
// handler ran — a child() that drops its context or a warn() mapped to
|
|
676
|
+
// debug would previously stay green.
|
|
677
|
+
test("info/warn/error land with the right level; debug is dropped; child merges its context", async () => {
|
|
678
|
+
clearLog();
|
|
679
|
+
let capturedLogs: JobLogEntry[] = [];
|
|
680
|
+
await withRunner(
|
|
681
|
+
async (runner) => {
|
|
682
|
+
await runner.dispatch("test:job:log-probe", { n: 1 });
|
|
683
|
+
await waitFor(() => {
|
|
684
|
+
expect(capturedLogs.length).toBeGreaterThan(0);
|
|
685
|
+
});
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
onJobComplete: (jobName, _id, _duration, logs) => {
|
|
689
|
+
if (jobName === "test:job:log-probe") capturedLogs = logs;
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// debug() is a no-op by design (JobLogEntry.level has no "debug" slot) —
|
|
695
|
+
// info/warn/error from the handler body, plus one from child().info(...).
|
|
696
|
+
expect(capturedLogs).toHaveLength(4);
|
|
697
|
+
expect(capturedLogs.map((l) => l.level)).toEqual(["info", "warn", "error", "info"]);
|
|
698
|
+
expect(capturedLogs[0]?.message).toContain("log-probe-info");
|
|
699
|
+
expect(capturedLogs[3]?.message).toContain("log-probe-child");
|
|
700
|
+
expect(capturedLogs[3]?.message).toContain("probe");
|
|
701
|
+
expect(capturedLogs[3]?.message).toContain("true");
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
|
|
476
705
|
describe("correlation propagation", () => {
|
|
477
706
|
test("dispatch inside requestContext.run passes correlationId into the job", async () => {
|
|
478
707
|
clearLog();
|
|
@@ -534,6 +763,103 @@ describe("error handling", () => {
|
|
|
534
763
|
});
|
|
535
764
|
});
|
|
536
765
|
});
|
|
766
|
+
|
|
767
|
+
test("onJobFailed receives the handler error on each attempt", async () => {
|
|
768
|
+
clearLog();
|
|
769
|
+
const failures: Array<{ jobName: string; error: string }> = [];
|
|
770
|
+
const starts: JobMeta[] = [];
|
|
771
|
+
await withRunner(
|
|
772
|
+
async (runner) => {
|
|
773
|
+
await runner.dispatch("test:job:failing-job");
|
|
774
|
+
// retries: 1 → attempts = 2 (initial + one retry)
|
|
775
|
+
await waitFor(() => {
|
|
776
|
+
expect(failures.length).toBeGreaterThanOrEqual(2);
|
|
777
|
+
});
|
|
778
|
+
expect(failures.every((f) => f.jobName === "test:job:failing-job")).toBe(true);
|
|
779
|
+
expect(failures.every((f) => f.error === "intentional failure")).toBe(true);
|
|
780
|
+
expect(starts.map((s) => s.attempt)).toEqual([1, 2]);
|
|
781
|
+
|
|
782
|
+
// Worker still alive after exhausted retries
|
|
783
|
+
await runner.dispatch("test:job:manual-report", { after: "retries" });
|
|
784
|
+
await waitFor(() => {
|
|
785
|
+
expect(
|
|
786
|
+
jobLog.some(
|
|
787
|
+
(e) => e.name === "test:job:manual-report" && e.payload["after"] === "retries",
|
|
788
|
+
),
|
|
789
|
+
).toBe(true);
|
|
790
|
+
});
|
|
791
|
+
},
|
|
792
|
+
{
|
|
793
|
+
onJobStart: (name, _id, meta) => {
|
|
794
|
+
if (name === "test:job:failing-job") starts.push(meta);
|
|
795
|
+
},
|
|
796
|
+
onJobFailed: (jobName, _id, error) => {
|
|
797
|
+
if (jobName === "test:job:failing-job") failures.push({ jobName, error });
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("perTenant without getActiveTenantIds fails wrapper; worker stays alive", async () => {
|
|
804
|
+
clearLog();
|
|
805
|
+
await withRunner(async (runner) => {
|
|
806
|
+
await runner.dispatch("test:job:per-tenant-fanout", { n: 1 });
|
|
807
|
+
// Wrapper throws before fan-out (and before onJobFailed) — settle, then
|
|
808
|
+
// prove the worker still consumes.
|
|
809
|
+
await sleep(300);
|
|
810
|
+
expect(jobLog.filter((e) => e.name === "test:job:per-tenant-fanout")).toHaveLength(0);
|
|
811
|
+
|
|
812
|
+
await runner.dispatch("test:job:manual-report", { after: "perTenant-miss" });
|
|
813
|
+
await waitFor(() => {
|
|
814
|
+
expect(jobLog.some((e) => e.name === "test:job:manual-report")).toBe(true);
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("perTenant with getActiveTenantIds fans out one run per tenant", async () => {
|
|
820
|
+
clearLog();
|
|
821
|
+
// @cast-boundary test fixture: TenantId is a branded string.
|
|
822
|
+
const tenants = ["per-tenant-fanout-a", "per-tenant-fanout-b"] as TenantId[];
|
|
823
|
+
await withRunner(
|
|
824
|
+
async (runner) => {
|
|
825
|
+
await runner.dispatch("test:job:per-tenant-fanout", { n: 7 });
|
|
826
|
+
await waitFor(() => {
|
|
827
|
+
const runs = jobLog.filter((e) => e.name === "test:job:per-tenant-fanout");
|
|
828
|
+
expect(runs.length).toBe(2);
|
|
829
|
+
expect(runs.every((e) => e.payload["n"] === 7)).toBe(true);
|
|
830
|
+
});
|
|
831
|
+
},
|
|
832
|
+
{ getActiveTenantIds: async () => tenants },
|
|
833
|
+
);
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
describe("handleEvent maxPerTenant", () => {
|
|
838
|
+
test("skips enqueue when tenant is already at the cap", async () => {
|
|
839
|
+
clearLog();
|
|
840
|
+
await withRunner(async (runner) => {
|
|
841
|
+
const user = { ...TestUsers.admin, tenantId: "cap-tenant-evt" };
|
|
842
|
+
await runner.handleEvent("test:write:capped-event", { n: 1 }, user);
|
|
843
|
+
await runner.handleEvent("test:write:capped-event", { n: 2 }, user);
|
|
844
|
+
await waitFor(() => {
|
|
845
|
+
const started = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
|
|
846
|
+
expect(started.length).toBe(2);
|
|
847
|
+
});
|
|
848
|
+
// Third must be skipped by the maxPerTenant guard. handleEvent's cap
|
|
849
|
+
// check runs synchronously before this call resolves, but a wrongly
|
|
850
|
+
// enqueued job still needs the worker to pick it up and push its
|
|
851
|
+
// jobLog entry — polling several times (failing at the first sign of
|
|
852
|
+
// a 3rd start) is more CI-load-tolerant than a single fixed sleep(200)
|
|
853
|
+
// racing against the handler's own 500ms sleep, which could land
|
|
854
|
+
// outside the window on a starved runner and silently pass.
|
|
855
|
+
await runner.handleEvent("test:write:capped-event", { n: 3 }, user);
|
|
856
|
+
for (const delayMs of [50, 100, 200, 400]) {
|
|
857
|
+
await sleep(delayMs);
|
|
858
|
+
const startedNow = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
|
|
859
|
+
expect(startedNow.length).toBe(2);
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
});
|
|
537
863
|
});
|
|
538
864
|
|
|
539
865
|
// --- Registry ---
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type Job, Queue, Worker } from "bullmq";
|
|
2
2
|
import { Redis } from "ioredis";
|
|
3
3
|
import { requestContext } from "../api/request-context";
|
|
4
|
-
import type { DbRow } from "../db/connection";
|
|
4
|
+
import type { DbConnection, DbRow } from "../db/connection";
|
|
5
|
+
import { createTenantDb } from "../db/tenant-db";
|
|
5
6
|
import { createSystemUser } from "../engine/system-user";
|
|
6
7
|
import {
|
|
7
8
|
type AppContext,
|
|
@@ -9,9 +10,17 @@ import {
|
|
|
9
10
|
type Registry,
|
|
10
11
|
type SessionUser,
|
|
11
12
|
SYSTEM_TENANT_ID,
|
|
13
|
+
type TenantId,
|
|
12
14
|
} from "../engine/types";
|
|
15
|
+
import { createFileContext } from "../files/file-handle";
|
|
13
16
|
import type { Logger } from "../logging/types";
|
|
14
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
emitJobQueueDepth,
|
|
19
|
+
getFallbackTracer,
|
|
20
|
+
type Meter,
|
|
21
|
+
type SerializedTraceContext,
|
|
22
|
+
type Tracer,
|
|
23
|
+
} from "../observability";
|
|
15
24
|
import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
|
|
16
25
|
import { RedisKeys } from "../pipeline/redis-keys";
|
|
17
26
|
|
|
@@ -31,9 +40,10 @@ export type JobLogEntry = {
|
|
|
31
40
|
timestamp: Temporal.Instant;
|
|
32
41
|
};
|
|
33
42
|
|
|
34
|
-
function createJobLogger(logs: JobLogEntry[]): Logger {
|
|
43
|
+
function createJobLogger(logs: JobLogEntry[], baseContext: Record<string, unknown> = {}): Logger {
|
|
35
44
|
function push(level: "info" | "warn" | "error", msg: string, data?: Record<string, unknown>) {
|
|
36
|
-
const
|
|
45
|
+
const merged = { ...baseContext, ...data };
|
|
46
|
+
const message = Object.keys(merged).length > 0 ? `${msg} ${JSON.stringify(merged)}` : msg;
|
|
37
47
|
logs.push({ level, message, timestamp: Temporal.Now.instant() });
|
|
38
48
|
}
|
|
39
49
|
const logger: Logger = {
|
|
@@ -46,9 +56,13 @@ function createJobLogger(logs: JobLogEntry[]): Logger {
|
|
|
46
56
|
error(msg, data) {
|
|
47
57
|
push("error", msg, data);
|
|
48
58
|
},
|
|
59
|
+
// No-op by design, not missing coverage: JobLogEntry.level (and the
|
|
60
|
+
// persisted run-completed/-failed event schema in bundled-features/
|
|
61
|
+
// jobs/events.ts) only has info|warn|error — a job's debug() calls have
|
|
62
|
+
// nowhere durable to land.
|
|
49
63
|
debug() {},
|
|
50
|
-
child() {
|
|
51
|
-
return
|
|
64
|
+
child(context) {
|
|
65
|
+
return createJobLogger(logs, { ...baseContext, ...context });
|
|
52
66
|
},
|
|
53
67
|
};
|
|
54
68
|
return logger;
|
|
@@ -94,7 +108,7 @@ export type JobRunnerOptions = {
|
|
|
94
108
|
// Tests set a unique prefix (e.g. `"test-${Date.now()}"`) for isolation —
|
|
95
109
|
// two parallel test-runners never see each other's jobs.
|
|
96
110
|
queueNamePrefix?: string | undefined;
|
|
97
|
-
getActiveTenantIds?: () => Promise<
|
|
111
|
+
getActiveTenantIds?: () => Promise<TenantId[]>;
|
|
98
112
|
onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
|
|
99
113
|
onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[]) => void;
|
|
100
114
|
onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[]) => void;
|
|
@@ -119,6 +133,29 @@ function captureTraceContext(tracer: Tracer): SerializedTraceContext | undefined
|
|
|
119
133
|
return { traceId: span.traceId, spanId: span.spanId };
|
|
120
134
|
}
|
|
121
135
|
|
|
136
|
+
const QUEUE_DEPTH_POLL_INTERVAL_MS = 15_000;
|
|
137
|
+
|
|
138
|
+
// kumiko_job_queue_depth: only the lane's own consumer polls — the count is
|
|
139
|
+
// a global Redis-backed value (BullMQ, not per-process), so one reporter per
|
|
140
|
+
// lane is enough. Fires once immediately (metrics exist before the first
|
|
141
|
+
// poll tick) then on an interval; the caller stores + clears the handle.
|
|
142
|
+
async function startQueueDepthPolling(
|
|
143
|
+
queue: Queue,
|
|
144
|
+
lane: JobRunIn,
|
|
145
|
+
meter: Meter,
|
|
146
|
+
): Promise<ReturnType<typeof setInterval>> {
|
|
147
|
+
const pollQueueDepth = async (): Promise<void> => {
|
|
148
|
+
try {
|
|
149
|
+
const counts = await queue.getJobCounts("waiting", "active", "delayed", "failed", "paused");
|
|
150
|
+
emitJobQueueDepth(meter, lane, counts);
|
|
151
|
+
} catch {
|
|
152
|
+
// skip: transient Redis hiccup — next poll retries
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
await pollQueueDepth();
|
|
156
|
+
return setInterval(() => void pollQueueDepth(), QUEUE_DEPTH_POLL_INTERVAL_MS);
|
|
157
|
+
}
|
|
158
|
+
|
|
122
159
|
function parseRedisOpts(url: string): { host: string; port: number; db?: number | undefined } {
|
|
123
160
|
const parsed = new URL(url);
|
|
124
161
|
const result: { host: string; port: number; db?: number | undefined } = {
|
|
@@ -181,6 +218,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
181
218
|
worker: new Queue(queueNameFor(queueNamePrefix, "worker"), { connection: redisOpts }),
|
|
182
219
|
};
|
|
183
220
|
let worker: Worker | null = null;
|
|
221
|
+
let queueDepthTimer: ReturnType<typeof setInterval> | null = null;
|
|
184
222
|
// Forward reference to the runner's own API, exposed on the job-handler ctx
|
|
185
223
|
// so a handler can dispatch a follow-up job (job→job chaining, e.g.
|
|
186
224
|
// delivery.render → delivery.send). Assigned just before return; reads happen
|
|
@@ -301,13 +339,35 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
301
339
|
SYSTEM_TENANT_ID;
|
|
302
340
|
const triggeredById = (rawData["_triggeredById"] as string | undefined) ?? null; // @cast-boundary dynamic-key
|
|
303
341
|
|
|
304
|
-
// _triggerName
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
// im rohen Payload kramen muss.
|
|
342
|
+
// Carry `_triggerName` from rawData when set — handleEvent injects it on
|
|
343
|
+
// multi-trigger dispatch; exposed as jobContext.triggerName so handlers
|
|
344
|
+
// don't dig through the raw payload themselves.
|
|
308
345
|
const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
|
|
346
|
+
// Mirror dispatch-shared.ts buildHandlerContext: ctx.files must resolve
|
|
347
|
+
// through the same _fileProviderResolver for jobs as for write-handlers,
|
|
348
|
+
// otherwise event-triggered jobs silently get an unresolved ctx.files.
|
|
349
|
+
const fileResolver = context._fileProviderResolver;
|
|
350
|
+
const files = fileResolver ? createFileContext(() => fileResolver(tenantId)) : context.files;
|
|
351
|
+
const jobSystemUser = createSystemUser(tenantId);
|
|
352
|
+
// Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
|
|
353
|
+
// must resolve for jobs the same way they do for write-handlers, or an
|
|
354
|
+
// app-author job calling ctx.notify(...)/ctx.config(...) hits a TypeError
|
|
355
|
+
// at runtime the write-handler path never would (framework#1532).
|
|
356
|
+
const notify = context._notifyFactory?.(jobSystemUser, tenantId);
|
|
357
|
+
const configDb = context.db as DbConnection | undefined; // @cast-boundary db-operator
|
|
358
|
+
const config =
|
|
359
|
+
context._configAccessorFactory && configDb
|
|
360
|
+
? context._configAccessorFactory({
|
|
361
|
+
user: { id: jobSystemUser.id, tenantId },
|
|
362
|
+
db: createTenantDb(configDb, tenantId, "system"),
|
|
363
|
+
secrets: context.secrets,
|
|
364
|
+
})
|
|
365
|
+
: undefined;
|
|
309
366
|
const jobContext: AppContext = {
|
|
310
367
|
...context,
|
|
368
|
+
files,
|
|
369
|
+
...(notify !== undefined && { notify }),
|
|
370
|
+
...(config !== undefined && { config }),
|
|
311
371
|
// The runner owns the registry it resolved this job from — expose it so
|
|
312
372
|
// workers can reach projections/jobs without the app author duplicating
|
|
313
373
|
// it into `context` (the JobContext contract guarantees `registry`).
|
|
@@ -315,7 +375,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
315
375
|
// Expose the runner so handlers can chain a follow-up job. Symmetric with
|
|
316
376
|
// how the command-dispatcher hands write-handlers their jobRunner.
|
|
317
377
|
...(selfRunner !== undefined && { jobRunner: selfRunner }),
|
|
318
|
-
systemUser:
|
|
378
|
+
systemUser: jobSystemUser,
|
|
319
379
|
triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
|
|
320
380
|
log: createJobLogger(logs),
|
|
321
381
|
...(triggerName !== undefined && { triggerName }),
|
|
@@ -431,9 +491,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
431
491
|
await consumerQueue.add(bootName, {}, { jobId: `boot-${name.replace(/\./g, "-")}` });
|
|
432
492
|
}
|
|
433
493
|
}
|
|
494
|
+
|
|
495
|
+
// Skipped when no meter is wired (context.meter is optional, e.g. in
|
|
496
|
+
// tests without an observability provider).
|
|
497
|
+
if (context.meter) {
|
|
498
|
+
queueDepthTimer = await startQueueDepthPolling(consumerQueue, consumerLane, context.meter);
|
|
499
|
+
}
|
|
434
500
|
},
|
|
435
501
|
|
|
436
502
|
async stop(): Promise<void> {
|
|
503
|
+
if (queueDepthTimer) {
|
|
504
|
+
clearInterval(queueDepthTimer);
|
|
505
|
+
queueDepthTimer = null;
|
|
506
|
+
}
|
|
437
507
|
if (worker) {
|
|
438
508
|
await worker.close();
|
|
439
509
|
worker = null;
|
package/src/logging/types.ts
CHANGED
|
@@ -1,7 +1 @@
|
|
|
1
|
-
export type
|
|
2
|
-
info(msg: string, data?: Record<string, unknown>): void;
|
|
3
|
-
warn(msg: string, data?: Record<string, unknown>): void;
|
|
4
|
-
error(msg: string, data?: Record<string, unknown>): void;
|
|
5
|
-
debug(msg: string, data?: Record<string, unknown>): void;
|
|
6
|
-
child(context: Record<string, unknown>): Logger;
|
|
7
|
-
};
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/logger";
|
|
@@ -200,7 +200,7 @@ export type EnqueueProjectionRebuildDeps = {
|
|
|
200
200
|
readonly db: DbConnection;
|
|
201
201
|
readonly registry: Registry;
|
|
202
202
|
// Present + projection-rebuild job registered (jobs composed) → tracked,
|
|
203
|
-
// retryable job (read_job_runs +
|
|
203
|
+
// retryable job (read_job_runs + store_job_run_logs). Absent → inline rebuild.
|
|
204
204
|
readonly jobRunner?: JobRunner;
|
|
205
205
|
};
|
|
206
206
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import type { TableColumns } from "../../db/dialect";
|
|
3
4
|
import { buildEntityTable } from "../../db/table-builder";
|
|
4
5
|
import { createRegistry, defineFeature } from "../../engine";
|
|
5
6
|
import type { AppContext, SaveContext } from "../../engine/types";
|
|
@@ -65,7 +66,9 @@ const todoEntity = {
|
|
|
65
66
|
|
|
66
67
|
let postSaveInvocations = 0;
|
|
67
68
|
const todoFeature = defineFeature("todo", (r) => {
|
|
68
|
-
|
|
69
|
+
// Brand (#742) is compile-time-only; the unbranded TableColumns view keeps the
|
|
70
|
+
// method-form insert (and its observability span) working — runtime shape is identical.
|
|
71
|
+
const todoTable: TableColumns = buildEntityTable("todo", todoEntity);
|
|
69
72
|
r.entity("todo", todoEntity);
|
|
70
73
|
|
|
71
74
|
r.writeHandler(
|
|
@@ -87,11 +87,13 @@ describe("RecordingTracer", () => {
|
|
|
87
87
|
expect(recorded).toHaveLength(1);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
it("
|
|
90
|
+
it("startSpan with parent context continues an upstream trace", () => {
|
|
91
91
|
const { tracer, recorded } = makeTracer();
|
|
92
|
-
const span = tracer.
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const span = tracer.startSpan("child", {
|
|
93
|
+
parent: {
|
|
94
|
+
traceId: "aabbccddeeff00112233445566778899",
|
|
95
|
+
spanId: "1122334455667788",
|
|
96
|
+
},
|
|
95
97
|
});
|
|
96
98
|
span.end();
|
|
97
99
|
expect(recorded[0]?.traceId).toBe("aabbccddeeff00112233445566778899");
|
|
@@ -50,8 +50,10 @@ export {
|
|
|
50
50
|
emitDispatcherHandler,
|
|
51
51
|
emitEventConsumerLag,
|
|
52
52
|
emitEventConsumerPassOutcome,
|
|
53
|
+
emitEventConsumerRearmExhausted,
|
|
53
54
|
emitEventDispatcherListenConnected,
|
|
54
55
|
emitHttpRequest,
|
|
56
|
+
emitJobQueueDepth,
|
|
55
57
|
registerStandardMetrics,
|
|
56
58
|
STANDARD_METRIC_DEFS,
|
|
57
59
|
} from "./standard-metrics";
|
|
@@ -5,7 +5,6 @@ import type {
|
|
|
5
5
|
Meter,
|
|
6
6
|
MetricDefinition,
|
|
7
7
|
ObservabilityProvider,
|
|
8
|
-
SerializedTraceContext,
|
|
9
8
|
Span,
|
|
10
9
|
SpanStatus,
|
|
11
10
|
StartSpanOptions,
|
|
@@ -68,14 +67,6 @@ class NoopTracer implements Tracer {
|
|
|
68
67
|
getActiveSpan(): Span | undefined {
|
|
69
68
|
return undefined;
|
|
70
69
|
}
|
|
71
|
-
|
|
72
|
-
startSpanFromContext(
|
|
73
|
-
name: string,
|
|
74
|
-
_context: SerializedTraceContext,
|
|
75
|
-
_options?: StartSpanOptions,
|
|
76
|
-
): Span {
|
|
77
|
-
return new NoopSpan(name, undefined);
|
|
78
|
-
}
|
|
79
70
|
}
|
|
80
71
|
|
|
81
72
|
class NoopCounter implements Counter {
|
|
@@ -175,18 +175,6 @@ export class RecordingTracer implements Tracer {
|
|
|
175
175
|
getActiveSpan(): Span | undefined {
|
|
176
176
|
return observabilityContext.getActiveSpan();
|
|
177
177
|
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* @deprecated Prefer `startSpan(name, { parent: context })`. Retained as a
|
|
181
|
-
* thin alias for call-sites that pre-date the unified StartSpanOptions.
|
|
182
|
-
*/
|
|
183
|
-
startSpanFromContext(
|
|
184
|
-
name: string,
|
|
185
|
-
context: SerializedTraceContext,
|
|
186
|
-
options?: StartSpanOptions,
|
|
187
|
-
): Span {
|
|
188
|
-
return this.startSpan(name, { ...options, parent: context });
|
|
189
|
-
}
|
|
190
178
|
}
|
|
191
179
|
|
|
192
180
|
// Helper to serialize an active Span into the cross-process format.
|