@ultimat3/cli 29.4.0 → 30.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (448) hide show
  1. package/CLAUDE.md +64 -40
  2. package/README.md +61 -11
  3. package/package.json +42 -28
  4. package/src/affected-tests.ts +97 -0
  5. package/src/affected-unowned.ts +43 -0
  6. package/src/affected.ts +25 -6
  7. package/src/app-boundaries.ts +29 -1
  8. package/src/app-evals.ts +12 -4
  9. package/src/app-load-unresolved.ts +62 -0
  10. package/src/app-load.ts +30 -37
  11. package/src/app-mcp.ts +14 -2
  12. package/src/app-module-graph.ts +232 -0
  13. package/src/app-reload-graph.ts +2 -2
  14. package/src/app-root.ts +37 -1
  15. package/src/bin.ts +1 -0
  16. package/src/boundary-findings.ts +6 -0
  17. package/src/budgets-freshness.ts +32 -0
  18. package/src/budgets.ts +20 -1
  19. package/src/build-capture-env.ts +9 -0
  20. package/src/cdp-shot-a11y.ts +2 -39
  21. package/src/ci-tested.ts +243 -0
  22. package/src/client-emit-customer-runtime.ts +139 -0
  23. package/src/client-emit-customer.ts +108 -0
  24. package/src/client-emit-drift.ts +72 -0
  25. package/src/client-emit-header.ts +67 -0
  26. package/src/client-emit-schemas.ts +175 -0
  27. package/src/client-emit-types.ts +121 -0
  28. package/src/client-emit-vendor.ts +146 -0
  29. package/src/client-errors-routes.ts +229 -0
  30. package/src/client-errors-script.ts +74 -0
  31. package/src/client-vitals-route.ts +197 -0
  32. package/src/client-vitals-script.ts +56 -0
  33. package/src/cmd-affected.ts +23 -0
  34. package/src/cmd-build-spec.ts +7 -1
  35. package/src/cmd-build.ts +35 -9
  36. package/src/cmd-ci-spec.ts +42 -6
  37. package/src/cmd-ci.ts +99 -2
  38. package/src/cmd-client-customer.ts +134 -0
  39. package/src/cmd-client-source.ts +81 -0
  40. package/src/cmd-client-spec.ts +48 -0
  41. package/src/cmd-client-write.ts +41 -0
  42. package/src/cmd-client.ts +111 -0
  43. package/src/cmd-db-recount.ts +120 -0
  44. package/src/cmd-db-spec.ts +36 -6
  45. package/src/cmd-db.ts +11 -3
  46. package/src/cmd-deploy-compose.ts +61 -3
  47. package/src/cmd-deploy-drain.ts +10 -2
  48. package/src/cmd-deploy-helm.ts +11 -0
  49. package/src/cmd-deploy-preflight.ts +118 -0
  50. package/src/cmd-deploy-spec.ts +58 -6
  51. package/src/cmd-deploy-verify.ts +380 -0
  52. package/src/cmd-deploy.ts +42 -9
  53. package/src/cmd-docs.ts +34 -4
  54. package/src/cmd-doctor.ts +66 -9
  55. package/src/cmd-drill-spec.ts +25 -0
  56. package/src/cmd-drill.ts +176 -0
  57. package/src/cmd-env-init.ts +102 -0
  58. package/src/cmd-env-spec.ts +10 -3
  59. package/src/cmd-env.ts +15 -10
  60. package/src/cmd-errors-recent.ts +60 -0
  61. package/src/cmd-errors-spec.ts +9 -1
  62. package/src/cmd-errors.ts +4 -0
  63. package/src/cmd-exercise-spec.ts +19 -0
  64. package/src/cmd-exercise.ts +129 -0
  65. package/src/cmd-generate-spec.ts +9 -1
  66. package/src/cmd-generate.ts +32 -27
  67. package/src/cmd-graph-load.ts +97 -0
  68. package/src/cmd-graph-spec.ts +16 -0
  69. package/src/cmd-graph.ts +73 -0
  70. package/src/cmd-i18n.ts +4 -1
  71. package/src/cmd-impact-spec.ts +13 -0
  72. package/src/cmd-impact.ts +64 -0
  73. package/src/cmd-jobs-resolve.ts +76 -0
  74. package/src/cmd-jobs-spec.ts +21 -2
  75. package/src/cmd-jobs.ts +10 -6
  76. package/src/cmd-mail-spec.ts +28 -0
  77. package/src/cmd-mail.ts +70 -0
  78. package/src/cmd-mcp-lock.ts +69 -0
  79. package/src/cmd-mcp-spec.ts +23 -4
  80. package/src/cmd-mcp.ts +17 -3
  81. package/src/cmd-new-spec.ts +8 -1
  82. package/src/cmd-new.ts +61 -9
  83. package/src/cmd-planned.ts +0 -6
  84. package/src/cmd-repro-spec.ts +31 -0
  85. package/src/cmd-repro.ts +174 -0
  86. package/src/cmd-stop-spec.ts +21 -0
  87. package/src/cmd-stop.ts +166 -0
  88. package/src/cmd-test-spec.ts +17 -4
  89. package/src/cmd-test.ts +70 -14
  90. package/src/cmd-upgrade-spec.ts +26 -0
  91. package/src/cmd-upgrade.ts +274 -0
  92. package/src/cmd-verify-spec.ts +3 -3
  93. package/src/db-backfill.ts +6 -0
  94. package/src/db-diagnostics.ts +106 -0
  95. package/src/db-generate.ts +9 -0
  96. package/src/db-reset-template.ts +1 -1
  97. package/src/db-rolling.ts +45 -0
  98. package/src/db-seed-generate.ts +188 -0
  99. package/src/deploy-verify-findings.ts +98 -0
  100. package/src/deploy-verify-flags.ts +109 -0
  101. package/src/deploy-verify-grade.ts +185 -0
  102. package/src/deploy-verify-queues.ts +53 -0
  103. package/src/dev-boot.ts +35 -15
  104. package/src/dev-child-watch.ts +16 -4
  105. package/src/dev-dashboard-guard.ts +8 -1
  106. package/src/dev-dashboard.ts +7 -0
  107. package/src/dev-lock-identity.ts +61 -0
  108. package/src/dev-lock-port-holder.ts +80 -0
  109. package/src/dev-lock-steal.ts +124 -0
  110. package/src/dev-lock.ts +92 -120
  111. package/src/dev-mail-templates.ts +51 -0
  112. package/src/dev-rebuild.ts +26 -2
  113. package/src/dev-route-table.ts +17 -0
  114. package/src/dev-supervisor.ts +5 -2
  115. package/src/dev-watch-tree.ts +11 -10
  116. package/src/dev-watch.ts +1 -1
  117. package/src/dispatch.ts +29 -6
  118. package/src/docs-section.ts +69 -0
  119. package/src/docs-sources.ts +100 -0
  120. package/src/doctor-auth.ts +11 -2
  121. package/src/doctor-chart-alerts.ts +55 -0
  122. package/src/doctor-pooler.ts +107 -0
  123. package/src/doctor-rolling.ts +35 -0
  124. package/src/doctor-row-security.ts +40 -0
  125. package/src/document-options.ts +13 -0
  126. package/src/drill-sync-lane.ts +25 -0
  127. package/src/drill-targets.ts +39 -0
  128. package/src/duplicate-packages.ts +1 -1
  129. package/src/env-production.ts +93 -0
  130. package/src/error-codes-shot.ts +59 -0
  131. package/src/error-codes-tooling.ts +29 -0
  132. package/src/error-codes.ts +100 -54
  133. package/src/error-contract.ts +2 -2
  134. package/src/errors.ts +22 -3
  135. package/src/exercise-database.ts +99 -0
  136. package/src/exercise-findings.ts +159 -0
  137. package/src/exercise-overrides.ts +42 -0
  138. package/src/exercise-registration.ts +58 -0
  139. package/src/exercise-static.ts +88 -0
  140. package/src/exercise-subject.ts +69 -0
  141. package/src/failure-capture-boot.ts +30 -0
  142. package/src/framework-env.ts +7 -1
  143. package/src/framework-schema.ts +72 -5
  144. package/src/generate-catalog-locales.ts +19 -0
  145. package/src/generate-chart-alerts.ts +156 -0
  146. package/src/generate-feature-required.ts +65 -0
  147. package/src/generate-feature.ts +83 -6
  148. package/src/generate-files.ts +24 -0
  149. package/src/generate-format-written.ts +32 -0
  150. package/src/generate-kinds.ts +6 -0
  151. package/src/generate-sign-in-config.ts +100 -0
  152. package/src/generate-sign-in-manifest.ts +28 -0
  153. package/src/generate-sign-in-mode.ts +101 -0
  154. package/src/generate-sign-in.ts +250 -0
  155. package/src/generate-write.ts +44 -7
  156. package/src/hand-rolled-socket.ts +72 -0
  157. package/src/i18n-app-key-shapes.ts +29 -0
  158. package/src/i18n-audit.ts +10 -3
  159. package/src/i18n-registration.ts +18 -3
  160. package/src/index.ts +2 -0
  161. package/src/island-bundle.ts +4 -3
  162. package/src/island-disposer.ts +116 -0
  163. package/src/island-realtime-state.ts +34 -6
  164. package/src/island-realtime.ts +6 -2
  165. package/src/island-routes.ts +3 -2
  166. package/src/isr-query-advice.ts +6 -11
  167. package/src/isr-query-guard.ts +4 -3
  168. package/src/job-budget.ts +95 -0
  169. package/src/jobs-json.ts +11 -0
  170. package/src/lint-findings.ts +104 -0
  171. package/src/local-cli.ts +34 -4
  172. package/src/loopback-host.ts +2 -2
  173. package/src/mail-preview-findings.ts +24 -0
  174. package/src/mcp-errors.ts +82 -2
  175. package/src/mcp-host.ts +11 -1
  176. package/src/mcp-lock-findings.ts +57 -0
  177. package/src/measure-paths.ts +12 -4
  178. package/src/measure-scope.ts +27 -13
  179. package/src/messages-client.ts +23 -0
  180. package/src/messages-db.ts +56 -0
  181. package/src/messages-gate.ts +107 -0
  182. package/src/messages-repro.ts +11 -0
  183. package/src/messages-shot.ts +51 -0
  184. package/src/messages-upgrade.ts +45 -0
  185. package/src/messages.ts +53 -178
  186. package/src/metrics-endpoint.ts +23 -5
  187. package/src/new-shapes-generate.ts +71 -0
  188. package/src/new-shapes.ts +63 -0
  189. package/src/openapi-compile-ir.ts +68 -0
  190. package/src/openapi-compile-schema.ts +284 -0
  191. package/src/openapi-compile.ts +273 -0
  192. package/src/openapi-refuse.ts +45 -0
  193. package/src/otlp-export.ts +2 -1
  194. package/src/output-findings.ts +123 -0
  195. package/src/output.ts +32 -8
  196. package/src/package-boundaries.ts +110 -0
  197. package/src/page-pins.ts +23 -0
  198. package/src/plain-routes.ts +13 -0
  199. package/src/prerender-entries.ts +59 -0
  200. package/src/prerender-heaviest.ts +27 -0
  201. package/src/prerender-preload.ts +6 -0
  202. package/src/prerender-report.ts +12 -0
  203. package/src/prerender-seo-reads.ts +38 -0
  204. package/src/prerender-served.ts +22 -7
  205. package/src/prerender-vitals.ts +42 -0
  206. package/src/prerender.ts +61 -32
  207. package/src/pwa-artifacts.ts +4 -3
  208. package/src/pwa-manifest-locales.ts +1 -1
  209. package/src/registry.ts +18 -0
  210. package/src/repro-bundle.ts +65 -0
  211. package/src/repro-errors.ts +47 -0
  212. package/src/repro-job.ts +72 -0
  213. package/src/repro-literal.ts +41 -0
  214. package/src/repro-request.ts +48 -0
  215. package/src/repro-write.ts +100 -0
  216. package/src/retain-sweep.ts +27 -0
  217. package/src/retained-assets-boot.ts +113 -0
  218. package/src/retained-assets-layout.ts +110 -0
  219. package/src/retained-assets-read.ts +142 -0
  220. package/src/retained-assets-sweep.ts +70 -0
  221. package/src/retained-assets.ts +171 -0
  222. package/src/revalidated-response.ts +8 -5
  223. package/src/role-replicator.ts +7 -1
  224. package/src/role-start-devices.ts +119 -0
  225. package/src/role-start-types.ts +6 -0
  226. package/src/role-start.ts +22 -5
  227. package/src/role-sync.ts +7 -1
  228. package/src/route-islands.ts +42 -0
  229. package/src/route-table.ts +2 -1
  230. package/src/row-security-boot.ts +58 -0
  231. package/src/row-security-wanted.ts +32 -0
  232. package/src/runtime-admin.ts +0 -1
  233. package/src/runtime-assets-site-cache.ts +55 -0
  234. package/src/runtime-assets-site.ts +136 -0
  235. package/src/runtime-assets.ts +99 -55
  236. package/src/runtime-bindings.ts +19 -3
  237. package/src/runtime-cache.ts +23 -14
  238. package/src/runtime-isr-outcome.ts +114 -60
  239. package/src/runtime-purge.ts +14 -2
  240. package/src/runtime-queue.ts +31 -4
  241. package/src/runtime-render-document.ts +204 -0
  242. package/src/runtime-render.ts +48 -198
  243. package/src/runtime-replica-position.ts +62 -0
  244. package/src/runtime-replica.ts +18 -3
  245. package/src/runtime-s3-path-style.ts +17 -0
  246. package/src/runtime-services.ts +19 -6
  247. package/src/runtime-statement-bound.ts +22 -0
  248. package/src/runtime-storage-upload.ts +9 -0
  249. package/src/runtime-storage.ts +41 -81
  250. package/src/schema-diff-views.ts +54 -0
  251. package/src/schema-diff.ts +21 -3
  252. package/src/schema-drift.ts +28 -9
  253. package/src/schema-dump-drift.ts +91 -7
  254. package/src/script-csp.ts +1 -1
  255. package/src/seo-cards.ts +26 -0
  256. package/src/seo-routes.ts +22 -8
  257. package/src/serve-boot.ts +54 -21
  258. package/src/serve-drain.ts +1 -0
  259. package/src/serve-env.ts +26 -8
  260. package/src/serve-retire.ts +12 -6
  261. package/src/serve-web.ts +42 -4
  262. package/src/serve.ts +9 -7
  263. package/src/site-asset-routes.ts +7 -1
  264. package/src/site-config.ts +4 -0
  265. package/src/site-llms-pages.ts +106 -0
  266. package/src/site-llms.ts +64 -0
  267. package/src/site-seo-write.ts +4 -2
  268. package/src/site-seo.ts +55 -6
  269. package/src/source-mtime.ts +50 -0
  270. package/src/static-capture-errors.ts +41 -0
  271. package/src/static-capture.ts +56 -0
  272. package/src/static-cards-serve.ts +39 -0
  273. package/src/static-cards.ts +93 -0
  274. package/src/static-document.ts +14 -13
  275. package/src/static-images.ts +115 -0
  276. package/src/stream-hole-scope.ts +55 -0
  277. package/src/style-bundle.ts +1 -1
  278. package/src/style-routes.ts +3 -2
  279. package/src/sw-precache-plan.ts +2 -2
  280. package/src/sw-routes.ts +2 -2
  281. package/src/templates/action-tests.ts +306 -0
  282. package/src/templates/action.ts +81 -269
  283. package/src/templates/backfill.ts +1 -1
  284. package/src/templates/entity.ts +21 -5
  285. package/src/templates/github/ci.yml.ts +8 -9
  286. package/src/templates/guard-bare-error.ts +2 -2
  287. package/src/templates/guard-error-declared.ts +263 -0
  288. package/src/templates/guard-native-confirm.ts +132 -0
  289. package/src/templates/guard-raw-fetch.ts +151 -0
  290. package/src/templates/guard-raw-interval.ts +121 -0
  291. package/src/templates/guard-repo-raw-sql.ts +71 -2
  292. package/src/templates/job.ts +1 -1
  293. package/src/templates/naming.ts +5 -0
  294. package/src/templates/pinned/docker-compose.yml +6 -0
  295. package/src/templates/pinned-images.ts +8 -0
  296. package/src/templates/policy.ts +85 -2
  297. package/src/templates/query.ts +70 -13
  298. package/src/templates/resource-create.ts +3 -1
  299. package/src/templates/resource-form-island.ts +15 -4
  300. package/src/templates/resource-page-db.ts +83 -0
  301. package/src/templates/resource-page.ts +161 -52
  302. package/src/templates/route.ts +4 -6
  303. package/src/templates/scaffold-app.ts +3 -0
  304. package/src/templates/scaffold-auth.ts +2 -1
  305. package/src/templates/scaffold-bin.ts +5 -52
  306. package/src/templates/scaffold-claude-agents.ts +19 -19
  307. package/src/templates/scaffold-claude-commands.ts +28 -31
  308. package/src/templates/scaffold-claude.ts +26 -23
  309. package/src/templates/scaffold-container-compose.ts +6 -1
  310. package/src/templates/scaffold-container.ts +24 -4
  311. package/src/templates/scaffold-dashboard-bare.ts +3 -3
  312. package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
  313. package/src/templates/scaffold-dashboard-example-view.ts +156 -0
  314. package/src/templates/scaffold-dashboard-example.ts +43 -131
  315. package/src/templates/scaffold-dashboard-shared.ts +3 -7
  316. package/src/templates/scaffold-db-client.ts +20 -6
  317. package/src/templates/scaffold-db-package.ts +5 -14
  318. package/src/templates/scaffold-docs.ts +73 -57
  319. package/src/templates/scaffold-domain-package.ts +18 -14
  320. package/src/templates/scaffold-entries.ts +7 -2
  321. package/src/templates/scaffold-exercise.ts +37 -0
  322. package/src/templates/scaffold-guards.ts +13 -1
  323. package/src/templates/scaffold-helm-alerts.ts +234 -0
  324. package/src/templates/scaffold-helm-templates.ts +7 -5
  325. package/src/templates/scaffold-helm.ts +46 -12
  326. package/src/templates/scaffold-i18n.ts +6 -2
  327. package/src/templates/scaffold-mcp-endpoint.ts +182 -0
  328. package/src/templates/scaffold-mcp-json.ts +31 -0
  329. package/src/templates/scaffold-mcp-package.ts +59 -56
  330. package/src/templates/scaffold-repo.ts +7 -2
  331. package/src/templates/scaffold-shell.ts +45 -79
  332. package/src/templates/scaffold-test-services.ts +13 -5
  333. package/src/templates/shapes/api-keys.ts +170 -0
  334. package/src/templates/shapes/api.ts +101 -0
  335. package/src/templates/shapes/blog-site.ts +340 -0
  336. package/src/templates/shapes/blog-slice.ts +265 -0
  337. package/src/templates/shapes/blog.ts +114 -0
  338. package/src/templates/shapes/dashboard-detail.ts +242 -0
  339. package/src/templates/shapes/dashboard.ts +53 -0
  340. package/src/templates/shapes/index.ts +17 -0
  341. package/src/templates/shapes/overlay.ts +99 -0
  342. package/src/templates/shapes/shared-edits.ts +46 -0
  343. package/src/templates/sign-in-action-tests.ts +237 -0
  344. package/src/templates/sign-in-authenticator.ts +112 -0
  345. package/src/templates/sign-in-oidc.ts +189 -0
  346. package/src/templates/sign-in-pages.ts +148 -0
  347. package/src/templates/sign-in-password.ts +216 -0
  348. package/src/templates/sign-in-shared.ts +268 -0
  349. package/src/templates/slice-foundation.ts +70 -15
  350. package/src/test-batches.ts +1 -1
  351. package/src/test-database.ts +1 -1
  352. package/src/test-shards.ts +7 -1
  353. package/src/test-workers.ts +18 -12
  354. package/src/tool-gate-names.ts +34 -0
  355. package/src/upgrade-agents-link.ts +49 -0
  356. package/src/upgrade-bun-floor.ts +225 -0
  357. package/src/upgrade-gate.ts +228 -0
  358. package/src/upgrade-pins.ts +63 -0
  359. package/src/upgrade-plan.ts +214 -0
  360. package/src/upgrade-registry.ts +53 -0
  361. package/src/upgrade-report.ts +139 -0
  362. package/src/upgrade-root.ts +74 -0
  363. package/src/verify-budgets-build.ts +118 -0
  364. package/src/verify-checks.ts +67 -24
  365. package/src/verify-coverage-run.ts +1 -1
  366. package/src/verify-e2e-lane.ts +41 -0
  367. package/src/verify-exercise.ts +64 -0
  368. package/src/verify-floor.ts +41 -0
  369. package/src/verify-merge.ts +19 -4
  370. package/src/verify-role-load.ts +2 -0
  371. package/src/verify-run.ts +28 -8
  372. package/src/verify-shard.ts +12 -2
  373. package/src/verify-skip-ceiling.ts +87 -0
  374. package/src/verify-step.ts +5 -0
  375. package/src/verify-tests.ts +25 -5
  376. package/src/view-drift.ts +55 -0
  377. package/src/worker-bundle.ts +3 -2
  378. package/src/workspace-graph.ts +38 -19
  379. package/src/write-generated.ts +43 -0
  380. package/src/zone-boot.ts +20 -0
  381. package/wiki/Actions.md +342 -0
  382. package/wiki/Admin-Dashboard.md +432 -0
  383. package/wiki/Agents.md +958 -0
  384. package/wiki/Auth.md +195 -0
  385. package/wiki/Bare-VM.md +111 -0
  386. package/wiki/Batching-And-Preloading.md +136 -0
  387. package/wiki/Building-Your-Own-Base.md +203 -0
  388. package/wiki/CI-Parallel-Gate.md +220 -0
  389. package/wiki/CLI-Reference.md +1894 -0
  390. package/wiki/Caching-And-Invalidation.md +255 -0
  391. package/wiki/Client-Data.md +194 -0
  392. package/wiki/Client-Navigation.md +513 -0
  393. package/wiki/Compared.md +93 -0
  394. package/wiki/Configuration.md +868 -0
  395. package/wiki/Contributing.md +12 -0
  396. package/wiki/Deploy-Recipes.md +122 -0
  397. package/wiki/Deployment.md +508 -0
  398. package/wiki/Entities-And-Migrations.md +1116 -0
  399. package/wiki/FAQ.md +175 -0
  400. package/wiki/Feature-Flags.md +65 -0
  401. package/wiki/Getting-Started.md +221 -0
  402. package/wiki/Home.md +160 -0
  403. package/wiki/I18n.md +239 -0
  404. package/wiki/Installation.md +206 -0
  405. package/wiki/Interface-Rules.md +339 -0
  406. package/wiki/Jobs-And-Workflows.md +783 -0
  407. package/wiki/Known-Gaps.md +185 -0
  408. package/wiki/MCP-And-AI.md +753 -0
  409. package/wiki/Mail.md +138 -0
  410. package/wiki/Migrating-An-Existing-App.md +594 -0
  411. package/wiki/Migrating-From-Django.md +54 -0
  412. package/wiki/Migrating-From-Laravel.md +53 -0
  413. package/wiki/Migrating-From-Node-Drizzle.md +53 -0
  414. package/wiki/Migrating-From-Rails.md +53 -0
  415. package/wiki/Migrations-And-Backfills.md +552 -0
  416. package/wiki/Money.md +338 -0
  417. package/wiki/N-Plus-One-Detection.md +197 -0
  418. package/wiki/Notify.md +208 -0
  419. package/wiki/Observability.md +245 -0
  420. package/wiki/Outbound-HTTP.md +109 -0
  421. package/wiki/PWA-And-Offline.md +343 -0
  422. package/wiki/Policies-And-Authz.md +251 -0
  423. package/wiki/Project-Layout.md +150 -0
  424. package/wiki/Queries-And-Live-Queries.md +429 -0
  425. package/wiki/Realtime.md +734 -0
  426. package/wiki/Resource-Management.md +121 -0
  427. package/wiki/Routes-And-Render-Modes.md +521 -0
  428. package/wiki/SEO.md +288 -0
  429. package/wiki/Scheduled-Tasks.md +175 -0
  430. package/wiki/Scraping.md +493 -0
  431. package/wiki/Shapes.md +63 -0
  432. package/wiki/Stability.md +97 -0
  433. package/wiki/Static-Assets.md +166 -0
  434. package/wiki/Storage-And-Uploads.md +404 -0
  435. package/wiki/Testing.md +1314 -0
  436. package/wiki/The-Eight-Primitives.md +247 -0
  437. package/wiki/Theming.md +362 -0
  438. package/wiki/Timezones-And-Dates.md +194 -0
  439. package/wiki/Troubleshooting.md +163 -0
  440. package/wiki/Tutorial-01-First-App.md +289 -0
  441. package/wiki/Tutorial-02-First-Feature.md +339 -0
  442. package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
  443. package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
  444. package/wiki/Tutorial-05-Deploy-Free.md +231 -0
  445. package/wiki/Tutorial-06-Growing-Up.md +158 -0
  446. package/wiki/UI-Components.md +156 -0
  447. package/wiki/Upgrading.md +3667 -0
  448. package/src/stored-object-headers.ts +0 -43
@@ -0,0 +1,55 @@
1
+ // A byte-bounded, least-recently-used memo with single flight: what keeps a public image variant
2
+ // from being decoded twice. Concurrent first requests share one promise, so a burst of visitors to
3
+ // a fresh deploy is one decode, not one each — and the bound holds the process's memory flat.
4
+
5
+ export interface VariantLru<T> {
6
+ /** The kept answer for `key`, the one in flight, or `make()`'s — kept if `sizeOf` sizes it. */
7
+ get(key: string, make: () => Promise<T>): Promise<T>;
8
+ }
9
+
10
+ /**
11
+ * `sizeOf` answering `undefined` means "answer, never keep" — a refusal is cheap to recompute and
12
+ * must not hold a slot a real variant could. An entry larger than the whole budget is never kept.
13
+ */
14
+ export function variantLru<T>(
15
+ maxBytes: number,
16
+ sizeOf: (value: T) => number | undefined,
17
+ ): VariantLru<T> {
18
+ const kept = new Map<string, { readonly value: T; readonly bytes: number }>();
19
+ const flying = new Map<string, Promise<T>>();
20
+ let total = 0;
21
+
22
+ const keep = (key: string, value: T): void => {
23
+ const bytes = sizeOf(value);
24
+ if (bytes === undefined || bytes > maxBytes) return;
25
+ kept.set(key, { value, bytes });
26
+ total += bytes;
27
+ // A Map iterates in insertion order, and a hit re-inserts: the first key is the coldest.
28
+ for (const [cold, entry] of kept) {
29
+ if (total <= maxBytes) break;
30
+ kept.delete(cold);
31
+ total -= entry.bytes;
32
+ }
33
+ };
34
+
35
+ return {
36
+ get(key, make) {
37
+ const hit = kept.get(key);
38
+ if (hit !== undefined) {
39
+ kept.delete(key);
40
+ kept.set(key, hit);
41
+ return Promise.resolve(hit.value);
42
+ }
43
+ const pending = flying.get(key);
44
+ if (pending !== undefined) return pending;
45
+ const made = make()
46
+ .then((value) => {
47
+ keep(key, value);
48
+ return value;
49
+ })
50
+ .finally(() => flying.delete(key));
51
+ flying.set(key, made);
52
+ return made;
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,136 @@
1
+ // The variants of a public site image, served: `responsiveImage({ src: asset('assets/hero.png') })`
2
+ // mints `?w=&f=` candidates, and `/assets` answered every one with the full original. This wraps the
3
+ // plain route with exactly the variants the markup can mint for that source, decoded once per
4
+ // content hash through the pipeline `/media` uses — and refuses every other query before a decode.
5
+
6
+ import { finiteCount, probeImage, sniffImageFormat } from '@ultimat3/core';
7
+ import type { Route, UltimateRequest } from '@ultimat3/http';
8
+ import { applyCacheHeaders, jsonResponse } from '@ultimat3/http';
9
+ import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
10
+ import {
11
+ builtinImageDriver,
12
+ DEFAULT_FORMATS,
13
+ DEFAULT_WIDTHS,
14
+ parseImageQuery,
15
+ usableWidths,
16
+ } from '@ultimat3/seo';
17
+ import { variantLru } from './runtime-assets-site-cache';
18
+ import { SITE_ASSET_CACHE, siteAssetRoutes } from './site-asset-routes';
19
+ import type { SiteAsset, SiteAssetTable } from './site-assets';
20
+ import { parseHashedAssetUrl } from './site-assets';
21
+
22
+ /** What one process holds of decoded variants. Bounded: an edited asset in `x dev` adds entries. */
23
+ export const SITE_VARIANT_CACHE_BYTES = 64 * 1024 * 1024;
24
+
25
+ /** The sources core's pipeline decodes. An SVG, an icon or a film has no width to resize to. */
26
+ const RASTERS: ReadonlySet<string> = new Set(['png', 'jpeg', 'webp', 'gif']);
27
+
28
+ export interface SiteVariantOptions {
29
+ /** `assetRoutes`' `images`: one driver for `/media` and `/assets`. Omitted, core's pipeline. */
30
+ readonly images?: ImageTransformDriver | undefined;
31
+ /** Bytes of variants kept in memory, least recently used evicted. */
32
+ readonly cacheBytes?: number;
33
+ }
34
+
35
+ type Answer =
36
+ | { readonly bytes: Uint8Array; readonly contentType: string }
37
+ | { readonly refused: string; readonly widths: readonly number[] };
38
+
39
+ const undeclared = (asset: SiteAsset, cause: string, widths: readonly number[]): Response =>
40
+ jsonResponse(
41
+ {
42
+ ok: false,
43
+ error: {
44
+ code: 'X_ASSET_VARIANT_UNDECLARED',
45
+ cause: `${asset.url}: ${cause} — /assets answers only the variants responsiveImage() mints, so a public URL is never a resize service`,
46
+ fix: `responsiveImage({ src: asset('${asset.path}'), width, height, alt }) # declared here: ?w=${widths.join('|') || 'none'} with ?f=${DEFAULT_FORMATS.join('|')} or no f; an upload under /media answers any width`,
47
+ meta: { url: asset.url, widths: [...widths], formats: [...DEFAULT_FORMATS] },
48
+ },
49
+ },
50
+ { status: 404 },
51
+ );
52
+
53
+ /**
54
+ * Why a query names no declared variant, or `undefined` when it names one. Widths are the srcset's
55
+ * own (`usableWidths` over `DEFAULT_WIDTHS`, asked rather than restated), formats `DEFAULT_FORMATS`
56
+ * or the source's own, and never a quality: `responsiveImage` writes none.
57
+ */
58
+ function undeclaredBy(query: ImageQuery, widths: readonly number[]): string | undefined {
59
+ if (query.quality !== undefined) return `?q=${String(query.quality)} is a quality`;
60
+ if (query.format !== undefined && !(DEFAULT_FORMATS as readonly string[]).includes(query.format))
61
+ return `?f=${query.format} is not an offered format`;
62
+ if (query.width !== undefined && !widths.includes(query.width))
63
+ return `?w=${String(query.width)} is not a srcset width of this source`;
64
+ return undefined;
65
+ }
66
+
67
+ /** The current asset a pathname names, or `undefined` for whatever the plain route answers. */
68
+ function currentAsset(table: SiteAssetTable, pathname: string): SiteAsset | undefined {
69
+ const named = parseHashedAssetUrl(pathname);
70
+ if (named === undefined) return undefined;
71
+ try {
72
+ const asset = table.resolve(named.path);
73
+ return asset.hash === named.hash ? asset : undefined;
74
+ } catch {
75
+ // The plain route answers a missing file with its own code; this one only stands aside.
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * `/assets/*file`, the plain route for every request without a transform query — and for a URL
82
+ * that names no current asset, so a stale hash keeps its one answer — and a declared variant else.
83
+ */
84
+ export function siteAssetVariantRoutes(
85
+ table: SiteAssetTable,
86
+ options: SiteVariantOptions = {},
87
+ ): readonly Route[] {
88
+ const cache = variantLru<Answer>(
89
+ finiteCount(
90
+ 'siteAssetVariantRoutes',
91
+ 'cacheBytes',
92
+ options.cacheBytes ?? SITE_VARIANT_CACHE_BYTES,
93
+ ),
94
+ (answer) => ('bytes' in answer ? answer.bytes.byteLength : undefined),
95
+ );
96
+ return siteAssetRoutes(table).map((plain) => ({
97
+ ...plain,
98
+ handler: async (request: UltimateRequest, ctx): Promise<Response> => {
99
+ const query = parseImageQuery(request.url.searchParams);
100
+ const asset = query === null ? undefined : currentAsset(table, request.pathname);
101
+ if (query === null || asset === undefined) return plain.handler(request, ctx);
102
+
103
+ const key = `${asset.hash}:${String(query.width ?? 'own')}:${query.format ?? 'own'}:${String(query.quality ?? '')}`;
104
+ const answer = await cache.get(key, async (): Promise<Answer> => {
105
+ const source = await Bun.file(asset.file).bytes();
106
+ const format = sniffImageFormat(source);
107
+ if (format === null || !RASTERS.has(format)) {
108
+ return { refused: 'the file is not a raster image', widths: [] };
109
+ }
110
+ // A header read: past `MAX_IMAGE_PIXELS` it is core's `X_IMAGE_TOO_LARGE`, before a decode.
111
+ const intrinsic = probeImage(source).width;
112
+ const widths = usableWidths(intrinsic, DEFAULT_WIDTHS);
113
+ const why = undeclaredBy(query, widths);
114
+ if (why !== undefined) return { refused: why, widths };
115
+ const driver = options.images ?? builtinImageDriver({ read: async () => source });
116
+ const made = await driver.transform({
117
+ src: asset.url,
118
+ width: query.width ?? intrinsic,
119
+ ...(query.format === undefined ? {} : { format: query.format }),
120
+ });
121
+ return { bytes: made.bytes, contentType: made.contentType };
122
+ });
123
+ if ('refused' in answer) return undeclared(asset, answer.refused, answer.widths);
124
+ return applyCacheHeaders(
125
+ // Copied: a `Uint8Array` may sit on a `SharedArrayBuffer`, which `Response` refuses.
126
+ new Response(new Uint8Array(answer.bytes), {
127
+ headers: {
128
+ 'content-type': answer.contentType,
129
+ 'content-length': String(answer.bytes.byteLength),
130
+ },
131
+ }),
132
+ SITE_ASSET_CACHE,
133
+ );
134
+ },
135
+ }));
136
+ }
@@ -6,12 +6,19 @@
6
6
  // from `runtime-storage.ts`, because the same bytes are reachable through both.
7
7
 
8
8
  import { probeImage } from '@ultimat3/core';
9
- import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
10
- import { applyCacheHeaders } from '@ultimat3/http';
9
+ import type { ObjectSource, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
10
+ import { applyCacheHeaders, notModified, objectResponse } from '@ultimat3/http';
11
11
  import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
12
12
  import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery, usableWidths } from '@ultimat3/seo';
13
- import type { ImageTransform, Storage, VariantFormat } from '@ultimat3/storage';
14
- import { DEFAULT_QUALITY, isTenantScoped, isVariantFormat, variantKey } from '@ultimat3/storage';
13
+ import type { ImageTransform, Storage, StorageObject, VariantFormat } from '@ultimat3/storage';
14
+ import {
15
+ DEFAULT_QUALITY,
16
+ isVariantFormat,
17
+ objectNotFound,
18
+ variantFormatOf,
19
+ variantKey,
20
+ variantRevision,
21
+ } from '@ultimat3/storage';
15
22
  import { faviconRoute } from './favicon';
16
23
  // The icon matrix's source, its base path and its renderer live in their own module so that
17
24
  // `pwa-artifacts.ts` — which this file imports for the manifest route — can reach them without
@@ -21,15 +28,16 @@ import { iconPlan, iconRenderer } from './icon-assets';
21
28
  import type { PwaArtifacts } from './pwa-artifacts';
22
29
  import { pwaManifestRoute } from './pwa-artifacts';
23
30
  import { revalidatedResponse } from './revalidated-response';
31
+ import { siteAssetVariantRoutes } from './runtime-assets-site';
24
32
  import {
25
33
  AUTHORIZED_OBJECT_CACHE,
26
34
  assertReadableKey,
35
+ assertVariantCurrent,
27
36
  authorizeStorageRead,
37
+ readStorageObject,
28
38
  STORAGE_READ_PERMISSION,
29
39
  } from './runtime-storage';
30
- import { siteAssetRoutes } from './site-asset-routes';
31
40
  import { siteAssetTable } from './site-assets';
32
- import { storedObjectHeaders } from './stored-object-headers';
33
41
 
34
42
  /**
35
43
  * Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it.
@@ -42,14 +50,6 @@ import { storedObjectHeaders } from './stored-object-headers';
42
50
  */
43
51
  export const MEDIA_BASE_PATH = '/media';
44
52
 
45
- /**
46
- * A content-addressed variant answers forever with the same bytes, so the immutable hint is a fact
47
- * about the key. It is NOT a fact about this route — see `mediaCache`. The generated ICONS are not
48
- * content-addressed (`/icons/icon-192.png` names a size, not bytes), so they revalidate instead —
49
- * see `revalidatedResponse`.
50
- */
51
- const IMMUTABLE_IMAGE: CacheHint = { mode: 'immutable' };
52
-
53
53
  /**
54
54
  * Whether a variant is one the framework itself can MINT, and therefore one worth storing.
55
55
  *
@@ -78,65 +78,105 @@ const isMintableWidth = (width: number | undefined, intrinsic: number): boolean
78
78
  const isMintableQuality = (quality: number | undefined): boolean =>
79
79
  quality === undefined || quality === DEFAULT_QUALITY;
80
80
 
81
- const imageResponse = (bytes: Uint8Array, contentType: string, cache: CacheHint): Response =>
82
- applyCacheHeaders(
83
- // Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
84
- // `SharedArrayBuffer`, which `Response` does not accept, and copying is what makes that true
85
- // by construction rather than by a cast that would only silence it.
86
- new Response(new Uint8Array(bytes), {
87
- headers: { 'content-type': contentType, ...storedObjectHeaders(contentType) },
88
- }),
89
- cache,
90
- );
91
-
92
81
  /**
93
- * Immutable is a claim about the KEY, and a tenant-scoped key names one org's private object: a
94
- * CDN or shared proxy that stores it under a public URL for a year hands it to every other tenant,
95
- * which is the cross-tenant read one hop removed. `/media` declared `public, max-age=31536000,
96
- * immutable` for exactly those keys until this branch. Applied in the handler rather than declared
97
- * in `meta.cache` because the posture is decided by the key, which no route declaration can see —
98
- * the pipeline's `cache-headers` stage only fills a `cache-control` a handler did not set.
82
+ * Bytes already in hand, as `objectResponse` asks for them. Copied, not passed through: a
83
+ * `Uint8Array<ArrayBufferLike>` may be backed by a `SharedArrayBuffer`, which `Response` does not
84
+ * accept, and copying is what makes that true by construction rather than by a cast.
99
85
  */
100
- const mediaCache = (key: string): CacheHint =>
101
- isTenantScoped(key) ? AUTHORIZED_OBJECT_CACHE : IMMUTABLE_IMAGE;
86
+ const heldBytes = (bytes: Uint8Array): ObjectSource['open'] => {
87
+ const body = new Uint8Array(bytes);
88
+ return (range) => (range === undefined ? body : body.subarray(range.start, range.end + 1));
89
+ };
102
90
 
103
91
  /**
104
92
  * `exactOptionalPropertyTypes` makes an explicit `undefined` a different answer from an absent
105
93
  * key, and `variantKey` reads presence — so a spread, never an assignment.
106
94
  */
107
- function storageTransform(query: ImageQuery, format: VariantFormat | undefined): ImageTransform {
95
+ function storageTransform(
96
+ query: ImageQuery,
97
+ format: VariantFormat,
98
+ revision: string,
99
+ ): ImageTransform {
108
100
  return {
109
101
  ...(query.width === undefined ? {} : { width: query.width }),
110
- ...(format === undefined ? {} : { format }),
102
+ format,
111
103
  ...(query.quality === undefined ? {} : { quality: query.quality }),
104
+ revision,
112
105
  };
113
106
  }
114
107
 
115
108
  /**
116
- * A cache hit costs one `exists` and one `get`; a miss costs a decode. The source is read once
117
- * either way and handed to the driver rather than fetched again — `read` exists so seo never has
118
- * to guess whether a `src` is a path, a key or a URL, and here it is unambiguously a storage key.
109
+ * The validator of one transform of one revision of a source. Built only from the source's own
110
+ * etag and values this route parsed, so a `?f=` no format names never reaches a header: `undefined`
111
+ * there, and the driver refuses that URL. An unknowable source etag is `undefined` too.
112
+ */
113
+ function variantEtag(
114
+ source: StorageObject,
115
+ query: ImageQuery,
116
+ format: VariantFormat | undefined,
117
+ ): string | undefined {
118
+ const bare = source.etag.replace(/^(W\/)?"|"$/g, '');
119
+ if (bare === '') return undefined;
120
+ if (format === undefined && query.format !== undefined) return undefined;
121
+ const transform = [
122
+ query.width === undefined ? 'intrinsic' : `w${query.width}`,
123
+ format ?? 'source',
124
+ ...(query.quality === undefined ? [] : [`q${query.quality}`]),
125
+ ];
126
+ return `"${bare}-${transform.join('-')}"`;
127
+ }
128
+
129
+ /**
130
+ * The source is STATED first, so a deleted one is `X_STORAGE_NOT_FOUND` on every disk before any
131
+ * variant is consulted, and its etag decides the key: a variant is looked up under the revision and
132
+ * the format it would be made in, so an overwrite misses it and `?w=` cannot share a key with
133
+ * `?w=&f=webp`. A key no one can state honestly (a `gif`, an unknowable etag) is served, never
134
+ * stored. A revalidation costs one `stat`, a hit one `exists` and one `get`, a miss a decode.
119
135
  */
120
136
  async function transformedVariant(
121
137
  storage: Storage,
138
+ request: UltimateRequest,
122
139
  key: string,
123
140
  query: ImageQuery,
124
141
  images: ImageTransformDriver | undefined,
125
142
  ): Promise<Response> {
126
143
  const disk = storage.disk();
144
+ await assertVariantCurrent(disk, storage.defaultDisk, key);
145
+ const object = await disk.stat(key);
146
+ if (object === undefined) throw objectNotFound(storage.defaultDisk, key);
147
+
127
148
  // A format storage cannot name has no variant key, so it cannot be cached. The driver refuses
128
149
  // it with core's `X_IMAGE_UNSUPPORTED`; refusing it here too would give one bad URL two codes.
150
+ const named = query.format;
129
151
  const format =
130
- query.format !== undefined && isVariantFormat(query.format) ? query.format : undefined;
131
- const cacheable = query.format === undefined || format !== undefined;
132
- const cached = cacheable ? variantKey(key, storageTransform(query, format)) : undefined;
133
- // The SOURCE key decides the posture, not the variant's: `variantKey` keeps the source's prefix,
134
- // so a variant of `org/<id>/…` is one org's object too, and reading the hint off the derived key
135
- // would be a second answer to a question the source already settled.
136
- const cache = mediaCache(key);
152
+ named === undefined
153
+ ? variantFormatOf(object.contentType)
154
+ : isVariantFormat(named)
155
+ ? named
156
+ : undefined;
157
+ const revision = variantRevision(object.etag);
158
+ const cached =
159
+ format === undefined || revision === undefined
160
+ ? undefined
161
+ : variantKey(key, storageTransform(query, format, revision));
162
+
163
+ const etag = variantEtag(object, query, format);
164
+ // Before anything is read: this is the request a revalidating browser makes for every image.
165
+ const fresh = etag === undefined ? undefined : notModified(request, etag);
166
+ if (fresh !== undefined) return applyCacheHeaders(fresh, AUTHORIZED_OBJECT_CACHE);
167
+ const answer = (contentType: string, bytes: Uint8Array): Promise<Response> =>
168
+ objectResponse(
169
+ request,
170
+ {
171
+ object: { contentType, size: bytes.byteLength, etag },
172
+ open: heldBytes(bytes),
173
+ },
174
+ { cache: AUTHORIZED_OBJECT_CACHE },
175
+ );
176
+
137
177
  if (cached !== undefined && (await disk.exists(cached))) {
138
178
  const hit = await disk.get(cached);
139
- return imageResponse(hit.bytes, hit.object.contentType, cache);
179
+ return answer(hit.object.contentType, hit.bytes);
140
180
  }
141
181
 
142
182
  const source = await disk.get(key);
@@ -157,12 +197,15 @@ async function transformedVariant(
157
197
  });
158
198
  if (
159
199
  cached !== undefined &&
200
+ // The bytes just decoded are the revision the key names, or an overwrite landed in between and
201
+ // this variant would answer for bytes it was not made from.
202
+ source.object.etag === object.etag &&
160
203
  isMintableWidth(query.width, intrinsic) &&
161
204
  isMintableQuality(query.quality)
162
205
  ) {
163
206
  await disk.put(cached, variant.bytes, { contentType: variant.contentType });
164
207
  }
165
- return imageResponse(variant.bytes, variant.contentType, cache);
208
+ return answer(variant.contentType, variant.bytes);
166
209
  }
167
210
 
168
211
  /**
@@ -181,17 +224,17 @@ async function mediaResponse(
181
224
  authorizeStorageRead({ disk: storage.defaultDisk, key: requested }, ctx);
182
225
  const key = assertReadableKey(storage, storage.defaultDisk, requested, ctx.actor);
183
226
  const query = parseImageQuery(request.url.searchParams);
184
- if (query !== null) return transformedVariant(storage, key, query, images);
185
- // No transform asked for: the object itself, still under the storage key's own safety checks.
186
- const read = await storage.disk().get(key);
187
- return imageResponse(read.bytes, read.object.contentType, mediaCache(key));
227
+ if (query !== null) return transformedVariant(storage, request, key, query, images);
228
+ // No transform asked for: the object itself, read and answered exactly as `/_storage` answers it.
229
+ const served = await readStorageObject(storage, { disk: storage.defaultDisk, key }, ctx.actor);
230
+ return objectResponse(request, served, { cache: AUTHORIZED_OBJECT_CACHE });
188
231
  }
189
232
 
190
233
  export interface AssetRoutesOptions {
191
234
  /** App root. The source icon is resolved against it; storage keys never are. */
192
235
  readonly root: string;
193
236
  readonly storage: Storage;
194
- /** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's PNG/JPEG/WebP pipeline. */
237
+ /** Replaces `builtinImageDriver` for `/media/*` and `/assets/*`. Omitted, core's pipeline. */
195
238
  readonly images?: ImageTransformDriver;
196
239
  /**
197
240
  * `manifest.webmanifest`, resolved at boot by `pwa-artifacts.ts`. Absent when the app declares
@@ -230,7 +273,7 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
230
273
  // whatever is on the app's only disk, which is every tenant's uploads, so it takes `/_storage`'s
231
274
  // declaration verbatim. `enforcedBy: 'handler'` for the reason that route gives — the `authz`
232
275
  // stage resolves a policy from `@ultimat3/render`'s page table, which this route is not in.
233
- // `cache` declares the conservative posture; `mediaCache` narrows or widens it per key.
276
+ // No key's bytes are fixed and every answer is authorized for one actor, so one posture for all.
234
277
  routes.push({
235
278
  method: 'GET',
236
279
  path: `${MEDIA_BASE_PATH}/*key`,
@@ -252,8 +295,9 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
252
295
  routes.push(faviconRoute(options.root));
253
296
  // And every other public file of the site: `apps/web/site/assets/**` at the hashed URLs
254
297
  // `asset()` mints, immutable. The same table `loadApp` installed for the renderer, so the URL a
255
- // document names and the URL this answers are one computation.
256
- routes.push(...siteAssetRoutes(siteAssetTable(options.root)));
298
+ // document names and the URL this answers are one computation. A raster also answers the
299
+ // `?w=&f=` variants a served `responsiveImage` mints for it, through the same driver as `/media`.
300
+ routes.push(...siteAssetVariantRoutes(siteAssetTable(options.root), { images: options.images }));
257
301
  // Same rule one asset further along: the icons above are the ones this manifest NAMES, so the
258
302
  // two belong to one surface and cannot be mounted from two places without drifting apart.
259
303
  if (options.pwa !== undefined) routes.push(pwaManifestRoute(options.pwa));
@@ -5,6 +5,8 @@
5
5
  import { mkdirSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import type { RealtimeConfig } from '@ultimat3/core';
8
+ import { renderThrowable } from '@ultimat3/core';
9
+ import { StorageUnwritableError } from './errors';
8
10
  import { REALTIME_DEFAULTS } from './runtime-realtime';
9
11
  import { safeUrlLabel } from './safe-url-label';
10
12
 
@@ -57,6 +59,22 @@ const s3EndpointOf = (env: Env): string | undefined => nonEmpty(env['S3_ENDPOINT
57
59
  */
58
60
  export const storageIsExternal = (env: Env): boolean => s3EndpointOf(env) !== undefined;
59
61
 
62
+ /**
63
+ * `.x/`, coded. A read-only root filesystem (the chart's `readOnlyRootFilesystem: true`) with
64
+ * `DATABASE_URL` set and no `S3_ENDPOINT` threw a bare `EROFS`/`EACCES` here — every role,
65
+ * `ROLE=migrate` included — before `startStorage`'s own coded refusal of the same disk could run.
66
+ */
67
+ function makeStateDir(stateDir: string): void {
68
+ try {
69
+ mkdirSync(stateDir, { recursive: true });
70
+ } catch (cause) {
71
+ throw new StorageUnwritableError(
72
+ `the embedded database or storage disk needs the state directory ${stateDir} and it could not be created: ${renderThrowable(cause)}`,
73
+ `export ULTIMATE_STATE_DIR=/tmp/ultimate-state # any writable directory or volume; or set DATABASE_URL, S3_ENDPOINT and S3_BUCKET so nothing embedded needs ${stateDir}`,
74
+ );
75
+ }
76
+ }
77
+
60
78
  /**
61
79
  * Embedded Postgres is PGlite on disk under `.x/`, so a restart keeps the data and a `x db reset`
62
80
  * is a directory delete rather than a container dance.
@@ -76,9 +94,7 @@ export function resolveServices(
76
94
  // do, in-process events never touch the disk. A container whose bindings are external runs
77
95
  // non-root over a read-only app directory, and a mkdir there is an EACCES at boot for a directory
78
96
  // that would have stayed empty — which an unset NATS_URL alone used to trigger.
79
- if (databaseUrl === undefined || s3Endpoint === undefined) {
80
- mkdirSync(stateDir, { recursive: true });
81
- }
97
+ if (databaseUrl === undefined || s3Endpoint === undefined) makeStateDir(stateDir);
82
98
  return {
83
99
  root,
84
100
  stateDir,
@@ -4,7 +4,7 @@
4
4
  // and lru unconditionally, redis on `REDIS_URL` and cdn on any real purge driver, so the key was
5
5
  // declared, validated at boot, documented, and read by nothing.
6
6
 
7
- import type { CacheTier, PurgeDriver } from '@ultimat3/cache';
7
+ import type { CacheTier, PurgeDriver, RedisLike } from '@ultimat3/cache';
8
8
  import {
9
9
  CacheDriverUnavailableError,
10
10
  cdnTier,
@@ -28,6 +28,7 @@ import {
28
28
  logger,
29
29
  renderThrowable,
30
30
  } from '@ultimat3/core';
31
+ import { redisClient, redisUrlSource } from '@ultimat3/core/redis';
31
32
  import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
32
33
  import type { Env } from './runtime-bindings';
33
34
 
@@ -69,6 +70,8 @@ export interface CacheTiersOptions {
69
70
  * `FIRST_DIAL_GRACE_MS`; a test of an OUTAGE passes 0.
70
71
  */
71
72
  readonly firstDialGraceMs?: number | undefined;
73
+ /** The client a `redis` rung reads, for the URL `env` names. Default core's `redisClient`. */
74
+ readonly redisClient?: ((url: string) => RedisLike) | undefined;
72
75
  }
73
76
 
74
77
  /**
@@ -100,14 +103,15 @@ export const cacheTiersOf = (config: AppConfig | undefined): readonly CacheTierN
100
103
  config?.cache.tiers ?? DEFAULT_CACHE_TIERS;
101
104
 
102
105
  /**
103
- * `REDIS_URL` is the same "an unset variable means the embedded default" law the db, events,
104
- * storage, mail and CDN bindings follow — and it is the variable Bun's own `Bun.redis` reads, so a
105
- * tier selected here and a client built there cannot point at two servers.
106
+ * The client for `url`, built at its first command, never at selection: `redisTier` dials nothing
107
+ * until it is read, and handing it a built client would dial at boot. `client` is memoised per URL
108
+ * (core's `redisClient`), so asking it per command is one lookup.
106
109
  */
107
- function redisUrl(env: Env): string | undefined {
108
- const url = env['REDIS_URL']?.trim();
109
- return url === undefined || url === '' ? undefined : url;
110
- }
110
+ const lazyRedis = (url: string, client: (url: string) => RedisLike): RedisLike => ({
111
+ get: (key) => client(url).get(key),
112
+ set: (key, value) => client(url).set(key, value),
113
+ send: (command, args) => client(url).send(command, args),
114
+ });
111
115
 
112
116
  /**
113
117
  * One rung, or a refusal. A process that cannot build what it was configured to build must not
@@ -125,16 +129,20 @@ function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
125
129
  return memoTier();
126
130
  case 'lru':
127
131
  return lruTier();
128
- case 'redis':
129
- if (redisUrl(options.env) === undefined) {
132
+ case 'redis': {
133
+ // `REDIS_URL` then `VALKEY_URL`, out of the boot's env: core's own order (`redisUrlSource`),
134
+ // so the URL checked here is the URL the client dials.
135
+ const source = redisUrlSource(options.env);
136
+ if (source === undefined) {
130
137
  throw new CacheDriverUnavailableError({
131
138
  driver: 'redis',
132
139
  cause:
133
- 'cache.tiers names it and REDIS_URL is unset, so this process would read a per-process ladder while the config declares a shared one',
140
+ 'cache.tiers names it and REDIS_URL (or VALKEY_URL) is unset, so this process would read a per-process ladder while the config declares a shared one',
134
141
  fix: 'set REDIS_URL in .env, or drop the redis tier from cache.tiers in app.config.ts',
135
142
  });
136
143
  }
137
- return redisTier();
144
+ return redisTier({ client: lazyRedis(source.url, options.redisClient ?? redisClient) });
145
+ }
138
146
  case 'cdn':
139
147
  // A noop tier would put a `cdn` line in every invalidation report claiming keys an edge that
140
148
  // does not exist had accepted — and the `/_x` cache panel renders those reports, so the lie
@@ -159,8 +167,9 @@ function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
159
167
  */
160
168
  function warnUnnamed(options: CacheTiersOptions): void {
161
169
  const named = new Set(options.tiers);
162
- if (!named.has('redis') && redisUrl(options.env) !== undefined) {
163
- logger.warn('cache.tier.unnamed', { tier: 'redis', source: 'REDIS_URL' });
170
+ const redis = redisUrlSource(options.env);
171
+ if (!named.has('redis') && redis !== undefined) {
172
+ logger.warn('cache.tier.unnamed', { tier: 'redis', source: redis.key });
164
173
  }
165
174
  if (!named.has('cdn') && !isNoopPurgeDriver(options.purge)) {
166
175
  logger.warn('cache.tier.unnamed', { tier: 'cdn', source: options.purge.name });