@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/templates/action.ts
CHANGED
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
// job handle, an MCP tool and these tests; the generator writes the declaration and the tests.
|
|
4
4
|
|
|
5
5
|
import { SlicePolicyIncompleteError } from '../generate-slice-policy';
|
|
6
|
+
import { actionContractTest, actionUnitTest } from './action-tests';
|
|
6
7
|
import type { FeatureTarget } from './entity';
|
|
7
|
-
import { sortedImports } from './imports';
|
|
8
8
|
import type { GeneratedFile, NameSet } from './naming';
|
|
9
9
|
import { names } from './naming';
|
|
10
10
|
import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
|
|
11
|
+
import type { DeclaredEntity, SliceModule } from './slice-foundation';
|
|
11
12
|
import {
|
|
12
13
|
exportsUnknown,
|
|
14
|
+
foreignSliceEntity,
|
|
13
15
|
grantOf,
|
|
16
|
+
policyReadsOrg,
|
|
14
17
|
sliceExports,
|
|
15
18
|
sliceFoundation,
|
|
19
|
+
sliceHasNoTenant,
|
|
16
20
|
sliceTakesScaffoldRow,
|
|
17
21
|
} from './slice-foundation';
|
|
18
22
|
import { wrapImport } from './wrap';
|
|
@@ -21,22 +25,43 @@ import { wrapImport } from './wrap';
|
|
|
21
25
|
* The handler's lookup-by-id, or the comment that says what a slice needs before it can have one.
|
|
22
26
|
* `x g resource` writes a slice whose `errors.ts` declares `<Feature>NotFoundError`, and so does
|
|
23
27
|
* the foundation this generator lays under an empty directory; a slice an author wrote by hand
|
|
24
|
-
* (
|
|
28
|
+
* (one adopter's `fleet`, with `HostNotFoundError` and `SessionNotFoundError`) declares the errors
|
|
25
29
|
* it has and not this one. Importing it anyway is a file that fails at import.
|
|
26
30
|
*/
|
|
27
|
-
const missingLookup = (
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
const missingLookup = (
|
|
32
|
+
feature: NameSet,
|
|
33
|
+
entity: boolean,
|
|
34
|
+
foreign: DeclaredEntity | undefined,
|
|
35
|
+
): string =>
|
|
36
|
+
foreign !== undefined
|
|
37
|
+
? ` // No lookup by id: ../entity declares ${foreign.binding} (${foreign.table}), not the
|
|
38
|
+
// table this generator scaffolds, so it writes no repo around it. Read the row your own way here.`
|
|
39
|
+
: entity
|
|
40
|
+
? ` // No lookup by id: ../errors declares no ${feature.pascal}NotFoundError, and a row that is not
|
|
30
41
|
// there needs one to be thrown for it. Declare it there — the shape \`x g resource\` writes —
|
|
31
42
|
// then read the row through ../repo and throw it when the read answers nothing.`
|
|
32
|
-
|
|
43
|
+
: ` // No lookup by id: this feature has no entity, and a generator writing into a slice never
|
|
33
44
|
// invents one. \`x g entity ${feature.kebab}\` declares the table; this body is yours until then.`;
|
|
34
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The refusals the handler may throw, closed from day one: a 4xx it throws outside the list is
|
|
48
|
+
* `X_ERROR_UNDECLARED` in every test. The lookup's class answers its own code, so an
|
|
49
|
+
* `errors.ts` the author wrote keeps whatever code it declares.
|
|
50
|
+
*/
|
|
51
|
+
const errorsLine = (feature: NameSet, lookup: boolean): string =>
|
|
52
|
+
lookup
|
|
53
|
+
? ` // The refusals a caller may branch on: add each code the handler comes to throw.
|
|
54
|
+
errors: [${feature.pascal}NotFoundError.code],`
|
|
55
|
+
: ` // The refusals a caller may branch on: add each code the handler comes to throw.
|
|
56
|
+
errors: [],`;
|
|
57
|
+
|
|
35
58
|
const actionSource = (
|
|
36
59
|
name: NameSet,
|
|
37
60
|
feature: NameSet,
|
|
38
61
|
lookup: boolean,
|
|
39
62
|
entity: boolean,
|
|
63
|
+
org: boolean,
|
|
64
|
+
foreign: DeclaredEntity | undefined,
|
|
40
65
|
): string => `// ${name.camel}: one mutation, server-authoritative. Input is validated before the handler runs
|
|
41
66
|
// and the policy is the same object the MCP tool and the HTTP route evaluate.
|
|
42
67
|
// \`t\` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package.
|
|
@@ -47,12 +72,17 @@ import { action, t } from '@ultimat3/action';
|
|
|
47
72
|
${lookup ? `\nimport { ${feature.pascal}NotFoundError } from '../errors';\n` : ''}${wrapImport([`can${feature.pascal}Write`, `${feature.camel}Tag`], '../policy')}
|
|
48
73
|
${lookup ? "import * as repo from '../repo';\n" : ''}
|
|
49
74
|
export const ${name.camel} = action({
|
|
50
|
-
|
|
75
|
+
${
|
|
76
|
+
org
|
|
77
|
+
? ` // orgId is part of the input because the policy decides on it — authz reads the declaration,
|
|
51
78
|
// never the database.
|
|
52
|
-
input: t.object({ id: t.uuid, orgId: t.uuid })
|
|
79
|
+
input: t.object({ id: t.uuid, orgId: t.uuid }),`
|
|
80
|
+
: ' input: t.object({ id: t.uuid }),'
|
|
81
|
+
}
|
|
53
82
|
output: t.object({ id: t.uuid${lookup ? ', title: t.string' : ''} }),
|
|
54
83
|
policy: can${feature.pascal}Write,
|
|
55
84
|
cache: { invalidates: [${feature.camel}Tag] },
|
|
85
|
+
${errorsLine(feature, lookup)}
|
|
56
86
|
// No \`mcp\`: a tool's description is what an agent reads to decide to call it, and a placeholder
|
|
57
87
|
// there is worse than no tool. Write one, then \`mcp: { expose: true, description: '…' }\`.
|
|
58
88
|
async handle({ input }) {
|
|
@@ -61,7 +91,7 @@ ${
|
|
|
61
91
|
? ` const row = await repo.byId(input.id);
|
|
62
92
|
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
|
|
63
93
|
return { id: row.id, title: row.title };`
|
|
64
|
-
: `${missingLookup(feature, entity)}
|
|
94
|
+
: `${missingLookup(feature, entity, foreign)}
|
|
65
95
|
return { id: input.id };`
|
|
66
96
|
}
|
|
67
97
|
},
|
|
@@ -73,6 +103,8 @@ const mutatorSource = (
|
|
|
73
103
|
feature: NameSet,
|
|
74
104
|
lookup: boolean,
|
|
75
105
|
entity: boolean,
|
|
106
|
+
org: boolean,
|
|
107
|
+
foreign: DeclaredEntity | undefined,
|
|
76
108
|
): string => `// ${name.camel}: an action with an optimistic local twin. The local half runs against the client
|
|
77
109
|
// store immediately; the server half is authoritative and reconciles on conflict.
|
|
78
110
|
|
|
@@ -86,18 +118,19 @@ interface Local${feature.pascal} {
|
|
|
86
118
|
}
|
|
87
119
|
|
|
88
120
|
export const ${name.camel} = mutator({
|
|
89
|
-
input: t.object({ id: t.uuid
|
|
121
|
+
input: t.object({ id: t.uuid,${org ? ' orgId: t.uuid,' : ''} title: t.string }),
|
|
90
122
|
output: t.object({ id: t.uuid, title: t.string }),
|
|
91
123
|
policy: can${feature.pascal}Write,
|
|
92
124
|
// Required on a mutator (X_MUTATOR_NOT_IDEMPOTENT): the client replays this write under one
|
|
93
125
|
// Idempotency-Key after a dropped response, and the replay answers the first result.
|
|
94
126
|
idempotent: true,
|
|
127
|
+
${errorsLine(feature, lookup)}
|
|
95
128
|
// No \`mcp\` until a real description is written: see \`x g action\`.
|
|
96
129
|
// tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app
|
|
97
130
|
// augments LocalTables, and generated code cannot assume that has happened yet. The name is the
|
|
98
131
|
// entity's snake_case table, so the local twin and the server row live under one key.
|
|
99
132
|
local(tx, input) {
|
|
100
|
-
tx.table<Local${feature.pascal}>('${feature.table}').update(input.id, {
|
|
133
|
+
tx.table<Local${feature.pascal}>('${foreign?.table ?? feature.table}').update(input.id, {
|
|
101
134
|
title: input.title,
|
|
102
135
|
pending: true,
|
|
103
136
|
});
|
|
@@ -108,7 +141,7 @@ ${
|
|
|
108
141
|
? ` const row = await repo.byId(input.id);
|
|
109
142
|
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
|
|
110
143
|
return { id: row.id, title: input.title };`
|
|
111
|
-
: `${missingLookup(feature, entity)}
|
|
144
|
+
: `${missingLookup(feature, entity, foreign)}
|
|
112
145
|
return { id: input.id, title: input.title };`
|
|
113
146
|
}
|
|
114
147
|
},
|
|
@@ -116,251 +149,6 @@ ${
|
|
|
116
149
|
});
|
|
117
150
|
`;
|
|
118
151
|
|
|
119
|
-
/** The code the slice's `errors.ts` declares for a missing row. */
|
|
120
|
-
const notFoundCode = (feature: NameSet): string =>
|
|
121
|
-
`X_${feature.kebab.toUpperCase().split('-').join('_')}_NOT_FOUND`;
|
|
122
|
-
|
|
123
|
-
const ID = '00000000-0000-4000-8000-000000000001';
|
|
124
|
-
const ORG = '00000000-0000-4000-8000-000000000002';
|
|
125
|
-
const OTHER_ORG = '00000000-0000-4000-8000-000000000009';
|
|
126
|
-
|
|
127
|
-
/** The declaration-shape assertions that differ between the two primitives. */
|
|
128
|
-
const shapeTest = (name: NameSet, isMutator: boolean): string =>
|
|
129
|
-
isMutator
|
|
130
|
-
? `unitTest('${name.camel} projects both halves and a conflict strategy', () => {
|
|
131
|
-
// The projected names mirror the declaration: local() optimistic, server() authoritative.
|
|
132
|
-
// server() routes through the same invoke() core as every other surface, so it cannot skip
|
|
133
|
-
// the input parse, the policy or the output parse.
|
|
134
|
-
expect(target.describeMutator().kind).toBe('mutator');
|
|
135
|
-
expect(target.conflict).toBe('server-wins');
|
|
136
|
-
expect(typeof target.local).toBe('function');
|
|
137
|
-
expect(typeof target.server).toBe('function');
|
|
138
|
-
});`
|
|
139
|
-
: `unitTest('${name.camel} is a declared action', () => {
|
|
140
|
-
expect(target.kind).toBe('action');
|
|
141
|
-
expect(target.describe().name).toBe('${name.camel}');
|
|
142
|
-
});`;
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* The preamble both generated tests share. `outsider` rides along only where it is USED: the
|
|
146
|
-
* scaffolded app lints with `noUnusedVariables: error`, so an actor declared in the unit half
|
|
147
|
-
* would be a lint failure in code nobody typed.
|
|
148
|
-
*/
|
|
149
|
-
const preamble = (
|
|
150
|
-
name: NameSet,
|
|
151
|
-
feature: NameSet,
|
|
152
|
-
isMutator: boolean,
|
|
153
|
-
wrappers: string,
|
|
154
|
-
outsider: boolean,
|
|
155
|
-
): string => `${outsider ? "import { testActor } from '@ultimat3/policy';\n" : ''}import { ${wrappers} } from '@ultimat3/testing';
|
|
156
|
-
import { ${name.camel} } from './${name.kebab}';
|
|
157
|
-
|
|
158
|
-
const id = '${ID}';
|
|
159
|
-
const orgId = '${ORG}';
|
|
160
|
-
const input = { id, orgId${isMutator ? ", title: 'a title'" : ''} };
|
|
161
|
-
|
|
162
|
-
// Named here because every projection needs a stable name and this file does not boot the app.
|
|
163
|
-
// At boot \`registerActions(await import('./actions'))\` stamps the same name onto the same
|
|
164
|
-
// object, so each projection of it works there with nothing to remember.
|
|
165
|
-
const target = ${name.camel}.named('${name.camel}');
|
|
166
|
-
${
|
|
167
|
-
outsider
|
|
168
|
-
? `
|
|
169
|
-
// Holds the grant, wrong org. That is the interesting actor: a denial here is the predicate
|
|
170
|
-
// deciding, not the permission check, so this test fails if the tenancy rule is ever dropped.
|
|
171
|
-
const outsider = testActor('outsider', {
|
|
172
|
-
orgId: '${OTHER_ORG}',
|
|
173
|
-
permissions: ['${feature.kebab}:write'],
|
|
174
|
-
}).actor;
|
|
175
|
-
`
|
|
176
|
-
: ''
|
|
177
|
-
}`;
|
|
178
|
-
|
|
179
|
-
/** What the unit test may do with the slice the declaration lives in. */
|
|
180
|
-
interface HandlerShape {
|
|
181
|
-
readonly isMutator: boolean;
|
|
182
|
-
/** The handler reads a row by id and throws `<Feature>NotFoundError` when there is none. */
|
|
183
|
-
readonly lookup: boolean;
|
|
184
|
-
/** The slice still takes the row `x g entity` scaffolds, so the test may store one. */
|
|
185
|
-
readonly storesRow: boolean;
|
|
186
|
-
readonly dbModule: string;
|
|
187
|
-
/**
|
|
188
|
-
* The permission the writer is granted — `<feature>:write` for the policy this generator wrote,
|
|
189
|
-
* the one literal `can('<perm>')` of a handwritten one, or `undefined` when that policy's grant
|
|
190
|
-
* cannot be read, and then no assertion depends on it.
|
|
191
|
-
*/
|
|
192
|
-
readonly grant: string | undefined;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* The optimistic half, run against the client store `@ultimat3/testing` ships. A twin is replayed
|
|
197
|
-
* on every rebase, so the two facts worth failing on are the row one application leaves and that a
|
|
198
|
-
* second leaves the same one — and that a row the store never held is not invented.
|
|
199
|
-
*/
|
|
200
|
-
const localTwinTests = (): string => `
|
|
201
|
-
unitTest('the local twin marks the row pending, and a replay lands in the same place', () => {
|
|
202
|
-
const store = memoryLocalTx({ [TABLE]: { [id]: { id, title: 'before', pending: false } } });
|
|
203
|
-
target.local(store.tx, input);
|
|
204
|
-
const once = store.rows(TABLE);
|
|
205
|
-
expect(once).toEqual({ [id]: { id, title: 'a title', pending: true } });
|
|
206
|
-
// A rebase replays the queued write: twice through must equal once through.
|
|
207
|
-
target.local(store.tx, input);
|
|
208
|
-
expect(store.rows(TABLE)).toEqual(once);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
unitTest('the local twin invents no row the store never held', () => {
|
|
212
|
-
const store = memoryLocalTx();
|
|
213
|
-
target.local(store.tx, input);
|
|
214
|
-
expect(store.rows(TABLE)).toEqual({});
|
|
215
|
-
});
|
|
216
|
-
`;
|
|
217
|
-
|
|
218
|
-
/** The authoritative half's cases, refusal first, in the shape the slice earns. */
|
|
219
|
-
const handlerTests = (feature: NameSet, shape: HandlerShape): string => {
|
|
220
|
-
if (shape.grant === undefined) {
|
|
221
|
-
return `
|
|
222
|
-
// No handler assertion: ../policy.ts is hand-written and its grant is not one literal can('…'),
|
|
223
|
-
// so which actor reaches the handler is the author's to state — add that test here.
|
|
224
|
-
`;
|
|
225
|
-
}
|
|
226
|
-
const answer = shape.isMutator ? "{ id, title: 'a title' }" : '{ id }';
|
|
227
|
-
if (!shape.lookup) {
|
|
228
|
-
return `
|
|
229
|
-
unitTest('an actor without the grant is refused before the handler runs', async () => {
|
|
230
|
-
const stranger = testActor('stranger', { orgId }).actor;
|
|
231
|
-
const refused = await target.as(stranger, input).catch((error: unknown) => error);
|
|
232
|
-
expect(refused).toBeUltimateError('X_FORBIDDEN');
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
unitTest('the handler answers a writer in the org', async () => {
|
|
236
|
-
expect(await target.as(writer, input)).toEqual(${answer});
|
|
237
|
-
});
|
|
238
|
-
`;
|
|
239
|
-
}
|
|
240
|
-
const found = shape.isMutator
|
|
241
|
-
? "{ id: row.id, title: 'a title' }"
|
|
242
|
-
: "{ id: row.id, title: 'kept' }";
|
|
243
|
-
const call = shape.isMutator
|
|
244
|
-
? "{ id: row.id, orgId, title: 'a title' }"
|
|
245
|
-
: '{ id: row.id, orgId }';
|
|
246
|
-
return `
|
|
247
|
-
unitTest('the handler refuses an id nothing holds', async () => {
|
|
248
|
-
const refused = await target.as(writer, input).catch((error: unknown) => error);
|
|
249
|
-
expect(refused).toBeUltimateError('${notFoundCode(feature)}');
|
|
250
|
-
});
|
|
251
|
-
${
|
|
252
|
-
shape.storesRow
|
|
253
|
-
? `
|
|
254
|
-
unitTest('the handler answers the row it found', async () => {
|
|
255
|
-
const draft = { orgId, title: 'kept', price: { minor: 0, currency: 'USD' } };
|
|
256
|
-
const row = await runWithContext(ctxOf({ actor: writer }), () => repo.insert(draft));
|
|
257
|
-
const answer = await target.as(writer, ${call});
|
|
258
|
-
expect(answer).toEqual(${found});
|
|
259
|
-
});
|
|
260
|
-
`
|
|
261
|
-
: ''
|
|
262
|
-
}`;
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* The unit test: the declaration's shape, the input it refuses, and the HANDLER — run through
|
|
267
|
-
* `.as()`, the one execution path, against the in-memory driver. A mutator's local twin runs
|
|
268
|
-
* against `memoryLocalTx`. The contract projections are next door, in their own suite.
|
|
269
|
-
*
|
|
270
|
-
* A row is stored only in a slice whose row this generator can spell (`sliceTakesScaffoldRow`): a
|
|
271
|
-
* slice an author reshaped has its own columns, and a test that inserted `{ title, price }` into
|
|
272
|
-
* it would be a guess about someone else's table.
|
|
273
|
-
*/
|
|
274
|
-
const actionUnitTest = (name: NameSet, feature: NameSet, shape: HandlerShape): string => {
|
|
275
|
-
const known = shape.grant !== undefined;
|
|
276
|
-
const stores = known && shape.lookup && shape.storesRow;
|
|
277
|
-
const testing = [
|
|
278
|
-
...(stores ? ['afterEach'] : []),
|
|
279
|
-
'expect',
|
|
280
|
-
...(shape.isMutator ? ['memoryLocalTx'] : []),
|
|
281
|
-
'unitTest',
|
|
282
|
-
].join(', ');
|
|
283
|
-
return `// ${name.camel}: its declared shape, the input it refuses, and what its handler answers — in
|
|
284
|
-
// process, so all three belong to the \`unit\` step. The contract projections are next door.
|
|
285
|
-
${sortedImports([
|
|
286
|
-
...(stores ? ["import { ctxOf, runWithContext } from '@ultimat3/core';"] : []),
|
|
287
|
-
...(known ? ["import { testActor } from '@ultimat3/policy';"] : []),
|
|
288
|
-
`import { ${testing} } from '@ultimat3/testing';`,
|
|
289
|
-
...(stores ? [`import { driver } from '${shape.dbModule}';`] : []),
|
|
290
|
-
])}${stores ? "\nimport * as repo from '../repo';" : ''}
|
|
291
|
-
import { ${name.camel} } from './${name.kebab}';
|
|
292
|
-
|
|
293
|
-
const id = '${ID}';
|
|
294
|
-
const orgId = '${ORG}';
|
|
295
|
-
const input = { id, orgId${shape.isMutator ? ", title: 'a title'" : ''} };
|
|
296
|
-
${shape.isMutator ? `\n// The client store's table: the entity's own, so the twin and the server row share one key.\nconst TABLE = '${feature.table}';\n` : ''}
|
|
297
|
-
// Named here because every projection needs a stable name and this file does not boot the app.
|
|
298
|
-
// At boot \`registerActions(await import('./actions'))\` stamps the same name onto the same
|
|
299
|
-
// object, so each projection of it works there with nothing to remember.
|
|
300
|
-
const target = ${name.camel}.named('${name.camel}');
|
|
301
|
-
${
|
|
302
|
-
known
|
|
303
|
-
? `
|
|
304
|
-
// Holds the grant in the org the input names: past the policy, so what answers is the handler.
|
|
305
|
-
const grant = ['${shape.grant}'];
|
|
306
|
-
const writer = testActor('writer', { orgId, permissions: grant }).actor;
|
|
307
|
-
`
|
|
308
|
-
: ''
|
|
309
|
-
}${
|
|
310
|
-
stores
|
|
311
|
-
? `
|
|
312
|
-
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
313
|
-
afterEach(() => {
|
|
314
|
-
driver.reset?.();
|
|
315
|
-
});
|
|
316
|
-
`
|
|
317
|
-
: ''
|
|
318
|
-
}
|
|
319
|
-
${shapeTest(name, shape.isMutator)}
|
|
320
|
-
|
|
321
|
-
unitTest('${name.camel} rejects input that is not a uuid', async () => {
|
|
322
|
-
await expect(target.input).toRejectInput({ ...input, id: 'not-a-uuid' });
|
|
323
|
-
await expect(target.input).toAcceptInput(input);
|
|
324
|
-
});
|
|
325
|
-
${shape.isMutator ? localTwinTests() : ''}${handlerTests(feature, shape)}`;
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
const actionContractTest = (
|
|
329
|
-
name: NameSet,
|
|
330
|
-
feature: NameSet,
|
|
331
|
-
isMutator: boolean,
|
|
332
|
-
/** The slice's policy is the one this generator wrote, so its tenancy rule is known. */
|
|
333
|
-
scaffoldPolicy = true,
|
|
334
|
-
): string => `// ${name.camel}: the contract every action owes, and the foreign-org actor it denies before the
|
|
335
|
-
// handler runs. One declaration, every surface.
|
|
336
|
-
${preamble(name, feature, isMutator, 'contractTest, expect', scaffoldPolicy)}
|
|
337
|
-
contractTest('${name.camel} passes the action contract', async () => {
|
|
338
|
-
// Three assertions the framework makes for any action, without knowing what this one does:
|
|
339
|
-
// garbage input is rejected, an anonymous actor is denied, and the operation reaches the
|
|
340
|
-
// OpenAPI document. \`.contract()\` is the projection; this loop just runs it.
|
|
341
|
-
for (const contract of target.contract()) await contract.run();
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
${
|
|
345
|
-
scaffoldPolicy
|
|
346
|
-
? `contractTest('${name.camel} denies a foreign org', async () => {
|
|
347
|
-
// \`.as()\` is the one execution path with the actor swapped, so this denial is the same one
|
|
348
|
-
// HTTP, MCP and the job surface would produce — and no repo call happened to produce it.
|
|
349
|
-
const denied = await target.as(outsider, input).catch((error: unknown) => error);
|
|
350
|
-
expect(denied).toBeUltimateError('X_FORBIDDEN');
|
|
351
|
-
});
|
|
352
|
-
`
|
|
353
|
-
: `// No foreign-org assertion: ../policy.ts is hand-written, and whether it checks the org is the
|
|
354
|
-
// author's rule to state — add that test here.
|
|
355
|
-
`
|
|
356
|
-
}
|
|
357
|
-
// No MCP tool until it is described: a placeholder description misleads the agent reading it.
|
|
358
|
-
contractTest('${name.camel} projects one operation and no tool', () => {
|
|
359
|
-
expect(target.openapi().operationId).toBe('${name.camel}');
|
|
360
|
-
expect(target.mcp?.expose ?? false).toBe(false);
|
|
361
|
-
});
|
|
362
|
-
`;
|
|
363
|
-
|
|
364
152
|
export interface ActionOptions extends FeatureTarget {
|
|
365
153
|
readonly mutator?: boolean;
|
|
366
154
|
/**
|
|
@@ -392,14 +180,36 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
|
|
|
392
180
|
const dir = `${target.surfaceDir}/${target.feature}/actions`;
|
|
393
181
|
const isMutator = target.mutator === true;
|
|
394
182
|
const entity = target.sliceEntity !== undefined;
|
|
183
|
+
// Another table under this slice's name: nothing is planted around it, and no row is read.
|
|
184
|
+
const foreign = foreignSliceEntity(target.feature, target.sliceEntity);
|
|
185
|
+
// `job`'s reason: which shape fits is a fact about THIS slice's `entity.ts`, and the comment in
|
|
186
|
+
// the one `x g entity` writes says deleting its tenant is a per-slice edit the generators read.
|
|
187
|
+
// A policy still deciding on `input.orgId` keeps it in the input, because the action compiles
|
|
188
|
+
// against the policy it imports and that file is its author's to change, never this generator's.
|
|
189
|
+
const singleTenant = sliceHasNoTenant(target.sliceEntity);
|
|
190
|
+
// The policy this run writes grants `<feature>:write`; a handwritten one, what it says.
|
|
191
|
+
const grant =
|
|
192
|
+
target.slicePolicy === undefined
|
|
193
|
+
? `${feature.kebab}:write`
|
|
194
|
+
: grantOf(target.slicePolicy, `can${feature.pascal}Write`);
|
|
195
|
+
const org =
|
|
196
|
+
!singleTenant || (target.slicePolicy !== undefined && policyReadsOrg(target.slicePolicy));
|
|
395
197
|
// Before a file is planned: a generated action that does not compile is refused, never written.
|
|
396
198
|
assertPolicyExports(target, feature, isMutator);
|
|
397
199
|
const lookup =
|
|
398
200
|
entity &&
|
|
201
|
+
foreign === undefined &&
|
|
399
202
|
(target.sliceErrors === undefined ||
|
|
400
203
|
sliceExports(target.sliceErrors, `${feature.pascal}NotFoundError`)) &&
|
|
401
204
|
// The lookup calls `repo.byId`: a `repo.ts` an author wrote without one gets the neutral body.
|
|
402
205
|
(target.sliceRepo === undefined || sliceExports(target.sliceRepo, 'byId'));
|
|
206
|
+
// `../errors` only when the body throws from it: with no lookup it is a module nothing imports,
|
|
207
|
+
// and an app's coverage floor counts it (`no unit test loads it`, a red first gate after this).
|
|
208
|
+
const foundation: readonly SliceModule[] = [
|
|
209
|
+
...(entity && foreign === undefined ? (['entity'] as const) : []),
|
|
210
|
+
'policy',
|
|
211
|
+
...(lookup ? (['errors'] as const) : []),
|
|
212
|
+
];
|
|
403
213
|
return [
|
|
404
214
|
// The three slice modules this action's source imports — `../errors`, `../policy`, `../repo`
|
|
405
215
|
// (which comes with `../entity`, its row type). Composed rather than assumed: `x g action`
|
|
@@ -408,15 +218,12 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
|
|
|
408
218
|
// And ONLY those: an action is not a resource scaffold. A module already on disk is its
|
|
409
219
|
// author's — it gets no test from here and no admin labels, both of which assert the shape
|
|
410
220
|
// `x g resource` writes, not the one that is there (`withoutOwned`).
|
|
411
|
-
...withoutOwned(
|
|
412
|
-
sliceFoundation(target, entity ? ['entity', 'policy', 'errors'] : ['policy']),
|
|
413
|
-
target,
|
|
414
|
-
),
|
|
221
|
+
...withoutOwned(sliceFoundation(target, foundation, undefined, { singleTenant }), target),
|
|
415
222
|
{
|
|
416
223
|
path: `${dir}/${name.kebab}.ts`,
|
|
417
224
|
contents: isMutator
|
|
418
|
-
? mutatorSource(name, feature, lookup, entity)
|
|
419
|
-
: actionSource(name, feature, lookup, entity),
|
|
225
|
+
? mutatorSource(name, feature, lookup, entity, org, foreign)
|
|
226
|
+
: actionSource(name, feature, lookup, entity, org, foreign),
|
|
420
227
|
},
|
|
421
228
|
// TWO test files, because the gate types a test by its FILENAME and this declaration owes two
|
|
422
229
|
// suites: the input parse is a `unit` assertion and the three projections are `contract` ones.
|
|
@@ -429,16 +236,21 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
|
|
|
429
236
|
lookup,
|
|
430
237
|
storesRow: sliceTakesScaffoldRow(target, target.sliceEntity, target.sliceRepo),
|
|
431
238
|
dbModule: target.dbModule ?? PLACEHOLDER_DB_MODULE,
|
|
432
|
-
|
|
433
|
-
grant
|
|
434
|
-
target.slicePolicy === undefined
|
|
435
|
-
? `${feature.kebab}:write`
|
|
436
|
-
: grantOf(target.slicePolicy, `can${feature.pascal}Write`),
|
|
239
|
+
org,
|
|
240
|
+
grant,
|
|
437
241
|
}),
|
|
438
242
|
},
|
|
439
243
|
{
|
|
440
244
|
path: `${dir}/${name.kebab}.contract.test.ts`,
|
|
441
|
-
contents: actionContractTest(
|
|
245
|
+
contents: actionContractTest(
|
|
246
|
+
name,
|
|
247
|
+
feature,
|
|
248
|
+
isMutator,
|
|
249
|
+
target.slicePolicy === undefined,
|
|
250
|
+
target.slicePolicy === undefined && !singleTenant,
|
|
251
|
+
// A hand-written policy's one literal grant: its exercise runs as an actor holding it.
|
|
252
|
+
grant,
|
|
253
|
+
),
|
|
442
254
|
},
|
|
443
255
|
];
|
|
444
256
|
}
|
|
@@ -242,7 +242,7 @@ const declaredTables = (source: string): readonly string[] =>
|
|
|
242
242
|
/**
|
|
243
243
|
* Declared beside its one thrower: `errors.ts` sits at its ceiling. The sweep this generator writes
|
|
244
244
|
* reads the slice's OWN scaffolded table (`db.<plural>`, its `title` and `price`), so a slice whose
|
|
245
|
-
* entities are named otherwise —
|
|
245
|
+
* entities are named otherwise — one adopter's `deadline`, holding `holidays` and
|
|
246
246
|
* `court_closures` — got `import type { Deadline }`, `db.deadlines` and admin labels for a table
|
|
247
247
|
* that does not exist. Refused before a file is planned; a hand sweep starts from a job.
|
|
248
248
|
*/
|
package/src/templates/entity.ts
CHANGED
|
@@ -45,7 +45,8 @@ export const ${name.camel} = entity('${table}', {
|
|
|
45
45
|
// \`orgId: uuid()\` column below and drop \`'orgId'\` from \`indexes\`. ${stems.repo}.ts needs no edit —
|
|
46
46
|
// it names no org. ${stems.entity}.test.ts then expects \`$tenantColumn\` null and \`orgScoped\` false,
|
|
47
47
|
// its \`row()\` fixture and ${stems.repo}.test.ts's \`draft()\` lose \`orgId\`, and ${stems.repo}.test.ts loses its
|
|
48
|
-
// last test.
|
|
48
|
+
// last test. policy.ts's \`can${name.pascal}Write\` stops reading \`input.orgId\` (\`can('…:write')\`
|
|
49
|
+
// alone is the rule), and from then on \`x g action\` and \`x g mutator\` input no \`orgId\`.
|
|
49
50
|
tenant: 'orgId',
|
|
50
51
|
columns: {
|
|
51
52
|
id: uuid().primaryKey(),
|
|
@@ -93,7 +94,7 @@ const repoSource = (name: NameSet, dbModule: string, stems: EntityStems): string
|
|
|
93
94
|
const listSignature = wrapList(
|
|
94
95
|
'',
|
|
95
96
|
'export async function list(',
|
|
96
|
-
['limit = 50'],
|
|
97
|
+
['limit = 50', "sort: ListSort = 'createdAt'", "dir: 'asc' | 'desc' = 'desc'"],
|
|
97
98
|
`): Promise<readonly ${row}[]> {`,
|
|
98
99
|
);
|
|
99
100
|
const byIdSignature = wrapList(
|
|
@@ -110,7 +111,7 @@ const repoSource = (name: NameSet, dbModule: string, stems: EntityStems): string
|
|
|
110
111
|
`): Promise<${row}> {`,
|
|
111
112
|
);
|
|
112
113
|
const listChain = chain(' ', `return ${table}`, [
|
|
113
|
-
|
|
114
|
+
'.orderBy(sort, dir)',
|
|
114
115
|
'.limit(limit)',
|
|
115
116
|
'.all()',
|
|
116
117
|
]);
|
|
@@ -133,10 +134,13 @@ ${byIdSignature}
|
|
|
133
134
|
${byIdBody}
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
/** The columns a list sorts by. Newest first (\`createdAt\`, \`desc\`) when nobody asks. */
|
|
138
|
+
export type ListSort = 'createdAt' | 'title';
|
|
139
|
+
|
|
136
140
|
${listSignature}
|
|
137
141
|
// The actor's org and nobody else's: the handle scopes the read, so there is no org to pass.
|
|
138
|
-
// Ordered
|
|
139
|
-
// adds the primary key
|
|
142
|
+
// Ordered BEFORE it is bounded, so a sort by title is the first titles and never the newest rows
|
|
143
|
+
// re-sorted; the handle adds the primary key last, in this direction, so the order is total.
|
|
140
144
|
${listChain}
|
|
141
145
|
}
|
|
142
146
|
|
|
@@ -209,6 +213,18 @@ unitTest('list is newest first and bounded', async () => {
|
|
|
209
213
|
expect(page.map((row) => row.title)).toEqual(['third', 'second']);
|
|
210
214
|
});
|
|
211
215
|
|
|
216
|
+
unitTest('list sorts before it bounds, either way', async () => {
|
|
217
|
+
// Written newest-last, so a bound taken before the sort would hold the wrong two.
|
|
218
|
+
for (const title of ['b', 'c', 'a']) {
|
|
219
|
+
await inOrg(orgId, () => repo.insert(draft(title)));
|
|
220
|
+
clock.advance(1000);
|
|
221
|
+
}
|
|
222
|
+
const first = await inOrg(orgId, () => repo.list(2, 'title', 'asc'));
|
|
223
|
+
expect(first.map((row) => row.title)).toEqual(['a', 'b']);
|
|
224
|
+
const last = await inOrg(orgId, () => repo.list(2, 'title', 'desc'));
|
|
225
|
+
expect(last.map((row) => row.title)).toEqual(['c', 'b']);
|
|
226
|
+
});
|
|
227
|
+
|
|
212
228
|
unitTest('an actor in another org never reads the row, and no call names an org', async () => {
|
|
213
229
|
const stored = await inOrg(orgId, () => repo.insert(draft('private')));
|
|
214
230
|
// Neither read takes an org: the handle scopes both to the actor it runs under.
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Nothing under `templates/` emitted `.github` until this file, so every scaffolded app started
|
|
5
5
|
// life with a gate that ran on exactly one machine — the author's. The two commands are
|
|
6
|
-
// `bun run setup` and `bun run
|
|
7
|
-
// out `bun install`, `x db migrate
|
|
6
|
+
// `bun run setup` and `bun run verify` and NOT a restatement of their steps: a workflow that spelled
|
|
7
|
+
// out `bun install`, `x db migrate` and `x verify`'s steps is a second definition of the gate,
|
|
8
8
|
// free to drift from the scripts a human runs, and axiom 1 has one way to do each thing.
|
|
9
|
-
// `bun run
|
|
10
|
-
//
|
|
9
|
+
// `bun run verify` is `x verify` alone: its `budgets` step builds when the stats are missing or
|
|
10
|
+
// older than the source (plan 101, 21), so no build step runs before it.
|
|
11
11
|
|
|
12
12
|
import { REQUIRED_BUN } from '../../app-root';
|
|
13
13
|
import type { GeneratedFile, NameSet } from '../naming';
|
|
@@ -35,10 +35,9 @@ const ci = (app: NameSet): string => `name: ${CI_WORKFLOW_NAME}
|
|
|
35
35
|
# order, on a machine that has never seen this repository. A check that exists only in CI is one
|
|
36
36
|
# nobody can reproduce locally, and a CI file that restates the gate's steps is a second gate.
|
|
37
37
|
#
|
|
38
|
-
# \`bun run
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
# instead and this workflow is red for a reason that has nothing to do with the commit.
|
|
38
|
+
# \`bun run verify\` is \`x verify\`, the whole gate. Its \`budgets\` step runs
|
|
39
|
+
# \`x build --target static\` itself when \`.x/build-stats.json\` is missing or older than the source,
|
|
40
|
+
# which on a fresh runner it always is, so no build step comes first.
|
|
42
41
|
#
|
|
43
42
|
# \`bun run setup\` brings up embedded Postgres in-process; the TEST services come from
|
|
44
43
|
# \`docker/docker-compose.test.yml\` — Postgres 18, Redis, NATS and S3 in RAM, durability and logs
|
|
@@ -94,7 +93,7 @@ jobs:
|
|
|
94
93
|
# \`--wait\` returns once every healthcheck passes; the env file points the suites at them.
|
|
95
94
|
- run: docker compose -f ${TEST_COMPOSE_PATH} up -d --wait
|
|
96
95
|
- run: cat ${TEST_ENV_PATH} >> "$GITHUB_ENV"
|
|
97
|
-
- run: bun run
|
|
96
|
+
- run: bun run verify
|
|
98
97
|
`;
|
|
99
98
|
|
|
100
99
|
/** The CI a new app is born with, in the order a reader meets it: the gate, then the image. */
|
|
@@ -59,7 +59,7 @@ export function bareThrows(files: readonly SourceFile[]): readonly Finding[] {
|
|
|
59
59
|
findings.push({
|
|
60
60
|
code: CODE,
|
|
61
61
|
cause: \`\${file.path}:\${line} throws a bare \${thrown} — it reaches its reader as a stack trace with no code, no cause and nothing to run\`,
|
|
62
|
-
fix: \`
|
|
62
|
+
fix: \`declare the failure in \${file.path} with defineError({ code, title, status, cause, fix }) from @ultimat3/core — an X_SCREAMING_SNAKE code and a fix naming a command — and throw its class, then: x verify\`,
|
|
63
63
|
at: file.path,
|
|
64
64
|
});
|
|
65
65
|
}
|
|
@@ -115,7 +115,7 @@ unitTest('every builtin Error class is the same rule', () => {
|
|
|
115
115
|
expect(bareThrows(file("throw new MyTypeError('x');"))).toEqual([]);
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
unitTest('
|
|
118
|
+
unitTest('a declared error is what the rule asks for', () => {
|
|
119
119
|
expect(bareThrows(file('throw new PostError(missingPost(id));'))).toEqual([]);
|
|
120
120
|
});
|
|
121
121
|
|