@cosmicdrift/kumiko-framework 0.386.0 → 0.388.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/AGENTS.md +6 -1
- package/LICENSE +21 -57
- package/README.md +2 -2
- package/agents/examples/anonymous-access.md +1 -1
- package/agents/examples/apex-landing.md +204 -0
- package/agents/examples/api-worker-split.md +64 -0
- package/agents/examples/app-config.md +14 -0
- package/agents/examples/auth-mfa.md +115 -0
- package/agents/examples/env-composition.md +28 -1
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/prod-boot-options.md +16 -0
- package/agents/examples/rate-limiting.md +48 -0
- package/agents/examples/record-detail-layout.md +692 -0
- package/agents/examples/seed-migration.md +212 -0
- package/agents/examples/wizard-form.md +941 -0
- package/agents/examples/writeform-section.md +548 -0
- package/agents/features.md +19 -2
- package/agents/widgets.md +40 -39
- package/dist/api/api-constants.js +2 -2
- package/dist/api/auth-middleware.js +7 -7
- package/dist/api/auth-rate-limiters.js +7 -3
- package/dist/api/auth-routes-mfa.js +1 -1
- package/dist/api/auth-routes-session.js +1 -1
- package/dist/api/auth-routes-shared.js +7 -3
- package/dist/api/auth-routes-signup.js +1 -1
- package/dist/api/client-error-route.js +8 -2
- package/dist/api/client-ip.js +3 -1
- package/dist/api/extra-route.js +1 -1
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +3 -1
- package/dist/api/pii-leak-guard.js +4 -2
- package/dist/api/readiness.js +5 -5
- package/dist/api/redis-sse-broker.js +8 -6
- package/dist/api/request-context.d.ts +1 -1
- package/dist/api/request-context.js +1 -1
- package/dist/api/routes.js +2 -2
- package/dist/api/server-boot-checks.d.ts +10 -0
- package/dist/api/server-boot-checks.js +157 -0
- package/dist/api/server-event-dispatcher.d.ts +26 -0
- package/dist/api/server-event-dispatcher.js +208 -0
- package/dist/api/server-extra-routes.d.ts +11 -0
- package/dist/api/server-extra-routes.js +203 -0
- package/dist/api/server-middleware.d.ts +20 -0
- package/dist/api/server-middleware.js +211 -0
- package/dist/api/server-routes.d.ts +19 -0
- package/dist/api/server-routes.js +293 -0
- package/dist/api/server-websocket-routes.d.ts +4 -0
- package/dist/api/server-websocket-routes.js +108 -0
- package/dist/api/server-wiring.d.ts +32 -0
- package/dist/api/server-wiring.js +236 -0
- package/dist/api/server.d.ts +10 -15
- package/dist/api/server.js +48 -1250
- package/dist/api/session-cookies.js +4 -4
- package/dist/api/sse-broker.js +1 -1
- package/dist/api/sse-route.d.ts +13 -13
- package/dist/api/sse-route.js +31 -27
- package/dist/auth/index.js +4 -4
- package/dist/auth/roles.d.ts +4 -4
- package/dist/auth/roles.js +24 -24
- package/dist/compliance/duration-spec.js +18 -20
- package/dist/compliance/index.js +2 -3
- package/dist/compliance/override-schema.d.ts +4 -4
- package/dist/compliance/override-schema.js +19 -22
- package/dist/compliance/profiles.d.ts +26 -29
- package/dist/compliance/profiles.js +62 -69
- package/dist/compliance/sub-processors.d.ts +28 -30
- package/dist/compliance/sub-processors.js +17 -21
- package/dist/consumer-cli.js +4 -4
- package/dist/crypto/blind-index.js +18 -17
- package/dist/crypto/ciphertext-pattern.js +1 -1
- package/dist/crypto/event-pii.js +3 -3
- package/dist/crypto/kek-source.d.ts +1 -1
- package/dist/crypto/kek-source.js +3 -2
- package/dist/crypto/kms-adapter.d.ts +1 -1
- package/dist/crypto/kms-adapter.js +4 -6
- package/dist/crypto/kms-wiring.d.ts +1 -2
- package/dist/crypto/kms-wiring.js +2 -2
- package/dist/crypto/owner-transition-rekey.d.ts +5 -1
- package/dist/crypto/owner-transition-rekey.js +1 -1
- package/dist/crypto/pg-kms-adapter.js +2 -6
- package/dist/crypto/pii-field-encryption.d.ts +5 -1
- package/dist/crypto/pii-field-encryption.js +7 -5
- package/dist/crypto/subject-resolver.d.ts +1 -1
- package/dist/crypto/subject-resolver.js +2 -2
- package/dist/db/aggregate-sql.d.ts +21 -0
- package/dist/db/aggregate-sql.js +155 -0
- package/dist/db/apply-entity-event.d.ts +6 -6
- package/dist/db/apply-entity-event.js +55 -55
- package/dist/db/blind-index-cleanup.js +7 -7
- package/dist/db/closed-connection.d.ts +9 -0
- package/dist/db/closed-connection.js +95 -0
- package/dist/db/collect-table-metas.js +10 -11
- package/dist/db/compound-types.d.ts +1 -1
- package/dist/db/compound-types.js +12 -12
- package/dist/db/cursor.js +3 -4
- package/dist/db/dialect.d.ts +2 -2
- package/dist/db/dialect.js +3 -3
- package/dist/db/distinct-on.d.ts +1 -1
- package/dist/db/driver-row-coercion.d.ts +2 -0
- package/dist/db/driver-row-coercion.js +186 -0
- package/dist/db/driver-value-preparation.d.ts +16 -0
- package/dist/db/driver-value-preparation.js +99 -0
- package/dist/db/eagerload.d.ts +3 -3
- package/dist/db/eagerload.js +21 -12
- package/dist/db/entity-field-encryption.d.ts +1 -0
- package/dist/db/entity-field-encryption.js +1 -1
- package/dist/db/entity-table-meta.d.ts +2 -2
- package/dist/db/entity-table-meta.js +105 -112
- package/dist/db/event-store-executor-context.d.ts +3 -2
- package/dist/db/event-store-executor-context.js +13 -13
- package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
- package/dist/db/event-store-executor-read-aggregate.js +112 -0
- package/dist/db/event-store-executor-read-filter.d.ts +31 -0
- package/dist/db/event-store-executor-read-filter.js +203 -0
- package/dist/db/event-store-executor-read-paging.d.ts +14 -0
- package/dist/db/event-store-executor-read-paging.js +99 -0
- package/dist/db/event-store-executor-read-rows.d.ts +6 -0
- package/dist/db/event-store-executor-read-rows.js +32 -0
- package/dist/db/event-store-executor-read-search.d.ts +28 -0
- package/dist/db/event-store-executor-read-search.js +221 -0
- package/dist/db/event-store-executor-read-sort.d.ts +41 -0
- package/dist/db/event-store-executor-read-sort.js +130 -0
- package/dist/db/event-store-executor-read-where.d.ts +24 -0
- package/dist/db/event-store-executor-read-where.js +81 -0
- package/dist/db/event-store-executor-read.d.ts +2 -9
- package/dist/db/event-store-executor-read.js +50 -704
- package/dist/db/event-store-executor-write-steps.d.ts +20 -0
- package/dist/db/event-store-executor-write-steps.js +100 -0
- package/dist/db/event-store-executor-write.js +401 -510
- package/dist/db/index.d.ts +6 -2
- package/dist/db/index.js +5 -1
- package/dist/db/list-aggregate-labels.d.ts +14 -0
- package/dist/db/list-aggregate-labels.js +26 -0
- package/dist/db/located-timestamp.d.ts +11 -11
- package/dist/db/located-timestamp.js +29 -30
- package/dist/db/migrate-generator.d.ts +1 -1
- package/dist/db/migrate-generator.js +97 -105
- package/dist/db/migrate-runner.js +29 -26
- package/dist/db/money.d.ts +6 -6
- package/dist/db/money.js +29 -27
- package/dist/db/queries/backfill-pii.js +5 -5
- package/dist/db/queries/ddl.js +1 -1
- package/dist/db/queries/es-ops.d.ts +1 -1
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/event-store.js +4 -2
- package/dist/db/queries/projection-rebuild.js +3 -3
- package/dist/db/queries/raw-sql.d.ts +2 -2
- package/dist/db/queries/raw-sql.js +2 -2
- package/dist/db/queries/shadow-swap.js +19 -19
- package/dist/db/query.d.ts +3 -16
- package/dist/db/query.js +8 -761
- package/dist/db/rebuild-marker.js +20 -21
- package/dist/db/reference-data.js +6 -1
- package/dist/db/replay-migration-sql.js +2 -2
- package/dist/db/schema-inspection.js +4 -5
- package/dist/db/table-builder.d.ts +1 -2
- package/dist/db/table-builder.js +137 -190
- package/dist/db/table-info.d.ts +10 -0
- package/dist/db/table-info.js +45 -0
- package/dist/db/temporal-value-guards.d.ts +6 -0
- package/dist/db/temporal-value-guards.js +15 -0
- package/dist/db/tenant-db-runner.d.ts +1 -0
- package/dist/db/tenant-db-runner.js +3 -0
- package/dist/db/tenant-db.d.ts +2 -2
- package/dist/db/tenant-db.js +48 -11
- package/dist/db/where-clause.d.ts +6 -0
- package/dist/db/where-clause.js +236 -0
- package/dist/derivatives/delete-stored-file.js +6 -2
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/access-roles.js +4 -3
- package/dist/engine/boot-validator/api-ext.js +14 -14
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +7 -6
- package/dist/engine/boot-validator/detail-screens.js +4 -4
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +77 -61
- package/dist/engine/boot-validator/form-derivations.js +35 -1
- package/dist/engine/boot-validator/gdpr-storage.js +4 -4
- package/dist/engine/boot-validator/i18n-keys.js +6 -4
- package/dist/engine/boot-validator/index.d.ts +2 -2
- package/dist/engine/boot-validator/index.js +22 -20
- package/dist/engine/boot-validator/nav.js +5 -5
- package/dist/engine/boot-validator/ownership.js +12 -9
- package/dist/engine/boot-validator/parent-ref.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +258 -187
- package/dist/engine/boot-validator/projection-list-screens.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +1 -1
- package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
- package/dist/engine/boot-validator/screens-actions.js +23 -17
- package/dist/engine/boot-validator/screens-dashboard.js +2 -5
- package/dist/engine/boot-validator/screens-forms.js +12 -17
- package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
- package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
- package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
- package/dist/engine/boot-validator/screens-lists.js +26 -24
- package/dist/engine/boot-validator/screens.d.ts +0 -2
- package/dist/engine/boot-validator/screens.js +102 -869
- package/dist/engine/boot-validator/security-baseline.js +3 -2
- package/dist/engine/boot-validator/transfer-graph.js +2 -2
- package/dist/engine/boot-validator/workspaces.js +3 -3
- package/dist/engine/bridge-stub.js +3 -4
- package/dist/engine/build-app-schema.js +130 -170
- package/dist/engine/build-config-feature-schema.js +50 -50
- package/dist/engine/build-target.js +20 -23
- package/dist/engine/codemod/pipeline-codemod.js +20 -13
- package/dist/engine/config-helpers.js +4 -4
- package/dist/engine/constants.js +2 -2
- package/dist/engine/create-app.js +2 -2
- package/dist/engine/define-feature.js +21 -13
- package/dist/engine/define-handler.js +13 -11
- package/dist/engine/define-step.js +1 -1
- package/dist/engine/define-workflow.d.ts +5 -5
- package/dist/engine/define-workflow.js +6 -6
- package/dist/engine/entity-handlers.d.ts +13 -0
- package/dist/engine/entity-handlers.js +22 -34
- package/dist/engine/entity-list-runtime-options.d.ts +8 -0
- package/dist/engine/entity-list-runtime-options.js +18 -0
- package/dist/engine/event-def.js +2 -2
- package/dist/engine/extension-names.d.ts +16 -17
- package/dist/engine/extension-names.js +37 -38
- package/dist/engine/extensions/escape-hatch-usage.js +1 -1
- package/dist/engine/extensions/tenant-data.js +2 -2
- package/dist/engine/extensions/user-data.d.ts +23 -24
- package/dist/engine/factories.d.ts +14 -13
- package/dist/engine/factories.js +38 -37
- package/dist/engine/feature-ast/entity-field-types.js +1 -1
- package/dist/engine/feature-ast/extractors/handlers.js +2 -1
- package/dist/engine/feature-ast/extractors/hooks.js +1 -1
- package/dist/engine/feature-ast/extractors/round2.js +25 -6
- package/dist/engine/feature-ast/extractors/shared.d.ts +2 -3
- package/dist/engine/feature-ast/extractors/shared.js +2 -3
- package/dist/engine/feature-ast/parse.js +13 -13
- package/dist/engine/feature-ast/patch.js +4 -182
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +2 -1
- package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
- package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
- package/dist/engine/feature-ast/patterns.d.ts +11 -2
- package/dist/engine/feature-ast/patterns.js +43 -51
- package/dist/engine/feature-ast/render.js +50 -89
- package/dist/engine/feature-config-events-jobs.js +15 -12
- package/dist/engine/feature-entity-handlers.js +12 -5
- package/dist/engine/feature-manifest.d.ts +9 -9
- package/dist/engine/feature-manifest.js +6 -7
- package/dist/engine/feature-ui-extensions.d.ts +3 -3
- package/dist/engine/feature-ui-extensions.js +30 -22
- package/dist/engine/field-access.js +5 -6
- package/dist/engine/form-derivations.js +18 -7
- package/dist/engine/handler-field-references.js +1 -1
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +4 -4
- package/dist/engine/object-form.d.ts +1 -0
- package/dist/engine/object-form.js +14 -4
- package/dist/engine/ownership.js +3 -3
- package/dist/engine/parse-ref-target.js +5 -7
- package/dist/engine/pattern-library/index.js +2 -2
- package/dist/engine/pattern-library/library.js +2 -2
- package/dist/engine/pattern-library/types.js +3 -3
- package/dist/engine/pipeline.js +2 -2
- package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
- package/dist/engine/project-app-schema-for-roles.js +7 -5
- package/dist/engine/projection-helpers.js +9 -9
- package/dist/engine/qualified-name.js +2 -2
- package/dist/engine/registry-facade.js +1 -1
- package/dist/engine/registry-ingest.js +13 -13
- package/dist/engine/registry-state.js +17 -19
- package/dist/engine/registry-validate.js +12 -12
- package/dist/engine/run-in.js +2 -2
- package/dist/engine/run-pipeline.js +7 -0
- package/dist/engine/schema-builder.js +251 -233
- package/dist/engine/screen-filter-ops.js +17 -20
- package/dist/engine/screen-helpers.js +4 -3
- package/dist/engine/screen-kind.d.ts +7 -0
- package/dist/engine/screen-kind.js +1 -0
- package/dist/engine/state-machine.d.ts +12 -12
- package/dist/engine/state-machine.js +7 -7
- package/dist/engine/steps/_resolver-utils.js +4 -3
- package/dist/engine/steps/for-each.js +4 -4
- package/dist/engine/steps/unsafe-projection-delete.js +1 -1
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -4
- package/dist/engine/steps/wait-for-event.js +4 -4
- package/dist/engine/steps/workflow-system-events.js +3 -3
- package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
- package/dist/engine/tier-resolver-extension.d.ts +29 -27
- package/dist/engine/tier-resolver-extension.js +26 -26
- package/dist/engine/transfer-adjacency.js +2 -2
- package/dist/engine/validate-projection-allowlist.js +9 -28
- package/dist/engine/walk-step-instances.d.ts +2 -0
- package/dist/engine/walk-step-instances.js +38 -0
- package/dist/engine/where-rule-lint.js +1 -1
- package/dist/engine/write-handler-qn-extract.js +1 -1
- package/dist/entrypoint/index.d.ts +4 -1
- package/dist/entrypoint/index.js +10 -4
- package/dist/env/index.d.ts +7 -6
- package/dist/env/index.js +10 -9
- package/dist/errors/docs-url.d.ts +1 -1
- package/dist/errors/docs-url.js +22 -2
- package/dist/errors/transition-details.js +7 -7
- package/dist/errors/write-error-info.d.ts +6 -6
- package/dist/errors/write-error-info.js +6 -6
- package/dist/es-ops/context.d.ts +9 -9
- package/dist/es-ops/context.js +29 -29
- package/dist/es-ops/index.js +7 -9
- package/dist/es-ops/operations-schema.js +4 -10
- package/dist/es-ops/runner.d.ts +11 -11
- package/dist/es-ops/runner.js +49 -53
- package/dist/es-ops/types.d.ts +55 -55
- package/dist/es-ops/types.js +11 -11
- package/dist/event-store/admin-api.js +7 -8
- package/dist/event-store/errors.js +1 -1
- package/dist/event-store/event-store.js +12 -6
- package/dist/event-store/rebuild-dead-letter.js +1 -1
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +4 -1
- package/dist/files/feature.js +9 -10
- package/dist/files/file-handle.js +7 -9
- package/dist/files/file-ref-entity.js +15 -15
- package/dist/files/file-ref-table.js +7 -8
- package/dist/files/file-routes.d.ts +7 -0
- package/dist/files/file-routes.js +22 -13
- package/dist/files/in-memory-provider.js +8 -9
- package/dist/files/local-provider.js +18 -25
- package/dist/files/provider-resolver.d.ts +7 -7
- package/dist/files/resolve-content-type.js +43 -0
- package/dist/files/storage-tracking.js +2 -2
- package/dist/files/upload-ledger.js +7 -0
- package/dist/files/zip-stream.d.ts +12 -19
- package/dist/files/zip-stream.js +44 -63
- package/dist/i18n/required-surface-keys.d.ts +1 -1
- package/dist/i18n/required-surface-keys.js +188 -212
- package/dist/internal/stack-support.d.ts +2 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-context.d.ts +32 -0
- package/dist/jobs/job-runner-context.js +305 -0
- package/dist/jobs/job-runner-execution.d.ts +7 -0
- package/dist/jobs/job-runner-execution.js +160 -0
- package/dist/jobs/job-runner-gates.d.ts +19 -0
- package/dist/jobs/job-runner-gates.js +198 -0
- package/dist/jobs/job-runner-helpers.d.ts +17 -3
- package/dist/jobs/job-runner-helpers.js +69 -4
- package/dist/jobs/job-runner-schedulers.d.ts +1 -1
- package/dist/jobs/job-runner-schedulers.js +3 -4
- package/dist/jobs/job-runner-scope.d.ts +44 -0
- package/dist/jobs/job-runner-scope.js +102 -0
- package/dist/jobs/job-runner-types.d.ts +12 -0
- package/dist/jobs/job-runner.d.ts +0 -1
- package/dist/jobs/job-runner.js +57 -669
- package/dist/lifecycle/lifecycle.js +3 -5
- package/dist/lifecycle/signal-handlers.js +5 -4
- package/dist/logging/utils.d.ts +2 -1
- package/dist/logging/utils.js +38 -10
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/kumiko-drift.js +14 -13
- package/dist/migrations/pending-rebuilds.d.ts +21 -20
- package/dist/migrations/pending-rebuilds.js +27 -27
- package/dist/migrations/projection-table-index.d.ts +3 -3
- package/dist/migrations/projection-table-index.js +8 -8
- package/dist/observability/escape-hatch-report.d.ts +3 -0
- package/dist/observability/escape-hatch-report.js +12 -1
- package/dist/observability/metric-validator.js +1 -1
- package/dist/observability/metrics-handle.js +3 -3
- package/dist/pipeline/append-event-core.js +11 -2
- package/dist/pipeline/cascade-handler.d.ts +1 -1
- package/dist/pipeline/cascade-handler.js +165 -138
- package/dist/pipeline/dispatch-batch.js +2 -2
- package/dist/pipeline/dispatch-query.js +20 -13
- package/dist/pipeline/dispatch-shared.d.ts +14 -3
- package/dist/pipeline/dispatch-shared.js +67 -721
- package/dist/pipeline/dispatch-stream.js +1 -1
- package/dist/pipeline/dispatch-write.js +297 -190
- package/dist/pipeline/dispatcher-utils.d.ts +1 -1
- package/dist/pipeline/dispatcher-utils.js +1 -1
- package/dist/pipeline/event-consumer-state.js +2 -2
- package/dist/pipeline/event-dispatcher-admin.js +2 -1
- package/dist/pipeline/event-dispatcher-delivery.js +4 -6
- package/dist/pipeline/event-dispatcher.js +17 -28
- package/dist/pipeline/handler-context-auth.d.ts +9 -0
- package/dist/pipeline/handler-context-auth.js +54 -0
- package/dist/pipeline/handler-context-config.d.ts +14 -0
- package/dist/pipeline/handler-context-config.js +113 -0
- package/dist/pipeline/handler-context-db.d.ts +24 -0
- package/dist/pipeline/handler-context-db.js +149 -0
- package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
- package/dist/pipeline/handler-context-dispatch.js +89 -0
- package/dist/pipeline/handler-context-events.d.ts +43 -0
- package/dist/pipeline/handler-context-events.js +237 -0
- package/dist/pipeline/handler-context-notify.d.ts +12 -0
- package/dist/pipeline/handler-context-notify.js +55 -0
- package/dist/pipeline/handler-context-observability.d.ts +7 -0
- package/dist/pipeline/handler-context-observability.js +21 -0
- package/dist/pipeline/handler-context-scope.d.ts +48 -0
- package/dist/pipeline/handler-context-scope.js +88 -0
- package/dist/pipeline/irreversible-operation-gate.js +1 -1
- package/dist/pipeline/lifecycle-pipeline.js +9 -9
- package/dist/pipeline/member-read-only-transaction.js +1 -3
- package/dist/pipeline/msp-rebuild.js +1 -1
- package/dist/pipeline/notify-scope.js +12 -1
- package/dist/pipeline/pre-handler-gates.js +20 -4
- package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
- package/dist/pipeline/pre-transaction-reservations.js +9 -3
- package/dist/pipeline/projection-rebuild.js +15 -13
- package/dist/pipeline/system-hooks.js +39 -41
- package/dist/pipeline/system-identity-switch.js +1 -3
- package/dist/pipeline/tenant-timezone-cache.js +1 -1
- package/dist/random/generate.d.ts +29 -29
- package/dist/random/generate.js +30 -30
- package/dist/random/words.js +20 -20
- package/dist/rate-limit/middleware.js +9 -5
- package/dist/redis/client.js +2 -2
- package/dist/redis/pubsub-signal.js +14 -17
- package/dist/schema-cli/apply-command.d.ts +2 -0
- package/dist/schema-cli/apply-command.js +97 -0
- package/dist/schema-cli/baseline-command.d.ts +2 -0
- package/dist/schema-cli/baseline-command.js +33 -0
- package/dist/schema-cli/generate-command.d.ts +2 -0
- package/dist/schema-cli/generate-command.js +90 -0
- package/dist/schema-cli/shared.d.ts +39 -0
- package/dist/schema-cli/shared.js +95 -0
- package/dist/schema-cli/status-command.d.ts +2 -0
- package/dist/schema-cli/status-command.js +73 -0
- package/dist/schema-cli/validate-boot-input.d.ts +5 -0
- package/dist/schema-cli/validate-boot-input.js +130 -0
- package/dist/schema-cli/validate-command.d.ts +2 -0
- package/dist/schema-cli/validate-command.js +130 -0
- package/dist/schema-cli.d.ts +2 -18
- package/dist/schema-cli.js +47 -510
- package/dist/search/index.js +4 -4
- package/dist/search/meilisearch-adapter.js +2 -1
- package/dist/search/purge-subject.js +4 -5
- package/dist/search/reindex-entity.js +6 -9
- package/dist/time/boot-tz-warning.d.ts +2 -2
- package/dist/time/boot-tz-warning.js +8 -8
- package/dist/time/iana.js +10 -10
- package/dist/time/index.js +9 -9
- package/dist/time/legacy-date.d.ts +2 -2
- package/dist/time/legacy-date.js +6 -8
- package/dist/time/polyfill.js +1 -1
- package/dist/time/tz-context.d.ts +1 -1
- package/dist/time/tz-context.js +1 -1
- package/dist/ui-types/app-schema.js +7 -7
- package/dist/ui-types/list-row-meta.js +4 -4
- package/dist/upgrade-cli.js +159 -130
- package/dist/utils/compare.js +1 -1
- package/package.json +5 -5
- package/src/changes.json +358 -0
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +2 -2
- package/src/scripts/codemod/feature-layout.ts +10 -2
- package/src/scripts/codemod/migrate-cross-tenant.ts +1 -1
- package/src/scripts/codemod/migrate-open-to-all.ts +1 -1
- package/src/scripts/codemod/object-form.ts +41 -4
- package/src/scripts/codemod/package-merge.ts +58 -8
- package/src/scripts/codemod/pii-personal-migration.ts +192 -153
- package/src/scripts/codemod/testing-injection.ts +122 -42
- package/src/scripts/codemod/testing-move.ts +29 -7
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +2 -2
package/dist/jobs/job-runner.js
CHANGED
|
@@ -1,27 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
import { crossTenantReadsGrantFor, escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
-
import { DelayedError, Queue, UnrecoverableError, Worker } from "bullmq";
|
|
1
|
+
import { DelayedError, Queue, Worker } from "bullmq";
|
|
4
2
|
import { Redis } from "ioredis";
|
|
5
3
|
import { requestContext } from "../api/request-context.js";
|
|
6
|
-
import { createCrossTenantReads } from "../db/cross-tenant-reads.js";
|
|
7
4
|
import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db.js";
|
|
8
|
-
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
9
5
|
import { bridgeStub } from "../engine/bridge-stub.js";
|
|
10
|
-
import { qnScope } from "../engine/qualified-name.js";
|
|
11
6
|
import { createSystemUser } from "../engine/system-user.js";
|
|
12
7
|
import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
|
|
13
8
|
import { InternalError } from "../errors/index.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
17
|
-
import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js";
|
|
9
|
+
import { createFallbackLogger } from "../logging/index.js";
|
|
10
|
+
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
18
11
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
19
12
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
20
|
-
import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
|
|
21
|
-
import { buildPersonalDataGate, parseWriteOrigin } from "../pipeline/write-origin.js";
|
|
22
13
|
import { cronRepeatOptions } from "./cron-repeat-options.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
14
|
+
import { applyHandlerDeferral, executeJobInSpan } from "./job-runner-execution.js";
|
|
15
|
+
import { claimSequentialSlot, deferOverTenantLimit, fanOutPerTenantJob, PER_TENANT_PREFIX, perTenantTargetOf, releaseSequentialSlot, resolveJobDefinitionOrFail, startJobRun, } from "./job-runner-gates.js";
|
|
16
|
+
import { BOOT_GATE_SLOW_WARN_MS, BOOT_REDIS_TIMEOUT_MS, buildRetryBullOpts, COMPLETED_JOB_RETENTION_AGE_SEC, captureTraceContext, FAILED_JOB_RETENTION_AGE_SEC, maxRetryWindowMs, parseRedisOpts, payloadMatchesTriggerFilter, STOP_QUEUE_READY_TIMEOUT_MS, stampGatedWriteOrigin, startQueueDepthPolling, TRACE_CONTEXT_KEY, timeoutReject, } from "./job-runner-helpers.js";
|
|
17
|
+
import { ACTIVE_TENANT_IDS_QUERY_NAME, bootJobIdForJobName, DEFAULT_QUEUE_NAME_PREFIX, markSchedulersSeen, ORPHAN_SCHEDULER_GRACE_MS, queueNameFor, removeOrphanJobSchedulers, schedulerIdForJobName, } from "./job-runner-schedulers.js";
|
|
18
|
+
import { announceDropped, announceQueued, isOverPerTenantLimit, laneForJob, } from "./job-runner-scope.js";
|
|
25
19
|
import { resolveMaxPerTenant } from "./max-per-tenant.js";
|
|
26
20
|
export { serializeJobSubject } from "./job-runner-helpers.js";
|
|
27
21
|
export { bootJobIdForJobName, ORPHAN_SCHEDULER_GRACE_MS, schedulerIdForJobName, } from "./job-runner-schedulers.js";
|
|
@@ -120,12 +114,6 @@ export function createJobRunner(options) {
|
|
|
120
114
|
"jobRetention.completedAgeSec.");
|
|
121
115
|
}
|
|
122
116
|
}
|
|
123
|
-
// Resolve the lane for a job — "worker" is the default because that's the
|
|
124
|
-
// sensible prod lane (heavy async off the request path). Jobs that opted
|
|
125
|
-
// into "api" must have been validated at registry boot already.
|
|
126
|
-
function laneForJob(def) {
|
|
127
|
-
return def.runIn ?? "worker";
|
|
128
|
-
}
|
|
129
117
|
// Sequential coordination: BullMQ OSS has no `group`, so we serialise
|
|
130
118
|
// same-name jobs ourselves with a per-name Redis lock. Only built when at
|
|
131
119
|
// least one job actually requested it — keeps the no-sequential boot path
|
|
@@ -139,8 +127,7 @@ export function createJobRunner(options) {
|
|
|
139
127
|
lockRedis = new Redis(redisOpts);
|
|
140
128
|
// Without a listener, a post-close 'error' (e.g. a teardown-race
|
|
141
129
|
// "Connection is closed") is unhandled and crashes the process — in
|
|
142
|
-
// bun:test it gets attributed to whichever test happens to run next
|
|
143
|
-
// (fw#1805).
|
|
130
|
+
// bun:test it gets attributed to whichever test happens to run next.
|
|
144
131
|
lockRedis.on("error", (err) => {
|
|
145
132
|
const log = stopping ? errorLogger.debug : errorLogger.error;
|
|
146
133
|
log("lock redis connection error", { error: err.message });
|
|
@@ -150,14 +137,6 @@ export function createJobRunner(options) {
|
|
|
150
137
|
// isolated by prefix on a shared Redis don't contend for the same lock.
|
|
151
138
|
sequentialLock = createDistributedLock(lockRedis, `${RedisKeys.lock}seq:${queueNamePrefix}:${lockScope}:`);
|
|
152
139
|
}
|
|
153
|
-
// Default lock-TTL for sequential jobs that didn't declare a timeout.
|
|
154
|
-
// 5 minutes matches BullMQ's default stalledInterval — long enough for
|
|
155
|
-
// any reasonable handler, short enough that a crashed worker recovers
|
|
156
|
-
// without manual intervention.
|
|
157
|
-
const SEQUENTIAL_DEFAULT_TTL_SEC = 305;
|
|
158
|
-
// How long to wait before re-trying a busy sequential lock. Short enough
|
|
159
|
-
// to feel responsive, long enough that we don't hammer Redis.
|
|
160
|
-
const SEQUENTIAL_RETRY_DELAY_MS = 200;
|
|
161
140
|
// Two queue-clients — one per lane. Every runner holds both, regardless of
|
|
162
141
|
// its own consumerLane, so dispatch()/handleEvent() always write to the
|
|
163
142
|
// queue matching the target job's runIn. Client-creation is cheap (shared
|
|
@@ -181,7 +160,7 @@ export function createJobRunner(options) {
|
|
|
181
160
|
}),
|
|
182
161
|
};
|
|
183
162
|
// Same unhandled-'error'-crash hazard as lockRedis above, just via
|
|
184
|
-
// BullMQ's internal ioredis client
|
|
163
|
+
// BullMQ's internal ioredis client.
|
|
185
164
|
for (const queue of Object.values(queues)) {
|
|
186
165
|
queue.on("error", (err) => {
|
|
187
166
|
const log = stopping ? errorLogger.debug : errorLogger.error;
|
|
@@ -203,644 +182,53 @@ export function createJobRunner(options) {
|
|
|
203
182
|
// Set by attachDispatcher() once the boot-level dispatcher exists.
|
|
204
183
|
// JobContext.write/writeAs/queryAs throw until this is set — see JobContext doc.
|
|
205
184
|
let dispatchWriteRef;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
for (const list of results) {
|
|
223
|
-
for (const j of list) {
|
|
224
|
-
if (j.name !== jobName)
|
|
225
|
-
continue;
|
|
226
|
-
if (scope.self && !isActiveBefore(j, scope.self))
|
|
227
|
-
continue;
|
|
228
|
-
const t = j.data?._tenantId; // @cast-boundary dynamic-key
|
|
229
|
-
if (t === tenantId) {
|
|
230
|
-
count += 1;
|
|
231
|
-
if (count >= max)
|
|
232
|
-
return true;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
return false;
|
|
237
|
-
}
|
|
238
|
-
function isActiveBefore(other, self) {
|
|
239
|
-
if (other.id === self.id)
|
|
240
|
-
return false;
|
|
241
|
-
if (self.processedOn === undefined)
|
|
242
|
-
return true;
|
|
243
|
-
const otherProcessedOn = other.processedOn ?? 0;
|
|
244
|
-
if (otherProcessedOn !== self.processedOn)
|
|
245
|
-
return otherProcessedOn < self.processedOn;
|
|
246
|
-
return (other.id ?? "") < (self.id ?? "");
|
|
247
|
-
}
|
|
248
|
-
// Growing delay bounds Redis and worker load while one tenant has a large
|
|
249
|
-
// backlog: every deferred job would otherwise be re-checked every deferMs.
|
|
250
|
-
function deferDelayMs(deferMs, deferCount) {
|
|
251
|
-
const grown = Math.min(deferMs * 2 ** Math.min(deferCount, DEFER_BACKOFF_MAX_DOUBLINGS), DEFER_DELAY_CAP_MS);
|
|
252
|
-
return grown + Math.floor(Math.random() * deferMs);
|
|
253
|
-
}
|
|
254
|
-
function isJobDeferral(value) {
|
|
255
|
-
return (typeof value === "object" &&
|
|
256
|
-
value !== null &&
|
|
257
|
-
"kind" in value &&
|
|
258
|
-
value.kind === "job-deferral");
|
|
259
|
-
}
|
|
260
|
-
function priorDeferCountOf(data, key) {
|
|
261
|
-
const prior = data[key];
|
|
262
|
-
return typeof prior === "number" ? prior : 0;
|
|
263
|
-
}
|
|
264
|
-
// Only err.message, never payload or the logs array: job payloads can carry PII.
|
|
265
|
-
function logFailedAttempt(entry) {
|
|
266
|
-
errorLogger.error("job failed", {
|
|
267
|
-
job: entry.job,
|
|
268
|
-
jobId: entry.jobId,
|
|
269
|
-
attempt: entry.attempt,
|
|
270
|
-
// BullMQ never retries an UnrecoverableError, whatever attempts remain.
|
|
271
|
-
final: entry.finalAttempt || entry.err instanceof UnrecoverableError,
|
|
272
|
-
error: scrubLogText(entry.err instanceof Error ? entry.err.message : String(entry.err)),
|
|
273
|
-
...(entry.err instanceof Error &&
|
|
274
|
-
entry.err.stack && { stack: scrubLogText(entry.err.stack) }),
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
async function reportPreRunFailure(jobName, bullJob, err) {
|
|
278
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
279
|
-
await options.onJobFailed?.(jobName, bullJob.id ?? "unknown", message, [
|
|
280
|
-
{ level: "error", message, timestamp: Temporal.Now.instant() },
|
|
281
|
-
]);
|
|
282
|
-
}
|
|
283
|
-
function handlerPayloadOf(data) {
|
|
284
|
-
const payload = {};
|
|
285
|
-
for (const [k, v] of Object.entries(data)) {
|
|
286
|
-
if (!k.startsWith("_"))
|
|
287
|
-
payload[k] = v;
|
|
288
|
-
}
|
|
289
|
-
return payload;
|
|
290
|
-
}
|
|
291
|
-
function tenantIdOfJobData(data) {
|
|
292
|
-
return (data["_tenantId"] ?? // @cast-boundary dynamic-key
|
|
293
|
-
data["tenantId"] ?? // @cast-boundary dynamic-key
|
|
294
|
-
SYSTEM_TENANT_ID);
|
|
295
|
-
}
|
|
296
|
-
// Tenant-visible queued state. A callback or subject problem must never fail
|
|
297
|
-
// the enqueue; a bad subject config surfaces as a failed run instead.
|
|
298
|
-
async function announceQueued(jobName, jobDef, bullJobId, data) {
|
|
299
|
-
const declaration = jobDef.tenantVisibleRun;
|
|
300
|
-
// skip: job did not opt in, no logger wired, or BullMQ returned no id
|
|
301
|
-
if (!declaration || !options.onJobQueued || bullJobId === undefined)
|
|
302
|
-
return;
|
|
303
|
-
const tenantId = tenantIdOfJobData(data);
|
|
304
|
-
// skip: tenant-less run — no tenant-scoped query could ever read it
|
|
305
|
-
if (tenantId === SYSTEM_TENANT_ID)
|
|
306
|
-
return;
|
|
307
|
-
try {
|
|
308
|
-
const subject = jobSubjectKey(jobName, handlerPayloadOf(data), declaration.subjectFields, "tenantVisibleRun");
|
|
309
|
-
await options.onJobQueued(jobName, bullJobId, { tenantId, subject });
|
|
310
|
-
}
|
|
311
|
-
catch (err) {
|
|
312
|
-
errorLogger.warn("tenant-visible queued state not recorded", {
|
|
313
|
-
job: jobName,
|
|
314
|
-
error: err instanceof Error ? err.message : String(err),
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
async function announceDropped(jobName, jobDef, bullJobId, data) {
|
|
319
|
-
// skip: job did not opt in or no logger wired
|
|
320
|
-
if (!jobDef.tenantVisibleRun || !options.onJobDropped)
|
|
321
|
-
return;
|
|
322
|
-
const tenantId = tenantIdOfJobData(data);
|
|
323
|
-
// skip: tenant-less run — announceQueued never recorded a row for it
|
|
324
|
-
if (tenantId === SYSTEM_TENANT_ID)
|
|
325
|
-
return;
|
|
326
|
-
try {
|
|
327
|
-
await options.onJobDropped(jobName, bullJobId, { tenantId });
|
|
328
|
-
}
|
|
329
|
-
catch (err) {
|
|
330
|
-
errorLogger.warn("tenant-visible queued state not cleared", {
|
|
331
|
-
job: jobName,
|
|
332
|
-
error: err instanceof Error ? err.message : String(err),
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
}
|
|
185
|
+
const scope = {
|
|
186
|
+
registry,
|
|
187
|
+
context,
|
|
188
|
+
options,
|
|
189
|
+
allJobs,
|
|
190
|
+
queues,
|
|
191
|
+
consumerLane,
|
|
192
|
+
tracer,
|
|
193
|
+
errorLogger,
|
|
194
|
+
sequentialLock,
|
|
195
|
+
getActiveTenantIds,
|
|
196
|
+
escapeHatchReportWindow,
|
|
197
|
+
systemCronEscapeHatchDedup,
|
|
198
|
+
getSelfRunner: () => selfRunner,
|
|
199
|
+
getDispatchWriteRef: () => dispatchWriteRef,
|
|
200
|
+
};
|
|
336
201
|
async function handleJob(bullJob, token) {
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// the _perTenant wrapper itself always lives in the consumer-lane
|
|
341
|
-
// (it's picked up by this runner's own worker).
|
|
342
|
-
if (rawName.startsWith("_perTenant:")) {
|
|
343
|
-
const actualName = rawName.slice("_perTenant:".length);
|
|
344
|
-
// These throws happen before runInSpan, so its onJobFailed never sees them
|
|
345
|
-
// and a drained test stack would stay green over a fan-out that never ran.
|
|
346
|
-
try {
|
|
347
|
-
if (!getActiveTenantIds) {
|
|
348
|
-
if (registry.getQueryHandler(ACTIVE_TENANT_IDS_QUERY_NAME)) {
|
|
349
|
-
throw new Error(`perTenant job "${actualName}": the tenant feature is mounted but the job runner has no ` +
|
|
350
|
-
"`context.db`: pass a DbConnection in the runner context or supply options.getActiveTenantIds");
|
|
351
|
-
}
|
|
352
|
-
throw new Error(`perTenant job "${actualName}" requires either options.getActiveTenantIds or the ` +
|
|
353
|
-
`tenant feature mounted (it registers "${ACTIVE_TENANT_IDS_QUERY_NAME}", which the ` +
|
|
354
|
-
"framework uses to resolve active tenants on its own)");
|
|
355
|
-
}
|
|
356
|
-
const actualDef = allJobs.get(actualName);
|
|
357
|
-
if (!actualDef) {
|
|
358
|
-
throw new Error(`Unknown job: ${actualName}`);
|
|
359
|
-
}
|
|
360
|
-
const tenantIds = await getActiveTenantIds();
|
|
361
|
-
const targetQueue = queues[laneForJob(actualDef)];
|
|
362
|
-
// wrapperJobId is only absent for a Job BullMQ somehow never assigned
|
|
363
|
-
// an id to (not observed in practice) — falling back to a constant
|
|
364
|
-
// there would collide every such run's children onto the same ids
|
|
365
|
-
// and silently swallow later runs as "duplicates", which is worse
|
|
366
|
-
// than the duplicate this is meant to prevent. Skip id-based dedup
|
|
367
|
-
// for that edge case instead: enqueue plainly, same as before.
|
|
368
|
-
const wrapperJobId = bullJob.id;
|
|
369
|
-
for (const tenantId of tenantIds) {
|
|
370
|
-
const childData = { ...bullJob.data, _tenantId: tenantId };
|
|
371
|
-
const child = await targetQueue.add(actualName, childData, {
|
|
372
|
-
...buildRetryBullOpts(actualDef),
|
|
373
|
-
// Dedup over wrapper retries only holds as long as the children
|
|
374
|
-
// stay in Redis for the wrapper's whole retry window — see the
|
|
375
|
-
// COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
|
|
376
|
-
...(wrapperJobId !== undefined
|
|
377
|
-
? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
|
|
378
|
-
: {}),
|
|
379
|
-
});
|
|
380
|
-
await announceQueued(actualName, actualDef, child.id, childData);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
catch (err) {
|
|
384
|
-
// A retry may still succeed, and a succeeding wrapper reports no completion
|
|
385
|
-
// that could clear an earlier failure, so only the final attempt is reported.
|
|
386
|
-
const retries = allJobs.get(actualName)?.retries ?? 0;
|
|
387
|
-
const finalAttempt = bullJob.attemptsMade + 1 >= retries + 1;
|
|
388
|
-
logFailedAttempt({
|
|
389
|
-
job: rawName,
|
|
390
|
-
jobId: bullJob.id ?? "unknown",
|
|
391
|
-
attempt: bullJob.attemptsMade + 1,
|
|
392
|
-
finalAttempt,
|
|
393
|
-
err,
|
|
394
|
-
});
|
|
395
|
-
if (finalAttempt) {
|
|
396
|
-
await reportPreRunFailure(rawName, bullJob, err);
|
|
397
|
-
}
|
|
398
|
-
throw err;
|
|
399
|
-
}
|
|
202
|
+
const perTenantTarget = perTenantTargetOf(bullJob.name);
|
|
203
|
+
if (perTenantTarget !== undefined) {
|
|
204
|
+
await fanOutPerTenantJob(scope, bullJob, perTenantTarget);
|
|
400
205
|
// skip: fan-out dispatcher job, per-tenant children enqueued
|
|
401
206
|
return;
|
|
402
207
|
}
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
if (!jobDef) {
|
|
406
|
-
const err = new Error(`Unknown job: ${jobName}`);
|
|
407
|
-
// Final regardless of attempts left: a retry cannot make the job exist.
|
|
408
|
-
logFailedAttempt({
|
|
409
|
-
job: jobName,
|
|
410
|
-
jobId: bullJob.id ?? "unknown",
|
|
411
|
-
attempt: bullJob.attemptsMade + 1,
|
|
412
|
-
finalAttempt: true,
|
|
413
|
-
err,
|
|
414
|
-
});
|
|
415
|
-
await reportPreRunFailure(jobName, bullJob, err);
|
|
416
|
-
throw err;
|
|
417
|
-
}
|
|
418
|
-
// maxPerTenant defer: checked before anything is recorded for this run, so a
|
|
419
|
-
// deferred pickup leaves no span, run-log entry or failure trace and does not
|
|
420
|
-
// consume an attempt (moveToDelayed + DelayedError, not a retry).
|
|
421
|
-
const maxPerTenant = resolveMaxPerTenant(jobDef);
|
|
422
|
-
const deferTenantId = bullJob.data["_tenantId"];
|
|
423
|
-
if (maxPerTenant?.onLimit === "defer" &&
|
|
424
|
-
typeof deferTenantId === "string" &&
|
|
425
|
-
token !== undefined &&
|
|
426
|
-
bullJob.moveToDelayed &&
|
|
427
|
-
(await isOverPerTenantLimit(jobName, deferTenantId, maxPerTenant.limit, {
|
|
428
|
-
activeOnly: true,
|
|
429
|
-
self: { id: bullJob.id, processedOn: bullJob.processedOn },
|
|
430
|
-
}))) {
|
|
431
|
-
const deferCount = priorDeferCountOf(bullJob.data, DEFER_COUNT_KEY);
|
|
432
|
-
await bullJob.updateData?.({ ...bullJob.data, [DEFER_COUNT_KEY]: deferCount + 1 });
|
|
433
|
-
await bullJob.moveToDelayed(Date.now() + deferDelayMs(maxPerTenant.deferMs, deferCount), token);
|
|
208
|
+
const jobDef = await resolveJobDefinitionOrFail(scope, bullJob);
|
|
209
|
+
if ((await deferOverTenantLimit(scope, jobDef, bullJob, token)) === "deferred") {
|
|
434
210
|
throw new DelayedError();
|
|
435
211
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// looks like an ordinary handoff to BullMQ.
|
|
441
|
-
let sequentialToken = null;
|
|
442
|
-
if (jobDef.concurrency === "sequential" && sequentialLock) {
|
|
443
|
-
const ttlSec = jobDef.timeout
|
|
444
|
-
? Math.ceil(jobDef.timeout / 1000) + 5
|
|
445
|
-
: SEQUENTIAL_DEFAULT_TTL_SEC;
|
|
446
|
-
sequentialToken = await sequentialLock.acquire(jobName, { ttlSeconds: ttlSec });
|
|
447
|
-
if (!sequentialToken) {
|
|
448
|
-
// Re-enqueue onto the job's own lane-queue. In practice that's the
|
|
449
|
-
// same queue the worker just picked from (since only the consuming
|
|
450
|
-
// lane runs handleJob at all), but route explicitly — no implicit
|
|
451
|
-
// coupling to "whichever queue the caller happened to be on".
|
|
452
|
-
// Attempts already spent travel in _priorAttempts so a lock conflict
|
|
453
|
-
// on a retry cannot hand out a fresh budget and exceed `retries`.
|
|
454
|
-
const priorAttempts = priorAttemptsOf(bullJob.data) + bullJob.attemptsMade;
|
|
455
|
-
const requeuedData = { ...bullJob.data, _priorAttempts: priorAttempts };
|
|
456
|
-
const requeued = await queues[laneForJob(jobDef)].add(jobName, requeuedData, {
|
|
457
|
-
delay: SEQUENTIAL_RETRY_DELAY_MS,
|
|
458
|
-
...buildRetryBullOpts(jobDef, priorAttempts),
|
|
459
|
-
});
|
|
460
|
-
if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
|
|
461
|
-
await announceDropped(jobName, jobDef, bullJob.id, bullJob.data);
|
|
462
|
-
}
|
|
463
|
-
await announceQueued(jobName, jobDef, requeued.id, requeuedData);
|
|
464
|
-
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
const jobId = bullJob.id ?? "unknown";
|
|
469
|
-
const startTime = Date.now();
|
|
470
|
-
const logs = [];
|
|
471
|
-
// Extract meta from job data. `attempt` is BullMQ's own counter
|
|
472
|
-
// (1-based on the first run, incremented on each retry) — threading
|
|
473
|
-
// it through lets the logger tag the run-started event with the
|
|
474
|
-
// retry number, so audit queries distinguish fresh from retry runs
|
|
475
|
-
// without peeking at BullMQ internals.
|
|
476
|
-
const rawData = bullJob.data;
|
|
477
|
-
const attempt = bullJob.attemptsMade + 1 + priorAttemptsOf(rawData);
|
|
478
|
-
const meta = {
|
|
479
|
-
triggeredById: rawData["_triggeredById"], // @cast-boundary dynamic-key
|
|
480
|
-
payload: rawData["_payload"], // @cast-boundary dynamic-key
|
|
481
|
-
attempt,
|
|
482
|
-
};
|
|
483
|
-
// Build handler payload (without internal meta fields)
|
|
484
|
-
const payload = handlerPayloadOf(rawData);
|
|
485
|
-
// Determine tenantId and triggeredBy from meta
|
|
486
|
-
const tenantId = tenantIdOfJobData(rawData);
|
|
487
|
-
const triggeredById = rawData["_triggeredById"] ?? null; // @cast-boundary dynamic-key
|
|
488
|
-
// Tenant-visible failure record (JobDefinition.tenantVisibleFailure). The
|
|
489
|
-
// subject is read from the handler payload, so it is resolved here where
|
|
490
|
-
// the payload is built, not in the callbacks.
|
|
491
|
-
const tenantVisibleDecl = jobDef.tenantVisibleFailure;
|
|
492
|
-
const tenantVisibleRunDecl = jobDef.tenantVisibleRun;
|
|
493
|
-
// A bad subject config must surface as a failed run, so it is thrown from
|
|
494
|
-
// inside runInSpan below instead of here, before any run record exists.
|
|
495
|
-
let tenantVisibleSubject = null;
|
|
496
|
-
let tenantVisibleRunSubject = null;
|
|
497
|
-
let subjectConfigError;
|
|
498
|
-
if (tenantVisibleRunDecl) {
|
|
499
|
-
try {
|
|
500
|
-
tenantVisibleRunSubject = jobSubjectKey(jobName, payload, tenantVisibleRunDecl.subjectFields, "tenantVisibleRun");
|
|
501
|
-
}
|
|
502
|
-
catch (err) {
|
|
503
|
-
subjectConfigError = err instanceof Error ? err : new Error(String(err));
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
if (tenantVisibleDecl && !subjectConfigError) {
|
|
507
|
-
try {
|
|
508
|
-
tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields, "tenantVisibleFailure");
|
|
509
|
-
}
|
|
510
|
-
catch (err) {
|
|
511
|
-
subjectConfigError = err instanceof Error ? err : new Error(String(err));
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
// BullMQ stops retrying once attemptsMade reaches the configured attempts
|
|
515
|
-
// (`retries + 1`), so this is the attempt whose failure is final.
|
|
516
|
-
const finalAttempt = bullJob.retryable === false || attempt >= (jobDef.retries ?? 0) + 1;
|
|
517
|
-
const outcomeMeta = (messageKey, unrecoverable = false) => ({
|
|
518
|
-
tenantId,
|
|
519
|
-
// BullMQ never retries an UnrecoverableError, whatever attempts are left.
|
|
520
|
-
finalAttempt: finalAttempt || unrecoverable,
|
|
521
|
-
...(tenantVisibleDecl &&
|
|
522
|
-
!subjectConfigError && {
|
|
523
|
-
tenantVisible: { subject: tenantVisibleSubject, messageKey },
|
|
524
|
-
}),
|
|
525
|
-
...(tenantVisibleRunDecl &&
|
|
526
|
-
!subjectConfigError && { tenantVisibleRun: { subject: tenantVisibleRunSubject } }),
|
|
527
|
-
});
|
|
528
|
-
// Carry `_triggerName` from rawData when set — handleEvent injects it on
|
|
529
|
-
// multi-trigger dispatch; exposed as jobContext.triggerName so handlers
|
|
530
|
-
// don't dig through the raw payload themselves.
|
|
531
|
-
const triggerName = rawData["_triggerName"]; // @cast-boundary dynamic-key
|
|
532
|
-
// Same carry-through for `_triggerEvent` — handleEvent injects it when
|
|
533
|
-
// the job-trigger event consumer supplied the stored event that fired
|
|
534
|
-
// this run (see createJobTriggerEventConsumer).
|
|
535
|
-
const rawTriggerEvent = rawData["_triggerEvent"];
|
|
536
|
-
const triggerEvent = isJobTriggerEvent(rawTriggerEvent) ? rawTriggerEvent : undefined;
|
|
537
|
-
// Absent = legacy or ungated root; present but invalid fails the run closed.
|
|
538
|
-
const rawWriteOrigin = rawData["_writeOrigin"]; // @cast-boundary dynamic-key
|
|
539
|
-
let jobOrigin;
|
|
540
|
-
let writeOriginInvalid = false;
|
|
541
|
-
if (rawWriteOrigin !== undefined) {
|
|
542
|
-
const parsed = parseWriteOrigin(rawWriteOrigin);
|
|
543
|
-
if (parsed) {
|
|
544
|
-
jobOrigin = { ...parsed, viaJob: jobName };
|
|
545
|
-
}
|
|
546
|
-
else {
|
|
547
|
-
writeOriginInvalid = true;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
const jobPersonalDataGate = jobOrigin ? buildPersonalDataGate(registry, jobOrigin) : undefined;
|
|
551
|
-
// Mirror dispatch-shared.ts buildHandlerContext: ctx.files must resolve
|
|
552
|
-
// through the same _fileProviderResolver for jobs as for write-handlers,
|
|
553
|
-
// otherwise event-triggered jobs silently get an unresolved ctx.files.
|
|
554
|
-
const fileResolver = context._fileProviderResolver;
|
|
555
|
-
const files = fileResolver ? createTenantFileContext(fileResolver, tenantId) : context.files;
|
|
556
|
-
const jobSystemUser = createSystemUser(tenantId);
|
|
557
|
-
// Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
|
|
558
|
-
// must resolve for jobs the same way they do for write-handlers, or an
|
|
559
|
-
// app-author job calling ctx.notify(...)/ctx.config(...) hits a TypeError
|
|
560
|
-
// at runtime the write-handler path never would (framework#1532).
|
|
561
|
-
const notify = context._notifyFactory?.(jobSystemUser, tenantId, selfRunner, undefined, context._fileProviderResolver);
|
|
562
|
-
const configDb = context.db; // @cast-boundary db-operator
|
|
563
|
-
// Shared by the config accessor and ctx.derivatives below — both need the
|
|
564
|
-
// same tenant-scoped db, and building it twice would let the two calls
|
|
565
|
-
// drift apart. Always "system" mode regardless of the job's own
|
|
566
|
-
// systemScope() status (pre-existing, not something this change alters)
|
|
567
|
-
// — isSystemJob below is what actually keeps ctx.systemDb off a
|
|
568
|
-
// non-system job; it is the only thing standing between this db and an
|
|
569
|
-
// unchecked cross-tenant escape hatch for such a job. Gated like jobDb so
|
|
570
|
-
// ctx.systemDb, built from it, is gated too.
|
|
571
|
-
const tenantScopedDb = configDb
|
|
572
|
-
? createTenantDb({
|
|
573
|
-
db: configDb,
|
|
574
|
-
tenantId,
|
|
575
|
-
mode: "system",
|
|
576
|
-
grants: {
|
|
577
|
-
projectionRegistry: registry,
|
|
578
|
-
...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
|
|
579
|
-
},
|
|
580
|
-
})
|
|
581
|
-
: undefined;
|
|
582
|
-
const isSystemJob = registry.isJobSystemScoped(jobName);
|
|
583
|
-
// One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
|
|
584
|
-
const isSystemCronRun = triggeredById === null && jobDef.escapeHatch !== undefined && "cron" in jobDef.trigger;
|
|
585
|
-
const reportEscapeHatch = createEscapeHatchReporter({
|
|
586
|
-
handler: jobName,
|
|
587
|
-
tenantId,
|
|
588
|
-
actor: jobSystemUser.id,
|
|
589
|
-
sink: context._escapeHatchAuditSink,
|
|
590
|
-
log: errorLogger,
|
|
591
|
-
window: escapeHatchReportWindow,
|
|
592
|
-
...(triggeredById !== null && { caller: triggeredById }),
|
|
593
|
-
...(isSystemCronRun && { processDedup: systemCronEscapeHatchDedup }),
|
|
594
|
-
...(context.meter && { meter: context.meter }),
|
|
595
|
-
});
|
|
596
|
-
const systemDb = isSystemJob && tenantScopedDb
|
|
597
|
-
? createUncheckedSystemDb(tenantScopedDb, undefined, reportEscapeHatch)
|
|
598
|
-
: undefined;
|
|
599
|
-
const jobDb = configDb
|
|
600
|
-
? createTenantDb({
|
|
601
|
-
db: configDb,
|
|
602
|
-
tenantId,
|
|
603
|
-
mode: "tenant",
|
|
604
|
-
tracer: context.tracer,
|
|
605
|
-
meter: context.meter,
|
|
606
|
-
grants: {
|
|
607
|
-
unsafeRaw: escapeHatchFor(jobDef.escapeHatch, "unsafeRaw"),
|
|
608
|
-
globalWrites: escapeHatchFor(jobDef.escapeHatch, "globalWrites"),
|
|
609
|
-
report: reportEscapeHatch,
|
|
610
|
-
projectionRegistry: registry,
|
|
611
|
-
...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
|
|
612
|
-
},
|
|
613
|
-
})
|
|
614
|
-
: undefined;
|
|
615
|
-
const crossTenantReadsReason = crossTenantReadsGrantFor(jobDef.escapeHatch)?.reason;
|
|
616
|
-
const crossTenantReads = tenantScopedDb && crossTenantReadsReason !== undefined
|
|
617
|
-
? createCrossTenantReads(tenantScopedDb, crossTenantReadsReason, reportEscapeHatch)
|
|
618
|
-
: undefined;
|
|
619
|
-
const configFactory = configAccessorFactoryOf(context);
|
|
620
|
-
const config = configFactory && tenantScopedDb
|
|
621
|
-
? configFactory({
|
|
622
|
-
user: { id: jobSystemUser.id, tenantId },
|
|
623
|
-
db: tenantScopedDb,
|
|
624
|
-
secrets: context.secrets,
|
|
625
|
-
})
|
|
626
|
-
: undefined;
|
|
627
|
-
// Mirror dispatch-shared.ts: ctx.derivatives needs files+db, same
|
|
628
|
-
// tenant-scoped db the config accessor above uses.
|
|
629
|
-
const derivatives = files && tenantScopedDb
|
|
630
|
-
? createDerivativesContext({
|
|
631
|
-
files,
|
|
632
|
-
registry,
|
|
633
|
-
db: tenantScopedDb,
|
|
634
|
-
tenantId,
|
|
635
|
-
})
|
|
636
|
-
: context.derivatives;
|
|
637
|
-
// Per-job-run reader, created lazily on queryAsMember's first call and
|
|
638
|
-
// cached for the rest of this run.
|
|
639
|
-
let memberReader;
|
|
640
|
-
const identitySwitchHatch = escapeHatchFor(jobDef.escapeHatch, "systemIdentity");
|
|
641
|
-
const identitySwitchGrantReason = identitySwitchHatch?.reason ??
|
|
642
|
-
(isSystemJob ? `r.systemScope() feature of job "${jobName}"` : undefined);
|
|
643
|
-
const ungatedQueryAs = (user, qn, payload) => {
|
|
644
|
-
if (!dispatchWriteRef) {
|
|
645
|
-
throw new Error("JobContext.queryAs called before dispatcher attached — call attachDispatcher() first");
|
|
646
|
-
}
|
|
647
|
-
return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
|
|
648
|
-
};
|
|
649
|
-
const gatedIdentitySwitch = createGatedIdentitySwitch(`job "${jobName}"`, jobSystemUser, isSystemJob || identitySwitchHatch !== undefined, {
|
|
650
|
-
queryAs: ungatedQueryAs,
|
|
651
|
-
writeAs: async (user, qn, payload) => {
|
|
652
|
-
if (!dispatchWriteRef) {
|
|
653
|
-
throw new Error("JobContext.writeAs called before dispatcher attached — call attachDispatcher() first");
|
|
654
|
-
}
|
|
655
|
-
return dispatchWriteRef.write(user, qn, payload, jobOrigin);
|
|
656
|
-
},
|
|
657
|
-
}, { reason: identitySwitchGrantReason, report: reportEscapeHatch });
|
|
658
|
-
const jobContextWithoutTz = {
|
|
659
|
-
defer: (delayMs) => {
|
|
660
|
-
if (!Number.isInteger(delayMs) || delayMs <= 0) {
|
|
661
|
-
throw new RangeError(`Job "${jobName}": ctx.defer needs a positive integer delay in ms (got ${delayMs})`);
|
|
662
|
-
}
|
|
663
|
-
if (token === undefined || !bullJob.moveToDelayed) {
|
|
664
|
-
throw new InternalError({
|
|
665
|
-
message: `Job "${jobName}": ctx.defer needs a queued run; it is not available inline (boot gate).`,
|
|
666
|
-
});
|
|
667
|
-
}
|
|
668
|
-
return { kind: "job-deferral", delayMs };
|
|
669
|
-
},
|
|
670
|
-
...context,
|
|
671
|
-
db: jobDb ?? missingDbProxy(jobName),
|
|
672
|
-
files,
|
|
673
|
-
derivatives,
|
|
674
|
-
...(notify !== undefined && { notify }),
|
|
675
|
-
...(config !== undefined && { config }),
|
|
676
|
-
...(systemDb && { systemDb }),
|
|
677
|
-
...(crossTenantReads && { crossTenantReads }),
|
|
678
|
-
// The runner owns the registry it resolved this job from — expose it so
|
|
679
|
-
// workers can reach projections/jobs without the app author duplicating
|
|
680
|
-
// it into `context` (the JobContext contract guarantees `registry`).
|
|
681
|
-
registry,
|
|
682
|
-
// Expose the runner so handlers can chain a follow-up job. Symmetric with
|
|
683
|
-
// how the command-dispatcher hands write-handlers their jobRunner.
|
|
684
|
-
...(selfRunner !== undefined && { jobRunner: selfRunner }),
|
|
685
|
-
systemUser: jobSystemUser,
|
|
686
|
-
triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
|
|
687
|
-
attempt,
|
|
688
|
-
finalAttempt,
|
|
689
|
-
log: createJobLogger(logs),
|
|
690
|
-
metricsFor: createMetricsForFactory(context.meter),
|
|
691
|
-
...(triggerName !== undefined && { triggerName }),
|
|
692
|
-
...(triggerEvent !== undefined && { triggerEvent }),
|
|
693
|
-
write: (qn, payload) => {
|
|
694
|
-
if (!dispatchWriteRef) {
|
|
695
|
-
throw new Error("JobContext.write called before dispatcher attached — call attachDispatcher() first");
|
|
696
|
-
}
|
|
697
|
-
return dispatchWriteRef.write(jobSystemUser, qn, payload, jobOrigin);
|
|
698
|
-
},
|
|
699
|
-
writeAs: gatedIdentitySwitch.writeAs,
|
|
700
|
-
queryAs: gatedIdentitySwitch.queryAs,
|
|
701
|
-
queryAsMember: (userId, qn, payload) => {
|
|
702
|
-
if (!dispatchWriteRef) {
|
|
703
|
-
throw new Error("JobContext.queryAsMember called before dispatcher attached — call attachDispatcher() first");
|
|
704
|
-
}
|
|
705
|
-
// Lazy per-run reader, mirroring the handler-context reader.
|
|
706
|
-
memberReader ??= dispatchWriteRef.createMemberReader(tenantId);
|
|
707
|
-
return memberReader(userId, qn, payload);
|
|
708
|
-
},
|
|
709
|
-
};
|
|
710
|
-
if (tenantVisibleRunDecl && !subjectConfigError) {
|
|
711
|
-
meta.tenantVisibleRun = { tenantId, subject: tenantVisibleRunSubject };
|
|
212
|
+
const slot = await claimSequentialSlot(scope, jobDef, bullJob);
|
|
213
|
+
if (slot.kind === "requeued") {
|
|
214
|
+
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
215
|
+
return;
|
|
712
216
|
}
|
|
713
|
-
await options.onJobStart?.(jobName, jobId, meta);
|
|
714
|
-
// Cross-process trace continuation: if the enqueuing code captured a
|
|
715
|
-
// parent span, start the job.execute span as its child. Works for event
|
|
716
|
-
// and manual triggers; cron jobs start a fresh root span.
|
|
717
|
-
const parentContext = readTraceContext(rawData);
|
|
718
|
-
// Correlation propagation: the scheduling request's correlationId was
|
|
719
|
-
// packed into _correlationId at dispatch time. Re-enter requestContext.run
|
|
720
|
-
// so event writes during this job stamp the same correlation as the
|
|
721
|
-
// request that scheduled it. Cron/boot jobs (no scheduler) start fresh
|
|
722
|
-
// — correlationId = new requestId, no parent causation.
|
|
723
|
-
const inheritedCorrelationId = rawData["_correlationId"] ?? undefined; // @cast-boundary dynamic-key
|
|
724
|
-
const jobRequestId = requestContext.generateId();
|
|
725
|
-
const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
|
|
726
|
-
const deferred = {};
|
|
727
|
-
const runInSpan = async () => {
|
|
728
|
-
try {
|
|
729
|
-
if (subjectConfigError) {
|
|
730
|
-
// Retrying cannot fix a declaration error.
|
|
731
|
-
throw new UnrecoverableError(subjectConfigError.message);
|
|
732
|
-
}
|
|
733
|
-
if (writeOriginInvalid) {
|
|
734
|
-
throw new InternalError({
|
|
735
|
-
message: `Job "${jobName}" received an unparseable _writeOrigin — refusing to run without ` +
|
|
736
|
-
"a trustworthy anonymous-root gate.",
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
const handlerResult = await requestContext.run({
|
|
740
|
-
requestId: jobRequestId,
|
|
741
|
-
correlationId: jobCorrelationId,
|
|
742
|
-
// #3043 — events a job writes carry the job as their origin.
|
|
743
|
-
handler: jobName,
|
|
744
|
-
feature: registry.getJobFeature(jobName) ?? qnScope(jobName),
|
|
745
|
-
writeOrigin: jobOrigin,
|
|
746
|
-
tenantId,
|
|
747
|
-
...(triggeredById !== null && { userId: triggeredById }),
|
|
748
|
-
}, async () => jobDef.handler(payload, {
|
|
749
|
-
...jobContextWithoutTz,
|
|
750
|
-
tz: await resolveJobTz(config, context),
|
|
751
|
-
}));
|
|
752
|
-
if (isJobDeferral(handlerResult)) {
|
|
753
|
-
const maxDefers = jobDef.maxDefers ?? DEFAULT_MAX_DEFERS;
|
|
754
|
-
if (priorDeferCountOf(bullJob.data, HANDLER_DEFER_COUNT_KEY) >= maxDefers) {
|
|
755
|
-
// The counter never resets, so a retry would hit the cap again.
|
|
756
|
-
throw new UnrecoverableError(`Job "${jobName}" was deferred ${maxDefers} times (maxDefers); giving up.`);
|
|
757
|
-
}
|
|
758
|
-
deferred.deferral = handlerResult;
|
|
759
|
-
// skip: the deferral is applied after the span closes, below
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
// Stamped before the observer hook: a throwing onJobComplete must not
|
|
763
|
-
// make a run that actually succeeded look dead to the liveness alert.
|
|
764
|
-
if (context.meter) {
|
|
765
|
-
try {
|
|
766
|
-
emitJobLastSuccess(context.meter, jobName);
|
|
767
|
-
}
|
|
768
|
-
catch (err) {
|
|
769
|
-
errorLogger.warn("job last-success metric failed", {
|
|
770
|
-
job: jobName,
|
|
771
|
-
error: err instanceof Error ? err.message : String(err),
|
|
772
|
-
});
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
const duration = Date.now() - startTime;
|
|
776
|
-
await options.onJobComplete?.(jobName, jobId, duration, logs, outcomeMeta(null));
|
|
777
|
-
}
|
|
778
|
-
catch (err) {
|
|
779
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
780
|
-
logs.push({ level: "error", message: errorMsg, timestamp: Temporal.Now.instant() });
|
|
781
|
-
logFailedAttempt({ job: jobName, jobId, attempt, finalAttempt, err });
|
|
782
|
-
await options.onJobFailed?.(jobName, jobId, errorMsg, logs, outcomeMeta(tenantFailureMessageKey(err, tenantVisibleDecl), err instanceof UnrecoverableError));
|
|
783
|
-
throw err;
|
|
784
|
-
}
|
|
785
|
-
};
|
|
786
|
-
// Unified span creation: withSpan handles start/end + status/exception
|
|
787
|
-
// recording identically for both parent-context and no-parent paths.
|
|
788
|
-
// When parentContext is set, the new parent-aware StartSpanOptions
|
|
789
|
-
// plumbs it through to startSpan — no manual try/finally needed.
|
|
790
217
|
try {
|
|
791
|
-
await
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
"job.id": jobId,
|
|
795
|
-
"job.attempt": attempt,
|
|
796
|
-
"kumiko.tenant_id": tenantId,
|
|
797
|
-
// Lane-routing attributes (Welle 2.6). `run_in` is the job's
|
|
798
|
-
// declared lane (explicit or default-"worker"); `consumer_lane`
|
|
799
|
-
// is which runner actually executed it. They diverge in
|
|
800
|
-
// all-in-one (both lanes live in one process) but must match
|
|
801
|
-
// in split deploys — a mismatch in prod logs signals a
|
|
802
|
-
// misrouted job that slipped past the boot-validator.
|
|
803
|
-
"kumiko.job.run_in": laneForJob(jobDef),
|
|
804
|
-
// Omit attribute entirely when no consumer (enqueuer-only runner) —
|
|
805
|
-
// SpanAttributeValue doesn't accept undefined.
|
|
806
|
-
...(consumerLane !== undefined ? { "kumiko.job.consumer_lane": consumerLane } : {}),
|
|
807
|
-
},
|
|
808
|
-
...(parentContext ? { parent: parentContext } : {}),
|
|
809
|
-
}, runInSpan));
|
|
810
|
-
if (deferred.deferral && token !== undefined && bullJob.moveToDelayed) {
|
|
811
|
-
// Run-log bookkeeping must not turn a defer into a failure.
|
|
812
|
-
try {
|
|
813
|
-
await options.onJobDeferred?.(jobName, jobId, meta);
|
|
814
|
-
}
|
|
815
|
-
catch (err) {
|
|
816
|
-
errorLogger.warn("job deferred hook failed", {
|
|
817
|
-
job: jobName,
|
|
818
|
-
jobId,
|
|
819
|
-
error: scrubLogText(err instanceof Error ? err.message : String(err)),
|
|
820
|
-
});
|
|
821
|
-
}
|
|
822
|
-
await bullJob.updateData?.({
|
|
823
|
-
...bullJob.data,
|
|
824
|
-
[HANDLER_DEFER_COUNT_KEY]: priorDeferCountOf(bullJob.data, HANDLER_DEFER_COUNT_KEY) + 1,
|
|
825
|
-
});
|
|
826
|
-
await bullJob.moveToDelayed(Date.now() + deferred.deferral.delayMs, token);
|
|
218
|
+
const run = await startJobRun(scope, jobDef, bullJob, token);
|
|
219
|
+
const deferral = await executeJobInSpan(scope, run);
|
|
220
|
+
if ((await applyHandlerDeferral(scope, run, bullJob, deferral, token)) === "deferred") {
|
|
827
221
|
throw new DelayedError();
|
|
828
222
|
}
|
|
829
223
|
}
|
|
830
224
|
finally {
|
|
831
|
-
|
|
832
|
-
// inside DistributedLock). A TTL-expired lock that's been claimed by
|
|
833
|
-
// a different owner stays put — releasing it would break sequencing
|
|
834
|
-
// for the new owner.
|
|
835
|
-
if (sequentialToken && sequentialLock) {
|
|
836
|
-
await sequentialLock.release(jobName, sequentialToken);
|
|
837
|
-
}
|
|
225
|
+
await releaseSequentialSlot(scope, bullJob.name, slot.lockToken);
|
|
838
226
|
}
|
|
839
227
|
}
|
|
840
228
|
// Boot gates run inline and are awaited, so a throwing gate rejects start()
|
|
841
229
|
// — and with it the entrypoint's start() and runProdApp's boot, before the
|
|
842
230
|
// process reports ready. Enqueuing them (as runOnBoot does) would only fail
|
|
843
|
-
// a queue job and let the deploy through
|
|
231
|
+
// a queue job and let the deploy through.
|
|
844
232
|
async function runBootGates(lane) {
|
|
845
233
|
for (const [name, jobDef] of allJobs) {
|
|
846
234
|
if (laneForJob(jobDef) !== lane)
|
|
@@ -900,7 +288,7 @@ export function createJobRunner(options) {
|
|
|
900
288
|
continue;
|
|
901
289
|
if ("cron" in jobDef.trigger) {
|
|
902
290
|
await queue.upsertJobScheduler(schedulerIdForJobName(name), cronRepeatOptions(jobDef.trigger), {
|
|
903
|
-
name: jobDef.perTenant ?
|
|
291
|
+
name: jobDef.perTenant ? `${PER_TENANT_PREFIX}${name}` : name,
|
|
904
292
|
data: {},
|
|
905
293
|
// Queue-level defaultJobOptions (jobRetentionOpts) covers
|
|
906
294
|
// retention; a count here would sweep queue-wide again and
|
|
@@ -978,7 +366,7 @@ export function createJobRunner(options) {
|
|
|
978
366
|
// still-settling blocking connection: it rejects in-flight commands
|
|
979
367
|
// via ioredis's flushQueue() during close(), which isn't a listenable
|
|
980
368
|
// 'error' event — the only fix is to not return until both of the
|
|
981
|
-
// worker's connections (main + blocking) are ready
|
|
369
|
+
// worker's connections (main + blocking) are ready. This
|
|
982
370
|
// mirrors the wait BullMQ already does internally for
|
|
983
371
|
// upsertJobScheduler()/add() below when the lane has a cron/boot job.
|
|
984
372
|
// Racing a timeout against it keeps an unreachable Redis from hanging
|
|
@@ -1012,7 +400,6 @@ export function createJobRunner(options) {
|
|
|
1012
400
|
});
|
|
1013
401
|
});
|
|
1014
402
|
await upsertCronSchedulers(consumerQueue, consumerLane);
|
|
1015
|
-
schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
|
|
1016
403
|
await removeOrphanJobSchedulers(consumerQueue, expectedSchedulerIds, consumerLane, {
|
|
1017
404
|
info: (msg, data) => context.log ? context.log.info(msg, data) : errorLogger.debug(msg, data),
|
|
1018
405
|
warn: errorLogger.warn,
|
|
@@ -1035,7 +422,7 @@ export function createJobRunner(options) {
|
|
|
1035
422
|
if (laneForJob(jobDef) !== consumerLane)
|
|
1036
423
|
continue;
|
|
1037
424
|
if (jobDef.runOnBoot) {
|
|
1038
|
-
const bootName = jobDef.perTenant ?
|
|
425
|
+
const bootName = jobDef.perTenant ? `${PER_TENANT_PREFIX}${name}` : name;
|
|
1039
426
|
const bootJobId = bootJobIdForJobName(name);
|
|
1040
427
|
const client = await consumerQueue.client;
|
|
1041
428
|
const alreadyEnqueued = await client.hexists(bootEnqueuedKey, bootJobId);
|
|
@@ -1053,6 +440,8 @@ export function createJobRunner(options) {
|
|
|
1053
440
|
if (context.meter) {
|
|
1054
441
|
queueDepthTimer = await startQueueDepthPolling(consumerQueue, consumerLane, context.meter);
|
|
1055
442
|
}
|
|
443
|
+
// Last on purpose: a start() that fails earlier must not leave a timer stamping for a runner that is not running.
|
|
444
|
+
schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
|
|
1056
445
|
},
|
|
1057
446
|
async stop() {
|
|
1058
447
|
stopping = true;
|
|
@@ -1070,7 +459,7 @@ export function createJobRunner(options) {
|
|
|
1070
459
|
}
|
|
1071
460
|
// BullMQ close() while a Queue's connection is still 'initializing'
|
|
1072
461
|
// rejects the in-flight INFO with "Connection is closed." on an emitter
|
|
1073
|
-
// without listeners -> unhandled rejection
|
|
462
|
+
// without listeners -> unhandled rejection. Wait for readiness
|
|
1074
463
|
// first; a failed/timed-out wait must not block the close itself.
|
|
1075
464
|
const stopReadyTimeoutMs = Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS);
|
|
1076
465
|
await settleAbandonedBootGates(stopReadyTimeoutMs);
|
|
@@ -1112,7 +501,7 @@ export function createJobRunner(options) {
|
|
|
1112
501
|
if (jobDef.perTenant) {
|
|
1113
502
|
const perTenantData = { ...(payload ?? {}) };
|
|
1114
503
|
stampGatedWriteOrigin(perTenantData);
|
|
1115
|
-
const job = await targetQueue.add(
|
|
504
|
+
const job = await targetQueue.add(`${PER_TENANT_PREFIX}${jobName}`, perTenantData, buildRetryBullOpts(jobDef));
|
|
1116
505
|
return job.id ?? "unknown";
|
|
1117
506
|
}
|
|
1118
507
|
// maxPerTenant guard: cap concurrent + waiting jobs of the same name
|
|
@@ -1126,7 +515,7 @@ export function createJobRunner(options) {
|
|
|
1126
515
|
if (maxPerTenant?.onLimit === "skip") {
|
|
1127
516
|
const tenantId = payload?._tenantId; // @cast-boundary dynamic-key
|
|
1128
517
|
if (tenantId !== undefined &&
|
|
1129
|
-
(await isOverPerTenantLimit(jobName, tenantId, maxPerTenant.limit))) {
|
|
518
|
+
(await isOverPerTenantLimit(scope, jobName, tenantId, maxPerTenant.limit))) {
|
|
1130
519
|
return "skipped:max-per-tenant";
|
|
1131
520
|
}
|
|
1132
521
|
}
|
|
@@ -1147,7 +536,7 @@ export function createJobRunner(options) {
|
|
|
1147
536
|
for (const j of waiting) {
|
|
1148
537
|
if (j.name === jobName && j.id) {
|
|
1149
538
|
await j.remove();
|
|
1150
|
-
await announceDropped(jobName, jobDef, j.id, j.data);
|
|
539
|
+
await announceDropped(scope, jobName, jobDef, j.id, j.data);
|
|
1151
540
|
}
|
|
1152
541
|
}
|
|
1153
542
|
break;
|
|
@@ -1190,7 +579,7 @@ export function createJobRunner(options) {
|
|
|
1190
579
|
data["_correlationId"] = reqCtx.correlationId;
|
|
1191
580
|
stampGatedWriteOrigin(data);
|
|
1192
581
|
const job = await targetQueue.add(jobName, data, bullOpts);
|
|
1193
|
-
await announceQueued(jobName, jobDef, job
|
|
582
|
+
await announceQueued(scope, jobName, jobDef, job, data);
|
|
1194
583
|
return job.id ?? "unknown";
|
|
1195
584
|
},
|
|
1196
585
|
async handleEvent(eventName, payload, user, triggerEvent) {
|
|
@@ -1203,8 +592,8 @@ export function createJobRunner(options) {
|
|
|
1203
592
|
for (const [name, jobDef] of allJobs) {
|
|
1204
593
|
if (!("on" in jobDef.trigger))
|
|
1205
594
|
continue;
|
|
1206
|
-
// skip:
|
|
1207
|
-
//
|
|
595
|
+
// skip: other trigger types (cron, manual) do not respond to events.
|
|
596
|
+
// Only "on" triggers are matched here.
|
|
1208
597
|
const triggerOn = jobDef.trigger.on;
|
|
1209
598
|
const matches = Array.isArray(triggerOn)
|
|
1210
599
|
? triggerOn.includes(eventName)
|
|
@@ -1218,10 +607,9 @@ export function createJobRunner(options) {
|
|
|
1218
607
|
data["_tenantId"] = user.tenantId;
|
|
1219
608
|
data["_triggeredById"] = user.id;
|
|
1220
609
|
}
|
|
1221
|
-
// Multi-
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
// (kein "ist es Multi?"-Branch nötig).
|
|
610
|
+
// Multi-trigger: add _triggerName to the payload so the handler knows which
|
|
611
|
+
// of the N triggers fired. Set it for single triggers too; this costs
|
|
612
|
+
// nothing and keeps handler code simple (no "is it multi?" branch).
|
|
1225
613
|
data["_triggerName"] = eventName;
|
|
1226
614
|
if (triggerEvent)
|
|
1227
615
|
data["_triggerEvent"] = triggerEvent;
|
|
@@ -1233,7 +621,7 @@ export function createJobRunner(options) {
|
|
|
1233
621
|
// jobs must respect the per-tenant cap or the limit is one-sided.
|
|
1234
622
|
const maxPerTenant = resolveMaxPerTenant(jobDef);
|
|
1235
623
|
if (maxPerTenant?.onLimit === "skip" && user?.tenantId !== undefined) {
|
|
1236
|
-
if (await isOverPerTenantLimit(name, String(user.tenantId), maxPerTenant.limit)) {
|
|
624
|
+
if (await isOverPerTenantLimit(scope, name, String(user.tenantId), maxPerTenant.limit)) {
|
|
1237
625
|
continue;
|
|
1238
626
|
}
|
|
1239
627
|
}
|
|
@@ -1241,7 +629,7 @@ export function createJobRunner(options) {
|
|
|
1241
629
|
// Route to the job's declared lane, not a fixed queue — that's
|
|
1242
630
|
// the whole reason both queues are held.
|
|
1243
631
|
const job = await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
|
|
1244
|
-
await announceQueued(name, jobDef, job
|
|
632
|
+
await announceQueued(scope, name, jobDef, job, data);
|
|
1245
633
|
}
|
|
1246
634
|
},
|
|
1247
635
|
attachDispatcher(ref) {
|