@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.
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 +13 -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 +28 -8
  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 +42 -138
  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 +17 -27
  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 +1313 -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,8 +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 { asCtx, unauthenticated } from '@ultimat3/http';
12
+ import type {
13
+ CacheHint,
14
+ ObjectSource,
15
+ RequestContext,
16
+ Route,
17
+ UltimateRequest,
18
+ } from '@ultimat3/http';
19
+ import { asCtx, objectResponse, unauthenticated } from '@ultimat3/http';
14
20
  import type { KnownPermission } from '@ultimat3/policy';
15
21
  import {
16
22
  can,
@@ -20,7 +26,7 @@ import {
20
26
  policyForbidden,
21
27
  reasonOf,
22
28
  } from '@ultimat3/policy';
23
- import type { Storage, StorageObject } from '@ultimat3/storage';
29
+ import type { Storage, StorageDriver } from '@ultimat3/storage';
24
30
  import {
25
31
  assertSafeKey,
26
32
  DEFAULT_SIGNED_URL_BASE,
@@ -30,9 +36,10 @@ import {
30
36
  keyUnshared,
31
37
  objectNotFound,
32
38
  orgMismatch,
39
+ variantRevision,
40
+ variantSourceOf,
33
41
  } from '@ultimat3/storage';
34
42
  import { storageUploadRoute } from './runtime-storage-upload';
35
- import { storedObjectHeaders } from './stored-object-headers';
36
43
 
37
44
  /**
38
45
  * The one capability that gates reading a stored object, on every disk. A permission and not a
@@ -57,9 +64,9 @@ const READ_PERMISSION = STORAGE_READ_PERMISSION as unknown as KnownPermission;
57
64
  * The cache posture of a response that was authorized for ONE actor, named once so the two routes
58
65
  * that serve stored bytes cannot declare different ones — which they did: `/media` answered
59
66
  * `public, max-age=31536000, immutable` for the same object this route marks private, so a CDN held
60
- * one tenant's file under a public key for a year. Revalidation costs a request and no bytes, which
61
- * is the trade an authorized response wants: the bytes never change under a key, but the actor's
62
- * 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.
63
70
  */
64
71
  export const AUTHORIZED_OBJECT_CACHE: CacheHint = {
65
72
  mode: 'private',
@@ -147,152 +154,49 @@ export function assertReadableKey(
147
154
  }
148
155
 
149
156
  /**
150
- * What the route serves: the object's description and a STREAM of its bytes, never a buffer. The
151
- * route used `get()`, which holds the whole object in memory and refuses past the disk's
152
- * `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.
153
160
  */
154
- export interface ServedObject {
155
- readonly object: StorageObject;
156
- /** Opened only once the route knows it will send a body: a 304 and a 416 open nothing. */
157
- 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
+ }
158
172
  }
159
173
 
160
174
  /**
161
175
  * Everything between the decision and the bytes. An unknown disk is the same 404 the foreign-tenant
162
176
  * case answers, rather than `X_STORAGE_DISK_UNKNOWN`, whose cause lists every configured disk name.
177
+ *
178
+ * A STREAM of the bytes, never a buffer: `get()` holds the whole object in memory and refuses past
179
+ * the disk's `maxGetBytes`, so a large upload was a pod's worth of heap or `X_STORAGE_TOO_LARGE`,
180
+ * and a video's `bytes=0-` probe is the whole object, which only a stream holds. A `Range:` is
181
+ * `streamRange`, so a seek late in the file costs its window. The content type is the STORED one and the validator the driver's own content hash: inventing a cache key here
182
+ * would be a second identity for one object.
163
183
  */
164
184
  export async function readStorageObject(
165
185
  storage: Storage,
166
186
  input: StorageReadInput,
167
187
  actor: Actor,
168
- ): Promise<ServedObject> {
188
+ ): Promise<ObjectSource> {
169
189
  const key = assertReadableKey(storage, input.disk, input.key, actor);
170
190
  if (!storage.diskNames.includes(input.disk)) throw objectNotFound(input.disk, key);
171
191
  const driver = storage.disk(input.disk);
192
+ await assertVariantCurrent(driver, input.disk, key);
172
193
  const object = await driver.stat(key);
173
194
  if (object === undefined) throw objectNotFound(input.disk, key);
174
- return { object, open: () => driver.stream(key) };
175
- }
176
-
177
- /** Inclusive, as `Range` and `Content-Range` both are. */
178
- export interface ByteRange {
179
- readonly start: number;
180
- readonly end: number;
181
- }
182
-
183
- const UNSATISFIABLE = 'unsatisfiable';
184
-
185
- /**
186
- * One range or none. A multi-range or malformed header is ignored rather than refused — RFC 9110
187
- * lets a server answer the whole representation, and `multipart/byteranges` is a body format no
188
- * caller of this route asks for. Ranges exist here for one reason: Safari will not play a `<video>`
189
- * from a source that answers 200 to a `Range` probe, so an uploaded video would be a dead player.
190
- */
191
- export function parseByteRange(
192
- header: string | null,
193
- size: number,
194
- ): ByteRange | typeof UNSATISFIABLE | undefined {
195
- if (header === null) return undefined;
196
- const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
197
- if (match === null) return undefined;
198
- const from = match[1] ?? '';
199
- const to = match[2] ?? '';
200
- if (from === '' && to === '') return undefined;
201
- if (from === '') {
202
- const wanted = Number(to);
203
- // A suffix longer than the object is the whole object, not a refusal — but there is no whole
204
- // object to fall back to at `size === 0`, and the arithmetic below answers `{ start: 0, end:
205
- // -1 }`, which the route renders as `content-range: bytes 0--1/0` with status 206. RFC 9110
206
- // requires 416. The non-suffix branch already gets this right through `start >= size`; this
207
- // one had no equivalent test.
208
- return size === 0 || wanted === 0
209
- ? UNSATISFIABLE
210
- : { start: Math.max(size - wanted, 0), end: size - 1 };
211
- }
212
- const start = Number(from);
213
- const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
214
- return start >= size || end < start ? UNSATISFIABLE : { start, end };
215
- }
216
-
217
- /** `*`, a weak validator and a comma list all count — the etag is what has to match. */
218
- export function etagMatches(header: string | null, etag: string): boolean {
219
- if (header === null) return false;
220
- if (header.trim() === '*') return true;
221
- return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === etag);
222
- }
223
-
224
- /**
225
- * The inclusive window `[start, end]` of a stream, as a stream. A disk streams from byte 0 (the
226
- * driver contract has no ranged read), so the bytes before `start` are read and dropped: bounded
227
- * memory, at the cost of reading the prefix — the trade a `Range` probe on a video wants over
228
- * buffering the whole object.
229
- */
230
- function windowOf(
231
- source: ReadableStream<Uint8Array>,
232
- start: number,
233
- end: number,
234
- ): ReadableStream<Uint8Array> {
235
- let offset = 0;
236
- return source.pipeThrough(
237
- new TransformStream<Uint8Array, Uint8Array>({
238
- transform(chunk, controller) {
239
- const from = Math.max(start - offset, 0);
240
- const to = Math.min(end + 1 - offset, chunk.byteLength);
241
- offset += chunk.byteLength;
242
- if (to > from) controller.enqueue(chunk.subarray(from, to));
243
- if (offset > end) controller.terminate();
244
- },
245
- }),
246
- );
247
- }
248
-
249
- /**
250
- * The content type is the STORED one — the upload gate sniffed those bytes and `put` recorded the
251
- * answer, so an extension in the URL is the only party that can lie. The validator is the driver's
252
- * own content hash (local: sha256 of the bytes, S3: the provider's etag), which is the identity
253
- * storage already keeps for an object; inventing a cache key here would be a second one.
254
- */
255
- export async function storageResponse(
256
- request: UltimateRequest,
257
- served: ServedObject,
258
- ): Promise<Response> {
259
- const { object } = served;
260
- const etag = `"${object.etag}"`;
261
- const headers = new Headers({
262
- 'content-type': object.contentType,
263
- etag,
264
- 'accept-ranges': 'bytes',
265
- // A type a browser would RUN (html, svg, xml) is a download, never a page on this origin.
266
- ...storedObjectHeaders(object.contentType),
267
- });
268
- // Omitted, never invented: a disk whose provider reported no date has none, and a `Last-Modified`
269
- // made up for it is a validator a cache would go on to trust.
270
- const modified = object.lastModified;
271
- if (modified !== undefined) headers.set('last-modified', modified.toUTCString());
272
- // Revalidation costs a request and no bytes, which is the trade an authorized response wants:
273
- // the bytes never change under a key, but the actor's permission to read them can be revoked.
274
- if (etagMatches(request.header('if-none-match'), etag)) {
275
- return new Response(null, { status: 304, headers });
276
- }
277
-
278
- const size = object.size;
279
- const range = parseByteRange(request.header('range'), size);
280
- if (range === UNSATISFIABLE) {
281
- headers.set('content-range', `bytes */${size}`);
282
- return new Response(null, { status: 416, headers });
283
- }
284
- // `size` is the stat's: a key's bytes never change under it (an upload mints a new key), so the
285
- // length declared here is the length the stream delivers.
286
- if (range === undefined) {
287
- headers.set('content-length', String(size));
288
- return new Response(await served.open(), { headers });
289
- }
290
- headers.set('content-range', `bytes ${range.start}-${range.end}/${size}`);
291
- headers.set('content-length', String(range.end - range.start + 1));
292
- return new Response(windowOf(await served.open(), range.start, range.end), {
293
- status: 206,
294
- headers,
295
- });
195
+ return {
196
+ object,
197
+ open: async (range) =>
198
+ range === undefined ? driver.stream(key) : driver.streamRange(key, [range.start, range.end]),
199
+ };
296
200
  }
297
201
 
298
202
  /**
@@ -355,7 +259,7 @@ export function storageRoutes(options: StorageRoutesOptions): readonly Route[] {
355
259
  key: request.params['key'] ?? '',
356
260
  };
357
261
  authorizeStorageRead(input, ctx);
358
- return storageResponse(request, await readStorageObject(options.storage, input, ctx.actor));
262
+ return objectResponse(request, await readStorageObject(options.storage, input, ctx.actor));
359
263
  },
360
264
  },
361
265
  // The upload half: the `PUT` every `grantUpload` URL names (#523). One call mounts both, so
@@ -0,0 +1,54 @@
1
+ // Single responsibility: the views half of `diffDeclaredSchema` — what the entities declare as
2
+ // `entity({ view })` against the views the newest migration's snapshot recorded. A view is
3
+ // recorded as the statements `x db gen` wrote for it, so equal statements are the whole agreement.
4
+
5
+ import type { ViewDescription } from '@ultimat3/db';
6
+ import type { SchemaDifference } from './schema-diff';
7
+
8
+ const DECLARED_ONLY = 'is declared by the entities and no migration recorded it';
9
+ const RECORDED_ONLY = 'was recorded by the newest migration and no entity declares it';
10
+ const STATEMENTS_MOVED = 'is declared with a query or index the newest migration did not record';
11
+
12
+ const kindOf = (view: ViewDescription): string =>
13
+ view.materialized === true ? 'materialized' : 'a plain view';
14
+
15
+ function viewDetail(declared: ViewDescription, recorded: ViewDescription): string | undefined {
16
+ if ((declared.materialized === true) !== (recorded.materialized === true)) {
17
+ return `is declared ${kindOf(declared)} and was recorded ${kindOf(recorded)}`;
18
+ }
19
+ const same =
20
+ declared.create.length === recorded.create.length &&
21
+ declared.create.every((statement, at) => statement === recorded.create[at]);
22
+ return same ? undefined : STATEMENTS_MOVED;
23
+ }
24
+
25
+ /**
26
+ * Both directions, by name. Absent `views` is "none recorded": every snapshot written before views
27
+ * existed, which is agreement with an app that declares none. A changed view is `unmigrated`, as a
28
+ * changed column is — the declaration is the intent, and `x db gen` rebuilds every view from it.
29
+ */
30
+ export function diffViews(
31
+ declared: readonly ViewDescription[] = [],
32
+ recorded: readonly ViewDescription[] = [],
33
+ ): readonly SchemaDifference[] {
34
+ const out: SchemaDifference[] = [];
35
+ const byName = new Map(recorded.map((view) => [view.name, view]));
36
+ for (const view of declared) {
37
+ const counterpart = byName.get(view.name);
38
+ const detail = counterpart === undefined ? DECLARED_ONLY : viewDetail(view, counterpart);
39
+ if (detail === undefined) continue;
40
+ out.push({ direction: 'unmigrated', part: 'view', table: view.name, name: view.name, detail });
41
+ }
42
+ const names = new Set(declared.map((view) => view.name));
43
+ for (const view of recorded) {
44
+ if (names.has(view.name)) continue;
45
+ out.push({
46
+ direction: 'undeclared',
47
+ part: 'view',
48
+ table: view.name,
49
+ name: view.name,
50
+ detail: RECORDED_ONLY,
51
+ });
52
+ }
53
+ return out;
54
+ }
@@ -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
+ }