@ultimat3/cli 29.4.0 → 30.1.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/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +11 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +9 -0
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +41 -81
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +7 -1
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1314 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
# Jobs and workflows
|
|
2
|
+
|
|
3
|
+
Durable background work, optionally multi-step. Postgres queue by default. `idempotencyKey` is required by the type. Drivers swap without touching job code.
|
|
4
|
+
|
|
5
|
+
`As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)).
|
|
6
|
+
|
|
7
|
+
Two factories return a `job` rather than a ninth primitive, so everything on this page applies to both: `backfill()` ([Migrations and backfills](Migrations-And-Backfills)) and `scrape()` ([Scraping](Scraping)).
|
|
8
|
+
|
|
9
|
+
## The canonical shape
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// job
|
|
13
|
+
export const onboardOrg = job({
|
|
14
|
+
input: t.object({ orgId: t.uuid }),
|
|
15
|
+
tenant: ({ orgId }) => orgId, // the org this run acts as
|
|
16
|
+
idempotencyKey: ({ orgId }) => `onboard:${orgId}`, // REQUIRED by the type
|
|
17
|
+
retry: { attempts: 5, backoff: 'exponential' },
|
|
18
|
+
async run({ input, step, ctx }) {
|
|
19
|
+
const org = await step.run('provision', () => ctx.orgs.provision(input.orgId));
|
|
20
|
+
await step.run('welcome-email', () => ctx.mail.send(welcomeEmail, org));
|
|
21
|
+
await step.sleep('3d');
|
|
22
|
+
await step.run('nudge', () => ctx.mail.send(nudgeEmail, org));
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## The fluent surface
|
|
28
|
+
|
|
29
|
+
Every projection is a method on the job — `onboardOrg.enqueue({ orgId })`, never `enqueueJob(onboardOrg, input)` — and every declared field is lifted onto it. A job has no `.def`.
|
|
30
|
+
|
|
31
|
+
| Member | Is | Rule |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `onboardOrg.enqueue(input, options?)` | the enqueue | resolves the ambient jobs facade, so it **joins the caller's transaction** when the app installed the outbox. One call site works in a request handler, a job, a script and a test. Answers `{ id, runId, deduped }` — the job's own ids, allocated when the row is staged, so an action can name the run it started without leaving the transaction. `options.runId` names the run instead: a lowercase uuid, or the enqueue is refused with `X_ID_INVALID` before anything is staged or queued |
|
|
34
|
+
| `onboardOrg.enqueueMany(inputs, options?)` | many of one job, in bulk | the same facade and the same transaction, ONE round trip per 1,000 inputs on every driver — one statement on Postgres (or on the outbox, staged), one Lua script on Redis — where a loop of `enqueue` is one per input (1,000 rows: 18 ms against 1.1 s, Postgres, 2026-11-02) — and the loop is `X_N_PLUS_ONE_WRITE` in `x dev` and the `statements` fixture, whose `fix:` names this call. Answers one `{ id, runId, deduped }` per input, in input order; two inputs sharing a key are one job, the second `deduped`. One `options` for the batch, without `runId`. Every refusal comes before the first write: past `MAX_ENQUEUE_BATCH` (10,000) is `X_JOB_ENQUEUE_BATCH_TOO_LARGE` (page the source with `backfill()`), an empty key in any input refuses the call. Staged in a transaction it commits or vanishes whole; outside one it is atomic per 1,000, so wrap it in `withTransaction` for all-or-nothing |
|
|
35
|
+
| `.as(actor, input, options?)` | the same enqueue, on someone's behalf | fills `tenantId` from the actor's org, so per-tenant concurrency and rate limits apply. `null` — or an actor with no org — leaves `tenantId` unset: the limiter's own shared bucket, not a fake org id on the row. It **queues; it never runs inline** |
|
|
36
|
+
| the declared tenant is checked at enqueue | `enqueue`, `.as` and `enqueueMany` | a payload whose `tenant(input)` names org A, enqueued with `tenantId` B, by `.as(actorOfB, …)`, or under an ambient actor of org B, is `X_JOB_TENANT_MISMATCH` before anything is queued. A row already queued that way is refused at its run, terminally, before the body. An actor with no org, and `tenant: 'none'`, are never compared. The ambient check's one escape is `crossTenant('<why>', fn)` by an actor holding `tenancy:cross`; an explicit `tenantId` or `.as()` is never admitted |
|
|
37
|
+
| `.run(args)` | the handler itself | the worker calls it. App code does not — see below |
|
|
38
|
+
| `.parse(raw)` | the payload check | a raw queue payload against the declared `input` |
|
|
39
|
+
| `.idempotencyKeyFor(input)` | the dedupe key | whatever the declared `idempotencyKey` returned. An empty string throws `X_INVARIANT` at the call — an empty key makes every enqueue look like a duplicate of every other |
|
|
40
|
+
| `.concurrencyKeyFor(input)` | the concurrency key | `concurrency.key(input)` for a keyed cap, `undefined` otherwise. An empty or non-string key, or one over 200 characters, throws `X_JOB_DECLARATION_INVALID` — the enqueue asks it first, so the refusal reaches the caller |
|
|
41
|
+
| `.describe()` | the manifest row | `name`, `input` (JSON Schema), `queue`, `retry: { attempts, backoff }`, `steps`, `idempotent`, `concurrency: { limit, keyed, whenBusy } \| null`. Never a key: both keys are computed from an input, so they are app data |
|
|
42
|
+
| `.kind` `.name` `.queue` `.retry` `.concurrency` `.whenBusy` `.timeoutMs` `.stepTimeoutMs` `.eventPollMs` `.input` | the declaration, lifted | readable, and already **resolved**: `kind` is `'job'`, `queue` is `'default'` when undeclared, `retry` carries the framework defaults merged underneath, `concurrency` is the cap as a number whichever way it was declared (`whenBusy` is set exactly when it is per key), and `timeout` / `stepTimeout` / `eventPoll` are normalized to ms. `stepTimeout` is a ceiling on **one** `step.run`, distinct from `timeout`'s ceiling on the whole job; `eventPoll` is how often `step.waitForEvent` looks. Both were implemented and unreachable until 2.0.0 — a non-positive value is refused at declaration, because `<= 0` reads as "no ceiling" |
|
|
43
|
+
|
|
44
|
+
**One enqueue implementation.** `<job>.enqueue`, `<job>.as` and a [task](Scheduled-Tasks)'s own `enqueue()` all resolve the same ambient facade; the only other calls into a driver's `enqueue` are the outbox relay and the scheduler's occurrence dispatch. So "does this join the transaction?" has one answer for every enqueue an app writes, and there is no second path to forget about.
|
|
45
|
+
|
|
46
|
+
`run` is on the handle and is still not yours to call. The worker's `executeJob` is the one execution path, and it owns the attempt counter, the step store, the timeout and the lease — none of which a direct call carries. That is also why `.as()` queues: on an [action](Actions) `.as()` *runs* the mutation as that actor, on a job it *enqueues* as that actor. Same word, and the difference is the primitive's execution surface, not an inconsistency.
|
|
47
|
+
|
|
48
|
+
`describe().steps` is **empty by design**. Step names are chosen inside `run()` at execution time, so they are not statically knowable — the steps a run actually recorded come from the run itself, via `x jobs show <id> --json`. `x.manifest.json`, the `/_x` jobs panel and the MCP dev server read one list, and that list is a map over each handle's own `describe()` — so the list and a single job can never disagree. `name` is the export name, stamped by `defineApi({ jobs: [postJobs] })` — the same call that names actions and queries. A module nobody hands over keeps `job()`'s positional `anonymous-job-<n>`, on the queue row and in `x.manifest.json`. A definition carrying its own `name:` keeps it: the name is a durable queue key, so queued and dead-lettered rows survive a renamed export.
|
|
49
|
+
|
|
50
|
+
## Transactional outbox by default
|
|
51
|
+
|
|
52
|
+
`<job>.enqueue` inside an [action](Actions) writes the job row in the **same transaction** as the business write — the handle resolves the ambient jobs facade, the one `ctx.jobs` names. Commit *is* the enqueue.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
async handle({ input, ctx }) {
|
|
56
|
+
const post = await ctx.posts.publish(input.postId); // INSERT/UPDATE
|
|
57
|
+
if (input.notify) await notifySubscribers.enqueue({ postId: post.id, orgId: post.orgId }); // same tx
|
|
58
|
+
return post;
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Bug class removed | How it happens without an outbox |
|
|
63
|
+
|---|---|
|
|
64
|
+
| **Ghost job** | enqueue succeeded, transaction rolled back → worker processes a post that does not exist |
|
|
65
|
+
| **Lost job** | transaction committed, broker `publish` failed → the email is never sent and nothing logs an error |
|
|
66
|
+
| **Double side effect** | retry of the whole handler re-enqueues → two welcome emails |
|
|
67
|
+
| **Ordering inversion** | worker reads the row before the writer's commit is visible → "record not found", then a retry storm |
|
|
68
|
+
|
|
69
|
+
Rolled back → the job never existed. Committed → durably queued. No window in between, no compensating-action code for an agent to forget.
|
|
70
|
+
|
|
71
|
+
**The relay publishes a claimed batch in two statements** — `driver.enqueueMany`, then `store.markPublishedMany`, where the row loop spends two per row: 8,564–9,505 rows/s at the default `batchSize` of 500, against 4,259–5,052 at the 100 it was (10,000 staged rows, three runs each, Postgres, 2026-11-03, a shared box). 500 because a pass is three statements whatever its size: its longest statement was 45 ms, and a dead relay strands 500 rows for its 30 s lease instead of 100. A batch that fails anywhere falls back to the row loop for that batch, which publishes in order up to the failing row and releases the rest: the order guarantee and the poison row named in `jobs.outbox.publish-failed` are unchanged. `JobDriver.enqueueMany` is required of every driver — Postgres, Redis and memory each answer a batch in one round trip; `OutboxStore.markPublishedMany` is optional, and a store without it keeps the row loop. A batch republished after a crash before its mark adds no row — every row is published under the id it was staged with.
|
|
72
|
+
|
|
73
|
+
External brokers are not exempted: the outbox table stays the transactional record and a relay moves committed rows onto the broker. At-least-once delivery is preserved; the atomicity is not negotiable.
|
|
74
|
+
|
|
75
|
+
## Durable steps
|
|
76
|
+
|
|
77
|
+
| API | Semantics |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `step.run(name, fn)` | executes `fn` once ever. Result persisted under `(jobId, name)`. On replay, returns the stored result without calling `fn`. **It answers what it stored, on the first run too**, and its type says so (`StepOutput<T>`, the JSON form): a `Date` is a string, so a body writes `new Date(value)` at the read; a miss stored from `undefined` is `null`, so compare `=== null`; a `bigint` or a cycle is `X_STEP_OUTPUT_UNSERIALIZABLE`, terminal. Return a cursor or a small plain view, not a page of rows. `fn` receives an `AbortSignal` — this step's ceiling and the run's cancellation, whichever fires first |
|
|
80
|
+
| `step.run(name, fn, { compensate })` | `compensate(output, { signal })` undoes the step if the run fails **for good** — never on a retry, a cancel, a drain or a suspension. Needs `job({ compensates: true })`, else `X_JOB_DECLARATION_INVALID`. See [Compensation](#compensation) |
|
|
81
|
+
| `step.run(name, fn, { once: true })` | at most once, ever: `started` is persisted **before** `fn`; a replay finding it (or `failed`) without `completed` stops with terminal `X_STEP_OUTCOME_UNKNOWN` and never calls `fn`. `x jobs resolve-step <id> <step> --ran --output '<json>'` or `--not-ran`, then `x jobs retry <id>` |
|
|
82
|
+
| `step.sleep(duration)` | persists a wake time, releases the worker, and the job resumes in a fresh process. No held connection, no timer in memory. `'3d'` is safe |
|
|
83
|
+
| `step.waitForEvent(name, event, { schema, match, timeout })` | suspends until a matching event arrives (webhook, another action, a user click) or the timeout fires. Returns the payload parsed by the required `schema`, or `undefined` on a timeout. A payload the schema refuses is `X_JOB_EVENT_INVALID`, terminal, on a hit or a replay. `publishEvent` inside a transaction commits and rolls back with it. "After the wait began" is read off the event bus's own clock — the database's, on the stored bus — so a worker whose clock runs ahead still sees an event published a moment later. **On the Postgres bus the publish wakes its waiters in the same statement** (as booted over the app's own queue): the run resumes on the worker's next pass, and the backstop poll is 15 min (96 replays per 24 h wait, was 2,880). Any other bus polls every 30 s. A declared `eventPoll` wins either way |
|
|
84
|
+
|
|
85
|
+
**The step is the retry unit, not the job.** A failure in `nudge` re-enters `run`, replays `provision` and `welcome-email` from storage in microseconds, and retries only `nudge`. That is why an onboarding flow can retry on day 3 without re-provisioning or re-emailing.
|
|
86
|
+
|
|
87
|
+
| Step rule | Enforcement |
|
|
88
|
+
|---|---|
|
|
89
|
+
| Names unique within one `run` | `X_STEP_DUPLICATE` at `x verify` |
|
|
90
|
+
| Names stable across deploys | renaming a step invalidates its stored result — it re-runs |
|
|
91
|
+
| Step results must be serializable | persisted through the driver's `steps` store (`StepStore.put`) |
|
|
92
|
+
| No step inside a loop with a computed name | non-deterministic names break replay; enumerate them |
|
|
93
|
+
| Non-idempotent external call inside a step | wrap with the provider's idempotency header, keyed off `${jobId}:${stepName}` — or, with no key to send, `{ once: true }` |
|
|
94
|
+
| `unwinding` and `compensate:*` are the framework's | `X_JOB_DECLARATION_INVALID` on `run`, `sleep` and `waitForEvent`, for every job |
|
|
95
|
+
|
|
96
|
+
## Compensation
|
|
97
|
+
|
|
98
|
+
Charge, reserve, ship: the third fails for good, the first two are undone. `try { … } catch { refund }` is wrong — it refunds on attempt 1 of a retryable failure, before the retry that ships.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { job, t } from '@ultimat3/jobs';
|
|
102
|
+
|
|
103
|
+
type Order = { readonly orgId: string; readonly orderId: string };
|
|
104
|
+
declare function charge(order: Order): Promise<{ id: string }>;
|
|
105
|
+
declare function refund(chargeId: string): Promise<void>;
|
|
106
|
+
declare function reserve(order: Order): Promise<{ id: string }>;
|
|
107
|
+
declare function release(reservationId: string): Promise<void>;
|
|
108
|
+
declare function ship(order: Order): Promise<void>;
|
|
109
|
+
|
|
110
|
+
export const checkout = job({
|
|
111
|
+
input: t.object({ orgId: t.uuid, orderId: t.uuid }),
|
|
112
|
+
tenant: ({ orgId }) => orgId,
|
|
113
|
+
idempotencyKey: ({ orderId }) => `checkout:${orderId}`,
|
|
114
|
+
compensates: true,
|
|
115
|
+
retry: { attempts: 3 },
|
|
116
|
+
run: async ({ input, step }) => {
|
|
117
|
+
await step.run('charge', () => charge(input), { compensate: (c) => refund(c.id) });
|
|
118
|
+
await step.run('reserve', () => reserve(input), { compensate: (r) => release(r.id) });
|
|
119
|
+
await step.run('ship', () => ship(input));
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Rule | Behaviour |
|
|
125
|
+
|---|---|
|
|
126
|
+
| when | only once the run has failed **for good** — attempts spent, or a terminal code |
|
|
127
|
+
| order | last-in first-out, each as step `compensate:<name>`: a replay skips what ran |
|
|
128
|
+
| durable | step `unwinding` is written before the first compensator; seen on any later attempt, the body replays only to re-register its compensators and the run unwinds. `x jobs retry` continues the unwind, never the forward path |
|
|
129
|
+
| crash on the final attempt | the row carries 2 attempts past `retry.attempts` (`UNWIND_MARGIN`, shown in `x jobs show` as `maxAttempts`); the claim re-offers it and it unwinds. Two more lapses bury it, `unwinding` visible |
|
|
130
|
+
| a compensator throws | the row goes back while the unwind has attempts; then dead with `X_JOB_COMPENSATION_FAILED` naming the step. Fix it, `x jobs retry <id>` |
|
|
131
|
+
| `once` + `compensate` | an unknown outcome stops the unwind at once (`X_JOB_COMPENSATION_FAILED`): resolve the step, then retry |
|
|
132
|
+
| the row's error | the original failure; the unwind is in the steps `x jobs show` lists |
|
|
133
|
+
| determinism | a body that branches on non-step state can register different compensators on replay — the same rule as every replay |
|
|
134
|
+
|
|
135
|
+
## The deadline cancels
|
|
136
|
+
|
|
137
|
+
A job's `timeout` aborts `ctx.signal` **before** it fails the attempt. The order is the whole point: failing the attempt re-queues the job, another worker claims it within milliseconds, and a body still running past that moment is a second copy of one job writing into the same run.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
run: async ({ input, ctx, step }) => {
|
|
141
|
+
const res = await fetch(url, { signal: ctx.signal }); // stops at the deadline
|
|
142
|
+
throwIfAborted(ctx); // or check it in a long loop
|
|
143
|
+
await step.run('save', (signal) => save(res, { signal })); // the step's own ceiling too
|
|
144
|
+
},
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`ctx.signal` is the same seam an [action](Actions) reads, composed with the caller's own — there is nothing jobs-specific to learn, and a job whose caller went away is cancelled for that reason too.
|
|
148
|
+
|
|
149
|
+
**A cancelled body writes nothing** (`As of 2026-11-02`). Once `ctx.signal` is aborted with a coded reason (`X_ABORTED`, `X_DRAINING`, `X_JOB_TIMEOUT`, `X_JOB_SLOT_LOST`, `X_JOB_LEASE_LOST`), a transaction, an entity write and an `enqueue` rethrow that reason. The step record stays fenced as before and a drained step still lands. Wrap work that must land in `runToCompletion(fn)`.
|
|
150
|
+
|
|
151
|
+
**SIGTERM fires it too** (`As of 2026-09-07`). The moment the process starts draining, the worker aborts `ctx.signal` on every job it holds with `X_DRAINING` — naming the worker and the signal — and then gives the body the drain's budget to unwind in. A body that stops is **interrupted**, not failed: the job goes straight back to the ready bucket with the attempt uncounted, so `attempts: 1` survives a rollout and the worker replacing this one claims it at once. The verdict is read off the signal, not off whatever the body threw, so an app's own error for a child the shutdown killed is an interruption too. A body that ignores the signal is waited on to the deadline and abandoned there, as before; a manual `worker.stop()` aborts nothing and waits for its work — until a SIGTERM lands on it, at which point the teardown already waiting adopts the shutdown's deadline and every held job hears the abort.
|
|
152
|
+
|
|
153
|
+
| Past the cancel | What happens |
|
|
154
|
+
|---|---|
|
|
155
|
+
| `step.run` / `step.sleep` / `step.waitForEvent` | refuse to write, raise `X_ABORTED`. A late `completed` would hand the next attempt a step it never ran; a late `failed` would erase one it did |
|
|
156
|
+
| a body that ignores the signal and finishes anyway | cannot be killed — nothing in JS can — so it is named: `jobs.timeout.abandoned` at `warn`, with the job and how it ended |
|
|
157
|
+
| a body that stops because it was cancelled | the intended end. Nothing is logged |
|
|
158
|
+
|
|
159
|
+
## Idempotency is in the type signature
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
idempotencyKey: ({ orgId }) => `onboard:${orgId}`, // REQUIRED by the type
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Omitting it is a **compile error**, not a lint warning. At-least-once is the only honest guarantee any queue provides, so every handler must be replay-safe — and "remember to add a key" is exactly the instruction an agent drops under pressure. A required field converts a runtime duplicate-charge incident into a red squiggle.
|
|
166
|
+
|
|
167
|
+
| Behavior | Rule |
|
|
168
|
+
|---|---|
|
|
169
|
+
| Duplicate enqueue with a live key | second enqueue returns the existing job handle, no new row |
|
|
170
|
+
| Key uniqueness window | while a row holding the key is **live** (`ready`, `delayed`, `running`, `suspended`) — scoped by job name and tenant (`x_jobs_name_tenant_idempotency_live_idx`, `LIVE_STATES` in every driver). Once that row is terminal (`done`, `failed`, `dead`, `cancelled`) the same key enqueues a new job at once; there is no per-queue retention policy and no post-terminal window. The terminal row itself is kept for `jobs.doneRetentionMs` (14 days) or `jobs.deadRetentionMs` (90 days), then the hourly `x.purge` deletes it ([Configuration](Configuration#jobs)) |
|
|
171
|
+
| Key must be | deterministic from `input` only. No timestamps, no random, no `ctx` |
|
|
172
|
+
| Same key, different payload | deduped: `{ deduped: true, id }` of the live row, the **first payload wins**, nothing thrown. `X_IDEMPOTENCY_CONFLICT` is an action's `Idempotency-Key` refusal ([Actions](Actions)), never the queue's |
|
|
173
|
+
| Same key, still in flight | deduped too: a `running` row holds its key. A re-arm from outside (a `task` on a cron) is a no-op while the run is live and a fresh run once it settled |
|
|
174
|
+
| Same key, from the job's own body | deduped onto the **running row itself**: a job re-enqueueing itself on its way out arms nothing, and no later run exists. Re-arm from outside the run instead |
|
|
175
|
+
| Missing key | `X_IDEMPOTENCY_REQUIRED` at build time |
|
|
176
|
+
|
|
177
|
+
**Durable business state lives in your tables, never only in the queue payload.** A payload is a pointer, not a record. If the queue is drained, replaced, or migrated to another driver, the business must still be reconstructible from Postgres alone. So `{ orgId }`, not `{ org: {...30 fields} }`.
|
|
178
|
+
|
|
179
|
+
## Concurrency, rate limits, queues, retry
|
|
180
|
+
|
|
181
|
+
Declared per job, held across the fleet.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
export const syncCrm = job({
|
|
185
|
+
input: t.object({ orgId: t.uuid, accountId: t.uuid }),
|
|
186
|
+
tenant: ({ orgId }) => orgId,
|
|
187
|
+
idempotencyKey: ({ accountId }) => `crm-sync:${accountId}`,
|
|
188
|
+
concurrency: { key: ({ accountId }) => accountId, limit: 1, whenBusy: 'fail' },
|
|
189
|
+
retry: { attempts: 3 },
|
|
190
|
+
queue: 'integrations',
|
|
191
|
+
async run({ input, step, ctx, finalAttempt }) { /* ... */ },
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
| Control | Meaning | Enforced by |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| `concurrency: 4` | max simultaneous runs of **this job**, fleet-wide | one `x_job_leases` row per **held slot**, keyed `(lease_key, slot)` — the primary key is what serialises two workers reaching for the same slot. A driver with no `LeaseStore` refuses the job at worker start (`X_JOB_CONCURRENCY_UNENFORCEABLE`) rather than capping per process |
|
|
198
|
+
| `concurrency: { key, limit }` | max simultaneous runs **sharing one key** — "one run per account" | the same lease rows, one set per `key(input)`. `key` answers a non-empty string of at most 200 characters; `limit` is a whole number, 1 or more |
|
|
199
|
+
| `concurrency.whenBusy` | what a claim over the cap does. `'wait'` (default): the run stays queued, no attempt burned. `'fail'`: the run settles **`failed`** with `X_JOB_KEY_BUSY`, its body never runs, nothing retries it, and it is not a dead letter | the worker, at claim. A plain number always waits |
|
|
200
|
+
| rate limits | starts per window, per tenant | `concurrencyLimiter({ ratePerTenant })` on the worker — **per process**, never a `job()` field. There is no `rateLimit:` on a job, `As of 2026-11`. A start is a body that ran: a claim handed back over `job.concurrency`, or refused by its key, spends none |
|
|
201
|
+
| a **fleet** budget — "600 sends a minute to the mail vendor" | one bucket every worker spends | `@ultimat3/http`'s limiter over the store the boot installs on every role, as the body's FIRST lines, ahead of the first `step.run`: `const vendor = rateLimiter({ config, store: installedRateLimitStore() }); assertRateLimitScope(config, vendor); await vendor.assert(key, bucket)`, with `config = resolveRateLimitConfig({ scope: 'shared', buckets: { vendor: toBucket('<job>', { limit, windowMs }) }, defaultBucket: 'vendor' })` at module scope. `assertRateLimitScope` refuses one process' memory under `'shared'` (`X_RATE_LIMIT_NOT_SHARED`) — N workers would each spend the whole budget. A refusal is `X_RATE_LIMITED`: the run is deferred to its `Retry-After` with the attempt **uncounted**, so a backlog waits for the vendor and is never dead-lettered for waiting. First, because a refusal after a step replays nothing that ran. Pinned by `rate-recipe.job.test.ts`; Postly's `deliverDigest` is the worked use |
|
|
202
|
+
| `maxInFlight` | max simultaneous runs in **one worker process**, across every queue it serves — Sidekiq's `concurrency`. `jobs.maxInFlight` in `app.config.ts`, or `jobWorker({ maxInFlight })`; unset, no process cap | read before every claim, so a worker at the cap claims nothing; each run that ends frees one claim at once. The per-queue `jobs.concurrency` slots still bound each queue under it. A pass splits its room over the queues (`ceil(room / queues left)`), hands what an empty queue leaves to the next, and starts after the last queue served — no queue starves. `worker.stats().maxInFlight`, gauges `job_worker_in_flight` / `job_worker_capacity`. `0`, fractional, `NaN`, `Infinity`: `X_INVARIANT` / `X_CONFIG_INVALID` |
|
|
203
|
+
| `queue` | named pool; the `worker` role runs one pool per config (`WORKER_QUEUES=default,integrations`) | worker pool sizing, see [Deployment](Deployment) |
|
|
204
|
+
| `retry.attempts` / `backoff` | `'exponential' \| 'linear' \| 'fixed'` | driver scheduler |
|
|
205
|
+
| `retry.jitter` | **equal** jitter — half fixed, half rolled — and `true` by default. Never `full`: a job that has already failed twice must not be handed a near-zero wait | driver scheduler |
|
|
206
|
+
| a **`terminal`** thrown code | stops on the attempt that failed, before `attempts` is reached — the same code run again is the same answer, and the attempts left are a queue slot, a provider bill, or three more wrong passwords at a site that locks the account after three. `lastError` records `— not retried: this code is classified terminal`, so an early stop is never silent | the code's `retry` classification, `registerErrorRetry({ X_YOUR_CODE: 'terminal' })`. An **unclassified** code is unaffected |
|
|
207
|
+
| a **`retry-after`** thrown code | the responder's `meta.retryAfterSeconds` replaces the delay, clamped by `maxDelay` — never the attempt count | same |
|
|
208
|
+
| attempts exhausted | moves to dead-letter with the full step trace | `x jobs retry <id>` replays from the failed step |
|
|
209
|
+
|
|
210
|
+
A job over a tenant, queue or global cap, or over a `'wait'` concurrency cap, is **deferred, never dropped** — it stays queued with a later `runAt`. `whenBusy: 'fail'` is the one declared exception. `'wait'` holds the cap and states no order: a later enqueue can start first.
|
|
211
|
+
|
|
212
|
+
### One run per key
|
|
213
|
+
|
|
214
|
+
| Case under `whenBusy: 'fail'` | Answer |
|
|
215
|
+
|---|---|
|
|
216
|
+
| another run holds the key | refused: state `failed`, `lastError` carries `X_JOB_KEY_BUSY` and its fix, `x jobs retry <id>` re-queues it |
|
|
217
|
+
| the only holder is this run's **own** earlier claim — a retry, a `step.sleep` resume or a redelivery meeting a slot not yet released or lapsed | waits one poll. A run is never failed by its own leftovers |
|
|
218
|
+
| the holder's worker was killed | the key stays busy until the lease TTL (the worker's `visibilityTimeoutMs`) passes, then frees with no cleanup. There is no separate `duration`: the TTL is that bound |
|
|
219
|
+
| `key(input)` throws at claim | that attempt fails without the body running and takes the ordinary retry path |
|
|
220
|
+
|
|
221
|
+
Refused where it is written:
|
|
222
|
+
|
|
223
|
+
| Declaration | When | Code |
|
|
224
|
+
|---|---|---|
|
|
225
|
+
| a cap of `0`, negative, fractional, `NaN` or `Infinity` — `concurrency: 0` and `limit: 0` alike; a non-function `key`; an unknown `whenBusy` | at `job()` | `X_JOB_DECLARATION_INVALID` |
|
|
226
|
+
| `key(input)` answering `''`, a non-string or more than 200 characters | at that enqueue | `X_JOB_DECLARATION_INVALID` |
|
|
227
|
+
| any `concurrency` on a driver with no lease store | `jobWorker().start()` | `X_JOB_CONCURRENCY_UNENFORCEABLE` |
|
|
228
|
+
|
|
229
|
+
`x jobs show <id> --json` reports the key a run counts under as `concurrencyKey`; the manifest's job row carries `concurrency: { limit, keyed, whenBusy }`.
|
|
230
|
+
|
|
231
|
+
### Consumers — `consumer()`
|
|
232
|
+
|
|
233
|
+
Reading a queue, a change stream or a vendor's cursor. A factory over `job()` plus a supervising `task()`, never a ninth primitive. It assembles what a hand-rolled consumer has to remember, and none of it can be left out:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { consumer } from '@ultimat3/jobs';
|
|
237
|
+
|
|
238
|
+
interface VendorOrder { readonly id: string; readonly cursor: string }
|
|
239
|
+
declare const vendor: {
|
|
240
|
+
stream(account: string, cursor: string | null, signal: AbortSignal): AsyncIterable<VendorOrder>;
|
|
241
|
+
behind(account: string, cursor: string | null): Promise<number>;
|
|
242
|
+
};
|
|
243
|
+
declare const accounts: { connected(): Promise<readonly string[]> };
|
|
244
|
+
declare const orders: { upsertAll(rows: readonly VendorOrder[]): Promise<void> };
|
|
245
|
+
declare const cursors: {
|
|
246
|
+
of(key: string): Promise<string | null>;
|
|
247
|
+
save(key: string, next: string): Promise<void>;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export const syncOrders = consumer<VendorOrder, string>({
|
|
251
|
+
name: 'sync-orders', // REQUIRED: a durable queue key
|
|
252
|
+
tenant: ({ key }) => key, // or 'none'
|
|
253
|
+
keys: () => accounts.connected(), // what should be running: one, or one per account
|
|
254
|
+
open: ({ key, cursor, signal }) => vendor.stream(key, cursor, signal), // the app's AsyncIterable
|
|
255
|
+
handle: async ({ items }) => {
|
|
256
|
+
await orders.upsertAll(items);
|
|
257
|
+
return items[items.length - 1]?.cursor ?? ''; // the cursor past this batch
|
|
258
|
+
},
|
|
259
|
+
cursor: { load: (key) => cursors.of(key), save: (key, next) => cursors.save(key, next) },
|
|
260
|
+
batch: 100,
|
|
261
|
+
rearm: '1m',
|
|
262
|
+
lag: ({ key, cursor }) => vendor.behind(key, cursor),
|
|
263
|
+
});
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
| What it does | How |
|
|
267
|
+
|---|---|
|
|
268
|
+
| one running body per key | `concurrency: { key, limit: 1, whenBusy: 'wait' }`; parallelism is more keys |
|
|
269
|
+
| one live row per key | `idempotencyKey: consumer:<name>:<key>` |
|
|
270
|
+
| no timeout | a run ends when its stream ends (a normal return) or a drain stops it. `timeout` is `X_CONSUMER_INVALID` |
|
|
271
|
+
| the re-arm | `.supervisor`, a task `<name>.supervisor` at `rearm` (whole minutes dividing an hour, UTC), enqueues `<name>.arm`, which asks `keys` and enqueues each. A no-op while a key runs (deduped), a restart once it has not. Registered at declaration |
|
|
272
|
+
| cursor and effect commit together | `handle` returns the cursor past its batch; the framework calls `cursor.save(key, next)` in the **same** `withTransaction` as `handle` (the ambient `db()` client) |
|
|
273
|
+
| drain | the run's `signal` aborts (`X_DRAINING`); the turn in hand commits or rolls back; the attempt is not counted; the cursor stays on the last committed batch |
|
|
274
|
+
| an upstream throw | the job's `retry` and dead-letter, as any job; the next arm restarts a dead key |
|
|
275
|
+
| a partly filled batch | commits after `CONSUMER_LINGER_MS` (50) without a next item, so a quiet stream never holds items read and uncommitted |
|
|
276
|
+
| lag | `lag({ key, cursor })`, optional: `progress(done, done + lag)` on the run. `x jobs show <id> --json` → `consumer: { key, lastBatchAt, lag }` |
|
|
277
|
+
|
|
278
|
+
**Delivery is at least once**: a drained, failed or crashed batch is delivered again, so `handle` must be idempotent per item — item ids are the app's. `keys` runs under `tenant: 'none'`: a tenant-scoped read there needs the app's own `crossTenant(reason, fn)`. Transports ship none: any broker is the app's dependency behind `open`.
|
|
279
|
+
|
|
280
|
+
| Refused | Code |
|
|
281
|
+
|---|---|
|
|
282
|
+
| no `cursor.load`/`cursor.save`, a `timeout`, a `batch` that is not a whole number ≥ 1, a `rearm` that is not whole minutes dividing an hour | `X_CONSUMER_INVALID`, at `consumer()` |
|
|
283
|
+
| `handle` or `open` calling the consumer's own `.enqueue` — a self-enqueue dedupes onto the running row and arms nothing | `X_CONSUMER_SELF_ENQUEUE`, terminal |
|
|
284
|
+
|
|
285
|
+
### The final attempt
|
|
286
|
+
|
|
287
|
+
`run({ finalAttempt })` is `true` when a failure of this attempt will not be retried for want of attempts — the same comparison the runner dead-letters on, so a body releases what a retry would have reused without re-deriving it from `retry.attempts`.
|
|
288
|
+
|
|
289
|
+
| It is not | Because |
|
|
290
|
+
|---|---|
|
|
291
|
+
| "this body runs at most once more" | a `terminal` code stops an earlier attempt |
|
|
292
|
+
| true exactly once per job | an attempt handed back uncounted — a `step.sleep`, a deploy's drain — is presented again under the same number |
|
|
293
|
+
|
|
294
|
+
## Operating a queue
|
|
295
|
+
|
|
296
|
+
Every operator capability is a member of `JobIntrospection` (`driver.introspect`), implemented by the Postgres and the memory driver alike — `x jobs`, `/_x` and the jobs dashboard every `defineAdmin()` serves at `/admin/jobs` ([Admin dashboard → The jobs dashboard](Admin-Dashboard#the-jobs-dashboard)) read nothing else. A hand-written driver implements every member; TS2741 names the one it lacks.
|
|
297
|
+
|
|
298
|
+
| Capability | Member | Bound |
|
|
299
|
+
|---|---|---|
|
|
300
|
+
| paged listing | `list({ queue, name, state, idPrefix, createdFrom, createdTo, tenantId, limit, after \| before })` — keyset, newest first: `after: jobCursor(lastRow)` is the next page, `before: jobCursor(firstRow)` the previous one; `tenantId` keeps one org's rows | `MAX_JOB_PAGE` 200 rows a page; more, or a cursor no page produced, is `X_JOB_PAGE_INVALID`; `after` with `before` is `X_INVARIANT` |
|
|
301
|
+
| delete | `remove(id)`; `removeMany({ state, queue?, name?, tenantId? })` | a running job is `X_JOB_NOT_REMOVABLE`; bulk touches `MAX_BULK_ROWS` 1,000 and answers `{ affected, remaining }` |
|
|
302
|
+
| bulk retry | `requeueMany({ state, queue?, name?, tenantId? })` | the same bound, spent only on rows it can move; a row whose key a live job holds stays and is not counted in `remaining`, so calling until it is zero ends |
|
|
303
|
+
| retry one | `requeue(id, { fromStep? })` — a finished job only | a live job is `X_JOB_NOT_REQUEUEABLE`, a key a live job holds `X_JOB_DUPLICATE`, an id nobody queued `X_JOB_NOT_FOUND`; `fromStep` drops that step and the later ones with the row's own move |
|
|
304
|
+
| cancel | `cancel(id, reason?)` — a live job only (ready, delayed, running, suspended) | a done, failed, dead or cancelled row is left as it ended; `cancelJob` / `x jobs cancel` answer `X_JOB_NOT_CANCELLABLE` |
|
|
305
|
+
| run a delayed job now | `promote(id)`; `promoteMany({ state, queue?, name?, tenantId? })` | only a job waiting on its `runAt` — `state` is one of `PROMOTABLE_STATES` (`delayed`, or `ready` backing off before a retry); the bulk bound as above |
|
|
306
|
+
| queue pause | `pauseQueue(q)` / `resumeQueue(q)` / `pausedQueues()` | a paused queue is never claimed, fleet-wide, within one poll; enqueues still land |
|
|
307
|
+
| task pause | `pauseTask(t)` / `resumeTask(t)` / `pausedTasks()` | on resume the task's own `catchUp` decides what it missed |
|
|
308
|
+
| a task's last fire | `taskFires()` — `{ task, occurrenceMs, firedAt }` per task, by name | the last occurrence that QUEUED its jobs: arming a task or skipping missed occurrences is not a fire. `MAX_TASK_FIRES` 1,000 rows |
|
|
309
|
+
| a task's next fire | `nextTaskRun(task, from)` — the next occurrence strictly after `from`, in the task's own zone | the scheduler's own resolver, so a dashboard and the scheduler cannot disagree; no running scheduler needed |
|
|
310
|
+
| who is working | `workers()` — id, host, started, queues, slots, in-flight job ids, last heartbeat | a row expires one visibility timeout after its last heartbeat: a killed worker leaves by expiry |
|
|
311
|
+
| history | `counters({ job, sinceMs })`, `counterTotals(sinceMs)` — `done`, `retried`, `failed`, `dead`, `durationMs` | `COUNTER_TIERS`: 1-minute buckets for 24 h, 5-minute for 7 d, 1-hour for 30 d |
|
|
312
|
+
| progress | `run({ progress })` → `progress(done, total, note?)`, on the row and in `x jobs show` | one write a second per run at most, and always the last before the run settles |
|
|
313
|
+
| how a run ended | `job({ onSettled })` — `completed` (with what `run` returned), `dead-lettered`, `dropped` or `refused`; after the row is settled, under the job's tenant, three tries of its own, on a live `ctx.signal` under `onSettledTimeout` (30 s by default): its `fetch` and its writes land | at most once across a crash; a hook that keeps failing is `X_JOB_ON_SETTLED_FAILED`, logged and reported, and the row is untouched. A cancel is not a settlement: whoever cancels is the observer |
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
export const syncAccount = job({
|
|
317
|
+
input: t.object({ accountId: t.uuid, orgId: t.uuid }),
|
|
318
|
+
tenant: ({ orgId }) => orgId,
|
|
319
|
+
idempotencyKey: ({ accountId }) => `sync:${accountId}`,
|
|
320
|
+
retry: { attempts: 3 },
|
|
321
|
+
async run({ input, step, progress, finalAttempt }) {
|
|
322
|
+
const rows = await step.run('load', () => loadRows(input.accountId));
|
|
323
|
+
for (const [index, row] of rows.entries()) {
|
|
324
|
+
await step.run(`sync:${row.id}`, () => syncRow(row));
|
|
325
|
+
progress(index + 1, rows.length);
|
|
326
|
+
}
|
|
327
|
+
return { rows: rows.length };
|
|
328
|
+
},
|
|
329
|
+
// ONE hook for every ending. `settled.result` is what `run` returned, typed from it.
|
|
330
|
+
async onSettled(settled) {
|
|
331
|
+
if (settled.outcome === 'completed') {
|
|
332
|
+
await markAccountSynced(settled.input.accountId, settled.result.rows);
|
|
333
|
+
} else if (settled.input !== undefined) {
|
|
334
|
+
await markAccountBroken(settled.input.accountId, settled.code);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
| `onSettled` | Rule |
|
|
341
|
+
|---|---|
|
|
342
|
+
| `completed` | the body returned and the ack landed. Carries `input` and `result` |
|
|
343
|
+
| `dead-lettered` / `dropped` | failed for good; `dropped` is a job declaring `retry.deadLetter: false`. Carries `error` (the row's `lastError`), `code` (its `X_*` code, when it has one) and `input` — `undefined` when the stored payload no longer parses |
|
|
344
|
+
| `refused` | `whenBusy: 'fail'` over a busy key. The body never ran; `code` is `X_JOB_KEY_BUSY` |
|
|
345
|
+
| never called for | a retry, a suspension, a drain, a row cancelled or removed by a caller, a settle that did not land |
|
|
346
|
+
| delivery | **at most once** per ending — a worker killed between the settle and the hook never runs it. What must be recorded for certain is written by the body, inside a `step.run` |
|
|
347
|
+
| failure | three tries back to back, all inside one `onSettledTimeout` (30 s by default), which aborts `ctx.signal` with `X_JOB_TIMEOUT` and gives the worker its slot back; then `X_JOB_ON_SETTLED_FAILED`. The job's outcome never changes |
|
|
348
|
+
|
|
349
|
+
| Fact | Detail |
|
|
350
|
+
|---|---|
|
|
351
|
+
| the counter moves in the settling statement | one round trip per settle, and a count that cannot disagree with the rows. A shed, a suspension and a drained attempt are handed back uncounted |
|
|
352
|
+
| `QueueStats.failed` | rows that ended `failed`: refused by a busy concurrency key, or exhausted on a `retry.deadLetter: false` job (settled `failed`, outcome `dropped` — that job used to be re-queued forever) |
|
|
353
|
+
| a job whose worker dies on every attempt is dead-lettered | a lease that lapses on a row's FINAL attempt is settled `dead` by the next claim itself — `lastError` says the lease lapsed — and never handed out again. It reaches the dead-letter queue after `retry.attempts` claims; it used to take a worker per visibility timeout, forever. Logged `jobs.claim.exhausted`; `onSettled` is told `dead-lettered`, and `WorkerStats.deadLettered` counts it. A job declaring `retry.deadLetter: false` is buried `failed` instead and told `dropped` — the worker names those jobs to the claim |
|
|
354
|
+
| settles are fenced on the claim | `{ workerId, claim }`: a body whose lease lapsed cannot settle, renew or report on the run that replaced it — whether another worker claimed it or the same one did. The miss is logged `jobs.settle.unowned` |
|
|
355
|
+
| `x jobs drain` (planned) | moves nothing: the subcommand exits `X_NOT_IMPLEMENTED` `As of 25.0.0` and has no body. A drain written for a future second driver settles what it moves without counting it — a moved job is not a completed one |
|
|
356
|
+
| a scheduled occurrence fires in one statement | the watermark and the occurrence's jobs move together, so a crash between them cannot fire it twice |
|
|
357
|
+
| remote "quiet this worker" | not shipped: SIGTERM drain is the mechanism, and the orchestrator owns process lifecycle |
|
|
358
|
+
|
|
359
|
+
### Idle cost and pickup latency
|
|
360
|
+
|
|
361
|
+
| Loop | Idle behaviour | Claims per idle minute |
|
|
362
|
+
|---|---|---|
|
|
363
|
+
| worker | waits `pollIntervalMs` (250 ms) while passes find work, doubling to `idlePollMaxMs` while they do not; an idle pass is one claim over every queue it serves | 12 with the wake, 30 without |
|
|
364
|
+
| outbox relay | `intervalMs` (200 ms) doubling to `idlePollMaxMs` | 12 with the wake, 30 without |
|
|
365
|
+
| scheduler | ticks every second and, with nothing due, reads no store: watermarks are held in memory while it leads and the lease is renewed every 10 s | 6 statements, for any number of tasks |
|
|
366
|
+
|
|
367
|
+
`idlePollMaxMs` defaults to 2 s, and to 5 s while the wake is proven.
|
|
368
|
+
|
|
369
|
+
**The wake.** A `worker` pod holds one `LISTEN` session (`startQueueWake`, started by the boot). The enqueue statement and the outbox stage carry a `pg_notify` — transactional, so it is delivered when the row commits and never when it rolls back — and an idle worker or relay passes at once.
|
|
370
|
+
|
|
371
|
+
| A job enqueued by | Starts after — idle worker, Postgres 17 on loopback, median / max, `As of 2026-10-01` |
|
|
372
|
+
|---|---|
|
|
373
|
+
| the same process | 10 ms / 19 ms |
|
|
374
|
+
| another process, direct | 8 ms / 18 ms |
|
|
375
|
+
| another process, through the outbox, from its commit | 9 ms / 13 ms |
|
|
376
|
+
| another process, with no wake | within `idlePollMaxMs` — 2 s |
|
|
377
|
+
| a retry or `step.sleep` the worker itself handed back | when it falls due |
|
|
378
|
+
| a delayed job another process queued | within `idlePollMaxMs` past its `runAt` |
|
|
379
|
+
|
|
380
|
+
| Rule | Detail |
|
|
381
|
+
|---|---|
|
|
382
|
+
| the payload is the queue name | never an id, an input or a tenant |
|
|
383
|
+
| at most one notification per queue per 250 ms | Postgres serialises every notifying commit behind one lock. A row its slot kept silent is found by the woken worker's next pass, one `pollIntervalMs` later — never worse than a fixed 250 ms poll |
|
|
384
|
+
| the poll is the guarantee | a notification is lost with the session that would have carried it; the driver re-dials and the poll covers the gap |
|
|
385
|
+
| the wake has to be proven | a probe sent through the pool must come back on the session: `jobs.wake.live`. If it does not — PgBouncer in `pool_mode = transaction` delivers no notification — the log says `jobs.wake.unverified` once and the ceiling stays 2 s. Give the `worker` role a direct or session-pooled `DATABASE_URL` ([`docs/ops/04-datastores.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/ops/04-datastores.md#pooling-and-the-trap-under-it)) |
|
|
386
|
+
| a queue pause | read by the claim statement itself, so it holds from each worker's next claim; a resume announces the queue |
|
|
387
|
+
|
|
388
|
+
## Exports and imports — `exportRows()` and `importRows()`
|
|
389
|
+
|
|
390
|
+
**A bulk file in either direction is a job.** `exportRows()` streams a paged read to object
|
|
391
|
+
storage, one object per page; `importRows()` reads an object back a page at a time into the app's
|
|
392
|
+
tables. Both are factories over `job()`, so both get `.enqueue()`, retries, the dead-letter path,
|
|
393
|
+
`x jobs show` and a manifest row. Long form, with the export's rules: the
|
|
394
|
+
[`@ultimat3/jobs` README](https://github.com/developerz-ai/ultimate/blob/main/packages/jobs/README.md#importing-a-large-file-is-a-job-too).
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
import { importRows, t } from '@ultimat3/jobs';
|
|
398
|
+
import { disk } from '@ultimat3/storage';
|
|
399
|
+
|
|
400
|
+
const Order = t.object({ id: t.uuid, total: t.number.int() });
|
|
401
|
+
declare const db: {
|
|
402
|
+
readonly orders: {
|
|
403
|
+
upsertAll(rows: readonly object[], args: { onConflict: readonly string[] }): Promise<unknown>;
|
|
404
|
+
};
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
export const importOrders = importRows({
|
|
408
|
+
name: 'orders.import',
|
|
409
|
+
input: t.object({ orgId: t.uuid, key: t.string }),
|
|
410
|
+
tenant: ({ orgId }) => orgId,
|
|
411
|
+
source: () => disk('imports'), // ImportSource: stat + get(key, { range })
|
|
412
|
+
key: ({ key }) => key,
|
|
413
|
+
format: 'csv',
|
|
414
|
+
columns: ['id', 'total'],
|
|
415
|
+
schema: Order, // any Standard Schema; csv text is coerced as an HTTP form is
|
|
416
|
+
handle: ({ rows, input }) =>
|
|
417
|
+
db.orders.upsertAll(rows.map((r) => ({ ...r, orgId: input.orgId })), {
|
|
418
|
+
onConflict: ['orgId', 'id'],
|
|
419
|
+
}),
|
|
420
|
+
errors: { sink: () => disk('imports'), prefix: ({ orgId, key }) => `${orgId}/errors/${key}` },
|
|
421
|
+
});
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
| Rule | Why |
|
|
425
|
+
|---|---|
|
|
426
|
+
| one `step.run` per page, checkpointed at the BYTE OFFSET | a killed attempt resumes there; no carry survives a step, because bytes after the last whole record are read again by the next page |
|
|
427
|
+
| record ends found on bytes, quote-aware | a newline inside `"…"` is data, a character split by a page edge arrives whole, CRLF and a UTF-8 BOM read as nothing |
|
|
428
|
+
| `handle` is AT LEAST ONCE per page | a page that dies after `handle` and before its checkpoint runs again: write with `upsertAll` |
|
|
429
|
+
| the csv header must name exactly `columns` | in any order; missing or undeclared is `X_IMPORT_HEADER_MISMATCH` before a row is read |
|
|
430
|
+
| a record past `maxRecordBytes` (64 KiB) is refused | usually an unclosed quote: `X_IMPORT_RECORD_TOO_LARGE` at its byte offset, never read on into the heap |
|
|
431
|
+
| a refused row is `{ line, field, code }` in `<prefix>/<runId>/errors-NNNNN.ndjson` | one part per page, keyed by page index so a replay overwrites, under the RUN so a re-import never mixes reports; the result's `errorPrefix` names it. **Never the refused value**: the report would keep what the app refused, PII included |
|
|
432
|
+
| past `maxRowErrors` (1,000; `0` allowed) the run fails | `X_IMPORT_TOO_MANY_ERRORS`; the `batch` that crossed it is not handled, the report is already written |
|
|
433
|
+
| a page uses its whole read, `batch` (500) rows per `handle` call | bytes read are the file's size and steps about file ÷ `pageBytes`: a 10 MB file of two-byte rows is 10 reads and 10 page steps |
|
|
434
|
+
| a csv with no first line (empty, a BOM, blank lines) | `X_IMPORT_HEADER_MISMATCH`, never a success with zero rows |
|
|
435
|
+
| no formula guard on the way in | the export prefixes `'` on `=`, `+`, `-`, `@`; an import cannot know the prefix was added, so it keeps it |
|
|
436
|
+
| peak memory is a page, not the file | measured `As of 2026-11-03`: a 200 MB csv (190 × `pageBytes`) imported with the heap up 42 MB, a 20 MB one with it up 24 MB |
|
|
437
|
+
|
|
438
|
+
## One person's data — `subjectExport()` and `subjectErase()`
|
|
439
|
+
|
|
440
|
+
**Declare the person once; the framework finds every table holding something of theirs.**
|
|
441
|
+
`defineSubject(entity, { driver, key, tables, targets?, columns? })` is a plain value both job
|
|
442
|
+
factories take. The reach is walked from the foreign keys already declared, through every table
|
|
443
|
+
the erase deletes, so a table added next month is `X_SUBJECT_TABLE_UNDECLARED` on `x verify` until
|
|
444
|
+
it has a treatment. Opt in: no `defineSubject`, no check.
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
import type { Driver, EntityCore } from '@ultimat3/entity';
|
|
448
|
+
import { defineSubject, subjectErase, subjectExport, t } from '@ultimat3/jobs';
|
|
449
|
+
import { disk } from '@ultimat3/storage';
|
|
450
|
+
|
|
451
|
+
declare const members: EntityCore;
|
|
452
|
+
declare const driver: Driver;
|
|
453
|
+
|
|
454
|
+
export const memberSubject = defineSubject<{ orgId: string; memberId: string }>(members, {
|
|
455
|
+
driver, // the app's own: tenancy, row observers and cache tags hold
|
|
456
|
+
key: (input) => input.memberId,
|
|
457
|
+
tables: {
|
|
458
|
+
members: { anonymize: { email: 'erased@example.invalid', name: '' } },
|
|
459
|
+
posts: { retain: 'published work belongs to the org' },
|
|
460
|
+
comments: 'delete',
|
|
461
|
+
likes: 'delete',
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
export const eraseMember = subjectErase({
|
|
466
|
+
name: 'members.erase',
|
|
467
|
+
input: t.object({ orgId: t.uuid, memberId: t.uuid }),
|
|
468
|
+
tenant: ({ orgId }) => orgId,
|
|
469
|
+
subject: memberSubject,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
export const exportMember = subjectExport({
|
|
473
|
+
name: 'members.export',
|
|
474
|
+
input: t.object({ orgId: t.uuid, memberId: t.uuid }),
|
|
475
|
+
tenant: ({ orgId }) => orgId,
|
|
476
|
+
subject: memberSubject,
|
|
477
|
+
prefix: ({ orgId, memberId }) => `org/${orgId}/subjects/${memberId}`,
|
|
478
|
+
format: 'ndjson',
|
|
479
|
+
sink: () => disk(),
|
|
480
|
+
});
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
| Rule | Why |
|
|
484
|
+
|---|---|
|
|
485
|
+
| every reached table states `'delete'`, `{ anonymize: { col: value } }` or `{ retain: '<why>' }` | nothing defaults: which law applies and what is kept is the app's. A blank reason, an unknown column, `null` on a not-null column, `'delete'` on an append-only or soft-delete entity: `X_SUBJECT_TREATMENT_INVALID` |
|
|
486
|
+
| the walk continues only through deleted tables | the likes on a RETAINED post are not the person's; the likes on a deleted one go with it |
|
|
487
|
+
| a kept row under a deleted parent is refused | it would block the delete (`restrict`) or be cascaded away; `'delete'`, or anonymize the key to `null` |
|
|
488
|
+
| children first, the subject last; one step per page of parent keys | a killed run resumes at the page it stopped on; every write is an equality on one key, so a replay changes nothing twice |
|
|
489
|
+
| a loop of deleted tables needs one nullable key | it is cleared first; none is `X_SUBJECT_CYCLE` |
|
|
490
|
+
| a table with no foreign key to the person is invisible | list it: `columns: { audit_copies: 'memberId' }`, then it needs a treatment too |
|
|
491
|
+
| framework data is a `targets:` entry | `authSubjectTarget` (`@ultimat3/auth`), `auditSubjectTarget` (`@ultimat3/action`, rewrites who acted, keeps the row), `storageSubjectTarget(disk, keysOf)` (`@ultimat3/storage`); `{ target, retain }` keeps one |
|
|
492
|
+
| the export is one object per page per table plus `index.json` | each table's count, treatment and `retain` reason; sealed columns never leave |
|
|
493
|
+
| one job per tenant | crossing tenants is the app's loop; queued job inputs and logs are out of reach (`x_jobs` ages out by `jobs-retention.ts`) |
|
|
494
|
+
| who may ask is the app's action and policy | no route and no MCP tool ships; receipts, identity checks and deadlines are the app's |
|
|
495
|
+
|
|
496
|
+
`x exercise` proves the declaration: `subjectProof` seeds every table with canaries, erases one
|
|
497
|
+
generated person, and searches every stored row. Their value outside a `retain` is
|
|
498
|
+
`X_SUBJECT_ERASE_INCOMPLETE`, naming the table and column.
|
|
499
|
+
|
|
500
|
+
## Rollups and materialized views
|
|
501
|
+
|
|
502
|
+
Two factories over `job()`, two jobs, `As of 2026-11`. A time window over append-mostly rows is
|
|
503
|
+
`rollup()`, recomputing only recent buckets into the app's own table. An arbitrary join is a
|
|
504
|
+
materialized view, refreshed whole by `refreshView()`. Neither is scheduled: the app's `task`
|
|
505
|
+
enqueues it.
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
import { bigint, database, entity, integer, newId, timestamp, uuid } from '@ultimat3/entity';
|
|
509
|
+
import { refreshView, rollup } from '@ultimat3/jobs';
|
|
510
|
+
|
|
511
|
+
const likes = entity('likes', {
|
|
512
|
+
columns: { id: uuid().primaryKey(), orgId: uuid().tenant(), createdAt: timestamp() },
|
|
513
|
+
});
|
|
514
|
+
const likesPerDay = entity('likes_per_day', {
|
|
515
|
+
columns: { id: uuid().primaryKey(), orgId: uuid().tenant(), day: timestamp(), likes: integer() },
|
|
516
|
+
indexes: [{ on: ['orgId', 'day'], unique: true }],
|
|
517
|
+
});
|
|
518
|
+
const postTotals = entity('post_totals', {
|
|
519
|
+
view: { sql: 'select org_id, post_id, count(*) as total from likes group by 1, 2', materialized: true },
|
|
520
|
+
columns: { orgId: uuid().tenant(), postId: uuid().unique(), total: bigint() },
|
|
521
|
+
primaryKey: ['postId'],
|
|
522
|
+
});
|
|
523
|
+
const db = database({ likes, likesPerDay });
|
|
524
|
+
const ORG = '00000000-0000-7000-8000-000000000001';
|
|
525
|
+
|
|
526
|
+
export const dailyLikes = rollup({
|
|
527
|
+
name: 'daily-likes',
|
|
528
|
+
into: likesPerDay, // the app's entity, unique index over `key`
|
|
529
|
+
table: () => db.likesPerDay,
|
|
530
|
+
key: ['orgId', 'day'],
|
|
531
|
+
lookback: '2d', // how late a row may arrive and still count
|
|
532
|
+
bucket: { unit: 'day', timeZone: 'UTC' }, // one row of `into`: a series unit, an IANA zone
|
|
533
|
+
tenant: (input) => input.orgId ?? ORG,
|
|
534
|
+
source: ({ since, bucket, input }) =>
|
|
535
|
+
db.likes
|
|
536
|
+
.where({ orgId: input.orgId ?? ORG })
|
|
537
|
+
.andWhere('createdAt', 'gte', since) // a bucket's first instant, never mid-bucket
|
|
538
|
+
.series({ on: 'createdAt', ...bucket, value: 'count' }),
|
|
539
|
+
map: (point, { input }) => ({
|
|
540
|
+
id: newId(),
|
|
541
|
+
orgId: input.orgId ?? ORG,
|
|
542
|
+
day: point.bucket,
|
|
543
|
+
likes: point.value,
|
|
544
|
+
}),
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
export const refreshPostTotals = refreshView({
|
|
548
|
+
name: 'refresh-post-totals',
|
|
549
|
+
view: postTotals,
|
|
550
|
+
concurrently: true,
|
|
551
|
+
});
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
| `rollup()` | |
|
|
555
|
+
|---|---|
|
|
556
|
+
| Each run | reads `source` from the first instant of the `bucket` holding `now - lookback` — in its zone, DST included, so the first bucket upserted whole is read whole — maps every item, ONE `upsertAll` on `key`: all buckets or none |
|
|
557
|
+
| Idempotent | a re-run over the same rows writes the same values; a late row inside `lookback` moves its bucket on the next run |
|
|
558
|
+
| Not handled | a row older than `lookback` when it lands; a bucket whose rows were all deleted keeps its last value |
|
|
559
|
+
| Refused at declaration | no `lookback`, a non-positive one, no `bucket`, a unit no `series()` has, a zone that is not IANA, a `key` no unique constraint on `into` covers — `X_JOB_ROLLUP_INVALID` |
|
|
560
|
+
|
|
561
|
+
| `refreshView()` | |
|
|
562
|
+
|---|---|
|
|
563
|
+
| Each run | `refresh materialized view [concurrently]` in one step; `tenant: 'none'` — the read path scopes the rows |
|
|
564
|
+
| `concurrently` | readers keep the old rows meanwhile; needs a unique index on the view (`.unique()`) |
|
|
565
|
+
| Refused at declaration | a table, a plain view, `concurrently` with no unique index — `X_JOB_REFRESH_VIEW_INVALID` |
|
|
566
|
+
|
|
567
|
+
## Outbound webhooks — `webhook()`
|
|
568
|
+
|
|
569
|
+
**An outbound webhook is a job: `webhook()` is a factory over `job()` that delivers one event to
|
|
570
|
+
one endpoint.** It brings `.enqueue()`, retry with core's backoff, the dead-letter path,
|
|
571
|
+
`x jobs show` and a manifest row; it adds signing, a timestamped signature, disable-after-N
|
|
572
|
+
consecutive failures and a ledger row per attempt (`packages/jobs/src/webhook.ts`). It never owns
|
|
573
|
+
the event taxonomy, which endpoints exist, or what a payload means — the fan-out is the app's own
|
|
574
|
+
loop over its own subscription table. Do not hand-write a delivery job.
|
|
575
|
+
|
|
576
|
+
```ts
|
|
577
|
+
import { memoryWebhookLedger, webhook } from '@ultimat3/jobs';
|
|
578
|
+
|
|
579
|
+
export const deliver = webhook({
|
|
580
|
+
name: 'partner.webhooks', // required: a durable queue key
|
|
581
|
+
tenant: ({ orgId }) => orgId, // the org that owns the endpoint — or 'none'
|
|
582
|
+
endpoint: ({ endpointId }) => db.endpoints.byId(endpointId), // read per attempt, never checkpointed
|
|
583
|
+
event: ({ eventId }) => db.events.byId(eventId), // { topic, body }
|
|
584
|
+
ledger: memoryWebhookLedger(), // dev only — a WebhookLedger over your own table in production
|
|
585
|
+
disableAfter: 10, // DEFAULT_WEBHOOK_DISABLE_AFTER
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
for (const endpoint of await subscribersOf('orders.paid')) {
|
|
589
|
+
await deliver.enqueue({ endpointId: endpoint.id, eventId: event.id, orgId: endpoint.orgId });
|
|
590
|
+
}
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
| Fact | Rule |
|
|
594
|
+
|---|---|
|
|
595
|
+
| one endpoint per job | retry, backoff and disable-after-N are per endpoint; a job that fanned out inside one body would retry every subscriber because one was down |
|
|
596
|
+
| the signature | `x-ultimate-webhook-signature: t=<seconds>,v1=<hex hmac-sha256>` over `v1:<t>:<eventId>:<topic>:<body>`, beside `x-ultimate-webhook-id` and `x-ultimate-webhook-topic` |
|
|
597
|
+
| the receiving half | `verifyWebhookSignature(request, { secret })` from `@ultimat3/http` — one format module in `@ultimat3/core`, re-exported by both |
|
|
598
|
+
| an endpoint URL | `https://` in production; a host resolving to a loopback, private, link-local or metadata address is refused unless `allowPrivate: true` |
|
|
599
|
+
| a `Retry-After` | honoured: `X_WEBHOOK_DELIVERY_THROTTLED` carries `meta.retryAfterSeconds` and the nack waits it out |
|
|
600
|
+
| a disabled endpoint | `X_WEBHOOK_ENDPOINT_DISABLED`; re-enabling is always the app's |
|
|
601
|
+
| the org | `tenant: ({ orgId }) => orgId` needs `orgId` on every enqueue, and both seams are handed it. A tenant that reads no `orgId` enqueues without one, as in 25.0.0. A payload naming no org for its tenant, or an `orgId` that is not the run's org, is `X_JOB_TENANT_MISMATCH` — terminal, dead-lettered on attempt 1. Switching a live webhook to an org tenant: drain its queue first, or re-enqueue what dead-letters with its `orgId` |
|
|
602
|
+
| the ledger | `WebhookLedger` is a seam, not a table: retention is the app's decision. Worked example: the reference app's `apps/web/app/webhooks/ledger.ts` over its own `webhook_deliveries` |
|
|
603
|
+
| the attempt deadline | `timeout:` bounds one attempt and the request inside it — there is no second per-request timeout |
|
|
604
|
+
|
|
605
|
+
Full contract, every rule with its reason: [`packages/jobs/README.md` § Outbound webhooks](https://github.com/developerz-ai/ultimate/blob/main/packages/jobs/README.md#outbound-webhooks-are-jobs-too).
|
|
606
|
+
|
|
607
|
+
## Inbound webhooks
|
|
608
|
+
|
|
609
|
+
**An inbound webhook is a job too: `webhookReceiver()` is a factory over `job()`, the twin of
|
|
610
|
+
`webhook()`, plus the plain route that feeds it.** The route reads the exact bytes, asks your
|
|
611
|
+
`verify`, enqueues the payload `verify` read out of them, and only then answers
|
|
612
|
+
`200 { ok: true, eventId, deduped }`. `handle` runs as the job's body and receives that payload: it
|
|
613
|
+
never re-verifies, so a retry past a vendor's 5-minute window is not refused. No vendor verifier
|
|
614
|
+
ships: `verify` is your function, and `verifyBodyHmac` from `@ultimat3/http` is the one helper
|
|
615
|
+
(`packages/jobs/src/webhook-receiver.ts`). It is the one door for a vendor's webhook. A pinned
|
|
616
|
+
action (`http.path`) answers 400/422 from its schema before the signature is checked, and refuses
|
|
617
|
+
XML, `octet-stream` and no content type.
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
// apps/web/app/billing/jobs/billing-events.ts — declaring it is its registration
|
|
621
|
+
import { verifyBodyHmac } from '@ultimat3/http';
|
|
622
|
+
import { t, webhookReceiver } from '@ultimat3/jobs';
|
|
623
|
+
|
|
624
|
+
const Event = t.object({ id: t.string, type: t.string });
|
|
625
|
+
|
|
626
|
+
export const billingEvents = webhookReceiver({
|
|
627
|
+
name: 'billing.events', // required: a durable queue key, and the route's webhook.<name>
|
|
628
|
+
path: '/hooks/billing/:account', // absolute, may hold :param; never under /api or /_x
|
|
629
|
+
tenant: 'none', // or ({ orgId }) => orgId
|
|
630
|
+
verify: (inbound) => { // { bytes, headers, params, url, request() } — nothing parsed
|
|
631
|
+
const { text } = verifyBodyHmac({
|
|
632
|
+
bytes: inbound.bytes,
|
|
633
|
+
secret: [Bun.env['BILLING_SECRET'] ?? '', Bun.env['BILLING_SECRET_OLD'] ?? ''],
|
|
634
|
+
given: inbound.headers.get('x-billing-signature'),
|
|
635
|
+
encoding: 'hex', // or 'base64'
|
|
636
|
+
prefix: 'sha256=',
|
|
637
|
+
});
|
|
638
|
+
const event = Event.parse(JSON.parse(text ?? ''), 'billing event'); // catch both: see below
|
|
639
|
+
return { eventId: event.id, payload: event }; // the id from the SIGNED bytes — or { answer }
|
|
640
|
+
},
|
|
641
|
+
handle: async ({ eventId, payload }) => { /* the work; at least once per eventId */ },
|
|
642
|
+
});
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
List it in `defineApi({ jobs })` like any job. Both boots mount every declared receiver's routes
|
|
646
|
+
beside `runtime.routes` — no second list, and one also named in `runtime.ts` is mounted once.
|
|
647
|
+
|
|
648
|
+
| Fact | Rule |
|
|
649
|
+
|---|---|
|
|
650
|
+
| the door | a plain `Route`, `meta: { name: 'webhook.<name>', auth: 'public' }`, `rateLimit` passed through. `x routes --json` lists it with `primitive: null`. No content type required, no body parsed before `verify` |
|
|
651
|
+
| a refusal | `verify` throws: `X_WEBHOOK_SIGNATURE_INVALID` / `_STALE` (401), nothing queued. An empty or unset secret, or one empty entry in a rotation list, is `X_CONFIG_INVALID` |
|
|
652
|
+
| the event id | the dedupe key, so read it from VERIFIED data — `verifyBodyHmac(...).text`, or a header the mac covers. An unsigned header lets one captured signature replay forever under fresh ids. 1–256 printable ASCII characters, else `X_WEBHOOK_EVENT_ID_INVALID` (422, never echoed) |
|
|
653
|
+
| parsing in `verify` | catch it and throw your own code naming no content: Bun's `JSON.parse` message quotes the token it stopped at, a piece of the body, into the log and the response |
|
|
654
|
+
| a challenge | `verify` answers `{ answer: Response }` (a subscription confirm), nothing queued. `handshake(inbound)` mounts `GET` on the same path; unset, no `GET` route exists |
|
|
655
|
+
| the body cap | `maxBytes`, default `DEFAULT_WEBHOOK_BODY_LIMIT` (1 MiB), enforced while the body streams: over is `X_BODY_INVALID` naming `maxBytes` |
|
|
656
|
+
| the ack | never before the durable row: an enqueue failure is `503` + `Retry-After: 30`, `X_WEBHOOK_RECEIVER_UNAVAILABLE`, and the vendor redelivers |
|
|
657
|
+
| dedupe | a redelivery while the job is live answers `deduped: true`. After it settles, `handle` runs again with the same `eventId` — **at least once**; the seen-set is your table |
|
|
658
|
+
| the stored payload | JSON, `.sensitive()`, so `x jobs show` prints `[redacted]`. Kept in `x_jobs` until `jobs.doneRetentionMs` / `jobs.deadRetentionMs` ([Configuration](Configuration)); evidence kept longer is stored by `handle` |
|
|
659
|
+
| a declaration fault | `X_WEBHOOK_RECEIVER_INVALID` at `webhookReceiver()`: a relative path, one under `/api` or `/_x`, a missing `verify`/`handle`, a non-function `handshake`, a bad `maxBytes` |
|
|
660
|
+
| `mail/events` | `verify` calls `ses.receive(inbound.request())` — a fresh `Request` over the bytes — and returns its events as the payload ([Mail](Mail)) |
|
|
661
|
+
|
|
662
|
+
Worked example: the reference app's `apps/web/app/webhooks/jobs/billing-events-receiver.ts`.
|
|
663
|
+
Codes: `X_WEBHOOK_ENDPOINT_UNKNOWN`, `X_WEBHOOK_ENDPOINT_INVALID`, `X_WEBHOOK_ENDPOINT_DISABLED`,
|
|
664
|
+
`X_WEBHOOK_EVENT_UNKNOWN`, `X_WEBHOOK_EVENT_INVALID`, `X_WEBHOOK_DELIVERY_FAILED`,
|
|
665
|
+
`X_WEBHOOK_DELIVERY_THROTTLED`, `X_WEBHOOK_DELIVERY_REJECTED` ([Error codes](Error-Codes)).
|
|
666
|
+
|
|
667
|
+
## Driver interface
|
|
668
|
+
|
|
669
|
+
One interface. **Job code never changes.** Step persistence hangs off the same object (`steps`), so it is identical on every implementation.
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
export interface JobDriver {
|
|
673
|
+
readonly name: string;
|
|
674
|
+
/** Step persistence lives with the queue: one store, one transaction boundary. */
|
|
675
|
+
readonly steps: StepStore;
|
|
676
|
+
enqueue(request: EnqueueRequest): Promise<EnqueueResult>;
|
|
677
|
+
claim(options: ClaimOptions): Promise<readonly ClaimedJob[]>;
|
|
678
|
+
/** Both settles are fenced on the CLAIMER and answer whether they landed. */
|
|
679
|
+
ack(jobId: string, by: { workerId: string; durationMs?: number }): Promise<boolean>;
|
|
680
|
+
nack(jobId: string, options: NackOptions): Promise<boolean>;
|
|
681
|
+
heartbeat(jobId: string, options: HeartbeatOptions): Promise<boolean>;
|
|
682
|
+
stats(): Promise<readonly QueueStats[]>;
|
|
683
|
+
/** The `x_backfills` ledger, when the driver ships one. `postgres` and `memory` do. */
|
|
684
|
+
readonly backfills?: BackfillLedger;
|
|
685
|
+
/** Fleet-wide slot counting — the only thing that can enforce `concurrency` across replicas. */
|
|
686
|
+
readonly leases?: LeaseStore;
|
|
687
|
+
readonly introspect?: JobIntrospection;
|
|
688
|
+
close?(): Promise<void>;
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
Three of the four optional members degrade rather than refuse: no `introspect` is `x jobs list` with nothing to list, no `backfills` is a `backfill()` pass that runs with no bookkeeping, and no `close` is a driver holding nothing to hand back.
|
|
693
|
+
|
|
694
|
+
**`leases` is the one that refuses.** The in-process limiter is a fast path over one heap and is multiplied by the replica count, so a driver with no `LeaseStore` can only hold `concurrency.limit` per process. `jobWorker().start()` therefore **throws `X_JOB_CONCURRENCY_UNENFORCEABLE`**, naming every registered job that declared `concurrency`, rather than logging a cap it cannot keep. `postgres` ships one; a driver you write yourself needs one before any job in the tree may declare `concurrency`.
|
|
695
|
+
|
|
696
|
+
Two implementations ship, `As of 25.0.0`: `postgresJobDriver()` and `memoryJobDriver()`. **Redis is not a jobs driver**, and neither is NATS: 25.0.0 deleted both all-throw stubs (`createNatsDriver` and the Redis one), whose every method raised `X_NOT_IMPLEMENTED` — an app could typecheck against a driver that could not enqueue a job. NATS stays the realtime fanout transport.
|
|
697
|
+
|
|
698
|
+
| Driver | Status `As of 25.0.0` | When | Trade-off |
|
|
699
|
+
|---|---|---|---|
|
|
700
|
+
| `postgres` (default) | **shipped** | always, up to ~thousands of jobs/sec. `x dev` runs it too, against the embedded PGlite | outbox is free (same DB, same tx); `SELECT ... FOR UPDATE SKIP LOCKED` claiming; zero extra infra |
|
|
701
|
+
| `memory` | **shipped**; there is no config value for any driver | tests and fixtures, through `memoryJobDriver()` | in-process; nothing survives a restart — which is why it was never a safe `x jobs drain` target |
|
|
702
|
+
|
|
703
|
+
**There is no `jobs.driver`, and 5.0.0 is where it went.** It accepted `'postgres' | 'redis' | 'nats'` and had **no reader anywhere** — boot always built `postgresJobDriver`, stated in [`packages/jobs/src/driver.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/jobs/src/driver.ts)'s own header. So setting it to `redis` changed nothing at all and you silently got Postgres, which is the more dangerous behaviour because nothing reports it. From 5.0.0 to 24.x a stale key still rode through untyped; **25.0.0 refuses it** at `defineConfig`, naming `setJobDriver`. Deleting it from `app.config.ts` is the whole of the upgrade.
|
|
704
|
+
|
|
705
|
+
**The seam that does work is `setJobDriver(driver)`** — swap the driver, zero job-code change, which is what the interface buys. `redisJobDriver()` is the second durable driver (`As of 2026-10`, #710): `setJobDriver(redisJobDriver())` on core's `redisClient()` — while Redis is down an `enqueue` is `X_DRIVER_UNAVAILABLE` at once, never a wait — imported from `@ultimat3/jobs/redis`, held to the same conformance suite as Postgres, without the operator surface (`x jobs show`, `retry`, `cancel`), backfill bookkeeping or fleet-wide `concurrency` — [`packages/jobs/README.md`](https://github.com/developerz-ai/ultimate/blob/main/packages/jobs/README.md#drivers) has the table. There is no NATS jobs driver.
|
|
706
|
+
|
|
707
|
+
**`x jobs drain` is planned, `As of 25.0.0`.** It exits `X_NOT_IMPLEMENTED` before the queue boots, pointing at `x jobs list --json`: with Postgres the one durable driver, there is nowhere to drain to. Its two former `--to` values, `redis` and `nats`, were all-throw stubs — until 2026-10 a drain leased the whole pending batch for five minutes, failed every enqueue and nacked it back, nothing moved, and no worker could claim those jobs meanwhile. 25.0.0 deleted the stubs and the unreachable drain body behind the planned answer. A drain that returns with a real second driver is new code, and owes the lesson of `--to memory`, a target until 2026-09: `memoryJobDriver()` is a `Map` inside the command's own process, so the drain enqueued each job into it, acked the durable row off the source, printed `ok: true`, and lost every copy when the command exited.
|
|
708
|
+
|
|
709
|
+
So there is no cross-driver migration procedure — and none is needed while `postgres` is the only driver that runs.
|
|
710
|
+
|
|
711
|
+
## Dead letter
|
|
712
|
+
|
|
713
|
+
| Stage | Behavior |
|
|
714
|
+
|---|---|
|
|
715
|
+
| Attempt `n` fails | `fail(id, err, retryAt)` with jittered backoff per `retry.backoff` |
|
|
716
|
+
| Attempts exhausted | row moves to dead-letter carrying the full step trace and the serialized error |
|
|
717
|
+
| Inspect | `x jobs show <id> --json` — step results, executions per step, next retry, the failing error |
|
|
718
|
+
| Replay | `x jobs retry <id>` — resumes **from the failed step**, completed steps replay from storage |
|
|
719
|
+
| Stop one | `x jobs cancel <id> --reason "<why>" --json` — exit 0 means it is genuinely stopped; a finished job or a driver that cannot cancel raises `X_JOB_NOT_CANCELLABLE` |
|
|
720
|
+
| Bulk | **not shipped.** `retry` and `cancel` each take one id positional; there is no `--failed-since` and no queue-wide replay. List first (`x jobs list --state dead --queue integrations --json`), then loop over the ids |
|
|
721
|
+
|
|
722
|
+
Draining a worker mid-job does **not** finish the current step (`As of 2026-09-07`). On SIGTERM the worker aborts each held job's `ctx.signal` with `X_DRAINING`; a body that reads it — `outbound()` from `@ultimat3/core/outbound` (it reads `ctx.signal` itself), `fetch(url, { signal: ctx.signal })`, `throwIfAborted(ctx)` between steps — is **interrupted**: the attempt is uncounted, the lease is released, and the job goes straight back to the ready bucket for the worker replacing this one. That worker resumes from the last step that **completed** — persisted steps replay from storage, the step cut short runs again from its start, and past the abort `step.run` refuses to write (`X_ABORTED`), so a half-finished step is never recorded as done. A body that ignores the signal runs until the drain deadline and is abandoned there: its lease lapses, the queue redelivers it, and the attempt is counted. Only a manual `worker.stop()` waits for the work it holds. Plan a deploy for the interruption, not for the step: keep steps short and idempotent, and pass `ctx.signal` to every outbound call.
|
|
723
|
+
|
|
724
|
+
## Observability
|
|
725
|
+
|
|
726
|
+
| Surface | Contents |
|
|
727
|
+
|---|---|
|
|
728
|
+
| `/_x` dev panel | queue depth per queue, in-flight, failed, step timeline per job, and the whole `x_backfills` ledger with a live count |
|
|
729
|
+
| `x jobs list --json` | one row per job: state, queue, attempts, `runAt`, idempotency key — plus the `backfill()` passes **in flight**, with rows so far and cursor |
|
|
730
|
+
| `x jobs show <id> --json` | machine-readable state, step results, next retry, dead-letter reason, and this run's ledger row under `backfill` when the job is a backfill |
|
|
731
|
+
| `x db backfill --list --json` | the whole ledger: one row per pass, newest first → [CLI reference](CLI-Reference#x-db) |
|
|
732
|
+
| MCP dev tools | `jobs.inspect` (definitions, retry policy, steps) and `queue.depth` (pending/running/failed per queue) — scope `dev:read`, never reachable in `ROLE=web` |
|
|
733
|
+
| OpenTelemetry | one span per job, one child span per step, trace linked to the enqueuing request |
|
|
734
|
+
| Metrics | `queue_depth{queue}`, `queue_demand{queue}` (what the worker HPA reads), `jobs_total{queue,outcome}`, `job_leases_lost_total{queue}` → [Observability](Observability) |
|
|
735
|
+
|
|
736
|
+
Every command supports `--json`. See [CLI reference](CLI-Reference).
|
|
737
|
+
|
|
738
|
+
**A lease that lapses is reported, never swallowed.** A worker renews the visibility window every `heartbeatIntervalMs` (default a third of the window) for as long as a job runs. One failed renewal is `jobs.heartbeat.failed` at `warn` — the window still has room for the next. A whole window with none landing is `jobs.lease.lost` at `error` plus one point on `job_leases_lost_total{queue}`: the queue is now free to hand that job to another worker while this one is still running it, which is at-least-once becoming exactly-twice. Alert on any non-zero rate. The window is measured from the last renewal that **landed**, on the worker's own clock, so a `heartbeat` that hangs is caught the same as one that rejects.
|
|
739
|
+
|
|
740
|
+
## Errors
|
|
741
|
+
|
|
742
|
+
| Code | Cause | Fix |
|
|
743
|
+
|---|---|---|
|
|
744
|
+
| `X_IDEMPOTENCY_REQUIRED` | a `job` declaration omits `idempotencyKey` | add `idempotencyKey: (input) => …` derived from `input` only |
|
|
745
|
+
| `X_STEP_DUPLICATE` | two `step.run` calls share a name in one `run` | rename one step; step names are the persistence key |
|
|
746
|
+
| `X_JOB_MAX_ATTEMPTS` | the job exhausted its retries and was dead-lettered | `x jobs show <id> --json`, then `x jobs retry <id>` — only a dead, cancelled, failed or done job can be requeued (`X_JOB_NOT_REQUEUEABLE` otherwise) |
|
|
747
|
+
| `X_JOB_DECLARATION_INVALID` | a `job()` declaration lacks fields every job declares — the cause names each | add the fields the fix lists |
|
|
748
|
+
| `X_IDEMPOTENCY_CONFLICT` | same key, different payload, or still in flight | fresh key for a different payload; otherwise retry after the first settles |
|
|
749
|
+
| `X_DRAINING` | the worker holding the job received SIGTERM — it is the reason on `ctx.signal`, and a body that unwinds on it is `interrupted` with the attempt uncounted | none — the job goes straight back to the queue and another worker claims it. Pass `ctx.signal` to outbound calls and `throwIfAborted(ctx)` between steps, so the body unwinds inside the drain budget instead of being killed at it |
|
|
750
|
+
| `X_FORBIDDEN` | the job's actor fails the originating action's policy | grant the permission, or enqueue as a system actor |
|
|
751
|
+
| `X_NOT_IMPLEMENTED` | introspection or the operator surface reached a driver with no `introspect` — a hand-rolled one | call `setJobDriver(postgresJobDriver({ executor }))`, or `setJobDriver(memoryJobDriver())` in a test. There is no config line to edit: `jobs.driver` was deleted in 5.0.0 because it never had a reader |
|
|
752
|
+
|
|
753
|
+
Full index: [Error codes](Error-Codes). Verbatim error shapes live in each package's `src/errors.ts`.
|
|
754
|
+
|
|
755
|
+
## Testing
|
|
756
|
+
|
|
757
|
+
`x test job` — cloned DB + frozen clock.
|
|
758
|
+
|
|
759
|
+
```ts
|
|
760
|
+
// job test — the step guarantee, not the happy path
|
|
761
|
+
test('onboardOrg retries only the failed step', async ({ seed, clock, mail }) => {
|
|
762
|
+
const { org } = await seed('fresh-org');
|
|
763
|
+
mail.failOnce(nudgeEmail);
|
|
764
|
+
await runJobs(onboardOrg, { orgId: org.id });
|
|
765
|
+
clock.advance('3d');
|
|
766
|
+
const trace = await runJobs.drain();
|
|
767
|
+
expect(trace.steps.provision.executions).toBe(1); // replayed from storage
|
|
768
|
+
expect(trace.steps['nudge'].executions).toBe(2); // only this one retried
|
|
769
|
+
});
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Asserted by the runner: step replay, idempotency-key dedupe, retry/backoff, concurrency and rate limits, outbox atomicity on rollback. `clock.advance` drives `step.sleep` — never assert on wall-clock time. See [Testing](Testing).
|
|
773
|
+
|
|
774
|
+
What a crash does to a job is asked by `crashProof(job, input)` — killed at every step boundary, a double effect is `X_JOB_CRASH_DIVERGED` — and what a race does by `simulateJobs({ jobs, workers, invariant })`, seeded. See [Testing](Testing#a-job-killed-at-every-boundary-and-raced-from-a-seed).
|
|
775
|
+
|
|
776
|
+
## Rules
|
|
777
|
+
|
|
778
|
+
- Never assume a job runs once. Assume at-least-once. A `backfill()`'s `handle` is the same rule one level down: it runs *before* its checkpoint lands, so an attempt cancelled between the two hands that page to the next one — write through `upsertAll`, `updateWhere` or a statement whose second run changes nothing.
|
|
779
|
+
- Never put durable business state only in the payload.
|
|
780
|
+
- Never do slow work inline in an action — enqueue a job.
|
|
781
|
+
- A job never renders, redirects, or reads headers. Actor and tenant come from `ctx`.
|
|
782
|
+
- One `step.run` per externally-visible side effect. A step that does two things cannot be retried.
|
|
783
|
+
- Cron never contains a handler body — that is a [scheduled task](Scheduled-Tasks) enqueuing a job.
|