@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,404 @@
1
+ # Storage and uploads
2
+
3
+ **Call sites name a disk, never a driver.** Package `@ultimat3/storage` (tier 1) — the full
4
+ reference is
5
+ [`packages/storage/README.md`](https://github.com/developerz-ai/ultimate/blob/main/packages/storage/README.md),
6
+ and the upload design is
7
+ [`docs/architecture/17-uploads.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/architecture/17-uploads.md).
8
+
9
+ ```ts
10
+ import { defineStorage, disk, localDriver, s3Driver, scopedKey } from '@ultimat3/storage';
11
+
12
+ defineStorage({
13
+ disks: {
14
+ uploads: localDriver({ root: '.storage/uploads' }),
15
+ media: s3Driver({ bucket: 'media', endpoint: process.env.S3_ENDPOINT, forcePathStyle: true }),
16
+ },
17
+ default: 'uploads',
18
+ });
19
+
20
+ await disk('media').put(scopedKey(orgId, 'avatars', 'a.png'), bytes, { contentType: 'image/png' });
21
+ ```
22
+
23
+ Swapping `local` for `s3` changes no call site, and `x dev` needs no S3 server.
24
+
25
+ | Driver | Backing | For |
26
+ |---|---|---|
27
+ | `localDriver` | `Bun.file` under one root; writes are staged and renamed in an order whose every crash point reads as absent, whole or untyped — never a wrong content type | dev, tests, a single node. A key cannot be a path prefix of another (`a` and `a/b`): the second is `X_STORAGE_KEY_CONFLICT`. `s3Driver` and `memoryStorageDriver` hold both |
28
+ | `s3Driver` | `Bun.s3` | any S3-compatible endpoint — AWS, R2, a self-hosted gateway. The difference is `endpoint`, `region` and `forcePathStyle`. Credentials are env var **names**, never literals |
29
+ | `memoryStorageDriver` | a `Map` in this process | a test's disk: `defineStorage({ disks: { uploads: memoryStorageDriver() } })`. Every method `localDriver` answers, the same refusals, the same signing rule; `objects()` is a copy of the stored bytes by key. Never a deployment's disk — a restart is every object gone |
30
+
31
+ **A declaration holds a disk by THUNK, never by value.** `disk('sessions')` resolves through
32
+ `defineStorage()`, which boot runs after an app's modules were evaluated — so a `scrape()` or any
33
+ other declaration built at import time takes `() => disk('sessions')` and reads it when it is
34
+ used ([Scraping](Scraping)). Called at module scope, `disk()` is `X_CONFIG_INVALID`: storage was
35
+ not defined yet.
36
+
37
+ A real S3 on a laptop is `docker/docker-compose.dev.yml`'s `s3` service — Versity S3 Gateway over a
38
+ volume, with the bucket already there (a directory under its root is a bucket, and the volume is
39
+ mounted at one). Point the app at it with `S3_ENDPOINT=http://127.0.0.1:9000`, `S3_BUCKET=<app>`,
40
+ `S3_FORCE_PATH_STYLE=1` and the key pair the file ships. That file chooses a server for development
41
+ only — production is whichever S3-compatible endpoint you run.
42
+
43
+ `S3_FORCE_PATH_STYLE` is `1` (on) or `0` (off); unset or blank is off. Anything else refuses the boot
44
+ with `X_CONFIG_INVALID`, because `true` used to read as off and send every request to `<bucket>.<host>`.
45
+
46
+ `S3_REGION` is the region requests are signed for. Unset, they are signed for `auto`: what R2 wants
47
+ and what the dev gateway is started with. An endpoint that expects another region refuses the
48
+ first write, list or delete with `X_CONFIG_INVALID`, and the fix line names the value to set.
49
+
50
+ ## A listing is the framework's one page
51
+
52
+ `disk(name).list({ prefix, cursor, limit })` answers `Page<StorageListEntry>` — `{ rows,
53
+ nextCursor, hasMore }`, the shape `findMany` and a query's `.page()` answer. `nextCursor` is a
54
+ string exactly when another object exists past the page, on every driver: a full last page is
55
+ `hasMore: false`, never a cursor to an empty one. `cursor: null` (or absent) is the first page, so
56
+ `cursor = page.nextCursor` until it is `null` walks the prefix. `limit` is a positive integer or
57
+ `X_INVARIANT`. Until 25.0.0 it was `{ objects, truncated, cursor? }` ([Upgrading](Upgrading)).
58
+
59
+ ## A ranged read — `get(key, { range: [first, last] })`
60
+
61
+ `As of 2026-10-31`. Both offsets INCLUSIVE, as HTTP's `Range:` is. s3 sends ONE signed GET with
62
+ `Range:` and reads the window, the size and the etag off that same answer, the local disk slices the file, memory slices its copy. A `last` past the end is
63
+ clamped (`read.range` says what was served; `read.object.size` is the whole object's), a `first`
64
+ at or past the end is `X_STORAGE_RANGE_UNSATISFIABLE` (416), and `maxGetBytes` bounds the RANGE —
65
+ an object refused whole is readable a page at a time.
66
+
67
+ **A sealed object is not range-readable.** `seal()` writes one AES-256-GCM envelope whose tag
68
+ covers every byte, and there is no chunked form of it: a slice neither opens nor means anything.
69
+ Page a sealed log by sealing each page as its own object; range-read plain objects.
70
+
71
+ A ranged read is what `importRows()` reads a large upload with: a `StorageDriver` IS an
72
+ `ImportSource` (`stat` + `get(key, { range })`), one page-sized window per step, never the object
73
+ whole ([Jobs and workflows](Jobs-And-Workflows#exports-and-imports--exportrows-and-importrows)).
74
+
75
+ ## A large object — `streamRange` and `putStream`
76
+
77
+ ```ts
78
+ import { disk } from '@ultimat3/storage';
79
+
80
+ declare const key: string, first: number, last: number, contentType: string;
81
+ declare const request: Request & { body: ReadableStream<Uint8Array> };
82
+
83
+ export const window = await disk('media').streamRange(key, [first, last]);
84
+ export const object = await disk('media').putStream(key, request.body, {
85
+ maxBytes: 2e9,
86
+ contentType,
87
+ });
88
+ ```
89
+
90
+ | Call | Rule |
91
+ |---|---|
92
+ | `streamRange(key, [first, last])` | inclusive and clamped, as `get`'s range. No byte before `first` is read and there is no ceiling, because nothing is buffered. A `first` past the end is `X_STORAGE_RANGE_UNSATISFIABLE`, whose fix is `stat(key)`. `/_storage` serves `Range:` through it |
93
+ | `putStream(key, stream, { maxBytes, contentType?, cacheControl?, metadata? })` | past the required `maxBytes` the write is abandoned (`X_STORAGE_TOO_LARGE`), the source cancelled and the key left as it was. Ignores `maxPutBytes`, which bounds what `put()` buffers |
94
+ | Not taken by `putStream` | `checksum`, `retention`, `legalHold`, `serverSideEncryption`: each needs the bytes before the object exists. A type error, and `X_INVARIANT` for an untyped caller. Use `put()` |
95
+ | s3, on the wire | a body under one part is one signed PUT; past it a multipart upload, sent one part at a time, so a part (5 MiB, or `maxBytes / 10000` if larger) is what the process holds. Nothing exists at the key until the Complete; every other exit aborts the upload |
96
+ | local | staged under `.meta/.tmp/` and renamed through `put()`'s commit steps. Measured: 1 GiB over a 1 MiB `maxPutBytes` grew RSS by 3 MiB (`As of 2026-11-02`) |
97
+ | memory | a `Map` holds bytes: the contract and the refusals, not the memory profile |
98
+
99
+ Both are required `StorageDriver` methods, so a driver lacking one is a compile error. A third-party
100
+ driver reuses `assertPutStream`, `pumpBounded`, `streamLimit`, `assertStreamRange` and `streamedRange`.
101
+
102
+ ## Keys are refused, never sanitised
103
+
104
+ `assertSafeKey()` runs on every key: `..`, absolute keys, backslashes, control bytes, encoded
105
+ separators, empty segments and over-long keys are `X_STORAGE_PATH_UNSAFE`. `scopedKey(org, …)` is
106
+ `org/<org>/…`; a surface guards a client key with `isWithinOrg(key, ctx.actor.orgId)` and
107
+ `isTenantScoped(key)` (`X_STORAGE_ORG_MISMATCH`).
108
+
109
+ ## The direct-upload round trip
110
+
111
+ User uploads never go through `put()` (which buffers, and stops at `maxPutBytes`, 10 MB by default).
112
+ They go straight to the disk, in three calls — and **the client never names the key**:
113
+
114
+ | Hop | Call | What it guarantees |
115
+ |---|---|---|
116
+ | 1. server, in an action | `grantUpload({ disk, orgId: ctx.actor.orgId, request, policy })` | a signed grant for a key the server chose, under the actor's org |
117
+ | 2. browser | `uploadFile({ file, grant, onProgress })` | a PUT with real progress; `storage/upload-client.ts` is the one XHR seam in the browser |
118
+ | 3. server, at `PUT /_storage/:disk/*key` | mounted by the framework in `x dev` and `runRole`, around `acceptSignedUpload`. Answers `201 { key }` | refuses a bad or expired signature, a key outside the actor's org, more bytes or another type than was granted, or magic bytes that contradict the type |
119
+
120
+ **The mounted `PUT`** (`As of 2026-09-24`, #523) requires a signed-in actor, like the `GET`. It
121
+ needs no permission: the grant is the authorization, and the action that minted it ran its own
122
+ policy. The route checks the signature and the expiry, and it checks that the key is inside the
123
+ actor's org. It validates the upload against what the grant signed (the content type and
124
+ `maxBytes`), so a PDF granted under `uploadPolicy({ allowedContentTypes: ['application/pdf'] })`
125
+ is accepted, and it does not use `uploadPolicy()`'s image-only default. The signed `maxBytes` caps
126
+ how much of the body is read. `bodyLimitBytes` does not, and neither does an unverified query value.
127
+
128
+ The signature covers the **constraints** (the disk's base path, method, key, expiry, `maxBytes`,
129
+ content type), so editing `?x-max=` invalidates it — and so does moving the URL to another disk:
130
+ every local disk signs with the one `STORAGE_SIGNING_SECRET`, and the base path
131
+ (`/_storage/<disk>`) is what keeps a grant for `uploads` from verifying on `evidence`. Uploads are
132
+ **sniffed**: `validateUpload()` reads magic bytes and refuses a `.png` that is really HTML
133
+ (`X_STORAGE_TYPE_REJECTED`). ISO base media files are told apart by their major brand, so
134
+ `image/avif`, `image/heic`, `video/quicktime`, `audio/mp4` and `video/mp4` each pass when the
135
+ policy allows them; `audio/x-m4a` and `video/x-m4v` are read as `audio/mp4` and `video/mp4`.
136
+
137
+ **The grant binds the size at the PUT, on every disk** (`As of 2026-11-02`). `request.size` is
138
+ required: a grant with no whole byte count of at least 1 is `X_STORAGE_UPLOAD_SIZE_UNDECLARED`, and
139
+ `uploadFile` sends the file's own. An s3 disk signs it as the exact `Content-Length`, so the provider
140
+ answers any other length, a chunked body included, with 403 `SignatureDoesNotMatch` and stores
141
+ nothing. The local disks sign it as the ceiling `acceptSignedUpload` counts against, so a shorter body
142
+ passes there and not on s3. `grant.maxBytes` is that declared size. A bare
143
+ `disk.signedUrl(key, { method: 'PUT' })` on s3 still binds nothing; with a `maxBytes` and no
144
+ `contentLength` it is refused rather than ignored. Proven on the test stack's gateway. A provider
145
+ that skips SigV4's signed-header check is the one thing that would unbind it, so
146
+ `promoteAttachment` still measures. `get()` refuses an object over the disk's `maxGetBytes` (default:
147
+ its `maxPutBytes`, 10 MB) with `X_STORAGE_TOO_LARGE`; read anything larger with `stream()`.
148
+
149
+ The local disk signs with `STORAGE_SIGNING_SECRET`. The shipped development secret is accepted
150
+ **only** in `development` or `test`; a process that names no environment is treated as production
151
+ and refused at construction (`X_ENV_MISSING`).
152
+
153
+ ## A large upload, in parts, that resumes
154
+
155
+ `As of 2026-11-06`. **Past `partThresholdBytes` (64 MiB) a file goes up in parts, and a dropped
156
+ connection costs one part, not the file.** One PUT restarts from byte 0 and S3 refuses a single PUT
157
+ over 5 GiB. Five server functions, each wrapped in the app's own `action()` as `grantUpload` is,
158
+ every one held to the actor's org:
159
+
160
+ | Call | Answers |
161
+ |---|---|
162
+ | `grantMultipart({ disk, orgId, request, policy, partBytes? })` | `{ key, uploadId, partBytes, maxParts, contentType, size, expiresAt }`: `grantUpload`'s refusals, then a session. `partBytes` is what 10,000 parts need for `size`, never under 5 MiB |
163
+ | `signPart({ disk, orgId, key, uploadId, partNumber, size, policy })` | a PUT URL whose length is signed. s3: S3's own `UploadPart`, presigned by core's signer. Local: `/_storage/<disk>/<key>?x-upload=…&x-part=…`, both inside the HMAC. A part no object under `policy.maxBytes` holds — past `max((partNumber − 1) × 5 MiB + size, partNumber × size)` — is `X_STORAGE_TOO_LARGE` |
164
+ | `listParts({ disk, orgId, key, uploadId })` | `{ partNumber, etag, size }[]`, the resume |
165
+ | `completeUpload({ disk, orgId, key, uploadId, parts, policy })` | the object. Each named part must have landed, ascending, every one but the last at least 5 MiB (`X_STORAGE_PARTS_INVALID`); the etags are the disk's own, so no bucket CORS has to expose `ETag`. A total past `policy.maxBytes` aborts and is `X_STORAGE_TOO_LARGE` |
166
+ | `abortUpload({ disk, orgId, key, uploadId })` | nothing left: no session, no part |
167
+
168
+ **`policy` is required on `signPart` and `completeUpload`** (`As of 2026-11-07`; no policy is `X_INVARIANT`). The server keeps no record of a grant, and the grant's `size` is the client's own claim, so the policy the app passes on its server is the bound: pass the one the grant was made under, never a value off the request.
169
+
170
+ ```ts
171
+ import type { MultipartUploadClient, UploadGrant, UploadRequest, UploadSource } from '@ultimat3/storage';
172
+ import { uploadFile } from '@ultimat3/storage';
173
+
174
+ declare const file: UploadSource;
175
+ declare const grantPoster: (request: UploadRequest) => Promise<UploadGrant>;
176
+ declare const video: MultipartUploadClient; // the app's five action calls, plus `resume`/`onGrant`
177
+
178
+ const { key } = await uploadFile({ file, grant: grantPoster, multipart: video });
179
+ ```
180
+
181
+ Four parts in flight, each retried with core's backoff. `onProgress` counts confirmed bytes.
182
+ `resume: grant` (the one `onGrant` handed over, persisted by the caller) lists what landed and sends
183
+ only what is missing. A cancelled `signal` aborts the session; any other failure leaves it open for
184
+ the resume. The upload stays under `pending/` until `promoteAttachment`, which sniffs a head window,
185
+ so a completed 2 GiB upload is never read whole. On s3 no part passes through the app; on the local
186
+ disks the one `PUT /_storage` route takes a part URL as well as a grant's.
187
+
188
+ A disk whose driver has no `multipart` (a third-party one) is `X_STORAGE_MULTIPART_UNSUPPORTED`; a
189
+ session that was completed, aborted or never opened is `X_STORAGE_UPLOAD_UNKNOWN`.
190
+
191
+ ## Which disks `/_storage` and `/media` serve
192
+
193
+ `As of 2026-09-24` (#524): **the app's own.** Both routes read the process's one registry per
194
+ request. That registry is the last `defineStorage()` call, which is the app's when an app module
195
+ declares its disks. The boot's env-selected disk (`object` on `S3_ENDPOINT`, else `local`) is
196
+ served only when nothing declared one. A `defineStorage` at module scope in any file under
197
+ `apps/*/` is enough:
198
+
199
+ ```ts
200
+ // apps/web/shared/storage.ts
201
+ export const storage = defineStorage({
202
+ disks: { uploads: localDriver({ root: '.storage/uploads' }), evidence: s3Driver({ bucket }) },
203
+ default: 'uploads',
204
+ });
205
+ ```
206
+
207
+ | | |
208
+ |---|---|
209
+ | `storage:read` | the `GET` requires it. An app that serves nothing through these routes leaves it out of its permission set: every read is then `401` for nobody and `403 X_FORBIDDEN` for everyone else (27.0.0). Until then an undeclared `storage:read` was a runtime `500 X_PERMISSION_UNKNOWN`, which `x verify`'s `policy` step reported |
210
+ | the boot's own disk | still built. In production with no `S3_ENDPOINT`, that is a local disk and still needs `STORAGE_SIGNING_SECRET`. To skip it, export `runtime = { storage }` from `apps/<app>/runtime.ts`. `runRole` reads that file, and it replaces the env-selected disk entirely |
211
+ | `definedStorage()` | the registry or `undefined`, without a throw. It is the question the routes ask |
212
+
213
+ ## Who may read a stored object
214
+
215
+ `As of 2026-10-08` (27.0.0): **`GET /_storage/:disk/*key` and `GET /media/*key` decide on the
216
+ session, the policy and the key — in that order — and a key no tenant owns is refused unless the
217
+ app shared it.**
218
+
219
+ | Key | Served to | Otherwise |
220
+ |---|---|---|
221
+ | `org/<id>/…` | a signed-in actor holding `storage:read` whose `orgId` is `<id>` | `X_STORAGE_ORG_MISMATCH` (404) |
222
+ | under a prefix `defineStorage({ shared })` lists **for that disk** | a signed-in actor holding `storage:read` | — |
223
+ | anything else — `db-export/…`, `raw/…`, `dsr/…` | nobody | `X_STORAGE_KEY_UNSHARED` (404) |
224
+
225
+ Both refusals are decided from the key alone, before a disk is read, so neither says whether an
226
+ object exists. Until 26.x the third row was served to every holder of `storage:read`: "not another
227
+ tenant's" was read as "everybody's".
228
+
229
+ ```ts
230
+ import { defineStorage, localDriver, s3Driver } from '@ultimat3/storage';
231
+
232
+ defineStorage({
233
+ disks: {
234
+ uploads: localDriver({ root: '.storage/uploads' }),
235
+ evidence: s3Driver({ bucket: 'evidence' }),
236
+ },
237
+ shared: { uploads: ['brand/'] }, // whole segments, ending in '/'; never 'org/'
238
+ });
239
+ ```
240
+
241
+ A prefix is refused at boot (`X_CONFIG_INVALID`) when it names an undeclared disk, lacks its
242
+ trailing `/`, would share the whole disk, or sits inside `org/`. `storage.isShared(disk, key)` is
243
+ the question both routes ask.
244
+
245
+ **The `GET` does not read `x-sig` or `x-exp`.** A signed GET URL `localDriver` mints points at
246
+ this route, and is served on the terms above whatever its signature says: it neither opens an
247
+ unshared key nor outlives the session. To serve an object by signature alone (no session), mount
248
+ your own route around `readSignedObject({ url, disk, orgId })`, or return the bytes from an action.
249
+
250
+ ## Serving an object to a stranger
251
+
252
+ `As of 2026-11-02`. **A file of the site is an `asset()`; a stored object is your route and
253
+ `objectResponse`.** A shared prefix still needs a signed-in reader, so it cannot express an object
254
+ whose authorization is app state (a published row): that decision is your route's, and the answer
255
+ is the framework's.
256
+
257
+ ```ts
258
+ import { byteWindow, objectResponse, type UltimateRequest } from '@ultimat3/http';
259
+ import { objectNotFound, storage } from '@ultimat3/storage';
260
+
261
+ declare function publishedPost(id: string): Promise<{ imageKey: string }>;
262
+
263
+ export const handler = async (request: UltimateRequest): Promise<Response> => {
264
+ // Your rule first, from your own row. Nothing here reads a session.
265
+ const post = await publishedPost(request.params['id'] ?? '');
266
+ const disk = storage().disk();
267
+ const object = await disk.stat(post.imageKey);
268
+ if (object === undefined) throw objectNotFound(disk.name, post.imageKey);
269
+ return objectResponse(
270
+ request,
271
+ {
272
+ object,
273
+ open: async (range) =>
274
+ range === undefined
275
+ ? disk.stream(post.imageKey)
276
+ : byteWindow(await disk.stream(post.imageKey), range),
277
+ },
278
+ { cache: { mode: 'public', maxAgeSeconds: 300 } },
279
+ );
280
+ };
281
+ ```
282
+
283
+ | It answers | When |
284
+ |---|---|
285
+ | `200`, stored type, `content-length`, `etag`, `last-modified` | a plain `GET`. No etag known: no `etag` header, and no `304` |
286
+ | `206`, `content-range` | one `Range:` window; `open` receives it, so it opens exactly those bytes |
287
+ | `304` · `416` | a matching `if-none-match` · a window past the end. Neither opens anything. An `if-range` that is not this revision ignores the range: `200`, whole |
288
+ | `content-disposition: attachment` + `content-security-policy: sandbox` | any type but a raster image, audio or video. `headers` replaces this rule for a caller with its own |
289
+ | your `cache`, on every status | omitted, the route's `meta.cache` decides |
290
+
291
+ `request` needs only `header(name)`, and `object` only `{ contentType, size, etag?, lastModified? }`:
292
+ `@ultimat3/http` imports no storage, so a `StorageObject` fits and so does anything else.
293
+
294
+ **Test the real route.** `storageRoutes({ storage })` from `@ultimat3/cli` returns the mounted
295
+ `GET` and `PUT` (`servedStorage(storage)` is the registry they read), as `assetRoutes` returns
296
+ `/media`, so an app's contract test drives the route its deploy serves rather than a copy.
297
+
298
+ **The body is streamed** (`stat` + `stream`), never buffered: an object past the disk's
299
+ `maxGetBytes` is served, and a `Range` reads and drops the bytes before its start rather than
300
+ holding the object. `/media` streams the object the same way, and buffers a variant only: it
301
+ decodes images.
302
+
303
+ **One cache posture on both routes** (`As of 2026-11-02`): `private, max-age=0` plus an `etag`, for
304
+ every key. A key is not content-addressed, since an overwrite changes the bytes under it, so
305
+ nothing here answers `immutable`: `/media` once did on a shared key, and a CDN then served it to
306
+ strangers for a year and never refreshed it. A public image is an `asset()`, or your own route over
307
+ `objectResponse`.
308
+
309
+ ## Attachments, quarantine, orphans
310
+
311
+ An upload lands under `org/<org>/pending/…` before its row exists, and is promoted once it has one:
312
+
313
+ | Call | Does |
314
+ |---|---|
315
+ | `promoteAttachment({ disk, key, orgId, target, policy })` | measure, copy, then delete, onto `org/<org>/<entity>/<id>/<field>/…`. **`policy` is required** — pass the one the upload was granted under: the object's size is read with `stat()` and one over `policy.maxBytes` is `X_STORAGE_TOO_LARGE` and stays pending. Only a **pending** key: another row's attached key is `X_STORAGE_NOT_PENDING`. A second call for a key an earlier call already moved is `X_STORAGE_ALREADY_PROMOTED` (409, `meta.attachedKey`) and touches nothing: answer the row that holds that key, and never delete it in your own cleanup |
316
+ | `grantUpload({ …, quarantine: true })` → `releaseQuarantine({ disk, key, orgId })` | the place for your scanner: a quarantined key cannot be promoted (`X_STORAGE_QUARANTINED`) until your scan job releases it. The scanner is your app's (axiom 8) |
317
+ | `sweepOrphans({ disk, orgId, olderThanMs })` | deletes stale `pending/` keys of one org and answers `{ deleted, failed }` — a refused delete is reported, never counted as done |
318
+ | `deleteVariants({ disk, key })` | deletes every stored variant of one source, of every revision, and answers `{ deleted, failed }`. The source is yours to delete. A variant keeps its source's prefix, so an org-wide erasure by prefix already collects them |
319
+
320
+ **An erasure of one object must call `deleteVariants`.** `disk.delete(key)` removes the source only.
321
+ Once it is gone `/media` and `/_storage` answer `X_STORAGE_NOT_FOUND` for the variant too, but its
322
+ bytes stay on the disk, and a variant minted before revisions (`hero.png@w640.webp`) names no revision
323
+ and stays readable by its own key. Delete the source and its variants, and report `failed`.
324
+
325
+ **Promotion also reads the object** (`As of 2026-11-02`). A presigned PUT on an s3 disk signs `host`
326
+ and the size, so the uploader chose the stored type and the bytes. `promoteAttachment` reads the first
327
+ `SNIFF_WINDOW_BYTES` (64 KiB) with one ranged read and judges them by the rule the upload path uses
328
+ (`validateStoredHead`); a contradiction is `X_STORAGE_TYPE_REJECTED`, and the key stays pending.
329
+
330
+ The upload URL stays valid after that read, so the copy names the revision judged (`ifMatch`: on s3,
331
+ `x-amz-copy-source-if-match`), and another is `X_STORAGE_OBJECT_CHANGED` (409). The copy is then
332
+ checked: another size is refused and deleted; an etag that differs or is unknown (a disk with none, or
333
+ SSE-KMS, which mints one per copy) means the copy's own head is judged, and refused and deleted if it
334
+ does not pass. Bytes past the window are not sniffed on any path through promotion.
335
+
336
+ | Refused at promotion | Why |
337
+ |---|---|
338
+ | an XML or `+xml` object over 64 KiB | a browser runs a script anywhere in an XML document, and the window cannot see all of it. Upload it through the server: `validateUpload` reads the whole body |
339
+ | over 4,096 bytes of leading whitespace (`MAX_LEADING_WHITESPACE`), on every path | no real file opens that way, and past the window nothing is seen |
340
+ | a disk whose `maxGetBytes` is under the window | its own `X_STORAGE_TOO_LARGE`: the ceiling bounds the range |
341
+
342
+ A grant with a `target` has no promotion step: [Known gaps](Known-Gaps).
343
+
344
+ ## Images
345
+
346
+ A variant's key is the whole source key plus the transform — `photos/hero.png` at width 640 is
347
+ `photos/hero.png@w640.webp` — so two sources that differ only by extension never share a cached
348
+ variant. `width` and `height` are whole numbers of at least 1 (`X_INVARIANT` otherwise).
349
+
350
+ `/media` keys what it stores on the bytes and the encoding as well: `photos/hero.png@w640-r0123abcd.png`,
351
+ `r<8 hex>` being `variantRevision(object.etag)` and the format the `?f=` asked for, else the source's
352
+ (`variantFormatOf`). It reads the source first, so a deleted source is `X_STORAGE_NOT_FOUND` whatever
353
+ is still stored, and an overwrite misses every old variant. The variant's own key answers for its
354
+ source too, on both routes: once the source is gone or holds another revision, it is
355
+ `X_STORAGE_NOT_FOUND`. A source no key can state, a `gif` or an
356
+ unknowable etag, is served and never stored. Old-revision variants are bytes nobody reads:
357
+ `deleteVariants` or a bucket lifecycle rule removes them, and when is yours to choose.
358
+
359
+ `transformImage()` and `blurPlaceholder()` run on core's pipeline (`Bun.Image`, no `sharp`) and
360
+ encode `png`, `jpeg` and `webp`; `avif` is key and `srcset` math only. `variantKey()`,
361
+ `srcsetDescriptors()` and `fitDimensions()` are pure, which is how [SEO](SEO) builds `srcset`
362
+ without decoding a byte.
363
+
364
+ ### Warm the variants after the upload — `warmVariants`
365
+
366
+ The first request for each width otherwise pays the decode on the request path. A job the upload
367
+ enqueues pays it once instead, under the exact keys `/media` reads:
368
+
369
+ ```ts
370
+ import { disk, warmVariants } from '@ultimat3/storage';
371
+
372
+ const warmed = await warmVariants({
373
+ disk: disk(),
374
+ key: 'org/o1/member/m1/avatar/a.png',
375
+ widths: [48, 96, 192],
376
+ formats: ['webp'],
377
+ });
378
+ ```
379
+
380
+ `{ written, failed, skipped }`: the revision is read off the source it decodes, so a variant is never
381
+ keyed to bytes it was not made from; a width above the source's is `skipped`, never an upscale; an
382
+ `avif` fails alone with core's `X_IMAGE_UNSUPPORTED` and the rest are still written. The source is
383
+ read and probed once and held to `MAX_IMAGE_PIXELS` before a pixel is decoded. `As of 2026-11`.
384
+ `examples/dummy`'s `app/orgs/jobs/warm-avatar.ts` is the worked job.
385
+
386
+ ## Expiry — `expireObjects` and `abortStaleUploads`
387
+
388
+ `As of 2026-11-06`. **The window is the app's, at the call; the sweep runs on every disk.** A local
389
+ disk has no bucket rule, and an abandoned upload's parts bill until something aborts them.
390
+
391
+ | Call | Does |
392
+ |---|---|
393
+ | `expireObjects({ disk, prefix, olderThanMs, keep?, clock? })` | deletes every object under `prefix` past the window. `{ deleted, failed, spared }`: undated, kept or new is spared, a locked object is `failed` and never forced. An empty `prefix` (the whole disk) is refused |
394
+ | `abortStaleUploads({ disk, olderThanMs, prefix?, match? })` | aborts every session opened before the window: `{ aborted, failed, spared }` |
395
+ | `objectPurgeTarget({ name, disk, prefix, match?, olderThanMs })` | both, as one target of the app's `purge()` job (`@ultimat3/jobs`), on the job's clock. No timer in storage. `match(key)` scopes both sweeps under `prefix`: a per-tenant layout (`org/<id>/imports/…` beside `org/<id>/member/…/avatar`) is one kind of file no single prefix names, so `prefix: 'org/', match: (key) => /^org\/[^/]+\/imports\//.test(key)` sweeps every org's imports and spares the avatar beside each |
396
+
397
+ A person's stored objects are a subject target: `storageSubjectTarget(() => disk(), keysOf, {
398
+ name? })` lists the keys `keysOf(subject)` names (a list, or a prefix the app lists) for
399
+ `subjectExport()` and deletes them for `subjectErase()`. Which keys are the person's is the app's;
400
+ there is no listing by owner. [Jobs and Workflows](Jobs-And-Workflows#one-persons-data--subjectexport-and-subjecterase).
401
+
402
+ Every `X_STORAGE_*` code is in [Error codes](Error-Codes). Encryption at rest, bucket lifecycle
403
+ rules and storage classes stay **bucket** configuration, the operator's (axiom 7): `expireObjects`
404
+ is the sweep an app runs, not a rule the framework writes to a bucket.