@ultimat3/cli 29.4.0 → 30.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (448) hide show
  1. package/CLAUDE.md +64 -40
  2. package/README.md +61 -11
  3. package/package.json +42 -28
  4. package/src/affected-tests.ts +97 -0
  5. package/src/affected-unowned.ts +43 -0
  6. package/src/affected.ts +25 -6
  7. package/src/app-boundaries.ts +29 -1
  8. package/src/app-evals.ts +12 -4
  9. package/src/app-load-unresolved.ts +62 -0
  10. package/src/app-load.ts +30 -37
  11. package/src/app-mcp.ts +14 -2
  12. package/src/app-module-graph.ts +232 -0
  13. package/src/app-reload-graph.ts +2 -2
  14. package/src/app-root.ts +37 -1
  15. package/src/bin.ts +1 -0
  16. package/src/boundary-findings.ts +6 -0
  17. package/src/budgets-freshness.ts +32 -0
  18. package/src/budgets.ts +20 -1
  19. package/src/build-capture-env.ts +9 -0
  20. package/src/cdp-shot-a11y.ts +2 -39
  21. package/src/ci-tested.ts +243 -0
  22. package/src/client-emit-customer-runtime.ts +139 -0
  23. package/src/client-emit-customer.ts +108 -0
  24. package/src/client-emit-drift.ts +72 -0
  25. package/src/client-emit-header.ts +67 -0
  26. package/src/client-emit-schemas.ts +175 -0
  27. package/src/client-emit-types.ts +121 -0
  28. package/src/client-emit-vendor.ts +146 -0
  29. package/src/client-errors-routes.ts +229 -0
  30. package/src/client-errors-script.ts +74 -0
  31. package/src/client-vitals-route.ts +197 -0
  32. package/src/client-vitals-script.ts +56 -0
  33. package/src/cmd-affected.ts +23 -0
  34. package/src/cmd-build-spec.ts +7 -1
  35. package/src/cmd-build.ts +35 -9
  36. package/src/cmd-ci-spec.ts +42 -6
  37. package/src/cmd-ci.ts +99 -2
  38. package/src/cmd-client-customer.ts +134 -0
  39. package/src/cmd-client-source.ts +81 -0
  40. package/src/cmd-client-spec.ts +48 -0
  41. package/src/cmd-client-write.ts +41 -0
  42. package/src/cmd-client.ts +111 -0
  43. package/src/cmd-db-recount.ts +120 -0
  44. package/src/cmd-db-spec.ts +36 -6
  45. package/src/cmd-db.ts +11 -3
  46. package/src/cmd-deploy-compose.ts +61 -3
  47. package/src/cmd-deploy-drain.ts +10 -2
  48. package/src/cmd-deploy-helm.ts +11 -0
  49. package/src/cmd-deploy-preflight.ts +118 -0
  50. package/src/cmd-deploy-spec.ts +58 -6
  51. package/src/cmd-deploy-verify.ts +380 -0
  52. package/src/cmd-deploy.ts +42 -9
  53. package/src/cmd-docs.ts +34 -4
  54. package/src/cmd-doctor.ts +66 -9
  55. package/src/cmd-drill-spec.ts +25 -0
  56. package/src/cmd-drill.ts +176 -0
  57. package/src/cmd-env-init.ts +102 -0
  58. package/src/cmd-env-spec.ts +10 -3
  59. package/src/cmd-env.ts +15 -10
  60. package/src/cmd-errors-recent.ts +60 -0
  61. package/src/cmd-errors-spec.ts +9 -1
  62. package/src/cmd-errors.ts +4 -0
  63. package/src/cmd-exercise-spec.ts +19 -0
  64. package/src/cmd-exercise.ts +129 -0
  65. package/src/cmd-generate-spec.ts +9 -1
  66. package/src/cmd-generate.ts +32 -27
  67. package/src/cmd-graph-load.ts +97 -0
  68. package/src/cmd-graph-spec.ts +16 -0
  69. package/src/cmd-graph.ts +73 -0
  70. package/src/cmd-i18n.ts +4 -1
  71. package/src/cmd-impact-spec.ts +13 -0
  72. package/src/cmd-impact.ts +64 -0
  73. package/src/cmd-jobs-resolve.ts +76 -0
  74. package/src/cmd-jobs-spec.ts +21 -2
  75. package/src/cmd-jobs.ts +10 -6
  76. package/src/cmd-mail-spec.ts +28 -0
  77. package/src/cmd-mail.ts +70 -0
  78. package/src/cmd-mcp-lock.ts +69 -0
  79. package/src/cmd-mcp-spec.ts +23 -4
  80. package/src/cmd-mcp.ts +17 -3
  81. package/src/cmd-new-spec.ts +8 -1
  82. package/src/cmd-new.ts +61 -9
  83. package/src/cmd-planned.ts +0 -6
  84. package/src/cmd-repro-spec.ts +31 -0
  85. package/src/cmd-repro.ts +174 -0
  86. package/src/cmd-stop-spec.ts +21 -0
  87. package/src/cmd-stop.ts +166 -0
  88. package/src/cmd-test-spec.ts +17 -4
  89. package/src/cmd-test.ts +70 -14
  90. package/src/cmd-upgrade-spec.ts +26 -0
  91. package/src/cmd-upgrade.ts +274 -0
  92. package/src/cmd-verify-spec.ts +3 -3
  93. package/src/db-backfill.ts +6 -0
  94. package/src/db-diagnostics.ts +106 -0
  95. package/src/db-generate.ts +9 -0
  96. package/src/db-reset-template.ts +1 -1
  97. package/src/db-rolling.ts +45 -0
  98. package/src/db-seed-generate.ts +188 -0
  99. package/src/deploy-verify-findings.ts +98 -0
  100. package/src/deploy-verify-flags.ts +109 -0
  101. package/src/deploy-verify-grade.ts +185 -0
  102. package/src/deploy-verify-queues.ts +53 -0
  103. package/src/dev-boot.ts +35 -15
  104. package/src/dev-child-watch.ts +16 -4
  105. package/src/dev-dashboard-guard.ts +8 -1
  106. package/src/dev-dashboard.ts +7 -0
  107. package/src/dev-lock-identity.ts +61 -0
  108. package/src/dev-lock-port-holder.ts +80 -0
  109. package/src/dev-lock-steal.ts +124 -0
  110. package/src/dev-lock.ts +92 -120
  111. package/src/dev-mail-templates.ts +51 -0
  112. package/src/dev-rebuild.ts +26 -2
  113. package/src/dev-route-table.ts +17 -0
  114. package/src/dev-supervisor.ts +5 -2
  115. package/src/dev-watch-tree.ts +11 -10
  116. package/src/dev-watch.ts +1 -1
  117. package/src/dispatch.ts +29 -6
  118. package/src/docs-section.ts +69 -0
  119. package/src/docs-sources.ts +100 -0
  120. package/src/doctor-auth.ts +11 -2
  121. package/src/doctor-chart-alerts.ts +55 -0
  122. package/src/doctor-pooler.ts +107 -0
  123. package/src/doctor-rolling.ts +35 -0
  124. package/src/doctor-row-security.ts +40 -0
  125. package/src/document-options.ts +13 -0
  126. package/src/drill-sync-lane.ts +25 -0
  127. package/src/drill-targets.ts +39 -0
  128. package/src/duplicate-packages.ts +1 -1
  129. package/src/env-production.ts +93 -0
  130. package/src/error-codes-shot.ts +59 -0
  131. package/src/error-codes-tooling.ts +29 -0
  132. package/src/error-codes.ts +100 -54
  133. package/src/error-contract.ts +2 -2
  134. package/src/errors.ts +22 -3
  135. package/src/exercise-database.ts +99 -0
  136. package/src/exercise-findings.ts +159 -0
  137. package/src/exercise-overrides.ts +42 -0
  138. package/src/exercise-registration.ts +58 -0
  139. package/src/exercise-static.ts +88 -0
  140. package/src/exercise-subject.ts +69 -0
  141. package/src/failure-capture-boot.ts +30 -0
  142. package/src/framework-env.ts +7 -1
  143. package/src/framework-schema.ts +72 -5
  144. package/src/generate-catalog-locales.ts +19 -0
  145. package/src/generate-chart-alerts.ts +156 -0
  146. package/src/generate-feature-required.ts +65 -0
  147. package/src/generate-feature.ts +83 -6
  148. package/src/generate-files.ts +24 -0
  149. package/src/generate-format-written.ts +32 -0
  150. package/src/generate-kinds.ts +6 -0
  151. package/src/generate-sign-in-config.ts +100 -0
  152. package/src/generate-sign-in-manifest.ts +28 -0
  153. package/src/generate-sign-in-mode.ts +101 -0
  154. package/src/generate-sign-in.ts +250 -0
  155. package/src/generate-write.ts +44 -7
  156. package/src/hand-rolled-socket.ts +72 -0
  157. package/src/i18n-app-key-shapes.ts +29 -0
  158. package/src/i18n-audit.ts +10 -3
  159. package/src/i18n-registration.ts +18 -3
  160. package/src/index.ts +2 -0
  161. package/src/island-bundle.ts +4 -3
  162. package/src/island-disposer.ts +116 -0
  163. package/src/island-realtime-state.ts +34 -6
  164. package/src/island-realtime.ts +6 -2
  165. package/src/island-routes.ts +3 -2
  166. package/src/isr-query-advice.ts +6 -11
  167. package/src/isr-query-guard.ts +4 -3
  168. package/src/job-budget.ts +95 -0
  169. package/src/jobs-json.ts +11 -0
  170. package/src/lint-findings.ts +104 -0
  171. package/src/local-cli.ts +34 -4
  172. package/src/loopback-host.ts +2 -2
  173. package/src/mail-preview-findings.ts +24 -0
  174. package/src/mcp-errors.ts +82 -2
  175. package/src/mcp-host.ts +11 -1
  176. package/src/mcp-lock-findings.ts +57 -0
  177. package/src/measure-paths.ts +12 -4
  178. package/src/measure-scope.ts +27 -13
  179. package/src/messages-client.ts +23 -0
  180. package/src/messages-db.ts +56 -0
  181. package/src/messages-gate.ts +107 -0
  182. package/src/messages-repro.ts +11 -0
  183. package/src/messages-shot.ts +51 -0
  184. package/src/messages-upgrade.ts +45 -0
  185. package/src/messages.ts +53 -178
  186. package/src/metrics-endpoint.ts +23 -5
  187. package/src/new-shapes-generate.ts +71 -0
  188. package/src/new-shapes.ts +63 -0
  189. package/src/openapi-compile-ir.ts +68 -0
  190. package/src/openapi-compile-schema.ts +284 -0
  191. package/src/openapi-compile.ts +273 -0
  192. package/src/openapi-refuse.ts +45 -0
  193. package/src/otlp-export.ts +2 -1
  194. package/src/output-findings.ts +123 -0
  195. package/src/output.ts +32 -8
  196. package/src/package-boundaries.ts +110 -0
  197. package/src/page-pins.ts +23 -0
  198. package/src/plain-routes.ts +13 -0
  199. package/src/prerender-entries.ts +59 -0
  200. package/src/prerender-heaviest.ts +27 -0
  201. package/src/prerender-preload.ts +6 -0
  202. package/src/prerender-report.ts +12 -0
  203. package/src/prerender-seo-reads.ts +38 -0
  204. package/src/prerender-served.ts +22 -7
  205. package/src/prerender-vitals.ts +42 -0
  206. package/src/prerender.ts +61 -32
  207. package/src/pwa-artifacts.ts +4 -3
  208. package/src/pwa-manifest-locales.ts +1 -1
  209. package/src/registry.ts +18 -0
  210. package/src/repro-bundle.ts +65 -0
  211. package/src/repro-errors.ts +47 -0
  212. package/src/repro-job.ts +72 -0
  213. package/src/repro-literal.ts +41 -0
  214. package/src/repro-request.ts +48 -0
  215. package/src/repro-write.ts +100 -0
  216. package/src/retain-sweep.ts +27 -0
  217. package/src/retained-assets-boot.ts +113 -0
  218. package/src/retained-assets-layout.ts +110 -0
  219. package/src/retained-assets-read.ts +142 -0
  220. package/src/retained-assets-sweep.ts +70 -0
  221. package/src/retained-assets.ts +171 -0
  222. package/src/revalidated-response.ts +8 -5
  223. package/src/role-replicator.ts +7 -1
  224. package/src/role-start-devices.ts +119 -0
  225. package/src/role-start-types.ts +6 -0
  226. package/src/role-start.ts +22 -5
  227. package/src/role-sync.ts +7 -1
  228. package/src/route-islands.ts +42 -0
  229. package/src/route-table.ts +2 -1
  230. package/src/row-security-boot.ts +58 -0
  231. package/src/row-security-wanted.ts +32 -0
  232. package/src/runtime-admin.ts +0 -1
  233. package/src/runtime-assets-site-cache.ts +55 -0
  234. package/src/runtime-assets-site.ts +136 -0
  235. package/src/runtime-assets.ts +99 -55
  236. package/src/runtime-bindings.ts +19 -3
  237. package/src/runtime-cache.ts +23 -14
  238. package/src/runtime-isr-outcome.ts +114 -60
  239. package/src/runtime-purge.ts +14 -2
  240. package/src/runtime-queue.ts +31 -4
  241. package/src/runtime-render-document.ts +204 -0
  242. package/src/runtime-render.ts +48 -198
  243. package/src/runtime-replica-position.ts +62 -0
  244. package/src/runtime-replica.ts +18 -3
  245. package/src/runtime-s3-path-style.ts +17 -0
  246. package/src/runtime-services.ts +19 -6
  247. package/src/runtime-statement-bound.ts +22 -0
  248. package/src/runtime-storage-upload.ts +9 -0
  249. package/src/runtime-storage.ts +41 -81
  250. package/src/schema-diff-views.ts +54 -0
  251. package/src/schema-diff.ts +21 -3
  252. package/src/schema-drift.ts +28 -9
  253. package/src/schema-dump-drift.ts +91 -7
  254. package/src/script-csp.ts +1 -1
  255. package/src/seo-cards.ts +26 -0
  256. package/src/seo-routes.ts +22 -8
  257. package/src/serve-boot.ts +54 -21
  258. package/src/serve-drain.ts +1 -0
  259. package/src/serve-env.ts +26 -8
  260. package/src/serve-retire.ts +12 -6
  261. package/src/serve-web.ts +42 -4
  262. package/src/serve.ts +9 -7
  263. package/src/site-asset-routes.ts +7 -1
  264. package/src/site-config.ts +4 -0
  265. package/src/site-llms-pages.ts +106 -0
  266. package/src/site-llms.ts +64 -0
  267. package/src/site-seo-write.ts +4 -2
  268. package/src/site-seo.ts +55 -6
  269. package/src/source-mtime.ts +50 -0
  270. package/src/static-capture-errors.ts +41 -0
  271. package/src/static-capture.ts +56 -0
  272. package/src/static-cards-serve.ts +39 -0
  273. package/src/static-cards.ts +93 -0
  274. package/src/static-document.ts +14 -13
  275. package/src/static-images.ts +115 -0
  276. package/src/stream-hole-scope.ts +55 -0
  277. package/src/style-bundle.ts +1 -1
  278. package/src/style-routes.ts +3 -2
  279. package/src/sw-precache-plan.ts +2 -2
  280. package/src/sw-routes.ts +2 -2
  281. package/src/templates/action-tests.ts +306 -0
  282. package/src/templates/action.ts +81 -269
  283. package/src/templates/backfill.ts +1 -1
  284. package/src/templates/entity.ts +21 -5
  285. package/src/templates/github/ci.yml.ts +8 -9
  286. package/src/templates/guard-bare-error.ts +2 -2
  287. package/src/templates/guard-error-declared.ts +263 -0
  288. package/src/templates/guard-native-confirm.ts +132 -0
  289. package/src/templates/guard-raw-fetch.ts +151 -0
  290. package/src/templates/guard-raw-interval.ts +121 -0
  291. package/src/templates/guard-repo-raw-sql.ts +71 -2
  292. package/src/templates/job.ts +1 -1
  293. package/src/templates/naming.ts +5 -0
  294. package/src/templates/pinned/docker-compose.yml +6 -0
  295. package/src/templates/pinned-images.ts +8 -0
  296. package/src/templates/policy.ts +85 -2
  297. package/src/templates/query.ts +70 -13
  298. package/src/templates/resource-create.ts +3 -1
  299. package/src/templates/resource-form-island.ts +15 -4
  300. package/src/templates/resource-page-db.ts +83 -0
  301. package/src/templates/resource-page.ts +161 -52
  302. package/src/templates/route.ts +4 -6
  303. package/src/templates/scaffold-app.ts +3 -0
  304. package/src/templates/scaffold-auth.ts +2 -1
  305. package/src/templates/scaffold-bin.ts +5 -52
  306. package/src/templates/scaffold-claude-agents.ts +19 -19
  307. package/src/templates/scaffold-claude-commands.ts +28 -31
  308. package/src/templates/scaffold-claude.ts +26 -23
  309. package/src/templates/scaffold-container-compose.ts +6 -1
  310. package/src/templates/scaffold-container.ts +24 -4
  311. package/src/templates/scaffold-dashboard-bare.ts +3 -3
  312. package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
  313. package/src/templates/scaffold-dashboard-example-view.ts +156 -0
  314. package/src/templates/scaffold-dashboard-example.ts +43 -131
  315. package/src/templates/scaffold-dashboard-shared.ts +3 -7
  316. package/src/templates/scaffold-db-client.ts +20 -6
  317. package/src/templates/scaffold-db-package.ts +5 -14
  318. package/src/templates/scaffold-docs.ts +73 -57
  319. package/src/templates/scaffold-domain-package.ts +18 -14
  320. package/src/templates/scaffold-entries.ts +7 -2
  321. package/src/templates/scaffold-exercise.ts +37 -0
  322. package/src/templates/scaffold-guards.ts +13 -1
  323. package/src/templates/scaffold-helm-alerts.ts +234 -0
  324. package/src/templates/scaffold-helm-templates.ts +7 -5
  325. package/src/templates/scaffold-helm.ts +46 -12
  326. package/src/templates/scaffold-i18n.ts +6 -2
  327. package/src/templates/scaffold-mcp-endpoint.ts +182 -0
  328. package/src/templates/scaffold-mcp-json.ts +31 -0
  329. package/src/templates/scaffold-mcp-package.ts +59 -56
  330. package/src/templates/scaffold-repo.ts +7 -2
  331. package/src/templates/scaffold-shell.ts +45 -79
  332. package/src/templates/scaffold-test-services.ts +13 -5
  333. package/src/templates/shapes/api-keys.ts +170 -0
  334. package/src/templates/shapes/api.ts +101 -0
  335. package/src/templates/shapes/blog-site.ts +340 -0
  336. package/src/templates/shapes/blog-slice.ts +265 -0
  337. package/src/templates/shapes/blog.ts +114 -0
  338. package/src/templates/shapes/dashboard-detail.ts +242 -0
  339. package/src/templates/shapes/dashboard.ts +53 -0
  340. package/src/templates/shapes/index.ts +17 -0
  341. package/src/templates/shapes/overlay.ts +99 -0
  342. package/src/templates/shapes/shared-edits.ts +46 -0
  343. package/src/templates/sign-in-action-tests.ts +237 -0
  344. package/src/templates/sign-in-authenticator.ts +112 -0
  345. package/src/templates/sign-in-oidc.ts +189 -0
  346. package/src/templates/sign-in-pages.ts +148 -0
  347. package/src/templates/sign-in-password.ts +216 -0
  348. package/src/templates/sign-in-shared.ts +268 -0
  349. package/src/templates/slice-foundation.ts +70 -15
  350. package/src/test-batches.ts +1 -1
  351. package/src/test-database.ts +1 -1
  352. package/src/test-shards.ts +7 -1
  353. package/src/test-workers.ts +18 -12
  354. package/src/tool-gate-names.ts +34 -0
  355. package/src/upgrade-agents-link.ts +49 -0
  356. package/src/upgrade-bun-floor.ts +225 -0
  357. package/src/upgrade-gate.ts +228 -0
  358. package/src/upgrade-pins.ts +63 -0
  359. package/src/upgrade-plan.ts +214 -0
  360. package/src/upgrade-registry.ts +53 -0
  361. package/src/upgrade-report.ts +139 -0
  362. package/src/upgrade-root.ts +74 -0
  363. package/src/verify-budgets-build.ts +118 -0
  364. package/src/verify-checks.ts +67 -24
  365. package/src/verify-coverage-run.ts +1 -1
  366. package/src/verify-e2e-lane.ts +41 -0
  367. package/src/verify-exercise.ts +64 -0
  368. package/src/verify-floor.ts +41 -0
  369. package/src/verify-merge.ts +19 -4
  370. package/src/verify-role-load.ts +2 -0
  371. package/src/verify-run.ts +28 -8
  372. package/src/verify-shard.ts +12 -2
  373. package/src/verify-skip-ceiling.ts +87 -0
  374. package/src/verify-step.ts +5 -0
  375. package/src/verify-tests.ts +25 -5
  376. package/src/view-drift.ts +55 -0
  377. package/src/worker-bundle.ts +3 -2
  378. package/src/workspace-graph.ts +38 -19
  379. package/src/write-generated.ts +43 -0
  380. package/src/zone-boot.ts +20 -0
  381. package/wiki/Actions.md +342 -0
  382. package/wiki/Admin-Dashboard.md +432 -0
  383. package/wiki/Agents.md +958 -0
  384. package/wiki/Auth.md +195 -0
  385. package/wiki/Bare-VM.md +111 -0
  386. package/wiki/Batching-And-Preloading.md +136 -0
  387. package/wiki/Building-Your-Own-Base.md +203 -0
  388. package/wiki/CI-Parallel-Gate.md +220 -0
  389. package/wiki/CLI-Reference.md +1894 -0
  390. package/wiki/Caching-And-Invalidation.md +255 -0
  391. package/wiki/Client-Data.md +194 -0
  392. package/wiki/Client-Navigation.md +513 -0
  393. package/wiki/Compared.md +93 -0
  394. package/wiki/Configuration.md +868 -0
  395. package/wiki/Contributing.md +12 -0
  396. package/wiki/Deploy-Recipes.md +122 -0
  397. package/wiki/Deployment.md +508 -0
  398. package/wiki/Entities-And-Migrations.md +1116 -0
  399. package/wiki/FAQ.md +175 -0
  400. package/wiki/Feature-Flags.md +65 -0
  401. package/wiki/Getting-Started.md +221 -0
  402. package/wiki/Home.md +160 -0
  403. package/wiki/I18n.md +239 -0
  404. package/wiki/Installation.md +206 -0
  405. package/wiki/Interface-Rules.md +339 -0
  406. package/wiki/Jobs-And-Workflows.md +783 -0
  407. package/wiki/Known-Gaps.md +185 -0
  408. package/wiki/MCP-And-AI.md +753 -0
  409. package/wiki/Mail.md +138 -0
  410. package/wiki/Migrating-An-Existing-App.md +594 -0
  411. package/wiki/Migrating-From-Django.md +54 -0
  412. package/wiki/Migrating-From-Laravel.md +53 -0
  413. package/wiki/Migrating-From-Node-Drizzle.md +53 -0
  414. package/wiki/Migrating-From-Rails.md +53 -0
  415. package/wiki/Migrations-And-Backfills.md +552 -0
  416. package/wiki/Money.md +338 -0
  417. package/wiki/N-Plus-One-Detection.md +197 -0
  418. package/wiki/Notify.md +208 -0
  419. package/wiki/Observability.md +245 -0
  420. package/wiki/Outbound-HTTP.md +109 -0
  421. package/wiki/PWA-And-Offline.md +343 -0
  422. package/wiki/Policies-And-Authz.md +251 -0
  423. package/wiki/Project-Layout.md +150 -0
  424. package/wiki/Queries-And-Live-Queries.md +429 -0
  425. package/wiki/Realtime.md +734 -0
  426. package/wiki/Resource-Management.md +121 -0
  427. package/wiki/Routes-And-Render-Modes.md +521 -0
  428. package/wiki/SEO.md +288 -0
  429. package/wiki/Scheduled-Tasks.md +175 -0
  430. package/wiki/Scraping.md +493 -0
  431. package/wiki/Shapes.md +63 -0
  432. package/wiki/Stability.md +97 -0
  433. package/wiki/Static-Assets.md +166 -0
  434. package/wiki/Storage-And-Uploads.md +404 -0
  435. package/wiki/Testing.md +1314 -0
  436. package/wiki/The-Eight-Primitives.md +247 -0
  437. package/wiki/Theming.md +362 -0
  438. package/wiki/Timezones-And-Dates.md +194 -0
  439. package/wiki/Troubleshooting.md +163 -0
  440. package/wiki/Tutorial-01-First-App.md +289 -0
  441. package/wiki/Tutorial-02-First-Feature.md +339 -0
  442. package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
  443. package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
  444. package/wiki/Tutorial-05-Deploy-Free.md +231 -0
  445. package/wiki/Tutorial-06-Growing-Up.md +158 -0
  446. package/wiki/UI-Components.md +156 -0
  447. package/wiki/Upgrading.md +3667 -0
  448. package/src/stored-object-headers.ts +0 -43
@@ -9,15 +9,14 @@
9
9
 
10
10
  import type { Actor } from '@ultimat3/core';
11
11
  import { actorOf } from '@ultimat3/core';
12
- import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
13
- import {
14
- asCtx,
15
- byteWindow,
16
- contentRangeOf,
17
- parseByteRange,
18
- RANGE_UNSATISFIABLE,
19
- unauthenticated,
12
+ import type {
13
+ CacheHint,
14
+ ObjectSource,
15
+ RequestContext,
16
+ Route,
17
+ UltimateRequest,
20
18
  } from '@ultimat3/http';
19
+ import { asCtx, objectResponse, unauthenticated } from '@ultimat3/http';
21
20
  import type { KnownPermission } from '@ultimat3/policy';
22
21
  import {
23
22
  can,
@@ -27,7 +26,7 @@ import {
27
26
  policyForbidden,
28
27
  reasonOf,
29
28
  } from '@ultimat3/policy';
30
- import type { Storage, StorageObject } from '@ultimat3/storage';
29
+ import type { Storage, StorageDriver } from '@ultimat3/storage';
31
30
  import {
32
31
  assertSafeKey,
33
32
  DEFAULT_SIGNED_URL_BASE,
@@ -37,9 +36,10 @@ import {
37
36
  keyUnshared,
38
37
  objectNotFound,
39
38
  orgMismatch,
39
+ variantRevision,
40
+ variantSourceOf,
40
41
  } from '@ultimat3/storage';
41
42
  import { storageUploadRoute } from './runtime-storage-upload';
42
- import { storedObjectHeaders } from './stored-object-headers';
43
43
 
44
44
  /**
45
45
  * The one capability that gates reading a stored object, on every disk. A permission and not a
@@ -64,9 +64,9 @@ const READ_PERMISSION = STORAGE_READ_PERMISSION as unknown as KnownPermission;
64
64
  * The cache posture of a response that was authorized for ONE actor, named once so the two routes
65
65
  * that serve stored bytes cannot declare different ones — which they did: `/media` answered
66
66
  * `public, max-age=31536000, immutable` for the same object this route marks private, so a CDN held
67
- * one tenant's file under a public key for a year. Revalidation costs a request and no bytes, which
68
- * is the trade an authorized response wants: the bytes never change under a key, but the actor's
69
- * permission to read them can be revoked. `vary` names the two headers that carry an identity.
67
+ * one tenant's file under a public key for a year. A key is not content-addressed either: an
68
+ * overwrite changes the bytes under it, which is what the etag answers. `vary` names the two
69
+ * headers that carry an identity.
70
70
  */
71
71
  export const AUTHORIZED_OBJECT_CACHE: CacheHint = {
72
72
  mode: 'private',
@@ -154,89 +154,49 @@ export function assertReadableKey(
154
154
  }
155
155
 
156
156
  /**
157
- * What the route serves: the object's description and a STREAM of its bytes, never a buffer. The
158
- * route used `get()`, which holds the whole object in memory and refuses past the disk's
159
- * `maxGetBytes` — so a large upload was either a pod's worth of heap or `X_STORAGE_TOO_LARGE`.
157
+ * A stored variant read by its OWN key answers for its source, or the URL outlives an erasure: a key
158
+ * naming a revision is served only while the source still holds that revision. Both routes read
159
+ * through here. An unrevisioned key is an ordinary object, because `logo@2x.png` is a real name.
160
160
  */
161
- export interface ServedObject {
162
- readonly object: StorageObject;
163
- /** Opened only once the route knows it will send a body: a 304 and a 416 open nothing. */
164
- readonly open: () => Promise<ReadableStream<Uint8Array>>;
161
+ export async function assertVariantCurrent(
162
+ driver: StorageDriver,
163
+ disk: string,
164
+ key: string,
165
+ ): Promise<void> {
166
+ const variant = variantSourceOf(key);
167
+ if (variant === undefined) return;
168
+ const source = await driver.stat(variant.source);
169
+ if (source === undefined || variantRevision(source.etag) !== variant.revision) {
170
+ throw objectNotFound(disk, key);
171
+ }
165
172
  }
166
173
 
167
174
  /**
168
175
  * Everything between the decision and the bytes. An unknown disk is the same 404 the foreign-tenant
169
176
  * case answers, rather than `X_STORAGE_DISK_UNKNOWN`, whose cause lists every configured disk name.
177
+ *
178
+ * A STREAM of the bytes, never a buffer: `get()` holds the whole object in memory and refuses past
179
+ * the disk's `maxGetBytes`, so a large upload was a pod's worth of heap or `X_STORAGE_TOO_LARGE`,
180
+ * and a video's `bytes=0-` probe is the whole object, which only a stream holds. A `Range:` is
181
+ * `streamRange`, so a seek late in the file costs its window. The content type is the STORED one and the validator the driver's own content hash: inventing a cache key here
182
+ * would be a second identity for one object.
170
183
  */
171
184
  export async function readStorageObject(
172
185
  storage: Storage,
173
186
  input: StorageReadInput,
174
187
  actor: Actor,
175
- ): Promise<ServedObject> {
188
+ ): Promise<ObjectSource> {
176
189
  const key = assertReadableKey(storage, input.disk, input.key, actor);
177
190
  if (!storage.diskNames.includes(input.disk)) throw objectNotFound(input.disk, key);
178
191
  const driver = storage.disk(input.disk);
192
+ await assertVariantCurrent(driver, input.disk, key);
179
193
  const object = await driver.stat(key);
180
194
  if (object === undefined) throw objectNotFound(input.disk, key);
181
- return { object, open: () => driver.stream(key) };
182
- }
183
-
184
- /** `*`, a weak validator and a comma list all count — the etag is what has to match. */
185
- export function etagMatches(header: string | null, etag: string): boolean {
186
- if (header === null) return false;
187
- if (header.trim() === '*') return true;
188
- return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === etag);
189
- }
190
-
191
- /**
192
- * The content type is the STORED one — the upload gate sniffed those bytes and `put` recorded the
193
- * answer, so an extension in the URL is the only party that can lie. The validator is the driver's
194
- * own content hash (local: sha256 of the bytes, S3: the provider's etag), which is the identity
195
- * storage already keeps for an object; inventing a cache key here would be a second one.
196
- */
197
- export async function storageResponse(
198
- request: UltimateRequest,
199
- served: ServedObject,
200
- ): Promise<Response> {
201
- const { object } = served;
202
- const etag = `"${object.etag}"`;
203
- const headers = new Headers({
204
- 'content-type': object.contentType,
205
- etag,
206
- 'accept-ranges': 'bytes',
207
- // A type a browser would RUN (html, svg, xml) is a download, never a page on this origin.
208
- ...storedObjectHeaders(object.contentType),
209
- });
210
- // Omitted, never invented: a disk whose provider reported no date has none, and a `Last-Modified`
211
- // made up for it is a validator a cache would go on to trust.
212
- const modified = object.lastModified;
213
- if (modified !== undefined) headers.set('last-modified', modified.toUTCString());
214
- // Revalidation costs a request and no bytes, which is the trade an authorized response wants:
215
- // the bytes never change under a key, but the actor's permission to read them can be revoked.
216
- if (etagMatches(request.header('if-none-match'), etag)) {
217
- return new Response(null, { status: 304, headers });
218
- }
219
-
220
- const size = object.size;
221
- const range = parseByteRange(request.header('range'), size);
222
- if (range === RANGE_UNSATISFIABLE) {
223
- headers.set('content-range', `bytes */${size}`);
224
- return new Response(null, { status: 416, headers });
225
- }
226
- // `size` is the stat's: a key's bytes never change under it (an upload mints a new key), so the
227
- // length declared here is the length the stream delivers.
228
- if (range === undefined) {
229
- headers.set('content-length', String(size));
230
- return new Response(await served.open(), { headers });
231
- }
232
- // A disk could serve the window itself (`get(key, { range })`), but `get` buffers and is bounded
233
- // by `maxGetBytes`; a video's `bytes=0-` probe is the whole object, which only a stream holds.
234
- headers.set('content-range', contentRangeOf(range, size));
235
- headers.set('content-length', String(range.end - range.start + 1));
236
- return new Response(byteWindow(await served.open(), range), {
237
- status: 206,
238
- headers,
239
- });
195
+ return {
196
+ object,
197
+ open: async (range) =>
198
+ range === undefined ? driver.stream(key) : driver.streamRange(key, [range.start, range.end]),
199
+ };
240
200
  }
241
201
 
242
202
  /**
@@ -299,7 +259,7 @@ export function storageRoutes(options: StorageRoutesOptions): readonly Route[] {
299
259
  key: request.params['key'] ?? '',
300
260
  };
301
261
  authorizeStorageRead(input, ctx);
302
- return storageResponse(request, await readStorageObject(options.storage, input, ctx.actor));
262
+ return objectResponse(request, await readStorageObject(options.storage, input, ctx.actor));
303
263
  },
304
264
  },
305
265
  // The upload half: the `PUT` every `grantUpload` URL names (#523). One call mounts both, so
@@ -0,0 +1,54 @@
1
+ // Single responsibility: the views half of `diffDeclaredSchema` — what the entities declare as
2
+ // `entity({ view })` against the views the newest migration's snapshot recorded. A view is
3
+ // recorded as the statements `x db gen` wrote for it, so equal statements are the whole agreement.
4
+
5
+ import type { ViewDescription } from '@ultimat3/db';
6
+ import type { SchemaDifference } from './schema-diff';
7
+
8
+ const DECLARED_ONLY = 'is declared by the entities and no migration recorded it';
9
+ const RECORDED_ONLY = 'was recorded by the newest migration and no entity declares it';
10
+ const STATEMENTS_MOVED = 'is declared with a query or index the newest migration did not record';
11
+
12
+ const kindOf = (view: ViewDescription): string =>
13
+ view.materialized === true ? 'materialized' : 'a plain view';
14
+
15
+ function viewDetail(declared: ViewDescription, recorded: ViewDescription): string | undefined {
16
+ if ((declared.materialized === true) !== (recorded.materialized === true)) {
17
+ return `is declared ${kindOf(declared)} and was recorded ${kindOf(recorded)}`;
18
+ }
19
+ const same =
20
+ declared.create.length === recorded.create.length &&
21
+ declared.create.every((statement, at) => statement === recorded.create[at]);
22
+ return same ? undefined : STATEMENTS_MOVED;
23
+ }
24
+
25
+ /**
26
+ * Both directions, by name. Absent `views` is "none recorded": every snapshot written before views
27
+ * existed, which is agreement with an app that declares none. A changed view is `unmigrated`, as a
28
+ * changed column is — the declaration is the intent, and `x db gen` rebuilds every view from it.
29
+ */
30
+ export function diffViews(
31
+ declared: readonly ViewDescription[] = [],
32
+ recorded: readonly ViewDescription[] = [],
33
+ ): readonly SchemaDifference[] {
34
+ const out: SchemaDifference[] = [];
35
+ const byName = new Map(recorded.map((view) => [view.name, view]));
36
+ for (const view of declared) {
37
+ const counterpart = byName.get(view.name);
38
+ const detail = counterpart === undefined ? DECLARED_ONLY : viewDetail(view, counterpart);
39
+ if (detail === undefined) continue;
40
+ out.push({ direction: 'unmigrated', part: 'view', table: view.name, name: view.name, detail });
41
+ }
42
+ const names = new Set(declared.map((view) => view.name));
43
+ for (const view of recorded) {
44
+ if (names.has(view.name)) continue;
45
+ out.push({
46
+ direction: 'undeclared',
47
+ part: 'view',
48
+ table: view.name,
49
+ name: view.name,
50
+ detail: RECORDED_ONLY,
51
+ });
52
+ }
53
+ return out;
54
+ }
@@ -17,6 +17,7 @@ import type {
17
17
  TableDescription,
18
18
  } from '@ultimat3/db';
19
19
  import { indexMethodOf } from '@ultimat3/db';
20
+ import { diffViews } from './schema-diff-views';
20
21
 
21
22
  /**
22
23
  * Which side holds a declaration the other does not, and they are two different repairs.
@@ -25,8 +26,11 @@ import { indexMethodOf } from '@ultimat3/db';
25
26
  */
26
27
  export type SchemaDirection = 'unmigrated' | 'undeclared';
27
28
 
28
- /** The part of a table a difference is about. Rendered into the cause verbatim. */
29
- export type SchemaPart = 'table' | 'column' | 'index' | 'foreign key' | 'check';
29
+ /**
30
+ * The part of a table a difference is about, or a whole view (`table` is then the view's name).
31
+ * Rendered into the cause verbatim.
32
+ */
33
+ export type SchemaPart = 'table' | 'view' | 'column' | 'index' | 'foreign key' | 'check';
30
34
 
31
35
  export interface SchemaDifference {
32
36
  readonly direction: SchemaDirection;
@@ -73,6 +77,12 @@ function columnDetail(
73
77
  recorded.generated ?? 'not generated'
74
78
  }`;
75
79
  }
80
+ // `true` or absent. Listed under `retired` with no marker yet is the app's half alone.
81
+ if ((declared.retired === true) !== (recorded.retired === true)) {
82
+ return declared.retired === true
83
+ ? 'is declared retired and no migration recorded it'
84
+ : 'is declared again and was recorded retired';
85
+ }
76
86
  return undefined;
77
87
  }
78
88
 
@@ -240,7 +250,14 @@ function compareTable(declared: TableDescription, recorded: TableDescription): S
240
250
  return out;
241
251
  }
242
252
 
243
- const PART_ORDER: readonly SchemaPart[] = ['table', 'column', 'index', 'foreign key', 'check'];
253
+ const PART_ORDER: readonly SchemaPart[] = [
254
+ 'table',
255
+ 'view',
256
+ 'column',
257
+ 'index',
258
+ 'foreign key',
259
+ 'check',
260
+ ];
244
261
 
245
262
  /**
246
263
  * Pure and total: the same two snapshots always produce the same ordered report, so a diff of two
@@ -281,6 +298,7 @@ export function diffDeclaredSchema(
281
298
  detail: RECORDED_ONLY,
282
299
  });
283
300
  }
301
+ out.push(...diffViews(declared.views, recorded.views));
284
302
  out.sort((a, b) => {
285
303
  if (a.table !== b.table) return a.table < b.table ? -1 : 1;
286
304
  if (a.part !== b.part) return PART_ORDER.indexOf(a.part) - PART_ORDER.indexOf(b.part);
@@ -15,7 +15,13 @@ import { existsSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { ERROR_DOCS_URL } from '@ultimat3/core';
17
17
  import type { EntityDescriptionLike, UnrenderedDeclaration } from '@ultimat3/db';
18
- import { declaredSchema, shellInertIdentifier, snapshotOf, unrenderedOf } from '@ultimat3/db';
18
+ import {
19
+ declaredSchema,
20
+ recordRetired,
21
+ shellInertIdentifier,
22
+ snapshotOf,
23
+ unrenderedOf,
24
+ } from '@ultimat3/db';
19
25
  import { describeEntities } from '@ultimat3/entity';
20
26
  import { loadApp } from './app-load';
21
27
  import { checkSourceDrift, DB_PACKAGE } from './drift';
@@ -26,6 +32,7 @@ import {
26
32
  } from './drift-replica-identity';
27
33
  import { MIGRATIONS_DIR, readMigrations } from './migrations';
28
34
  import type { Finding } from './output';
35
+ import { appRowSecurity, type RowSecurityWanted, rowSecurityFindings } from './row-security-wanted';
29
36
  import { diffDeclaredSchema, type SchemaDifference } from './schema-diff';
30
37
 
31
38
  /**
@@ -43,7 +50,7 @@ export type DeclaredEntities = (
43
50
  * one of these tables was dropped" — a DROP per table, handed out as the fix, for a syntax error in
44
51
  * one file. `loadApp`'s findings are reported by the steps that own them.
45
52
  */
46
- const appEntities: DeclaredEntities = async (root) => {
53
+ export const appEntities: DeclaredEntities = async (root) => {
47
54
  const app = await loadApp(root);
48
55
  if (app.findings.length > 0) return undefined;
49
56
  return describeEntities();
@@ -111,10 +118,10 @@ function repairFix(
111
118
  * never get what the app declares; `undeclared` means the database holds what nothing declares.
112
119
  * One "drift" verdict over both teaches a reader neither.
113
120
  */
114
- /** A table difference names the table once: `table "customers" on table "customers"` did not. */
121
+ /** A table or view difference names it once: `table "customers" on table "customers"` did not. */
115
122
  export const schemaDifferenceCause = (difference: SchemaDifference): string =>
116
- difference.part === 'table'
117
- ? `table "${difference.table}" ${difference.detail}`
123
+ difference.part === 'table' || difference.part === 'view'
124
+ ? `${difference.part} "${difference.table}" ${difference.detail}`
118
125
  : `${difference.part} "${difference.name}" on table "${difference.table}" ${difference.detail}`;
119
126
 
120
127
  function findingFor(difference: SchemaDifference, repair: Repair): Finding {
@@ -139,6 +146,7 @@ export async function checkSnapshotDrift(
139
146
  root: string,
140
147
  declared: DeclaredEntities = appEntities,
141
148
  identity: ReplicaIdentityWanted = appReplicaIdentity,
149
+ rowSecurity: RowSecurityWanted = appRowSecurity,
142
150
  ): Promise<readonly Finding[]> {
143
151
  if (!existsSync(join(root, DB_PACKAGE))) return [];
144
152
  const entities = await declared(root);
@@ -151,12 +159,22 @@ export async function checkSnapshotDrift(
151
159
  // states for the same call: a short one makes every subscribed name in the missing module a typo.
152
160
  const wanted = wantedReplicaIdentity(identity, new Set(entities.map((entity) => entity.table)));
153
161
  if ('finding' in wanted) return [wanted.finding];
154
- const differences = diffDeclaredSchema(snapshotOf(entities, wanted.tables), recorded);
155
- if (differences.length === 0) return [];
162
+ // `recordRetired`, the step `x db gen` takes after `snapshotOf`: a retired column stays on the
163
+ // record though no entity declares it, and that is the design rather than a difference.
164
+ // Its own code, ahead of the generic diff: a flipped flag moves no entity and no source hash.
165
+ const policies = rowSecurityFindings(entities, recorded, await rowSecurity(root));
166
+ const differences = diffDeclaredSchema(
167
+ recordRetired(snapshotOf(entities, wanted.tables), entities, recorded),
168
+ recorded,
169
+ );
170
+ if (differences.length === 0) return policies;
156
171
  // `recorded` and not the entities alone: whether an `assert` is a LOSS depends on whether a
157
172
  // migration recorded a CHECK for it, which only the sidecar knows.
158
173
  const unrendered = unrenderedOf(entities, recorded);
159
- return differences.map((difference) => findingFor(difference, repairFix(unrendered, difference)));
174
+ return [
175
+ ...policies,
176
+ ...differences.map((difference) => findingFor(difference, repairFix(unrendered, difference))),
177
+ ];
160
178
  }
161
179
 
162
180
  /**
@@ -175,8 +193,9 @@ export async function checkMigrationDrift(
175
193
  declared: DeclaredEntities = appEntities,
176
194
  hashDrift: (root: string) => Promise<readonly Finding[]> = checkSourceDrift,
177
195
  identity: ReplicaIdentityWanted = appReplicaIdentity,
196
+ rowSecurity: RowSecurityWanted = appRowSecurity,
178
197
  ): Promise<readonly Finding[]> {
179
- const snapshot = await checkSnapshotDrift(root, declared, identity);
198
+ const snapshot = await checkSnapshotDrift(root, declared, identity, rowSecurity);
180
199
  if (snapshot.length > 0) return snapshot;
181
200
  return hashDrift(root);
182
201
  }
@@ -1,7 +1,7 @@
1
- // The `drift` step's fourth rail: is the committed schema dump what the migrations produce, and
2
- // does a database loaded from it come back the same? The other three rails read files and open
1
+ // The `drift` step's fourth rail: is the committed schema dump what the migrations produce, does
2
+ // it hold what their snapshots record, and does a database loaded from it come back the same? The other three rails read files and open
3
3
  // nothing; this one replays the migrations on a scratch embedded database, because "what the
4
- // migrations produce" is a fact only a database can state.
4
+ // migrations produce" is a fact only a database can state — and so is what a view outputs.
5
5
 
6
6
  // why: Bun has no directory probe — `Bun.file().exists()` answers about a file.
7
7
  import { existsSync } from 'node:fs';
@@ -9,7 +9,8 @@ import { existsSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { ERROR_DOCS_URL } from '@ultimat3/core';
11
11
  import type { Migration } from '@ultimat3/db';
12
- import type { SchemaDumpDifference } from '@ultimat3/db/schema-dump';
12
+ import { ungeneratableStatements } from '@ultimat3/db';
13
+ import type { SchemaDumpDifference, SchemaDumpFile } from '@ultimat3/db/schema-dump';
13
14
  import { compareSchemaDump, schemaDumpDrift } from '@ultimat3/db/schema-dump';
14
15
  import {
15
16
  hasSchemaDump,
@@ -20,9 +21,12 @@ import {
20
21
  } from './db-schema-dump';
21
22
  import { dumpNeverGenerated, replayFailure } from './db-schema-refresh';
22
23
  import { DB_PACKAGE } from './drift';
23
- import { readMigrations } from './migrations';
24
+ import { MIGRATIONS_DIR, readMigrations } from './migrations';
24
25
  import type { Finding } from './output';
25
26
  import { findingFrom } from './output';
27
+ import { appEntities, type DeclaredEntities } from './schema-drift';
28
+ import { quoteArg } from './shell-quote';
29
+ import { viewColumnFindings } from './view-drift';
26
30
 
27
31
  /** Injected so this module's rules are testable without booting a database per case. */
28
32
  export type SchemaReplay = (migrations: readonly Migration[]) => Promise<ReplayedSchema>;
@@ -33,6 +37,77 @@ const findingFor = (difference: SchemaDumpDifference): Finding => ({
33
37
  at: `${SCHEMA_DUMP_DIR}/${difference.path}`,
34
38
  });
35
39
 
40
+ /** One ` "name" type…,` line of a generated `create table`; a `constraint` line opens with no quote. */
41
+ const DUMP_COLUMN = /^ {2}"((?:[^"]|"")+)" /gm;
42
+
43
+ const dumpColumns = (sql: string): readonly string[] =>
44
+ [...sql.matchAll(DUMP_COLUMN)].map((match) => (match[1] ?? '').replaceAll('""', '"'));
45
+
46
+ /** The tables a statement alters or creates, as written: optionally schema-qualified, quoted or not. */
47
+ const TABLE_STATEMENT =
48
+ /\b(?:alter|create|drop)\s+(?:unlogged\s+|temp(?:orary)?\s+)?table\s+(?:if\s+(?:not\s+)?exists\s+)?(?:only\s+)?(?:(?:"[^"]+"|[\w$]+)\.)?(?:"([^"]+)"|([\w$]+))/gi;
49
+
50
+ /** As Postgres names it: an unquoted identifier folds to lower case, a quoted one is kept. */
51
+ const tablesNamedBy = (sql: string): readonly string[] =>
52
+ [...sql.matchAll(TABLE_STATEMENT)].flatMap((match) => {
53
+ const name = match[1] ?? match[2]?.toLowerCase();
54
+ return name === undefined ? [] : [name];
55
+ });
56
+
57
+ /**
58
+ * Tables whose columns a snapshot cannot speak for: `x db gen` writes a snapshot from the entities,
59
+ * so hand-written SQL is on no snapshot. That is every migration holding statements it could not have
60
+ * generated, and every one newer than the newest snapshot, which is a file somebody wrote by hand.
61
+ */
62
+ const handWrittenTables = (migrations: readonly Migration[], newest: number): Set<string> => {
63
+ const tables = new Set<string>();
64
+ migrations.forEach((migration, index) => {
65
+ const sql = index > newest ? migration.up : ungeneratableStatements(migration.up).join(';\n');
66
+ for (const table of tablesNamedBy(sql)) tables.add(table);
67
+ });
68
+ return tables;
69
+ };
70
+
71
+ /**
72
+ * The rail the replay below cannot be: the dump IS that replay's output, so an edited migration
73
+ * regenerates into it and both sides agree. The snapshot was written from the entities and did not
74
+ * move. No database, and read beside the replay's findings rather than ahead of them: an edited
75
+ * migration and a stale file are two repairs.
76
+ */
77
+ function dumpColumnFindings(
78
+ migrations: readonly Migration[],
79
+ committed: readonly SchemaDumpFile[],
80
+ ): readonly Finding[] {
81
+ const newest = migrations.findLastIndex((migration) => migration.snapshot !== undefined);
82
+ const snapshot = migrations[newest]?.snapshot;
83
+ if (snapshot === undefined) return [];
84
+ const exempt = handWrittenTables(migrations, newest);
85
+ const files = new Map(committed.map((file) => [file.path, file.content]));
86
+ return snapshot.tables.flatMap((table) => {
87
+ const path = `04_tables/${table.name}.sql`;
88
+ const held = files.get(path);
89
+ if (held === undefined || exempt.has(table.name)) return [];
90
+ const dumped = dumpColumns(held);
91
+ const recorded = table.columns.map((column) => column.name);
92
+ const extra = dumped.filter((name) => !recorded.includes(name));
93
+ const lost = recorded.filter((name) => !dumped.includes(name));
94
+ if (extra.length === 0 && lost.length === 0) return [];
95
+ const said = [
96
+ ...extra.map((name) => `"${name}" is in the dump`),
97
+ ...lost.map((name) => `"${name}" is only in the snapshot`),
98
+ ].join(', ');
99
+ return [
100
+ {
101
+ code: 'X_DB_DRIFT',
102
+ cause: `the dump holds a column no migration's snapshot records, in table "${table.name}": ${said}`,
103
+ fix: `git checkout -- ${quoteArg(MIGRATIONS_DIR)} ${quoteArg(SCHEMA_DUMP_DIR)} # an applied migration was edited: restore it. Meant it: declare the column on its entity, then x db gen "<change>"`,
104
+ docs: ERROR_DOCS_URL,
105
+ at: `${SCHEMA_DUMP_DIR}/${path}`,
106
+ },
107
+ ];
108
+ });
109
+ }
110
+
36
111
  /**
37
112
  * Empty result = the dump is the migrations' own.
38
113
  *
@@ -49,16 +124,25 @@ export async function checkSchemaDump(
49
124
  root: string,
50
125
  env: Readonly<Record<string, string | undefined>> = Bun.env,
51
126
  replay: SchemaReplay = replayFor(root, env, true),
127
+ declared: DeclaredEntities = appEntities,
52
128
  ): Promise<readonly Finding[]> {
53
129
  if (!existsSync(join(root, DB_PACKAGE))) return [];
54
130
  const migrations = await readMigrations(root);
55
131
  if (!hasSchemaDump(root)) return migrations.length === 0 ? [] : [dumpNeverGenerated()];
56
132
  const committed = await readSchemaDump(root);
133
+ const columns = dumpColumnFindings(migrations, committed);
57
134
  let replayed: ReplayedSchema;
58
135
  try {
59
136
  replayed = await replay(migrations);
60
137
  } catch (error) {
61
- return [replayFailure(error)];
138
+ return [...columns, replayFailure(error)];
62
139
  }
63
- return [...compareSchemaDump(committed, replayed.files), ...replayed.reload].map(findingFor);
140
+ // Only a replay that created a view loads the app: what a view outputs is the one column list
141
+ // no snapshot records, and an app with no view owes this rail nothing.
142
+ const entities = replayed.catalog.views.length === 0 ? undefined : await declared(root);
143
+ return [
144
+ ...columns,
145
+ ...[...compareSchemaDump(committed, replayed.files), ...replayed.reload].map(findingFor),
146
+ ...(entities === undefined ? [] : viewColumnFindings(entities, replayed.catalog)),
147
+ ];
64
148
  }
package/src/script-csp.ts CHANGED
@@ -16,7 +16,7 @@ import { HYDRATE_RUNTIME_BODIES, STREAM_REVEAL_BODIES } from '@ultimat3/render';
16
16
  * which `theme-boot.ts` derives from the same string it inlines.
17
17
  */
18
18
  export function inlineScriptSources(extra: readonly string[] = []): readonly string[] {
19
- // The stream reveal too: a `render: 'stream'` page swaps each Suspense boundary in with a
19
+ // The stream reveal too: a `render: 'stream'` page swaps each `<Hole>` in with a
20
20
  // constant inline `$X()` call, and unhashed it is blocked under the enforced policy.
21
21
  const bodies = [...HYDRATE_RUNTIME_BODIES, ...STREAM_REVEAL_BODIES];
22
22
  return [...new Set([...bodies.map(cspHashSource), ...extra])].sort();
@@ -0,0 +1,26 @@
1
+ // Single responsibility: the convention `og.capture` rests on, as a gate finding — a declared card
2
+ // path is a `site/` route, or the build has no page to photograph. `x verify`'s `seo` step asks it
3
+ // of every route whose meta the scan resolved; the build asks it again of the filled paths.
4
+
5
+ import { routeEntries } from '@ultimat3/render';
6
+ import type { RouteRecord } from '@ultimat3/seo';
7
+ import type { Finding } from './output';
8
+ import { findingFrom } from './output';
9
+ import { CardRouteUnknownError } from './static-capture-errors';
10
+
11
+ /** True when some `site/` route's pattern answers `path` — a static or a prerendered dynamic one. */
12
+ const isSiteRoute = (path: string): boolean =>
13
+ routeEntries().some((entry) => entry.surface === 'site' && entry.pattern.regex.test(path));
14
+
15
+ export function cardRouteFindings(records: readonly RouteRecord[]): readonly Finding[] {
16
+ const findings: Finding[] = [];
17
+ for (const record of records) {
18
+ const capture = record.meta?.og?.capture;
19
+ if (capture === undefined || isSiteRoute(capture)) continue;
20
+ findings.push({
21
+ ...findingFrom(new CardRouteUnknownError([capture], record.file)),
22
+ at: record.file,
23
+ });
24
+ }
25
+ return findings;
26
+ }
package/src/seo-routes.ts CHANGED
@@ -1,5 +1,5 @@
1
- // `GET /robots.txt`, `GET /sitemap.xml` and its parts (`/sitemaps/<n>.xml`) from a running web
2
- // role — the files the static export writes, for a deploy that serves its pages from a container.
1
+ // `GET /robots.txt`, `GET /llms.txt`, `GET /sitemap.xml` and its parts (`/sitemaps/<n>.xml`) from a
2
+ // running web role — the files the static export writes, for a deploy that serves from a container.
3
3
  // Mounted by `x dev` and `runRole` alike, before the app's pages, for `style-routes.ts`' reason.
4
4
 
5
5
  import {
@@ -11,8 +11,8 @@ import {
11
11
  tryResolveEnvironment,
12
12
  } from '@ultimat3/core';
13
13
  import type { Route, UltimateRequest } from '@ultimat3/http';
14
- import { applyCacheHeaders, jsonResponse, NO_STORE } from '@ultimat3/http';
15
- import { SITEMAP_PARTS_DIR } from '@ultimat3/seo';
14
+ import { applyCacheHeaders, jsonResponse, NO_STORE, textResponse } from '@ultimat3/http';
15
+ import { LLMS_PATH, SITEMAP_PARTS_DIR } from '@ultimat3/seo';
16
16
  import { quoteArg } from './shell-quote';
17
17
  import { NO_SITE_SETTINGS, publicOrigin, type SiteSettings } from './site-config';
18
18
  import type { SiteSeo } from './site-seo';
@@ -74,7 +74,7 @@ interface Memo {
74
74
  }
75
75
 
76
76
  /** A placeholder for the one statement between building a first memo and starting its enumeration. */
77
- const NO_ANSWER: SiteSeo = { robots: '', sitemaps: [] };
77
+ const NO_ANSWER: SiteSeo = { robots: '', sitemaps: [], llms: '' };
78
78
 
79
79
  export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
80
80
  const environment: Environment =
@@ -87,6 +87,7 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
87
87
  robotsGroups: options.site?.robotsGroups ?? [],
88
88
  ...(options.site === undefined ? {} : { sitemap: options.site.sitemap }),
89
89
  ...(options.root === undefined ? {} : { root: options.root }),
90
+ ...(options.site?.llms === undefined ? {} : { llms: options.site.llms }),
90
91
  });
91
92
  // One answer per origin. A FIRST enumeration is single-flight and awaited; failed, it is dropped,
92
93
  // so the next request asks again rather than serving the failure for an hour.
@@ -189,12 +190,24 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
189
190
  meta: { name: 'seo.robots', auth: 'public', tags: ['seo'] },
190
191
  handler: async (request: UltimateRequest): Promise<Response> =>
191
192
  applyCacheHeaders(
192
- new Response((await answer(request)).robots, {
193
+ textResponse((await answer(request)).robots, {
193
194
  headers: { 'content-type': 'text/plain; charset=utf-8' },
194
195
  }),
195
196
  SEO_CACHE,
196
197
  ),
197
198
  },
199
+ {
200
+ method: 'GET',
201
+ path: LLMS_PATH,
202
+ meta: { name: 'seo.llms', auth: 'public', tags: ['seo'] },
203
+ handler: async (request: UltimateRequest): Promise<Response> =>
204
+ applyCacheHeaders(
205
+ textResponse((await answer(request)).llms, {
206
+ headers: { 'content-type': 'text/markdown; charset=utf-8' },
207
+ }),
208
+ SEO_CACHE,
209
+ ),
210
+ },
198
211
  {
199
212
  method: 'GET',
200
213
  path: SITEMAP_PATH,
@@ -202,7 +215,8 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
202
215
  // The first file is `/sitemap.xml` in both shapes: the whole urlset, or the index of parts.
203
216
  handler: async (request: UltimateRequest): Promise<Response> =>
204
217
  applyCacheHeaders(
205
- new Response((await answer(request)).sitemaps[0]?.xml ?? '', {
218
+ // `textResponse` for the length it declares: `compress` reads nothing undeclared.
219
+ textResponse((await answer(request)).sitemaps[0]?.xml ?? '', {
206
220
  headers: { 'content-type': 'application/xml; charset=utf-8' },
207
221
  }),
208
222
  SEO_CACHE,
@@ -219,7 +233,7 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
219
233
  const part = seo.sitemaps.find((file) => file.path === request.pathname);
220
234
  if (part !== undefined && part.path !== SITEMAP_PATH) {
221
235
  return applyCacheHeaders(
222
- new Response(part.xml, {
236
+ textResponse(part.xml, {
223
237
  headers: { 'content-type': 'application/xml; charset=utf-8' },
224
238
  }),
225
239
  SEO_CACHE,