@ultimat3/cli 29.4.0 → 30.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +11 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +9 -0
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +41 -81
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +7 -1
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1314 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Single responsibility: where a repro test lands and what running it says. `<root>/repro/`, one
|
|
2
|
+
// file per subject — outside every surface, so it may import the app's API index for its
|
|
3
|
+
// registrations without crossing a boundary — and `--verify` is one `bun test` of that file.
|
|
4
|
+
|
|
5
|
+
import { existsSync } from 'node:fs'; // why: a synchronous probe of two optional app files.
|
|
6
|
+
// why: Bun exposes no path-join primitive.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { isJsonObject } from '@ultimat3/core';
|
|
9
|
+
import { API_INDEX } from './app-root';
|
|
10
|
+
import type { Runner } from './exec';
|
|
11
|
+
import { formatWritten } from './generate-format-written';
|
|
12
|
+
import type { ReproSubject } from './repro-bundle';
|
|
13
|
+
import { jobTestSource } from './repro-job';
|
|
14
|
+
import { requestTestSource } from './repro-request';
|
|
15
|
+
import { testEnvOverrides } from './test-dotenv';
|
|
16
|
+
|
|
17
|
+
/** The directory every written repro lands in, relative to the app root. */
|
|
18
|
+
export const REPRO_DIR = 'repro';
|
|
19
|
+
|
|
20
|
+
/** `Received: "X_CODE: cause"` — what `expect(outcome.failure).toBeNull()` prints on a red run. */
|
|
21
|
+
const RECEIVED = /Received:\s*"(X_[A-Z0-9_]+)/;
|
|
22
|
+
|
|
23
|
+
/** Lower-case alphanumerics only: the id came from a person or a URL, the name is a file name. */
|
|
24
|
+
const shortOf = (id: string): string =>
|
|
25
|
+
id
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^a-z0-9]/g, '')
|
|
28
|
+
.slice(0, 12) || 'unnamed';
|
|
29
|
+
|
|
30
|
+
export function reproFile(subject: ReproSubject): string {
|
|
31
|
+
return subject.kind === 'job'
|
|
32
|
+
? `${REPRO_DIR}/repro-${shortOf(subject.failure.jobId)}.job.test.ts`
|
|
33
|
+
: `${REPRO_DIR}/repro-${shortOf(subject.bundle.requestId ?? subject.bundle.id)}.contract.test.ts`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The app's `@<app>/db` package name, when it has one that exports the driver the code reads. */
|
|
37
|
+
async function dbPackageOf(root: string): Promise<string | null> {
|
|
38
|
+
const path = join(root, 'packages', 'db', 'package.json');
|
|
39
|
+
if (!existsSync(path)) return null;
|
|
40
|
+
const parsed: unknown = await Bun.file(path).json();
|
|
41
|
+
return isJsonObject(parsed) && typeof parsed['name'] === 'string' ? parsed['name'] : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The source a subject replays as; the API index is imported for what it registers. */
|
|
45
|
+
export async function reproSource(
|
|
46
|
+
root: string,
|
|
47
|
+
subject: ReproSubject,
|
|
48
|
+
id: string,
|
|
49
|
+
build: string | null,
|
|
50
|
+
): Promise<string> {
|
|
51
|
+
const imports = existsSync(join(root, API_INDEX))
|
|
52
|
+
? [`../${API_INDEX.replace(/\/index\.ts$/, '')}`]
|
|
53
|
+
: [];
|
|
54
|
+
if (subject.kind === 'job') return jobTestSource(subject.failure, { id, imports });
|
|
55
|
+
return requestTestSource(subject.bundle, {
|
|
56
|
+
id,
|
|
57
|
+
imports,
|
|
58
|
+
build,
|
|
59
|
+
dbPackage: await dbPackageOf(root),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function writeRepro(
|
|
64
|
+
root: string,
|
|
65
|
+
file: string,
|
|
66
|
+
source: string,
|
|
67
|
+
runner: Runner,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
await Bun.write(join(root, file), source);
|
|
70
|
+
await formatWritten(root, [file], runner);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ReproVerdict {
|
|
74
|
+
readonly reproduced: boolean;
|
|
75
|
+
/** The code the written test ended in, read off its output; `null` for a pass. */
|
|
76
|
+
readonly got: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reproduced = the written test is RED and its output carries the captured code. A green run, or a
|
|
81
|
+
* red one for another reason, is the answer `X_REPRO_NOT_REPRODUCED` reports — never a crash.
|
|
82
|
+
*/
|
|
83
|
+
export async function verifyRepro(
|
|
84
|
+
root: string,
|
|
85
|
+
file: string,
|
|
86
|
+
expected: string | null,
|
|
87
|
+
runner: Runner,
|
|
88
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
89
|
+
): Promise<ReproVerdict> {
|
|
90
|
+
// A test process, never the shell's deploy: no `DATABASE_URL` for the app's driver to follow
|
|
91
|
+
// and no `ULTIMATE_ENV` to outrank `bun test`'s own — the replay runs where the app's tests run.
|
|
92
|
+
const run = await runner(['bun', 'test', `./${file}`], {
|
|
93
|
+
cwd: root,
|
|
94
|
+
env: { ...testEnvOverrides(root, env), DATABASE_URL: undefined, ULTIMATE_ENV: undefined },
|
|
95
|
+
});
|
|
96
|
+
if (run.ok) return { reproduced: false, got: null };
|
|
97
|
+
// The ANSWER the assertion printed — never the test's name or its source, which name the code too.
|
|
98
|
+
const got = RECEIVED.exec(`${run.stdout}\n${run.stderr}`)?.[1] ?? 'a failure with no code';
|
|
99
|
+
return { reproduced: expected !== null && got === expected, got };
|
|
100
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The `manifest` step's retention half: an entity declaring `retain` that no `purge()` covers is a
|
|
2
|
+
// promise nothing keeps — rows past the window are never deleted, and nobody finds out until an
|
|
3
|
+
// audit. `X_ENTITY_RETAIN_UNSWEPT` makes it a build error (axiom 3).
|
|
4
|
+
|
|
5
|
+
import { unsweptEntities } from '@ultimat3/jobs';
|
|
6
|
+
import { loadApp } from './app-load';
|
|
7
|
+
import type { AppLoader } from './app-permissions';
|
|
8
|
+
import type { Finding } from './output';
|
|
9
|
+
|
|
10
|
+
export function retainUnsweptFinding(entity: string): Finding {
|
|
11
|
+
return {
|
|
12
|
+
code: 'X_ENTITY_RETAIN_UNSWEPT',
|
|
13
|
+
cause: `entity "${entity}" declares retain, and no purge() registered in this app sweeps it — rows past its window are kept forever`,
|
|
14
|
+
fix: "export const retention = purge({ entities: 'declared' }) — in a jobs module named in jobs: [...] in apps/web/api/index.ts, then x verify",
|
|
15
|
+
at: 'apps/web/api/index.ts',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The step's answer, after the app loaded — a short registry is the load's finding, not this one. */
|
|
20
|
+
export async function retainUnsweptFindings(
|
|
21
|
+
root: string,
|
|
22
|
+
load: AppLoader = loadApp,
|
|
23
|
+
unswept: () => readonly string[] = unsweptEntities,
|
|
24
|
+
): Promise<readonly Finding[]> {
|
|
25
|
+
if ((await load(root)).findings.length > 0) return [];
|
|
26
|
+
return unswept().map(retainUnsweptFinding);
|
|
27
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The web role's boot half of `assets.retain`: read the section the `*Of(config)` way, refuse a
|
|
2
|
+
// disk the app never declared, publish every hashed file this build serves — before the role
|
|
3
|
+
// binds, so before `/readyz` can say ready — and hand back the reader the asset routes miss into.
|
|
4
|
+
|
|
5
|
+
import type { AppConfig, AssetsRetainConfig } from '@ultimat3/core';
|
|
6
|
+
import { ConfigInvalidError, renderFixLiteral } from '@ultimat3/core';
|
|
7
|
+
import type { Storage } from '@ultimat3/storage';
|
|
8
|
+
import type { IslandBundle } from './island-bundle';
|
|
9
|
+
import type { BuildAsset, PublishReport, RetainLog } from './retained-assets';
|
|
10
|
+
import { publishBuildAssets } from './retained-assets';
|
|
11
|
+
import { retainedPrefix } from './retained-assets-layout';
|
|
12
|
+
import type { RetainedAssets } from './retained-assets-read';
|
|
13
|
+
import { retainedAssets } from './retained-assets-read';
|
|
14
|
+
import { siteAssetTable } from './site-assets';
|
|
15
|
+
import type { StyleBundle } from './style-bundle';
|
|
16
|
+
import type { FrameworkScript } from './worker-bundle';
|
|
17
|
+
|
|
18
|
+
/** `assets.retain`, or `undefined` — absent is today's behaviour: each pod serves what it built. */
|
|
19
|
+
export function retainOf(config: AppConfig | undefined): AssetsRetainConfig | undefined {
|
|
20
|
+
return config?.assets.retain;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What this build serves at a content-addressed URL: the whole of what an old tab can ask for. */
|
|
24
|
+
export interface BuiltSurface {
|
|
25
|
+
readonly root: string;
|
|
26
|
+
readonly islands: IslandBundle;
|
|
27
|
+
readonly styles: StyleBundle;
|
|
28
|
+
readonly scripts: readonly FrameworkScript[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const encode = (text: string): Uint8Array => new TextEncoder().encode(text);
|
|
32
|
+
|
|
33
|
+
/** Every hashed file, once per URL, in route order: styles, islands, scripts, site assets. */
|
|
34
|
+
export function buildAssetsOf(surface: BuiltSurface): readonly BuildAsset[] {
|
|
35
|
+
const assets: BuildAsset[] = [
|
|
36
|
+
...surface.styles.chunks.map((chunk) => ({
|
|
37
|
+
url: chunk.url,
|
|
38
|
+
contentType: 'text/css; charset=utf-8',
|
|
39
|
+
body: encode(chunk.css),
|
|
40
|
+
})),
|
|
41
|
+
...[...surface.islands.chunks, ...surface.islands.shared].map((chunk) => ({
|
|
42
|
+
url: chunk.url,
|
|
43
|
+
contentType: 'text/javascript',
|
|
44
|
+
body: encode(chunk.code),
|
|
45
|
+
})),
|
|
46
|
+
...surface.scripts.map((script) => ({
|
|
47
|
+
url: script.url,
|
|
48
|
+
contentType: 'text/javascript',
|
|
49
|
+
body: encode(script.code),
|
|
50
|
+
})),
|
|
51
|
+
// A `Bun.file`, so a disk refuses one past its ceiling by size without reading it.
|
|
52
|
+
...siteAssetTable(surface.root)
|
|
53
|
+
.all()
|
|
54
|
+
.map((asset) => ({
|
|
55
|
+
url: asset.url,
|
|
56
|
+
contentType: asset.contentType,
|
|
57
|
+
body: Bun.file(asset.file),
|
|
58
|
+
})),
|
|
59
|
+
];
|
|
60
|
+
return [...new Map(assets.map((asset) => [asset.url, asset] as const)).values()];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RetainBootInput extends BuiltSurface {
|
|
64
|
+
readonly config: AppConfig | undefined;
|
|
65
|
+
/** The disks the app's routes read (`servedStorage`) — `retain.disk` must be one of them. */
|
|
66
|
+
readonly storage: Storage;
|
|
67
|
+
readonly buildId: string;
|
|
68
|
+
/** `resolveEnvironment` of the boot's env: half of the scope, with `config.name`. */
|
|
69
|
+
readonly environment: string;
|
|
70
|
+
readonly log?: RetainLog | undefined;
|
|
71
|
+
/** Test seam; `RETAIN_PUBLISH_DEADLINE_MS` otherwise. */
|
|
72
|
+
readonly deadlineMs?: number | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RetainedBoot {
|
|
76
|
+
/** What the asset routes read a miss through — `withRetainedAssets(routes, reader)`. */
|
|
77
|
+
readonly reader: RetainedAssets;
|
|
78
|
+
readonly report: PublishReport;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `undefined` when the app declared no window. A misnamed disk refuses the boot: it is the app's
|
|
83
|
+
* config, wrong, and every pod of the deploy would otherwise run without the window it asked for.
|
|
84
|
+
* A disk that refuses a write does NOT: `publishBuildAssets` logs it and the pod serves, reading
|
|
85
|
+
* earlier builds' files all the same. The scope is `config.name` and the environment, so staging
|
|
86
|
+
* and production on one bucket never sweep or serve each other.
|
|
87
|
+
*/
|
|
88
|
+
export async function retainBuildAssets(input: RetainBootInput): Promise<RetainedBoot | undefined> {
|
|
89
|
+
const retain = retainOf(input.config);
|
|
90
|
+
if (input.config === undefined || retain === undefined) return undefined;
|
|
91
|
+
const names = input.storage.diskNames;
|
|
92
|
+
if (!names.includes(retain.disk)) {
|
|
93
|
+
throw new ConfigInvalidError({
|
|
94
|
+
cause: `assets.retain.disk is "${retain.disk}" but the app's disks are: ${names.join(', ')}`,
|
|
95
|
+
fix: `assets: { retain: { disk: ${renderFixLiteral(names[0], "'<disk>'")}, builds: ${String(retain.builds)} } } # in app.config.ts; the declared disks: ${names.join(', ')}`,
|
|
96
|
+
meta: { key: 'assets.retain.disk' },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const scope = {
|
|
100
|
+
disk: input.storage.disk(retain.disk),
|
|
101
|
+
prefix: retainedPrefix(input.config.name, input.environment),
|
|
102
|
+
};
|
|
103
|
+
const report = await publishBuildAssets({
|
|
104
|
+
...scope,
|
|
105
|
+
diskName: retain.disk,
|
|
106
|
+
buildId: input.buildId,
|
|
107
|
+
builds: retain.builds,
|
|
108
|
+
assets: buildAssetsOf(input),
|
|
109
|
+
...(input.log === undefined ? {} : { log: input.log }),
|
|
110
|
+
...(input.deadlineMs === undefined ? {} : { deadlineMs: input.deadlineMs }),
|
|
111
|
+
});
|
|
112
|
+
return { reader: retainedAssets(scope), report };
|
|
113
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Where `assets.retain` keeps things on its disk, and the reads every half shares: the key of a
|
|
2
|
+
// file and of a manifest, one app-and-environment prefix, and the manifests as written. The
|
|
3
|
+
// publish, the sweep and the read each import this; none imports another's file for it.
|
|
4
|
+
|
|
5
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
6
|
+
import type { StorageDriver, StorageListEntry, StorageRead } from '@ultimat3/storage';
|
|
7
|
+
|
|
8
|
+
/** Outside `org/`, so `/_storage` and `/media` refuse it (`assertReadableKey`): served only here. */
|
|
9
|
+
export const RETAINED_ASSET_PREFIX = 'x-build-assets';
|
|
10
|
+
/** Disk calls in flight at once: a boot over S3 is round trips, not bytes. */
|
|
11
|
+
const CONCURRENCY = 8;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One app in one environment on the disk: `x-build-assets/<app>/<environment>`. Staging and
|
|
15
|
+
* production sharing a bucket must neither sweep nor serve each other's builds.
|
|
16
|
+
*/
|
|
17
|
+
export interface RetainedScope {
|
|
18
|
+
readonly disk: StorageDriver;
|
|
19
|
+
readonly prefix: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `config.name` is already a slug (`defineConfig`); the environment is one of `ENVIRONMENTS`. */
|
|
23
|
+
export const retainedPrefix = (app: string, environment: string): string =>
|
|
24
|
+
`${RETAINED_ASSET_PREFIX}/${encodeURIComponent(app)}/${encodeURIComponent(environment)}`;
|
|
25
|
+
|
|
26
|
+
/** `/styles/ab12cd34.css` → `<prefix>/files/styles/ab12cd34.css`. Content-addressed: shared. */
|
|
27
|
+
export const retainedFileKey = (prefix: string, url: string): string => `${prefix}/files${url}`;
|
|
28
|
+
|
|
29
|
+
/** The listing prefix every file of the scope sits under. */
|
|
30
|
+
export const filesPrefix = (prefix: string): string => `${prefix}/files/`;
|
|
31
|
+
|
|
32
|
+
/** The listing prefix every manifest of the scope sits under. */
|
|
33
|
+
export const manifestsPrefix = (prefix: string): string => `${prefix}/builds/`;
|
|
34
|
+
|
|
35
|
+
/** Where a build says it is complete — written after its files. */
|
|
36
|
+
export const manifestKey = (prefix: string, buildId: string): string =>
|
|
37
|
+
`${manifestsPrefix(prefix)}${encodeURIComponent(buildId)}.json`;
|
|
38
|
+
|
|
39
|
+
/** What a manifest holds: the build, when it last went live, and every URL it serves. */
|
|
40
|
+
export interface BuildManifest {
|
|
41
|
+
readonly buildId: string;
|
|
42
|
+
readonly publishedAt: number;
|
|
43
|
+
readonly urls: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function inBatches<T>(
|
|
47
|
+
items: readonly T[],
|
|
48
|
+
each: (item: T) => Promise<void>,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
for (let at = 0; at < items.length; at += CONCURRENCY) {
|
|
51
|
+
await Promise.all(items.slice(at, at + CONCURRENCY).map(each));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function listAll(
|
|
56
|
+
disk: StorageDriver,
|
|
57
|
+
prefix: string,
|
|
58
|
+
): Promise<readonly StorageListEntry[]> {
|
|
59
|
+
const all: StorageListEntry[] = [];
|
|
60
|
+
let cursor: string | null = null;
|
|
61
|
+
for (;;) {
|
|
62
|
+
const page = await disk.list({ prefix, cursor });
|
|
63
|
+
all.push(...page.rows);
|
|
64
|
+
if (!page.hasMore) return all;
|
|
65
|
+
cursor = page.nextCursor;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseManifest(bytes: Uint8Array): BuildManifest | undefined {
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
|
77
|
+
const { buildId, publishedAt, urls } = parsed as Record<string, unknown>;
|
|
78
|
+
if (typeof buildId !== 'string' || typeof publishedAt !== 'number') return undefined;
|
|
79
|
+
if (!Array.isArray(urls) || !urls.every((url) => typeof url === 'string')) return undefined;
|
|
80
|
+
return { buildId, publishedAt, urls };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface StoredManifest {
|
|
84
|
+
readonly key: string;
|
|
85
|
+
readonly manifest: BuildManifest;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every manifest of the scope, or `undefined` when one does not parse: what it names is unknown,
|
|
90
|
+
* so a caller deciding what to delete or to serve must not guess.
|
|
91
|
+
*/
|
|
92
|
+
export async function readManifests(
|
|
93
|
+
scope: RetainedScope,
|
|
94
|
+
): Promise<readonly StoredManifest[] | undefined> {
|
|
95
|
+
const manifests: StoredManifest[] = [];
|
|
96
|
+
for (const entry of await listAll(scope.disk, manifestsPrefix(scope.prefix))) {
|
|
97
|
+
let read: StorageRead;
|
|
98
|
+
try {
|
|
99
|
+
read = await scope.disk.get(entry.key);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// Deleted by another pod's sweep between the listing and the read: no longer a manifest.
|
|
102
|
+
if (isUltimateError(error) && error.code === 'X_STORAGE_NOT_FOUND') continue;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const manifest = parseManifest(read.bytes);
|
|
106
|
+
if (manifest === undefined) return undefined;
|
|
107
|
+
manifests.push({ key: entry.key, manifest });
|
|
108
|
+
}
|
|
109
|
+
return manifests;
|
|
110
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The read half of `assets.retain`: an asset route that missed asks the retained disk, and the disk
|
|
2
|
+
// answers only a URL a retained build's manifest names — the manifest is the one gate, so no route
|
|
3
|
+
// restates a URL shape. Composed over the web boot's routes by `serve-web.ts`; `x dev` never is.
|
|
4
|
+
|
|
5
|
+
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import { finiteCount, logger, renderThrowable, systemClock } from '@ultimat3/core';
|
|
7
|
+
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
8
|
+
import { objectResponse } from '@ultimat3/http';
|
|
9
|
+
import type { StorageObject } from '@ultimat3/storage';
|
|
10
|
+
import type { RetainedScope } from './retained-assets-layout';
|
|
11
|
+
import { readManifests, retainedFileKey } from './retained-assets-layout';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How often an unnamed URL may make the reader list the manifests again. A new build's pods
|
|
15
|
+
* publish after this one booted, and its URLs must become readable here (a new document reaching
|
|
16
|
+
* an old pod); a URL nobody ever published must not cost a listing per request.
|
|
17
|
+
*/
|
|
18
|
+
export const RETAINED_REFRESH_MS = 5_000;
|
|
19
|
+
|
|
20
|
+
/** What `serve-web.ts` hands the routes: the retained answer for a request, or none. */
|
|
21
|
+
export interface RetainedAssets {
|
|
22
|
+
read(request: UltimateRequest): Promise<Response | undefined>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RetainedAssetsOptions extends RetainedScope {
|
|
26
|
+
readonly clock?: Clock | undefined;
|
|
27
|
+
readonly refreshMs?: number | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A body whose first chunk has arrived, or a throw: what was swept since `stat` is a miss. */
|
|
31
|
+
async function startedBody(
|
|
32
|
+
body: ReadableStream<Uint8Array>,
|
|
33
|
+
): Promise<{ readonly stream: ReadableStream<Uint8Array>; readonly cancel: () => void }> {
|
|
34
|
+
const reader = body.getReader();
|
|
35
|
+
const first = await reader.read();
|
|
36
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
37
|
+
start(controller) {
|
|
38
|
+
if (first.done) controller.close();
|
|
39
|
+
else controller.enqueue(first.value);
|
|
40
|
+
},
|
|
41
|
+
async pull(controller) {
|
|
42
|
+
const next = await reader.read();
|
|
43
|
+
if (next.done) controller.close();
|
|
44
|
+
else controller.enqueue(next.value);
|
|
45
|
+
},
|
|
46
|
+
cancel: (reason) => reader.cancel(reason),
|
|
47
|
+
});
|
|
48
|
+
return { stream, cancel: () => void reader.cancel().catch(() => undefined) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function retainedAssets(options: RetainedAssetsOptions): RetainedAssets {
|
|
52
|
+
const clock = options.clock ?? systemClock;
|
|
53
|
+
const refreshMs = finiteCount(
|
|
54
|
+
'retainedAssets',
|
|
55
|
+
'refreshMs',
|
|
56
|
+
options.refreshMs ?? RETAINED_REFRESH_MS,
|
|
57
|
+
);
|
|
58
|
+
let named: ReadonlySet<string> = new Set();
|
|
59
|
+
let refreshedAt = Number.NEGATIVE_INFINITY;
|
|
60
|
+
let refreshing: Promise<void> | undefined;
|
|
61
|
+
|
|
62
|
+
const refresh = (): Promise<void> => {
|
|
63
|
+
refreshing ??= (async () => {
|
|
64
|
+
refreshedAt = clock.now().getTime();
|
|
65
|
+
const manifests = await readManifests(options);
|
|
66
|
+
// A manifest that does not parse leaves the last good set: never serve on a guess.
|
|
67
|
+
if (manifests !== undefined) {
|
|
68
|
+
named = new Set(manifests.flatMap(({ manifest }) => manifest.urls));
|
|
69
|
+
}
|
|
70
|
+
})().finally(() => {
|
|
71
|
+
refreshing = undefined;
|
|
72
|
+
});
|
|
73
|
+
return refreshing;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const isNamed = async (pathname: string): Promise<boolean> => {
|
|
77
|
+
if (named.has(pathname)) return true;
|
|
78
|
+
if (clock.now().getTime() - refreshedAt < refreshMs) return false;
|
|
79
|
+
await refresh();
|
|
80
|
+
return named.has(pathname);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
async read(request: UltimateRequest): Promise<Response | undefined> {
|
|
85
|
+
const { pathname } = request;
|
|
86
|
+
try {
|
|
87
|
+
if (!(await isNamed(pathname))) return undefined;
|
|
88
|
+
const key = retainedFileKey(options.prefix, pathname);
|
|
89
|
+
const object: StorageObject | undefined = await options.disk.stat(key);
|
|
90
|
+
if (object === undefined) return undefined;
|
|
91
|
+
const body = await startedBody(await options.disk.stream(key));
|
|
92
|
+
let sent = false;
|
|
93
|
+
const response = await objectResponse(
|
|
94
|
+
request,
|
|
95
|
+
{
|
|
96
|
+
object,
|
|
97
|
+
open: (range) => {
|
|
98
|
+
if (range === undefined) {
|
|
99
|
+
sent = true;
|
|
100
|
+
return body.stream;
|
|
101
|
+
}
|
|
102
|
+
return options.disk.streamRange(key, [range.start, range.end]);
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
// `{}`, not the stored-object sandbox: these are the app's own scripts and stylesheets,
|
|
106
|
+
// and an `attachment` disposition would make a browser refuse to run them.
|
|
107
|
+
{ cache: { mode: 'immutable' }, headers: {} },
|
|
108
|
+
);
|
|
109
|
+
if (!sent) body.cancel();
|
|
110
|
+
return response;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// A disk that cannot answer is a miss: the route's own 404 is still true for this process.
|
|
113
|
+
logger.warn('assets.retain_read_failed', { pathname, cause: renderThrowable(error) });
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Every `GET` route tagged `assets` asks `retained` when it would answer 404. Over the composed
|
|
122
|
+
* table rather than inside each factory, so the routes `x dev` mounts are byte-for-byte the
|
|
123
|
+
* container's and no factory gains a second way to answer.
|
|
124
|
+
*/
|
|
125
|
+
export function withRetainedAssets(
|
|
126
|
+
routes: readonly Route[],
|
|
127
|
+
retained: RetainedAssets | undefined,
|
|
128
|
+
): readonly Route[] {
|
|
129
|
+
if (retained === undefined) return routes;
|
|
130
|
+
return routes.map((route) =>
|
|
131
|
+
route.method !== 'GET' || !(route.meta?.tags ?? []).includes('assets')
|
|
132
|
+
? route
|
|
133
|
+
: {
|
|
134
|
+
...route,
|
|
135
|
+
handler: async (request, ctx) => {
|
|
136
|
+
const answer = await route.handler(request, ctx);
|
|
137
|
+
if (answer.status !== 404) return answer;
|
|
138
|
+
return (await retained.read(request)) ?? answer;
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The `assets.retain` sweep: keep the newest `builds` manifests of one app-and-environment scope
|
|
2
|
+
// and delete the files none of them names — without ever deleting what another pod is publishing
|
|
3
|
+
// at the same moment. Run at the end of a publish.
|
|
4
|
+
|
|
5
|
+
import type { RetainedScope, StoredManifest } from './retained-assets-layout';
|
|
6
|
+
import {
|
|
7
|
+
filesPrefix,
|
|
8
|
+
inBatches,
|
|
9
|
+
listAll,
|
|
10
|
+
readManifests,
|
|
11
|
+
retainedFileKey,
|
|
12
|
+
} from './retained-assets-layout';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A file put this recently is spared whatever the manifests say: it may belong to a publish still
|
|
16
|
+
* in flight on another pod, whose manifest is not written yet. Far longer than a publish may take
|
|
17
|
+
* (`RETAIN_PUBLISH_DEADLINE_MS`), so clock skew between a pod and the disk does not eat it.
|
|
18
|
+
*/
|
|
19
|
+
export const RETAIN_SWEEP_GRACE_MS = 10 * 60_000;
|
|
20
|
+
|
|
21
|
+
const urlsOf = (manifests: readonly StoredManifest[]): readonly string[] =>
|
|
22
|
+
manifests.flatMap(({ manifest }) => manifest.urls);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Manifests, then files, then the dropped manifests: a pod killed anywhere leaves extra objects,
|
|
26
|
+
* never a missing one. Three guards against a concurrent publish: a file younger than the grace
|
|
27
|
+
* stays; the manifests are read AGAIN just before deleting, and any that appeared keeps what it
|
|
28
|
+
* names; a manifest that does not parse stops the sweep. Returns the files deleted.
|
|
29
|
+
*/
|
|
30
|
+
export async function sweepRetained(
|
|
31
|
+
scope: RetainedScope,
|
|
32
|
+
builds: number,
|
|
33
|
+
startedAt: number,
|
|
34
|
+
): Promise<number> {
|
|
35
|
+
const manifests = await readManifests(scope);
|
|
36
|
+
if (manifests === undefined) return 0;
|
|
37
|
+
const ordered = [...manifests].sort(
|
|
38
|
+
(a, b) =>
|
|
39
|
+
b.manifest.publishedAt - a.manifest.publishedAt ||
|
|
40
|
+
a.manifest.buildId.localeCompare(b.manifest.buildId),
|
|
41
|
+
);
|
|
42
|
+
const dropped = ordered.slice(builds);
|
|
43
|
+
if (dropped.length === 0) return 0;
|
|
44
|
+
const droppedAt = new Map(dropped.map(({ key, manifest }) => [key, manifest.publishedAt]));
|
|
45
|
+
const files = await listAll(scope.disk, filesPrefix(scope.prefix));
|
|
46
|
+
const again = await readManifests(scope);
|
|
47
|
+
if (again === undefined) return 0;
|
|
48
|
+
// Still dropped: read the first time as dropped and not re-dated since by a pod of that build
|
|
49
|
+
// booting again (a rollback), which makes it the newest and not ours to drop.
|
|
50
|
+
const stillDropped = (one: StoredManifest): boolean =>
|
|
51
|
+
droppedAt.get(one.key) === one.manifest.publishedAt;
|
|
52
|
+
const named = new Set(
|
|
53
|
+
urlsOf(again.filter((one) => !stillDropped(one))).map((url) =>
|
|
54
|
+
retainedFileKey(scope.prefix, url),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
const redated = new Set(
|
|
58
|
+
again.filter((one) => droppedAt.has(one.key) && !stillDropped(one)).map(({ key }) => key),
|
|
59
|
+
);
|
|
60
|
+
const cutoff = startedAt - RETAIN_SWEEP_GRACE_MS;
|
|
61
|
+
const doomed = files
|
|
62
|
+
.filter((entry) => !named.has(entry.key))
|
|
63
|
+
// Unknown age is spared, as `sweepOrphans` spares it: never epoch 0, never "old enough".
|
|
64
|
+
.filter((entry) => entry.lastModified !== undefined && entry.lastModified.getTime() < cutoff)
|
|
65
|
+
.map((entry) => entry.key);
|
|
66
|
+
await inBatches(doomed, (key) => scope.disk.delete(key));
|
|
67
|
+
const drop = dropped.filter(({ key }) => !redated.has(key));
|
|
68
|
+
await inBatches(drop, ({ key }) => scope.disk.delete(key));
|
|
69
|
+
return doomed.length;
|
|
70
|
+
}
|