@ultimat3/cli 29.4.0 → 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 +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 +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,188 @@
|
|
|
1
|
+
// `x db seed --generate [--rows n] [--seed n]`: rows for every registered entity, off the entity
|
|
2
|
+
// declarations alone (`@ultimat3/testing`'s `seedGraph`), written through each entity's own
|
|
3
|
+
// repository. Generated rows are `dev` fixtures — the same graph `x exercise` runs on — so
|
|
4
|
+
// production is refused whatever tier is asked for. One pass is one row of `x db seed`'s report.
|
|
5
|
+
|
|
6
|
+
import type { Environment } from '@ultimat3/core';
|
|
7
|
+
import { resolveEnvironment, UltimateError } from '@ultimat3/core';
|
|
8
|
+
import { withTransaction } from '@ultimat3/db';
|
|
9
|
+
import type { EntityCore, SeedTier } from '@ultimat3/entity';
|
|
10
|
+
import { postgresDriver, registeredEntities, seedTiersFor } from '@ultimat3/entity';
|
|
11
|
+
import type { SeedGraph, SeedWriter } from '@ultimat3/testing';
|
|
12
|
+
import { entitySeedWriter, insertSeedGraph, seedGraph } from '@ultimat3/testing';
|
|
13
|
+
import { loadApp } from './app-load';
|
|
14
|
+
import type { CommandContext } from './command';
|
|
15
|
+
import type { SeedPassRow } from './db-seed';
|
|
16
|
+
import { parseSeedTierFlag, renderSeedTable, seedPassToJson } from './db-seed';
|
|
17
|
+
import { readIntFlag } from './flag-number';
|
|
18
|
+
import { withJobDriver } from './jobs-driver';
|
|
19
|
+
import { msg } from './messages';
|
|
20
|
+
import type { CommandResult } from './output';
|
|
21
|
+
import { findingFrom } from './output';
|
|
22
|
+
import type { ParsedArgs } from './parse';
|
|
23
|
+
import { flagBool, flagString } from './parse';
|
|
24
|
+
|
|
25
|
+
/** The name a generated pass reports under: a seed name no `defineSeed` can be confused with. */
|
|
26
|
+
export const GENERATED_SEED = '(generated)';
|
|
27
|
+
|
|
28
|
+
/** What `refuseGeneratedSeed` answers: the tier generated rows are, in every report. */
|
|
29
|
+
const GENERATED_TIER: SeedTier = 'dev';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generated rows asked for where they must not land. `X_SEED_ENVIRONMENT`, the code `x db seed`
|
|
33
|
+
* already answers "this environment does not run that tier" with — but never its fix: a generated
|
|
34
|
+
* graph has no natural key to replay against, so no tier name makes production take it.
|
|
35
|
+
*/
|
|
36
|
+
export class GeneratedSeedRefusedError extends UltimateError {
|
|
37
|
+
constructor(environment: Environment, tiers: readonly SeedTier[]) {
|
|
38
|
+
super({
|
|
39
|
+
code: 'X_SEED_ENVIRONMENT',
|
|
40
|
+
cause: `x db seed --generate writes dev fixture rows, and ULTIMATE_ENV resolved ${environment}, where this run takes ${tiers.join(', ')} — generated rows never reach production, whatever tier is named`,
|
|
41
|
+
fix: 'ULTIMATE_ENV=development x db seed --generate --dry-run --json',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Throws unless this environment takes `dev` rows and is not production. */
|
|
47
|
+
export function refuseGeneratedSeed(
|
|
48
|
+
environment: Environment,
|
|
49
|
+
requested: SeedTier | undefined,
|
|
50
|
+
): void {
|
|
51
|
+
const tiers = seedTiersFor(environment, requested);
|
|
52
|
+
if (environment === 'production' || !tiers.includes(GENERATED_TIER)) {
|
|
53
|
+
throw new GeneratedSeedRefusedError(environment, tiers);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface GeneratedSeedFlags {
|
|
58
|
+
readonly rows?: number;
|
|
59
|
+
readonly seed?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `--rows` (at least one) and `--seed`, through the one integer reader. */
|
|
63
|
+
export function generatedSeedFlags(args: ParsedArgs): GeneratedSeedFlags {
|
|
64
|
+
const rows = readIntFlag(args, {
|
|
65
|
+
name: 'rows',
|
|
66
|
+
command: 'db seed',
|
|
67
|
+
min: 1,
|
|
68
|
+
max: 10_000,
|
|
69
|
+
example: 'x db seed --generate --rows 3 --json',
|
|
70
|
+
});
|
|
71
|
+
const seed = readIntFlag(args, {
|
|
72
|
+
name: 'seed',
|
|
73
|
+
command: 'db seed',
|
|
74
|
+
min: 0,
|
|
75
|
+
max: 2_147_483_647,
|
|
76
|
+
example: 'x db seed --generate --seed 1 --json',
|
|
77
|
+
});
|
|
78
|
+
return { ...(rows === undefined ? {} : { rows }), ...(seed === undefined ? {} : { seed }) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The plan: `--rows` per table (per tenant when scoped), drawn from `--seed`. */
|
|
82
|
+
export const generatedSeedPlan = (
|
|
83
|
+
entities: Readonly<Record<string, EntityCore>>,
|
|
84
|
+
flags: GeneratedSeedFlags,
|
|
85
|
+
): SeedGraph =>
|
|
86
|
+
seedGraph(entities, {
|
|
87
|
+
...(flags.rows === undefined ? {} : { perTable: flags.rows }),
|
|
88
|
+
...(flags.seed === undefined ? {} : { seed: flags.seed }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export interface GeneratedSeedWrite {
|
|
92
|
+
readonly graph: SeedGraph;
|
|
93
|
+
readonly writer: SeedWriter;
|
|
94
|
+
readonly dryRun: boolean;
|
|
95
|
+
/** One transaction around the whole graph: half a graph is a child row with no parent. */
|
|
96
|
+
readonly transaction: <T>(work: () => Promise<T>) => Promise<T>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Writes the plan, or counts it under `--dry-run`. Never throws — a failure is the row. */
|
|
100
|
+
export async function writeGeneratedSeed(input: GeneratedSeedWrite): Promise<SeedPassRow> {
|
|
101
|
+
const started = Bun.nanoseconds();
|
|
102
|
+
const ms = (): number => Math.round((Bun.nanoseconds() - started) / 1_000_000);
|
|
103
|
+
const base = { file: GENERATED_SEED, name: GENERATED_SEED, tier: GENERATED_TIER, skipped: 0 };
|
|
104
|
+
try {
|
|
105
|
+
if (!input.dryRun) await input.transaction(() => insertSeedGraph(input.graph, input.writer));
|
|
106
|
+
return {
|
|
107
|
+
...base,
|
|
108
|
+
status: 'ok',
|
|
109
|
+
ms: ms(),
|
|
110
|
+
inserted: input.graph.inserts.length,
|
|
111
|
+
updated: input.graph.updates.length,
|
|
112
|
+
finding: null,
|
|
113
|
+
};
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return {
|
|
116
|
+
...base,
|
|
117
|
+
status: 'failed',
|
|
118
|
+
ms: ms(),
|
|
119
|
+
inserted: 0,
|
|
120
|
+
updated: 0,
|
|
121
|
+
finding: findingFrom(error),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every registered TABLE, by name — the set the plan and the writer are both handed. A view entity
|
|
128
|
+
* is left out: Postgres derives its rows, and a write to one is `X_ENTITY_VIEW_READ_ONLY`.
|
|
129
|
+
*/
|
|
130
|
+
export const registeredEntityCores = (): Readonly<Record<string, EntityCore>> =>
|
|
131
|
+
Object.fromEntries(
|
|
132
|
+
registeredEntities().flatMap((entry) =>
|
|
133
|
+
entry.core === undefined || entry.core.$viewQuery !== null
|
|
134
|
+
? []
|
|
135
|
+
: [[entry.name, entry.core as EntityCore]],
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The command half. The environment is refused BEFORE the app loads or a connection opens, the
|
|
141
|
+
* rule `runSeed` keeps for the same reason: the refusal must not have touched the database first.
|
|
142
|
+
*/
|
|
143
|
+
export async function runGeneratedSeed(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
144
|
+
const environment = resolveEnvironment({ env: ctx.env });
|
|
145
|
+
const requested = parseSeedTierFlag(
|
|
146
|
+
flagString(ctx.args, 'tier') ?? ctx.env['ULTIMATE_SEED_TIER'],
|
|
147
|
+
);
|
|
148
|
+
refuseGeneratedSeed(environment, requested);
|
|
149
|
+
const flags = generatedSeedFlags(ctx.args);
|
|
150
|
+
const dryRun = flagBool(ctx.args, 'dry-run');
|
|
151
|
+
const { findings } = await loadApp(root);
|
|
152
|
+
const entities = registeredEntityCores();
|
|
153
|
+
const graph = generatedSeedPlan(entities, flags);
|
|
154
|
+
const write = (): Promise<CommandResult> =>
|
|
155
|
+
writeGeneratedSeed({
|
|
156
|
+
graph,
|
|
157
|
+
writer: entitySeedWriter({ entities, driver: postgresDriver() }),
|
|
158
|
+
dryRun,
|
|
159
|
+
transaction: (work) => withTransaction(() => work()),
|
|
160
|
+
}).then((row) => generatedResult(row, dryRun, findings));
|
|
161
|
+
return dryRun ? write() : withJobDriver(root, ctx, write);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function generatedResult(
|
|
165
|
+
row: SeedPassRow,
|
|
166
|
+
dryRun: boolean,
|
|
167
|
+
findings: CommandResult['findings'],
|
|
168
|
+
): CommandResult {
|
|
169
|
+
const loaded = findings ?? [];
|
|
170
|
+
return {
|
|
171
|
+
ok: row.finding === null && loaded.length === 0,
|
|
172
|
+
command: 'db',
|
|
173
|
+
summary:
|
|
174
|
+
row.finding !== null
|
|
175
|
+
? msg('cli.db.seed.failed', { failed: 1, count: 1 })
|
|
176
|
+
: dryRun
|
|
177
|
+
? msg('cli.db.seed.dryRun', { count: 1 })
|
|
178
|
+
: msg('cli.db.seed.done', {
|
|
179
|
+
count: 1,
|
|
180
|
+
inserted: row.inserted,
|
|
181
|
+
updated: row.updated,
|
|
182
|
+
skipped: row.skipped,
|
|
183
|
+
}),
|
|
184
|
+
findings: [...(row.finding === null ? [] : [row.finding]), ...loaded],
|
|
185
|
+
lines: renderSeedTable([row]).map((line) => ` ${line}`),
|
|
186
|
+
data: seedPassToJson([row]),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// What `x deploy verify` reports: its `--json` shape and the finding for each way it fails. Causes
|
|
2
|
+
// run every server-named build through `renderCauseValue`, and fixes every value through
|
|
3
|
+
// `renderFixShellArg`, because a load balancer writes those strings.
|
|
4
|
+
|
|
5
|
+
import { ERROR_DOCS_URL, renderCauseValue, renderFixShellArg } from '@ultimat3/core';
|
|
6
|
+
import type { VerifyOptions } from './deploy-verify-flags';
|
|
7
|
+
import type { QueueGrade, SampleFailure, SampleTally, UnservedQueue } from './deploy-verify-grade';
|
|
8
|
+
import { tallyToJson } from './deploy-verify-grade';
|
|
9
|
+
import type { Finding, JsonValue } from './output';
|
|
10
|
+
|
|
11
|
+
/** What the wait ended on. `lastFailure`: the last transport error, when a sample had one. */
|
|
12
|
+
export interface WaitOutcome {
|
|
13
|
+
readonly tally: SampleTally;
|
|
14
|
+
readonly closed: boolean;
|
|
15
|
+
readonly lastFailure: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const unservedFinding = (unserved: UnservedQueue, origin: string): Finding => {
|
|
19
|
+
const running =
|
|
20
|
+
unserved.builds.length === 0
|
|
21
|
+
? 'no worker claims it'
|
|
22
|
+
: `the workers claiming it run ${unserved.builds.map((build) => renderCauseValue(build)).join(', ')}, or are draining`;
|
|
23
|
+
const queue = renderFixShellArg(unserved.queue, '<queue>');
|
|
24
|
+
return {
|
|
25
|
+
code: 'X_DEPLOY_QUEUE_UNSERVED',
|
|
26
|
+
cause: `queue ${renderCauseValue(unserved.queue)} has no serving worker of this build, with ${unserved.depth} job(s) due or running: ${running}`,
|
|
27
|
+
fix: `x jobs list --json # then WORKER_QUEUES=${queue} on a worker Deployment, or x deploy verify --url ${renderFixShellArg(origin, 'https://app.example.com')} --idle-ok ${queue} --json for a scale-to-zero pool`,
|
|
28
|
+
docs: ERROR_DOCS_URL,
|
|
29
|
+
meta: { queue: unserved.queue, builds: [...unserved.builds], depth: unserved.depth },
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Every queue read failed until `--timeout`: the database was never read, so nothing passed. */
|
|
34
|
+
export const queueReadFinding = (lastFailure: string | undefined): Finding => ({
|
|
35
|
+
code: 'X_DB_UNAVAILABLE',
|
|
36
|
+
cause: `no read of x_job_workers succeeded before --timeout passed${lastFailure === undefined ? '' : `: ${renderCauseValue(lastFailure)}`}`,
|
|
37
|
+
fix: 'x jobs list --json # the same rows, over the same DATABASE_URL',
|
|
38
|
+
docs: ERROR_DOCS_URL,
|
|
39
|
+
...(lastFailure === undefined ? {} : { meta: { lastFailure } }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export function sampleFinding(
|
|
43
|
+
failure: SampleFailure,
|
|
44
|
+
wait: WaitOutcome,
|
|
45
|
+
build: string,
|
|
46
|
+
options: VerifyOptions,
|
|
47
|
+
): Finding {
|
|
48
|
+
const samples = tallyToJson(wait.tally);
|
|
49
|
+
const url = renderFixShellArg(options.origin, 'https://app.example.com');
|
|
50
|
+
const rerun = `x deploy verify --url ${url} --build ${renderFixShellArg(build, '"$BUILD_ID"')} --json`;
|
|
51
|
+
const name = renderCauseValue(build);
|
|
52
|
+
const texts: Readonly<Record<SampleFailure, { readonly cause: string; readonly fix: string }>> = {
|
|
53
|
+
X_DEPLOY_BUILD_MISMATCH: {
|
|
54
|
+
cause: `no sample of ${samples.total} carried build ${name} before --timeout passed`,
|
|
55
|
+
fix: `x deploy verify --url ${url} --build "$BUILD_ID" --json # the id CI stamped; meta.byBuild names what answered`,
|
|
56
|
+
},
|
|
57
|
+
X_DEPLOY_BUILD_UNSTAMPED: {
|
|
58
|
+
cause: `${wait.tally.unstamped} of ${samples.total} samples answered 2xx with no x-ultimate-build, so the image names no build`,
|
|
59
|
+
fix: 'x build --target docker --json # stamps BUILD_ID into the image; or set BUILD_ID in the image environment',
|
|
60
|
+
},
|
|
61
|
+
X_DEPLOY_UNSTABLE: {
|
|
62
|
+
cause: `build ${name} answered, but ${options.windowMs} ms of consecutive good samples never closed: ${samples.good} of ${samples.total} were good`,
|
|
63
|
+
fix: `${rerun} # samples.byStatus and samples.byBuild name what interrupted it`,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
code: failure,
|
|
68
|
+
...texts[failure],
|
|
69
|
+
docs: ERROR_DOCS_URL,
|
|
70
|
+
meta: {
|
|
71
|
+
build,
|
|
72
|
+
byBuild: samples.byBuild,
|
|
73
|
+
byStatus: samples.byStatus,
|
|
74
|
+
...(wait.lastFailure === undefined ? {} : { lastFailure: wait.lastFailure }),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const resultData = (
|
|
80
|
+
build: string,
|
|
81
|
+
windowMs: number,
|
|
82
|
+
tally: SampleTally,
|
|
83
|
+
queues: QueueGrade,
|
|
84
|
+
): { readonly [key: string]: JsonValue } => ({
|
|
85
|
+
build,
|
|
86
|
+
samples: tallyToJson(tally),
|
|
87
|
+
windowMs,
|
|
88
|
+
queues: {
|
|
89
|
+
graded: queues.graded,
|
|
90
|
+
served: [...queues.served],
|
|
91
|
+
unserved: queues.unserved.map(({ queue, builds, depth }) => ({
|
|
92
|
+
queue,
|
|
93
|
+
builds: [...builds],
|
|
94
|
+
depth,
|
|
95
|
+
})),
|
|
96
|
+
idle: [...queues.idle],
|
|
97
|
+
},
|
|
98
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// `x deploy verify`'s flags, read and refused before anything is sampled: every value the wait could
|
|
2
|
+
// not use is an `X_CLI_BAD_FLAG` here, never a red run minutes later.
|
|
3
|
+
|
|
4
|
+
// why: Bun exposes no path-join primitive; the manifest sits at the app root.
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { renderCauseValue, stringField } from '@ultimat3/core';
|
|
7
|
+
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
8
|
+
import { parseDuration } from '@ultimat3/time';
|
|
9
|
+
import type { CommandContext } from './command';
|
|
10
|
+
import { BadFlagError } from './errors';
|
|
11
|
+
import { flagList, flagString } from './parse';
|
|
12
|
+
|
|
13
|
+
export const VERIFY_COMMAND = 'deploy verify';
|
|
14
|
+
const EXAMPLE = 'x deploy verify --url https://app.example.com --json';
|
|
15
|
+
const DEFAULT_WINDOW = '60s';
|
|
16
|
+
const DEFAULT_INTERVAL = '5s';
|
|
17
|
+
const DEFAULT_TIMEOUT = '10m';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `@ultimat3/time`'s `parseDuration`, the grammar a job's `timeout: '1d'` is read with, so a flag
|
|
21
|
+
* never refuses a value a declaration accepts. Bare `0` where the flag can mean none; a negative or
|
|
22
|
+
* a zero where it cannot is refused here, because a wait of nothing is not a wait.
|
|
23
|
+
*/
|
|
24
|
+
export function readDuration(
|
|
25
|
+
raw: string,
|
|
26
|
+
flag: { readonly flag: string; readonly zero: boolean },
|
|
27
|
+
): number {
|
|
28
|
+
const ms = raw.trim() === '0' ? 0 : parsedOrNaN(raw);
|
|
29
|
+
if (ms >= 0 && (flag.zero || ms > 0)) return ms;
|
|
30
|
+
throw new BadFlagError({
|
|
31
|
+
flag: flag.flag,
|
|
32
|
+
command: VERIFY_COMMAND,
|
|
33
|
+
reason: `${renderCauseValue(raw)} is not a ${flag.zero ? '' : 'positive '}duration such as 60s, 10m, 1d or PT1H`,
|
|
34
|
+
fix: EXAMPLE,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `NaN` for anything `parseDuration` refuses: the flag's own refusal above names the flag. */
|
|
39
|
+
function parsedOrNaN(raw: string): number {
|
|
40
|
+
try {
|
|
41
|
+
return parseDuration(raw);
|
|
42
|
+
} catch {
|
|
43
|
+
return Number.NaN;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The origin, and only that: `/readyz` lives at the root, so a path would be silently dropped, and
|
|
49
|
+
* credentials would ride into every log line that names the URL. The cause never echoes the value.
|
|
50
|
+
*/
|
|
51
|
+
function readOrigin(raw: string | undefined): string {
|
|
52
|
+
const refuse = (reason: string): BadFlagError =>
|
|
53
|
+
new BadFlagError({ flag: 'url', command: VERIFY_COMMAND, reason, fix: EXAMPLE });
|
|
54
|
+
if (raw === undefined) throw refuse('it is required: the origin the load balancer answers on');
|
|
55
|
+
const parsed = URL.canParse(raw) ? new URL(raw) : undefined;
|
|
56
|
+
if (parsed === undefined || !['http:', 'https:'].includes(parsed.protocol)) {
|
|
57
|
+
throw refuse('it must be an http or https origin');
|
|
58
|
+
}
|
|
59
|
+
if (parsed.href !== `${parsed.origin}/`) {
|
|
60
|
+
throw refuse('it must be a bare origin: no path, query, fragment or credentials');
|
|
61
|
+
}
|
|
62
|
+
return parsed.origin;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `--build`, else the manifest's: what `x build --target docker` stamped when CI stamped nothing. */
|
|
66
|
+
export async function readBuild(ctx: CommandContext, root: string): Promise<string> {
|
|
67
|
+
const given = flagString(ctx.args, 'build');
|
|
68
|
+
if (given !== undefined && given.trim() !== '') return given;
|
|
69
|
+
const file = Bun.file(join(root, MANIFEST_FILENAME));
|
|
70
|
+
// A manifest that is not JSON is one that names no build: refused below, never a raw SyntaxError.
|
|
71
|
+
const manifest: unknown = (await file.exists())
|
|
72
|
+
? await file.json().catch(() => undefined)
|
|
73
|
+
: undefined;
|
|
74
|
+
const buildId = stringField(manifest, 'buildId');
|
|
75
|
+
if (typeof buildId === 'string' && buildId !== '') return buildId;
|
|
76
|
+
throw new BadFlagError({
|
|
77
|
+
flag: 'build',
|
|
78
|
+
command: VERIFY_COMMAND,
|
|
79
|
+
reason: `not given, and ${MANIFEST_FILENAME} names no buildId`,
|
|
80
|
+
fix: 'x deploy verify --url https://app.example.com --build "$BUILD_ID" --json',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface VerifyOptions {
|
|
85
|
+
readonly origin: string;
|
|
86
|
+
readonly windowMs: number;
|
|
87
|
+
readonly intervalMs: number;
|
|
88
|
+
readonly timeoutMs: number;
|
|
89
|
+
readonly idleOk: readonly string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readOptions(ctx: CommandContext): VerifyOptions {
|
|
93
|
+
const duration = (flag: string, fallback: string, zero = false): number =>
|
|
94
|
+
readDuration(flagString(ctx.args, flag) ?? fallback, { flag, zero });
|
|
95
|
+
const origin = readOrigin(flagString(ctx.args, 'url'));
|
|
96
|
+
const windowMs = duration('window', DEFAULT_WINDOW, true);
|
|
97
|
+
const intervalMs = duration('interval', DEFAULT_INTERVAL);
|
|
98
|
+
const timeoutMs = duration('timeout', DEFAULT_TIMEOUT);
|
|
99
|
+
// A window the wait can never hold is a red run whatever production does.
|
|
100
|
+
if (windowMs > timeoutMs) {
|
|
101
|
+
throw new BadFlagError({
|
|
102
|
+
flag: 'window',
|
|
103
|
+
command: VERIFY_COMMAND,
|
|
104
|
+
reason: `${windowMs} ms cannot close inside --timeout of ${timeoutMs} ms`,
|
|
105
|
+
fix: EXAMPLE,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return { origin, windowMs, intervalMs, timeoutMs, idleOk: flagList(ctx.args, 'idle-ok') };
|
|
109
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The verdict behind `x deploy verify`, pure: samples folded into a tally and a window, worker rows
|
|
2
|
+
// graded per queue. No socket, no database, no clock — `cmd-deploy-verify.ts` owns the waiting.
|
|
3
|
+
|
|
4
|
+
import type { WorkerRecord } from '@ultimat3/jobs';
|
|
5
|
+
|
|
6
|
+
/** One `GET /readyz`. A `null` status is a sample nothing answered: refused, reset, timed out. */
|
|
7
|
+
export interface Sample {
|
|
8
|
+
/** Monotonic ms, when the answer arrived. */
|
|
9
|
+
readonly at: number;
|
|
10
|
+
readonly status: number | null;
|
|
11
|
+
/** `x-ultimate-build` as answered, on any status. `null`: no header, or an empty one. */
|
|
12
|
+
readonly build: string | null;
|
|
13
|
+
/** Answered from a cache (`Age` > 0): a CDN's copy says nothing about the fleet behind it. */
|
|
14
|
+
readonly cached: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Samples since the first good one: what the bad rate is measured over. */
|
|
18
|
+
export interface SinceGood {
|
|
19
|
+
readonly total: number;
|
|
20
|
+
readonly bad: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SampleTally {
|
|
24
|
+
readonly total: number;
|
|
25
|
+
readonly good: number;
|
|
26
|
+
/** Built with computed keys and read through `Object.hasOwn`: a server names these. */
|
|
27
|
+
readonly byBuild: Readonly<Record<string, number>>;
|
|
28
|
+
readonly byStatus: Readonly<Record<string, number>>;
|
|
29
|
+
/** 2xx answers that named no build: what `X_DEPLOY_BUILD_UNSTAMPED` is told apart by. */
|
|
30
|
+
readonly unstamped: number;
|
|
31
|
+
/** When the current run of good samples began. `null`: the last sample broke it. */
|
|
32
|
+
readonly goodSince: number | null;
|
|
33
|
+
readonly lastAt: number | null;
|
|
34
|
+
readonly cached: number;
|
|
35
|
+
/** `null` until a sample was good: old answers before it are the roll, not instability. */
|
|
36
|
+
readonly sinceGood: SinceGood | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The `byStatus` key of a sample nothing answered. */
|
|
40
|
+
export const UNREACHABLE = 'unreachable';
|
|
41
|
+
|
|
42
|
+
/** The label `builds` gives a row with no build: a worker from before the column. */
|
|
43
|
+
export const UNKNOWN_BUILD = 'unknown';
|
|
44
|
+
|
|
45
|
+
export const emptyTally = (): SampleTally => ({
|
|
46
|
+
total: 0,
|
|
47
|
+
good: 0,
|
|
48
|
+
byBuild: {},
|
|
49
|
+
byStatus: {},
|
|
50
|
+
unstamped: 0,
|
|
51
|
+
goodSince: null,
|
|
52
|
+
lastAt: null,
|
|
53
|
+
cached: 0,
|
|
54
|
+
sinceGood: null,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const countOf = (counts: Readonly<Record<string, number>>, key: string): number =>
|
|
58
|
+
Object.hasOwn(counts, key) ? (counts[key] ?? 0) : 0;
|
|
59
|
+
|
|
60
|
+
const bump = (
|
|
61
|
+
counts: Readonly<Record<string, number>>,
|
|
62
|
+
key: string,
|
|
63
|
+
): Readonly<Record<string, number>> => ({ ...counts, [key]: countOf(counts, key) + 1 });
|
|
64
|
+
|
|
65
|
+
const is2xx = (status: number | null): boolean => status !== null && status >= 200 && status < 300;
|
|
66
|
+
|
|
67
|
+
export function foldSample(tally: SampleTally, sample: Sample, build: string): SampleTally {
|
|
68
|
+
const good = is2xx(sample.status) && sample.build === build && !sample.cached;
|
|
69
|
+
const since = tally.sinceGood;
|
|
70
|
+
return {
|
|
71
|
+
total: tally.total + 1,
|
|
72
|
+
good: tally.good + (good ? 1 : 0),
|
|
73
|
+
byBuild: sample.build === null ? tally.byBuild : bump(tally.byBuild, sample.build),
|
|
74
|
+
byStatus: bump(tally.byStatus, sample.status === null ? UNREACHABLE : String(sample.status)),
|
|
75
|
+
unstamped: tally.unstamped + (is2xx(sample.status) && sample.build === null ? 1 : 0),
|
|
76
|
+
// Any bad sample restarts it: an old build mid-window is a roll still in flight.
|
|
77
|
+
goodSince: good ? (tally.goodSince ?? sample.at) : null,
|
|
78
|
+
lastAt: sample.at,
|
|
79
|
+
cached: tally.cached + (sample.cached ? 1 : 0),
|
|
80
|
+
sinceGood:
|
|
81
|
+
since === null
|
|
82
|
+
? good
|
|
83
|
+
? { total: 1, bad: 0 }
|
|
84
|
+
: null
|
|
85
|
+
: { total: since.total + 1, bad: since.bad + (good ? 0 : 1) },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Closed when the good run spans `windowMs` AND, since the first good sample, fewer than one sample
|
|
91
|
+
* in `perWindow` (`ceil(window / interval)`) was bad. The run alone passed a fleet stuck with 10% old
|
|
92
|
+
* pods 99.6% of the time: thirteen good in a row happen by chance. `--window 0` closes on the first
|
|
93
|
+
* good sample: the build answered once.
|
|
94
|
+
*/
|
|
95
|
+
export const windowClosed = (tally: SampleTally, windowMs: number, perWindow: number): boolean =>
|
|
96
|
+
tally.goodSince !== null &&
|
|
97
|
+
tally.lastAt !== null &&
|
|
98
|
+
tally.lastAt - tally.goodSince >= windowMs &&
|
|
99
|
+
tally.sinceGood !== null &&
|
|
100
|
+
tally.sinceGood.bad * perWindow < tally.sinceGood.total;
|
|
101
|
+
|
|
102
|
+
export type SampleFailure =
|
|
103
|
+
| 'X_DEPLOY_BUILD_MISMATCH'
|
|
104
|
+
| 'X_DEPLOY_BUILD_UNSTAMPED'
|
|
105
|
+
| 'X_DEPLOY_UNSTABLE';
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Why a wait ended without the window closing. "The build answered" reads the header on any status:
|
|
109
|
+
* a new pod answering 503 is the build, unstable, and telling that operator it never shipped would
|
|
110
|
+
* send them to the wrong problem. Unstamped only when no sample named any build, because one old
|
|
111
|
+
* build beside it means the header works and the build is what is missing.
|
|
112
|
+
*/
|
|
113
|
+
export function failureOf(tally: SampleTally, build: string): SampleFailure {
|
|
114
|
+
if (countOf(tally.byBuild, build) > 0) return 'X_DEPLOY_UNSTABLE';
|
|
115
|
+
const namedNone = Object.keys(tally.byBuild).length === 0;
|
|
116
|
+
return namedNone && tally.unstamped > 0 ? 'X_DEPLOY_BUILD_UNSTAMPED' : 'X_DEPLOY_BUILD_MISMATCH';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The tally as `--json` reports it. The window's bookkeeping stays out. */
|
|
120
|
+
export const tallyToJson = (tally: SampleTally) => ({
|
|
121
|
+
total: tally.total,
|
|
122
|
+
good: tally.good,
|
|
123
|
+
byBuild: { ...tally.byBuild },
|
|
124
|
+
byStatus: { ...tally.byStatus },
|
|
125
|
+
/** Each build's share of every sample, to three places: a stuck roll reads as its old share. */
|
|
126
|
+
share: Object.fromEntries(
|
|
127
|
+
Object.entries(tally.byBuild).map(([name, count]) => [
|
|
128
|
+
name,
|
|
129
|
+
Math.round((count / Math.max(1, tally.total)) * 1000) / 1000,
|
|
130
|
+
]),
|
|
131
|
+
),
|
|
132
|
+
cached: tally.cached,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
export interface UnservedQueue {
|
|
136
|
+
readonly queue: string;
|
|
137
|
+
/** Every build a live row claiming this queue runs, sorted: a stalled roll reads as one. */
|
|
138
|
+
readonly builds: readonly string[];
|
|
139
|
+
readonly depth: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface QueueGrade {
|
|
143
|
+
readonly graded: boolean;
|
|
144
|
+
readonly served: readonly string[];
|
|
145
|
+
readonly unserved: readonly UnservedQueue[];
|
|
146
|
+
/** On `--idle-ok`, no serving worker, depth 0. */
|
|
147
|
+
readonly idle: readonly string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** No `DATABASE_URL`, or a window that never closed: nothing was read, so nothing passed. */
|
|
151
|
+
export const UNGRADED: QueueGrade = Object.freeze({
|
|
152
|
+
graded: false,
|
|
153
|
+
served: [],
|
|
154
|
+
unserved: [],
|
|
155
|
+
idle: [],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
export interface QueueGradeInput {
|
|
159
|
+
readonly build: string;
|
|
160
|
+
readonly expected: readonly string[];
|
|
161
|
+
readonly workers: readonly WorkerRecord[];
|
|
162
|
+
/** Work a worker would have to run now, per queue. A queue with no entry holds none. */
|
|
163
|
+
readonly depth: ReadonlyMap<string, number>;
|
|
164
|
+
readonly idleOk: readonly string[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function gradeQueues(input: QueueGradeInput): QueueGrade {
|
|
168
|
+
const served: string[] = [];
|
|
169
|
+
const unserved: UnservedQueue[] = [];
|
|
170
|
+
const idle: string[] = [];
|
|
171
|
+
for (const queue of input.expected) {
|
|
172
|
+
const claiming = input.workers.filter((worker) => worker.queues.includes(queue));
|
|
173
|
+
const serving = claiming.some(
|
|
174
|
+
(worker) => worker.drainingAt === undefined && worker.build === input.build,
|
|
175
|
+
);
|
|
176
|
+
const depth = input.depth.get(queue) ?? 0;
|
|
177
|
+
if (serving) served.push(queue);
|
|
178
|
+
else if (depth === 0 && input.idleOk.includes(queue)) idle.push(queue);
|
|
179
|
+
else {
|
|
180
|
+
const builds = new Set(claiming.map((worker) => worker.build ?? UNKNOWN_BUILD));
|
|
181
|
+
unserved.push({ queue, builds: [...builds].sort(), depth });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { graded: true, served, unserved, idle };
|
|
185
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The real sources behind `x deploy verify`'s queue grade: the set an app's workers must claim, and
|
|
2
|
+
// the rows that say who claims them. Apart from the command so the verdict on the build alone never
|
|
3
|
+
// imports the app's module graph or a database driver.
|
|
4
|
+
|
|
5
|
+
import { dbExecutor, postgresClient } from '@ultimat3/db';
|
|
6
|
+
import type { JobDriver } from '@ultimat3/jobs';
|
|
7
|
+
import { postgresJobDriver, registeredJobs } from '@ultimat3/jobs';
|
|
8
|
+
import { loadAppConfig } from './app-config-load';
|
|
9
|
+
import { loadApp } from './app-load';
|
|
10
|
+
import type { ExpectedQueues, QueueReader } from './cmd-deploy-verify';
|
|
11
|
+
import { workerQueuesFor } from './runtime-jobs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What an unset `WORKER_QUEUES` serves: `jobs.queues` plus every registered job's queue. A closed
|
|
15
|
+
* set: a `WORKER_QUEUES` pattern is refused at boot (`X_WORKER_QUEUE_UNKNOWN`), and a queue only
|
|
16
|
+
* `enqueue({ queue })` reaches has to be in `jobs.queues`.
|
|
17
|
+
*/
|
|
18
|
+
export async function expectedQueues(root: string): Promise<ExpectedQueues> {
|
|
19
|
+
const { findings } = await loadApp(root);
|
|
20
|
+
const config = await loadAppConfig(root);
|
|
21
|
+
const registered = registeredJobs().map((handle) => handle.queue);
|
|
22
|
+
return { queues: workerQueuesFor(config?.jobs.queues ?? [], registered) ?? [], findings };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One connection, against the url given and never the ambient client: this process owns no db. */
|
|
26
|
+
export function openQueue(
|
|
27
|
+
url: string,
|
|
28
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
29
|
+
statementTimeoutMs: number,
|
|
30
|
+
): Promise<QueueReader> {
|
|
31
|
+
const client = postgresClient({
|
|
32
|
+
url,
|
|
33
|
+
// The server cancels a read past what is left of `--timeout`: the command stops waiting on its
|
|
34
|
+
// own clock, and this is what stops the database working on a query nobody reads.
|
|
35
|
+
profile: { max: 1, statementTimeoutMs },
|
|
36
|
+
env,
|
|
37
|
+
applicationName: 'x deploy verify',
|
|
38
|
+
});
|
|
39
|
+
const driver = postgresJobDriver({ executor: dbExecutor(() => client) });
|
|
40
|
+
return Promise.resolve(queueReaderOf(driver, () => client.close()));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Any driver as the grade's reader. A driver with no registry lists no worker: the grade then reads
|
|
45
|
+
* every queue unserved, never served by default.
|
|
46
|
+
*/
|
|
47
|
+
export function queueReaderOf(driver: JobDriver, close: () => Promise<void>): QueueReader {
|
|
48
|
+
return {
|
|
49
|
+
workers: async () => (await driver.introspect?.workers()) ?? [],
|
|
50
|
+
demand: () => driver.demand(),
|
|
51
|
+
close,
|
|
52
|
+
};
|
|
53
|
+
}
|