@ultimat3/cli 29.3.2 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +13 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +28 -8
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +42 -138
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +17 -27
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1313 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
package/src/runtime-render.ts
CHANGED
|
@@ -3,81 +3,59 @@
|
|
|
3
3
|
// document (head + the route's component, the surface's CSS LINKED, `style-bundle.ts`), it never
|
|
4
4
|
// decides what a mode means or what headers it earns.
|
|
5
5
|
|
|
6
|
-
import { actionPathStyle } from '@ultimat3/action';
|
|
7
6
|
import { clientScopeOf } from '@ultimat3/auth';
|
|
8
7
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
9
|
-
import { CLIENT_SCOPE_HEADER,
|
|
8
|
+
import { CLIENT_SCOPE_HEADER, singleFlight } from '@ultimat3/core';
|
|
10
9
|
import type {
|
|
11
10
|
RouteMeta as HttpRouteMeta,
|
|
12
11
|
RedirectIntent,
|
|
13
12
|
RequestContext,
|
|
14
13
|
Route,
|
|
15
|
-
RouteParams,
|
|
16
14
|
UltimateRequest,
|
|
17
15
|
} from '@ultimat3/http';
|
|
18
|
-
import { asCtx, html, NO_STORE, redirect, stream, takeRedirect } from '@ultimat3/http';
|
|
19
|
-
import { currentLocale } from '@ultimat3/i18n';
|
|
20
|
-
import type { IslandCollector, RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
|
|
21
16
|
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
asCtx,
|
|
18
|
+
html,
|
|
19
|
+
NO_STORE,
|
|
20
|
+
redirect,
|
|
21
|
+
stream,
|
|
22
|
+
takeRedirect,
|
|
23
|
+
withAnonymousRequest,
|
|
24
|
+
} from '@ultimat3/http';
|
|
25
|
+
import type { RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
|
|
26
|
+
import {
|
|
26
27
|
documentCarriesScope,
|
|
27
|
-
headFromMeta,
|
|
28
|
-
hydrateRuntime,
|
|
29
|
-
metaContextFor,
|
|
30
|
-
renderHead,
|
|
31
28
|
routeDataFor,
|
|
32
29
|
routeEntries,
|
|
33
30
|
routeFor,
|
|
34
31
|
routeStatusOf,
|
|
35
|
-
seoRenderers,
|
|
36
32
|
} from '@ultimat3/render';
|
|
37
33
|
import type { IsrController } from '@ultimat3/render/server';
|
|
38
34
|
import {
|
|
39
35
|
contentHash,
|
|
40
|
-
|
|
41
|
-
renderComponent,
|
|
36
|
+
holeSink,
|
|
42
37
|
renderSsr,
|
|
43
38
|
ssrHeaders,
|
|
44
39
|
staticHeaders,
|
|
45
40
|
streamResult,
|
|
46
41
|
} from '@ultimat3/render/server';
|
|
47
|
-
import { loadAppConfig } from './app-config-load';
|
|
48
|
-
import type { DocumentOptions } from './document-options';
|
|
49
|
-
import { routeQuery } from './isr-query-guard';
|
|
50
42
|
import {
|
|
51
43
|
type NavigationDocumentHead,
|
|
52
44
|
navigationMetaOf,
|
|
53
|
-
navigationTagsOf,
|
|
54
45
|
principalRelocation,
|
|
55
46
|
} from './page-navigation';
|
|
56
|
-
import {
|
|
47
|
+
import { collectorFor, streamScripts } from './route-islands';
|
|
57
48
|
import { addressedUrl } from './runtime-address';
|
|
58
49
|
import { attachedIsr } from './runtime-isr';
|
|
59
50
|
import { isrOutcome } from './runtime-isr-outcome';
|
|
60
|
-
import {
|
|
51
|
+
import type { DevRouteData, RenderOptions } from './runtime-render-document';
|
|
52
|
+
import { documentFrom, headFor, lang, routeBody, stylesFor } from './runtime-render-document';
|
|
61
53
|
import type { StaticResult } from './static-document';
|
|
62
54
|
import { createStaticMemo, staticMemoKey, staticResponse } from './static-document';
|
|
63
|
-
import {
|
|
55
|
+
import { enterHole } from './stream-hole-scope';
|
|
64
56
|
|
|
65
57
|
export type { DocumentOptions, IslandResolver } from './document-options';
|
|
66
58
|
|
|
67
|
-
/**
|
|
68
|
-
* The app-wide favicon head, from `site.favicon`: the content-hashed URL `asset('assets/….svg')`
|
|
69
|
-
* returns and `/assets/*` serves, never the raw path. Per render, never frozen at boot.
|
|
70
|
-
*/
|
|
71
|
-
export async function appFaviconHead(root: string): Promise<string | undefined> {
|
|
72
|
-
const favicon = (await loadAppConfig(root))?.site.favicon;
|
|
73
|
-
if (favicon === undefined) return undefined;
|
|
74
|
-
const url = siteAssetTable(root).resolve(favicon).url;
|
|
75
|
-
return `<link rel="icon" type="image/svg+xml" href="${escapeHtml(url)}">`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** `DocumentOptions`, plus the app root the app's own favicon is resolved against per render. */
|
|
79
|
-
export type RenderOptions = DocumentOptions & { readonly root?: string };
|
|
80
|
-
|
|
81
59
|
export interface DevRenderOptions extends RenderOptions {
|
|
82
60
|
readonly buildId: string;
|
|
83
61
|
/**
|
|
@@ -92,130 +70,6 @@ export interface DevRenderOptions extends RenderOptions {
|
|
|
92
70
|
*/
|
|
93
71
|
readonly memoStatic?: boolean;
|
|
94
72
|
}
|
|
95
|
-
/** What a route's `meta(data)` is given. `url` is a string because that is what `ld.*` embeds. */
|
|
96
|
-
export interface DevRouteData extends Record<string, unknown> {
|
|
97
|
-
readonly url: string;
|
|
98
|
-
readonly params: RouteParams;
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* `<html lang>` is the request's own locale, never a constant: the `locale` stage negotiated it
|
|
102
|
-
* one stage before this handler and published it on the context, so a hardcoded `'en'` shipped
|
|
103
|
-
* every document mislabelled — wrong for a screen reader, wrong for `hreflang`. Outside a request
|
|
104
|
-
* (`x build`'s prerender) it is the app's own configured fallback.
|
|
105
|
-
*/
|
|
106
|
-
const lang = (): string => currentLocale();
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* `scope` is present only on a PRIVATE document (a gated `ssr` page, every `stream`): the page's
|
|
110
|
-
* client scope (`@ultimat3/auth`'s `clientScopeOf`), which core's `pageClient()` reads to fence its
|
|
111
|
-
* one store per principal. A shareable document carries NO scope tag — absent means "not rendered
|
|
112
|
-
* for anyone", a different answer from `''`, the anonymous page.
|
|
113
|
-
*/
|
|
114
|
-
const headFor = async (
|
|
115
|
-
entry: RouteEntry,
|
|
116
|
-
ctx: DevRouteData,
|
|
117
|
-
data: RouteData,
|
|
118
|
-
options: RenderOptions,
|
|
119
|
-
scope?: string,
|
|
120
|
-
): Promise<string> => {
|
|
121
|
-
const meta = metaContextFor(ctx, data);
|
|
122
|
-
const url = new URL(ctx.url);
|
|
123
|
-
const router = navigationTagsOf(options.navigation, entry, meta.t);
|
|
124
|
-
return (
|
|
125
|
-
renderHead(
|
|
126
|
-
headFromMeta(
|
|
127
|
-
await entry.config.meta(meta),
|
|
128
|
-
seoRenderers({
|
|
129
|
-
path: url.pathname,
|
|
130
|
-
baseUrl: options.origin ?? url.origin,
|
|
131
|
-
// The default is the cluster's own head (`alternates` is default-first by contract), so
|
|
132
|
-
// `x-default` comes from the list the hreflang tags do — never a second locale read.
|
|
133
|
-
localization: {
|
|
134
|
-
locale: meta.locale,
|
|
135
|
-
defaultLocale: meta.alternates[0]?.locale ?? meta.locale,
|
|
136
|
-
alternates: meta.alternates,
|
|
137
|
-
},
|
|
138
|
-
}),
|
|
139
|
-
[
|
|
140
|
-
...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
|
|
141
|
-
// Every document, shareable ones included: the style is the app's, not a visitor's, and
|
|
142
|
-
// it is the only way an island's `rpc()` learns it. Read per render — `defineApi` may run
|
|
143
|
-
// after these routes are built. `'resource'`, the default, is no tag.
|
|
144
|
-
...clientPathStyleTags(actionPathStyle()),
|
|
145
|
-
...router,
|
|
146
|
-
// The page boot rides the scope tag: its whole job — restoring a principal's persisted
|
|
147
|
-
// records and replaying its queued writes — is per principal, and a shareable document
|
|
148
|
-
// (no scope tag) has neither.
|
|
149
|
-
...(scope === undefined
|
|
150
|
-
? []
|
|
151
|
-
: [clientScopeTag(scope), ...clientPersistTags(options.persisted?.() ?? [])]),
|
|
152
|
-
],
|
|
153
|
-
),
|
|
154
|
-
) +
|
|
155
|
-
(options.themeHead ?? '') +
|
|
156
|
-
// One mechanism per document: the router prefetches for itself, the browser for the rest.
|
|
157
|
-
(router.length === 0 ? (options.speculationHead ?? '') : '') +
|
|
158
|
-
// The app's own favicon: one link, every document, the URL the table resolves NOW.
|
|
159
|
-
((options.root === undefined ? options.faviconHead : await appFaviconHead(options.root)) ??
|
|
160
|
-
'') +
|
|
161
|
-
// Per locale: each links its own locale's manifest (`PwaArtifacts.headFor`).
|
|
162
|
-
(typeof options.pwaHead === 'function' ? options.pwaHead(meta.locale) : (options.pwaHead ?? ''))
|
|
163
|
-
);
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* `<link rel="stylesheet">` for the surface's own stylesheets, or nothing at all when the surface
|
|
168
|
-
* imports none.
|
|
169
|
-
*
|
|
170
|
-
* In `<head>`, which is what keeps this a swap and not a regression: a `<link rel="stylesheet">`
|
|
171
|
-
* there is render-blocking in every browser, exactly as the inline block was, so there is no window
|
|
172
|
-
* in which the document paints unstyled. Moving it to the body, or deferring it, would introduce a
|
|
173
|
-
* flash — never do that here.
|
|
174
|
-
*
|
|
175
|
-
* Read through `styleBundle()` rather than passed in through `DocumentOptions`: the registry it
|
|
176
|
-
* derives from is process-global (importing the app IS what fills it), so a caller that forgot to
|
|
177
|
-
* thread a resolver would serve a document with no CSS at all. One reader, and it is the same one
|
|
178
|
-
* `styleRoutes` serves from.
|
|
179
|
-
*/
|
|
180
|
-
const styleTag = (entry: RouteEntry): string => {
|
|
181
|
-
const href = styleBundle().hrefFor(entry.surface);
|
|
182
|
-
return href === undefined ? '' : `<link rel="stylesheet" href="${href}">`;
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
/** The surface stylesheet, then the app's brand — the order that lets the brand win the cascade. */
|
|
186
|
-
const stylesFor = (entry: RouteEntry, options: DocumentOptions): string =>
|
|
187
|
-
styleTag(entry) + (options.brandHead ?? '');
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* The route's rendered body, inside the hydration root. Every mode goes through here — an empty
|
|
191
|
-
* root is what a module exporting no component renders, and nothing else: `spa` was the one mode
|
|
192
|
-
* that never reached this function, so every `spa` route ever declared served an empty root.
|
|
193
|
-
*/
|
|
194
|
-
export async function routeBody(
|
|
195
|
-
entry: RouteEntry,
|
|
196
|
-
ctx: DevRouteData,
|
|
197
|
-
data: RouteData,
|
|
198
|
-
islands: IslandCollector,
|
|
199
|
-
): Promise<string> {
|
|
200
|
-
if (entry.component === undefined) return `<div id="${ROOT_ELEMENT_ID}"></div>`;
|
|
201
|
-
const url = new URL(ctx.url);
|
|
202
|
-
const html = await renderComponent(
|
|
203
|
-
entry.component,
|
|
204
|
-
// `data` is the route's own `load` result and is what `meta` was just given — the same object,
|
|
205
|
-
// never a second resolution. `query` is supplied because a page that reads `props.query.x`
|
|
206
|
-
// otherwise dereferences undefined and takes the whole render down.
|
|
207
|
-
{
|
|
208
|
-
data,
|
|
209
|
-
params: ctx.params,
|
|
210
|
-
url: ctx.url,
|
|
211
|
-
query: routeQuery(entry, url),
|
|
212
|
-
},
|
|
213
|
-
entry.file,
|
|
214
|
-
{ islands },
|
|
215
|
-
);
|
|
216
|
-
return `<div id="${ROOT_ELEMENT_ID}">${html}</div>`;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
73
|
/**
|
|
220
74
|
* Head + body for one route render — the document `x dev` serves, which the build's prerenderer
|
|
221
75
|
* must emit too.
|
|
@@ -262,33 +116,6 @@ function documentScope(entry: RouteEntry, buildId: string, actor: Actor): string
|
|
|
262
116
|
}
|
|
263
117
|
}
|
|
264
118
|
|
|
265
|
-
/**
|
|
266
|
-
* The document from data ALREADY resolved. Split from `routeDocument` so one request resolves
|
|
267
|
-
* `load` exactly once: `stream` renders head and body separately, and resolving in each would let
|
|
268
|
-
* a `<title>` describe content the body does not contain.
|
|
269
|
-
*
|
|
270
|
-
* The hydration runtime is appended after the body and only after it: what strategies a page needs
|
|
271
|
-
* is a fact about the islands the walk just recorded, so emitting it earlier would guess or ship
|
|
272
|
-
* the whole runtime to a page with no island.
|
|
273
|
-
*/
|
|
274
|
-
async function documentFrom(
|
|
275
|
-
entry: RouteEntry,
|
|
276
|
-
ctx: DevRouteData,
|
|
277
|
-
data: RouteData,
|
|
278
|
-
options: RenderOptions,
|
|
279
|
-
scope?: string,
|
|
280
|
-
): Promise<string> {
|
|
281
|
-
const islands = collectorFor(entry, options, scope);
|
|
282
|
-
const [head, body] = await Promise.all([
|
|
283
|
-
headFor(entry, ctx, data, options, scope),
|
|
284
|
-
routeBody(entry, ctx, data, islands),
|
|
285
|
-
]);
|
|
286
|
-
return (
|
|
287
|
-
`<!doctype html><html lang="${lang()}"><head>${head}${stylesFor(entry, options)}</head>` +
|
|
288
|
-
`<body>${body}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}</body></html>`
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
119
|
/** Every mode but `isr`, which `renderIsr` answers: its `load` runs only when the store misses. */
|
|
293
120
|
async function resultFor(
|
|
294
121
|
entry: RouteEntry,
|
|
@@ -309,17 +136,18 @@ async function resultFor(
|
|
|
309
136
|
return { status, headers: staticHeaders(contentHash(body), options.buildId), body };
|
|
310
137
|
}
|
|
311
138
|
case 'stream': {
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
// the whole body — correct output, no streaming benefit.
|
|
139
|
+
// Everything outside a `<Hole>` is the first flush; every hole fills after it, in the
|
|
140
|
+
// request's context, in completion order. `status` and any redirect were decided by `load`
|
|
141
|
+
// above: a hole starts after the headers left, so its failure is its own error markup.
|
|
316
142
|
// A stream is always `private, no-store` (`streamResult`), so it always carries the scope.
|
|
317
143
|
const scope = documentScope(entry, options.buildId, ctx.actor);
|
|
318
144
|
const islands = collectorFor(entry, options, scope);
|
|
145
|
+
const sink = holeSink({ enter: enterHole });
|
|
319
146
|
const [head, shell] = await Promise.all([
|
|
320
147
|
headFor(entry, request, data, options, scope),
|
|
321
|
-
routeBody(entry, request, data, islands),
|
|
148
|
+
routeBody(entry, request, data, islands, sink),
|
|
322
149
|
]);
|
|
150
|
+
const scripts = streamScripts(entry, islands, options, scope, sink.holes.length > 0);
|
|
323
151
|
return withScope(
|
|
324
152
|
streamResult(
|
|
325
153
|
{
|
|
@@ -327,8 +155,8 @@ async function resultFor(
|
|
|
327
155
|
// The runtime rides the first flush, with the shell it boots. A later chunk would leave
|
|
328
156
|
// the window between flush one and the close with inert islands and no listeners on
|
|
329
157
|
// them — the first-click-lost failure `interaction` replay exists for.
|
|
330
|
-
shell: `${shell}${
|
|
331
|
-
holes:
|
|
158
|
+
shell: `${shell}${scripts.first}`,
|
|
159
|
+
holes: sink.holes.map((hole) => ({ ...hole, after: scripts.after })),
|
|
332
160
|
},
|
|
333
161
|
{ buildId: options.buildId },
|
|
334
162
|
status,
|
|
@@ -428,7 +256,7 @@ export function appRoutes(options: DevRenderOptions): readonly Route[] {
|
|
|
428
256
|
// A static page already rendered by this process is answered without `load` or a render.
|
|
429
257
|
const key =
|
|
430
258
|
options.memoStatic === true
|
|
431
|
-
? staticMemoKey(entry, addressedUrl(request.url, ctx), asCtx(ctx).locale)
|
|
259
|
+
? staticMemoKey(entry, addressedUrl(request.url, ctx), asCtx(ctx).locale, options.origin)
|
|
432
260
|
: undefined;
|
|
433
261
|
const kept = key === undefined ? undefined : memo.get(key);
|
|
434
262
|
if (kept !== undefined) return staticResponse(request, kept);
|
|
@@ -468,6 +296,21 @@ async function renderEntry(
|
|
|
468
296
|
const at = entry.config.render === 'static' ? addressedUrl(request.url, ctx) : request.url;
|
|
469
297
|
const data: DevRouteData = { url: at.href, params: ctx.params };
|
|
470
298
|
if (entry.config.render === 'isr') return renderIsr(entry, data, ctx, options, isr);
|
|
299
|
+
// A `static` document is kept and answered to every later visitor, so it is rendered as nobody,
|
|
300
|
+
// `load` and document both: the memo and a CDN would otherwise hold this visitor's answer.
|
|
301
|
+
if (entry.config.render === 'static') {
|
|
302
|
+
return withAnonymousRequest(ctx, at, (scope) => loadAndRender(entry, data, scope, options));
|
|
303
|
+
}
|
|
304
|
+
return loadAndRender(entry, data, ctx, options);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Every mode but `isr`, whose `load` is inside its producer (`runtime-isr-outcome.ts`). */
|
|
308
|
+
async function loadAndRender(
|
|
309
|
+
entry: RouteEntry,
|
|
310
|
+
data: DevRouteData,
|
|
311
|
+
ctx: RequestContext,
|
|
312
|
+
options: DevRenderOptions,
|
|
313
|
+
): Promise<Rendered> {
|
|
471
314
|
// ONCE per request, before the mode is chosen: every branch of `resultFor` reads this same
|
|
472
315
|
// object, so a route's `load` runs exactly once however its mode splits head from body.
|
|
473
316
|
const loaded = await routeDataFor(entry.config, data);
|
|
@@ -494,7 +337,14 @@ async function renderIsr(
|
|
|
494
337
|
): Promise<Rendered> {
|
|
495
338
|
const document = (loaded: RouteData, at: DevRouteData): Promise<string> =>
|
|
496
339
|
documentFrom(entry, at, loaded, options);
|
|
497
|
-
const outcome = await isrOutcome({
|
|
340
|
+
const outcome = await isrOutcome({
|
|
341
|
+
entry,
|
|
342
|
+
data,
|
|
343
|
+
ctx,
|
|
344
|
+
isr,
|
|
345
|
+
document,
|
|
346
|
+
origin: options.origin,
|
|
347
|
+
});
|
|
498
348
|
if (outcome.kind === 'redirect') return { respond: () => loadRedirect(entry, outcome.to) };
|
|
499
349
|
return { respond: () => responseOf(outcome.result) };
|
|
500
350
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Single responsibility: the replica scope's request frame, and the position it carries. A request
|
|
2
|
+
// that wrote answers with the primary's WAL position (`__Host-x-pos` and `x-ultimate-position`);
|
|
3
|
+
// a later request that brings it back reads a replica only once that replica has replayed it.
|
|
4
|
+
|
|
5
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import { db, formatPosition, parsePosition, primaryPosition, withReplicaReads } from '@ultimat3/db';
|
|
7
|
+
import {
|
|
8
|
+
type Middleware,
|
|
9
|
+
type RequestContext,
|
|
10
|
+
setCookie,
|
|
11
|
+
type UltimateRequest,
|
|
12
|
+
} from '@ultimat3/http';
|
|
13
|
+
|
|
14
|
+
export const POSITION_COOKIE = '__Host-x-pos';
|
|
15
|
+
export const POSITION_HEADER = 'x-ultimate-position';
|
|
16
|
+
/** How long a write's position is carried: past it, a replica is assumed to have caught up. */
|
|
17
|
+
export const POSITION_STICKY_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The position this request carries, parsed — the header an API client echoes, else the cookie a
|
|
21
|
+
* browser does. Never the raw value: a forged one costs the sender a primary read, nothing more.
|
|
22
|
+
*/
|
|
23
|
+
export function carriedPosition(request: UltimateRequest): bigint | undefined {
|
|
24
|
+
return (
|
|
25
|
+
parsePosition(request.header(POSITION_HEADER)) ?? parsePosition(request.cookie(POSITION_COOKIE))
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stamps the position on the request's response-header bag, which the `response` stage lays onto
|
|
31
|
+
* whatever Response the handler built — a `Response.redirect`, the write-then-redirect this exists
|
|
32
|
+
* for, included. The cookie through `setCookie`, so its attributes are core's one serializer's.
|
|
33
|
+
*/
|
|
34
|
+
function stamp(ctx: RequestContext, position: bigint, stickyMs: number): void {
|
|
35
|
+
const text = formatPosition(position);
|
|
36
|
+
ctx.headers.set(POSITION_HEADER, text);
|
|
37
|
+
setCookie(POSITION_COOKIE, text, { maxAge: Math.ceil(stickyMs / 1000) });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Opens the scope with the carried position, runs the request, and — only when it wrote — reads the
|
|
42
|
+
* primary's position ONCE after every transaction in it committed. Inside the scope, so the read
|
|
43
|
+
* itself is the primary's: a scope that wrote routes nothing to the replica.
|
|
44
|
+
*/
|
|
45
|
+
export function positionStage(stickyMs: number = POSITION_STICKY_MS): Middleware {
|
|
46
|
+
return (request, ctx, next) =>
|
|
47
|
+
withReplicaReads(
|
|
48
|
+
async (scope) => {
|
|
49
|
+
const response = await next(request, ctx);
|
|
50
|
+
if (!scope.wrote) return response;
|
|
51
|
+
const position = await primaryPosition(db()).catch((error: unknown) => {
|
|
52
|
+
// The write committed and the answer is ready: a lost position costs the NEXT request a
|
|
53
|
+
// possibly stale replica read within the sticky window, never this request its answer.
|
|
54
|
+
logger.warn('db.replica_position_unread', { error: renderThrowable(error) });
|
|
55
|
+
return undefined;
|
|
56
|
+
});
|
|
57
|
+
if (position !== undefined) stamp(ctx, position, stickyMs);
|
|
58
|
+
return response;
|
|
59
|
+
},
|
|
60
|
+
{ minPosition: carriedPosition(request) },
|
|
61
|
+
);
|
|
62
|
+
}
|
package/src/runtime-replica.ts
CHANGED
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
// what a database is.
|
|
18
18
|
|
|
19
19
|
import type { DbClient, PoolProfile, PostgresClient } from '@ultimat3/db';
|
|
20
|
-
import { postgresClient, REPLICA_URL_ENV, replicatedClient
|
|
20
|
+
import { POOLER_ENV, postgresClient, REPLICA_URL_ENV, replicatedClient } from '@ultimat3/db';
|
|
21
21
|
import type { Middleware } from '@ultimat3/http';
|
|
22
22
|
import type { ServiceBinding } from './runtime-bindings';
|
|
23
23
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
24
|
+
import { positionStage } from './runtime-replica-position';
|
|
24
25
|
|
|
25
26
|
export type ReplicaEnv = Readonly<Record<string, string | undefined>>;
|
|
26
27
|
|
|
@@ -44,6 +45,9 @@ export interface ReplicaAttachment {
|
|
|
44
45
|
readonly replica: PostgresClient | undefined;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
const isPool = (client: DbClient): client is PostgresClient =>
|
|
49
|
+
'profile' in client && 'pooler' in client;
|
|
50
|
+
|
|
47
51
|
/**
|
|
48
52
|
* The primary as it is, or the primary with a standby behind it. Composed here rather than by
|
|
49
53
|
* calling `defaultClient()`, because that function builds its own primary from `DATABASE_URL` and
|
|
@@ -57,7 +61,14 @@ export function attachReplica(
|
|
|
57
61
|
profile: Partial<PoolProfile> = {},
|
|
58
62
|
): ReplicaAttachment {
|
|
59
63
|
if (replicaUrl === undefined) return { client: primary, replica: undefined };
|
|
60
|
-
const replica = postgresClient({
|
|
64
|
+
const replica = postgresClient({
|
|
65
|
+
url: replicaUrl,
|
|
66
|
+
applicationName: 'ultimate-replica',
|
|
67
|
+
// The primary's answers, never a second read of the environment: one process, one role's
|
|
68
|
+
// numbers and one pooler mode. An embedded primary has neither, and the defaults stand.
|
|
69
|
+
profile: isPool(primary) ? { ...primary.profile, ...profile } : profile,
|
|
70
|
+
env: { [POOLER_ENV]: isPool(primary) ? primary.pooler : undefined },
|
|
71
|
+
});
|
|
61
72
|
return { client: replicatedClient(primary, replica), replica };
|
|
62
73
|
}
|
|
63
74
|
|
|
@@ -66,13 +77,17 @@ export function attachReplica(
|
|
|
66
77
|
* every later read in it to the primary, which is what read-your-writes means. `compose()` wraps
|
|
67
78
|
* each route handler, and every read and write a request makes happens inside one.
|
|
68
79
|
*
|
|
80
|
+
* The same frame carries read-your-writes ACROSS requests: the position a write produced goes back
|
|
81
|
+
* as a cookie and a header, and the next request's reads wait for a replica that has replayed it
|
|
82
|
+
* (`runtime-replica-position.ts`).
|
|
83
|
+
*
|
|
69
84
|
* EMPTY when no replica is configured, and that is the whole cost argument: a homework app
|
|
70
85
|
* installs no middleware, allocates no scope object and enters no `AsyncLocalStorage` run per
|
|
71
86
|
* request. With one configured, the list is a single frame.
|
|
72
87
|
*/
|
|
73
88
|
export function replicaMiddleware(binding: ServiceBinding, env: ReplicaEnv): readonly Middleware[] {
|
|
74
89
|
if (replicaUrlFor(binding, env) === undefined) return [];
|
|
75
|
-
return [(
|
|
90
|
+
return [positionStage()];
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
/**
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Single responsibility: the one reader of `S3_FORCE_PATH_STYLE`. A gateway addressed by host and port
|
|
2
|
+
// needs path style and R2 and AWS do not, so it is declared, never sniffed from the endpoint. It read
|
|
3
|
+
// only `'1'`, so `true` silently selected virtual-host style and every request missed the bucket.
|
|
4
|
+
|
|
5
|
+
import { ConfigInvalidError, renderCauseValue } from '@ultimat3/core';
|
|
6
|
+
import type { Env } from './runtime-bindings';
|
|
7
|
+
|
|
8
|
+
/** `1` is on; `0`, unset and blank are off, as a blank `S3_*` is unset everywhere else on the disk. */
|
|
9
|
+
export function s3PathStyleOf(env: Env): boolean {
|
|
10
|
+
const value = env['S3_FORCE_PATH_STYLE'];
|
|
11
|
+
if (value === undefined || value === '' || value === '0') return false;
|
|
12
|
+
if (value === '1') return true;
|
|
13
|
+
throw new ConfigInvalidError({
|
|
14
|
+
cause: `S3_FORCE_PATH_STYLE is ${renderCauseValue(value)}, and only 0 and 1 say which addressing the disk uses`,
|
|
15
|
+
fix: 'S3_FORCE_PATH_STYLE=1 # a gateway addressed by host and port; 0 for AWS and R2',
|
|
16
|
+
});
|
|
17
|
+
}
|
package/src/runtime-services.ts
CHANGED
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
// boot installs, only backed by embedded drivers.
|
|
5
5
|
|
|
6
6
|
import { mkdirSync } from 'node:fs';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
configureAuthLimiters,
|
|
9
|
+
configureTotpReplay,
|
|
10
|
+
postgresAuthLimiter,
|
|
11
|
+
postgresTotpReplayGuard,
|
|
12
|
+
resetAuthLimiters,
|
|
13
|
+
resetTotpReplay,
|
|
14
|
+
} from '@ultimat3/auth';
|
|
8
15
|
import type { PurgeDriver } from '@ultimat3/cache';
|
|
9
16
|
import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
|
|
10
17
|
import {
|
|
@@ -43,9 +50,10 @@ import { inboxRetentionOf } from './runtime-notify-retention';
|
|
|
43
50
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
44
51
|
import { installRetentionSweep } from './runtime-purge';
|
|
45
52
|
import { installAppWebPush, selectWebPush } from './runtime-push';
|
|
46
|
-
import type
|
|
47
|
-
import { startQueue } from './runtime-queue';
|
|
53
|
+
import { type DevDbClient, type SchemaMode, startQueue } from './runtime-queue';
|
|
48
54
|
import { realtimeConfigOf } from './runtime-realtime';
|
|
55
|
+
import { s3PathStyleOf } from './runtime-s3-path-style';
|
|
56
|
+
import { reportStatementBound } from './runtime-statement-bound';
|
|
49
57
|
|
|
50
58
|
export interface RunningServices {
|
|
51
59
|
readonly services: DevServices;
|
|
@@ -194,9 +202,7 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
|
|
|
194
202
|
bucket,
|
|
195
203
|
endpoint: binding.url,
|
|
196
204
|
...(env['S3_REGION'] === undefined ? {} : { region: env['S3_REGION'] }),
|
|
197
|
-
|
|
198
|
-
// never sniffed from the endpoint.
|
|
199
|
-
forcePathStyle: env['S3_FORCE_PATH_STYLE'] === '1',
|
|
205
|
+
forcePathStyle: s3PathStyleOf(env),
|
|
200
206
|
}),
|
|
201
207
|
},
|
|
202
208
|
default: 'object',
|
|
@@ -361,6 +367,7 @@ export async function startServices(
|
|
|
361
367
|
// a different answer than the middleware that routes to it.
|
|
362
368
|
const queue = await startQueue(services, overrides, env, schema);
|
|
363
369
|
const { db, jobs, outbox, events } = queue;
|
|
370
|
+
await reportStatementBound(db);
|
|
364
371
|
// The same executor the jobs driver, the outbox, the event bus and the idempotency store run
|
|
365
372
|
// on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
|
|
366
373
|
// `undefined`), one wrapper.
|
|
@@ -388,6 +395,11 @@ export async function startServices(
|
|
|
388
395
|
postgresAuthLimiter({ executor, clock: systemClock, policy }),
|
|
389
396
|
);
|
|
390
397
|
started.push(() => resetAuthLimiters());
|
|
398
|
+
// Where spent TOTP steps are kept, for the same reason: a code one replica accepted must be
|
|
399
|
+
// refused by the rest, and `defineAuth` runs when the app's modules import.
|
|
400
|
+
const totpReplay = postgresTotpReplayGuard({ executor });
|
|
401
|
+
configureTotpReplay(() => totpReplay);
|
|
402
|
+
started.push(() => resetTotpReplay());
|
|
391
403
|
// Where an action's or a query's declared `rateLimit:` is counted — on every surface, so a
|
|
392
404
|
// task or a one-off command spends from the fleet's table too, not one process' memory. Before
|
|
393
405
|
// `loadApp` for the auth limiters' reason; `startRoles` stacks a deployment's override over it.
|
|
@@ -403,6 +415,7 @@ export async function startServices(
|
|
|
403
415
|
installRetentionSweep({
|
|
404
416
|
idempotency: queue.idempotency,
|
|
405
417
|
rateLimit: rateLimitStore,
|
|
418
|
+
totpReplay,
|
|
406
419
|
events,
|
|
407
420
|
...jobsRetentionTargets(executor, config),
|
|
408
421
|
inboxRetention: inboxRetentionOf(config),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Single responsibility: the one boot line for a statement bound that is not the role's. A report,
|
|
2
|
+
// never a refusal: a fleet already running on a pooler that drops the bound must not stop on upgrade.
|
|
3
|
+
|
|
4
|
+
import { logger, renderThrowable, resolveRole } from '@ultimat3/core';
|
|
5
|
+
import type { DbClient, PostgresClient } from '@ultimat3/db';
|
|
6
|
+
import { lostStatementBound, statementBound } from '@ultimat3/db';
|
|
7
|
+
|
|
8
|
+
/** PGlite has no profile and no `options`, and a replica's bound is read by nothing at boot. */
|
|
9
|
+
const isPool = (db: DbClient): db is PostgresClient => 'profile' in db && 'pooler' in db;
|
|
10
|
+
|
|
11
|
+
/** Total: a boot that got past `db.ping()` does not fail on a report about itself. */
|
|
12
|
+
export async function reportStatementBound(db: DbClient): Promise<void> {
|
|
13
|
+
if (!isPool(db)) return;
|
|
14
|
+
try {
|
|
15
|
+
const lost = lostStatementBound(await statementBound(db), resolveRole(), db.pooler);
|
|
16
|
+
if (lost !== undefined) {
|
|
17
|
+
logger.error(lost.format(), { code: lost.code, ...lost.meta });
|
|
18
|
+
}
|
|
19
|
+
} catch (error) {
|
|
20
|
+
logger.debug('db.statement_bound_unread', { error: renderThrowable(error) });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -8,6 +8,7 @@ import type { RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
|
8
8
|
import { jsonResponse, NO_STORE } from '@ultimat3/http';
|
|
9
9
|
import type { Storage } from '@ultimat3/storage';
|
|
10
10
|
import {
|
|
11
|
+
acceptSignedPart,
|
|
11
12
|
acceptSignedUpload,
|
|
12
13
|
DEFAULT_MAX_UPLOAD_BYTES,
|
|
13
14
|
DEFAULT_SIGNED_URL_BASE,
|
|
@@ -61,6 +62,14 @@ async function acceptUpload(
|
|
|
61
62
|
const read = await readWithinLimit(request.raw.body, ceiling);
|
|
62
63
|
if ('over' in read) throw tooLarge(constraints.key, read.over, ceiling);
|
|
63
64
|
|
|
65
|
+
// A part URL (`signPart` on a local disk) writes one part into its open session — never the
|
|
66
|
+
// object, which `completeUpload` assembles. The signature says which, so the one PUT route
|
|
67
|
+
// serves both: a second route on the same path could only shadow this one.
|
|
68
|
+
if (constraints.part !== undefined) {
|
|
69
|
+
const part = await acceptSignedPart({ url, disk, orgId, bytes: read.bytes });
|
|
70
|
+
return jsonResponse(part, { status: 200 });
|
|
71
|
+
}
|
|
72
|
+
|
|
64
73
|
const stored = await acceptSignedUpload({
|
|
65
74
|
url,
|
|
66
75
|
disk,
|