@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,171 @@
|
|
|
1
|
+
// Earlier builds' hashed files, kept readable across a deploy (`assets.retain`): a web pod
|
|
2
|
+
// publishes what it serves to a shared disk before it reports ready. This file is the publish and
|
|
3
|
+
// the key layout; the sweep is `retained-assets-sweep.ts`, the read `retained-assets-read.ts`.
|
|
4
|
+
|
|
5
|
+
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
finiteCount,
|
|
8
|
+
isUltimateError,
|
|
9
|
+
logger,
|
|
10
|
+
renderCauseValue,
|
|
11
|
+
renderThrowable,
|
|
12
|
+
systemClock,
|
|
13
|
+
UltimateError,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
|
+
import type { BuildManifest, RetainedScope } from './retained-assets-layout';
|
|
16
|
+
import { inBatches, manifestKey, retainedFileKey } from './retained-assets-layout';
|
|
17
|
+
import { sweepRetained } from './retained-assets-sweep';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How long a publish may hold the pod out of readiness. Past it the pod serves without having
|
|
21
|
+
* published: a stalled bucket must not turn a deploy into one that never finishes.
|
|
22
|
+
*/
|
|
23
|
+
export const RETAIN_PUBLISH_DEADLINE_MS = 30_000;
|
|
24
|
+
|
|
25
|
+
/** One file a build serves, at the content-addressed URL its documents name. */
|
|
26
|
+
export interface BuildAsset {
|
|
27
|
+
readonly url: string;
|
|
28
|
+
readonly contentType: string;
|
|
29
|
+
/** A `Blob` (`Bun.file`) for a file on disk: a disk refuses one past its ceiling unread. */
|
|
30
|
+
readonly body: Uint8Array | Blob;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The two levels a publish speaks at; `logger` unless a test records them. */
|
|
34
|
+
export interface RetainLog {
|
|
35
|
+
warn(message: string, fields: Readonly<Record<string, unknown>>): void;
|
|
36
|
+
error(message: string, fields: Readonly<Record<string, unknown>>): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PublishBuildAssetsInput extends RetainedScope {
|
|
40
|
+
/** As `defineStorage` named it — in every log line, so the operator knows which bucket. */
|
|
41
|
+
readonly diskName: string;
|
|
42
|
+
readonly buildId: string;
|
|
43
|
+
/** `assets.retain.builds`: how many builds' manifests, newest first, the sweep keeps. */
|
|
44
|
+
readonly builds: number;
|
|
45
|
+
readonly assets: readonly BuildAsset[];
|
|
46
|
+
readonly clock?: Clock | undefined;
|
|
47
|
+
readonly log?: RetainLog | undefined;
|
|
48
|
+
/** Defaults to `RETAIN_PUBLISH_DEADLINE_MS`. */
|
|
49
|
+
readonly deadlineMs?: number | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PublishReport {
|
|
53
|
+
/** `present`: another pod of this build published it; this one healed and re-dated it. */
|
|
54
|
+
readonly outcome: 'published' | 'present' | 'failed';
|
|
55
|
+
readonly put: number;
|
|
56
|
+
/** URLs over the disk's put ceiling: served by their own build's pods only. */
|
|
57
|
+
readonly skipped: readonly string[];
|
|
58
|
+
/** Objects the sweep deleted. */
|
|
59
|
+
readonly swept: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A put, list or delete the disk refused during a publish, or its deadline. Logged, not thrown. */
|
|
63
|
+
export class AssetRetainFailedError extends UltimateError {
|
|
64
|
+
constructor(input: { readonly disk: string; readonly buildId: string; readonly error: unknown }) {
|
|
65
|
+
super({
|
|
66
|
+
code: 'X_ASSET_RETAIN_FAILED',
|
|
67
|
+
cause: `publishing build ${renderCauseValue(input.buildId)} to the retained-asset disk ${renderCauseValue(input.disk)} failed (${renderThrowable(input.error)}) — this pod serves, but a tab holding an earlier build's document may meet a 404 for its stylesheet or island until it reloads`,
|
|
68
|
+
fix: 'x doctor --json',
|
|
69
|
+
meta: { disk: input.disk, buildId: input.buildId },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The deadline's own failure, named so the cause says which limit was hit. */
|
|
75
|
+
class PublishDeadlineError extends UltimateError {
|
|
76
|
+
constructor(ms: number) {
|
|
77
|
+
super({
|
|
78
|
+
code: 'X_ASSET_RETAIN_FAILED',
|
|
79
|
+
cause: `the publish did not finish within ${String(ms)}ms`,
|
|
80
|
+
fix: 'x doctor --json',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const isTooLarge = (error: unknown): boolean =>
|
|
86
|
+
isUltimateError(error) && error.code === 'X_STORAGE_TOO_LARGE';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Never throws for the disk and never outlasts its deadline: a failure is one
|
|
90
|
+
* `X_ASSET_RETAIN_FAILED` line and the pod serves, because a stale tab is a smaller harm than a
|
|
91
|
+
* deploy that cannot start. A deadline that is not a whole number of at least 1 ms is refused up
|
|
92
|
+
* front (`X_INVARIANT`): `setTimeout` fires a `NaN` at once. A publish
|
|
93
|
+
* abandoned at the deadline may still finish in the background; every step of it is idempotent.
|
|
94
|
+
*/
|
|
95
|
+
export async function publishBuildAssets(input: PublishBuildAssetsInput): Promise<PublishReport> {
|
|
96
|
+
const log = input.log ?? logger;
|
|
97
|
+
const ms = finiteCount(
|
|
98
|
+
'publishBuildAssets',
|
|
99
|
+
'deadlineMs',
|
|
100
|
+
input.deadlineMs ?? RETAIN_PUBLISH_DEADLINE_MS,
|
|
101
|
+
1,
|
|
102
|
+
);
|
|
103
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
104
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
105
|
+
timer = setTimeout(() => reject(new PublishDeadlineError(ms)), ms);
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
return await Promise.race([publish(input, log), deadline]);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const failed = new AssetRetainFailedError({
|
|
111
|
+
disk: input.diskName,
|
|
112
|
+
buildId: input.buildId,
|
|
113
|
+
error,
|
|
114
|
+
});
|
|
115
|
+
log.error(failed.code, {
|
|
116
|
+
code: failed.code,
|
|
117
|
+
cause: failed.cause,
|
|
118
|
+
fix: failed.fix,
|
|
119
|
+
disk: input.diskName,
|
|
120
|
+
buildId: input.buildId,
|
|
121
|
+
});
|
|
122
|
+
return { outcome: 'failed', put: 0, skipped: [], swept: 0 };
|
|
123
|
+
} finally {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The same steps whether or not the build was published before: put each missing file, write the
|
|
130
|
+
* manifest dated NOW, put back anything a sweep took meanwhile. So a build whose files a bad sweep
|
|
131
|
+
* removed heals on its next pod, and a rolled-back build — the one live — is the newest manifest.
|
|
132
|
+
*/
|
|
133
|
+
async function publish(input: PublishBuildAssetsInput, log: RetainLog): Promise<PublishReport> {
|
|
134
|
+
const { disk, prefix, buildId } = input;
|
|
135
|
+
const present = await disk.exists(manifestKey(prefix, buildId));
|
|
136
|
+
const skipped: string[] = [];
|
|
137
|
+
let put = 0;
|
|
138
|
+
const ensure = async (asset: BuildAsset): Promise<void> => {
|
|
139
|
+
const key = retainedFileKey(prefix, asset.url);
|
|
140
|
+
if (await disk.exists(key)) return;
|
|
141
|
+
try {
|
|
142
|
+
await disk.put(key, asset.body, { contentType: asset.contentType });
|
|
143
|
+
put += 1;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!isTooLarge(error)) throw error;
|
|
146
|
+
if (!skipped.includes(asset.url)) skipped.push(asset.url);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
await inBatches(input.assets, ensure);
|
|
150
|
+
if (skipped.length > 0) {
|
|
151
|
+
log.warn('assets.retain_skipped', {
|
|
152
|
+
disk: input.diskName,
|
|
153
|
+
buildId,
|
|
154
|
+
urls: [...skipped].sort(),
|
|
155
|
+
cause: "over the disk's maxPutBytes, so only this build's own pods serve them",
|
|
156
|
+
fix: 's3Driver({ bucket, maxPutBytes: <bytes> }) # the disk assets.retain.disk names, in its defineStorage; localDriver takes the same option',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const kept = input.assets.filter((asset) => !skipped.includes(asset.url));
|
|
160
|
+
const now = (input.clock ?? systemClock).now().getTime();
|
|
161
|
+
const manifest: BuildManifest = { buildId, publishedAt: now, urls: kept.map((a) => a.url) };
|
|
162
|
+
await disk.put(manifestKey(prefix, buildId), new TextEncoder().encode(JSON.stringify(manifest)), {
|
|
163
|
+
contentType: 'application/json',
|
|
164
|
+
});
|
|
165
|
+
// A sweep on another pod may have read the manifests before this one existed and deleted a file
|
|
166
|
+
// this build found present: asked again now that the manifest protects it from the next sweep.
|
|
167
|
+
await inBatches(kept, ensure);
|
|
168
|
+
if (present) return { outcome: 'present', put, skipped: [...skipped].sort(), swept: 0 };
|
|
169
|
+
const swept = await sweepRetained({ disk, prefix }, input.builds, now);
|
|
170
|
+
return { outcome: 'published', put, skipped: [...skipped].sort(), swept };
|
|
171
|
+
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// name that outlives its bytes is a file no client refreshes for a year.
|
|
5
5
|
|
|
6
6
|
import type { UltimateRequest } from '@ultimat3/http';
|
|
7
|
+
import { notModified } from '@ultimat3/http';
|
|
7
8
|
import { contentHash } from '@ultimat3/render/server';
|
|
8
|
-
import { etagMatches } from './runtime-storage';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The document's own posture (`@ultimat3/render`'s `render-static.ts`), for the same reason: the
|
|
@@ -32,8 +32,11 @@ export function revalidatedResponse(
|
|
|
32
32
|
'cache-control': REVALIDATE_CACHE_CONTROL,
|
|
33
33
|
etag,
|
|
34
34
|
};
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
// The length on the 200 only: `compress` reads nothing undeclared, and a 304 has no content.
|
|
36
|
+
return (
|
|
37
|
+
notModified(request, etag, headers) ??
|
|
38
|
+
new Response(bytes, {
|
|
39
|
+
headers: { ...headers, 'content-length': String(bytes.byteLength) },
|
|
40
|
+
})
|
|
41
|
+
);
|
|
39
42
|
}
|
package/src/role-replicator.ts
CHANGED
|
@@ -137,7 +137,13 @@ function standBy(replicator: Replicator, key: string): () => void {
|
|
|
137
137
|
// Voided: both outcomes are handled here, and a timer has nobody to hand a promise to.
|
|
138
138
|
void replicator.start().then(
|
|
139
139
|
(held) => {
|
|
140
|
-
|
|
140
|
+
// Granted after the role stopped: its stop has already run, so nothing else would ever
|
|
141
|
+
// release this slot — a stopped role would hold it and decode. Given straight back.
|
|
142
|
+
if (held && cancelled) {
|
|
143
|
+
void replicator.stop().catch((thrown: unknown) => {
|
|
144
|
+
logger.warn('replicator.standby_failed', { key, error: renderThrowable(thrown) });
|
|
145
|
+
});
|
|
146
|
+
} else if (held) logger.info('replicator.slot_taken_over', { key });
|
|
141
147
|
else if (!cancelled) ask(attempt + 1);
|
|
142
148
|
},
|
|
143
149
|
(thrown: unknown) => {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The device sockets one process hosts: every declared `deviceSocket()` attached to its bus, and —
|
|
2
|
+
// on the web role — one listener mount per path that resolves the declaration PER UPGRADE. A
|
|
3
|
+
// container's declarations never change; `x dev`'s rescan re-declares a saved path in a new
|
|
4
|
+
// generation, and `refresh()` attaches that value and drains the one it replaced.
|
|
5
|
+
|
|
6
|
+
import type { Role } from '@ultimat3/core';
|
|
7
|
+
import type {
|
|
8
|
+
DeviceAttachment,
|
|
9
|
+
DeviceSocket,
|
|
10
|
+
DeviceUpgradeTarget,
|
|
11
|
+
DeviceWs,
|
|
12
|
+
Transport,
|
|
13
|
+
} from '@ultimat3/realtime/server';
|
|
14
|
+
import { declaredDeviceSockets } from '@ultimat3/realtime/server';
|
|
15
|
+
|
|
16
|
+
/** What the listener is handed: a declaration's own mount shape, so `httpServer` takes it as one. */
|
|
17
|
+
export type DeviceMount = Pick<DeviceSocket, 'path' | 'fetch' | 'websocket'>;
|
|
18
|
+
|
|
19
|
+
export interface DeviceHosting {
|
|
20
|
+
/** One per path declared at boot; empty without the web role, which is where devices dial. */
|
|
21
|
+
readonly mounts: readonly DeviceMount[];
|
|
22
|
+
/**
|
|
23
|
+
* After a rescan: each re-declared path's new value attached, the old one's sockets told to
|
|
24
|
+
* reconnect and closed, its attachment stopped. Answers the declared paths the listener does not
|
|
25
|
+
* serve — added after it bound, which only a new process can open.
|
|
26
|
+
*/
|
|
27
|
+
refresh(): Promise<readonly string[]>;
|
|
28
|
+
/** Every attachment stopped, newest first. */
|
|
29
|
+
stop(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Held {
|
|
33
|
+
readonly link: DeviceSocket;
|
|
34
|
+
readonly attachment: DeviceAttachment;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The listener's door for one path. The upgrade is answered by the path's CURRENT declaration, and
|
|
39
|
+
* each socket's events go back to the declaration that upgraded it — keyed by the `data` it handed
|
|
40
|
+
* `server.upgrade`, `joinWebSocketMounts`'s rule — so a socket opened before a reload is closed by
|
|
41
|
+
* the host that holds it, never handed to one that does not.
|
|
42
|
+
*/
|
|
43
|
+
function pathMount(path: string, current: () => DeviceSocket): DeviceMount {
|
|
44
|
+
const owners = new WeakMap<object, DeviceSocket>();
|
|
45
|
+
const ownerOf = (ws: DeviceWs): DeviceSocket | undefined => owners.get(ws.data);
|
|
46
|
+
// Bun's per-listener limits are fixed when it binds; the framework's frame limit is a constant.
|
|
47
|
+
const { maxPayloadLength, idleTimeout } = current().websocket;
|
|
48
|
+
return {
|
|
49
|
+
path,
|
|
50
|
+
fetch(request, server) {
|
|
51
|
+
const link = current();
|
|
52
|
+
const recording: DeviceUpgradeTarget = {
|
|
53
|
+
upgrade(upgraded, options) {
|
|
54
|
+
owners.set(options.data, link);
|
|
55
|
+
return server.upgrade(upgraded, options);
|
|
56
|
+
},
|
|
57
|
+
requestIP: (asked) => server.requestIP(asked),
|
|
58
|
+
};
|
|
59
|
+
return link.fetch(request, recording);
|
|
60
|
+
},
|
|
61
|
+
websocket: {
|
|
62
|
+
open: (ws) => ownerOf(ws)?.websocket.open(ws),
|
|
63
|
+
message: (ws, message) => ownerOf(ws)?.websocket.message(ws, message),
|
|
64
|
+
close: (ws) => ownerOf(ws)?.websocket.close(ws),
|
|
65
|
+
maxPayloadLength,
|
|
66
|
+
idleTimeout,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function hostDeviceSockets(
|
|
72
|
+
transport: Transport,
|
|
73
|
+
roles: readonly Role[],
|
|
74
|
+
): Promise<DeviceHosting> {
|
|
75
|
+
const serve = roles.includes('web');
|
|
76
|
+
const held = new Map<string, Held>();
|
|
77
|
+
const attach = async (link: DeviceSocket): Promise<Held> => ({
|
|
78
|
+
link,
|
|
79
|
+
attachment: await link.attach({ transport, serve }),
|
|
80
|
+
});
|
|
81
|
+
const stopAll = (): void => {
|
|
82
|
+
for (const entry of [...held.values()].reverse()) entry.attachment.stop();
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
for (const link of declaredDeviceSockets()) held.set(link.path, await attach(link));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
stopAll();
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
const served = serve ? [...held.keys()] : [];
|
|
91
|
+
// `held` replaces a served path's entry and never drops one; the boot's is the type's fallback.
|
|
92
|
+
const mounts = serve
|
|
93
|
+
? [...held].map(([path, boot]) => pathMount(path, () => (held.get(path) ?? boot).link))
|
|
94
|
+
: [];
|
|
95
|
+
return {
|
|
96
|
+
mounts,
|
|
97
|
+
async refresh() {
|
|
98
|
+
for (const link of declaredDeviceSockets()) {
|
|
99
|
+
const was = held.get(link.path);
|
|
100
|
+
if (was?.link === link) continue;
|
|
101
|
+
// The new value takes the door BEFORE the old one sheds its sockets, so a device that
|
|
102
|
+
// redials on the drain's `reconnect` frame reaches the edited handler.
|
|
103
|
+
held.set(link.path, await attach(link));
|
|
104
|
+
if (was === undefined) continue;
|
|
105
|
+
// No spread and no grace: one developer's devices, and the save is waiting on it.
|
|
106
|
+
await was.attachment.drain({ graceMs: 0, spreadMs: 0 });
|
|
107
|
+
was.attachment.stop();
|
|
108
|
+
}
|
|
109
|
+
return serve
|
|
110
|
+
? declaredDeviceSockets()
|
|
111
|
+
.map((link) => link.path)
|
|
112
|
+
.filter((path) => !served.includes(path))
|
|
113
|
+
: [];
|
|
114
|
+
},
|
|
115
|
+
async stop() {
|
|
116
|
+
stopAll();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
package/src/role-start-types.ts
CHANGED
|
@@ -108,5 +108,11 @@ export interface RunningRoles {
|
|
|
108
108
|
readonly liveBridge: LiveReplicator | null;
|
|
109
109
|
/** The sync node's registry, so a test can hold a real subscription; null without the role. */
|
|
110
110
|
readonly liveRegistry: LiveQueryRegistry | null;
|
|
111
|
+
/**
|
|
112
|
+
* `x dev`'s rescan seam (`dev-rebuild.ts`): every `deviceSocket()` a save re-declared attached
|
|
113
|
+
* and served in place of the old one, whose sockets are told to redial. Answers the declared
|
|
114
|
+
* paths this listener cannot serve — added after it bound. A container never calls it.
|
|
115
|
+
*/
|
|
116
|
+
refreshDevices(): Promise<readonly string[]>;
|
|
111
117
|
stop(): Promise<void>;
|
|
112
118
|
}
|
package/src/role-start.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
} from './metrics-endpoint';
|
|
39
39
|
import { rolesUnderRealtime } from './role-realtime';
|
|
40
40
|
import { startReplicator } from './role-replicator';
|
|
41
|
+
import { type DeviceMount, hostDeviceSockets } from './role-start-devices';
|
|
41
42
|
import type { RunningRoles, StartRolesOptions } from './role-start-types';
|
|
42
43
|
import { DEV_ROLES } from './role-start-types';
|
|
43
44
|
import { prepareSync, type RunningSync, syncReadsChanges } from './role-sync';
|
|
@@ -160,9 +161,17 @@ function rateLimitStoreFor(options: StartRolesOptions): RateLimitStore | undefin
|
|
|
160
161
|
return supplied;
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
/**
|
|
164
|
-
*
|
|
165
|
-
|
|
164
|
+
/**
|
|
165
|
+
* `mount` is the sync node's socket, served on THIS port as well as its own — why, in `role-sync`;
|
|
166
|
+
* undefined without the `sync` role. Beside it, a door per declared `deviceSocket()` path: this
|
|
167
|
+
* listener is where a device dials, and `hostDeviceSockets` made this process their host before it
|
|
168
|
+
* binds. Neither, and this server opens no websocket, as it always did.
|
|
169
|
+
*/
|
|
170
|
+
function startWeb(
|
|
171
|
+
options: StartRolesOptions,
|
|
172
|
+
devices: readonly DeviceMount[],
|
|
173
|
+
mount?: WebSocketMount<SyncWs>,
|
|
174
|
+
): ServerHandle {
|
|
166
175
|
warnIfUnauthenticatable(options.routes);
|
|
167
176
|
const binding = options.http ?? DEV_BINDING;
|
|
168
177
|
const hops = trustedHopsFromEnv(options.env);
|
|
@@ -170,7 +179,7 @@ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): S
|
|
|
170
179
|
return httpServer({
|
|
171
180
|
routes: options.routes,
|
|
172
181
|
role: 'web',
|
|
173
|
-
...(mount === undefined ?
|
|
182
|
+
websocket: [...(mount === undefined ? [] : [mount]), ...devices],
|
|
174
183
|
...(options.drain === undefined ? {} : { drain: options.drain }),
|
|
175
184
|
hooks: devHooks({
|
|
176
185
|
...(options.devNotices === undefined ? {} : { devNotices: options.devNotices }),
|
|
@@ -288,12 +297,18 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
288
297
|
// `x dev` is one process running several roles: its `process_info` says which.
|
|
289
298
|
role: selected.join('+'),
|
|
290
299
|
whenTaken: whenMetricsPortTaken(binding.dev, options.env),
|
|
300
|
+
dev: binding.dev,
|
|
291
301
|
reserved: rolePorts(options.port, selected),
|
|
292
302
|
});
|
|
293
303
|
started.push(async () => metrics.stop());
|
|
294
304
|
// Opened here or adopted from `serve-boot.ts`, this is where the build id is known (#734).
|
|
295
305
|
metrics.announceBuild(options.buildId);
|
|
296
306
|
|
|
307
|
+
// Every declared `deviceSocket()` on this process's bus, before any role serves: a job or an
|
|
308
|
+
// action anywhere may `request()` a device, and the web role holds their sockets.
|
|
309
|
+
const devices = await hostDeviceSockets(options.runtime.transport, selected);
|
|
310
|
+
started.push(() => devices.stop());
|
|
311
|
+
|
|
297
312
|
// BUILT here, BOUND below, the web role between them: `web` serves the node's socket on its
|
|
298
313
|
// own port and a listening server cannot be handed one, while the neighbouring-port refusals
|
|
299
314
|
// are only the right answer once the web port's own has been given.
|
|
@@ -305,7 +320,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
305
320
|
let releaseSync = prepared?.stop ?? null;
|
|
306
321
|
let server: ServerHandle | null;
|
|
307
322
|
try {
|
|
308
|
-
server = selected.includes('web') ? startWeb(options, prepared?.mount) : null;
|
|
323
|
+
server = selected.includes('web') ? startWeb(options, devices.mounts, prepared?.mount) : null;
|
|
309
324
|
} catch (error) {
|
|
310
325
|
await releaseSync?.().catch(() => undefined);
|
|
311
326
|
throw error;
|
|
@@ -323,6 +338,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
323
338
|
const worker = selected.includes('worker')
|
|
324
339
|
? jobWorker({
|
|
325
340
|
driver: options.runtime.jobs,
|
|
341
|
+
build: options.buildId,
|
|
326
342
|
context: () => ctxOf({ role: 'worker', buildId: options.buildId }),
|
|
327
343
|
...workerOptionsFor(options.runtime.workerConfig),
|
|
328
344
|
})
|
|
@@ -415,6 +431,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
415
431
|
liveFeed: live.feed,
|
|
416
432
|
liveBridge: live.bridge,
|
|
417
433
|
liveRegistry: sync?.registry ?? null,
|
|
434
|
+
refreshDevices: () => devices.refresh(),
|
|
418
435
|
async stop() {
|
|
419
436
|
// `started` IS the stop list, in boot order: released newest first — the live feed, the
|
|
420
437
|
// replicator (so the slot is released before the bus it published to closes), the
|
package/src/role-sync.ts
CHANGED
|
@@ -310,7 +310,7 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
310
310
|
},
|
|
311
311
|
listen: async (appUrl) => {
|
|
312
312
|
origins.push(...devOrigins(options, origins, appUrl));
|
|
313
|
-
return await listen(options, node, { registry, hub }, appUrl);
|
|
313
|
+
return await listen(options, node, { registry, hub }, appUrl, stopWaiting);
|
|
314
314
|
},
|
|
315
315
|
};
|
|
316
316
|
}
|
|
@@ -335,6 +335,8 @@ async function listen(
|
|
|
335
335
|
node: SyncNode,
|
|
336
336
|
feeds: Pick<RunningSync, 'registry' | 'hub'>,
|
|
337
337
|
appUrl: string | null,
|
|
338
|
+
/** `startOrWaitForBus`'s cancel: a node stopped while it waits must not start on a reconnect. */
|
|
339
|
+
stopWaiting: () => void,
|
|
338
340
|
): Promise<RunningSync> {
|
|
339
341
|
const port = syncPortFor(options.port);
|
|
340
342
|
try {
|
|
@@ -365,11 +367,15 @@ async function listen(
|
|
|
365
367
|
url: listener.url,
|
|
366
368
|
...feeds,
|
|
367
369
|
stop: async () => {
|
|
370
|
+
// First, as `prepareSync`'s own stop does: the bus's reconnect subscription otherwise
|
|
371
|
+
// outlived the node and called `node.start()` on it after this stop returned.
|
|
372
|
+
stopWaiting();
|
|
368
373
|
listener.stop();
|
|
369
374
|
await node.stop();
|
|
370
375
|
},
|
|
371
376
|
};
|
|
372
377
|
} catch (error) {
|
|
378
|
+
stopWaiting();
|
|
373
379
|
await node.stop();
|
|
374
380
|
const refusal = await syncBindRefusal(options.port, port);
|
|
375
381
|
if (refusal !== undefined) throw refusal;
|
package/src/route-islands.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { posix } from 'node:path';
|
|
|
7
7
|
import type { IslandCollector, RouteEntry } from '@ultimat3/render';
|
|
8
8
|
import {
|
|
9
9
|
clientBootTags,
|
|
10
|
+
hydrateRuntime,
|
|
10
11
|
islandCollector,
|
|
11
12
|
islandModuleId,
|
|
12
13
|
islandModuleIds,
|
|
@@ -71,3 +72,44 @@ export function bootScript(
|
|
|
71
72
|
});
|
|
72
73
|
return reaches ? renderHead(clientBootTags(options.sync)) : '';
|
|
73
74
|
}
|
|
75
|
+
|
|
76
|
+
/** A stream document's boot and hydration scripts: its first flush's, and each hole chunk's. */
|
|
77
|
+
export interface StreamScripts {
|
|
78
|
+
readonly first: string;
|
|
79
|
+
/** For a hole's chunk, after its reveal: `''` while nothing it shows can hold an island. */
|
|
80
|
+
readonly after: (outcome: 'filled' | 'failed') => string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* An inline module waits for parsing to end, and a document with a hole open is still parsing, so
|
|
85
|
+
* the runtime is `async` while nothing has to run before it. Once a boot is in the document it stays
|
|
86
|
+
* deferred: the boot must run first (`bootScript`), at the cost a Known-Gaps row states.
|
|
87
|
+
*
|
|
88
|
+
* `after` re-runs the whole runtime once any hole recorded an island, rather than comparing against
|
|
89
|
+
* what the last chunk took: holes record while running and reveal in completion order, so an island
|
|
90
|
+
* recorded by a hole still open would otherwise be spent on another hole's chunk, before its root is
|
|
91
|
+
* in the DOM. `each` visits a root once, so an extra run does nothing; every body is already hashed.
|
|
92
|
+
* A FAILED hole shows its `error` markup, walked at shell time — so counted in `flushed` — yet not in
|
|
93
|
+
* the DOM until this chunk: it re-runs whenever the page recorded any island at all.
|
|
94
|
+
*/
|
|
95
|
+
export function streamScripts(
|
|
96
|
+
entry: RouteEntry,
|
|
97
|
+
islands: IslandCollector,
|
|
98
|
+
options: DocumentOptions,
|
|
99
|
+
scope: string | undefined,
|
|
100
|
+
holes: boolean,
|
|
101
|
+
): StreamScripts {
|
|
102
|
+
const boot = bootScript(entry, islands, options, scope);
|
|
103
|
+
const flushed = islands.directives.length;
|
|
104
|
+
let booted = boot !== '';
|
|
105
|
+
return {
|
|
106
|
+
first: `${boot}${hydrateRuntime(islands.directives, { async: holes && !booted })}`,
|
|
107
|
+
after: (outcome) => {
|
|
108
|
+
const recorded = islands.directives.length;
|
|
109
|
+
if (recorded === (outcome === 'failed' ? 0 : flushed)) return '';
|
|
110
|
+
const late = booted ? '' : bootScript(entry, islands, options, scope);
|
|
111
|
+
booted = booted || late !== '';
|
|
112
|
+
return `${late}${hydrateRuntime(islands.directives, { async: !booted })}`;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
package/src/route-table.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { describePages } from '@ultimat3/render';
|
|
|
8
8
|
import { apiRoutes } from './api-routes';
|
|
9
9
|
import { loadAppRuntime } from './app-runtime';
|
|
10
10
|
import type { JsonValue } from './output';
|
|
11
|
+
import { plainRoutes } from './plain-routes';
|
|
11
12
|
|
|
12
13
|
/** One printed and one JSON row per served route: a page, a mounted page, or an API route. */
|
|
13
14
|
export interface RouteRow {
|
|
@@ -94,7 +95,7 @@ export function routeRows(plain: readonly Route[] = []): readonly RouteRow[] {
|
|
|
94
95
|
* since an action or query validates its own input. Loaded as `x dev` loads them.
|
|
95
96
|
*/
|
|
96
97
|
export async function plainAppRoutes(root: string): Promise<readonly Route[]> {
|
|
97
|
-
return (await loadAppRuntime(root))?.routes
|
|
98
|
+
return plainRoutes((await loadAppRuntime(root))?.routes);
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
/** Fixed-width columns so the output diffs cleanly between runs and between machines. */
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Single responsibility: arming the database's tenant guard for a booted role. With
|
|
2
|
+
// `database.rowSecurity` on, the serving role must be one Postgres applies policies to
|
|
3
|
+
// (`X_DB_ROW_SECURITY_BYPASSED` otherwise), every BEGIN carries the actor's tenant, and a bare
|
|
4
|
+
// statement is wrapped in a transaction that does. Off, nothing here runs.
|
|
5
|
+
|
|
6
|
+
import type { AppConfig } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
assertRowSecurityBinds,
|
|
9
|
+
db,
|
|
10
|
+
holdMigratorRole,
|
|
11
|
+
raw,
|
|
12
|
+
rowSecureClient,
|
|
13
|
+
serveAsAppRole,
|
|
14
|
+
setDbClient,
|
|
15
|
+
setTransactionSettings,
|
|
16
|
+
} from '@ultimat3/db';
|
|
17
|
+
import { rowSecuritySettings } from '@ultimat3/entity';
|
|
18
|
+
import { loadAppConfig } from './app-config-load';
|
|
19
|
+
|
|
20
|
+
/** `database.rowSecurity` on a loaded config; no `app.config.ts` at all is off. */
|
|
21
|
+
export function rowSecurityDeclared(config: AppConfig | undefined): boolean {
|
|
22
|
+
return config?.database.rowSecurity === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Judge the role on the ambient client, then install the seam and the wrapper. Judged FIRST: a boot
|
|
27
|
+
* refused here installs nothing, so a test that catches the refusal leaves the process as it was.
|
|
28
|
+
* `embedded` is PGlite, whose one session is a superuser and whose roles are ours: it switches to
|
|
29
|
+
* `x_app` before the judgement (`serveAsAppRole`). An external database's roles are the operator's
|
|
30
|
+
* and are only ever judged. Returns the undo, which `stop()` runs so a second boot starts clean.
|
|
31
|
+
*/
|
|
32
|
+
export async function armRowSecurity(
|
|
33
|
+
enabled: boolean,
|
|
34
|
+
options: { readonly embedded?: boolean } = {},
|
|
35
|
+
): Promise<() => Promise<void>> {
|
|
36
|
+
if (!enabled) return async () => undefined;
|
|
37
|
+
const ambient = db();
|
|
38
|
+
if (options.embedded === true) await serveAsAppRole(ambient);
|
|
39
|
+
await assertRowSecurityBinds(ambient);
|
|
40
|
+
setTransactionSettings(rowSecuritySettings);
|
|
41
|
+
setDbClient(rowSecureClient(ambient));
|
|
42
|
+
return async () => {
|
|
43
|
+
setTransactionSettings(undefined);
|
|
44
|
+
setDbClient(ambient);
|
|
45
|
+
if (options.embedded === true) await ambient.execute(raw('reset role'));
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The migrate step on PGlite, whose roles are ours — never called for an external database, whose
|
|
51
|
+
* migrator is the operator's `DATABASE_URL`. With the flag on, the app's migrations run as
|
|
52
|
+
* `x_migrator`, so it owns what they create and `x_app`, what `x dev` serves as, owns nothing.
|
|
53
|
+
* Off, nothing runs. Returns the undo.
|
|
54
|
+
*/
|
|
55
|
+
export async function migrateAsMigrator(root: string): Promise<() => Promise<void>> {
|
|
56
|
+
if (!rowSecurityDeclared(await loadAppConfig(root))) return async () => undefined;
|
|
57
|
+
return holdMigratorRole(db());
|
|
58
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Single responsibility: what `x db gen` and the gate's `drift` step read about row security — the
|
|
2
|
+
// app's `database.rowSecurity` and each tenant table's column — and the drift step's verdict on it.
|
|
3
|
+
// One reader for both, so the gate can never expect a policy the generator would not write.
|
|
4
|
+
|
|
5
|
+
import type { EntityDescriptionLike, RowSecurityOption, SchemaDescription } from '@ultimat3/db';
|
|
6
|
+
import { rowSecurityDifferences, rowSecurityDrift } from '@ultimat3/db';
|
|
7
|
+
import { describeTenancy } from '@ultimat3/entity';
|
|
8
|
+
import { loadAppConfig } from './app-config-load';
|
|
9
|
+
import { MIGRATIONS_DIR } from './migrations';
|
|
10
|
+
import type { Finding } from './output';
|
|
11
|
+
import { findingFrom } from './output';
|
|
12
|
+
import { rowSecurityDeclared } from './row-security-boot';
|
|
13
|
+
|
|
14
|
+
/** The app's answer, read after `loadApp` filled the registry. Injected for tests. */
|
|
15
|
+
export type RowSecurityWanted = (root: string) => Promise<RowSecurityOption>;
|
|
16
|
+
|
|
17
|
+
export const appRowSecurity: RowSecurityWanted = async (root) => ({
|
|
18
|
+
enabled: rowSecurityDeclared(await loadAppConfig(root)),
|
|
19
|
+
tenants: describeTenancy(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** One `X_DB_ROW_SECURITY_DRIFT` per tenant table the flag and the newest snapshot disagree on. */
|
|
23
|
+
export function rowSecurityFindings(
|
|
24
|
+
entities: readonly EntityDescriptionLike[],
|
|
25
|
+
recorded: SchemaDescription,
|
|
26
|
+
option: RowSecurityOption,
|
|
27
|
+
): readonly Finding[] {
|
|
28
|
+
return rowSecurityDifferences(entities, recorded, option).map((difference) => ({
|
|
29
|
+
...findingFrom(rowSecurityDrift(difference)),
|
|
30
|
+
at: MIGRATIONS_DIR,
|
|
31
|
+
}));
|
|
32
|
+
}
|
package/src/runtime-admin.ts
CHANGED
|
@@ -44,7 +44,6 @@ const entryFor = (route: AdminRouteConfig, body: unknown): RouteEntry => ({
|
|
|
44
44
|
// An admin screen is behind sign-in: it carries the `app/` surface's stylesheet, never `site/`'s.
|
|
45
45
|
surface: 'app',
|
|
46
46
|
config: route.config,
|
|
47
|
-
suspenseBoundaries: 0,
|
|
48
47
|
islands: [],
|
|
49
48
|
pattern: compilePattern(route.path),
|
|
50
49
|
component: () => body,
|