@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
|
@@ -16,8 +16,10 @@ const source =
|
|
|
16
16
|
// step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
|
|
17
17
|
//
|
|
18
18
|
// Only a statement the handle can express is reported: \`select *\`, \`insert\`, \`update\` or \`delete\`
|
|
19
|
-
// on ONE table, with plain comparisons
|
|
19
|
+
// on ONE table, with plain comparisons, and \`select * … where id = ? for update\`. A join, an aggregate, a CTE, a window, an \`or\`, a function
|
|
20
20
|
// call and a statement composed from another fragment are past what the handle says, and silent.
|
|
21
|
+
// One more shape is reported in any statement, join or not: an \`order by date_trunc('milliseconds', …)\`,
|
|
22
|
+
// whose repair is \`keyset()\` from \`@ultimat3/db\`.
|
|
21
23
|
|
|
22
24
|
import type { Finding, Guard } from '@ultimat3/cli';
|
|
23
25
|
|
|
@@ -142,6 +144,8 @@ const UPDATE = new RegExp(
|
|
|
142
144
|
\`^update \${TABLE} set \${ASSIGNMENT}(?:, ?\${ASSIGNMENT})* where \${WHERE}(?: returning \\\\*)?$\`,
|
|
143
145
|
);
|
|
144
146
|
const DELETE = new RegExp(\`^delete from \${TABLE} where \${WHERE}(?: returning \\\\*)?$\`);
|
|
147
|
+
/** One row by its id, locked: the read-modify-write \`lock\` exists for. Another lock mode is silent. */
|
|
148
|
+
const LOCKED = new RegExp(\`^select \\\\* from \${TABLE} where \${WHERE}(?: limit 1)? for update$\`);
|
|
145
149
|
|
|
146
150
|
/** \`short_links\` → \`shortLinks\`: the handle is keyed by the camelCase the entity declares. */
|
|
147
151
|
const camel = (name: string): string =>
|
|
@@ -190,6 +194,10 @@ export function handleCallFor(sql: string): string | undefined {
|
|
|
190
194
|
.trim()
|
|
191
195
|
.replace(/;$/, '')
|
|
192
196
|
.trim();
|
|
197
|
+
const locked = LOCKED.exec(text);
|
|
198
|
+
if (locked !== null && /(^| )"?id"? = \\?/.test(locked[2] ?? '')) {
|
|
199
|
+
return \`db.\${camel(locked[1] ?? '')}.lock(id, fn)\`;
|
|
200
|
+
}
|
|
193
201
|
const select = SELECT.exec(text);
|
|
194
202
|
if (select !== null) {
|
|
195
203
|
const [, table = '', where, column, direction, limit] = select;
|
|
@@ -220,11 +228,38 @@ export function handleCallFor(sql: string): string | undefined {
|
|
|
220
228
|
return undefined;
|
|
221
229
|
}
|
|
222
230
|
|
|
231
|
+
/**
|
|
232
|
+
* \`order by date_trunc('milliseconds', col)\`: the cure for a JS \`Date\` dropping microseconds, and
|
|
233
|
+
* the plan's loss — an expression the index does not hold sorts every row. A line comment inside
|
|
234
|
+
* the statement is not code, so it is dropped before the match.
|
|
235
|
+
*/
|
|
236
|
+
export function truncatedOrder(sql: string): boolean {
|
|
237
|
+
const text = sql
|
|
238
|
+
.replaceAll(/--[^\\n]*/g, ' ')
|
|
239
|
+
.toLowerCase()
|
|
240
|
+
.replaceAll(/\\s+/g, ' ');
|
|
241
|
+
return /\\border by [^;]*\\bdate_trunc ?\\( ?'milliseconds'/.test(text);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const KEYSET_CALL =
|
|
245
|
+
"keyset({ scope, keys: [{ column: raw('p.created_at'), kind: 'timestamptz', direction: 'desc' }, { column: raw('p.id'), kind: 'uuid', direction: 'desc' }], after, limit })";
|
|
246
|
+
|
|
247
|
+
const truncatedFinding = (at: string): Finding => ({
|
|
248
|
+
code: CODE,
|
|
249
|
+
cause: \`\${at} orders by date_trunc('milliseconds', …), the workaround for a Date losing microseconds — and the index cannot serve that expression: measured on 300,000 rows, an Index Only Scan (0.2 ms) became a Parallel Seq Scan and a sort (100 ms)\`,
|
|
250
|
+
fix: \`\${KEYSET_CALL} — from @ultimat3/db, its select, where, orderBy and limit spliced in place of the date_trunc order at \${at}, then: x verify\`,
|
|
251
|
+
at,
|
|
252
|
+
});
|
|
253
|
+
|
|
223
254
|
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
224
255
|
export function rawRepoSql(files: readonly RepoFile[]): readonly Finding[] {
|
|
225
256
|
const findings: Finding[] = [];
|
|
226
257
|
for (const file of files) {
|
|
227
258
|
for (const statement of sqlStatements(file.source)) {
|
|
259
|
+
if (truncatedOrder(statement.text)) {
|
|
260
|
+
findings.push(truncatedFinding(\`\${file.path}:\${statement.line}\`));
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
228
263
|
const call = handleCallFor(statement.text);
|
|
229
264
|
if (call === undefined) continue;
|
|
230
265
|
const at = \`\${file.path}:\${statement.line}\`;
|
|
@@ -254,7 +289,7 @@ const test =
|
|
|
254
289
|
// a green gate over the convention it was written to enforce.
|
|
255
290
|
|
|
256
291
|
import { expect, unitTest } from '@ultimat3/testing';
|
|
257
|
-
import { handleCallFor, rawRepoSql, sqlStatements } from './repo-raw-sql';
|
|
292
|
+
import { handleCallFor, rawRepoSql, sqlStatements, truncatedOrder } from './repo-raw-sql';
|
|
258
293
|
|
|
259
294
|
const REPO = 'apps/web/app/post/repo.ts';
|
|
260
295
|
const repo = (source: string) => [{ path: REPO, source }];
|
|
@@ -302,6 +337,18 @@ unitTest('insert, update and delete on one table are the same rule', () => {
|
|
|
302
337
|
);
|
|
303
338
|
});
|
|
304
339
|
|
|
340
|
+
unitTest('a row locked by its id is the lock call, with or without its tenant', () => {
|
|
341
|
+
const locks = [
|
|
342
|
+
'select * from posts where id = ? for update',
|
|
343
|
+
'select * from posts where id = ? limit 1 for update',
|
|
344
|
+
'SELECT * FROM posts WHERE org_id = ? AND id = ? FOR UPDATE;',
|
|
345
|
+
];
|
|
346
|
+
for (const sql of locks) expect(handleCallFor(sql)).toBe('db.posts.lock(id, fn)');
|
|
347
|
+
const source = \`const row = sql\\\`select * from short_links where id = \${hole('id')} for update\\\`;\`;
|
|
348
|
+
const findings = rawRepoSql(repo(source));
|
|
349
|
+
expect(findings[0]?.fix).toStartWith('db.shortLinks.lock(id, fn) — ');
|
|
350
|
+
});
|
|
351
|
+
|
|
305
352
|
// The legitimate lookalikes — the reason the rule can stay switched on. Each is a statement the
|
|
306
353
|
// handle has no word for, and raw SQL in a repo is the right answer for it.
|
|
307
354
|
unitTest('a statement past what the handle says is silent', () => {
|
|
@@ -314,10 +361,32 @@ unitTest('a statement past what the handle says is silent', () => {
|
|
|
314
361
|
'with recent as (select * from posts) select * from recent',
|
|
315
362
|
'update posts set like_count = like_count + 1 where id = ?',
|
|
316
363
|
'insert into likes (post_id, user_id) values (?, ?) on conflict do nothing',
|
|
364
|
+
'select * from jobs where state = ? for update skip locked',
|
|
365
|
+
'select * from posts where id = ? for no key update',
|
|
366
|
+
'select * from posts where org_id = ? for update',
|
|
317
367
|
];
|
|
318
368
|
for (const sql of silent) expect(handleCallFor(sql)).toBeUndefined();
|
|
319
369
|
});
|
|
320
370
|
|
|
371
|
+
unitTest(
|
|
372
|
+
'an order by a millisecond-truncated column is refused in any statement, naming keyset()',
|
|
373
|
+
() => {
|
|
374
|
+
const join =
|
|
375
|
+
"select p.* from posts p join members m on m.id = p.author_id order by date_trunc('milliseconds', p.created_at) desc, p.id desc";
|
|
376
|
+
expect(truncatedOrder(join)).toBe(true);
|
|
377
|
+
expect(
|
|
378
|
+
truncatedOrder(
|
|
379
|
+
"select * from posts where date_trunc('day', created_at) = ? order by created_at",
|
|
380
|
+
),
|
|
381
|
+
).toBe(false);
|
|
382
|
+
expect(
|
|
383
|
+
truncatedOrder("select * from posts -- order by date_trunc('milliseconds', created_at)"),
|
|
384
|
+
).toBe(false);
|
|
385
|
+
const findings = rawRepoSql(repo(\`const q = sql\\\`\${join}\\\`;\`));
|
|
386
|
+
expect(findings[0]?.fix).toStartWith('keyset({ scope, keys: [');
|
|
387
|
+
},
|
|
388
|
+
);
|
|
389
|
+
|
|
321
390
|
unitTest('a statement composed from another fragment is silent', () => {
|
|
322
391
|
const inner = \`sql\\\`and a = \${hole('a')}\\\`\`;
|
|
323
392
|
const source = \`const rows = sql\\\`select * from posts where id = \${hole('id')} \${hole(inner)}\\\`;\`;
|
package/src/templates/job.ts
CHANGED
|
@@ -34,7 +34,7 @@ export const ${name.camel} = job({
|
|
|
34
34
|
retry: { attempts: 5, backoff: 'exponential' },
|
|
35
35
|
async run({ input, step }) {
|
|
36
36
|
const row = await step.run('load', () => repo.byId(input.id));
|
|
37
|
-
if (row ===
|
|
37
|
+
if (row === null) return { skipped: true };
|
|
38
38
|
await step.run('process', async () => {
|
|
39
39
|
await repo.list(1);
|
|
40
40
|
});
|
package/src/templates/naming.ts
CHANGED
|
@@ -25,6 +25,11 @@ export interface GeneratedSourceFile {
|
|
|
25
25
|
readonly path: string;
|
|
26
26
|
readonly contents: string | Uint8Array;
|
|
27
27
|
readonly merge?: undefined;
|
|
28
|
+
/**
|
|
29
|
+
* A sibling path this file is a RELATIVE symlink to (`AGENTS.md` → `CLAUDE.md`). `contents` is
|
|
30
|
+
* then the target's own text: the copy `write-generated.ts` writes where the OS refuses a link.
|
|
31
|
+
*/
|
|
32
|
+
readonly link?: string;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/**
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# The image pins a scaffolded app's compose files read (pinned-images.ts). YAML because Dependabot
|
|
2
|
+
# rewrites only compose files and Dockerfiles: a pin held in a template string is one it never sees.
|
|
3
|
+
# It is grouped in .github/dependabot.yml with docker/, so one PR moves every copy together.
|
|
4
|
+
services:
|
|
5
|
+
s3:
|
|
6
|
+
image: versity/versitygw:v1.9.0@sha256:eb7f786fcd91e72194e48fcf1ac16302d686bf8e3a71434bdbc067a6efd3fdc7
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The one source of a scaffolded app's Dependabot-maintained image pins: ./pinned/docker-compose.yml.
|
|
2
|
+
|
|
3
|
+
import pinned from './pinned/docker-compose.yml' with { type: 'yaml' };
|
|
4
|
+
|
|
5
|
+
const { services } = pinned as { services: { s3: { image: string } } };
|
|
6
|
+
|
|
7
|
+
/** The S3 gateway every scaffolded compose file runs. */
|
|
8
|
+
export const S3_GATEWAY_IMAGE = services.s3.image;
|
package/src/templates/policy.ts
CHANGED
|
@@ -80,6 +80,82 @@ export const can${feature.pascal}Create = can(
|
|
|
80
80
|
);
|
|
81
81
|
`;
|
|
82
82
|
|
|
83
|
+
/** One grant and no predicate: what decides on an org has nothing to decide on in this slice. */
|
|
84
|
+
const grantRule = (feature: NameSet, rule: string, verb: string): string =>
|
|
85
|
+
wrapList(
|
|
86
|
+
'',
|
|
87
|
+
`export const can${feature.pascal}${rule} = can(`,
|
|
88
|
+
[`'${feature.kebab}:${verb}'`],
|
|
89
|
+
');',
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The slice's entity names no tenant (`sliceHasNoTenant`), so there is no org for a rule to compare
|
|
94
|
+
* and none an orgless actor lacks: the grant is the whole rule. A row-level one is the author's.
|
|
95
|
+
*/
|
|
96
|
+
const singleTenantPolicySource = (
|
|
97
|
+
feature: NameSet,
|
|
98
|
+
): string => `// Authz for the ${feature.kebab} feature, whose entity names no tenant: the grant is the rule. Every
|
|
99
|
+
// branch here is reachable from every surface, and predicates are synchronous on purpose.
|
|
100
|
+
|
|
101
|
+
import { tag } from '@ultimat3/cache';
|
|
102
|
+
import { can, definePermissions } from '@ultimat3/policy';
|
|
103
|
+
|
|
104
|
+
// The permission set, declared rather than assumed. The augmentation narrows \`can()\` to these
|
|
105
|
+
// strings, so a typo is a build error instead of a rule that silently never matches.
|
|
106
|
+
declare module '@ultimat3/policy' {
|
|
107
|
+
interface PermissionRegistry {
|
|
108
|
+
'${feature.kebab}:read': true;
|
|
109
|
+
'${feature.kebab}:write': true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
${permissionSet(feature)}
|
|
114
|
+
|
|
115
|
+
/** What a write invalidates and a read depends on — one tag, both directions. */
|
|
116
|
+
export const ${feature.camel}Tag = tag('${feature.kebab}');
|
|
117
|
+
|
|
118
|
+
${grantRule(feature, 'Read', 'read')}
|
|
119
|
+
|
|
120
|
+
${grantRule(feature, 'Write', 'write')}
|
|
121
|
+
|
|
122
|
+
${grantRule(feature, 'Create', 'write')}
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
const singleTenantPolicyTest = (
|
|
126
|
+
feature: NameSet,
|
|
127
|
+
): string => `// The ${feature.kebab} rules, from the DENIAL side: anonymous, and the actor holding only read.
|
|
128
|
+
import { testActor } from '@ultimat3/policy';
|
|
129
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
130
|
+
${wrapImport([`can${feature.pascal}Create`, `can${feature.pascal}Read`, `can${feature.pascal}Write`], './policy')}
|
|
131
|
+
|
|
132
|
+
// Each grant named once, so every assertion below fits the formatter's width at any feature name.
|
|
133
|
+
const read = '${feature.kebab}:read';
|
|
134
|
+
const write = '${feature.kebab}:write';
|
|
135
|
+
const input = {};
|
|
136
|
+
|
|
137
|
+
const reader = testActor('reader', { permissions: [read] }).actor;
|
|
138
|
+
const writer = testActor('writer', { permissions: [read, write] }).actor;
|
|
139
|
+
|
|
140
|
+
unitTest('${feature.camel} read denies anonymous', async () => {
|
|
141
|
+
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input });
|
|
142
|
+
await expect(can${feature.pascal}Read).not.toDenyPolicy({ actor: reader, input });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
unitTest('${feature.camel} write and create deny the read-only actor', async () => {
|
|
146
|
+
await expect(can${feature.pascal}Write).toDenyPolicy({ actor: reader, input });
|
|
147
|
+
await expect(can${feature.pascal}Write).not.toDenyPolicy({ actor: writer, input });
|
|
148
|
+
await expect(can${feature.pascal}Create).toDenyPolicy({ actor: reader, input });
|
|
149
|
+
await expect(can${feature.pascal}Create).not.toDenyPolicy({ actor: writer, input });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
unitTest('${feature.camel} rules name the permission they require', () => {
|
|
153
|
+
expect(can${feature.pascal}Read.permissions).toEqual(['${feature.kebab}:read']);
|
|
154
|
+
expect(can${feature.pascal}Write.permissions).toEqual(['${feature.kebab}:write']);
|
|
155
|
+
expect(can${feature.pascal}Create.permissions).toEqual(['${feature.kebab}:write']);
|
|
156
|
+
});
|
|
157
|
+
`;
|
|
158
|
+
|
|
83
159
|
const policyTest = (
|
|
84
160
|
feature: NameSet,
|
|
85
161
|
): string => `// The ${feature.kebab} rules, from the DENIAL side: anonymous, cross-org, and the actor holding
|
|
@@ -142,11 +218,18 @@ unitTest('${feature.camel} rules name the permission they require', () => {
|
|
|
142
218
|
export function policyFiles(
|
|
143
219
|
rawName: string,
|
|
144
220
|
target: { readonly surfaceDir: string; readonly feature: string },
|
|
221
|
+
shape: { readonly singleTenant?: boolean } = {},
|
|
145
222
|
): readonly GeneratedFile[] {
|
|
146
223
|
const feature = names(rawName.length > 0 ? rawName : target.feature);
|
|
147
224
|
const dir = `${target.surfaceDir}/${target.feature}`;
|
|
148
225
|
return [
|
|
149
|
-
{
|
|
150
|
-
|
|
226
|
+
{
|
|
227
|
+
path: `${dir}/policy.ts`,
|
|
228
|
+
contents: shape.singleTenant ? singleTenantPolicySource(feature) : policySource(feature),
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
path: `${dir}/policy.test.ts`,
|
|
232
|
+
contents: shape.singleTenant ? singleTenantPolicyTest(feature) : policyTest(feature),
|
|
233
|
+
},
|
|
151
234
|
];
|
|
152
235
|
}
|
package/src/templates/query.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// `x g query <name> [--live]` — a read. Live queries must be deterministic and bounded, so the
|
|
1
|
+
// `x g query <name> --feature <slice> [--live]` — a read. Live queries must be deterministic and bounded, so the
|
|
2
2
|
// generated declaration always carries `orderBy` + `limit` and the generated test pins them:
|
|
3
3
|
// an unbounded live query is a memory leak that only shows up under load.
|
|
4
4
|
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
5
6
|
import type { FeatureTarget } from './entity';
|
|
6
7
|
import { sortedImports } from './imports';
|
|
7
8
|
import type { GeneratedFile, NameSet } from './naming';
|
|
8
9
|
import { names } from './naming';
|
|
9
10
|
import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
|
|
10
|
-
import { sliceFoundation, sliceTakesScaffoldRow } from './slice-foundation';
|
|
11
|
+
import { foreignSliceEntity, sliceFoundation, sliceTakesScaffoldRow } from './slice-foundation';
|
|
11
12
|
import { wrapImport } from './wrap';
|
|
12
13
|
|
|
13
14
|
/** A live read always fans out fresh, so a TTL on it would only ever be dead configuration. */
|
|
@@ -30,27 +31,38 @@ ${wrapImport(
|
|
|
30
31
|
)}
|
|
31
32
|
import * as repo from '../repo';
|
|
32
33
|
|
|
34
|
+
/** The columns this read sorts by: \`repo.ListSort\`, and what a page's \`listUrl\` offers. */
|
|
35
|
+
export const ${name.camel}Sorts = ['createdAt', 'title'] as const;
|
|
36
|
+
|
|
33
37
|
// No \`orgId\` in the input, and none in the policy: \`repo.list\` reads through the typed handle,
|
|
34
38
|
// which scopes every read to the ACTOR's org, and a live window is keyed by the subscriber's
|
|
35
39
|
// tenant. One source of tenancy — a parameter would be a second one a caller could set.
|
|
36
40
|
export const ${name.camel} = query({
|
|
37
|
-
input: t.object({
|
|
41
|
+
input: t.object({
|
|
42
|
+
limit: t.number.default(50),
|
|
43
|
+
// A closed set, so a sort is a column this read has an order for, never a caller's string.
|
|
44
|
+
sort: t.enum(${name.camel}Sorts).default('createdAt'),
|
|
45
|
+
dir: t.enum(['asc', 'desc']).default('desc'),
|
|
46
|
+
}),
|
|
38
47
|
policy: can${feature.pascal}Read,
|
|
39
48
|
live: ${String(live)},${cacheLine(feature, live)}
|
|
49
|
+
// The refusals a caller may branch on, closed: add each code this read comes to throw.
|
|
50
|
+
errors: [],
|
|
40
51
|
// Opt-in, unlike an action's tool: a read hands rows to an agent, so silence exposes nothing.
|
|
41
52
|
mcp: { expose: true, description: '${name.raw} — edit this description' },
|
|
42
|
-
sql: ({ limit }) =>
|
|
53
|
+
sql: ({ limit, sort, dir }) =>
|
|
43
54
|
// \`feature.table\`, not the kebab plural: \`from()\` quotes the identifier into the SQL text,
|
|
44
55
|
// and the entity created the table as snake_case.
|
|
45
|
-
from<${feature.pascal}>('${feature.table}', () => repo.list(limit))
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
.orderBy(
|
|
50
|
-
// The primary key last
|
|
51
|
-
// rows that tie can swap between evaluations — a
|
|
52
|
-
// the other, and a live subscription patches a row
|
|
53
|
-
.
|
|
56
|
+
from<${feature.pascal}>('${feature.table}', () => repo.list(limit, sort, dir))
|
|
57
|
+
// \`repo.list\`'s own order. These lines are ONE read spelled twice: the thunk is what the
|
|
58
|
+
// in-memory driver answers and this chain is the SQL Postgres runs, so a sort or a direction
|
|
59
|
+
// that differs here is a bounded page holding different rows on each.
|
|
60
|
+
.orderBy(sort, dir)
|
|
61
|
+
// The primary key last, in the same direction the handle adds it, is what makes the order
|
|
62
|
+
// TOTAL: a sort column alone ties, and two rows that tie can swap between evaluations — a
|
|
63
|
+
// bounded read then drops one and repeats the other, and a live subscription patches a row
|
|
64
|
+
// it never sent.
|
|
65
|
+
.orderBy('id', dir)
|
|
54
66
|
.limit(limit),
|
|
55
67
|
});
|
|
56
68
|
`;
|
|
@@ -121,6 +133,23 @@ ${wrapper}('${name.camel} is bounded and TOTALLY ordered', async () => {
|
|
|
121
133
|
expect(text.slice(text.lastIndexOf('order by'))).toContain('id');
|
|
122
134
|
});
|
|
123
135
|
|
|
136
|
+
${wrapper}('the read sorts by the column it is asked for, either way', async () => {
|
|
137
|
+
// The same sort the in-memory thunk applies (\`repo.list(limit, sort, dir)\`), spelled as SQL —
|
|
138
|
+
// and the id tiebreak follows the direction, as the handle's does.
|
|
139
|
+
const reason = 'a scaffolded test asserts the SQL text; the policy is asserted separately';
|
|
140
|
+
const sqlFor = async (dir: 'asc' | 'desc') => {
|
|
141
|
+
const input = { limit: 50, sort: 'title' as const, dir };
|
|
142
|
+
const source = await sourceFor(target, input, { actor: null, unenforced: reason });
|
|
143
|
+
return source.toSQL().sql.toLowerCase();
|
|
144
|
+
};
|
|
145
|
+
const asc = await sqlFor('asc');
|
|
146
|
+
expect(asc).toContain('order by "title" asc');
|
|
147
|
+
expect(asc).toContain('"id" asc');
|
|
148
|
+
const desc = await sqlFor('desc');
|
|
149
|
+
expect(desc).toContain('order by "title" desc');
|
|
150
|
+
expect(desc).toContain('"id" desc');
|
|
151
|
+
});
|
|
152
|
+
|
|
124
153
|
${wrapper}('${name.camel} denies an orgless actor before a read', async () => {
|
|
125
154
|
// \`.as()\` is the one read path with the actor swapped: validate, authorize, then read. The
|
|
126
155
|
// denial lands before any SQL executes, which is why this needs no database.
|
|
@@ -249,6 +278,15 @@ unitTest('stored rows read back newest first, bounded, and never another org', a
|
|
|
249
278
|
const theirs = await target.as(outsider, { limit: 50 });
|
|
250
279
|
expect(theirs.map((row) => row.title)).toEqual(['theirs']);
|
|
251
280
|
});
|
|
281
|
+
|
|
282
|
+
unitTest('the read is one statement, however many rows it answers', async ({ statements }) => {
|
|
283
|
+
await store(orgId, 'first');
|
|
284
|
+
await store(orgId, 'second');
|
|
285
|
+
const before = statements.count();
|
|
286
|
+
await target.as(member, { limit: 50 });
|
|
287
|
+
// On this driver as on Postgres: a read added per row in the handler is a third statement here.
|
|
288
|
+
expect(statements.count() - before).toBe(1);
|
|
289
|
+
});
|
|
252
290
|
`;
|
|
253
291
|
|
|
254
292
|
/**
|
|
@@ -325,11 +363,30 @@ export interface QueryOptions extends FeatureTarget {
|
|
|
325
363
|
readonly sliceRepo?: string;
|
|
326
364
|
}
|
|
327
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Declared beside its one thrower, as `backfill.ts`'s twin is: the read imports the slice's own row
|
|
368
|
+
* type and repo, and a slice declaring another table under its name has neither.
|
|
369
|
+
*/
|
|
370
|
+
export class QuerySliceEntityError extends UltimateError {
|
|
371
|
+
constructor(input: { readonly path: string; readonly binding: string; readonly table: string }) {
|
|
372
|
+
super({
|
|
373
|
+
code: 'X_QUERY_SLICE_ENTITY',
|
|
374
|
+
cause: `x g query reads the slice's own scaffolded table, and its entity.ts declares ${input.binding} (${input.table}) instead — the generated read would import a row type and a repo that do not exist`,
|
|
375
|
+
fix: `x queries list --json # then declare query({ sql: … from('${input.table}', …) }) by hand in ${input.path}, over ../entity's ${input.binding}`,
|
|
376
|
+
meta: { binding: input.binding, table: input.table },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
328
381
|
export function queryFiles(rawName: string, target: QueryOptions): readonly GeneratedFile[] {
|
|
329
382
|
const name = names(rawName);
|
|
330
383
|
const feature = names(target.feature);
|
|
331
384
|
const live = target.live === true;
|
|
332
385
|
const dir = `${target.surfaceDir}/${target.feature}/${live ? 'live' : 'queries'}`;
|
|
386
|
+
const foreign = foreignSliceEntity(target.feature, target.sliceEntity);
|
|
387
|
+
if (foreign !== undefined) {
|
|
388
|
+
throw new QuerySliceEntityError({ path: `${dir}/${name.kebab}.ts`, ...foreign });
|
|
389
|
+
}
|
|
333
390
|
const shape: ReadShape = {
|
|
334
391
|
live,
|
|
335
392
|
storesRow: sliceTakesScaffoldRow(target, target.sliceEntity, target.sliceRepo),
|
|
@@ -137,7 +137,9 @@ const writer = testActor('writer', {
|
|
|
137
137
|
}).actor;
|
|
138
138
|
|
|
139
139
|
contractTest('create${feature.pascal} passes the action contract', async () => {
|
|
140
|
-
|
|
140
|
+
// The fourth assertion runs the handler as the writer on 16 generated legal inputs, each
|
|
141
|
+
// inside a transaction that rolls back, so nothing it creates outlives the test.
|
|
142
|
+
for (const contract of target.contract({ exercise: { actor: writer } })) await contract.run();
|
|
141
143
|
});
|
|
142
144
|
|
|
143
145
|
contractTest('create${feature.pascal} needs an actor with an org', async () => {
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
createContext,
|
|
47
47
|
createEffect,
|
|
48
48
|
createMemo,
|
|
49
|
+
createRoot,
|
|
49
50
|
createSignal,
|
|
50
51
|
onCleanup,
|
|
51
52
|
useContext,
|
|
@@ -146,17 +147,27 @@ function ${feature.pascal}FormBody(props: ${feature.pascal}FormProps): JSX.Eleme
|
|
|
146
147
|
* registers. Delete the line and the first \`<UiProvider>\` render throws X_UI_RUNTIME_MISSING —
|
|
147
148
|
* loud on purpose, because a DOM render that lost its runtime is a theme toggle that does nothing.
|
|
148
149
|
*
|
|
149
|
-
*
|
|
150
|
+
* Seven NAMED imports, never \`import * as solidRuntime\`: a namespace object handed to a function
|
|
150
151
|
* keeps every export of solid-js alive, and the bundler cannot shake what it cannot see unused —
|
|
151
152
|
* measured at 14.8 kB minified per island chunk (5.6 kB gzipped) for the namespace form.
|
|
152
153
|
*
|
|
153
154
|
* The shell is cleared first: Solid's \`render\` APPENDS when the container already has children,
|
|
154
155
|
* so without it the server's markup stays on screen above a second, live copy of the same thing.
|
|
155
156
|
*/
|
|
156
|
-
export function mount(el: HTMLElement, props: ${feature.pascal}FormProps): void {
|
|
157
|
-
setSolidRuntime({
|
|
157
|
+
export function mount(el: HTMLElement, props: ${feature.pascal}FormProps): () => void {
|
|
158
|
+
setSolidRuntime({
|
|
159
|
+
createContext,
|
|
160
|
+
useContext,
|
|
161
|
+
createSignal,
|
|
162
|
+
createMemo,
|
|
163
|
+
createEffect,
|
|
164
|
+
onCleanup,
|
|
165
|
+
createRoot,
|
|
166
|
+
});
|
|
158
167
|
el.textContent = '';
|
|
159
|
-
|
|
168
|
+
// Solid's disposer, returned: the client router calls it when it navigates away, and a mount
|
|
169
|
+
// that returns nothing is X_ISLAND_DISPOSER_MISSING.
|
|
170
|
+
return render(
|
|
160
171
|
() => (
|
|
161
172
|
<UiProvider locale={props.locale}>
|
|
162
173
|
<${feature.pascal}FormBody {...props} />
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `x g resource`'s list page, read by Postgres: the generated test that renders the same sorted,
|
|
2
|
+
// paged list as `page.test.ts` with the app's migrated database installed under the handle.
|
|
3
|
+
|
|
4
|
+
import { sortedImports } from './imports';
|
|
5
|
+
import type { ResourcePageOptions } from './resource-page';
|
|
6
|
+
|
|
7
|
+
/** The org the rows belong to — the same one `page.test.ts` writes under. */
|
|
8
|
+
const ORG = '00000000-0000-4000-8000-000000000002';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The same sort and the same pages, read by Postgres: the app's migrated database installed under
|
|
12
|
+
* the handle, so the query's SQL — `order by`, the keyset seek — answers instead of the memory
|
|
13
|
+
* thunk. The database is embedded Postgres (PGlite) booted from the migrated template, so the
|
|
14
|
+
* proof needs no server and runs on the `unit` step.
|
|
15
|
+
*/
|
|
16
|
+
export const pageDbTest = (options: ResourcePageOptions): string => {
|
|
17
|
+
const { feature } = options;
|
|
18
|
+
const path = feature.pluralKebab;
|
|
19
|
+
return `// /${path} against the app's migrated database: the read's SQL path, where page.test.ts is the
|
|
20
|
+
// in-memory one. The same rows, sorted and paged by link, come back in the same order.
|
|
21
|
+
${sortedImports([
|
|
22
|
+
"import { migratedDatabase } from '@ultimat3/cli';",
|
|
23
|
+
"import { ctxOf, frozenClock, runWithContext } from '@ultimat3/core';",
|
|
24
|
+
"import { setDbClient } from '@ultimat3/db';",
|
|
25
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
26
|
+
"import {\n afterAll,\n beforeAll,\n expect,\n renderRoute,\n reusableDatabase,\n unitTest,\n} from '@ultimat3/testing';",
|
|
27
|
+
])}
|
|
28
|
+
// The app's API, as boot loads it: \`defineApi\` names the query the page reads.
|
|
29
|
+
import '../../api';
|
|
30
|
+
import * as repo from '../${feature.kebab}/repo';
|
|
31
|
+
import * as page from './page';
|
|
32
|
+
|
|
33
|
+
// A template restore is ~0.5 s; its first build (initdb plus every migration) is what this bounds.
|
|
34
|
+
const BOOT_MS = 120_000;
|
|
35
|
+
|
|
36
|
+
const workerDatabase = reusableDatabase(() => migratedDatabase({ root: import.meta.dir }));
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
setDbClient(await workerDatabase());
|
|
39
|
+
}, BOOT_MS);
|
|
40
|
+
// Back to memory, so this file's database never reaches the next file's unit tests.
|
|
41
|
+
afterAll(() => setDbClient(undefined));
|
|
42
|
+
|
|
43
|
+
const url = 'https://example.test/${path}';
|
|
44
|
+
const orgId = '${ORG}';
|
|
45
|
+
// The read grant, named once so the line below fits the formatter's width at any feature name.
|
|
46
|
+
const read = '${feature.kebab}:read';
|
|
47
|
+
const member = testActor('member', { orgId, permissions: [read] }).actor;
|
|
48
|
+
const clock = frozenClock('2026-01-01T00:00:00.000Z');
|
|
49
|
+
|
|
50
|
+
const store = (title: string) => {
|
|
51
|
+
const draft = { orgId, title, price: { minor: 1200, currency: 'USD' } };
|
|
52
|
+
return runWithContext(ctxOf({ actor: member, clock }), () => repo.insert(draft));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The anchor a pager side renders, read off the HTML: \`rel\` names the side. */
|
|
56
|
+
const linkTo = (html: string, rel: string): string | undefined => {
|
|
57
|
+
const anchor = new RegExp(\`<a\\\\b[^>]*\\\\brel="\${rel}"[^>]*>\`).exec(html)?.[0];
|
|
58
|
+
return /href="([^"]*)"/.exec(anchor ?? '')?.[1]?.replaceAll('&', '&');
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
unitTest('Postgres sorts and pages the list as the memory read does', async () => {
|
|
62
|
+
for (const title of ['beta', 'gamma', 'alpha']) {
|
|
63
|
+
await store(title);
|
|
64
|
+
clock.advance(1000);
|
|
65
|
+
}
|
|
66
|
+
// Unsorted is the query's own order, newest first.
|
|
67
|
+
const newest = await renderRoute(page, { url, actor: member });
|
|
68
|
+
expect(newest.text.indexOf('alpha')).toBeLessThan(newest.text.indexOf('gamma'));
|
|
69
|
+
expect(newest.text.indexOf('gamma')).toBeLessThan(newest.text.indexOf('beta'));
|
|
70
|
+
|
|
71
|
+
// By title, two to a page: the first two titles, then the third through the cursor.
|
|
72
|
+
const sorted = \`\${url}?sort=title&dir=asc&limit=2\`;
|
|
73
|
+
const first = await renderRoute(page, { url: sorted, actor: member });
|
|
74
|
+
expect(first.text.indexOf('alpha')).toBeLessThan(first.text.indexOf('beta'));
|
|
75
|
+
expect(first.text).not.toContain('gamma');
|
|
76
|
+
const next = linkTo(first.html, 'next');
|
|
77
|
+
const second = await renderRoute(page, { url: new URL(next ?? '', url).href, actor: member });
|
|
78
|
+
expect(second.text).toContain('gamma');
|
|
79
|
+
expect(second.text).not.toContain('alpha');
|
|
80
|
+
expect(linkTo(second.html, 'next')).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
`;
|
|
83
|
+
};
|