@ultimat3/cli 29.3.2 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +13 -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 +28 -8
- 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 +42 -138
- 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 +17 -27
- 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 +1313 -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,508 @@
|
|
|
1
|
+
# Deployment
|
|
2
|
+
|
|
3
|
+
One image, N roles. Build once; the `ROLE` env var selects behavior. No role-specific Dockerfile, no per-role dependency set, no drift between what you tested and what runs.
|
|
4
|
+
|
|
5
|
+
`As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)). All three artifact targets ship — `x build --target docker`, `x build --target binary`, `x build --target static` — plus `x build --target prebuilt`, the step the app image runs on itself (`As of 2026-10`), and so do the compose files and the Helm chart. Milestone 11 is 🚧 on one thing ([roadmap](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/14-roadmap.md)): the two-platform proof — the demo app on Compose **and** on K8s from one image, with a rolling restart invisible to connected clients.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
docker build -t myapp . # once
|
|
9
|
+
ROLE=web myapp # ← same image
|
|
10
|
+
ROLE=sync myapp
|
|
11
|
+
ROLE=worker myapp
|
|
12
|
+
ROLE=scheduler myapp
|
|
13
|
+
ROLE=migrate myapp # pre-deploy hook
|
|
14
|
+
ROLE=replicator myapp
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Roles
|
|
18
|
+
|
|
19
|
+
| Role | Does | Scales on | Notes |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| `web` | SSR + static + RPC (actions/queries over HTTP) | **RPS** | behind CDN, stateless, N replicas, no local state |
|
|
22
|
+
| `sync` | live queries + fanout over WebSockets | **concurrent connections** | stateless, **no sticky sessions** — a client may reconnect to any node |
|
|
23
|
+
| `worker` | jobs + steps | **queue demand** (`queue_demand`: held plus due) | one pool per queue served; `WORKER_QUEUES=default,integrations` claims exactly those and nothing else, unset claims `jobs.queues` plus every registered job's queue ([Configuration](Configuration#jobs)) |
|
|
24
|
+
| `scheduler` | cron dispatch → enqueue only | **fixed 1** | leader election is an expiring row in `x_scheduler_leader` (`postgresLeaseLeader`), never an advisory lock — that grant is session-scoped and the executor is a pool. A second instance is a warm standby, not a duplicate |
|
|
25
|
+
| `migrate` | run-once, pre-deploy | n/a | applies migrations through the ledger and **exits**; never binds a port. Holds the migration advisory lock on one pinned session for the whole run, so overlapping deploys serialise — the second waits, polling once per 500ms for up to 60s, then exits non-zero with `X_MIGRATE_CONCURRENT` rather than hanging the rollout (`As of 2026-08`; 1.2.0 waits forever — [Known gaps](Known-Gaps)) |
|
|
26
|
+
| `replicator` | logical replication → change feed → matcher → NATS | **1 per database** | owns the replication slot; a second instance would double-deliver, so it takes an advisory lock — a container that loses it stays up, `/readyz` 503, and takes over when the holder goes (`x dev --role replicator` refuses with `X_REPLICATOR_SLOT_HELD` instead) |
|
|
27
|
+
|
|
28
|
+
- No role holds durable state. Everything survivable is in Postgres, NATS, or object storage.
|
|
29
|
+
- Only `migrate` **exits non-zero with a typed error** when its lock is held (`X_MIGRATE_CONCURRENT`), and `x dev --role replicator` refuses (`X_REPLICATOR_SLOT_HELD`). A `scheduler` or `replicator` container that does not hold its lease or lock stays up as a standby — unready, retrying — rather than running degraded or double-delivering.
|
|
30
|
+
- **There is no `ROLE=all`.** Those six names are the whole set; anything else is `X_ROLE_UNKNOWN` at boot. For dev, `x dev` co-locates `web`, `sync`, `worker` and `scheduler` in one process — role isolation is simulated, not skipped, and `--role` opts the `replicator` in.
|
|
31
|
+
|
|
32
|
+
`PORT` selects the bind port, default `3000`. Empty or whitespace falls back to the default; anything else must be an integer in 0–65535 or the process refuses with `X_PORT_INVALID` rather than quietly binding 3000 and failing the platform's health probe with nothing in the log that names the cause. The production entry is the scaffolded `apps/web/server.ts` → [CLI reference](CLI-Reference).
|
|
33
|
+
|
|
34
|
+
## Health endpoints
|
|
35
|
+
|
|
36
|
+
**Every serving role answers both.** `web` and `sync` on their HTTP server; `worker`, `scheduler` and `replicator` construct none and answer them on the metrics listener (`METRICS_PORT`, default 9090, `packages/cli/src/metrics-endpoint.ts`) with the verdict only — `{ state, ready, role }`, never the check names. There `/readyz` is 503 until the role has started and from the first instant of a drain, which is what the chart's readiness probe on those roles reads. Both endpoints return a body, never a bare `200 OK`.
|
|
37
|
+
|
|
38
|
+
| Endpoint | Answers | 503 when | Consumer |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| `/healthz` | "is this process alive?" | the lifecycle is `stopped`. Ignores the readiness checks on purpose: a database outage that failed liveness fleet-wide restarts every pod into the same outage, cold | liveness probe → restart |
|
|
41
|
+
| `/readyz` | "should traffic come here?" | starting, **draining**, stopped, or any registered check answers `failing` | readiness probe → remove from rotation |
|
|
42
|
+
|
|
43
|
+
The body is `{ state, ready, role }` for everyone, and `{ state, ready, uptimeMs, inflight, buildId, checks, registered, role }` — `checks` a **map** of name → `ok` / `failing` — for a peer `configureHttp({ healthDetailPeers })` lists (default `['loopback']`: `kubectl exec`, a port-forward, the container's own healthcheck). The status code is the same for both, and is all a probe reads. A published container port is NOT loopback: the caller arrives from the bridge gateway, so list `'private'` to read the detail through `-p`. The `sync` role's own port answers by the same list, and tells a request carrying `Forwarded` or `X-Forwarded-For` the verdict only.
|
|
44
|
+
|
|
45
|
+
| Registered check | When it exists | What it reads |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `database` | always | the previous `db.ping()`, refreshed on read — never awaited inside the probe |
|
|
48
|
+
| `transport` | only when the transport can report a connection: NATS can, the in-process bus cannot | `transport.connected`. `failing` on `sync` and the replicator; **`degraded`** on `web`, `worker` and `scheduler`, which only publish to the bus and serve without it (`As of 2026-10`) |
|
|
49
|
+
| anything per-role | **never** `As of 2026-08-22` | this table used to list five — replication lag, migration-version skew, "one pool claiming", "holds the leader lock", "slot active" — and none was wired. The one since is `replicator` (`As of 2026-10-02`: failing while its stream is down). A `scheduler` standby does not report not-ready: its `/readyz` answers exactly as the leader's |
|
|
50
|
+
|
|
51
|
+
**`health: { readiness: 'process' }` in `app.config.ts`** (`As of 22.5`; default `'dependencies'`). Every replica shares the database, so one blip fails `database` everywhere at once and the ingress answers "no available server" for the whole site — pages that never touch the database included. In `'process'` mode `/readyz` is 503 only while starting, draining or stopped; a failing check stays 200 with `ready: true` and the check still named `failing` in `checks`. `/readyz?deep=1` always answers in `'dependencies'` mode — point monitoring there. Web and sync roles alike; liveness is unchanged in both modes.
|
|
52
|
+
|
|
53
|
+
**A check has three answers: `ok`, `degraded`, `failing`** (`As of 2026-10`). `degraded` is a dependency the role serves without — today, the bus on a role that only publishes to it. It never turns `/readyz` into a 503, in either mode, so a NATS outage cannot pull every web pod from the ingress; `/readyz?deep=1` is strict and answers 503 for it. An app registers its own with `registerReadinessCheck(name, check, { onFailure: 'degraded' })`.
|
|
54
|
+
|
|
55
|
+
**Which build answers is a header, on every health answer** (`As of 27.2.3`). `/healthz`, `/readyz` and `/readyz?deep=1` send `x-ultimate-build` — the same header every page sends — on the web port, the sync port and the metrics listener alike, whoever asks. The body does not change: a stranger still reads `{ state, ready, role }`. A deploy check that waits for the new build runs [`x deploy verify --url https://<host>`](CLI-Reference#x-deploy-verify) (`As of 30.0.0`): it samples this header until the build holds, then grades the queues. Two cases send none: a web role whose `buildId` is `null` (no `BUILD_ID` and none from the boot), and the metrics listener in the moments before its boot has computed the id — a container opens it first, so a cold pod's probe gets the status without the header.
|
|
56
|
+
|
|
57
|
+
**`registered: 0` is a real answer, not an error.** An empty registry is still ready, so a 200 means no more than "the socket is bound" — which is what the chart's and compose's healthchecks route traffic on. Read `registered` before you trust `checks: {}`.
|
|
58
|
+
|
|
59
|
+
## Graceful drain on SIGTERM
|
|
60
|
+
|
|
61
|
+
Identical in every role. Framework behavior, not a deployment guide.
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
SIGTERM
|
|
65
|
+
1. /readyz → 503 (web, sync: the listener stays open for drain.readinessGraceMs —
|
|
66
|
+
5 s outside development/test — so the LB stops routing here
|
|
67
|
+
first; every other role: no grace)
|
|
68
|
+
2. stop accepting new work (HTTP: close listener; worker: stop claiming; sync: refuse new upgrades)
|
|
69
|
+
3. finish in-flight work (bounded by drain.deadlineMs in app.config.ts, default 25 s)
|
|
70
|
+
4. role-specific handoff (see table)
|
|
71
|
+
5. flush OTel spans + logs
|
|
72
|
+
6. close pools, release advisory locks, exit 0
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`onShutdown` has three phases, `accept → inflight → close`. `accept` and `inflight` run hooks in
|
|
76
|
+
registration order. **`close` runs them last-registered first**, `As of 2026-11`: resources are released in
|
|
77
|
+
reverse of acquisition, and the boot starts the OTLP exporter before it imports the app, so the flush
|
|
78
|
+
is the last hook and a teardown's own spans still reach the collector.
|
|
79
|
+
|
|
80
|
+
Both numbers are `app.config.ts`'s `drain` section, applied to every role by the production boot
|
|
81
|
+
([Configuration](Configuration#drain)). The budget is what a running job gets on a deploy, so an
|
|
82
|
+
app with long jobs raises it there: `drain: { deadlineMs: 600_000 }`. The platform's kill timer
|
|
83
|
+
must outlast grace + budget — the Helm chart derives each role's `terminationGracePeriodSeconds`
|
|
84
|
+
from the values `x deploy --method helm` passes it; on Compose, `stop_grace_period` is yours.
|
|
85
|
+
|
|
86
|
+
| Role | Step 4 handoff |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `web` | let in-flight requests and streaming responses finish; a stream past the deadline gets a typed truncation, not a socket reset |
|
|
89
|
+
| `sync` | send every client a `reconnect` frame **with a per-client backoff delay** (see below), then close cleanly |
|
|
90
|
+
| `worker` | stop claiming at step 2 and **cancel nothing**: every held job keeps running, and one that finishes inside the budget is acked. At the cut-off — the worker's budget (`drain.workerDeadlineMs` when declared, else `drain.deadlineMs`) less a margin (2 s, or half of a budget under 4 s) — a job still running has its `ctx.signal` aborted with `X_DRAINING`; a step that completes after that is still recorded. Half a margin later every claim still held is handed back uncounted (`countsAsAttempt: false`), so the replacement worker claims it at once — not after the visibility timeout — and replays every completed step instead of re-running it. A worker started after the signal (a boot still in progress) claims nothing |
|
|
91
|
+
| `scheduler` | delete the lease row immediately so the standby promotes on its next round rather than waiting out the 30s TTL |
|
|
92
|
+
| `replicator` | flush the change feed to NATS up to the last confirmed LSN, then release the slot |
|
|
93
|
+
|
|
94
|
+
A hook or request still running at `drain.deadlineMs` (the worker's: `drain.workerDeadlineMs` when declared) is **abandoned** — logged as `X_SHUTDOWN_TIMEOUT` naming it, never thrown, and the process exits clean; requests arriving during the drain get `X_DRAINING`.
|
|
95
|
+
|
|
96
|
+
### Retiring a worker before SIGTERM
|
|
97
|
+
|
|
98
|
+
The SIGTERM path above already finishes held jobs — inside the worker's drain budget: `drain.deadlineMs` (at most 1 h), or `drain.workerDeadlineMs` (at most a day, the worker alone) when declared. A rollout or a scale-down on Kubernetes does not start there: `As of 30.0.0` the worker is **retired** first, for up to `drain.workerRetireMs` (an hour by default, `0` off, at most a day). The signal is **SIGUSR2** (`As of 25.0.0`, `ROLE=worker` only, `packages/cli/src/serve-retire.ts`).
|
|
99
|
+
|
|
100
|
+
| Step | What the worker does |
|
|
101
|
+
|---|---|
|
|
102
|
+
| SIGUSR2 | `isRetiring()` (`@ultimat3/core`) turns true for the rest of the process; logs `jobs.worker.retiring` (`inFlight`), stops claiming |
|
|
103
|
+
| held jobs | every one runs to the end, however long — nothing is aborted, nothing handed back |
|
|
104
|
+
| done | logs `jobs.worker.retired`, then the ordinary drain, then **exits 0** |
|
|
105
|
+
|
|
106
|
+
It exits rather than holds: the only thing a `preStop` can observe is PID 1 going away, and a worker that claims nothing has nothing left to serve. A second SIGUSR2 joins the first. One during boot waits for the worker, then retires it. A SIGTERM that lands mid-retire is the ordinary drain: the wait is bound to the worker's budget (`drain.workerDeadlineMs`, else `drain.deadlineMs`) and cut off at the margin, as in the table — so a worker whose jobs outlast the hour declares `drain.workerDeadlineMs`. `isDraining()` stays false until that drain; the app's own code (a heartbeat, a status row) reads `isRetiring()` to say "finishing, about to exit" — `x dev`'s restart, which retires the same way, sets it too. Outside a pod deletion an exit 0 is restarted by the Deployment like any other: a fresh worker.
|
|
107
|
+
|
|
108
|
+
**The Helm chart wires it behind one chart value**, `roles.worker.retireSeconds` in `values.yaml` (3600, on, by default). `x deploy --method helm` derives it from the app's `drain.workerRetireMs`, 0 included. The worker's `preStop` sends its PID 1 SIGUSR2 and waits for it to exit, for at most those seconds; then the SIGTERM lands with the drain budget left, so a job past the retire is cut off and handed back rather than SIGKILLed. The same seconds are added to its `terminationGracePeriodSeconds`, which the kubelet counts the `preStop` against. An idle worker exits at once, so the retire costs only while a job really holds a pod, and a Terminating pod does not count against rollout completion: new pods claim at once, and the rollout shows an old worker Terminating while it holds a job. `drain.workerDeadlineMs` is a different knob: it bounds a SIGTERM drain, then aborts; the retire aborts nothing. The hook is `bun -e`, the one binary every app image carries — the image's `ENTRYPOINT ["bun", "apps/web/server.ts"]` is exec form, so bun **is** PID 1. Outside the chart, the target is the same: `kill -USR2 1` inside the container, or `docker kill --signal USR2 <container>`.
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
helm upgrade app docker/helm --set roles.worker.retireSeconds=7200 \
|
|
112
|
+
--set drain.workerDeadlineSeconds=7500 # 2 h worker; `x deploy --method helm` sets both itself
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**`x verify` holds the job to it**: a job whose declared `timeout` exceeds the worker's budget (`drain.workerRetireMs` when above 0, else `drain.workerDeadlineMs`, else `drain.deadlineMs`) reds the `manifest` step with `X_JOB_OUTLASTS_DRAIN`, naming the job, its limit and the budget. A job with no `timeout` is not graded.
|
|
116
|
+
|
|
117
|
+
**Compose stays SIGTERM with `stop_grace_period`**: it has no `preStop`, and a SIGKILL mid-job is worse than a handed-back claim, so size `stop_grace_period` to the drain as below. It is the single-node rung, and makes no such claim → [scale ladder](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/17-scale-ladder.md).
|
|
118
|
+
|
|
119
|
+
**Windows has no SIGUSR2**: there the retire installs nothing and the worker stops only through the drain above (Ctrl-C, Ctrl-Break). A native Windows process is for development and CI.
|
|
120
|
+
|
|
121
|
+
### Which signals start the drain
|
|
122
|
+
|
|
123
|
+
Windows never sends SIGTERM to a console process, so the set is per platform — `drainSignals(platform)` in `@ultimat3/core`, installed by every role `As of 2026-10-05`:
|
|
124
|
+
|
|
125
|
+
| Host | Drains on | Not a drain |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| Linux, macOS, any container (Docker Desktop on Windows included — the image is Linux) | `SIGTERM`, `SIGINT` (Ctrl-C) | `SIGHUP`: a terminal hang-up, not a stop |
|
|
128
|
+
| native Windows (`x dev`, a local `x build --target binary` run) | `SIGTERM`, `SIGINT` (Ctrl-C), `SIGHUP` (the console window closing), `SIGBREAK` (Ctrl-Break) | `taskkill /F` and Task Manager's End task: no signal, an outright kill |
|
|
129
|
+
|
|
130
|
+
**Stop a native Windows process with Ctrl-C or Ctrl-Break**, and the drain log line names the signal (`{"msg":"draining","signal":"SIGBREAK"}`). Closing the console window also drains, inside the few seconds Windows allows before it kills the process. A native Windows binary is for development and CI. Deploying on a Windows host means the Linux image under Docker Desktop, where SIGTERM works as on any other host.
|
|
131
|
+
|
|
132
|
+
### Why `sync` sends server-directed jittered reconnect
|
|
133
|
+
|
|
134
|
+
Closing 50,000 sockets at once means 50,000 simultaneous reconnects, all resubscribing, all asking "what changed since my LSN?" — a self-inflicted DDoS landing during a deploy when capacity is already reduced, and it is fractal: surviving nodes overload, drop connections, and the herd re-forms.
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
{ "type": "reconnect", "v": 3, "afterMs": 1830, "reason": "drain" }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`ReconnectFrame` in `packages/realtime/src/sync-protocol.ts` — four fields, built by
|
|
141
|
+
`reconnectFrame()` (`thundering-herd.ts`); `v` is `PROTOCOL_VERSION`, `reason` one of `drain`,
|
|
142
|
+
`overload`, `rebalance`.
|
|
143
|
+
|
|
144
|
+
| Property | Effect |
|
|
145
|
+
|---|---|
|
|
146
|
+
| Per-client `afterMs`, jittered over a window | reconnects arrive spread out, not as a spike |
|
|
147
|
+
| The window is `syncNode({ drainSpreadMs })`, default 30 s | each socket draws its own delay inside it (`drainPlan`) |
|
|
148
|
+
| The resume point is the **client's**, not the frame's | the frame carries no cursor. On reopen the client sends `hello`, then one `subscribe` per live query carrying that query's own cursor, and re-announces every channel from its own; the node replays from its change buffer when the cursor is inside it and serves one snapshot when it is not (`live-resume.ts`) |
|
|
149
|
+
| Clients redistribute | the LB places them across remaining nodes; no sticky session to honour |
|
|
150
|
+
| Client-side backoff is a floor, not the mechanism | a client that loses the socket without a frame still backs off exponentially with jitter; one that got a frame waits its `afterMs` instead |
|
|
151
|
+
|
|
152
|
+
**`sync` takes both shutdown phases, and they answer different questions** `As of 2026-08`. The `accept` phase calls `stopAccepting()`: `/readyz` flips to 503 and an upgrade arriving anyway is shed with `retry-after-ms`, while every socket the node already holds keeps its patch stream — sockets are untouched and no `reconnect` frame has been sent yet. The `close` phase is the drain below, then `stop()`. Registered with no phase, the whole thing landed in `close`, and until that last phase ran the node went on upgrading new websockets onto a process that was going away.
|
|
153
|
+
|
|
154
|
+
There is no `realtime.drain` config key — the spread window is `syncNode({ drainSpreadMs })`, default 30s, and the grace is `drain({ graceMs })`, default 5s ([Configuration](Configuration)). The reconnect benchmark that gated topology now exists: 50,000 sockets, a `SIGKILL`ed `sync` node with **no** drain and no `reconnect` frame — all 50,000 reconnected on their own backoff, 49,981 received a channel patch inside the window at p50 54.0s / p90 105.5s, 156,851 connect attempts shed before any query path. That is time to the first patch on the reconnected socket — reachability, not consistency — and it is the floor this section's frame is meant to beat. Measured on **one** node ([Realtime](Realtime)).
|
|
155
|
+
|
|
156
|
+
## `x build`
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
x build --target docker # one image, all roles (default)
|
|
160
|
+
x build --target binary # a Bun-compiled launcher: no Bun on the host, but the app tree beside it
|
|
161
|
+
x build --target static # site/ output only: HTML, assets, sitemap, feeds
|
|
162
|
+
x build --target prebuilt # the line the Dockerfile runs INSIDE the image build
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
| Target | Output | Use |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| `docker` | one OCI image, `ROLE` selects behavior | the normal path |
|
|
168
|
+
| `binary` | `.x/app` — `bun build --compile` of `apps/web/server.ts`, `ROLE` selects behavior. A **launcher**, not a self-contained artifact: it embeds the Bun runtime, and at run time reads the app's `package.json` and `tsconfig.json`, imports `app.config.ts` and `apps/*`, and loads `@ultimat3/*` from the app's `node_modules` (kept external — `binaryArgs`, `packages/cli/src/cmd-build.ts`). Start it from the app root with the source and installed `node_modules` beside it. What CI proves `As of 2026-10-06`: the required `windows` job builds a scaffold with `x build --target binary`, starts `.x/app.exe` from the app root and needs `200` from `/healthz` (`.github/workflows/ci.yml`); on Linux, `packages/cli/e2e/binary-launch.e2e.test.ts` compiles a probe tree with the same `binaryArgs` and runs it — a probe, not a scaffold. **Not yet served from a bare VM** ([Known gaps](Known-Gaps)) | VMs, systemd, air-gapped, a CLI-shaped product |
|
|
169
|
+
| `static` | `.x/static` — 0kb-JS pages, hashed assets, `sitemap.xml`, `robots.txt`, feeds | CDN / object storage, deployed independently |
|
|
170
|
+
| `prebuilt` | `node_modules/.cache/ultimate/` — every island chunk and every compiled stylesheet. No gate, no subprocess, takes neither `--tag` nor `--out` | never by hand: it is `docker`'s other half, a `RUN` line in the image |
|
|
171
|
+
|
|
172
|
+
### What a pod boots from
|
|
173
|
+
|
|
174
|
+
**A pod builds nothing at boot.** `As of 2026-10-01`. The scaffolded Dockerfile carries, after `COPY . .`:
|
|
175
|
+
|
|
176
|
+
```dockerfile
|
|
177
|
+
RUN bun node_modules/@ultimat3/cli/src/bin.ts build --target prebuilt
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
| Rule | Why |
|
|
181
|
+
|---|---|
|
|
182
|
+
| it runs in the image build, never on the host | the store is valid for one Bun, one framework version and one set of absolute paths — the image's |
|
|
183
|
+
| above `ENV NODE_ENV=production` | it imports the app with no deployment environment; in production `app.config.ts` asks for values no image build has |
|
|
184
|
+
| above `ARG BUILD_ID` | two images differing only by their stamp share the layer |
|
|
185
|
+
| it writes under `node_modules/`, not `.x/` | `.x/` is state: every ignore file drops it and the compose topology mounts a tmpfs over it |
|
|
186
|
+
| a module that will not import fails the image build | its stylesheets would be missing, and every pod would compile them |
|
|
187
|
+
|
|
188
|
+
An image without the line still serves. Every boot then runs Babel over every island and Sass over
|
|
189
|
+
every stylesheet, and logs one `error` line saying so — `X_IMAGE_NOT_PREBUILT`, with the counts,
|
|
190
|
+
the milliseconds and the line above. Measured on the demo app's image, `ROLE=web`, four boots each:
|
|
191
|
+
|
|
192
|
+
| | time to ready | boot CPU | peak RSS | settled RSS |
|
|
193
|
+
|---|---|---|---|---|
|
|
194
|
+
| no store | 4.2–4.8 s | 8.3–9.5 CPU-s | 244–269 Mi | 215–220 Mi |
|
|
195
|
+
| prebuilt | 1.6–1.7 s | 1.8–2.0 CPU-s | 141–147 Mi | 97–102 Mi |
|
|
196
|
+
|
|
197
|
+
**An app scaffolded before 23.0.0 does not have the line** — its `docker/Dockerfile` is the app's
|
|
198
|
+
own file. Add it; the boot log names it until you do ([Upgrading](Upgrading)).
|
|
199
|
+
|
|
200
|
+
All targets share one build ID (content hash), stamped into the image, the HTML, the assets, `x.manifest.json` — **and `sw.js`**, `As of 2026-08`: the worker's cache names carry the build id, so a deploy retires the previous build's caches instead of serving them ([#390](https://github.com/developerz-ai/ultimate/issues/390)). Every target that serves or writes a document emits `manifest.webmanifest`, `sw.js` and `x-sw-register.js` alike ([PWA and offline](PWA-And-Offline)).
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
$ x build --target docker
|
|
204
|
+
✓ typecheck + boundaries ✓ site/ 12 routes static 0kb js
|
|
205
|
+
✓ app/ 31 routes stream ✓ static assets avif+webp, 214 files
|
|
206
|
+
✓ manifest + openapi emitted ✓ image myapp:8f2a1c9 118MB
|
|
207
|
+
build id 8f2a1c9
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`x build` runs six of `x verify`'s steps first — `typecheck`, `lint`, `boundaries`, `filesize`, `package-shape`, `errors` — and produces no artifact if any fail. It also refuses before spawning the builder when the target's entry file is missing (`X_BUILD_ENTRY_MISSING`) → [CLI reference](CLI-Reference).
|
|
211
|
+
|
|
212
|
+
## Dev compose
|
|
213
|
+
|
|
214
|
+
```yaml
|
|
215
|
+
# docker/docker-compose.dev.yml — only needed for parity checks; `x dev` needs none of this
|
|
216
|
+
services:
|
|
217
|
+
db: { image: postgres:17-alpine, ports: ['127.0.0.1:5432:5432'] }
|
|
218
|
+
nats: { image: nats:2-alpine, command: ['-js'], ports: ['127.0.0.1:4222:4222'] }
|
|
219
|
+
s3: { image: versity/versitygw:v1.8.0, ports: ['127.0.0.1:9000:9000'], volumes: ['s3bucket:/data/myapp'] }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`x dev` uses embedded Postgres, in-process NATS, and a local directory for S3 — **Docker is not required to develop.** This file exists for parity debugging: three backing services and no `app` service, because the app in development is `x dev` on the host, pointed at them by environment (the file's header is the line to paste).
|
|
223
|
+
|
|
224
|
+
| Service | Rule |
|
|
225
|
+
|---|---|
|
|
226
|
+
| every port | binds `127.0.0.1` — the credentials are in the file |
|
|
227
|
+
| `s3` | Versity S3 Gateway over a directory. A directory under `/data` is a bucket, so the volume mounted at `/data/<app>` **is** the bucket: ready on the first `up`, no CreateBucket call |
|
|
228
|
+
| `S3_REGION` | not needed against it: an unset region signs for `auto`, and the gateway is started with that region. An endpoint that expects another refuses the first write with `X_CONFIG_INVALID`, naming the value to set |
|
|
229
|
+
| production | any S3-compatible endpoint. This file picks a server for a laptop, never for a deployment |
|
|
230
|
+
|
|
231
|
+
## Prod compose
|
|
232
|
+
|
|
233
|
+
```yaml
|
|
234
|
+
# docker/docker-compose.prod.yml
|
|
235
|
+
x-app: &app
|
|
236
|
+
image: myapp:${BUILD_ID}
|
|
237
|
+
env_file: [../.env.production] # what the CONTAINERS see — not what `${VAR:?}` reads
|
|
238
|
+
restart: unless-stopped
|
|
239
|
+
|
|
240
|
+
services:
|
|
241
|
+
migrate: { <<: *app, environment: { ROLE: migrate }, restart: 'no' }
|
|
242
|
+
web: { <<: *app, environment: { ROLE: web, PORT: 3000 }, ports: ['3000:3000'],
|
|
243
|
+
deploy: { replicas: 1 },
|
|
244
|
+
depends_on: { migrate: { condition: service_completed_successfully } } }
|
|
245
|
+
# PORT is the WEB port even here: `sync` binds PORT + 1, so 3000 is a listener on 3001.
|
|
246
|
+
sync: { <<: *app, environment: { ROLE: sync, PORT: 3000 }, ports: ['3001:3001'],
|
|
247
|
+
deploy: { replicas: 1 } }
|
|
248
|
+
# WORKER_QUEUES is the EXACT set a worker claims (25.0.0): `integrations` runs on its own
|
|
249
|
+
# service, so the other worker names every remaining queue itself. Unset, a worker claims
|
|
250
|
+
# jobs.queues plus every queue a registered job is enqueued on; set, `jobs.worker.queue-unserved`
|
|
251
|
+
# at boot names the registered queues this one leaves to another.
|
|
252
|
+
worker: { <<: *app, environment: { ROLE: worker, WORKER_QUEUES: 'myapp-default,default' },
|
|
253
|
+
deploy: { replicas: 4 } }
|
|
254
|
+
worker-integrations: { <<: *app, environment: { ROLE: worker, WORKER_QUEUES: integrations },
|
|
255
|
+
deploy: { replicas: 1 } }
|
|
256
|
+
scheduler: { <<: *app, environment: { ROLE: scheduler }, deploy: { replicas: 1 } }
|
|
257
|
+
replicator: { <<: *app, environment: { ROLE: replicator }, deploy: { replicas: 1 } }
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
| Rule | Reason |
|
|
261
|
+
|---|---|
|
|
262
|
+
| `migrate` completes before `web`/`sync` start | a new schema must exist before new code reads it |
|
|
263
|
+
| `web` and `sync` at 1 replica | each publishes a host port, and a host port has exactly one binder |
|
|
264
|
+
| `PORT: 3000` on `sync`, published as `3001:3001` | the `sync` role binds `PORT + 1`; naming 3001 opens 3002 and publishes a socket nothing in the container ever opened |
|
|
265
|
+
| `SYNC_URL=ws://<host>:3001/_x/sync` in `.env.production`, read through `--env-file` | a page dials `/_x/sync` on its own origin by default, and on this rung that is `web`'s port, which does not serve the socket. With no proxy in front, only the deployment knows where `sync` is published. The shipped compose file refuses to start `web` without it (`${SYNC_URL:?…}`). Must be `ws://` or `wss://`, else `X_CONFIG_INVALID` at boot. Since 21.0.0. |
|
|
266
|
+
| `APP_URL=http://<host>:3000` in `.env.production`, reaching `sync` | the sync node admits a socket from `APP_URL`'s origin and no other once one is declared — scheme, host and port, exactly; with none declared, from the origin it was reached on. On this rung the page is `:3000` and the socket `:3001`, two origins, so without it every upgrade is `403 X_SOCKET_ORIGIN_REFUSED`. The shipped compose file refuses to start `sync` without it (`${APP_URL:?…}`). One proxy serving both on one host needs none (a PaaS: a node reached over plain http admits the `https` spelling of that host and port), and the Helm chart sets `APP_URL` on the sync role from `ingress.host` |
|
|
267
|
+
| `POSTGRES_PASSWORD` in `.env.production`, read through `--env-file` | `x new`'s compose file runs its own `db` service and refuses to start it without one (`${POSTGRES_PASSWORD:?…}`) |
|
|
268
|
+
| every command passes `--env-file .env.production`, before `-f` | Compose interpolates `${VAR:?…}` from the shell and `--env-file` only, **never** from a service's `env_file:`. Without the flag a value set only in `.env.production` reads as missing and the parse fails. `x deploy` passes it on every step (`packages/cli/src/cmd-deploy.ts`, `PROD_ENV_FILE`); a variable set in the shell still wins |
|
|
269
|
+
| `scheduler` and `replicator` at 1 replica | leader lock makes a second one a standby, not throughput |
|
|
270
|
+
| `stop_grace_period` ≥ `drain.readinessGraceMs` + `drain.deadlineMs` + 10 s (`40s` ships, for the defaults); the worker service's ≥ `drain.workerDeadlineMs` + 10 s when declared | otherwise SIGKILL truncates the drain and the reconnect fanout. Raise it with `drain.deadlineMs` (the worker's with `drain.workerDeadlineMs`) — nothing derives it on this rung |
|
|
271
|
+
| roles that publish no host port are rolled **start-first** | `x deploy` scales the new container up beside the old with `--no-recreate`, waits for its healthcheck, then stops and removes the old one; `web` and `sync` publish one and are recreated stop-first until a proxy fronts them ([`docs/ops/README.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/ops/README.md)) |
|
|
272
|
+
| Health probes from `/readyz` | never from a TCP check — a process can accept sockets while unable to serve |
|
|
273
|
+
|
|
274
|
+
`x deploy --method compose` applies this against the committed `docker/docker-compose.prod.yml`; it is a plain compose file you can read, diff, and run by hand — from the app root, as `docker compose --env-file .env.production -f docker/docker-compose.prod.yml up -d`.
|
|
275
|
+
|
|
276
|
+
**`.env.production` has one writer**, `x env init --production` (`As of 30.0.0`): it fills every `${VAR:?}` above, a `DATABASE_URL` naming the compose `db` (role `postgres`, database the app name, the generated `POSTGRES_PASSWORD`), and every secret the boot refuses to start without — then `x deploy` reads it before the first step and names what is missing (`X_DEPLOY_ENV_MISSING`). The three commands, executed on every push by the `compose-deploy` CI job: `x env init --production`, `x build --target docker`, `x deploy` ([Deploy recipes](Deploy-Recipes#one-box-compose)).
|
|
277
|
+
|
|
278
|
+
**The `/app/.x` tmpfs is owned by the image's user** (`As of 30.0.0`): `'/app/.x:uid=1000,gid=1000'` in the scaffold (its `USER 1000:1000`), `uid=65532` in the framework's specimen (distroless `nonroot`). A tmpfs mounts root-owned under `read_only: true`, so before this every role refused at boot with `X_STORAGE_UNWRITABLE … EACCES mkdir '/app/.x/storage'`. An app scaffolded earlier edits that one line.
|
|
279
|
+
|
|
280
|
+
> **`web` and `sync` are one replica each, and the file says so** `As of 2026-08`. Both publish a host port, one host port has exactly one binder, so both declare `replicas: 1` — a ceiling that is declared rather than discovered when the second container dies on `port is already allocated`. `worker`, `scheduler` and `replicator` publish nothing; `worker` scales freely.
|
|
281
|
+
>
|
|
282
|
+
> | Want | Do |
|
|
283
|
+
> |---|---|
|
|
284
|
+
> | more `web`/`sync` on the same box | delete their `ports:` lines, add a reverse proxy of your choosing to the compose file, point it at the service names — compose DNS resolves each to every replica |
|
|
285
|
+
> | more `web`/`sync`, full stop | the Helm chart below, which carries a per-role HPA and an Ingress |
|
|
286
|
+
>
|
|
287
|
+
> The framework ships neither proxy: a proxy image in `docker-compose.prod.yml` is a dependency every app inherits and a second answer to "how does traffic reach a role" beside the chart's Ingress. This is the rung-1 ceiling → [`docs/idea/17-scale-ladder.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/17-scale-ladder.md).
|
|
288
|
+
|
|
289
|
+
## Helm
|
|
290
|
+
|
|
291
|
+
**`x new` writes it**, `As of 2026-08-19` — `docker/helm`, one `Deployment` per role, 8 files: `Chart.yaml`, `values.yaml` and 6 templates. There is still no `--helm` flag on `x build`; the chart is a scaffold artifact like `docker/Dockerfile`, not a build target.
|
|
292
|
+
|
|
293
|
+
| Role | HPA metric | Typical range | Notes |
|
|
294
|
+
|---|---|---|---|
|
|
295
|
+
| `web` | requests/sec (or CPU as fallback) | 3–50 | behind Ingress + CDN; `terminationGracePeriodSeconds` derived per role from `drain.*` in values (45 s web/sync, 35 s the rest, by default) |
|
|
296
|
+
| `sync` | **active WS connections** (custom metric) | 2–100 | no session affinity; connection count is the only honest signal |
|
|
297
|
+
| `worker` | **queue demand** per named queue (`queue_demand`, External metric) | 2–200 | one Deployment per queue when isolation matters |
|
|
298
|
+
| `scheduler` | none — `replicas: 1` | 1 | `PodDisruptionBudget` maxUnavailable 1, leader lock covers overlap |
|
|
299
|
+
| `replicator` | none — `replicas: 1` | 1 | owns the slot; `strategy: Recreate`, because a surged pod can only stand by unready and `maxUnavailable: 0` would never retire the holder |
|
|
300
|
+
| `migrate` | n/a | — | pre-install/pre-upgrade `Job` hook; blocks the release on failure |
|
|
301
|
+
|
|
302
|
+
CPU autoscaling is wrong for `sync` and `worker`: a node holding 80k idle sockets is near-zero CPU and near-capacity, and a worker blocked on a slow HTTP call is idle CPU with a growing backlog. The framework **declares** both series — `connections` and `queue_demand`, with `rps` derived from the monotonic `http_requests_total` — and `SCALING_METRICS` maps each role's signal to its series so the chart and the role table cannot drift. `As of 2026-08` every role serves `/metrics` on `METRICS_PORT` (default 9090) and `http`/`realtime`/`jobs` call the recorders, so the signals exist. **The chart's half is closed in 2.0.0**: `values.yaml` declares `metricsPort: 9090`, `_helpers.tpl` emits a container port named `metrics` on every role but `migrate`, `service.yaml` publishes it by name and `templates/servicemonitor.yaml` ships the scrape target. `serviceMonitor.enabled` is `auto`: the object renders wherever the cluster serves the Prometheus operator's kind and is skipped where it does not, so `helm install` never fails on an unknown kind — and `templates/alert-rules.yaml` ships the mechanism alerts the same way (`alerts.enabled: auto`). One thing remains, and it is not the chart's: turning scraped series into the `Pods` metrics an HPA reads needs a **custom-metrics adapter**. Do not hand-add a metrics container port — the chart already emits one and a duplicate is rejected by the API server → [Observability](Observability).
|
|
303
|
+
|
|
304
|
+
`x deploy --method helm` works in a fresh app `As of 2026-08-19`: the command implements helm
|
|
305
|
+
completely and its `X_NOT_IMPLEMENTED` branch — which claimed the *build* did not implement it, over
|
|
306
|
+
a build that did — is deleted. An app that deleted its chart now gets helm's own error. The image
|
|
307
|
+
goes in as `--set-string image.repository=… --set-string image.tag=…` `As of 2026-10`: a plain
|
|
308
|
+
`--set` typed an all-digit tag (a build number) as an integer and rendered `app:%!s(int64=…)`.
|
|
309
|
+
`As of 2026-10-05` it also passes the chart values `--set drain.deadlineSeconds=… --set
|
|
310
|
+
drain.readinessGraceSeconds=…`,
|
|
311
|
+
read off the app's own `drain` config, so the chart's grace periods follow a raised budget. Every role carries a readiness probe on `/readyz` (worker and scheduler on
|
|
312
|
+
the metrics port), every Deployment `minReadySeconds: 10`, and every rolling role keeps
|
|
313
|
+
`maxUnavailable: 0`.
|
|
314
|
+
|
|
315
|
+
The framework repo's own [`docker/helm`](https://github.com/developerz-ai/ultimate/tree/main/docker/helm)
|
|
316
|
+
carries one template the scaffold does not — `pdb.yaml`, which is cluster policy. It ships in no
|
|
317
|
+
npm tarball, so taking it is a `git clone` of this repo. The monitoring pair (`servicemonitor.yaml`,
|
|
318
|
+
`alert-rules.yaml`) is in both charts; an app chart written before them gets both from
|
|
319
|
+
`x g chart-alerts`. Its `image.repository` is a placeholder
|
|
320
|
+
(`registry.example.com/your-app`) — the framework publishes no app image — and an emptied one is a
|
|
321
|
+
render error naming the key. On 3.0.0 and below, `x new` writes no
|
|
322
|
+
chart at all and `--method helm` exits `X_NOT_IMPLEMENTED`: copy the chart in, or use
|
|
323
|
+
`--method compose`.
|
|
324
|
+
|
|
325
|
+
## Routing a proxy to the `sync` role
|
|
326
|
+
|
|
327
|
+
`As of 26.1.0`. A proxy in front of both roles (an Ingress, a Cloudflare tunnel, nginx, an ALB)
|
|
328
|
+
sends the `sync` role exactly one path family, and everything else to `web`:
|
|
329
|
+
|
|
330
|
+
| Route to | Exactly | Regex |
|
|
331
|
+
|---|---|---|
|
|
332
|
+
| `sync` | the socket, `/_x/sync` — and anything under `/_x/sync/`, which the framework reserves for it | `^/_x/sync(/.*)?$` |
|
|
333
|
+
| `web` | everything else — pages, actions, queries, `/_x/assets/…`, `/islands/…`, `/healthz` | — |
|
|
334
|
+
|
|
335
|
+
**Anchor the rule.** `^/_x/sync` with no end anchor is the rule an operator writes first, and it is
|
|
336
|
+
safe as of 26.1.0 only because nothing else the framework serves starts with that string. Through
|
|
337
|
+
26.0.0 the sync worker lived at `/_x/sync-worker/<hash>.js`: that rule sent it to the sync role,
|
|
338
|
+
which serves only the socket, so no page got its shared worker (no realtime), and the 404 came back
|
|
339
|
+
with no `cache-control`, which a CDN pinned for its default TTL (Cloudflare: four hours).
|
|
340
|
+
|
|
341
|
+
**Every framework-served, content-addressed script lives under one asset prefix, `/_x/assets/`** —
|
|
342
|
+
the Rails rule: `<prefix>/<name>/<sha>.js`, served `immutable`, a new URL per build:
|
|
343
|
+
|
|
344
|
+
| Script | URL |
|
|
345
|
+
|---|---|
|
|
346
|
+
| the sync worker | `/_x/assets/sync-worker/<hash>.js` |
|
|
347
|
+
| the page boot | `/_x/assets/page-boot/<hash>.js` |
|
|
348
|
+
| the client router | `/_x/assets/navigation/<hash>.js` |
|
|
349
|
+
|
|
350
|
+
Island chunks (`/islands/…`), surface stylesheets and `asset()` files have prefixes of their own; no
|
|
351
|
+
live endpoint shares any of them. A miss on any content-addressed path is `404` with
|
|
352
|
+
`cache-control: no-store` — a CDN never keeps it — and so is every answer of the `sync` role that is
|
|
353
|
+
not the socket (a stray path, a plain GET of the socket path, a shed upgrade). Old `/_x/sync-worker/`,
|
|
354
|
+
`/_x/page-boot/` and `/_x/navigation/` URLs are not served: each named one build's file, which no
|
|
355
|
+
other build serves either, and a tab mid-roll that already started its worker keeps it.
|
|
356
|
+
|
|
357
|
+
## Behind a proxy — `TRUSTED_PROXY_HOPS`
|
|
358
|
+
|
|
359
|
+
`TRUSTED_PROXY_HOPS` counts the proxies that **append** to `x-forwarded-for` between the client and
|
|
360
|
+
`web`/`sync` → [Configuration](Configuration). Unset trusts no proxy header. Proxies treat the two
|
|
361
|
+
headers differently, and the reader follows that:
|
|
362
|
+
|
|
363
|
+
| Header | What proxies do | What the framework reads |
|
|
364
|
+
|---|---|---|
|
|
365
|
+
| `x-forwarded-for` → `ctx.ip` | **append** their peer (nginx `$proxy_add_x_forwarded_for`, an ALB, Envoy) | the entry `hops` from the right. A shorter list is not the declared chain: nothing is trusted and the socket address is used |
|
|
366
|
+
| `x-forwarded-proto` → `ctx.https` (HSTS, the CSRF self-origin) | usually **overwrite** (nginx `$scheme`, an ALB, Traefik); some append | a full list: the entry `hops` from the right. A **shorter** list: its first entry — an overwrite erased what the client sent, so what is left was written by a trusted hop |
|
|
367
|
+
|
|
368
|
+
As of 2026-10, two hops with an overwriting proto proxy read `https`, so HSTS goes out. Before, a
|
|
369
|
+
one-entry proto header behind `TRUSTED_PROXY_HOPS=2` counted as "not the chain": `ctx.https` was
|
|
370
|
+
false and no HSTS was sent.
|
|
371
|
+
|
|
372
|
+
**Make sure at least one trusted hop writes `x-forwarded-proto`.** If none does, the client's own
|
|
373
|
+
value reaches the app, exactly as it always has with `TRUSTED_PROXY_HOPS=1`. The edge that ends TLS
|
|
374
|
+
is the one to set it: nginx `proxy_set_header X-Forwarded-Proto $scheme;`. An inner proxy reached
|
|
375
|
+
over plain http must pass the edge's value on rather than writing its own `$scheme` — on
|
|
376
|
+
ingress-nginx, `use-forwarded-headers: "true"`.
|
|
377
|
+
|
|
378
|
+
## Through a connection pooler
|
|
379
|
+
|
|
380
|
+
`DATABASE_POOLER=session|transaction` when a pooler sits between the app and Postgres, unset when
|
|
381
|
+
none does. `As of 2026-11-02`. The client then sends neither bound as libpq `options`, and the bounds
|
|
382
|
+
become the server's:
|
|
383
|
+
|
|
384
|
+
```sql
|
|
385
|
+
-- direct to the server, per database user; numbers per role: docs/ops/04-datastores.md
|
|
386
|
+
alter role <user> set statement_timeout = '10s';
|
|
387
|
+
alter role <user> set idle_in_transaction_session_timeout = '30s';
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Then reopen the pooler's server connections: a role setting applies at login.
|
|
391
|
+
|
|
392
|
+
| Role | Under `DATABASE_POOLER=transaction` |
|
|
393
|
+
|---|---|
|
|
394
|
+
| `web`, `sync`, `scheduler` | runs |
|
|
395
|
+
| `worker` | runs on the poll: no wake, pickup within `idlePollMaxMs`, `jobs.wake.unverified` once. For the wake, a direct or session-pooled `DATABASE_URL` and `DATABASE_POOLER` unset or `session` for that role |
|
|
396
|
+
| `migrate`, `replicator`, `x db migrate` | `X_DB_POOLER_SESSION_REQUIRED`, before a socket: point `DATABASE_URL` at the server's own port and unset the switch for that role |
|
|
397
|
+
|
|
398
|
+
`x doctor --json` reports the bound as the session reads it (`data.statementBound`). A bound that
|
|
399
|
+
is gone is `X_DB_STATEMENT_BOUND_LOST`, one error line at boot and a finding, never a refusal. The
|
|
400
|
+
table per mode: [`docs/ops/04-datastores.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/ops/04-datastores.md#pooling-and-the-trap-under-it).
|
|
401
|
+
|
|
402
|
+
## Compression
|
|
403
|
+
|
|
404
|
+
The container gzips a public text answer of 1 KiB to 256 KiB with a declared length. Nothing to configure, and nothing to add in front.
|
|
405
|
+
|
|
406
|
+
| Situation | What happens |
|
|
407
|
+
|---|---|
|
|
408
|
+
| a proxy or CDN in front | changes nothing: a coded response is not re-coded, and `vary: accept-encoding` keys its copies |
|
|
409
|
+
| a private answer (`private`, `no-store`, `no-cache`), and any signed-in answer that is not `immutable` | identity, always. BREACH: a coded length would measure a secret beside attacker input |
|
|
410
|
+
| a handler streaming public text it must not buffer | sets `cache-control: no-transform`, or declares no `content-length` |
|
|
411
|
+
| a `stream()` body | never read: it declares no length |
|
|
412
|
+
| an action's `text/event-stream` answer (`stream:`, `llm()`, `agent()`) | never coded or buffered: the type is off the list, it is `no-store`, and it sends `x-accel-buffering: no` for an nginx in front |
|
|
413
|
+
|
|
414
|
+
Rule and figures: [`docs/architecture/16-build-pipeline.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/architecture/16-build-pipeline.md#what-is-compressed).
|
|
415
|
+
|
|
416
|
+
## Static deploys independently
|
|
417
|
+
|
|
418
|
+
```
|
|
419
|
+
x build --target static # dist in .x/static — upload it with your CDN's own tool
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
| Property | Consequence |
|
|
423
|
+
|---|---|
|
|
424
|
+
| Static build does not include the app image | a copy change, a new blog post, a pricing tweak **does not redeploy the API** |
|
|
425
|
+
| Independent version, content-addressed files | a host keeps earlier exports' hashed files because nothing deletes them, so an open tab still resolves its assets. The container needs `assets.retain` for the same ([Rollback](#hashed-assets-across-a-deploy--assetsretain)) |
|
|
426
|
+
| ISR pages regenerate server-side and push to the CDN | no full rebuild for one changed record |
|
|
427
|
+
| Rollback is a pointer swap | seconds, no container churn |
|
|
428
|
+
| Cache purge | tag-driven, one hop from the write ([Caching and invalidation](Caching-And-Invalidation)) |
|
|
429
|
+
|
|
430
|
+
## Targets
|
|
431
|
+
|
|
432
|
+
The only requirement: **something that runs containers, plus Postgres.** NATS and object storage are optional in small deployments — Postgres covers queue and pubsub; a local volume covers files.
|
|
433
|
+
|
|
434
|
+
| Target | How | Notes |
|
|
435
|
+
|---|---|---|
|
|
436
|
+
| Hetzner + Compose | `compose.prod.yml` on one or two boxes | cheapest credible production; one node runs all roles |
|
|
437
|
+
| Fly.io | one app per role | drain semantics map cleanly to Fly's SIGTERM handling; settings per host: [Deploy recipes](Deploy-Recipes) |
|
|
438
|
+
| Railway / Render | one service per role, same image | set `ROLE` per service |
|
|
439
|
+
| AWS ECS / Fargate | one task definition per role | ALB for `web`, NLB for `sync` |
|
|
440
|
+
| Any Kubernetes | the generated Helm chart | EKS, GKE, AKS, k3s — no cloud-specific resources |
|
|
441
|
+
| Bare VM | `--target binary` + systemd units per role | no container runtime at all |
|
|
442
|
+
|
|
443
|
+
Not supported, **by design**: vendor edge runtimes, serverless-function-per-route, vendor KV / queue / cron primitives, proprietary image loaders. Each would need a second implementation of a framework primitive, and the second implementation is where behavior diverges.
|
|
444
|
+
|
|
445
|
+
## Release checklist
|
|
446
|
+
|
|
447
|
+
```
|
|
448
|
+
x verify # the gate — green means shippable
|
|
449
|
+
x build --target docker
|
|
450
|
+
ROLE=migrate <image> # pre-deploy, must exit 0
|
|
451
|
+
<roll web + sync> # drain-aware; clients reconnect with backoff
|
|
452
|
+
x build --target static # independently, whenever copy changes
|
|
453
|
+
x doctor --json # env, versions, drift, ports
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
## Running it for real — `docs/ops/`
|
|
457
|
+
|
|
458
|
+
The framework depends on none of this; it is the operations manual for the app you deploy with it. **Recommendations, not contracts** — nothing in `packages/` reads a word of it.
|
|
459
|
+
|
|
460
|
+
| Doc | Answers |
|
|
461
|
+
|---|---|
|
|
462
|
+
| [`docs/ops/README.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/ops/README.md) | the PaaS → Compose → Kubernetes ladder, and which rung you are on |
|
|
463
|
+
| [`docs/ops/01-kubernetes.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/ops/01-kubernetes.md) | the chart, one Deployment per role, probes, PDBs, HPAs |
|
|
464
|
+
| [`docs/ops/02-secrets.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/ops/02-secrets.md) | env or a mounted file, and nothing vendor-shaped |
|
|
465
|
+
| [`docs/ops/03-observability.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/ops/03-observability.md) | what to scrape, what to alert on, what is not implemented yet → [Observability](Observability) |
|
|
466
|
+
| [`docs/ops/04-datastores.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/ops/04-datastores.md) | sizing Postgres, NATS and object storage for a given rung |
|
|
467
|
+
| [`docs/ops/05-disaster-recovery.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/ops/05-disaster-recovery.md) | backups, PITR, restore drills, replication-slot recovery |
|
|
468
|
+
| [`docs/ops/06-runbooks.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/ops/06-runbooks.md) | symptom → page → action, per role |
|
|
469
|
+
|
|
470
|
+
Two design-only companions — **specification, not shipped behaviour**: [`docs/idea/16-app-targets.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/16-app-targets.md) (three targets, one backend, two view layers) and [`docs/idea/17-scale-ladder.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/17-scale-ladder.md) (why the app code is identical at rung 0 and rung 4).
|
|
471
|
+
|
|
472
|
+
## Rollback
|
|
473
|
+
|
|
474
|
+
Redeploy the previous image tag. Inside an `assets.retain` window (below) a rollback 404s nobody mid-session; without one, a tab holding the other build's document reloads.
|
|
475
|
+
|
|
476
|
+
The older image's migrate role decides whether the schema allows it (`As of 2026-11`). Over ledger rows the newer build applied it applies nothing and logs them (`ahead`). Over a row the newer build RECORDED as destructive (`x_migrations.destructive`, written at apply) it refuses before anything serves:
|
|
477
|
+
|
|
478
|
+
| Ledger ahead of the build | Migrate role |
|
|
479
|
+
|---|---|
|
|
480
|
+
| additive rows only | passes, warns `ultimate migrate ledger ahead of build` |
|
|
481
|
+
| a destructive row | `X_MIGRATION_ROLLBACK_DESTRUCTIVE`: roll forward, or `ROLLBACK_ALLOW_DESTRUCTIVE=<id>[,<id>…]` naming every such row after checking the build |
|
|
482
|
+
| a row with no bit (`null`: applied before the ledger kept one) | passes, warns: the framework cannot know |
|
|
483
|
+
|
|
484
|
+
The refusal is the ROLLED-BACK image's code, so it protects from the release after the one that ships it; a rollback to an older build cannot refuse. `x deploy verify --json` then proves the rolled-back build serves → [runbooks](https://github.com/developerz-ai/ultimate/blob/main/docs/ops/06-runbooks.md#a-migration-failed-mid-deploy).
|
|
485
|
+
|
|
486
|
+
### Hashed assets across a deploy — `assets.retain`
|
|
487
|
+
|
|
488
|
+
A web process serves only the hashed files it built: `/styles/<hash>.css`, `/islands/<island>-<hash>.js`, `/_x/assets/<script>/<hash>.js`, `/assets/<name>.<hash>.<ext>`. With no window, an open tab holding the previous build's document gets a 404 for its stylesheet, or for its island chunk on the first click, after every deploy, one replica or many. During a rolling update both directions fail: a new document that reaches an old pod also gets no stylesheet. Declare a window over a disk every web pod can read:
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
// app.config.ts — `deploys` is a disk from the app's defineStorage({ disks })
|
|
492
|
+
import { defineConfig } from '@ultimat3/core';
|
|
493
|
+
|
|
494
|
+
export const config = defineConfig({
|
|
495
|
+
name: 'my-app',
|
|
496
|
+
assets: { retain: { disk: 'deploys', builds: 3 } },
|
|
497
|
+
});
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
| Step | What happens |
|
|
501
|
+
|---|---|
|
|
502
|
+
| boot, before `/readyz` | the web pod puts each missing hashed file at `x-build-assets/<app>/<environment>/files/<url>` (content-addressed, so a file two builds share is one object), then writes `…/builds/<buildId>.json` dated now. `<app>` is `config.name`, `<environment>` is `resolveEnvironment()` (`ULTIMATE_ENV`, else `NODE_ENV`), so staging and production on one bucket never touch each other. A pod of a build already published puts back any file a sweep took and re-dates the manifest, so a rolled-back build counts as the newest. At most 30 s (`RETAIN_PUBLISH_DEADLINE_MS`): past it the pod serves |
|
|
503
|
+
| sweep, after a publish | keeps the newest `builds` manifests and deletes every file none of them names, except a file younger than 10 minutes (`RETAIN_SWEEP_GRACE_MS`, a publish still in flight) and anything named by a manifest that appeared while it ran; a pod killed mid-sweep leaves extra objects, never a missing one |
|
|
504
|
+
| a miss on any asset route | a URL a retained build's manifest names is read from the disk as an object — `200` `immutable`, an `etag` (304) and `Range` (206). Any other URL never reads the disk: the usual `no-store` 404. A build published after this pod booted becomes readable within 5 s (`RETAINED_REFRESH_MS`) |
|
|
505
|
+
| a file over the disk's `maxPutBytes` | skipped, named in one `assets.retain_skipped` line; only its own build's pods serve it |
|
|
506
|
+
| the disk refuses a put, list or delete, or the publish passes its deadline | `X_ASSET_RETAIN_FAILED` is logged and the pod serves anyway |
|
|
507
|
+
|
|
508
|
+
`disk` naming no declared disk, or `builds` that is not a whole number ≥ 1, is `X_CONFIG_INVALID`. `x dev` never publishes. An S3-compatible bucket or a volume shared on one box is the whole requirement: no CDN, no platform primitive. The prefix sits outside `org/`, so `/_storage` and `/media` refuse it. A static export needs none of this: a host keeps earlier exports' hashed files because nothing deletes them. Version-skew handling: [Upgrading](Upgrading). Failure symptoms: [Troubleshooting](Troubleshooting).
|