@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-storage.ts
CHANGED
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
|
|
10
10
|
import type { Actor } from '@ultimat3/core';
|
|
11
11
|
import { actorOf } from '@ultimat3/core';
|
|
12
|
-
import type {
|
|
13
|
-
|
|
12
|
+
import type {
|
|
13
|
+
CacheHint,
|
|
14
|
+
ObjectSource,
|
|
15
|
+
RequestContext,
|
|
16
|
+
Route,
|
|
17
|
+
UltimateRequest,
|
|
18
|
+
} from '@ultimat3/http';
|
|
19
|
+
import { asCtx, objectResponse, unauthenticated } from '@ultimat3/http';
|
|
14
20
|
import type { KnownPermission } from '@ultimat3/policy';
|
|
15
21
|
import {
|
|
16
22
|
can,
|
|
@@ -20,7 +26,7 @@ import {
|
|
|
20
26
|
policyForbidden,
|
|
21
27
|
reasonOf,
|
|
22
28
|
} from '@ultimat3/policy';
|
|
23
|
-
import type { Storage,
|
|
29
|
+
import type { Storage, StorageDriver } from '@ultimat3/storage';
|
|
24
30
|
import {
|
|
25
31
|
assertSafeKey,
|
|
26
32
|
DEFAULT_SIGNED_URL_BASE,
|
|
@@ -30,9 +36,10 @@ import {
|
|
|
30
36
|
keyUnshared,
|
|
31
37
|
objectNotFound,
|
|
32
38
|
orgMismatch,
|
|
39
|
+
variantRevision,
|
|
40
|
+
variantSourceOf,
|
|
33
41
|
} from '@ultimat3/storage';
|
|
34
42
|
import { storageUploadRoute } from './runtime-storage-upload';
|
|
35
|
-
import { storedObjectHeaders } from './stored-object-headers';
|
|
36
43
|
|
|
37
44
|
/**
|
|
38
45
|
* The one capability that gates reading a stored object, on every disk. A permission and not a
|
|
@@ -57,9 +64,9 @@ const READ_PERMISSION = STORAGE_READ_PERMISSION as unknown as KnownPermission;
|
|
|
57
64
|
* The cache posture of a response that was authorized for ONE actor, named once so the two routes
|
|
58
65
|
* that serve stored bytes cannot declare different ones — which they did: `/media` answered
|
|
59
66
|
* `public, max-age=31536000, immutable` for the same object this route marks private, so a CDN held
|
|
60
|
-
* one tenant's file under a public key for a year.
|
|
61
|
-
*
|
|
62
|
-
*
|
|
67
|
+
* one tenant's file under a public key for a year. A key is not content-addressed either: an
|
|
68
|
+
* overwrite changes the bytes under it, which is what the etag answers. `vary` names the two
|
|
69
|
+
* headers that carry an identity.
|
|
63
70
|
*/
|
|
64
71
|
export const AUTHORIZED_OBJECT_CACHE: CacheHint = {
|
|
65
72
|
mode: 'private',
|
|
@@ -147,152 +154,49 @@ export function assertReadableKey(
|
|
|
147
154
|
}
|
|
148
155
|
|
|
149
156
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
157
|
+
* A stored variant read by its OWN key answers for its source, or the URL outlives an erasure: a key
|
|
158
|
+
* naming a revision is served only while the source still holds that revision. Both routes read
|
|
159
|
+
* through here. An unrevisioned key is an ordinary object, because `logo@2x.png` is a real name.
|
|
153
160
|
*/
|
|
154
|
-
export
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
export async function assertVariantCurrent(
|
|
162
|
+
driver: StorageDriver,
|
|
163
|
+
disk: string,
|
|
164
|
+
key: string,
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
const variant = variantSourceOf(key);
|
|
167
|
+
if (variant === undefined) return;
|
|
168
|
+
const source = await driver.stat(variant.source);
|
|
169
|
+
if (source === undefined || variantRevision(source.etag) !== variant.revision) {
|
|
170
|
+
throw objectNotFound(disk, key);
|
|
171
|
+
}
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
/**
|
|
161
175
|
* Everything between the decision and the bytes. An unknown disk is the same 404 the foreign-tenant
|
|
162
176
|
* case answers, rather than `X_STORAGE_DISK_UNKNOWN`, whose cause lists every configured disk name.
|
|
177
|
+
*
|
|
178
|
+
* A STREAM of the bytes, never a buffer: `get()` holds the whole object in memory and refuses past
|
|
179
|
+
* the disk's `maxGetBytes`, so a large upload was a pod's worth of heap or `X_STORAGE_TOO_LARGE`,
|
|
180
|
+
* and a video's `bytes=0-` probe is the whole object, which only a stream holds. A `Range:` is
|
|
181
|
+
* `streamRange`, so a seek late in the file costs its window. The content type is the STORED one and the validator the driver's own content hash: inventing a cache key here
|
|
182
|
+
* would be a second identity for one object.
|
|
163
183
|
*/
|
|
164
184
|
export async function readStorageObject(
|
|
165
185
|
storage: Storage,
|
|
166
186
|
input: StorageReadInput,
|
|
167
187
|
actor: Actor,
|
|
168
|
-
): Promise<
|
|
188
|
+
): Promise<ObjectSource> {
|
|
169
189
|
const key = assertReadableKey(storage, input.disk, input.key, actor);
|
|
170
190
|
if (!storage.diskNames.includes(input.disk)) throw objectNotFound(input.disk, key);
|
|
171
191
|
const driver = storage.disk(input.disk);
|
|
192
|
+
await assertVariantCurrent(driver, input.disk, key);
|
|
172
193
|
const object = await driver.stat(key);
|
|
173
194
|
if (object === undefined) throw objectNotFound(input.disk, key);
|
|
174
|
-
return {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
readonly start: number;
|
|
180
|
-
readonly end: number;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const UNSATISFIABLE = 'unsatisfiable';
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* One range or none. A multi-range or malformed header is ignored rather than refused — RFC 9110
|
|
187
|
-
* lets a server answer the whole representation, and `multipart/byteranges` is a body format no
|
|
188
|
-
* caller of this route asks for. Ranges exist here for one reason: Safari will not play a `<video>`
|
|
189
|
-
* from a source that answers 200 to a `Range` probe, so an uploaded video would be a dead player.
|
|
190
|
-
*/
|
|
191
|
-
export function parseByteRange(
|
|
192
|
-
header: string | null,
|
|
193
|
-
size: number,
|
|
194
|
-
): ByteRange | typeof UNSATISFIABLE | undefined {
|
|
195
|
-
if (header === null) return undefined;
|
|
196
|
-
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
197
|
-
if (match === null) return undefined;
|
|
198
|
-
const from = match[1] ?? '';
|
|
199
|
-
const to = match[2] ?? '';
|
|
200
|
-
if (from === '' && to === '') return undefined;
|
|
201
|
-
if (from === '') {
|
|
202
|
-
const wanted = Number(to);
|
|
203
|
-
// A suffix longer than the object is the whole object, not a refusal — but there is no whole
|
|
204
|
-
// object to fall back to at `size === 0`, and the arithmetic below answers `{ start: 0, end:
|
|
205
|
-
// -1 }`, which the route renders as `content-range: bytes 0--1/0` with status 206. RFC 9110
|
|
206
|
-
// requires 416. The non-suffix branch already gets this right through `start >= size`; this
|
|
207
|
-
// one had no equivalent test.
|
|
208
|
-
return size === 0 || wanted === 0
|
|
209
|
-
? UNSATISFIABLE
|
|
210
|
-
: { start: Math.max(size - wanted, 0), end: size - 1 };
|
|
211
|
-
}
|
|
212
|
-
const start = Number(from);
|
|
213
|
-
const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
|
|
214
|
-
return start >= size || end < start ? UNSATISFIABLE : { start, end };
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** `*`, a weak validator and a comma list all count — the etag is what has to match. */
|
|
218
|
-
export function etagMatches(header: string | null, etag: string): boolean {
|
|
219
|
-
if (header === null) return false;
|
|
220
|
-
if (header.trim() === '*') return true;
|
|
221
|
-
return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === etag);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* The inclusive window `[start, end]` of a stream, as a stream. A disk streams from byte 0 (the
|
|
226
|
-
* driver contract has no ranged read), so the bytes before `start` are read and dropped: bounded
|
|
227
|
-
* memory, at the cost of reading the prefix — the trade a `Range` probe on a video wants over
|
|
228
|
-
* buffering the whole object.
|
|
229
|
-
*/
|
|
230
|
-
function windowOf(
|
|
231
|
-
source: ReadableStream<Uint8Array>,
|
|
232
|
-
start: number,
|
|
233
|
-
end: number,
|
|
234
|
-
): ReadableStream<Uint8Array> {
|
|
235
|
-
let offset = 0;
|
|
236
|
-
return source.pipeThrough(
|
|
237
|
-
new TransformStream<Uint8Array, Uint8Array>({
|
|
238
|
-
transform(chunk, controller) {
|
|
239
|
-
const from = Math.max(start - offset, 0);
|
|
240
|
-
const to = Math.min(end + 1 - offset, chunk.byteLength);
|
|
241
|
-
offset += chunk.byteLength;
|
|
242
|
-
if (to > from) controller.enqueue(chunk.subarray(from, to));
|
|
243
|
-
if (offset > end) controller.terminate();
|
|
244
|
-
},
|
|
245
|
-
}),
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* The content type is the STORED one — the upload gate sniffed those bytes and `put` recorded the
|
|
251
|
-
* answer, so an extension in the URL is the only party that can lie. The validator is the driver's
|
|
252
|
-
* own content hash (local: sha256 of the bytes, S3: the provider's etag), which is the identity
|
|
253
|
-
* storage already keeps for an object; inventing a cache key here would be a second one.
|
|
254
|
-
*/
|
|
255
|
-
export async function storageResponse(
|
|
256
|
-
request: UltimateRequest,
|
|
257
|
-
served: ServedObject,
|
|
258
|
-
): Promise<Response> {
|
|
259
|
-
const { object } = served;
|
|
260
|
-
const etag = `"${object.etag}"`;
|
|
261
|
-
const headers = new Headers({
|
|
262
|
-
'content-type': object.contentType,
|
|
263
|
-
etag,
|
|
264
|
-
'accept-ranges': 'bytes',
|
|
265
|
-
// A type a browser would RUN (html, svg, xml) is a download, never a page on this origin.
|
|
266
|
-
...storedObjectHeaders(object.contentType),
|
|
267
|
-
});
|
|
268
|
-
// Omitted, never invented: a disk whose provider reported no date has none, and a `Last-Modified`
|
|
269
|
-
// made up for it is a validator a cache would go on to trust.
|
|
270
|
-
const modified = object.lastModified;
|
|
271
|
-
if (modified !== undefined) headers.set('last-modified', modified.toUTCString());
|
|
272
|
-
// Revalidation costs a request and no bytes, which is the trade an authorized response wants:
|
|
273
|
-
// the bytes never change under a key, but the actor's permission to read them can be revoked.
|
|
274
|
-
if (etagMatches(request.header('if-none-match'), etag)) {
|
|
275
|
-
return new Response(null, { status: 304, headers });
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const size = object.size;
|
|
279
|
-
const range = parseByteRange(request.header('range'), size);
|
|
280
|
-
if (range === UNSATISFIABLE) {
|
|
281
|
-
headers.set('content-range', `bytes */${size}`);
|
|
282
|
-
return new Response(null, { status: 416, headers });
|
|
283
|
-
}
|
|
284
|
-
// `size` is the stat's: a key's bytes never change under it (an upload mints a new key), so the
|
|
285
|
-
// length declared here is the length the stream delivers.
|
|
286
|
-
if (range === undefined) {
|
|
287
|
-
headers.set('content-length', String(size));
|
|
288
|
-
return new Response(await served.open(), { headers });
|
|
289
|
-
}
|
|
290
|
-
headers.set('content-range', `bytes ${range.start}-${range.end}/${size}`);
|
|
291
|
-
headers.set('content-length', String(range.end - range.start + 1));
|
|
292
|
-
return new Response(windowOf(await served.open(), range.start, range.end), {
|
|
293
|
-
status: 206,
|
|
294
|
-
headers,
|
|
295
|
-
});
|
|
195
|
+
return {
|
|
196
|
+
object,
|
|
197
|
+
open: async (range) =>
|
|
198
|
+
range === undefined ? driver.stream(key) : driver.streamRange(key, [range.start, range.end]),
|
|
199
|
+
};
|
|
296
200
|
}
|
|
297
201
|
|
|
298
202
|
/**
|
|
@@ -355,7 +259,7 @@ export function storageRoutes(options: StorageRoutesOptions): readonly Route[] {
|
|
|
355
259
|
key: request.params['key'] ?? '',
|
|
356
260
|
};
|
|
357
261
|
authorizeStorageRead(input, ctx);
|
|
358
|
-
return
|
|
262
|
+
return objectResponse(request, await readStorageObject(options.storage, input, ctx.actor));
|
|
359
263
|
},
|
|
360
264
|
},
|
|
361
265
|
// The upload half: the `PUT` every `grantUpload` URL names (#523). One call mounts both, so
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Single responsibility: the views half of `diffDeclaredSchema` — what the entities declare as
|
|
2
|
+
// `entity({ view })` against the views the newest migration's snapshot recorded. A view is
|
|
3
|
+
// recorded as the statements `x db gen` wrote for it, so equal statements are the whole agreement.
|
|
4
|
+
|
|
5
|
+
import type { ViewDescription } from '@ultimat3/db';
|
|
6
|
+
import type { SchemaDifference } from './schema-diff';
|
|
7
|
+
|
|
8
|
+
const DECLARED_ONLY = 'is declared by the entities and no migration recorded it';
|
|
9
|
+
const RECORDED_ONLY = 'was recorded by the newest migration and no entity declares it';
|
|
10
|
+
const STATEMENTS_MOVED = 'is declared with a query or index the newest migration did not record';
|
|
11
|
+
|
|
12
|
+
const kindOf = (view: ViewDescription): string =>
|
|
13
|
+
view.materialized === true ? 'materialized' : 'a plain view';
|
|
14
|
+
|
|
15
|
+
function viewDetail(declared: ViewDescription, recorded: ViewDescription): string | undefined {
|
|
16
|
+
if ((declared.materialized === true) !== (recorded.materialized === true)) {
|
|
17
|
+
return `is declared ${kindOf(declared)} and was recorded ${kindOf(recorded)}`;
|
|
18
|
+
}
|
|
19
|
+
const same =
|
|
20
|
+
declared.create.length === recorded.create.length &&
|
|
21
|
+
declared.create.every((statement, at) => statement === recorded.create[at]);
|
|
22
|
+
return same ? undefined : STATEMENTS_MOVED;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Both directions, by name. Absent `views` is "none recorded": every snapshot written before views
|
|
27
|
+
* existed, which is agreement with an app that declares none. A changed view is `unmigrated`, as a
|
|
28
|
+
* changed column is — the declaration is the intent, and `x db gen` rebuilds every view from it.
|
|
29
|
+
*/
|
|
30
|
+
export function diffViews(
|
|
31
|
+
declared: readonly ViewDescription[] = [],
|
|
32
|
+
recorded: readonly ViewDescription[] = [],
|
|
33
|
+
): readonly SchemaDifference[] {
|
|
34
|
+
const out: SchemaDifference[] = [];
|
|
35
|
+
const byName = new Map(recorded.map((view) => [view.name, view]));
|
|
36
|
+
for (const view of declared) {
|
|
37
|
+
const counterpart = byName.get(view.name);
|
|
38
|
+
const detail = counterpart === undefined ? DECLARED_ONLY : viewDetail(view, counterpart);
|
|
39
|
+
if (detail === undefined) continue;
|
|
40
|
+
out.push({ direction: 'unmigrated', part: 'view', table: view.name, name: view.name, detail });
|
|
41
|
+
}
|
|
42
|
+
const names = new Set(declared.map((view) => view.name));
|
|
43
|
+
for (const view of recorded) {
|
|
44
|
+
if (names.has(view.name)) continue;
|
|
45
|
+
out.push({
|
|
46
|
+
direction: 'undeclared',
|
|
47
|
+
part: 'view',
|
|
48
|
+
table: view.name,
|
|
49
|
+
name: view.name,
|
|
50
|
+
detail: RECORDED_ONLY,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
package/src/schema-diff.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
TableDescription,
|
|
18
18
|
} from '@ultimat3/db';
|
|
19
19
|
import { indexMethodOf } from '@ultimat3/db';
|
|
20
|
+
import { diffViews } from './schema-diff-views';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Which side holds a declaration the other does not, and they are two different repairs.
|
|
@@ -25,8 +26,11 @@ import { indexMethodOf } from '@ultimat3/db';
|
|
|
25
26
|
*/
|
|
26
27
|
export type SchemaDirection = 'unmigrated' | 'undeclared';
|
|
27
28
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The part of a table a difference is about, or a whole view (`table` is then the view's name).
|
|
31
|
+
* Rendered into the cause verbatim.
|
|
32
|
+
*/
|
|
33
|
+
export type SchemaPart = 'table' | 'view' | 'column' | 'index' | 'foreign key' | 'check';
|
|
30
34
|
|
|
31
35
|
export interface SchemaDifference {
|
|
32
36
|
readonly direction: SchemaDirection;
|
|
@@ -73,6 +77,12 @@ function columnDetail(
|
|
|
73
77
|
recorded.generated ?? 'not generated'
|
|
74
78
|
}`;
|
|
75
79
|
}
|
|
80
|
+
// `true` or absent. Listed under `retired` with no marker yet is the app's half alone.
|
|
81
|
+
if ((declared.retired === true) !== (recorded.retired === true)) {
|
|
82
|
+
return declared.retired === true
|
|
83
|
+
? 'is declared retired and no migration recorded it'
|
|
84
|
+
: 'is declared again and was recorded retired';
|
|
85
|
+
}
|
|
76
86
|
return undefined;
|
|
77
87
|
}
|
|
78
88
|
|
|
@@ -240,7 +250,14 @@ function compareTable(declared: TableDescription, recorded: TableDescription): S
|
|
|
240
250
|
return out;
|
|
241
251
|
}
|
|
242
252
|
|
|
243
|
-
const PART_ORDER: readonly SchemaPart[] = [
|
|
253
|
+
const PART_ORDER: readonly SchemaPart[] = [
|
|
254
|
+
'table',
|
|
255
|
+
'view',
|
|
256
|
+
'column',
|
|
257
|
+
'index',
|
|
258
|
+
'foreign key',
|
|
259
|
+
'check',
|
|
260
|
+
];
|
|
244
261
|
|
|
245
262
|
/**
|
|
246
263
|
* Pure and total: the same two snapshots always produce the same ordered report, so a diff of two
|
|
@@ -281,6 +298,7 @@ export function diffDeclaredSchema(
|
|
|
281
298
|
detail: RECORDED_ONLY,
|
|
282
299
|
});
|
|
283
300
|
}
|
|
301
|
+
out.push(...diffViews(declared.views, recorded.views));
|
|
284
302
|
out.sort((a, b) => {
|
|
285
303
|
if (a.table !== b.table) return a.table < b.table ? -1 : 1;
|
|
286
304
|
if (a.part !== b.part) return PART_ORDER.indexOf(a.part) - PART_ORDER.indexOf(b.part);
|
package/src/schema-drift.ts
CHANGED
|
@@ -15,7 +15,13 @@ import { existsSync } from 'node:fs';
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
17
17
|
import type { EntityDescriptionLike, UnrenderedDeclaration } from '@ultimat3/db';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
declaredSchema,
|
|
20
|
+
recordRetired,
|
|
21
|
+
shellInertIdentifier,
|
|
22
|
+
snapshotOf,
|
|
23
|
+
unrenderedOf,
|
|
24
|
+
} from '@ultimat3/db';
|
|
19
25
|
import { describeEntities } from '@ultimat3/entity';
|
|
20
26
|
import { loadApp } from './app-load';
|
|
21
27
|
import { checkSourceDrift, DB_PACKAGE } from './drift';
|
|
@@ -26,6 +32,7 @@ import {
|
|
|
26
32
|
} from './drift-replica-identity';
|
|
27
33
|
import { MIGRATIONS_DIR, readMigrations } from './migrations';
|
|
28
34
|
import type { Finding } from './output';
|
|
35
|
+
import { appRowSecurity, type RowSecurityWanted, rowSecurityFindings } from './row-security-wanted';
|
|
29
36
|
import { diffDeclaredSchema, type SchemaDifference } from './schema-diff';
|
|
30
37
|
|
|
31
38
|
/**
|
|
@@ -43,7 +50,7 @@ export type DeclaredEntities = (
|
|
|
43
50
|
* one of these tables was dropped" — a DROP per table, handed out as the fix, for a syntax error in
|
|
44
51
|
* one file. `loadApp`'s findings are reported by the steps that own them.
|
|
45
52
|
*/
|
|
46
|
-
const appEntities: DeclaredEntities = async (root) => {
|
|
53
|
+
export const appEntities: DeclaredEntities = async (root) => {
|
|
47
54
|
const app = await loadApp(root);
|
|
48
55
|
if (app.findings.length > 0) return undefined;
|
|
49
56
|
return describeEntities();
|
|
@@ -111,10 +118,10 @@ function repairFix(
|
|
|
111
118
|
* never get what the app declares; `undeclared` means the database holds what nothing declares.
|
|
112
119
|
* One "drift" verdict over both teaches a reader neither.
|
|
113
120
|
*/
|
|
114
|
-
/** A table difference names
|
|
121
|
+
/** A table or view difference names it once: `table "customers" on table "customers"` did not. */
|
|
115
122
|
export const schemaDifferenceCause = (difference: SchemaDifference): string =>
|
|
116
|
-
difference.part === 'table'
|
|
117
|
-
?
|
|
123
|
+
difference.part === 'table' || difference.part === 'view'
|
|
124
|
+
? `${difference.part} "${difference.table}" ${difference.detail}`
|
|
118
125
|
: `${difference.part} "${difference.name}" on table "${difference.table}" ${difference.detail}`;
|
|
119
126
|
|
|
120
127
|
function findingFor(difference: SchemaDifference, repair: Repair): Finding {
|
|
@@ -139,6 +146,7 @@ export async function checkSnapshotDrift(
|
|
|
139
146
|
root: string,
|
|
140
147
|
declared: DeclaredEntities = appEntities,
|
|
141
148
|
identity: ReplicaIdentityWanted = appReplicaIdentity,
|
|
149
|
+
rowSecurity: RowSecurityWanted = appRowSecurity,
|
|
142
150
|
): Promise<readonly Finding[]> {
|
|
143
151
|
if (!existsSync(join(root, DB_PACKAGE))) return [];
|
|
144
152
|
const entities = await declared(root);
|
|
@@ -151,12 +159,22 @@ export async function checkSnapshotDrift(
|
|
|
151
159
|
// states for the same call: a short one makes every subscribed name in the missing module a typo.
|
|
152
160
|
const wanted = wantedReplicaIdentity(identity, new Set(entities.map((entity) => entity.table)));
|
|
153
161
|
if ('finding' in wanted) return [wanted.finding];
|
|
154
|
-
|
|
155
|
-
|
|
162
|
+
// `recordRetired`, the step `x db gen` takes after `snapshotOf`: a retired column stays on the
|
|
163
|
+
// record though no entity declares it, and that is the design rather than a difference.
|
|
164
|
+
// Its own code, ahead of the generic diff: a flipped flag moves no entity and no source hash.
|
|
165
|
+
const policies = rowSecurityFindings(entities, recorded, await rowSecurity(root));
|
|
166
|
+
const differences = diffDeclaredSchema(
|
|
167
|
+
recordRetired(snapshotOf(entities, wanted.tables), entities, recorded),
|
|
168
|
+
recorded,
|
|
169
|
+
);
|
|
170
|
+
if (differences.length === 0) return policies;
|
|
156
171
|
// `recorded` and not the entities alone: whether an `assert` is a LOSS depends on whether a
|
|
157
172
|
// migration recorded a CHECK for it, which only the sidecar knows.
|
|
158
173
|
const unrendered = unrenderedOf(entities, recorded);
|
|
159
|
-
return
|
|
174
|
+
return [
|
|
175
|
+
...policies,
|
|
176
|
+
...differences.map((difference) => findingFor(difference, repairFix(unrendered, difference))),
|
|
177
|
+
];
|
|
160
178
|
}
|
|
161
179
|
|
|
162
180
|
/**
|
|
@@ -175,8 +193,9 @@ export async function checkMigrationDrift(
|
|
|
175
193
|
declared: DeclaredEntities = appEntities,
|
|
176
194
|
hashDrift: (root: string) => Promise<readonly Finding[]> = checkSourceDrift,
|
|
177
195
|
identity: ReplicaIdentityWanted = appReplicaIdentity,
|
|
196
|
+
rowSecurity: RowSecurityWanted = appRowSecurity,
|
|
178
197
|
): Promise<readonly Finding[]> {
|
|
179
|
-
const snapshot = await checkSnapshotDrift(root, declared, identity);
|
|
198
|
+
const snapshot = await checkSnapshotDrift(root, declared, identity, rowSecurity);
|
|
180
199
|
if (snapshot.length > 0) return snapshot;
|
|
181
200
|
return hashDrift(root);
|
|
182
201
|
}
|
package/src/schema-dump-drift.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// The `drift` step's fourth rail: is the committed schema dump what the migrations produce,
|
|
2
|
-
// does a database loaded from it come back the same? The other three rails read files and open
|
|
1
|
+
// The `drift` step's fourth rail: is the committed schema dump what the migrations produce, does
|
|
2
|
+
// it hold what their snapshots record, and does a database loaded from it come back the same? The other three rails read files and open
|
|
3
3
|
// nothing; this one replays the migrations on a scratch embedded database, because "what the
|
|
4
|
-
// migrations produce" is a fact only a database can state.
|
|
4
|
+
// migrations produce" is a fact only a database can state — and so is what a view outputs.
|
|
5
5
|
|
|
6
6
|
// why: Bun has no directory probe — `Bun.file().exists()` answers about a file.
|
|
7
7
|
import { existsSync } from 'node:fs';
|
|
@@ -9,7 +9,8 @@ import { existsSync } from 'node:fs';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
11
11
|
import type { Migration } from '@ultimat3/db';
|
|
12
|
-
import
|
|
12
|
+
import { ungeneratableStatements } from '@ultimat3/db';
|
|
13
|
+
import type { SchemaDumpDifference, SchemaDumpFile } from '@ultimat3/db/schema-dump';
|
|
13
14
|
import { compareSchemaDump, schemaDumpDrift } from '@ultimat3/db/schema-dump';
|
|
14
15
|
import {
|
|
15
16
|
hasSchemaDump,
|
|
@@ -20,9 +21,12 @@ import {
|
|
|
20
21
|
} from './db-schema-dump';
|
|
21
22
|
import { dumpNeverGenerated, replayFailure } from './db-schema-refresh';
|
|
22
23
|
import { DB_PACKAGE } from './drift';
|
|
23
|
-
import { readMigrations } from './migrations';
|
|
24
|
+
import { MIGRATIONS_DIR, readMigrations } from './migrations';
|
|
24
25
|
import type { Finding } from './output';
|
|
25
26
|
import { findingFrom } from './output';
|
|
27
|
+
import { appEntities, type DeclaredEntities } from './schema-drift';
|
|
28
|
+
import { quoteArg } from './shell-quote';
|
|
29
|
+
import { viewColumnFindings } from './view-drift';
|
|
26
30
|
|
|
27
31
|
/** Injected so this module's rules are testable without booting a database per case. */
|
|
28
32
|
export type SchemaReplay = (migrations: readonly Migration[]) => Promise<ReplayedSchema>;
|
|
@@ -33,6 +37,77 @@ const findingFor = (difference: SchemaDumpDifference): Finding => ({
|
|
|
33
37
|
at: `${SCHEMA_DUMP_DIR}/${difference.path}`,
|
|
34
38
|
});
|
|
35
39
|
|
|
40
|
+
/** One ` "name" type…,` line of a generated `create table`; a `constraint` line opens with no quote. */
|
|
41
|
+
const DUMP_COLUMN = /^ {2}"((?:[^"]|"")+)" /gm;
|
|
42
|
+
|
|
43
|
+
const dumpColumns = (sql: string): readonly string[] =>
|
|
44
|
+
[...sql.matchAll(DUMP_COLUMN)].map((match) => (match[1] ?? '').replaceAll('""', '"'));
|
|
45
|
+
|
|
46
|
+
/** The tables a statement alters or creates, as written: optionally schema-qualified, quoted or not. */
|
|
47
|
+
const TABLE_STATEMENT =
|
|
48
|
+
/\b(?:alter|create|drop)\s+(?:unlogged\s+|temp(?:orary)?\s+)?table\s+(?:if\s+(?:not\s+)?exists\s+)?(?:only\s+)?(?:(?:"[^"]+"|[\w$]+)\.)?(?:"([^"]+)"|([\w$]+))/gi;
|
|
49
|
+
|
|
50
|
+
/** As Postgres names it: an unquoted identifier folds to lower case, a quoted one is kept. */
|
|
51
|
+
const tablesNamedBy = (sql: string): readonly string[] =>
|
|
52
|
+
[...sql.matchAll(TABLE_STATEMENT)].flatMap((match) => {
|
|
53
|
+
const name = match[1] ?? match[2]?.toLowerCase();
|
|
54
|
+
return name === undefined ? [] : [name];
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Tables whose columns a snapshot cannot speak for: `x db gen` writes a snapshot from the entities,
|
|
59
|
+
* so hand-written SQL is on no snapshot. That is every migration holding statements it could not have
|
|
60
|
+
* generated, and every one newer than the newest snapshot, which is a file somebody wrote by hand.
|
|
61
|
+
*/
|
|
62
|
+
const handWrittenTables = (migrations: readonly Migration[], newest: number): Set<string> => {
|
|
63
|
+
const tables = new Set<string>();
|
|
64
|
+
migrations.forEach((migration, index) => {
|
|
65
|
+
const sql = index > newest ? migration.up : ungeneratableStatements(migration.up).join(';\n');
|
|
66
|
+
for (const table of tablesNamedBy(sql)) tables.add(table);
|
|
67
|
+
});
|
|
68
|
+
return tables;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The rail the replay below cannot be: the dump IS that replay's output, so an edited migration
|
|
73
|
+
* regenerates into it and both sides agree. The snapshot was written from the entities and did not
|
|
74
|
+
* move. No database, and read beside the replay's findings rather than ahead of them: an edited
|
|
75
|
+
* migration and a stale file are two repairs.
|
|
76
|
+
*/
|
|
77
|
+
function dumpColumnFindings(
|
|
78
|
+
migrations: readonly Migration[],
|
|
79
|
+
committed: readonly SchemaDumpFile[],
|
|
80
|
+
): readonly Finding[] {
|
|
81
|
+
const newest = migrations.findLastIndex((migration) => migration.snapshot !== undefined);
|
|
82
|
+
const snapshot = migrations[newest]?.snapshot;
|
|
83
|
+
if (snapshot === undefined) return [];
|
|
84
|
+
const exempt = handWrittenTables(migrations, newest);
|
|
85
|
+
const files = new Map(committed.map((file) => [file.path, file.content]));
|
|
86
|
+
return snapshot.tables.flatMap((table) => {
|
|
87
|
+
const path = `04_tables/${table.name}.sql`;
|
|
88
|
+
const held = files.get(path);
|
|
89
|
+
if (held === undefined || exempt.has(table.name)) return [];
|
|
90
|
+
const dumped = dumpColumns(held);
|
|
91
|
+
const recorded = table.columns.map((column) => column.name);
|
|
92
|
+
const extra = dumped.filter((name) => !recorded.includes(name));
|
|
93
|
+
const lost = recorded.filter((name) => !dumped.includes(name));
|
|
94
|
+
if (extra.length === 0 && lost.length === 0) return [];
|
|
95
|
+
const said = [
|
|
96
|
+
...extra.map((name) => `"${name}" is in the dump`),
|
|
97
|
+
...lost.map((name) => `"${name}" is only in the snapshot`),
|
|
98
|
+
].join(', ');
|
|
99
|
+
return [
|
|
100
|
+
{
|
|
101
|
+
code: 'X_DB_DRIFT',
|
|
102
|
+
cause: `the dump holds a column no migration's snapshot records, in table "${table.name}": ${said}`,
|
|
103
|
+
fix: `git checkout -- ${quoteArg(MIGRATIONS_DIR)} ${quoteArg(SCHEMA_DUMP_DIR)} # an applied migration was edited: restore it. Meant it: declare the column on its entity, then x db gen "<change>"`,
|
|
104
|
+
docs: ERROR_DOCS_URL,
|
|
105
|
+
at: `${SCHEMA_DUMP_DIR}/${path}`,
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
36
111
|
/**
|
|
37
112
|
* Empty result = the dump is the migrations' own.
|
|
38
113
|
*
|
|
@@ -49,16 +124,25 @@ export async function checkSchemaDump(
|
|
|
49
124
|
root: string,
|
|
50
125
|
env: Readonly<Record<string, string | undefined>> = Bun.env,
|
|
51
126
|
replay: SchemaReplay = replayFor(root, env, true),
|
|
127
|
+
declared: DeclaredEntities = appEntities,
|
|
52
128
|
): Promise<readonly Finding[]> {
|
|
53
129
|
if (!existsSync(join(root, DB_PACKAGE))) return [];
|
|
54
130
|
const migrations = await readMigrations(root);
|
|
55
131
|
if (!hasSchemaDump(root)) return migrations.length === 0 ? [] : [dumpNeverGenerated()];
|
|
56
132
|
const committed = await readSchemaDump(root);
|
|
133
|
+
const columns = dumpColumnFindings(migrations, committed);
|
|
57
134
|
let replayed: ReplayedSchema;
|
|
58
135
|
try {
|
|
59
136
|
replayed = await replay(migrations);
|
|
60
137
|
} catch (error) {
|
|
61
|
-
return [replayFailure(error)];
|
|
138
|
+
return [...columns, replayFailure(error)];
|
|
62
139
|
}
|
|
63
|
-
|
|
140
|
+
// Only a replay that created a view loads the app: what a view outputs is the one column list
|
|
141
|
+
// no snapshot records, and an app with no view owes this rail nothing.
|
|
142
|
+
const entities = replayed.catalog.views.length === 0 ? undefined : await declared(root);
|
|
143
|
+
return [
|
|
144
|
+
...columns,
|
|
145
|
+
...[...compareSchemaDump(committed, replayed.files), ...replayed.reload].map(findingFor),
|
|
146
|
+
...(entities === undefined ? [] : viewColumnFindings(entities, replayed.catalog)),
|
|
147
|
+
];
|
|
64
148
|
}
|
package/src/script-csp.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { HYDRATE_RUNTIME_BODIES, STREAM_REVEAL_BODIES } from '@ultimat3/render';
|
|
|
16
16
|
* which `theme-boot.ts` derives from the same string it inlines.
|
|
17
17
|
*/
|
|
18
18
|
export function inlineScriptSources(extra: readonly string[] = []): readonly string[] {
|
|
19
|
-
// The stream reveal too: a `render: 'stream'` page swaps each
|
|
19
|
+
// The stream reveal too: a `render: 'stream'` page swaps each `<Hole>` in with a
|
|
20
20
|
// constant inline `$X()` call, and unhashed it is blocked under the enforced policy.
|
|
21
21
|
const bodies = [...HYDRATE_RUNTIME_BODIES, ...STREAM_REVEAL_BODIES];
|
|
22
22
|
return [...new Set([...bodies.map(cspHashSource), ...extra])].sort();
|
package/src/seo-cards.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Single responsibility: the convention `og.capture` rests on, as a gate finding — a declared card
|
|
2
|
+
// path is a `site/` route, or the build has no page to photograph. `x verify`'s `seo` step asks it
|
|
3
|
+
// of every route whose meta the scan resolved; the build asks it again of the filled paths.
|
|
4
|
+
|
|
5
|
+
import { routeEntries } from '@ultimat3/render';
|
|
6
|
+
import type { RouteRecord } from '@ultimat3/seo';
|
|
7
|
+
import type { Finding } from './output';
|
|
8
|
+
import { findingFrom } from './output';
|
|
9
|
+
import { CardRouteUnknownError } from './static-capture-errors';
|
|
10
|
+
|
|
11
|
+
/** True when some `site/` route's pattern answers `path` — a static or a prerendered dynamic one. */
|
|
12
|
+
const isSiteRoute = (path: string): boolean =>
|
|
13
|
+
routeEntries().some((entry) => entry.surface === 'site' && entry.pattern.regex.test(path));
|
|
14
|
+
|
|
15
|
+
export function cardRouteFindings(records: readonly RouteRecord[]): readonly Finding[] {
|
|
16
|
+
const findings: Finding[] = [];
|
|
17
|
+
for (const record of records) {
|
|
18
|
+
const capture = record.meta?.og?.capture;
|
|
19
|
+
if (capture === undefined || isSiteRoute(capture)) continue;
|
|
20
|
+
findings.push({
|
|
21
|
+
...findingFrom(new CardRouteUnknownError([capture], record.file)),
|
|
22
|
+
at: record.file,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return findings;
|
|
26
|
+
}
|