@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
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// The `blog` shape's `post` slice: an entity with no tenant (a blog is one publication), the repo,
|
|
2
|
+
// two public reads the site renders, and the seed. Writes go through `/admin`, which derives its
|
|
3
|
+
// screens from the entity, so the slice declares no write action of its own.
|
|
4
|
+
|
|
5
|
+
import type { GeneratedFile, NameSet } from '../naming';
|
|
6
|
+
|
|
7
|
+
const DIR = 'apps/web/app/post';
|
|
8
|
+
|
|
9
|
+
const entity =
|
|
10
|
+
(): string => `// A post: a slug, a title and a body, published when \`publishedAt\` is set. No \`tenant:\` — a blog
|
|
11
|
+
// is one publication, so every reader reads the same rows.
|
|
12
|
+
|
|
13
|
+
import { entity, invariant, text, timestamp, uuid } from '@ultimat3/entity';
|
|
14
|
+
|
|
15
|
+
export const post = entity('posts', {
|
|
16
|
+
columns: {
|
|
17
|
+
id: uuid().primaryKey(),
|
|
18
|
+
slug: text({ max: 120 }).unique(),
|
|
19
|
+
title: text({ max: 200 }),
|
|
20
|
+
body: text(),
|
|
21
|
+
/** Null is a draft: no read below returns it, so no page renders it. */
|
|
22
|
+
publishedAt: timestamp().nullable(),
|
|
23
|
+
createdAt: timestamp().defaultNow(),
|
|
24
|
+
},
|
|
25
|
+
invariants: (c) => [
|
|
26
|
+
invariant('post_title_not_blank', c.title.trimmed().minLength(1)),
|
|
27
|
+
invariant('post_slug_not_blank', c.slug.trimmed().minLength(1)),
|
|
28
|
+
],
|
|
29
|
+
indexes: [{ on: ['publishedAt'] }],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export type Post = typeof post.$row;
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const entityTest =
|
|
36
|
+
(): string => `// The table as declared: shared by every reader, with its two invariants.
|
|
37
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
38
|
+
import { post } from './entity';
|
|
39
|
+
|
|
40
|
+
unitTest('post is one table every reader shares, with its two invariants', () => {
|
|
41
|
+
expect(post.$name).toBe('posts');
|
|
42
|
+
expect(post.$tenantColumn).toBeNull();
|
|
43
|
+
expect(post.$invariants.map((rule) => rule.name)).toEqual([
|
|
44
|
+
'post_title_not_blank',
|
|
45
|
+
'post_slug_not_blank',
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
const repo = (
|
|
51
|
+
app: NameSet,
|
|
52
|
+
): string => `// The only reads of \`posts\`: published rows, newest first, and one by its slug.
|
|
53
|
+
|
|
54
|
+
import { db } from '@${app.kebab}/db';
|
|
55
|
+
import type { Post } from './entity';
|
|
56
|
+
|
|
57
|
+
export async function published(limit: number): Promise<readonly Post[]> {
|
|
58
|
+
return db.posts
|
|
59
|
+
.andWhere('publishedAt', 'is-not-null')
|
|
60
|
+
.orderBy('publishedAt', 'desc')
|
|
61
|
+
.orderBy('id', 'desc')
|
|
62
|
+
.limit(limit)
|
|
63
|
+
.all();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function publishedBySlug(slug: string): Promise<readonly Post[]> {
|
|
67
|
+
return db.posts.where({ slug }).andWhere('publishedAt', 'is-not-null').limit(1).all();
|
|
68
|
+
}
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const repoTest = (
|
|
72
|
+
app: NameSet,
|
|
73
|
+
): string => `// The repo's two reads, against the in-memory driver: never a draft, newest first.
|
|
74
|
+
import { db, driver } from '@${app.kebab}/db';
|
|
75
|
+
import { afterEach, expect, unitTest } from '@ultimat3/testing';
|
|
76
|
+
import * as repo from './repo';
|
|
77
|
+
|
|
78
|
+
const at = (iso: string): Date => new Date(iso);
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
driver.reset?.();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const write = async (slug: string, publishedAt: Date | null): Promise<void> => {
|
|
85
|
+
await db.posts.insert({ slug, title: slug, body: 'body', publishedAt });
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
unitTest('published is newest first, bounded, and never a draft', async () => {
|
|
89
|
+
await write('old', at('2026-01-01T00:00:00Z'));
|
|
90
|
+
await write('new', at('2026-02-01T00:00:00Z'));
|
|
91
|
+
await write('draft', null);
|
|
92
|
+
expect((await repo.published(10)).map((row) => row.slug)).toEqual(['new', 'old']);
|
|
93
|
+
expect((await repo.published(1)).map((row) => row.slug)).toEqual(['new']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
unitTest('publishedBySlug answers one published row, and nothing for a draft', async () => {
|
|
97
|
+
await write('live', at('2026-01-01T00:00:00Z'));
|
|
98
|
+
await write('draft', null);
|
|
99
|
+
expect((await repo.publishedBySlug('live')).map((row) => row.slug)).toEqual(['live']);
|
|
100
|
+
expect(await repo.publishedBySlug('draft')).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
const publishedPosts =
|
|
105
|
+
(): string => `// The blog index: published posts, newest first. Public — \`allow('public')\` says so out loud.
|
|
106
|
+
|
|
107
|
+
import { allow } from '@ultimat3/policy';
|
|
108
|
+
import { from, query, t } from '@ultimat3/query';
|
|
109
|
+
import type { Post } from '../entity';
|
|
110
|
+
import * as repo from '../repo';
|
|
111
|
+
|
|
112
|
+
export const publishedPosts = query({
|
|
113
|
+
input: t.object({ limit: t.number.int().min(1).max(50).default(20) }),
|
|
114
|
+
policy: allow('public'),
|
|
115
|
+
mcp: { expose: true, description: 'The published posts, newest first' },
|
|
116
|
+
sql: ({ limit }) =>
|
|
117
|
+
from<Post>('posts', () => repo.published(limit))
|
|
118
|
+
.compare('publishedAt', '!=', null)
|
|
119
|
+
.orderBy('publishedAt', 'desc')
|
|
120
|
+
.orderBy('id', 'desc')
|
|
121
|
+
.limit(limit),
|
|
122
|
+
});
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
const publishedPost =
|
|
126
|
+
(): string => `// One published post by its slug: a page of at most one row, so a missing slug is an empty page
|
|
127
|
+
// (the route answers 404) rather than an error every crawler logs.
|
|
128
|
+
|
|
129
|
+
import { allow } from '@ultimat3/policy';
|
|
130
|
+
import { from, query, t } from '@ultimat3/query';
|
|
131
|
+
import type { Post } from '../entity';
|
|
132
|
+
import * as repo from '../repo';
|
|
133
|
+
|
|
134
|
+
export const publishedPost = query({
|
|
135
|
+
input: t.object({ slug: t.string.max(120) }),
|
|
136
|
+
policy: allow('public'),
|
|
137
|
+
mcp: { expose: true, description: 'One published post, by its slug' },
|
|
138
|
+
sql: ({ slug }) =>
|
|
139
|
+
from<Post>('posts', () => repo.publishedBySlug(slug))
|
|
140
|
+
.where({ slug })
|
|
141
|
+
.compare('publishedAt', '!=', null)
|
|
142
|
+
.limit(1),
|
|
143
|
+
});
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
const queriesTest = (
|
|
147
|
+
app: NameSet,
|
|
148
|
+
): string => `// The two public reads, called as an anonymous visitor: published rows only.
|
|
149
|
+
import { db, driver } from '@${app.kebab}/db';
|
|
150
|
+
import { anonymousActor } from '@ultimat3/core';
|
|
151
|
+
import { afterEach, expect, unitTest } from '@ultimat3/testing';
|
|
152
|
+
import { publishedPost } from './published-post';
|
|
153
|
+
import { publishedPosts } from './published-posts';
|
|
154
|
+
|
|
155
|
+
const index = publishedPosts.named('publishedPosts');
|
|
156
|
+
const one = publishedPost.named('publishedPost');
|
|
157
|
+
|
|
158
|
+
afterEach(() => {
|
|
159
|
+
driver.reset?.();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
unitTest('anyone reads the published posts, and no draft', async () => {
|
|
163
|
+
await db.posts.insert({ slug: 'hello', title: 'Hello', body: 'b', publishedAt: new Date(0) });
|
|
164
|
+
await db.posts.insert({ slug: 'wip', title: 'Wip', body: 'b', publishedAt: null });
|
|
165
|
+
const rows = await index.as(anonymousActor(), {});
|
|
166
|
+
expect(rows.map((row) => row.slug)).toEqual(['hello']);
|
|
167
|
+
expect((await one.as(anonymousActor(), { slug: 'hello' })).map((row) => row.title)).toEqual([
|
|
168
|
+
'Hello',
|
|
169
|
+
]);
|
|
170
|
+
expect(await one.as(anonymousActor(), { slug: 'wip' })).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
const seed = (app: NameSet): string => `import { defineSeed } from '@ultimat3/entity';
|
|
175
|
+
import { post } from './schema';
|
|
176
|
+
|
|
177
|
+
/** Stable across runs: \`id('post:hello')\` is a UUID v5 of the label, not a random one. */
|
|
178
|
+
export const ${app.camel}Seed = defineSeed('${app.kebab}', async ({ insert, id }) => {
|
|
179
|
+
await insert(post, [
|
|
180
|
+
{
|
|
181
|
+
id: id('post:hello'),
|
|
182
|
+
slug: 'hello',
|
|
183
|
+
title: 'Hello, world',
|
|
184
|
+
body: 'The first post. Edit it, or write the next one, at /admin.',
|
|
185
|
+
publishedAt: new Date('2026-01-01T00:00:00Z'),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: id('post:draft'),
|
|
189
|
+
slug: 'draft',
|
|
190
|
+
title: 'A draft',
|
|
191
|
+
body: 'Unpublished: no page renders it until publishedAt is set.',
|
|
192
|
+
publishedAt: null,
|
|
193
|
+
},
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
`;
|
|
197
|
+
|
|
198
|
+
const seedTest = (
|
|
199
|
+
app: NameSet,
|
|
200
|
+
): string => `// The seed, run against the in-memory driver: what it writes, and that a second run writes nothing.
|
|
201
|
+
import { isSeed } from '@ultimat3/entity';
|
|
202
|
+
import { afterEach, expect, unitTest } from '@ultimat3/testing';
|
|
203
|
+
import { db, driver } from './client';
|
|
204
|
+
import { ${app.camel}Seed } from './seed';
|
|
205
|
+
|
|
206
|
+
afterEach(() => {
|
|
207
|
+
driver.reset?.();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
unitTest('the seed is one x db seed discovers, in the dev tier', () => {
|
|
211
|
+
expect(isSeed(${app.camel}Seed)).toBe(true);
|
|
212
|
+
expect(${app.camel}Seed.tier).toBe('dev');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
unitTest('it writes one published post and one draft, once', async () => {
|
|
216
|
+
const first = await ${app.camel}Seed.run({ driver });
|
|
217
|
+
expect(first.metrics.inserted).toBe(2);
|
|
218
|
+
const rows = await db.posts.orderBy('slug').all();
|
|
219
|
+
expect(rows.map((row) => [row.slug, row.publishedAt === null])).toEqual([
|
|
220
|
+
['draft', true],
|
|
221
|
+
['hello', false],
|
|
222
|
+
]);
|
|
223
|
+
expect((await ${app.camel}Seed.run({ driver })).metrics.inserted).toBe(0);
|
|
224
|
+
});
|
|
225
|
+
`;
|
|
226
|
+
|
|
227
|
+
const authTest =
|
|
228
|
+
(): string => `// The auth instance is built once per process, on first use. Built here over a MIGRATED database,
|
|
229
|
+
// never the process default: the adapter keeps the client it was built over, so an instance first
|
|
230
|
+
// built with no database installed is the one every later sign-in test in this process reaches —
|
|
231
|
+
// unable to reach any database, in a file that installed its own.
|
|
232
|
+
import { migratedDatabase } from '@ultimat3/cli';
|
|
233
|
+
import { setDbClient } from '@ultimat3/db';
|
|
234
|
+
import { afterAll, beforeAll, expect, reusableDatabase, unitTest } from '@ultimat3/testing';
|
|
235
|
+
import { appAuth } from './auth';
|
|
236
|
+
|
|
237
|
+
// A template restore is ~0.5 s; its first build (initdb plus every migration) is what this bounds.
|
|
238
|
+
const BOOT_MS = 120_000;
|
|
239
|
+
|
|
240
|
+
const workerDatabase = reusableDatabase(() => migratedDatabase({ root: import.meta.dir }));
|
|
241
|
+
beforeAll(async () => {
|
|
242
|
+
setDbClient(await workerDatabase());
|
|
243
|
+
}, BOOT_MS);
|
|
244
|
+
afterAll(() => setDbClient(undefined));
|
|
245
|
+
|
|
246
|
+
unitTest('appAuth builds one instance and hands it back every time', () => {
|
|
247
|
+
expect(appAuth()).toBe(appAuth());
|
|
248
|
+
});
|
|
249
|
+
`;
|
|
250
|
+
|
|
251
|
+
/** The slice, its tests, and the seed that replaces the empty one. */
|
|
252
|
+
export function blogSliceFiles(app: NameSet): readonly GeneratedFile[] {
|
|
253
|
+
return [
|
|
254
|
+
{ path: `${DIR}/entity.ts`, contents: entity() },
|
|
255
|
+
{ path: `${DIR}/entity.test.ts`, contents: entityTest() },
|
|
256
|
+
{ path: `${DIR}/repo.ts`, contents: repo(app) },
|
|
257
|
+
{ path: `${DIR}/repo.test.ts`, contents: repoTest(app) },
|
|
258
|
+
{ path: `${DIR}/queries/published-posts.ts`, contents: publishedPosts() },
|
|
259
|
+
{ path: `${DIR}/queries/published-post.ts`, contents: publishedPost() },
|
|
260
|
+
{ path: `${DIR}/queries/queries.test.ts`, contents: queriesTest(app) },
|
|
261
|
+
{ path: 'packages/db/src/seed.ts', contents: seed(app) },
|
|
262
|
+
{ path: 'packages/db/src/seed.test.ts', contents: seedTest(app) },
|
|
263
|
+
{ path: 'apps/web/app/auth/auth.test.ts', contents: authTest() },
|
|
264
|
+
];
|
|
265
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// `x new <name> --shape blog`: a publication. `site/` is the only rendered surface — an `isr` index
|
|
2
|
+
// and one page per post, zero JavaScript on every route — over a `post` slice with no tenant,
|
|
3
|
+
// written at `/admin`. No `app/` page, no island, no sign-in page, no dashboard.
|
|
4
|
+
|
|
5
|
+
import type { GeneratedFile, NameSet } from '../naming';
|
|
6
|
+
import { blogSiteFiles } from './blog-site';
|
|
7
|
+
import { blogSliceFiles } from './blog-slice';
|
|
8
|
+
import type { ShapeEdit, ShapeOverlay } from './overlay';
|
|
9
|
+
import { claudeRows, shapeLines } from './shared-edits';
|
|
10
|
+
|
|
11
|
+
const apiIndex = (): string => `/**
|
|
12
|
+
* The API surface: every primitive this app exposes, registered in one call. The site's pages
|
|
13
|
+
* read these two over HTTP through \`shared/client.ts\`; MCP lists both (\`mcp.expose\`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { defineApi } from '@ultimat3/action';
|
|
17
|
+
import * as publishedPost from '../app/post/queries/published-post';
|
|
18
|
+
import * as publishedPosts from '../app/post/queries/published-posts';
|
|
19
|
+
import * as health from './health';
|
|
20
|
+
|
|
21
|
+
export const api = defineApi({
|
|
22
|
+
actions: [health],
|
|
23
|
+
queries: [publishedPosts, publishedPost],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** What the typed client is shaped from — imported as a TYPE only by \`shared/client.ts\`. */
|
|
27
|
+
export type Api = typeof api;
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const apiIndexTest =
|
|
31
|
+
(): string => `// The registration, read back: a read left out of the list is a route that answers 404.
|
|
32
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
33
|
+
import { api } from './index';
|
|
34
|
+
|
|
35
|
+
unitTest('health and both public reads are registered under their export names', () => {
|
|
36
|
+
expect(api.actions.health.describe().name).toBe('health');
|
|
37
|
+
expect(api.queries.publishedPosts.describe().name).toBe('publishedPosts');
|
|
38
|
+
expect(api.queries.publishedPost.describe().name).toBe('publishedPost');
|
|
39
|
+
});
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const edits = (app: NameSet): readonly ShapeEdit[] => [
|
|
43
|
+
{
|
|
44
|
+
path: 'packages/db/src/client.ts',
|
|
45
|
+
find: 'const entities = {};',
|
|
46
|
+
replace: 'const entities = {\n posts: post,\n};',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
path: 'packages/db/src/client.ts',
|
|
50
|
+
find: "import { storeMode } from '@ultimat3/core';",
|
|
51
|
+
replace: `import { post } from '@${app.kebab}/web/app/post/entity';\nimport { storeMode } from '@ultimat3/core';`,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
path: 'packages/db/src/schema.ts',
|
|
55
|
+
find: 'export {};',
|
|
56
|
+
replace: `export { post } from '@${app.kebab}/web/app/post/entity';`,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
path: 'packages/db/package.json',
|
|
60
|
+
find: ' "scripts": {',
|
|
61
|
+
replace: ` "dependencies": {\n "@${app.kebab}/web": "0.0.0"\n },\n "scripts": {`,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
path: 'apps/web/package.json',
|
|
65
|
+
find: ' "dependencies": {\n',
|
|
66
|
+
replace: ` "dependencies": {\n "@${app.kebab}/db": "0.0.0",\n`,
|
|
67
|
+
},
|
|
68
|
+
// The admin's resource screens for `posts` are where a post is written.
|
|
69
|
+
{
|
|
70
|
+
path: 'apps/web/shared/roles.ts',
|
|
71
|
+
find: " 'dashboard:read',\n]);",
|
|
72
|
+
replace: " 'dashboard:read',\n 'posts:read',\n 'posts:write',\n 'posts:delete',\n]);",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
path: 'apps/web/shared/roles.ts',
|
|
76
|
+
find: " grants: ['admin:read', 'admin:write', 'admin:destroy', 'job:read', 'audit:read'],",
|
|
77
|
+
replace: [
|
|
78
|
+
' grants: [',
|
|
79
|
+
...['admin:read', 'admin:write', 'admin:destroy', 'job:read', 'audit:read'].map(
|
|
80
|
+
(grant) => ` '${grant}',`,
|
|
81
|
+
),
|
|
82
|
+
" 'posts:read',",
|
|
83
|
+
" 'posts:write',",
|
|
84
|
+
" 'posts:delete',",
|
|
85
|
+
' ],',
|
|
86
|
+
].join('\n'),
|
|
87
|
+
},
|
|
88
|
+
...claudeRows({
|
|
89
|
+
page: '`apps/web/site/page.test.ts`',
|
|
90
|
+
island: 'none in this shape: `bunx x g island <name>` writes one with its states',
|
|
91
|
+
}),
|
|
92
|
+
...shapeLines(
|
|
93
|
+
'blog',
|
|
94
|
+
'a publication — `apps/web/site/` renders the `post` slice (`apps/web/app/post/`) with zero JavaScript, and `/admin` writes it',
|
|
95
|
+
),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
export const blogShape: ShapeOverlay = {
|
|
99
|
+
example: false,
|
|
100
|
+
drop: [
|
|
101
|
+
'apps/web/app/dashboard/',
|
|
102
|
+
'apps/web/shared/shell',
|
|
103
|
+
'apps/web/shared/theme-toggle.island',
|
|
104
|
+
'apps/web/shared/browser-client',
|
|
105
|
+
],
|
|
106
|
+
dropKeys: ['app.dashboard', 'shell', 'site.home'],
|
|
107
|
+
edits,
|
|
108
|
+
files: (app): readonly GeneratedFile[] => [
|
|
109
|
+
...blogSliceFiles(app),
|
|
110
|
+
...blogSiteFiles(app),
|
|
111
|
+
{ path: 'apps/web/api/index.ts', contents: apiIndex() },
|
|
112
|
+
{ path: 'apps/web/api/index.test.ts', contents: apiIndexTest() },
|
|
113
|
+
],
|
|
114
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// `--shape dashboard`'s detail: one post by id, read through a `single` query as the request's
|
|
2
|
+
// actor, and the page `/posts` links each title to. Tests beside each, against the in-memory driver.
|
|
3
|
+
|
|
4
|
+
import type { GeneratedFile, NameSet } from '../naming';
|
|
5
|
+
|
|
6
|
+
const DIR = 'apps/web/app/post/queries';
|
|
7
|
+
const PAGE = 'apps/web/app/posts/[id]';
|
|
8
|
+
|
|
9
|
+
const postById =
|
|
10
|
+
(): string => `// postById: one post of the caller's org, by id — the row a detail page reads. A page of at
|
|
11
|
+
// most one row: an id the org does not hold reads empty, so another org's post is "not found",
|
|
12
|
+
// never a refusal that confirms it exists. \`single\` makes the wire answer the row itself.
|
|
13
|
+
|
|
14
|
+
import { from, query, t } from '@ultimat3/query';
|
|
15
|
+
import type { Post } from '../entity';
|
|
16
|
+
import { canPostRead } from '../policy';
|
|
17
|
+
import * as repo from '../repo';
|
|
18
|
+
|
|
19
|
+
export const postById = query({
|
|
20
|
+
input: t.object({ id: t.uuid }),
|
|
21
|
+
policy: canPostRead,
|
|
22
|
+
single: true,
|
|
23
|
+
// The refusals a caller may branch on, closed: add each code this read comes to throw.
|
|
24
|
+
errors: [],
|
|
25
|
+
sql: ({ id }) =>
|
|
26
|
+
from<Post>('posts', async () => {
|
|
27
|
+
const row = await repo.byId(id);
|
|
28
|
+
return row === undefined ? [] : [row];
|
|
29
|
+
})
|
|
30
|
+
.where({ id })
|
|
31
|
+
.limit(1),
|
|
32
|
+
});
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const postByIdTest = (
|
|
36
|
+
app: NameSet,
|
|
37
|
+
): string => `// postById, READ: one row of the caller's org, and nothing of another org's.
|
|
38
|
+
import { driver } from '@${app.kebab}/db';
|
|
39
|
+
import { ctxOf, frozenClock, runWithContext } from '@ultimat3/core';
|
|
40
|
+
import { testActor } from '@ultimat3/policy';
|
|
41
|
+
import { afterEach, expect, unitTest } from '@ultimat3/testing';
|
|
42
|
+
import * as repo from '../repo';
|
|
43
|
+
import { postById } from './post-by-id';
|
|
44
|
+
|
|
45
|
+
const orgId = '00000000-0000-4000-8000-000000000002';
|
|
46
|
+
const otherOrg = '00000000-0000-4000-8000-000000000009';
|
|
47
|
+
const target = postById.named('postById');
|
|
48
|
+
const read = 'post:read';
|
|
49
|
+
const member = testActor('member', { orgId, permissions: [read] }).actor;
|
|
50
|
+
const clock = frozenClock('2026-01-01T00:00:00.000Z');
|
|
51
|
+
|
|
52
|
+
const store = (owner: string, title: string) => {
|
|
53
|
+
const draft = { orgId: owner, title, price: { minor: 0, currency: 'USD' } };
|
|
54
|
+
const writer = ctxOf({ actor: testActor('writer', { orgId: owner }).actor, clock });
|
|
55
|
+
return runWithContext(writer, () => repo.insert(draft));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
driver.reset?.();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
unitTest('a caller with no grant is refused before the read', async () => {
|
|
63
|
+
const stranger = testActor('stranger', { orgId }).actor;
|
|
64
|
+
const id = '00000000-0000-4000-8000-000000000001';
|
|
65
|
+
const refused = await target.as(stranger, { id }).catch((error: unknown) => error);
|
|
66
|
+
expect(refused).toBeUltimateError('X_FORBIDDEN');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
unitTest('the row by its id, and another org’s id reads empty', async () => {
|
|
70
|
+
const ours = await store(orgId, 'ours');
|
|
71
|
+
const theirs = await store(otherOrg, 'theirs');
|
|
72
|
+
expect((await target.as(member, { id: ours.id })).map((row) => row.title)).toEqual(['ours']);
|
|
73
|
+
expect(await target.as(member, { id: theirs.id })).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const page = (
|
|
78
|
+
app: NameSet,
|
|
79
|
+
): string => `// /posts/:id: one post of the org that is looking — the row /posts links each title to. Read
|
|
80
|
+
// through the slice's own query as the request's actor, so the policy and the tenancy are the
|
|
81
|
+
// query's: another org's id is the not-found state, never its row.
|
|
82
|
+
|
|
83
|
+
import { useT } from '@${app.kebab}/i18n';
|
|
84
|
+
import type { AsyncState } from '@ultimat3/core';
|
|
85
|
+
import type { KnownPermission } from '@ultimat3/policy';
|
|
86
|
+
import { defineRoute } from '@ultimat3/render';
|
|
87
|
+
import {
|
|
88
|
+
DateTime,
|
|
89
|
+
DescriptionList,
|
|
90
|
+
EmptyState,
|
|
91
|
+
ErrorState,
|
|
92
|
+
Money,
|
|
93
|
+
PageHeader,
|
|
94
|
+
Section,
|
|
95
|
+
} from '@ultimat3/ui';
|
|
96
|
+
import type { JSX } from 'solid-js';
|
|
97
|
+
import { Shell } from '../../../shared/shell';
|
|
98
|
+
import type { Post } from '../../post/entity';
|
|
99
|
+
import { postById } from '../../post/queries/post-by-id';
|
|
100
|
+
import styles from './page.module.scss';
|
|
101
|
+
|
|
102
|
+
/** The read's outcome as DATA: the row, none (not this org's, or no such id), or the refusal. */
|
|
103
|
+
export type PageData = AsyncState<Post | undefined>;
|
|
104
|
+
|
|
105
|
+
/** A refusal or a malformed id is the page's error branch, never a 500 for the whole document. */
|
|
106
|
+
export async function postState(read: () => Promise<readonly Post[]>): Promise<PageData> {
|
|
107
|
+
try {
|
|
108
|
+
return { status: 'ready', data: (await read())[0] };
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return { status: 'failed', error };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const config = defineRoute({
|
|
115
|
+
// Per request, by id: no build holds an id to render it at, and \`stream\` would need a
|
|
116
|
+
// \`prerender()\` list of every row. \`ssr\` with a param is reported unweighable, never a finding.
|
|
117
|
+
render: 'ssr',
|
|
118
|
+
hydrate: 'visible',
|
|
119
|
+
offline: 'runtime',
|
|
120
|
+
policy: { permission: 'post:read' satisfies KnownPermission },
|
|
121
|
+
// The dashboard's own figure: this page hydrates the same one island, the shell's theme toggle,
|
|
122
|
+
// and the post itself is server markup.
|
|
123
|
+
budget: { js: '60kb' },
|
|
124
|
+
load: ({ params }) => postState(() => postById({ id: params.id ?? '' })),
|
|
125
|
+
meta: ({ t }) => ({
|
|
126
|
+
title: t('app.post.detail'),
|
|
127
|
+
description: t('app.posts.description'),
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
function Body(props: { readonly data: PageData }): JSX.Element {
|
|
132
|
+
const t = useT();
|
|
133
|
+
const state = props.data;
|
|
134
|
+
if (state.status === 'failed') return <ErrorState error={state.error} />;
|
|
135
|
+
const row = state.status === 'ready' ? state.data : undefined;
|
|
136
|
+
if (row === undefined) return <EmptyState title={t('app.post.missing')} />;
|
|
137
|
+
return (
|
|
138
|
+
<Section title={row.title}>
|
|
139
|
+
<DescriptionList
|
|
140
|
+
items={[
|
|
141
|
+
{ term: t('app.post.priceLabel'), detail: <Money value={row.price} /> },
|
|
142
|
+
{
|
|
143
|
+
term: t('app.post.created'),
|
|
144
|
+
detail: <DateTime value={row.createdAt} timeZone="UTC" dateStyle="medium" />,
|
|
145
|
+
},
|
|
146
|
+
]}
|
|
147
|
+
/>
|
|
148
|
+
</Section>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function Page(props: { readonly data: PageData }): JSX.Element {
|
|
153
|
+
const t = useT();
|
|
154
|
+
return (
|
|
155
|
+
<Shell>
|
|
156
|
+
<div class={styles.page}>
|
|
157
|
+
<PageHeader title={t('app.post.detail')} />
|
|
158
|
+
<Body data={props.data} />
|
|
159
|
+
<a href="/posts">{t('app.post.back')}</a>
|
|
160
|
+
</div>
|
|
161
|
+
</Shell>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
const pageTest = (
|
|
167
|
+
app: NameSet,
|
|
168
|
+
): string => `// /posts/:id, rendered as a request renders it: the row, the not-found state for an id the
|
|
169
|
+
// org does not hold, and the refusal for a caller without the grant.
|
|
170
|
+
import { driver } from '@${app.kebab}/db';
|
|
171
|
+
import { useT } from '@${app.kebab}/i18n';
|
|
172
|
+
import { ctxOf, frozenClock, runWithContext } from '@ultimat3/core';
|
|
173
|
+
import { testActor } from '@ultimat3/policy';
|
|
174
|
+
import { afterEach, expect, renderRoute, unitTest } from '@ultimat3/testing';
|
|
175
|
+
// The app's API, as boot loads it: \`defineApi\` names the query the page reads.
|
|
176
|
+
import '../../../api';
|
|
177
|
+
import * as repo from '../../post/repo';
|
|
178
|
+
import * as page from './page';
|
|
179
|
+
|
|
180
|
+
const orgId = '00000000-0000-4000-8000-000000000002';
|
|
181
|
+
const otherOrg = '00000000-0000-4000-8000-000000000009';
|
|
182
|
+
const read = 'post:read';
|
|
183
|
+
const member = testActor('member', { orgId, permissions: [read] }).actor;
|
|
184
|
+
const clock = frozenClock('2026-01-01T00:00:00.000Z');
|
|
185
|
+
|
|
186
|
+
const store = (owner: string, title: string) => {
|
|
187
|
+
const draft = { orgId: owner, title, price: { minor: 1200, currency: 'USD' } };
|
|
188
|
+
const writer = ctxOf({ actor: testActor('writer', { orgId: owner }).actor, clock });
|
|
189
|
+
return runWithContext(writer, () => repo.insert(draft));
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const at = (id: string) => ({ url: \`https://example.test/posts/\${id}\`, params: { id } });
|
|
193
|
+
|
|
194
|
+
afterEach(() => {
|
|
195
|
+
driver.reset?.();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
unitTest('the post, its price and when it was created', async () => {
|
|
199
|
+
const row = await store(orgId, 'first post');
|
|
200
|
+
const view = await renderRoute(page, { ...at(row.id), actor: member });
|
|
201
|
+
expect(view.text).toContain('first post');
|
|
202
|
+
expect(view.text).toContain('$12.00');
|
|
203
|
+
expect(view.meta.title).toBe(useT()('app.post.detail'));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
unitTest('another org’s post is not found, never its row', async () => {
|
|
207
|
+
const theirs = await store(otherOrg, 'theirs');
|
|
208
|
+
const view = await renderRoute(page, { ...at(theirs.id), actor: member });
|
|
209
|
+
expect(view.text).not.toContain('theirs');
|
|
210
|
+
expect(view.text).toContain(useT()('app.post.missing'));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
unitTest('a caller the read refuses sees the refusal, and the page around it', async () => {
|
|
214
|
+
const row = await store(orgId, 'ours');
|
|
215
|
+
const stranger = testActor('stranger', { orgId }).actor;
|
|
216
|
+
const view = await renderRoute(page, { ...at(row.id), actor: stranger });
|
|
217
|
+
expect(view.html).toContain('X_FORBIDDEN');
|
|
218
|
+
expect(view.text).toContain(useT()('app.post.back'));
|
|
219
|
+
});
|
|
220
|
+
`;
|
|
221
|
+
|
|
222
|
+
const pageStyles =
|
|
223
|
+
(): string => `// Semantic tokens only — a raw hex here is refused by the boundaries step (guards/raw-colour.ts).
|
|
224
|
+
@use '@ultimat3/ui/tokens' as tokens;
|
|
225
|
+
|
|
226
|
+
.page {
|
|
227
|
+
display: grid;
|
|
228
|
+
gap: tokens.space(4);
|
|
229
|
+
padding: tokens.space(6);
|
|
230
|
+
background: tokens.role('bg');
|
|
231
|
+
color: tokens.role('fg');
|
|
232
|
+
}
|
|
233
|
+
`;
|
|
234
|
+
|
|
235
|
+
/** The read, the page, and a test beside each. */
|
|
236
|
+
export const dashboardDetailFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
237
|
+
{ path: `${DIR}/post-by-id.ts`, contents: postById() },
|
|
238
|
+
{ path: `${DIR}/post-by-id.test.ts`, contents: postByIdTest(app) },
|
|
239
|
+
{ path: `${PAGE}/page.tsx`, contents: page(app) },
|
|
240
|
+
{ path: `${PAGE}/page.test.ts`, contents: pageTest(app) },
|
|
241
|
+
{ path: `${PAGE}/page.module.scss`, contents: pageStyles() },
|
|
242
|
+
];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `x new <name> --shape dashboard`: an internal tool. The default scaffold's dashboard, the `post`
|
|
2
|
+
// table and its job, `/admin` — plus a detail page each row links to, and sign-in, written by
|
|
3
|
+
// `x g sign-in --password` itself. The marketing `site/` stays a home page and the error documents.
|
|
4
|
+
|
|
5
|
+
import { catalogJson } from '../catalog-json';
|
|
6
|
+
import { catalogPath } from '../locales';
|
|
7
|
+
import type { GeneratedFile, NameSet } from '../naming';
|
|
8
|
+
import { dashboardDetailFiles } from './dashboard-detail';
|
|
9
|
+
import type { ShapeEdit, ShapeOverlay } from './overlay';
|
|
10
|
+
import { shapeLines } from './shared-edits';
|
|
11
|
+
|
|
12
|
+
const API = 'apps/web/api/index.ts';
|
|
13
|
+
const LIST_IMPORT = "import * as postList from '../app/post/live/post-list';\n";
|
|
14
|
+
|
|
15
|
+
const edits = (): readonly ShapeEdit[] => [
|
|
16
|
+
{
|
|
17
|
+
path: API,
|
|
18
|
+
find: LIST_IMPORT,
|
|
19
|
+
replace: `${LIST_IMPORT}import * as postById from '../app/post/queries/post-by-id';\n`,
|
|
20
|
+
},
|
|
21
|
+
{ path: API, find: 'queries: [postList],', replace: 'queries: [postList, postById],' },
|
|
22
|
+
// Each title is the link to its own page; the table stays links, no script.
|
|
23
|
+
{
|
|
24
|
+
path: 'apps/web/app/posts/page.tsx',
|
|
25
|
+
find: 'cell: (row) => row.title,',
|
|
26
|
+
replace: `cell: (row) => <a href={\`/posts/\${row.id}\`}>{row.title}</a>,`,
|
|
27
|
+
},
|
|
28
|
+
...shapeLines(
|
|
29
|
+
'dashboard',
|
|
30
|
+
'an internal tool — the `post` table at `/posts` links each row to `apps/web/app/posts/[id]/page.tsx`, and `x g sign-in --password` wrote the sign-in',
|
|
31
|
+
),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** The keys the detail page reads, merged into the catalog the base wrote. */
|
|
35
|
+
const catalog = (): GeneratedFile => ({
|
|
36
|
+
path: catalogPath('en'),
|
|
37
|
+
merge: 'json',
|
|
38
|
+
contents: catalogJson({
|
|
39
|
+
'app.post.back': 'All posts',
|
|
40
|
+
'app.post.created': 'Created',
|
|
41
|
+
'app.post.detail': 'Post',
|
|
42
|
+
'app.post.missing': 'No such post in this organisation.',
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const dashboardShape: ShapeOverlay = {
|
|
47
|
+
example: true,
|
|
48
|
+
drop: [],
|
|
49
|
+
dropKeys: [],
|
|
50
|
+
edits,
|
|
51
|
+
files: (app: NameSet): readonly GeneratedFile[] => [...dashboardDetailFiles(app), catalog()],
|
|
52
|
+
generate: [['sign-in', '--password']],
|
|
53
|
+
};
|