@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
package/wiki/Money.md ADDED
@@ -0,0 +1,338 @@
1
+ # Money
2
+
3
+ ```ts
4
+ export interface Money {
5
+ readonly minor: number;
6
+ readonly currency: string;
7
+ /** Decimal places `minor` counts, when they are not the currency's own. */
8
+ readonly scale?: number;
9
+ }
10
+ ```
11
+
12
+ Integer minor units + a three-letter uppercase code — the ISO-4217 rows the framework ships (**53**, `As of 2026-08` — [Currency exponents](#currency-exponents)), plus whatever the app registers. Never a float, never a `number` in a column, never a currency-less amount.
13
+
14
+ ## The ceiling: 9,007,199,254,740,991 minor units
15
+
16
+ `minor` is a JavaScript `number`, so the largest amount a `Money` can carry is **`Number.MAX_SAFE_INTEGER` — 9,007,199,254,740,991 minor units**. In USD cents that is roughly $90 trillion; in JPY, ¥9 quadrillion.
17
+
18
+ **Past it, refused. Never truncated, never widened, never rounded into the row.** `fromMinor(2 ** 53, 'USD')` throws `X_MONEY_NOT_INTEGER`, and `t.money` fails the same value at the HTTP boundary with the field path attached — the same refusal twice, so a body that would have failed at the row write fails at the door instead.
19
+
20
+ | Where the refusal fires | Code |
21
+ |---|---|
22
+ | a value built in code — `fromMinor()`, arithmetic, `fromDecimal` | `X_MONEY_NOT_INTEGER` |
23
+ | a value off the wire — `t.money`, and therefore the OpenAPI contract | the boundary's own issue, with the field path |
24
+ | a value read back off a `bigint` column past ±2^53 | `X_INVARIANT_VIOLATED`, naming the value |
25
+
26
+ That is deliberate. `minor` is not a `bigint` because money is projected onto every wire this framework generates: `JSON.stringify` refuses a bigint, and `t.money` **is** the OpenAPI contract. A silently-widened amount that crashes the response three layers away is worse than a loud refusal at the boundary. Migrating an app with amount columns that might exceed it: [Migrating an existing app](Migrating-An-Existing-App#1-money--magnitude) carries the audit query.
27
+
28
+ ## `scale` — sub-minor precision, without a second money type
29
+
30
+ `scale` names the decimal places `minor` counts, **when they are not the currency's own**. Absent — the shape every value and every row already had — means the currency's natural minor unit: 2 for USD, 0 for JPY, 3 for KWD.
31
+
32
+ ```ts
33
+ fromMinor(1999, 'USD'); // $19.99 — no scale key, the currency's own minor unit
34
+ fromMinor(2, 'USD', 6); // $0.000002 — minor counts millionths
35
+ toDecimalString(fromMinor(2, 'USD', 6)); // '0.000002'
36
+ ```
37
+
38
+ | Rule | Detail |
39
+ |---|---|
40
+ | Range | `0 … MAX_MONEY_SCALE`, and **`MAX_MONEY_SCALE` is 15**. 10^15 is the last power of ten that is itself a safe integer, so a finer scale could not name its own unit inside the range `minor` is already checked against |
41
+ | Absent ≠ `0` | absent is "the currency's own"; `0` is "whole units". Collapsing them is a 100x reinterpretation of every ordinary price, so the key is carried only when it was sent and a value at the currency's own scale round-trips byte for byte |
42
+ | It exists because a cents-only value could not name a sub-cent amount at all | the one place that needed one — a model call costing $0.00016 — rounded up to a whole cent and reported **62x** the real spend. The alternative was a second money type, which is the axiom-1 violation the one declaration exists to prevent |
43
+ | A legacy `numeric(19,4)` fits | four decimals on a two-decimal currency is `scale: 4` — the value stays exact and stays one `Money` |
44
+ | Arithmetic meets at the **finer** scale | which is exact for both operands; the coarser one widens. Widening never throws; narrowing is `rescale()`, which takes a mode out loud |
45
+ | A comparison widens as `bigint` | so `MAX_SAFE_INTEGER` cents restated in micros can be compared without being storable |
46
+ | An out-of-range scale is `X_MONEY_SCALE_INVALID` | a scale that names no decimal place is a data bug, not a formatting preference |
47
+
48
+ `fromDecimal('12.9999', 'USD', { scale: 4 })` keeps every digit. Without the `scale`, more fraction digits than the currency has is `X_MONEY_NOT_INTEGER`, and its `fix:` offers the exact `scale` that would keep them.
49
+
50
+ **One declaration, three names.** `@ultimat3/schema`'s `MoneyValue` is the type; `@ultimat3/money`'s `Money` and `@ultimat3/entity`'s `MoneyValue` are aliases of it, not restatements of its shape. It lives at tier 0 because that is the only tier every package may import. A row a `money()` column decodes therefore *is* a `Money` — pass it to `add()`, `formatMoney()` or `<Money>` with no conversion and no cast.
51
+
52
+ The **column** is still `bigint`, wider than the value it holds — see the range rule under [Storage](#storage).
53
+
54
+ `{ minor: 1999, currency: 'USD' }` is $19.99. `{ minor: 1999, currency: 'JPY' }` is ¥1999. The exponent comes from the currency table, never from an assumed `/ 100`.
55
+
56
+ ## Why not a float
57
+
58
+ | Float behavior | Consequence |
59
+ |---|---|
60
+ | `0.1 + 0.2 === 0.30000000000000004` | a ledger that does not balance |
61
+ | Binary fractions cannot represent cents exactly | rounding drift that grows with row count |
62
+ | No currency attached | `total = subtotal + shipping` compiles across USD and EUR |
63
+ | `Math.round` mode is unspecified in law | tax rules name `half-up` or `half-even` explicitly; whichever `Math.round` does is not an answer |
64
+
65
+ An integer count of minor units is exact, and the currency travels with it so a mismatch is catchable.
66
+
67
+ ## Storage
68
+
69
+ **Three columns per amount.** No JSON blob, no `numeric` amount, no float.
70
+
71
+ | Column | Type | Notes |
72
+ |---|---|---|
73
+ | `<name>_minor` | `bigint` (`integer` under 2^31 minor units) | the integer count. `NOT NULL` |
74
+ | `<name>_currency` | `char(3)` | uppercase three-letter code. `NOT NULL`, `CHECK (<name>_currency ~ '^[A-Z]{3}$')` — the shape, not the table |
75
+ | `<name>_scale` | `integer`, **nullable** | the value's own `scale`, or `NULL` for "the currency's own minor unit". `CHECK (… is null or (… >= 0 and … <= 15))`. `NULL` decodes to an **absent key**, never to `0` — a `0` written here would claim whole units, a 100x reinterpretation of every ordinary price. Nullable on purpose: a `NOT NULL` would demand a scale on every value that has none, which is every row written before the column existed |
76
+
77
+ | Rejected storage | Why |
78
+ |---|---|
79
+ | `numeric(12,2)` | assumes exponent 2; wrong for JPY and KWD, and reads back as a string that becomes a float |
80
+ | `double precision` | see above |
81
+ | `jsonb` money object | not indexable, not sum-able, not constrainable |
82
+ | Minor units with no currency column | the amount is meaningless the day a second currency appears |
83
+
84
+ Entities declare a money field once and the generated migration emits all three columns plus the checks. See [Entities and migrations](Entities-And-Migrations).
85
+
86
+ **A table this framework did not create names its own columns.** `money({ columns: … })` maps the three onto what is already there, merged per part over the `<name>_minor` / `<name>_currency` / `<name>_scale` defaults — so a legacy `amount_cents` beside a `currency` is two words, not three:
87
+
88
+ ```ts
89
+ total: money({ columns: { minor: 'amount_cents', currency: 'currency', scale: null } }),
90
+ ```
91
+
92
+ `scale: null` says the table has no scale column at all. What that costs is that every amount is then at the currency's own minor unit — which is exactly what an absent scale already meant, so it costs no correctness. Adopting a live schema: [Migrating an existing app](Migrating-An-Existing-App).
93
+
94
+ **An amount column with no currency column beside it is not adoptable as `money()`, and should not be.** A `Money` is an amount *and* a currency by construction; a single implied currency is the bug the type exists to prevent, and a framework that guessed one would guess on every row. Declare such a column as `decimal({ precision, scale })` and carry the currency in your own column.
95
+
96
+ `<name>_scale` is **not addressable** as a predicate or a sort key. A scale says which units `minor` counts, so ordering or filtering by it compares two different questions.
97
+
98
+ **The column is wider than the value, and the gap is a refusal.** `bigint` holds more than a JS number does, so a `<name>_minor` past ±2^53 — written by a psql session, a backfill, another service, never by this framework — is refused when it is read (`X_INVARIANT_VIOLATED`, naming the value). It is never rounded into the row, and never carried as a `bigint` that would crash the response three layers later. `@ultimat3/realtime` refuses the identical value for the identical reason, so the two readers of one column agree.
99
+
100
+ A **writer** may still hand a `bigint` — `MoneyInput` is `{ minor: bigint | number; currency: string }`, so a minor unit read straight off a `bigint` column reaches an insert with no conversion at the call site. Both drivers narrow it to the value type before storing, so what a row holds never depends on which one you built.
101
+
102
+ ## Operations
103
+
104
+ Allowed, total, and typed. `As of 2026-08` the package ships the currency table, the rounding modes, the error codes, and the arithmetic surface below.
105
+
106
+ | Operation | Signature | Behavior |
107
+ |---|---|---|
108
+ | add | `add(a: Money, b: Money): Money` | same currency only; the operands meet at the finer of their two scales, which is exact for both |
109
+ | subtract | `subtract(a: Money, b: Money): Money` | same currency only; negative results are legal (refunds, credits) |
110
+ | multiply | `multiply(m: Money, factor: number, mode?: RoundingMode): Money` | exact for an integer factor. For a fraction, `mode` defaults to `DEFAULT_ROUNDING` — see [Rounding modes](#rounding-modes). The scale is taken as the exact fraction the factor's decimal spelling names, never as a float product |
111
+ | divide | `divide(m: Money, divisor: number, mode?: RoundingMode): Money` | a single share; the remainder is lost by design. `divisor: 0` is `X_ALLOCATION_INVALID` |
112
+ | negate | `negate(m: Money): Money` | |
113
+ | absolute | `absolute(m: Money): Money` | |
114
+ | allocate by ratios | `allocate(m, ratios)` · `allocateByRatios` · `allocateByPercentages` | distributes the remainder one minor unit at a time, largest ratio first. Sum always equals the input, exactly |
115
+ | compare | `compare(a: Money, b: Money): -1 \| 0 \| 1` | same currency only; widens as `bigint`, so it never throws on a value it could not store |
116
+ | predicates | `isZero`, `isNegative`, `isPositive`, `equals`, `lessThan`, `greaterThan`, `min`, `max` | |
117
+ | sum | `sum(items: readonly Money[], currency?: string): Money` | empty array requires an explicit currency |
118
+ | from decimal string | `fromDecimal(value, currency, { scale?, rounding? })` | parses `'19.99'` at the currency's exponent, or at `scale`. More fraction digits than the scale and no `rounding` is `X_MONEY_NOT_INTEGER` |
119
+ | to decimal string | `toDecimalString(m: Money): string` | for exports and APIs, not for display. `toDecimalNumber` for the lossy `number` |
120
+ | restate at another scale | `rescale(m, scale, mode?)` | widening is always exact. Narrowing needs `mode` **only when it is inexact** — nothing being decided means nothing to declare; an inexact narrowing with no mode is `X_MONEY_NOT_INTEGER`, which is literally what it would produce |
121
+ | convert | `convert(m, to, rate, options?): ConvertedMoney` | explicit rate object. Returns `{ amount, source, rate, at, provider? }` — the original is never overwritten, so a finance audit can reproduce every converted amount |
122
+
123
+ ### Rejected
124
+
125
+ | Attempt | Result |
126
+ |---|---|
127
+ | `add({ minor, currency: 'USD' }, { minor, currency: 'EUR' })` | throws `X_INVARIANT` (`X_CURRENCY_MISMATCH`) — two currencies are not one number |
128
+ | `multiply(m, 0.0825)` with no rounding mode | throws — the caller must name the mode the tax rule names |
129
+ | `{ minor: 19.99, currency: 'USD' }` | throws `X_MONEY_NOT_INTEGER`; fix names `fromDecimal` |
130
+ | `fromDecimal('19.999', 'USD')` with no `rounding` | throws — more fraction digits than USD has minor units |
131
+ | `convert(m, 'EUR')` with no rate | throws `X_RATE_MISSING`. There is no default rate provider, because a wrong rate is worse than a missing one |
132
+ | A code this process does not know | throws `X_CURRENCY_UNKNOWN` — use one `currencyCodes()` lists, or add it with `registerCurrency({ code, exponent, name })` |
133
+ | `allocate(m, [0, 0])` or a negative ratio | throws `X_ALLOCATION_INVALID` |
134
+ | `allocate(m, parts)` with more than 1,000,000 parts (`MAX_ALLOCATION_PARTS`) | throws `X_ALLOCATION_INVALID` — every part is held at once, and `1e10` was a bare `RangeError` |
135
+
136
+ Total across a currency boundary requires a conversion first. Silent coercion is how a marketplace bills in the wrong currency for a quarter.
137
+
138
+ ## Rounding modes
139
+
140
+ **`DEFAULT_ROUNDING` is `'half-up'`.** Four modes, `ROUNDING_MODES` in declaration order:
141
+
142
+ | Mode | Rule | Use |
143
+ |---|---|---|
144
+ | **`half-up`** | 0.5 **away from zero** | the commercial default most invoicing rules specify. **The package default** |
145
+ | `half-even` | 0.5 to the nearest even (banker's) | ISO 80000-1; avoids upward drift over many rows |
146
+ | `down` | truncate toward zero | never overcharge |
147
+ | `up` | away from zero | never undercharge |
148
+
149
+ `roundToInteger(value, mode)` rounds fractional minor units; `roundToDigits(value, digits, mode)` is used when a decimal string is more precise than the currency's minor unit; `roundRatio(numerator, denominator, mode)` rounds an exact `bigint` fraction, which is what `multiply`, `divide`, `convert` and `fromDecimal` all use. A float product can only judge a value IEEE-754 has already moved — `100 * 1.005` is `100.49999999999999`, so `half-up` would answer 100 where the exact 100.5 owes 101, charging a 0.5% fee on €1.00 as nothing.
150
+
151
+ `-0` is never produced: an amount rounding to nothing stays `0`, because `JSON.stringify` writes `-0` as `0` while `Object.is` and any keyed `Map` see a different value.
152
+
153
+ ### Changing rounding mode shifts every tie by one minor unit
154
+
155
+ A tie — an exact `.5` fractional minor unit — is where the modes disagree, and they disagree by exactly one minor unit every time. Under `half-up` the shift is **systematic** (always away from zero); under `half-even` it is **balanced** (half the ties go each way). That is why the two diverge with row count instead of cancelling, and why a migrating app that silently changes mode moves a reconciled ledger.
156
+
157
+ ```ts
158
+ import { fromMinor, multiply } from '@ultimat3/money';
159
+
160
+ const one = fromMinor(100, 'USD'); // $1.00
161
+
162
+ multiply(one, 0.125); // { minor: 13, currency: 'USD' } — the default, half-up
163
+ multiply(one, 0.125, 'half-up'); // { minor: 13, … } $0.13
164
+ multiply(one, 0.125, 'half-even'); // { minor: 12, … } $0.12 — 12 is even
165
+ multiply(one, 0.125, 'down'); // { minor: 12, … }
166
+ multiply(one, 0.125, 'up'); // { minor: 13, … }
167
+ ```
168
+
169
+ 12.5 minor units, four answers. One cent per tie, in one direction, over however many rows tie.
170
+
171
+ ### What other stacks default to
172
+
173
+ `As of 2026-08`, and each is configurable — check what your app actually set, not what its ecosystem defaults to.
174
+
175
+ | Stack | Default rounding | vs Ultimate |
176
+ |---|---|---|
177
+ | **Ultimate** — `DEFAULT_ROUNDING` | `half-up` (away from zero) | — |
178
+ | Ruby, the `money` gem that `money-rails` wraps — `Money.rounding_mode` | `ROUND_HALF_EVEN` (banker's) | **different.** A tie that was $0.12 becomes $0.13 |
179
+ | Python, `decimal.Decimal` — the default context's `rounding` | `ROUND_HALF_EVEN` | **different**, same direction. `ROUND_HALF_UP` in Python is opt-in per `quantize()` call, never the default |
180
+ | JavaScript `Math.round` | half toward **+∞**, not away from zero | differs on **negatives**: `Math.round(-2.5)` is `-2`, `half-up` gives `-3`. This is one reason there is no `Math.round` anywhere in `@ultimat3/money` |
181
+
182
+ **Migrating, do this in order:**
183
+
184
+ | # | Step |
185
+ |---|---|
186
+ | 1 | Read the legacy app's configured mode, not its ecosystem default |
187
+ | 2 | If it is `half-even`, pass `'half-even'` explicitly at every `multiply`/`divide`/`convert`/`fromDecimal` call site that touches money the legacy app also computed |
188
+ | 3 | Reconcile a sample before cutover: recompute one period's line items both ways and diff the totals. The difference is one minor unit per tie, so a non-zero diff tells you exactly how many ties there were |
189
+ | 4 | Whichever you choose, name it. The tax rule names a mode in law, and whichever one a default happens to implement is not an answer |
190
+
191
+ A shift of ±1 minor unit is invisible on one invoice and is a reconciliation failure across millions of rows.
192
+
193
+ ## Currency exponents
194
+
195
+ The exponent table is data, not an assumption. `As of 2026-08` it ships **53 ISO-4217 rows** ([`packages/money/src/currency.ts:21`](https://github.com/developerz-ai/ultimate/blob/main/packages/money/src/currency.ts)).
196
+
197
+ | Currency | Exponent | `minor: 1000` means |
198
+ |---|---|---|
199
+ | `USD`, `EUR`, `GBP` | 2 | 10.00 |
200
+ | `JPY`, `KRW`, `VND`, `ISK`, `CLP`, `XOF` | 0 | 1000 |
201
+ | `KWD`, `BHD`, `JOD`, `OMR`, `TND` | 3 | 1.000 |
202
+
203
+ `exponentOf(currency)` and `scaleOf(currency)` derive from the table. A hardcoded `/ 100` is wrong in nine of the codes above.
204
+
205
+ ## Registering a currency
206
+
207
+ `As of 2026-08` the table is open, and it is opened by a call — not by config, not by a fork. 53 of roughly 180 ISO codes is a *convention*, and axiom 8 says an app encodes its own by calling a function.
208
+
209
+ ```ts
210
+ import { fromMinor, registerCurrency, toDecimalString } from '@ultimat3/money';
211
+
212
+ // Once at boot, before any XBT amount is built.
213
+ registerCurrency({ code: 'XBT', exponent: 8, name: 'Bitcoin' });
214
+
215
+ const dust = fromMinor(1, 'XBT');
216
+ toDecimalString(dust); // '0.00000001'
217
+ ```
218
+
219
+ A local currency, a scrip, a loyalty point, a token — anything the shipped rows do not carry.
220
+
221
+ | Rule | Detail |
222
+ |---|---|
223
+ | Refused, never defaulted | no exponent is guessable, so a bad declaration throws `X_CURRENCY_INVALID` rather than assuming 2 — a silent 2 reads `1.23456789 XBT` as `1.23` and shifts a stored `minor` by a power of ten |
224
+ | One code, one declaration | a second, *different* declaration of a code already in force throws `X_CURRENCY_REDEFINED`. An identical one returns the row in force, so a module imported twice is not a crash |
225
+ | A shipped ISO row is closed | `registerCurrency({ code: 'USD', exponent: 3, … })` throws `X_CURRENCY_REDEFINED`; registrations live beside `CURRENCIES`, never over it |
226
+ | Before the first amount | `fromMinor()`, `fromDecimal()` and every arithmetic call resolve the exponent at the call, so an amount built before the registration throws `X_CURRENCY_UNKNOWN` |
227
+ | Per process, not per row | a registration is in-memory. Every process that reads an amount in that currency must make the same call — the DB `CHECK` is `^[A-Z]{3}$`, so the row is already writable without it |
228
+
229
+ | Enumeration | Answers |
230
+ |---|---|
231
+ | `CURRENCIES` | the 53 rows this package ships. A constant, the same in every process |
232
+ | `currencyCodes()` | every code *this process* accepts, registrations included, sorted. The list `X_CURRENCY_UNKNOWN`'s fix names |
233
+ | `isValidCurrency(code)` | whether this process accepts it, without throwing |
234
+
235
+ ### Why the table opened
236
+
237
+ Three layers already treated the currency set as open, and only arithmetic disagreed. All three now read one declaration — `CURRENCY_CODE_PATTERN = '^[A-Z]{3}$'` in `packages/schema/src/money-value.ts`.
238
+
239
+ | Layer | What it accepts | Where |
240
+ |---|---|---|
241
+ | the boundary | `isCurrencyCode` in `moneySchema` | `@ultimat3/schema`, `money-value.ts` |
242
+ | storage | `parseCurrency`, and the Postgres `CHECK` `currencyCheck()` emits | `@ultimat3/entity`, `columns.ts` |
243
+ | the published contract | `currency: { type: 'string', pattern: … }` in the generated OpenAPI | `@ultimat3/schema`, `json-schema.ts` |
244
+
245
+ So an app could already take `GHS` over HTTP, validate it, write it to Postgres and read it back — and `add()` was the only thing that refused it. The set was never closed; one package disagreed with the boundary, the storage layer and the contract it publishes. `CurrencyCode` is `string`, not a union, and the table had zero consumers outside `packages/money/` — nothing depended on it being total.
246
+
247
+ The pattern stays inside the syntax ECMAScript, JSON Schema and POSIX ERE spell identically. A `\d`, a lookahead or a non-greedy quantifier would make the Postgres `CHECK` stop meaning what `isCurrencyCode` means, and a real server is the first thing that would say so.
248
+
249
+ ## Formatting
250
+
251
+ Edge only — a route or a component. Never in a service, repo, or job.
252
+
253
+ ```ts
254
+ import { formatMoney } from '@ultimat3/money';
255
+ ```
256
+
257
+ | Rule | Detail |
258
+ |---|---|
259
+ | Engine | `Intl.NumberFormat` with `style: 'currency'` |
260
+ | Locale | explicit, from the request context. Never the process default |
261
+ | Currency | explicit, from the `Money` value. Never a template-literal `$` |
262
+ | Digits | `minimumFractionDigits` / `maximumFractionDigits` from the currency exponent |
263
+ | `trimZeroFraction: true` | drops the fraction of a **whole** amount and nothing else: 1200 USD is `$12`, 1250 USD is `$12.50` — never `$12.5`. Price lists, never invoices |
264
+ | `fractionDigits` | a whole number from 0 to 100 (`MAX_FRACTION_DIGITS`); anything else is `X_MONEY_SCALE_INVALID`, never `Intl`'s bare `RangeError` |
265
+ | Formatter cache | one per (locale, currency, options) — constructing per row is measurable |
266
+ | Never | string concatenation, `toFixed(2)`, a hand-written thousands separator |
267
+
268
+ `toDecimal` is for machine consumers (CSV, API payloads, accounting exports). `formatMoney` is for humans. They are not interchangeable.
269
+
270
+ ## An amount a person typed — `parseAmount`
271
+
272
+ `As of 2026-11-06`. `parseAmount(text, { currency, locale })` reads the separators and digits of
273
+ the person's locale off `Intl.NumberFormat`, then hands the digits to `fromDecimal` as a string:
274
+ no float, ever.
275
+
276
+ | Text | Locale | Currency | Answer |
277
+ |---|---|---|---|
278
+ | `1.234,56` | `de-DE` | EUR | 123456 |
279
+ | `1 234,56` (any space) | `fr-FR` | EUR | 123456 |
280
+ | `12,34,567.89` | `hi-IN` | INR | 123456789 |
281
+ | `١٬٢٣٤٫٥٦` | `ar-EG` | EGP | 123456 |
282
+ | `1,234` | `en-US` | USD | 123400 |
283
+ | `1,234` | `de-DE` | EUR | refused: three fraction digits, EUR has two |
284
+ | `1.5` | `de-DE` | EUR | refused: `.` does not group as `de-DE` groups |
285
+
286
+ The locale's currency symbol and the currency code are ignored, a sign is `-` or the locale's
287
+ minus. Every refusal is `X_MONEY_INPUT_INVALID` (422), whose cause names the character: re-prompt
288
+ the person, never round or guess.
289
+
290
+ ## Where money appears in the framework
291
+
292
+ | Site | Shape |
293
+ |---|---|
294
+ | LLM budgets | `budget: { tokensIn: 8000, costPerCall: { minor: 5, currency: 'USD' } }` — exceeding it throws before spending |
295
+ | LLM cost accounting | per call, per tenant, per prompt version. No MCP tool reports it yet — `budgets.report` is designed and not built. `x ai cache --json` is **planned** and exits `X_NOT_IMPLEMENTED`; `x test eval --json` is the shipped command it points at |
296
+ | Job cost accounting | per job run and per step, so an expensive retry loop is visible in the queue view |
297
+ | Admin columns | a money column renders as a right-aligned formatted amount with the currency code; the raw minor value is shown on hover |
298
+ | OTel span attributes | `cost.minor` + `cost.currency` as separate attributes, never a formatted string |
299
+
300
+ ```ts
301
+ export const summarize = llm({
302
+ model: 'claude-sonnet-5-5',
303
+ input: t.object({ postId: t.uuid }),
304
+ output: t.object({ summary: t.string, tags: t.string.array() }),
305
+ prompt: summarizePrompt, // versioned artifact
306
+ cache: { semantic: { threshold: 0.97, ttl: '7d' } },
307
+ budget: { tokensIn: 8000, costPerCall: { minor: 5, currency: 'USD' } },
308
+ policy: can('post:read'),
309
+ });
310
+ ```
311
+
312
+ See [MCP and AI](MCP-And-AI).
313
+
314
+ ## Errors
315
+
316
+ | Code | Cause | Fix |
317
+ |---|---|---|
318
+ | `X_MONEY_NOT_INTEGER` | `minor` is not a safe integer, or a decimal string is more precise than the currency | `fromDecimal(...)`, or pass an explicit rounding mode |
319
+ | `X_CURRENCY_UNKNOWN` | the code is neither a shipped ISO row nor registered by this process | pick one `currencyCodes()` lists, or declare it at boot with `registerCurrency({ code, exponent, name })` |
320
+ | `X_CURRENCY_MISMATCH` | combining two currencies — an `X_INVARIANT`-class violation | `convert(...)` first, then combine |
321
+ | `X_CURRENCY_INVALID` | a `registerCurrency` declaration that cannot become a currency — bad code shape, an exponent outside `0…15`, or an empty name | fix the declaration: three A–Z letters, a whole exponent, a non-empty name |
322
+ | `X_CURRENCY_REDEFINED` | one code registered twice with different meanings | keep one `registerCurrency` call and delete the other |
323
+ | `X_ALLOCATION_INVALID` | non-positive part count, or ratios that are non-finite, negative, or all zero | pass valid ratios |
324
+ | `X_RATE_MISSING` | no rate registered for the pair | register a `RateProvider` covering it |
325
+
326
+ Full list: [Error codes](Error-Codes).
327
+
328
+ ## Rules
329
+
330
+ - `Money = { readonly minor: number; readonly currency: string }`. No other money type exists — `MoneyValue` in `@ultimat3/schema` and in `@ultimat3/entity` is the same declaration under another name.
331
+ - Never a float. Never a bare `number` for an amount. Never a `bigint` on a row.
332
+ - Two columns: integer minor + `char(3)` currency. A stored minor unit past ±2^53 is refused on read, never rounded.
333
+ - Same currency or throw. No implicit conversion, ever.
334
+ - A non-integer multiplier requires an explicit rounding mode.
335
+ - `allocate` distributes the remainder; the parts always sum to the whole.
336
+ - Exponents come from the currency table, never from `100`.
337
+ - A currency the shipped rows lack is `registerCurrency({ code, exponent, name })` at boot, never a fork of the table.
338
+ - Format at the edge with an explicit locale and currency → [I18n](I18n).
@@ -0,0 +1,197 @@
1
+ # N+1 detection
2
+
3
+ A statement issued once per row of a set — one `findById` per member of a page where one `in`
4
+ query would answer, one `insert` per row where one `insertAll` would answer — is the loop this
5
+ detector exists to catch. `As of 2026-08`, dev-only: it costs a production process nothing.
6
+
7
+ ## The two codes
8
+
9
+ | Code | What it means |
10
+ |---|---|
11
+ | `X_N_PLUS_ONE_QUERY` | a read repeated once per row — `members.findById` called fifty times where one `in` statement would do |
12
+ | `X_N_PLUS_ONE_WRITE` | a write repeated once per row — one `insert`, `update` or `delete` per row of a set, where one statement writes all of them |
13
+
14
+ Both codes are **declared and composed** by `@ultimat3/entity` (`nPlusOne()` in
15
+ `packages/entity/src/errors.ts`); nothing else builds the `code`/`cause`/`fix` text, so `x dev`'s
16
+ findings, the timeline, the overlay and a failing test all read the identical message.
17
+
18
+ Who *raises* it is a separate question, and the answer differs by surface:
19
+
20
+ | Surface | What happens at the threshold |
21
+ |---|---|
22
+ | `testStatements` (`@ultimat3/testing`) | **throws** `nPlusOne(…)` from inside `onStatement`, so the failing line is the loop's own line |
23
+ | `x dev` (`packages/cli/src/dev-n-plus-one.ts`) | **records a verdict and logs one warning** per request per code — it never throws, because a reporting-only observer that threw would break the app it is watching |
24
+ | `/_x` and the browser overlay | render the recorded verdict; they raise nothing |
25
+
26
+ `packages/cli/src/statement-loop.ts` is what keeps them identical: it calls `@ultimat3/entity`'s
27
+ `nPlusOne` and reads `code`/`cause`/`fix` off the resulting error rather than restating any of it.
28
+ Full row, verbatim: [Error codes → Entity and database](Error-Codes#entity-and-database).
29
+
30
+ ## Before / after
31
+
32
+ ```ts
33
+ // naive: one statement per row
34
+ const authors = [];
35
+ for (const post of posts) {
36
+ authors.push(await db.members.findById(post.authorId));
37
+ }
38
+ ```
39
+
40
+ ```ts
41
+ // one statement for the page
42
+ const page = await db.posts.where({ orgId }).preload('author').page();
43
+ ```
44
+
45
+ `preload('author')` is derived from the `references()` column the entity already declares — there
46
+ is no separate list of valid relation names to keep in sync, so a name that doesn't resolve to one
47
+ is `X_PRELOAD_UNKNOWN_RELATION`, not a silent miss. Detail on preload's own semantics — scoping,
48
+ concurrency, projection — is [Batching and preloading](Batching-And-Preloading); the row-by-row
49
+ shape it collapses is also covered in [Entities and migrations → Point lookups batch
50
+ themselves](Entities-And-Migrations).
51
+
52
+ The write side's fix is the equivalent bulk call: `db.<entity>.insertAll(rows)`,
53
+ `db.<entity>.updateWhere(filter, patch)`, `db.<entity>.deleteWhere(filter)` — named directly in the
54
+ error's `fix:` line, keyed off which of the three the loop was.
55
+
56
+ ## How a finding reaches you: four surfaces, one verdict
57
+
58
+ A statement shape that crosses the threshold inside one request is reported everywhere at once —
59
+ never one surface first and the others eventually:
60
+
61
+ | Surface | Where |
62
+ |---|---|
63
+ | `x dev`'s findings | printed in the terminal, and in `--json` |
64
+ | `/_x/timeline` | the `nPlusOne` field alongside `repeatedSql`, the admin dev panel |
65
+ | the browser dev overlay | rendered next to the error as a notice, same code/cause/fix |
66
+ | the logger | one `logger.warn` line, per request, per code |
67
+
68
+ All four read the same `StatementLoopFact` (`packages/cli/src/statement-loop.ts`) built from one
69
+ `RepeatedStatement`, so a report can never say something different on one surface than another. The
70
+ log line is intentionally coarser than the findings list: a request that loops three different
71
+ *shapes* of read still gets one `X_N_PLUS_ONE_QUERY` warning, because a log is read to learn *that*
72
+ this request looped — the three shapes themselves are what the other three surfaces are for.
73
+
74
+ **The threshold** is 5 statements of one shape (`N_PLUS_ONE_THRESHOLD`,
75
+ `packages/entity/src/n-plus-one.ts`), and it is the one thing the two detectors genuinely share:
76
+ `x dev`'s ledger and `testStatements` both import that constant, so neither can be tuned
77
+ without the other moving with it.
78
+
79
+ Everything else about them differs, deliberately:
80
+
81
+ | | `x dev`'s ledger | `testStatements` |
82
+ |---|---|---|
83
+ | Counting window | one `Ctx` — a `WeakMap` keyed on the request, which dies with it | fixture creation → disposal, including statements sent outside any request |
84
+ | At the threshold | records a verdict, logs once per request per code | throws, once per shape |
85
+ | `expectedQueryLoop` statements | not counted at all — this ledger *is* the verdict | counted by `count()` and `shapes()`, excluded only from the `unexpected` tally the throw reads |
86
+
87
+ So a shape can be visible in a test's `shapes()` and absent from `x dev`'s verdict list for the
88
+ same run, and neither is wrong: one is measuring, the other is judging.
89
+
90
+ ## The seam it rides on
91
+
92
+ None of this reaches into the driver. `@ultimat3/db`'s `observe.ts` is a tier-1, process-wide
93
+ `StatementObserver` seam — `setStatementObserver(observer)` installs one, `statementObserver()`
94
+ reads it — that the two statement funnels (`runOn` in `statement-funnel.ts`, `statement()` in `pglite.ts`)
95
+ call after every statement settles, success or failure. Every call site guards first: read the
96
+ accessor, branch on `undefined`, and only build a `StatementEvent` once something is actually
97
+ installed. Nothing is allocated on the path when nothing is watching — one property read and one
98
+ branch, uninstalled — which is what makes this genuinely free in production (axiom 6): `serve.ts`
99
+ never calls `setStatementObserver`, so a production process always takes that branch.
100
+
101
+ `@ultimat3/entity`'s `postgresRepo` (`packages/entity/src/pg-driver.ts`) is what attaches an
102
+ `entity`/`op` pair to each statement, via `withStatementAttribution(entity.$name, op, send)`. It is
103
+ the last frame that still knows both a repository is calling `findById` on `members` before the SQL
104
+ is compiled — below it there's only text, which is why hand-written SQL and job-queue statements
105
+ show up unattributed rather than misattributed. The queue's own per-step write — one
106
+ `insert … on conflict` into `x_job_steps` per `step.run` — arrives *expected*: `steps.ts` declares
107
+ it one-per-step by design, so a job of five steps is not a verdict against the framework.
108
+
109
+ ## Where it installs: dev only
110
+
111
+ `x dev` — `startDev()` in `packages/cli/src/cmd-dev.ts` — builds a `StatementLedger`
112
+ (`createStatementLedger()`, `packages/cli/src/dev-n-plus-one.ts`) and installs its `observer` at
113
+ boot, before the app's own modules load, alongside the trace recorder. The ledger keys its
114
+ per-shape counts on the request's `Ctx` object in a `WeakMap`, so state dies with the request it
115
+ belongs to and nothing needs sweeping. `packages/cli/src/serve.ts` — the production entry point —
116
+ never calls `setStatementObserver` at all. There is no flag to disable this in dev and no flag to
117
+ enable it in prod; the wiring itself is the switch.
118
+
119
+ ## Silencing a genuine one-per-row loop
120
+
121
+ Some loops really are optimal one-statement-per-row — a handful of independent indexed lookups is
122
+ sometimes cheaper than one unindexed `OR` across all of them. The one way to say so is
123
+ `expectedQueryLoop(reason, fn)` (`packages/db/src/expected-loop.ts`):
124
+
125
+ ```ts
126
+ // one indexed lookup per search field beats one unindexed OR across all of them
127
+ return expectedQueryLoop('search runs one indexed lookup per field', async () => {
128
+ for (const field of fields) hits.push(...(await repo.list({ where: [eq(field, term)] })));
129
+ return hits;
130
+ });
131
+ ```
132
+
133
+ `reason` is required and must be non-blank, because an exemption with no argument is a pragma with
134
+ extra steps and the whole point is that the argument is written down next to the loop it defends. A
135
+ blank one throws `X_INVARIANT` at the call, carrying its own repair:
136
+
137
+ | | |
138
+ |---|---|
139
+ | Code | `X_INVARIANT` (borrowed from `@ultimat3/core`'s `assert`, not declared by `db`) |
140
+ | Cause | `expectedQueryLoop() was given a blank reason, so the loop it silences carries no argument` |
141
+ | `fix:` | `pass why the loop is optimal: expectedQueryLoop('one indexed lookup per field', fn)` |
142
+
143
+ ```bash
144
+ x errors explain X_INVARIANT --json
145
+ ```
146
+
147
+ The scope rides an `AsyncLocalStorage`, so it survives every
148
+ `await` inside `fn`, at any depth, and two loops running concurrently never read each other's
149
+ reason; nesting keeps the innermost one.
150
+
151
+ What it suppresses is the **verdict**, not the statement: the SQL still sends, the observer still
152
+ sees it, the trace still opens a span. A detector counting repeats is told the author already
153
+ reasoned about this one and stays quiet; anything that only measures — the timeline, a span, a
154
+ metric — keeps showing the loop exactly as before. The framework's own deliberate loops declare
155
+ themselves the same way: `migrate()`/`rollback()` (`packages/db/src/migrate.ts`) apply and reverse
156
+ one migration per transaction, one statement at a time, inside a scope naming why.
157
+
158
+ ## Failing the test instead of warning in a terminal nobody is watching
159
+
160
+ `x dev` warns; CI is not attended. `@ultimat3/testing`'s `statements` fixture
161
+ (`testStatements`, `packages/testing/src/fixture-statements.ts`) installs the identical
162
+ detector in **throw** mode for the length of one test — destructuring `statements` is the whole
163
+ opt-in, there is no `strict: true` flag to forget:
164
+
165
+ ```ts
166
+ test('the feed reads its authors once', async ({ statements }) => {
167
+ await loadFeed();
168
+ // X_N_PLUS_ONE_QUERY: members.findById ran 5 times in one request — one read per row
169
+ // fix: db.posts.preload('author') # one statement for the whole page
170
+ expect(statements.count('posts.findMany')).toBe(1);
171
+ });
172
+ ```
173
+
174
+ It counts from the moment the fixture is built, not from a request boundary — a plain unit test
175
+ calling `posts.findById(id)` in a loop has no `Ctx` anywhere, and that is exactly the loop this
176
+ fixture exists to catch, unlike the dev ledger which only counts inside a request. The verdict
177
+ still only counts unexpected statements — an `expectedQueryLoop` scope suppresses the throw the
178
+ same way it suppresses the dev warning — while `count()` and `shapes()` report every statement sent,
179
+ expected ones included, because a test asserting "this page issues two statements" must not see a
180
+ different number depending on who declared what. It throws once per shape, at the statement that
181
+ crosses the threshold, so the failing line is the loop's own line rather than a summary at
182
+ teardown; `shapes()` still reports the whole loop afterward for a test that caught the error.
183
+
184
+ ## Why this costs production nothing
185
+
186
+ Three separate facts add up to zero production cost, not one flag: the `observe.ts` seam is
187
+ guard-first and allocates nothing uninstalled; `withStatementAttribution` is the same shape,
188
+ guarding before it even opens its `AsyncLocalStorage` scope; and `serve.ts` — the only production
189
+ entry point — never calls `setStatementObserver`. Turning the detector on is entirely a fact about
190
+ which binary is running: `x dev` wires it; `apps/web/server.ts` under any `ROLE` never does. There is no `x serve` command — the production entry is the scaffolded `server.ts`, run directly or through `x build`'s artifact.
191
+
192
+ ## See also
193
+
194
+ - [Batching and preloading](Batching-And-Preloading)
195
+ - [Entities and migrations](Entities-And-Migrations)
196
+ - [Error codes](Error-Codes)
197
+ - [Testing](Testing)