@ultimat3/cli 29.4.0 → 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 +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 +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
@@ -0,0 +1,171 @@
1
+ // Earlier builds' hashed files, kept readable across a deploy (`assets.retain`): a web pod
2
+ // publishes what it serves to a shared disk before it reports ready. This file is the publish and
3
+ // the key layout; the sweep is `retained-assets-sweep.ts`, the read `retained-assets-read.ts`.
4
+
5
+ import type { Clock } from '@ultimat3/core';
6
+ import {
7
+ finiteCount,
8
+ isUltimateError,
9
+ logger,
10
+ renderCauseValue,
11
+ renderThrowable,
12
+ systemClock,
13
+ UltimateError,
14
+ } from '@ultimat3/core';
15
+ import type { BuildManifest, RetainedScope } from './retained-assets-layout';
16
+ import { inBatches, manifestKey, retainedFileKey } from './retained-assets-layout';
17
+ import { sweepRetained } from './retained-assets-sweep';
18
+
19
+ /**
20
+ * How long a publish may hold the pod out of readiness. Past it the pod serves without having
21
+ * published: a stalled bucket must not turn a deploy into one that never finishes.
22
+ */
23
+ export const RETAIN_PUBLISH_DEADLINE_MS = 30_000;
24
+
25
+ /** One file a build serves, at the content-addressed URL its documents name. */
26
+ export interface BuildAsset {
27
+ readonly url: string;
28
+ readonly contentType: string;
29
+ /** A `Blob` (`Bun.file`) for a file on disk: a disk refuses one past its ceiling unread. */
30
+ readonly body: Uint8Array | Blob;
31
+ }
32
+
33
+ /** The two levels a publish speaks at; `logger` unless a test records them. */
34
+ export interface RetainLog {
35
+ warn(message: string, fields: Readonly<Record<string, unknown>>): void;
36
+ error(message: string, fields: Readonly<Record<string, unknown>>): void;
37
+ }
38
+
39
+ export interface PublishBuildAssetsInput extends RetainedScope {
40
+ /** As `defineStorage` named it — in every log line, so the operator knows which bucket. */
41
+ readonly diskName: string;
42
+ readonly buildId: string;
43
+ /** `assets.retain.builds`: how many builds' manifests, newest first, the sweep keeps. */
44
+ readonly builds: number;
45
+ readonly assets: readonly BuildAsset[];
46
+ readonly clock?: Clock | undefined;
47
+ readonly log?: RetainLog | undefined;
48
+ /** Defaults to `RETAIN_PUBLISH_DEADLINE_MS`. */
49
+ readonly deadlineMs?: number | undefined;
50
+ }
51
+
52
+ export interface PublishReport {
53
+ /** `present`: another pod of this build published it; this one healed and re-dated it. */
54
+ readonly outcome: 'published' | 'present' | 'failed';
55
+ readonly put: number;
56
+ /** URLs over the disk's put ceiling: served by their own build's pods only. */
57
+ readonly skipped: readonly string[];
58
+ /** Objects the sweep deleted. */
59
+ readonly swept: number;
60
+ }
61
+
62
+ /** A put, list or delete the disk refused during a publish, or its deadline. Logged, not thrown. */
63
+ export class AssetRetainFailedError extends UltimateError {
64
+ constructor(input: { readonly disk: string; readonly buildId: string; readonly error: unknown }) {
65
+ super({
66
+ code: 'X_ASSET_RETAIN_FAILED',
67
+ cause: `publishing build ${renderCauseValue(input.buildId)} to the retained-asset disk ${renderCauseValue(input.disk)} failed (${renderThrowable(input.error)}) — this pod serves, but a tab holding an earlier build's document may meet a 404 for its stylesheet or island until it reloads`,
68
+ fix: 'x doctor --json',
69
+ meta: { disk: input.disk, buildId: input.buildId },
70
+ });
71
+ }
72
+ }
73
+
74
+ /** The deadline's own failure, named so the cause says which limit was hit. */
75
+ class PublishDeadlineError extends UltimateError {
76
+ constructor(ms: number) {
77
+ super({
78
+ code: 'X_ASSET_RETAIN_FAILED',
79
+ cause: `the publish did not finish within ${String(ms)}ms`,
80
+ fix: 'x doctor --json',
81
+ });
82
+ }
83
+ }
84
+
85
+ const isTooLarge = (error: unknown): boolean =>
86
+ isUltimateError(error) && error.code === 'X_STORAGE_TOO_LARGE';
87
+
88
+ /**
89
+ * Never throws for the disk and never outlasts its deadline: a failure is one
90
+ * `X_ASSET_RETAIN_FAILED` line and the pod serves, because a stale tab is a smaller harm than a
91
+ * deploy that cannot start. A deadline that is not a whole number of at least 1 ms is refused up
92
+ * front (`X_INVARIANT`): `setTimeout` fires a `NaN` at once. A publish
93
+ * abandoned at the deadline may still finish in the background; every step of it is idempotent.
94
+ */
95
+ export async function publishBuildAssets(input: PublishBuildAssetsInput): Promise<PublishReport> {
96
+ const log = input.log ?? logger;
97
+ const ms = finiteCount(
98
+ 'publishBuildAssets',
99
+ 'deadlineMs',
100
+ input.deadlineMs ?? RETAIN_PUBLISH_DEADLINE_MS,
101
+ 1,
102
+ );
103
+ let timer: ReturnType<typeof setTimeout> | undefined;
104
+ const deadline = new Promise<never>((_, reject) => {
105
+ timer = setTimeout(() => reject(new PublishDeadlineError(ms)), ms);
106
+ });
107
+ try {
108
+ return await Promise.race([publish(input, log), deadline]);
109
+ } catch (error) {
110
+ const failed = new AssetRetainFailedError({
111
+ disk: input.diskName,
112
+ buildId: input.buildId,
113
+ error,
114
+ });
115
+ log.error(failed.code, {
116
+ code: failed.code,
117
+ cause: failed.cause,
118
+ fix: failed.fix,
119
+ disk: input.diskName,
120
+ buildId: input.buildId,
121
+ });
122
+ return { outcome: 'failed', put: 0, skipped: [], swept: 0 };
123
+ } finally {
124
+ clearTimeout(timer);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The same steps whether or not the build was published before: put each missing file, write the
130
+ * manifest dated NOW, put back anything a sweep took meanwhile. So a build whose files a bad sweep
131
+ * removed heals on its next pod, and a rolled-back build — the one live — is the newest manifest.
132
+ */
133
+ async function publish(input: PublishBuildAssetsInput, log: RetainLog): Promise<PublishReport> {
134
+ const { disk, prefix, buildId } = input;
135
+ const present = await disk.exists(manifestKey(prefix, buildId));
136
+ const skipped: string[] = [];
137
+ let put = 0;
138
+ const ensure = async (asset: BuildAsset): Promise<void> => {
139
+ const key = retainedFileKey(prefix, asset.url);
140
+ if (await disk.exists(key)) return;
141
+ try {
142
+ await disk.put(key, asset.body, { contentType: asset.contentType });
143
+ put += 1;
144
+ } catch (error) {
145
+ if (!isTooLarge(error)) throw error;
146
+ if (!skipped.includes(asset.url)) skipped.push(asset.url);
147
+ }
148
+ };
149
+ await inBatches(input.assets, ensure);
150
+ if (skipped.length > 0) {
151
+ log.warn('assets.retain_skipped', {
152
+ disk: input.diskName,
153
+ buildId,
154
+ urls: [...skipped].sort(),
155
+ cause: "over the disk's maxPutBytes, so only this build's own pods serve them",
156
+ fix: 's3Driver({ bucket, maxPutBytes: <bytes> }) # the disk assets.retain.disk names, in its defineStorage; localDriver takes the same option',
157
+ });
158
+ }
159
+ const kept = input.assets.filter((asset) => !skipped.includes(asset.url));
160
+ const now = (input.clock ?? systemClock).now().getTime();
161
+ const manifest: BuildManifest = { buildId, publishedAt: now, urls: kept.map((a) => a.url) };
162
+ await disk.put(manifestKey(prefix, buildId), new TextEncoder().encode(JSON.stringify(manifest)), {
163
+ contentType: 'application/json',
164
+ });
165
+ // A sweep on another pod may have read the manifests before this one existed and deleted a file
166
+ // this build found present: asked again now that the manifest protects it from the next sweep.
167
+ await inBatches(kept, ensure);
168
+ if (present) return { outcome: 'present', put, skipped: [...skipped].sort(), swept: 0 };
169
+ const swept = await sweepRetained({ disk, prefix }, input.builds, now);
170
+ return { outcome: 'published', put, skipped: [...skipped].sort(), swept };
171
+ }
@@ -4,8 +4,8 @@
4
4
  // name that outlives its bytes is a file no client refreshes for a year.
5
5
 
6
6
  import type { UltimateRequest } from '@ultimat3/http';
7
+ import { notModified } from '@ultimat3/http';
7
8
  import { contentHash } from '@ultimat3/render/server';
8
- import { etagMatches } from './runtime-storage';
9
9
 
10
10
  /**
11
11
  * The document's own posture (`@ultimat3/render`'s `render-static.ts`), for the same reason: the
@@ -32,8 +32,11 @@ export function revalidatedResponse(
32
32
  'cache-control': REVALIDATE_CACHE_CONTROL,
33
33
  etag,
34
34
  };
35
- if (etagMatches(request.header('if-none-match'), etag)) {
36
- return new Response(null, { status: 304, headers });
37
- }
38
- return new Response(bytes, { headers });
35
+ // The length on the 200 only: `compress` reads nothing undeclared, and a 304 has no content.
36
+ return (
37
+ notModified(request, etag, headers) ??
38
+ new Response(bytes, {
39
+ headers: { ...headers, 'content-length': String(bytes.byteLength) },
40
+ })
41
+ );
39
42
  }
@@ -137,7 +137,13 @@ function standBy(replicator: Replicator, key: string): () => void {
137
137
  // Voided: both outcomes are handled here, and a timer has nobody to hand a promise to.
138
138
  void replicator.start().then(
139
139
  (held) => {
140
- if (held) logger.info('replicator.slot_taken_over', { key });
140
+ // Granted after the role stopped: its stop has already run, so nothing else would ever
141
+ // release this slot — a stopped role would hold it and decode. Given straight back.
142
+ if (held && cancelled) {
143
+ void replicator.stop().catch((thrown: unknown) => {
144
+ logger.warn('replicator.standby_failed', { key, error: renderThrowable(thrown) });
145
+ });
146
+ } else if (held) logger.info('replicator.slot_taken_over', { key });
141
147
  else if (!cancelled) ask(attempt + 1);
142
148
  },
143
149
  (thrown: unknown) => {
@@ -0,0 +1,119 @@
1
+ // The device sockets one process hosts: every declared `deviceSocket()` attached to its bus, and —
2
+ // on the web role — one listener mount per path that resolves the declaration PER UPGRADE. A
3
+ // container's declarations never change; `x dev`'s rescan re-declares a saved path in a new
4
+ // generation, and `refresh()` attaches that value and drains the one it replaced.
5
+
6
+ import type { Role } from '@ultimat3/core';
7
+ import type {
8
+ DeviceAttachment,
9
+ DeviceSocket,
10
+ DeviceUpgradeTarget,
11
+ DeviceWs,
12
+ Transport,
13
+ } from '@ultimat3/realtime/server';
14
+ import { declaredDeviceSockets } from '@ultimat3/realtime/server';
15
+
16
+ /** What the listener is handed: a declaration's own mount shape, so `httpServer` takes it as one. */
17
+ export type DeviceMount = Pick<DeviceSocket, 'path' | 'fetch' | 'websocket'>;
18
+
19
+ export interface DeviceHosting {
20
+ /** One per path declared at boot; empty without the web role, which is where devices dial. */
21
+ readonly mounts: readonly DeviceMount[];
22
+ /**
23
+ * After a rescan: each re-declared path's new value attached, the old one's sockets told to
24
+ * reconnect and closed, its attachment stopped. Answers the declared paths the listener does not
25
+ * serve — added after it bound, which only a new process can open.
26
+ */
27
+ refresh(): Promise<readonly string[]>;
28
+ /** Every attachment stopped, newest first. */
29
+ stop(): Promise<void>;
30
+ }
31
+
32
+ interface Held {
33
+ readonly link: DeviceSocket;
34
+ readonly attachment: DeviceAttachment;
35
+ }
36
+
37
+ /**
38
+ * The listener's door for one path. The upgrade is answered by the path's CURRENT declaration, and
39
+ * each socket's events go back to the declaration that upgraded it — keyed by the `data` it handed
40
+ * `server.upgrade`, `joinWebSocketMounts`'s rule — so a socket opened before a reload is closed by
41
+ * the host that holds it, never handed to one that does not.
42
+ */
43
+ function pathMount(path: string, current: () => DeviceSocket): DeviceMount {
44
+ const owners = new WeakMap<object, DeviceSocket>();
45
+ const ownerOf = (ws: DeviceWs): DeviceSocket | undefined => owners.get(ws.data);
46
+ // Bun's per-listener limits are fixed when it binds; the framework's frame limit is a constant.
47
+ const { maxPayloadLength, idleTimeout } = current().websocket;
48
+ return {
49
+ path,
50
+ fetch(request, server) {
51
+ const link = current();
52
+ const recording: DeviceUpgradeTarget = {
53
+ upgrade(upgraded, options) {
54
+ owners.set(options.data, link);
55
+ return server.upgrade(upgraded, options);
56
+ },
57
+ requestIP: (asked) => server.requestIP(asked),
58
+ };
59
+ return link.fetch(request, recording);
60
+ },
61
+ websocket: {
62
+ open: (ws) => ownerOf(ws)?.websocket.open(ws),
63
+ message: (ws, message) => ownerOf(ws)?.websocket.message(ws, message),
64
+ close: (ws) => ownerOf(ws)?.websocket.close(ws),
65
+ maxPayloadLength,
66
+ idleTimeout,
67
+ },
68
+ };
69
+ }
70
+
71
+ export async function hostDeviceSockets(
72
+ transport: Transport,
73
+ roles: readonly Role[],
74
+ ): Promise<DeviceHosting> {
75
+ const serve = roles.includes('web');
76
+ const held = new Map<string, Held>();
77
+ const attach = async (link: DeviceSocket): Promise<Held> => ({
78
+ link,
79
+ attachment: await link.attach({ transport, serve }),
80
+ });
81
+ const stopAll = (): void => {
82
+ for (const entry of [...held.values()].reverse()) entry.attachment.stop();
83
+ };
84
+ try {
85
+ for (const link of declaredDeviceSockets()) held.set(link.path, await attach(link));
86
+ } catch (error) {
87
+ stopAll();
88
+ throw error;
89
+ }
90
+ const served = serve ? [...held.keys()] : [];
91
+ // `held` replaces a served path's entry and never drops one; the boot's is the type's fallback.
92
+ const mounts = serve
93
+ ? [...held].map(([path, boot]) => pathMount(path, () => (held.get(path) ?? boot).link))
94
+ : [];
95
+ return {
96
+ mounts,
97
+ async refresh() {
98
+ for (const link of declaredDeviceSockets()) {
99
+ const was = held.get(link.path);
100
+ if (was?.link === link) continue;
101
+ // The new value takes the door BEFORE the old one sheds its sockets, so a device that
102
+ // redials on the drain's `reconnect` frame reaches the edited handler.
103
+ held.set(link.path, await attach(link));
104
+ if (was === undefined) continue;
105
+ // No spread and no grace: one developer's devices, and the save is waiting on it.
106
+ await was.attachment.drain({ graceMs: 0, spreadMs: 0 });
107
+ was.attachment.stop();
108
+ }
109
+ return serve
110
+ ? declaredDeviceSockets()
111
+ .map((link) => link.path)
112
+ .filter((path) => !served.includes(path))
113
+ : [];
114
+ },
115
+ async stop() {
116
+ stopAll();
117
+ },
118
+ };
119
+ }
@@ -108,5 +108,11 @@ export interface RunningRoles {
108
108
  readonly liveBridge: LiveReplicator | null;
109
109
  /** The sync node's registry, so a test can hold a real subscription; null without the role. */
110
110
  readonly liveRegistry: LiveQueryRegistry | null;
111
+ /**
112
+ * `x dev`'s rescan seam (`dev-rebuild.ts`): every `deviceSocket()` a save re-declared attached
113
+ * and served in place of the old one, whose sockets are told to redial. Answers the declared
114
+ * paths this listener cannot serve — added after it bound. A container never calls it.
115
+ */
116
+ refreshDevices(): Promise<readonly string[]>;
111
117
  stop(): Promise<void>;
112
118
  }
package/src/role-start.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  } from './metrics-endpoint';
39
39
  import { rolesUnderRealtime } from './role-realtime';
40
40
  import { startReplicator } from './role-replicator';
41
+ import { type DeviceMount, hostDeviceSockets } from './role-start-devices';
41
42
  import type { RunningRoles, StartRolesOptions } from './role-start-types';
42
43
  import { DEV_ROLES } from './role-start-types';
43
44
  import { prepareSync, type RunningSync, syncReadsChanges } from './role-sync';
@@ -160,9 +161,17 @@ function rateLimitStoreFor(options: StartRolesOptions): RateLimitStore | undefin
160
161
  return supplied;
161
162
  }
162
163
 
163
- /** `mount` is the sync node's socket, served on THIS port as well as its own — why, in `role-sync`.
164
- * Undefined without the `sync` role, and then this server opens no websocket, as it always did. */
165
- function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): ServerHandle {
164
+ /**
165
+ * `mount` is the sync node's socket, served on THIS port as well as its own — why, in `role-sync`;
166
+ * undefined without the `sync` role. Beside it, a door per declared `deviceSocket()` path: this
167
+ * listener is where a device dials, and `hostDeviceSockets` made this process their host before it
168
+ * binds. Neither, and this server opens no websocket, as it always did.
169
+ */
170
+ function startWeb(
171
+ options: StartRolesOptions,
172
+ devices: readonly DeviceMount[],
173
+ mount?: WebSocketMount<SyncWs>,
174
+ ): ServerHandle {
166
175
  warnIfUnauthenticatable(options.routes);
167
176
  const binding = options.http ?? DEV_BINDING;
168
177
  const hops = trustedHopsFromEnv(options.env);
@@ -170,7 +179,7 @@ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): S
170
179
  return httpServer({
171
180
  routes: options.routes,
172
181
  role: 'web',
173
- ...(mount === undefined ? {} : { websocket: mount }),
182
+ websocket: [...(mount === undefined ? [] : [mount]), ...devices],
174
183
  ...(options.drain === undefined ? {} : { drain: options.drain }),
175
184
  hooks: devHooks({
176
185
  ...(options.devNotices === undefined ? {} : { devNotices: options.devNotices }),
@@ -288,12 +297,18 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
288
297
  // `x dev` is one process running several roles: its `process_info` says which.
289
298
  role: selected.join('+'),
290
299
  whenTaken: whenMetricsPortTaken(binding.dev, options.env),
300
+ dev: binding.dev,
291
301
  reserved: rolePorts(options.port, selected),
292
302
  });
293
303
  started.push(async () => metrics.stop());
294
304
  // Opened here or adopted from `serve-boot.ts`, this is where the build id is known (#734).
295
305
  metrics.announceBuild(options.buildId);
296
306
 
307
+ // Every declared `deviceSocket()` on this process's bus, before any role serves: a job or an
308
+ // action anywhere may `request()` a device, and the web role holds their sockets.
309
+ const devices = await hostDeviceSockets(options.runtime.transport, selected);
310
+ started.push(() => devices.stop());
311
+
297
312
  // BUILT here, BOUND below, the web role between them: `web` serves the node's socket on its
298
313
  // own port and a listening server cannot be handed one, while the neighbouring-port refusals
299
314
  // are only the right answer once the web port's own has been given.
@@ -305,7 +320,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
305
320
  let releaseSync = prepared?.stop ?? null;
306
321
  let server: ServerHandle | null;
307
322
  try {
308
- server = selected.includes('web') ? startWeb(options, prepared?.mount) : null;
323
+ server = selected.includes('web') ? startWeb(options, devices.mounts, prepared?.mount) : null;
309
324
  } catch (error) {
310
325
  await releaseSync?.().catch(() => undefined);
311
326
  throw error;
@@ -323,6 +338,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
323
338
  const worker = selected.includes('worker')
324
339
  ? jobWorker({
325
340
  driver: options.runtime.jobs,
341
+ build: options.buildId,
326
342
  context: () => ctxOf({ role: 'worker', buildId: options.buildId }),
327
343
  ...workerOptionsFor(options.runtime.workerConfig),
328
344
  })
@@ -415,6 +431,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
415
431
  liveFeed: live.feed,
416
432
  liveBridge: live.bridge,
417
433
  liveRegistry: sync?.registry ?? null,
434
+ refreshDevices: () => devices.refresh(),
418
435
  async stop() {
419
436
  // `started` IS the stop list, in boot order: released newest first — the live feed, the
420
437
  // replicator (so the slot is released before the bus it published to closes), the
package/src/role-sync.ts CHANGED
@@ -310,7 +310,7 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
310
310
  },
311
311
  listen: async (appUrl) => {
312
312
  origins.push(...devOrigins(options, origins, appUrl));
313
- return await listen(options, node, { registry, hub }, appUrl);
313
+ return await listen(options, node, { registry, hub }, appUrl, stopWaiting);
314
314
  },
315
315
  };
316
316
  }
@@ -335,6 +335,8 @@ async function listen(
335
335
  node: SyncNode,
336
336
  feeds: Pick<RunningSync, 'registry' | 'hub'>,
337
337
  appUrl: string | null,
338
+ /** `startOrWaitForBus`'s cancel: a node stopped while it waits must not start on a reconnect. */
339
+ stopWaiting: () => void,
338
340
  ): Promise<RunningSync> {
339
341
  const port = syncPortFor(options.port);
340
342
  try {
@@ -365,11 +367,15 @@ async function listen(
365
367
  url: listener.url,
366
368
  ...feeds,
367
369
  stop: async () => {
370
+ // First, as `prepareSync`'s own stop does: the bus's reconnect subscription otherwise
371
+ // outlived the node and called `node.start()` on it after this stop returned.
372
+ stopWaiting();
368
373
  listener.stop();
369
374
  await node.stop();
370
375
  },
371
376
  };
372
377
  } catch (error) {
378
+ stopWaiting();
373
379
  await node.stop();
374
380
  const refusal = await syncBindRefusal(options.port, port);
375
381
  if (refusal !== undefined) throw refusal;
@@ -7,6 +7,7 @@ import { posix } from 'node:path';
7
7
  import type { IslandCollector, RouteEntry } from '@ultimat3/render';
8
8
  import {
9
9
  clientBootTags,
10
+ hydrateRuntime,
10
11
  islandCollector,
11
12
  islandModuleId,
12
13
  islandModuleIds,
@@ -71,3 +72,44 @@ export function bootScript(
71
72
  });
72
73
  return reaches ? renderHead(clientBootTags(options.sync)) : '';
73
74
  }
75
+
76
+ /** A stream document's boot and hydration scripts: its first flush's, and each hole chunk's. */
77
+ export interface StreamScripts {
78
+ readonly first: string;
79
+ /** For a hole's chunk, after its reveal: `''` while nothing it shows can hold an island. */
80
+ readonly after: (outcome: 'filled' | 'failed') => string;
81
+ }
82
+
83
+ /**
84
+ * An inline module waits for parsing to end, and a document with a hole open is still parsing, so
85
+ * the runtime is `async` while nothing has to run before it. Once a boot is in the document it stays
86
+ * deferred: the boot must run first (`bootScript`), at the cost a Known-Gaps row states.
87
+ *
88
+ * `after` re-runs the whole runtime once any hole recorded an island, rather than comparing against
89
+ * what the last chunk took: holes record while running and reveal in completion order, so an island
90
+ * recorded by a hole still open would otherwise be spent on another hole's chunk, before its root is
91
+ * in the DOM. `each` visits a root once, so an extra run does nothing; every body is already hashed.
92
+ * A FAILED hole shows its `error` markup, walked at shell time — so counted in `flushed` — yet not in
93
+ * the DOM until this chunk: it re-runs whenever the page recorded any island at all.
94
+ */
95
+ export function streamScripts(
96
+ entry: RouteEntry,
97
+ islands: IslandCollector,
98
+ options: DocumentOptions,
99
+ scope: string | undefined,
100
+ holes: boolean,
101
+ ): StreamScripts {
102
+ const boot = bootScript(entry, islands, options, scope);
103
+ const flushed = islands.directives.length;
104
+ let booted = boot !== '';
105
+ return {
106
+ first: `${boot}${hydrateRuntime(islands.directives, { async: holes && !booted })}`,
107
+ after: (outcome) => {
108
+ const recorded = islands.directives.length;
109
+ if (recorded === (outcome === 'failed' ? 0 : flushed)) return '';
110
+ const late = booted ? '' : bootScript(entry, islands, options, scope);
111
+ booted = booted || late !== '';
112
+ return `${late}${hydrateRuntime(islands.directives, { async: !booted })}`;
113
+ },
114
+ };
115
+ }
@@ -8,6 +8,7 @@ import { describePages } from '@ultimat3/render';
8
8
  import { apiRoutes } from './api-routes';
9
9
  import { loadAppRuntime } from './app-runtime';
10
10
  import type { JsonValue } from './output';
11
+ import { plainRoutes } from './plain-routes';
11
12
 
12
13
  /** One printed and one JSON row per served route: a page, a mounted page, or an API route. */
13
14
  export interface RouteRow {
@@ -94,7 +95,7 @@ export function routeRows(plain: readonly Route[] = []): readonly RouteRow[] {
94
95
  * since an action or query validates its own input. Loaded as `x dev` loads them.
95
96
  */
96
97
  export async function plainAppRoutes(root: string): Promise<readonly Route[]> {
97
- return (await loadAppRuntime(root))?.routes ?? [];
98
+ return plainRoutes((await loadAppRuntime(root))?.routes);
98
99
  }
99
100
 
100
101
  /** Fixed-width columns so the output diffs cleanly between runs and between machines. */
@@ -0,0 +1,58 @@
1
+ // Single responsibility: arming the database's tenant guard for a booted role. With
2
+ // `database.rowSecurity` on, the serving role must be one Postgres applies policies to
3
+ // (`X_DB_ROW_SECURITY_BYPASSED` otherwise), every BEGIN carries the actor's tenant, and a bare
4
+ // statement is wrapped in a transaction that does. Off, nothing here runs.
5
+
6
+ import type { AppConfig } from '@ultimat3/core';
7
+ import {
8
+ assertRowSecurityBinds,
9
+ db,
10
+ holdMigratorRole,
11
+ raw,
12
+ rowSecureClient,
13
+ serveAsAppRole,
14
+ setDbClient,
15
+ setTransactionSettings,
16
+ } from '@ultimat3/db';
17
+ import { rowSecuritySettings } from '@ultimat3/entity';
18
+ import { loadAppConfig } from './app-config-load';
19
+
20
+ /** `database.rowSecurity` on a loaded config; no `app.config.ts` at all is off. */
21
+ export function rowSecurityDeclared(config: AppConfig | undefined): boolean {
22
+ return config?.database.rowSecurity === true;
23
+ }
24
+
25
+ /**
26
+ * Judge the role on the ambient client, then install the seam and the wrapper. Judged FIRST: a boot
27
+ * refused here installs nothing, so a test that catches the refusal leaves the process as it was.
28
+ * `embedded` is PGlite, whose one session is a superuser and whose roles are ours: it switches to
29
+ * `x_app` before the judgement (`serveAsAppRole`). An external database's roles are the operator's
30
+ * and are only ever judged. Returns the undo, which `stop()` runs so a second boot starts clean.
31
+ */
32
+ export async function armRowSecurity(
33
+ enabled: boolean,
34
+ options: { readonly embedded?: boolean } = {},
35
+ ): Promise<() => Promise<void>> {
36
+ if (!enabled) return async () => undefined;
37
+ const ambient = db();
38
+ if (options.embedded === true) await serveAsAppRole(ambient);
39
+ await assertRowSecurityBinds(ambient);
40
+ setTransactionSettings(rowSecuritySettings);
41
+ setDbClient(rowSecureClient(ambient));
42
+ return async () => {
43
+ setTransactionSettings(undefined);
44
+ setDbClient(ambient);
45
+ if (options.embedded === true) await ambient.execute(raw('reset role'));
46
+ };
47
+ }
48
+
49
+ /**
50
+ * The migrate step on PGlite, whose roles are ours — never called for an external database, whose
51
+ * migrator is the operator's `DATABASE_URL`. With the flag on, the app's migrations run as
52
+ * `x_migrator`, so it owns what they create and `x_app`, what `x dev` serves as, owns nothing.
53
+ * Off, nothing runs. Returns the undo.
54
+ */
55
+ export async function migrateAsMigrator(root: string): Promise<() => Promise<void>> {
56
+ if (!rowSecurityDeclared(await loadAppConfig(root))) return async () => undefined;
57
+ return holdMigratorRole(db());
58
+ }
@@ -0,0 +1,32 @@
1
+ // Single responsibility: what `x db gen` and the gate's `drift` step read about row security — the
2
+ // app's `database.rowSecurity` and each tenant table's column — and the drift step's verdict on it.
3
+ // One reader for both, so the gate can never expect a policy the generator would not write.
4
+
5
+ import type { EntityDescriptionLike, RowSecurityOption, SchemaDescription } from '@ultimat3/db';
6
+ import { rowSecurityDifferences, rowSecurityDrift } from '@ultimat3/db';
7
+ import { describeTenancy } from '@ultimat3/entity';
8
+ import { loadAppConfig } from './app-config-load';
9
+ import { MIGRATIONS_DIR } from './migrations';
10
+ import type { Finding } from './output';
11
+ import { findingFrom } from './output';
12
+ import { rowSecurityDeclared } from './row-security-boot';
13
+
14
+ /** The app's answer, read after `loadApp` filled the registry. Injected for tests. */
15
+ export type RowSecurityWanted = (root: string) => Promise<RowSecurityOption>;
16
+
17
+ export const appRowSecurity: RowSecurityWanted = async (root) => ({
18
+ enabled: rowSecurityDeclared(await loadAppConfig(root)),
19
+ tenants: describeTenancy(),
20
+ });
21
+
22
+ /** One `X_DB_ROW_SECURITY_DRIFT` per tenant table the flag and the newest snapshot disagree on. */
23
+ export function rowSecurityFindings(
24
+ entities: readonly EntityDescriptionLike[],
25
+ recorded: SchemaDescription,
26
+ option: RowSecurityOption,
27
+ ): readonly Finding[] {
28
+ return rowSecurityDifferences(entities, recorded, option).map((difference) => ({
29
+ ...findingFrom(rowSecurityDrift(difference)),
30
+ at: MIGRATIONS_DIR,
31
+ }));
32
+ }
@@ -44,7 +44,6 @@ const entryFor = (route: AdminRouteConfig, body: unknown): RouteEntry => ({
44
44
  // An admin screen is behind sign-in: it carries the `app/` surface's stylesheet, never `site/`'s.
45
45
  surface: 'app',
46
46
  config: route.config,
47
- suspenseBoundaries: 0,
48
47
  islands: [],
49
48
  pattern: compilePattern(route.path),
50
49
  component: () => body,