@ultimat3/cli 29.3.2 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (448) hide show
  1. package/CLAUDE.md +64 -40
  2. package/README.md +61 -11
  3. package/package.json +42 -28
  4. package/src/affected-tests.ts +97 -0
  5. package/src/affected-unowned.ts +43 -0
  6. package/src/affected.ts +25 -6
  7. package/src/app-boundaries.ts +29 -1
  8. package/src/app-evals.ts +12 -4
  9. package/src/app-load-unresolved.ts +62 -0
  10. package/src/app-load.ts +30 -37
  11. package/src/app-mcp.ts +14 -2
  12. package/src/app-module-graph.ts +232 -0
  13. package/src/app-reload-graph.ts +2 -2
  14. package/src/app-root.ts +37 -1
  15. package/src/bin.ts +1 -0
  16. package/src/boundary-findings.ts +6 -0
  17. package/src/budgets-freshness.ts +32 -0
  18. package/src/budgets.ts +20 -1
  19. package/src/build-capture-env.ts +9 -0
  20. package/src/cdp-shot-a11y.ts +2 -39
  21. package/src/ci-tested.ts +243 -0
  22. package/src/client-emit-customer-runtime.ts +139 -0
  23. package/src/client-emit-customer.ts +108 -0
  24. package/src/client-emit-drift.ts +72 -0
  25. package/src/client-emit-header.ts +67 -0
  26. package/src/client-emit-schemas.ts +175 -0
  27. package/src/client-emit-types.ts +121 -0
  28. package/src/client-emit-vendor.ts +146 -0
  29. package/src/client-errors-routes.ts +229 -0
  30. package/src/client-errors-script.ts +74 -0
  31. package/src/client-vitals-route.ts +197 -0
  32. package/src/client-vitals-script.ts +56 -0
  33. package/src/cmd-affected.ts +23 -0
  34. package/src/cmd-build-spec.ts +7 -1
  35. package/src/cmd-build.ts +35 -9
  36. package/src/cmd-ci-spec.ts +42 -6
  37. package/src/cmd-ci.ts +99 -2
  38. package/src/cmd-client-customer.ts +134 -0
  39. package/src/cmd-client-source.ts +81 -0
  40. package/src/cmd-client-spec.ts +48 -0
  41. package/src/cmd-client-write.ts +41 -0
  42. package/src/cmd-client.ts +111 -0
  43. package/src/cmd-db-recount.ts +120 -0
  44. package/src/cmd-db-spec.ts +36 -6
  45. package/src/cmd-db.ts +13 -3
  46. package/src/cmd-deploy-compose.ts +61 -3
  47. package/src/cmd-deploy-drain.ts +10 -2
  48. package/src/cmd-deploy-helm.ts +11 -0
  49. package/src/cmd-deploy-preflight.ts +118 -0
  50. package/src/cmd-deploy-spec.ts +58 -6
  51. package/src/cmd-deploy-verify.ts +380 -0
  52. package/src/cmd-deploy.ts +42 -9
  53. package/src/cmd-docs.ts +34 -4
  54. package/src/cmd-doctor.ts +66 -9
  55. package/src/cmd-drill-spec.ts +25 -0
  56. package/src/cmd-drill.ts +176 -0
  57. package/src/cmd-env-init.ts +102 -0
  58. package/src/cmd-env-spec.ts +10 -3
  59. package/src/cmd-env.ts +15 -10
  60. package/src/cmd-errors-recent.ts +60 -0
  61. package/src/cmd-errors-spec.ts +9 -1
  62. package/src/cmd-errors.ts +4 -0
  63. package/src/cmd-exercise-spec.ts +19 -0
  64. package/src/cmd-exercise.ts +129 -0
  65. package/src/cmd-generate-spec.ts +9 -1
  66. package/src/cmd-generate.ts +32 -27
  67. package/src/cmd-graph-load.ts +97 -0
  68. package/src/cmd-graph-spec.ts +16 -0
  69. package/src/cmd-graph.ts +73 -0
  70. package/src/cmd-i18n.ts +4 -1
  71. package/src/cmd-impact-spec.ts +13 -0
  72. package/src/cmd-impact.ts +64 -0
  73. package/src/cmd-jobs-resolve.ts +76 -0
  74. package/src/cmd-jobs-spec.ts +21 -2
  75. package/src/cmd-jobs.ts +10 -6
  76. package/src/cmd-mail-spec.ts +28 -0
  77. package/src/cmd-mail.ts +70 -0
  78. package/src/cmd-mcp-lock.ts +69 -0
  79. package/src/cmd-mcp-spec.ts +23 -4
  80. package/src/cmd-mcp.ts +17 -3
  81. package/src/cmd-new-spec.ts +8 -1
  82. package/src/cmd-new.ts +61 -9
  83. package/src/cmd-planned.ts +0 -6
  84. package/src/cmd-repro-spec.ts +31 -0
  85. package/src/cmd-repro.ts +174 -0
  86. package/src/cmd-stop-spec.ts +21 -0
  87. package/src/cmd-stop.ts +166 -0
  88. package/src/cmd-test-spec.ts +17 -4
  89. package/src/cmd-test.ts +70 -14
  90. package/src/cmd-upgrade-spec.ts +26 -0
  91. package/src/cmd-upgrade.ts +274 -0
  92. package/src/cmd-verify-spec.ts +3 -3
  93. package/src/db-backfill.ts +6 -0
  94. package/src/db-diagnostics.ts +106 -0
  95. package/src/db-generate.ts +28 -8
  96. package/src/db-reset-template.ts +1 -1
  97. package/src/db-rolling.ts +45 -0
  98. package/src/db-seed-generate.ts +188 -0
  99. package/src/deploy-verify-findings.ts +98 -0
  100. package/src/deploy-verify-flags.ts +109 -0
  101. package/src/deploy-verify-grade.ts +185 -0
  102. package/src/deploy-verify-queues.ts +53 -0
  103. package/src/dev-boot.ts +35 -15
  104. package/src/dev-child-watch.ts +16 -4
  105. package/src/dev-dashboard-guard.ts +8 -1
  106. package/src/dev-dashboard.ts +7 -0
  107. package/src/dev-lock-identity.ts +61 -0
  108. package/src/dev-lock-port-holder.ts +80 -0
  109. package/src/dev-lock-steal.ts +124 -0
  110. package/src/dev-lock.ts +92 -120
  111. package/src/dev-mail-templates.ts +51 -0
  112. package/src/dev-rebuild.ts +26 -2
  113. package/src/dev-route-table.ts +17 -0
  114. package/src/dev-supervisor.ts +5 -2
  115. package/src/dev-watch-tree.ts +11 -10
  116. package/src/dev-watch.ts +1 -1
  117. package/src/dispatch.ts +29 -6
  118. package/src/docs-section.ts +69 -0
  119. package/src/docs-sources.ts +100 -0
  120. package/src/doctor-auth.ts +11 -2
  121. package/src/doctor-chart-alerts.ts +55 -0
  122. package/src/doctor-pooler.ts +107 -0
  123. package/src/doctor-rolling.ts +35 -0
  124. package/src/doctor-row-security.ts +40 -0
  125. package/src/document-options.ts +13 -0
  126. package/src/drill-sync-lane.ts +25 -0
  127. package/src/drill-targets.ts +39 -0
  128. package/src/duplicate-packages.ts +1 -1
  129. package/src/env-production.ts +93 -0
  130. package/src/error-codes-shot.ts +59 -0
  131. package/src/error-codes-tooling.ts +29 -0
  132. package/src/error-codes.ts +100 -54
  133. package/src/error-contract.ts +2 -2
  134. package/src/errors.ts +22 -3
  135. package/src/exercise-database.ts +99 -0
  136. package/src/exercise-findings.ts +159 -0
  137. package/src/exercise-overrides.ts +42 -0
  138. package/src/exercise-registration.ts +58 -0
  139. package/src/exercise-static.ts +88 -0
  140. package/src/exercise-subject.ts +69 -0
  141. package/src/failure-capture-boot.ts +30 -0
  142. package/src/framework-env.ts +7 -1
  143. package/src/framework-schema.ts +72 -5
  144. package/src/generate-catalog-locales.ts +19 -0
  145. package/src/generate-chart-alerts.ts +156 -0
  146. package/src/generate-feature-required.ts +65 -0
  147. package/src/generate-feature.ts +83 -6
  148. package/src/generate-files.ts +24 -0
  149. package/src/generate-format-written.ts +32 -0
  150. package/src/generate-kinds.ts +6 -0
  151. package/src/generate-sign-in-config.ts +100 -0
  152. package/src/generate-sign-in-manifest.ts +28 -0
  153. package/src/generate-sign-in-mode.ts +101 -0
  154. package/src/generate-sign-in.ts +250 -0
  155. package/src/generate-write.ts +44 -7
  156. package/src/hand-rolled-socket.ts +72 -0
  157. package/src/i18n-app-key-shapes.ts +29 -0
  158. package/src/i18n-audit.ts +10 -3
  159. package/src/i18n-registration.ts +18 -3
  160. package/src/index.ts +2 -0
  161. package/src/island-bundle.ts +4 -3
  162. package/src/island-disposer.ts +116 -0
  163. package/src/island-realtime-state.ts +34 -6
  164. package/src/island-realtime.ts +6 -2
  165. package/src/island-routes.ts +3 -2
  166. package/src/isr-query-advice.ts +6 -11
  167. package/src/isr-query-guard.ts +4 -3
  168. package/src/job-budget.ts +95 -0
  169. package/src/jobs-json.ts +11 -0
  170. package/src/lint-findings.ts +104 -0
  171. package/src/local-cli.ts +34 -4
  172. package/src/loopback-host.ts +2 -2
  173. package/src/mail-preview-findings.ts +24 -0
  174. package/src/mcp-errors.ts +82 -2
  175. package/src/mcp-host.ts +11 -1
  176. package/src/mcp-lock-findings.ts +57 -0
  177. package/src/measure-paths.ts +12 -4
  178. package/src/measure-scope.ts +27 -13
  179. package/src/messages-client.ts +23 -0
  180. package/src/messages-db.ts +56 -0
  181. package/src/messages-gate.ts +107 -0
  182. package/src/messages-repro.ts +11 -0
  183. package/src/messages-shot.ts +51 -0
  184. package/src/messages-upgrade.ts +45 -0
  185. package/src/messages.ts +53 -178
  186. package/src/metrics-endpoint.ts +23 -5
  187. package/src/new-shapes-generate.ts +71 -0
  188. package/src/new-shapes.ts +63 -0
  189. package/src/openapi-compile-ir.ts +68 -0
  190. package/src/openapi-compile-schema.ts +284 -0
  191. package/src/openapi-compile.ts +273 -0
  192. package/src/openapi-refuse.ts +45 -0
  193. package/src/otlp-export.ts +2 -1
  194. package/src/output-findings.ts +123 -0
  195. package/src/output.ts +32 -8
  196. package/src/package-boundaries.ts +110 -0
  197. package/src/page-pins.ts +23 -0
  198. package/src/plain-routes.ts +13 -0
  199. package/src/prerender-entries.ts +59 -0
  200. package/src/prerender-heaviest.ts +27 -0
  201. package/src/prerender-preload.ts +6 -0
  202. package/src/prerender-report.ts +12 -0
  203. package/src/prerender-seo-reads.ts +38 -0
  204. package/src/prerender-served.ts +22 -7
  205. package/src/prerender-vitals.ts +42 -0
  206. package/src/prerender.ts +61 -32
  207. package/src/pwa-artifacts.ts +4 -3
  208. package/src/pwa-manifest-locales.ts +1 -1
  209. package/src/registry.ts +18 -0
  210. package/src/repro-bundle.ts +65 -0
  211. package/src/repro-errors.ts +47 -0
  212. package/src/repro-job.ts +72 -0
  213. package/src/repro-literal.ts +41 -0
  214. package/src/repro-request.ts +48 -0
  215. package/src/repro-write.ts +100 -0
  216. package/src/retain-sweep.ts +27 -0
  217. package/src/retained-assets-boot.ts +113 -0
  218. package/src/retained-assets-layout.ts +110 -0
  219. package/src/retained-assets-read.ts +142 -0
  220. package/src/retained-assets-sweep.ts +70 -0
  221. package/src/retained-assets.ts +171 -0
  222. package/src/revalidated-response.ts +8 -5
  223. package/src/role-replicator.ts +7 -1
  224. package/src/role-start-devices.ts +119 -0
  225. package/src/role-start-types.ts +6 -0
  226. package/src/role-start.ts +22 -5
  227. package/src/role-sync.ts +7 -1
  228. package/src/route-islands.ts +42 -0
  229. package/src/route-table.ts +2 -1
  230. package/src/row-security-boot.ts +58 -0
  231. package/src/row-security-wanted.ts +32 -0
  232. package/src/runtime-admin.ts +0 -1
  233. package/src/runtime-assets-site-cache.ts +55 -0
  234. package/src/runtime-assets-site.ts +136 -0
  235. package/src/runtime-assets.ts +99 -55
  236. package/src/runtime-bindings.ts +19 -3
  237. package/src/runtime-cache.ts +23 -14
  238. package/src/runtime-isr-outcome.ts +114 -60
  239. package/src/runtime-purge.ts +14 -2
  240. package/src/runtime-queue.ts +31 -4
  241. package/src/runtime-render-document.ts +204 -0
  242. package/src/runtime-render.ts +48 -198
  243. package/src/runtime-replica-position.ts +62 -0
  244. package/src/runtime-replica.ts +18 -3
  245. package/src/runtime-s3-path-style.ts +17 -0
  246. package/src/runtime-services.ts +19 -6
  247. package/src/runtime-statement-bound.ts +22 -0
  248. package/src/runtime-storage-upload.ts +9 -0
  249. package/src/runtime-storage.ts +42 -138
  250. package/src/schema-diff-views.ts +54 -0
  251. package/src/schema-diff.ts +21 -3
  252. package/src/schema-drift.ts +28 -9
  253. package/src/schema-dump-drift.ts +91 -7
  254. package/src/script-csp.ts +1 -1
  255. package/src/seo-cards.ts +26 -0
  256. package/src/seo-routes.ts +22 -8
  257. package/src/serve-boot.ts +54 -21
  258. package/src/serve-drain.ts +1 -0
  259. package/src/serve-env.ts +26 -8
  260. package/src/serve-retire.ts +12 -6
  261. package/src/serve-web.ts +42 -4
  262. package/src/serve.ts +9 -7
  263. package/src/site-asset-routes.ts +17 -27
  264. package/src/site-config.ts +4 -0
  265. package/src/site-llms-pages.ts +106 -0
  266. package/src/site-llms.ts +64 -0
  267. package/src/site-seo-write.ts +4 -2
  268. package/src/site-seo.ts +55 -6
  269. package/src/source-mtime.ts +50 -0
  270. package/src/static-capture-errors.ts +41 -0
  271. package/src/static-capture.ts +56 -0
  272. package/src/static-cards-serve.ts +39 -0
  273. package/src/static-cards.ts +93 -0
  274. package/src/static-document.ts +14 -13
  275. package/src/static-images.ts +115 -0
  276. package/src/stream-hole-scope.ts +55 -0
  277. package/src/style-bundle.ts +1 -1
  278. package/src/style-routes.ts +3 -2
  279. package/src/sw-precache-plan.ts +2 -2
  280. package/src/sw-routes.ts +2 -2
  281. package/src/templates/action-tests.ts +306 -0
  282. package/src/templates/action.ts +81 -269
  283. package/src/templates/backfill.ts +1 -1
  284. package/src/templates/entity.ts +21 -5
  285. package/src/templates/github/ci.yml.ts +8 -9
  286. package/src/templates/guard-bare-error.ts +2 -2
  287. package/src/templates/guard-error-declared.ts +263 -0
  288. package/src/templates/guard-native-confirm.ts +132 -0
  289. package/src/templates/guard-raw-fetch.ts +151 -0
  290. package/src/templates/guard-raw-interval.ts +121 -0
  291. package/src/templates/guard-repo-raw-sql.ts +71 -2
  292. package/src/templates/job.ts +1 -1
  293. package/src/templates/naming.ts +5 -0
  294. package/src/templates/pinned/docker-compose.yml +6 -0
  295. package/src/templates/pinned-images.ts +8 -0
  296. package/src/templates/policy.ts +85 -2
  297. package/src/templates/query.ts +70 -13
  298. package/src/templates/resource-create.ts +3 -1
  299. package/src/templates/resource-form-island.ts +15 -4
  300. package/src/templates/resource-page-db.ts +83 -0
  301. package/src/templates/resource-page.ts +161 -52
  302. package/src/templates/route.ts +4 -6
  303. package/src/templates/scaffold-app.ts +3 -0
  304. package/src/templates/scaffold-auth.ts +2 -1
  305. package/src/templates/scaffold-bin.ts +5 -52
  306. package/src/templates/scaffold-claude-agents.ts +19 -19
  307. package/src/templates/scaffold-claude-commands.ts +28 -31
  308. package/src/templates/scaffold-claude.ts +26 -23
  309. package/src/templates/scaffold-container-compose.ts +6 -1
  310. package/src/templates/scaffold-container.ts +24 -4
  311. package/src/templates/scaffold-dashboard-bare.ts +3 -3
  312. package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
  313. package/src/templates/scaffold-dashboard-example-view.ts +156 -0
  314. package/src/templates/scaffold-dashboard-example.ts +43 -131
  315. package/src/templates/scaffold-dashboard-shared.ts +3 -7
  316. package/src/templates/scaffold-db-client.ts +20 -6
  317. package/src/templates/scaffold-db-package.ts +5 -14
  318. package/src/templates/scaffold-docs.ts +73 -57
  319. package/src/templates/scaffold-domain-package.ts +18 -14
  320. package/src/templates/scaffold-entries.ts +7 -2
  321. package/src/templates/scaffold-exercise.ts +37 -0
  322. package/src/templates/scaffold-guards.ts +13 -1
  323. package/src/templates/scaffold-helm-alerts.ts +234 -0
  324. package/src/templates/scaffold-helm-templates.ts +7 -5
  325. package/src/templates/scaffold-helm.ts +46 -12
  326. package/src/templates/scaffold-i18n.ts +6 -2
  327. package/src/templates/scaffold-mcp-endpoint.ts +182 -0
  328. package/src/templates/scaffold-mcp-json.ts +31 -0
  329. package/src/templates/scaffold-mcp-package.ts +59 -56
  330. package/src/templates/scaffold-repo.ts +7 -2
  331. package/src/templates/scaffold-shell.ts +45 -79
  332. package/src/templates/scaffold-test-services.ts +13 -5
  333. package/src/templates/shapes/api-keys.ts +170 -0
  334. package/src/templates/shapes/api.ts +101 -0
  335. package/src/templates/shapes/blog-site.ts +340 -0
  336. package/src/templates/shapes/blog-slice.ts +265 -0
  337. package/src/templates/shapes/blog.ts +114 -0
  338. package/src/templates/shapes/dashboard-detail.ts +242 -0
  339. package/src/templates/shapes/dashboard.ts +53 -0
  340. package/src/templates/shapes/index.ts +17 -0
  341. package/src/templates/shapes/overlay.ts +99 -0
  342. package/src/templates/shapes/shared-edits.ts +46 -0
  343. package/src/templates/sign-in-action-tests.ts +237 -0
  344. package/src/templates/sign-in-authenticator.ts +112 -0
  345. package/src/templates/sign-in-oidc.ts +189 -0
  346. package/src/templates/sign-in-pages.ts +148 -0
  347. package/src/templates/sign-in-password.ts +216 -0
  348. package/src/templates/sign-in-shared.ts +268 -0
  349. package/src/templates/slice-foundation.ts +70 -15
  350. package/src/test-batches.ts +1 -1
  351. package/src/test-database.ts +1 -1
  352. package/src/test-shards.ts +7 -1
  353. package/src/test-workers.ts +18 -12
  354. package/src/tool-gate-names.ts +34 -0
  355. package/src/upgrade-agents-link.ts +49 -0
  356. package/src/upgrade-bun-floor.ts +225 -0
  357. package/src/upgrade-gate.ts +228 -0
  358. package/src/upgrade-pins.ts +63 -0
  359. package/src/upgrade-plan.ts +214 -0
  360. package/src/upgrade-registry.ts +53 -0
  361. package/src/upgrade-report.ts +139 -0
  362. package/src/upgrade-root.ts +74 -0
  363. package/src/verify-budgets-build.ts +118 -0
  364. package/src/verify-checks.ts +67 -24
  365. package/src/verify-coverage-run.ts +1 -1
  366. package/src/verify-e2e-lane.ts +41 -0
  367. package/src/verify-exercise.ts +64 -0
  368. package/src/verify-floor.ts +41 -0
  369. package/src/verify-merge.ts +19 -4
  370. package/src/verify-role-load.ts +2 -0
  371. package/src/verify-run.ts +28 -8
  372. package/src/verify-shard.ts +12 -2
  373. package/src/verify-skip-ceiling.ts +87 -0
  374. package/src/verify-step.ts +5 -0
  375. package/src/verify-tests.ts +25 -5
  376. package/src/view-drift.ts +55 -0
  377. package/src/worker-bundle.ts +3 -2
  378. package/src/workspace-graph.ts +38 -19
  379. package/src/write-generated.ts +43 -0
  380. package/src/zone-boot.ts +20 -0
  381. package/wiki/Actions.md +342 -0
  382. package/wiki/Admin-Dashboard.md +432 -0
  383. package/wiki/Agents.md +958 -0
  384. package/wiki/Auth.md +195 -0
  385. package/wiki/Bare-VM.md +111 -0
  386. package/wiki/Batching-And-Preloading.md +136 -0
  387. package/wiki/Building-Your-Own-Base.md +203 -0
  388. package/wiki/CI-Parallel-Gate.md +220 -0
  389. package/wiki/CLI-Reference.md +1894 -0
  390. package/wiki/Caching-And-Invalidation.md +255 -0
  391. package/wiki/Client-Data.md +194 -0
  392. package/wiki/Client-Navigation.md +513 -0
  393. package/wiki/Compared.md +93 -0
  394. package/wiki/Configuration.md +868 -0
  395. package/wiki/Contributing.md +12 -0
  396. package/wiki/Deploy-Recipes.md +122 -0
  397. package/wiki/Deployment.md +508 -0
  398. package/wiki/Entities-And-Migrations.md +1116 -0
  399. package/wiki/FAQ.md +175 -0
  400. package/wiki/Feature-Flags.md +65 -0
  401. package/wiki/Getting-Started.md +221 -0
  402. package/wiki/Home.md +160 -0
  403. package/wiki/I18n.md +239 -0
  404. package/wiki/Installation.md +206 -0
  405. package/wiki/Interface-Rules.md +339 -0
  406. package/wiki/Jobs-And-Workflows.md +783 -0
  407. package/wiki/Known-Gaps.md +185 -0
  408. package/wiki/MCP-And-AI.md +753 -0
  409. package/wiki/Mail.md +138 -0
  410. package/wiki/Migrating-An-Existing-App.md +594 -0
  411. package/wiki/Migrating-From-Django.md +54 -0
  412. package/wiki/Migrating-From-Laravel.md +53 -0
  413. package/wiki/Migrating-From-Node-Drizzle.md +53 -0
  414. package/wiki/Migrating-From-Rails.md +53 -0
  415. package/wiki/Migrations-And-Backfills.md +552 -0
  416. package/wiki/Money.md +338 -0
  417. package/wiki/N-Plus-One-Detection.md +197 -0
  418. package/wiki/Notify.md +208 -0
  419. package/wiki/Observability.md +245 -0
  420. package/wiki/Outbound-HTTP.md +109 -0
  421. package/wiki/PWA-And-Offline.md +343 -0
  422. package/wiki/Policies-And-Authz.md +251 -0
  423. package/wiki/Project-Layout.md +150 -0
  424. package/wiki/Queries-And-Live-Queries.md +429 -0
  425. package/wiki/Realtime.md +734 -0
  426. package/wiki/Resource-Management.md +121 -0
  427. package/wiki/Routes-And-Render-Modes.md +521 -0
  428. package/wiki/SEO.md +288 -0
  429. package/wiki/Scheduled-Tasks.md +175 -0
  430. package/wiki/Scraping.md +493 -0
  431. package/wiki/Shapes.md +63 -0
  432. package/wiki/Stability.md +97 -0
  433. package/wiki/Static-Assets.md +166 -0
  434. package/wiki/Storage-And-Uploads.md +404 -0
  435. package/wiki/Testing.md +1313 -0
  436. package/wiki/The-Eight-Primitives.md +247 -0
  437. package/wiki/Theming.md +362 -0
  438. package/wiki/Timezones-And-Dates.md +194 -0
  439. package/wiki/Troubleshooting.md +163 -0
  440. package/wiki/Tutorial-01-First-App.md +289 -0
  441. package/wiki/Tutorial-02-First-Feature.md +339 -0
  442. package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
  443. package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
  444. package/wiki/Tutorial-05-Deploy-Free.md +231 -0
  445. package/wiki/Tutorial-06-Growing-Up.md +158 -0
  446. package/wiki/UI-Components.md +156 -0
  447. package/wiki/Upgrading.md +3667 -0
  448. package/src/stored-object-headers.ts +0 -43
@@ -0,0 +1,1116 @@
1
+ # Entities and migrations
2
+
3
+ An `entity` is a table + its domain type + its invariants. The single source of the DB schema, the TS type, and the parse boundary.
4
+
5
+ | Aspect | Rule |
6
+ |---|---|
7
+ | Projects to | SQL DDL, domain type (`typeof posts.$row`), migration, repo type, admin screen, seed factory |
8
+ | Owns | column types, defaults, invariants, tenant column |
9
+ | Never | business logic, I/O, HTTP awareness, policy decisions |
10
+
11
+ One `entity()` call per table, in `packages/db/src/schema/<name>.ts`; a feature's own `entity.ts` holds only that feature's view schemas. Migrations sit beside the entities in `packages/db/migrations/` as plain SQL. Neither holds **any business logic**. Reads and writes go through `@ultimat3/entity`'s own `postgresDriver()`, which compiles a query plan to parameterised SQL — no ORM in the request path ([`pg-driver.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/entity/src/pg-driver.ts)).
12
+
13
+ ## Six projections
14
+
15
+ | Projection | Where it lands | Consumed by |
16
+ |---|---|---|
17
+ | SQL DDL | the generated migration — columns, CHECKs, indexes | Postgres |
18
+ | Domain type | `export type Post = typeof posts.$row` | actions, queries, Solid component props |
19
+ | Migration | `packages/db/migrations/*.sql` | `x db migrate`, `ROLE=migrate` |
20
+ | Repo type | the feature's `repo.ts` signature | `ctx.<service>` inside `handle` |
21
+ | Admin screen | `apps/admin/` | operators, and the admin app's MCP surface |
22
+ | Seed factory | `seed(name)` fixtures | all six test types |
23
+
24
+ One inferred chain, no hand-typed link:
25
+
26
+ ```text
27
+ entity('posts', { columns }) → typeof posts.$row + invariants → action input/output → typed client + MCP tool → component props
28
+ ```
29
+
30
+ Rename a column and the entity type changes, the action's output stops matching, and the component prop errors — all at typecheck, before a test runs.
31
+
32
+ ## Shape
33
+
34
+ `As of 2026-08`:
35
+
36
+ ```ts
37
+ export const posts = entity('posts', {
38
+ columns: {
39
+ id: uuid().primaryKey(),
40
+ orgId: uuid().references(() => orgs.id, { onDelete: 'cascade' }).tenant(),
41
+ title: text({ max: TITLE_MAX }),
42
+ body: text(),
43
+ status: enumerated(POST_STATUSES).default('draft'),
44
+ likeCount: integer().default(0),
45
+ publishedAt: timestamp().nullable(),
46
+ createdAt: timestamp().defaultNow(),
47
+ updatedAt: timestamp().defaultNow().onUpdateNow(),
48
+ },
49
+ tenant: 'orgId', // said out loud; inferred from `.tenant()` or an `orgId` column if omitted
50
+ invariants: (c) => [
51
+ invariant('post_title_present', c.title.trimmed().minLength(1)),
52
+ invariant('post_like_count_non_negative', c.likeCount.atLeast(0)),
53
+ ],
54
+ indexes: [{ on: ['orgId', 'publishedAt'], order: 'desc' }],
55
+ });
56
+
57
+ export type Post = typeof posts.$row;
58
+ ```
59
+
60
+ The entity name is the first argument. Everything else is the init object:
61
+
62
+ | Field | Meaning |
63
+ |---|---|
64
+ | `columns` | types + defaults + FKs. Money is `bigint` minor units + `char(3)` currency, never a float; timestamps are `timestamptz`, stored UTC |
65
+ | `table` | the physical table, when it is not the entity's own name. For a schema this framework did not generate — see [Adopting an existing database](#adopting-an-existing-database) |
66
+ | `tenant` | the tenant column. Omitted, it is inferred from `.tenant()` or a column named `orgId` — silence never means unscoped |
67
+ | `shared` | `'<why its rows belong to no tenant>'` — for an entity with no tenant column whose references reach one that has a tenant (a price list every org's subscriptions point at). Without it the `exercise` step reports `X_ENTITY_TENANT_UNDECLARED`; blank is refused at `entity()` with the same code, and beside a tenant column it is `X_INVARIANT_VIOLATED`. See [Policies and authz](Policies-And-Authz#the-static-tenant-check) |
68
+ | `scan` | `'<why it stays small>'` — a bounded table (`'a closed catalog of 8 rows'`) the `exercise` step's plan sweep may see read whole. Without it a sequential scan of it is `X_QUERY_UNINDEXED`; blank is `X_INVARIANT_VIOLATED`. See [Testing](Testing#the-plan-and-scale-sweeps) |
69
+ | `invariants` | `(c) => [invariant(name, expr), …]` — named predicates enforced on write, projected to a CHECK or UNIQUE constraint where expressible. `c` is typed from `columns`, so a typo is a compile error |
70
+ | `indexes` | composite and partial indexes; a single unique or indexed column declares it on the column instead. `concurrently: true` on an entry builds it with `create index concurrently` in a migration of its own, outside a transaction — for a big, hot table ([Indexes built concurrently](Migrations-And-Backfills#indexes-built-concurrently--expand--contract)) |
71
+ | `primaryKey` | composite keys only — a single key is `.primaryKey()` on the column |
72
+ | `tags` | extra cache tags this entity participates in, beyond its own `entity:<name>` |
73
+
74
+ Presence of a `deletedAt` column is what makes an entity soft-deletable — not a flag.
75
+
76
+ ### Which invariants reach the database
77
+
78
+ "Where expressible" is a rule, not a hedge. An invariant reports a `sql:` string or `sql: null`, and
79
+ `$describe()` tells you which — a rule the database does not know is a rule a bulk `UPDATE` from a
80
+ migration or a psql session can violate.
81
+
82
+ | Written as | Reaches SQL | Note |
83
+ |---|---|---|
84
+ | `c.title.trimmed().minLength(1)` · `c.likeCount.atLeast(0)` · `c.email.contains('@')` · `c.a.eq(c.b)` · `c.flag.isTrue()` · `c.publishedAt.isNull()` / `.isNotNull()` | yes | `CHECK`. `trimmed()` strips leading and trailing **spaces** only, as one-argument `btrim` does — a tab is a character on both sides. `atLeast` / `eq(<number>)` compare the number the column holds, so a `bigint()` or `decimal()` row (a decimal string) is judged by its digits |
85
+ | `iff(c.status.eq('published'), c.publishedAt.isNotNull())` | yes | `(a) = (b)` — a rule that holds *exactly when* another does. The **one** combinator: `and`/`or`/`not` are deliberately absent |
86
+ | `c.unique(['orgId', 'userId'])` | yes | `UNIQUE`, not a CHECK — a single row cannot see a duplicate |
87
+ | `c.slug.matches(SLUG_PATTERN)` — a **`RegExp`** | yes | `slug ~ '…'` |
88
+ | `c.slug.matches(isValidSlug)` — a **function** | **no** | `sql: null` |
89
+ | `c.satisfies(fn, ['status', 'publishedAt'])` | **no** | `sql: null`, by construction — a JS function over several columns |
90
+
91
+ **`iff` renders `=`, not `IS NOT DISTINCT FROM`, and that is not an oversight.** Every operator in
92
+ this language is *false* on a null operand in TypeScript, and `=` between two booleans where either
93
+ is NULL yields NULL — which a CHECK **passes**. The total form fixes that row and breaks a worse
94
+ one: with a partial operand it makes Postgres **refuse a row TypeScript accepted**, which reaches
95
+ the caller as a raw `23514` instead of `X_INVARIANT_VIOLATED`. `=` keeps the disagreement in the
96
+ direction where the app refuses first, so no write reaches a CHECK that would have refused it.
97
+ Where both operands are total — a non-nullable column, an `IS NOT NULL` — the two spellings are the
98
+ same predicate, measured.
99
+
100
+ Pass the pattern, not a function wrapping it. `matches(isValidSlug)` and `matches(SLUG_PATTERN)`
101
+ read almost identically and only one of them reaches a constraint.
102
+
103
+ **Nothing is translated.** `pattern.source` is the string `.test()` runs *and* the string spliced
104
+ into the CHECK — one string, two engines, so they cannot drift. That is only safe because the
105
+ constructs where the two engines disagree are **refused at declaration**:
106
+
107
+ | Refused | Because | Write instead |
108
+ |---|---|---|
109
+ | `\b` | Postgres reads it as BACKSPACE — `'foo' ~ '\bfoo'` is **false** | a predicate; the rule stays app-only |
110
+ | `.` | matches `\n` in Postgres, not in JS | `[^\n\r]` |
111
+ | `\w` `\W` | locale alphanumeric — `'é' ~ '^\w$'` is **true** | `[A-Za-z0-9_]` |
112
+ | `\s` `\S` | `'\u00a0'` is whitespace to JS, not to Postgres | `[ \t\n\r\f\v]` |
113
+ | `\A` `\Z` | anchors in Postgres, letters in JS | `^` `$` |
114
+ | `\1`–`\9`, `(?<name>…)`, `(?i)`, `[[:alpha:]]`, a non-ASCII range endpoint | backreference, named group, embedded flag, POSIX class, collation-ordered range | a predicate |
115
+
116
+ The refusal is `X_INVARIANT_VIOLATED` at `entity()` time — before a migration exists — and names the
117
+ construct, its index, both readings, and the portable spelling where there is one. The subset is
118
+ measured against a live server and re-measured by its own tests, so a future Postgres that closes a
119
+ gap turns those tests red rather than leaving a stale exclusion in place.
120
+
121
+
122
+ ## The fluent surface
123
+
124
+ Every projection is a method on the entity — `posts.$view(['id', 'title'])`, never `view(posts, ['id', 'title'])` — and every declared field is lifted onto it under a `$`. An entity has no `.def`.
125
+
126
+ | Member | Is | Rule |
127
+ |---|---|---|
128
+ | `posts.title` | a column reference | the columns are the entity's own properties, which is what forces the `$` sigil onto every framework member |
129
+ | `$row` | the phantom that carries the row type | `export type Post = typeof posts.$row`. Reading it as a value throws `X_INVARIANT_VIOLATED` — a type was meant |
130
+ | `$schema` | the Standard Schema the columns already describe | forms and actions hand input to it; the row shape is never declared a second time |
131
+ | `$parse(value)` | the parse boundary | fills declared defaults, then validates every column through the column that owns it. Throws on a bad value |
132
+ | `$view(keys)` | the row projection | `const PostView = posts.$view(['id', 'title'])` — what an action names as its `output`. An unknown key is a compile error, and `X_INVARIANT_VIOLATED` at declaration for a JS caller |
133
+ | `$assert(row)` | every invariant, run | called by the repo on insert and on update; reports **every** failing invariant at once, so one round trip fixes all |
134
+ | `$describe()` | the manifest row | name, table, primary key, physical columns, invariants, indexes (name **and** column list, uniqueness, predicate, direction — a name cannot be parsed back into columns), tags, `cacheTag`, `softDelete`, `orgScoped` |
135
+ | `$cacheTag` | `entity:<name>` | the string `@ultimat3/cache` invalidates by |
136
+ | `$tagFor(id)` | `entity:<name>:<id>` | row-level invalidation for live queries |
137
+ | `$tenantColumn` | the tenant column's property key, or `null` | presence is what turns tenancy on — resolution order under **Tenant column rule** below |
138
+ | `$name` `$table` `$columns` `$primaryKey` `$indexes` `$invariants` `$tags` `$softDelete` | the declaration, lifted | readable. `$table` is `$name` verbatim — the first argument to `entity()` is the physical table name, never pluralised or snake-cased for you |
139
+
140
+ `entity` is the one primitive whose surface is `$`-prefixed, and the type says why: `type Entity<Row, C> = EntityCore<Row, C> & C` — the entity **is** its columns. `posts.title` is a column reference, so an unprefixed member would make `view`, `name` or `tenant` an illegal column name; with the sigil, a column called `name` and the entity's own name coexist as `posts.name` and `posts.$name`. `action`, `query` and `job` carry no sigil because nothing is merged into them. Reaching for `posts.view([…])` after reading [Actions](Actions) does not typecheck — there is no such member, only `$view`, and there is no free `view(entity, keys)` to import either. Nothing is hidden the way an action's `handle` is: an entity has no body to protect, so the whole declaration reads back, and the sigil protects the column namespace instead. Registration is not a second step — `entity()` registers itself as it is declared, which is why a duplicate name is `X_ENTITY_DUPLICATE` at import rather than a silent last-one-wins.
141
+
142
+ `$view()` is what closes the type chain. `output: posts.$view(['id', 'title', 'excerpt'])` returns a Standard Schema over `Pick<Row, K>`, so renaming a column stops the key list itself from compiling, and the action's output type and every consumer of it fail in the same pass — not at runtime as a missing field. It is also **introspectable**, `As of 2026-09-05`: the view carries the same schema IR a `t.object` does, so that `output:` reaches `openapi.json`, the MCP tool's `outputSchema` and the typed client with each column's own constraints (`text({ max: 2000 })` publishes `maxLength: 2000`). Until then a view was refused at registration with `X_SCHEMA_UNSUPPORTED` and every app kept a hand-copied `t.object` beside its entity — a copy that had already drifted in the reference app. What it catches is the key list and the projected type; a value is still checked at runtime, by the column that owns it rather than by a second copy of its rule. A view never invents a default: it projects a row that already exists, so an absent required column is missing data.
143
+
144
+ ## Tenant column rule
145
+
146
+ Every multi-tenant entity declares `tenant`. Every query against it is tenant-scoped or it fails:
147
+
148
+ ```
149
+ X_TENANCY_UNSCOPED: query is not scoped to a tenant
150
+ cause: select on "posts" has no predicate on tenant column "orgId"
151
+ fix: query through posts repo (ctx.posts.*) — it applies the tenant scope from ctx.actor
152
+ ```
153
+
154
+ Which column that is, in order — first match wins:
155
+
156
+ | Order | Source | Detail |
157
+ |---|---|---|
158
+ | 1 | `tenant: 'workspaceId'` | the declaration, said out loud. It need not be named `orgId` and need not carry `.tenant()`. Declared, it wins outright — a `.tenant()` mark elsewhere is not a conflict, just not the winner |
159
+ | 2 | `.tenant()` on a column | the first column marked, in declaration order |
160
+ | 3 | a column named `orgId` | literal property name, no mark needed |
161
+ | 4 | none of those | `$tenantColumn` is `null` and the entity is not tenant-scoped |
162
+
163
+ Omitting `tenant` keeps steps 2–4, so silence never means unscoped. A `tenant` key naming no column is a declaration error — because the alternative is a silently unscoped table. The error lists the columns to pick from and both edits that resolve it, so the declaration is repaired without opening the entity file:
164
+
165
+ ```
166
+ X_INVARIANT_VIOLATED: a domain invariant rejected this row
167
+ cause: posts.tenant: tenant: 'workspaceId' names no column — pick from: id, title, body, orgId
168
+ fix: set tenant to one of id, title, body, orgId in entity('posts'), or remove the tenant key — inference then takes the .tenant() column, else one named orgId
169
+ ```
170
+
171
+ Removing the key is a real option, not a hedge: inference (steps 2–4) still applies, so the table stays scoped whenever a column is marked `.tenant()` or named `orgId`.
172
+
173
+ | Consequence | Detail |
174
+ |---|---|
175
+ | Repo methods | inject the tenant predicate from `ctx.actor.orgId`; a raw `db.*` call in a service is a boundary error |
176
+ | Cache keys | framework-generated from the query name, its parsed input and its tags — never hand-built. The tenant reaches the key through the **input**, so a `cache:` read must take it there ([Caching and invalidation](Caching-And-Invalidation)) |
177
+ | Live queries | the tenant predicate is part of the matcher, not a post-filter |
178
+ | Vector search | tenant + policy filters applied **in SQL**, so similarity search cannot leak across tenants |
179
+ | Foreign keys | a reference holds its two ends to one tenant, in the database: below |
180
+
181
+ ### Same-tenant keys
182
+
183
+ A `references()` with both ends tenant-scoped is a composite key, and refuses a row pointing into another tenant. `As of 2026-11-02`, the default.
184
+
185
+ ```ts
186
+ import { entity, text, uuid } from '@ultimat3/entity';
187
+
188
+ const orgs = entity('orgs', { columns: { id: uuid().primaryKey() } });
189
+ const posts = entity('posts', {
190
+ columns: { id: uuid().primaryKey(), orgId: uuid().references(() => orgs.id).tenant() },
191
+ });
192
+ const items = entity('items', {
193
+ columns: { id: uuid().primaryKey(), orgId: uuid().references(() => orgs.id).tenant() },
194
+ });
195
+
196
+ export const comments = entity('comments', {
197
+ columns: {
198
+ id: uuid().primaryKey(),
199
+ orgId: uuid().references(() => orgs.id).tenant(),
200
+ postId: uuid().references(() => posts.id), // (org_id, post_id) -> posts (org_id, id)
201
+ // the plain key, on purpose
202
+ catalogId: uuid().references(() => items.id, { crossTenant: 'a shared catalog row' }),
203
+ body: text(),
204
+ },
205
+ });
206
+ ```
207
+
208
+ | Edge | Key |
209
+ |---|---|
210
+ | both ends scoped | `<table>_<column>_tenant_fkey` over `(tenant, column)`. A self-reference too |
211
+ | an end with no tenant (`orgId -> orgs.id`), or the tenant column itself | the plain `<table>_<column>_fkey` |
212
+ | `crossTenant: '<reason>'` | the plain key. A blank reason, and a reason on an edge that was never same-tenant, are `X_INVARIANT_VIOLATED` |
213
+ | a null key | checked by nobody, as on any foreign key |
214
+
215
+ | Question | Answer |
216
+ |---|---|
217
+ | The refusal | `X_DB_FOREIGN_KEY_VIOLATION`, the same code and text as a dangling id: no existence oracle |
218
+ | Raw SQL | meets the same key. It is the database's, not the repo's |
219
+ | Memory driver | refuses a target **found under another tenant**, same code. Not found stays accepted: it enforces no foreign key at all |
220
+ | The index behind it | a unique `(tenant, column)` on the target, one per referenced table, skipped when a total unique over those columns exists. `x db gen` declares it for you, and keeps it while a retired column's key still needs it |
221
+ | `onDelete: 'set null'` | written `on delete set null (<column>)`, or the tenant column would go too. Needs Postgres 15+ |
222
+ | Onto live tables | `x db gen "same-tenant keys"`: all deferred, in order — build the index, add the key `not valid`, validate, drop the old key. Writes keep landing and the old key holds until the new one validates |
223
+ | Validate fails `X_DB_FOREIGN_KEY_VIOLATION` | rows already point across tenants, and that is the finding. Decide those rows, or state `crossTenant` on an edge that means it |
224
+
225
+ ### Locking a row or a key
226
+
227
+ A read-modify-write through the handle loses updates. `db.<table>.lock` does not. `As of 2026-11-02`.
228
+
229
+ ```ts
230
+ import { database, entity, integer, type Tx, uuid } from '@ultimat3/entity';
231
+
232
+ const posts = entity('posts', {
233
+ columns: { id: uuid().primaryKey(), likeCount: integer().default(0) },
234
+ });
235
+ const invoices = entity('invoices', {
236
+ columns: { id: uuid().primaryKey(), number: integer() },
237
+ });
238
+ const db = database({ posts, invoices });
239
+ declare function nextNumber(year: number, tx: Tx): Promise<number>;
240
+
241
+ export async function like(postId: string): Promise<void> {
242
+ await db.posts.lock(postId, async (post, tx) => {
243
+ await db.posts.update(postId, { likeCount: post.likeCount + 1 }, { tx });
244
+ });
245
+ }
246
+
247
+ export async function nextInvoice(year: number): Promise<number> {
248
+ return db.invoices.lockKey(`number:${String(year)}`, async (tx) => nextNumber(year, tx));
249
+ }
250
+ ```
251
+
252
+ | | `lock(id, (row, tx) => R, options?)` | `lockKey(key, (tx) => R, options?)` |
253
+ |---|---|---|
254
+ | Postgres | `select … for update`, the read path's own statement | `pg_advisory_xact_lock(hashtextextended('<table>:<tenant or ->:<key>', 0))` |
255
+ | Scope | that row | one key, one table, one tenant: org A's key never blocks org B |
256
+ | Absent, another tenant's, soft-deleted | `X_NOT_FOUND` | n/a |
257
+ | No actor on a scoped entity | `X_TENANCY_UNSCOPED`, as a read | `X_TENANCY_UNSCOPED`, as a read |
258
+ | Blank key | n/a | `X_INVARIANT` |
259
+
260
+ | Rule | Detail |
261
+ |---|---|
262
+ | Transaction | its own, committed when `fn` returns, rolled back when it throws. Nested in `withTransaction` it is a savepoint, and the lock lives to the root COMMIT |
263
+ | Writes inside `fn` | with `{ tx }`, the rule on every write. Memory undoes only what names it |
264
+ | Bound | no wait option. The role's `statement_timeout`. A deadlock is `X_DB_SERIALIZATION_FAILURE` |
265
+ | Memory driver | a queue per key, reentrant within one async chain; a nested lock joins the outer one's transaction; `update(id)` and `delete(id)` wait for a row's holder, the filtered writes and `upsertAll` do not; a waiter past 5 s is `X_DB_STATEMENT_TIMEOUT`, Postgres' code, usually two locks taken in opposite order |
266
+ | A hand-written `Repo` | both are optional on the contract; the handle answers `X_LOCK_UNSUPPORTED` |
267
+ | `crossTenant()` with no `orgId` | holds its own `<table>:-:<key>`, no org's. Name `orgId` to take that org's key |
268
+
269
+ ## The repo
270
+
271
+ A feature's `repo.ts` is the only module that touches the database, and every statement in it goes
272
+ through the typed handle. `As of 2026-10`.
273
+
274
+ ```ts
275
+ // apps/web/app/widget/repo.ts — as `x g entity widget` writes it
276
+ import { db } from '@myapp/db';
277
+ import type { Widget } from './entity';
278
+
279
+ export async function byId(id: string): Promise<Widget | undefined> {
280
+ return (await db.widgets.where({ id }).one()) ?? undefined;
281
+ }
282
+
283
+ export async function list(limit = 50): Promise<readonly Widget[]> {
284
+ return db.widgets.orderBy('createdAt', 'desc').limit(limit).all();
285
+ }
286
+
287
+ export async function insert(row: Omit<Widget, 'id' | 'createdAt'>): Promise<Widget> {
288
+ return db.widgets.insert(row);
289
+ }
290
+ ```
291
+
292
+ ```ts
293
+ // packages/db/src/client.ts — the handle. `x g entity` adds the import and the line.
294
+ const entities = {
295
+ posts: post,
296
+ widgets: widget,
297
+ };
298
+
299
+ export const db = database(entities, { driver });
300
+ ```
301
+
302
+ | The handle owns | So the repo never writes |
303
+ |---|---|
304
+ | tenancy — every read runs under the actor's org | an org parameter, a `.where({ orgId })`, an `org_id = …` in SQL. No function in a generated repo takes or names an org. A read with no actor is `X_TENANCY_UNSCOPED`; an actor with no org is `X_TENANCY_ACTOR_ORG_REQUIRED`; a row written for another org is `X_TENANCY_ACTOR_MISMATCH` |
305
+ | codecs — money, timestamps, [sealed columns](#sealed-columns) | row decoding, a column list, money's three columns |
306
+ | paging — `.limit(n)`, then `.after(cursor).page()`; a raw join pages with `keyset()` from `@ultimat3/db` ([below](#paging-a-raw-join)) | an offset, or a paging module |
307
+ | the transaction — inside `withTransaction` the handle is the transaction, and a statement on it after the scope ended is `X_DB_TRANSACTION_CLOSED`: `await` it, or `outsideTransaction(fn)` for work that must outlive the scope | a client argument |
308
+ | locks — `db.posts.lock(id, fn)` for a row, `db.posts.lockKey(key, fn)` for a key, both under the tenant | `for update` by hand, or an advisory lock |
309
+ | abandonment — once `ctx.signal` is aborted with a coded reason (`X_TIMEOUT`, a job's cancellation) a write rethrows that reason: a transaction rolls back and neither driver writes. A client hang-up is not a refusal. `runToCompletion(fn)` for work that must land | a check of the signal before each write |
310
+
311
+ | Raw `sql` | Where |
312
+ |---|---|
313
+ | belongs | a `query`'s source, built with `from()`; a migration |
314
+ | is the exception | a `repo.ts`, for what the handle cannot say: a join, an aggregate, a CTE, a window. Its unit test runs [the migrated-database recipe](Testing#database-state-and-what-a-run-leaves-behind): the handle's `testDriver()` follows the installed client, so the handle's writes and the join meet in one database |
315
+ | is refused | a `repo.ts`, for `select *` / `insert` / `update` / `delete` on one table, and `select * … where id = ? for update` (`db.<table>.lock(id, fn)`) |
316
+ | is refused, any statement | `order by date_trunc('milliseconds', …)`: the index cannot serve it. `keyset()` pages at the column's own precision |
317
+
318
+ | Enforced by | What fails |
319
+ |---|---|
320
+ | `tsc` | `db.widgets` exists only when `widget` is in the set; a renamed column is an error in the repo |
321
+ | `guards/repo-raw-sql.ts` — `X_REPO_RAW_SQL`, `boundaries` step | a `sql` literal in a `repo.ts` the handle can express; the `fix:` is the handle call |
322
+ | `X_DB_HANDLE_UNREGISTERED` — `x g entity`, `x g resource` | a `client.ts` with no `const <set> = { … }` passed to `database()`, or an `index.ts` that does not export the handle; the `fix:` is the lines to add |
323
+
324
+ ### Paging a raw join
325
+
326
+ `keyset()` (`@ultimat3/db`): keyset paging for a statement the handle cannot say. `.after()` on the
327
+ handle, `keyset()` in a raw read: two surfaces, never two ways for one read. `As of 2026-11-06`.
328
+
329
+ ```ts
330
+ import { db, keyset, raw, sql } from '@ultimat3/db';
331
+
332
+ export async function feed(orgId: string, after: string | null) {
333
+ const page = keyset({
334
+ scope: `posts.feed:${orgId}`,
335
+ keys: [
336
+ { column: raw('p.created_at'), kind: 'timestamptz', direction: 'desc' },
337
+ { column: raw('p.id'), kind: 'uuid', direction: 'desc' },
338
+ ],
339
+ after,
340
+ limit: 50,
341
+ });
342
+ const rows = await db().query<{ id: string; title: string; author: string }>(
343
+ sql`select p.id, p.title, m.name as author, ${page.select}
344
+ from posts p join members m on m.id = p.author_id
345
+ where p.org_id = ${orgId} and ${page.where}
346
+ order by ${page.orderBy} limit ${page.limit}`,
347
+ );
348
+ return page.page(rows); // core's Page: { rows, nextCursor, hasMore }
349
+ }
350
+ ```
351
+
352
+ | Question | Answer |
353
+ |---|---|
354
+ | Microseconds | each key is selected as `col::text` and bound back as `$n::<kind>`: exact. A `Date` would drop them, and a seek from a floored position drops rows |
355
+ | The plan | `order by` stays on the bare columns: an index on `(created_at desc, id desc)` answers it as an Index Only Scan. `date_trunc('milliseconds', …)` measured 100 ms against 0.2 ms on 300,000 rows |
356
+ | Kinds | `timestamptz`, `uuid`, `text`, `integer`, `bigint` |
357
+ | Directions | all one way: a row comparison. Mixed: the or-chain |
358
+ | A null key | `X_KEYSET_NULL_KEY` on any row of the page: a row comparison has no null order. `coalesce` the key, or order by a not-null column |
359
+ | A cursor from another read | `X_CURSOR_INVALID`. The scope is yours plus the keys: the same scope under another sort is refused too |
360
+ | Memory | none: raw SQL has no memory path. Test it on the migrated database |
361
+
362
+ The generated `repo.test.ts` runs all three functions against the in-memory driver, inside
363
+ `runWithContext` — the actor is what scopes the read, so a test names one, and asserts that an
364
+ actor in another org reads nothing with no org named in any call. Row equality is
365
+ `expect(row).toEqualRow(other)`: a sealed column is a non-enumerable property, which `toEqual` skips.
366
+
367
+ After `x g entity <name>`: `bunx x db gen "create <table>"`, then `bunx x db migrate`. The generator
368
+ prints both, with `bun install` first when it added a workspace dependency
369
+ ([CLI reference](CLI-Reference)).
370
+
371
+ A feature with more than one table: `x g entity <second> --feature <feature>` writes `entity-<second>.ts` + `repo-<second>.ts` (and a test beside each) next to the first entity's `entity.ts`, and registers it on the handle the same way (`As of 27.6.2`). One file per table; `x db gen` picks every one up from the registry.
372
+
373
+ ## Point lookups batch themselves
374
+
375
+ `findById` called several times in one microtask of one request is **one** statement, `As of 2026-08`:
376
+
377
+ ```ts
378
+ // One `select … where "id" in ($1, $2, $3, …)`, not one statement per post.
379
+ const authors = await Promise.all(posts.map((post) => users.findById(post.authorId, { orgId })));
380
+ ```
381
+
382
+ Nothing to opt into — no `dataloader()`, no `batch()`. `findById` keeps its signature and its meaning, which is the only way the fix reaches code that is already written.
383
+
384
+ | Property | Behavior |
385
+ |---|---|
386
+ | Window | one microtask, closed before the statement goes out. A sequential `for … of` loop shares no microtask — its `await` ends the window — so what batches one is the page it is looping over (below) |
387
+ | Lifetime | one request. The batch is keyed by context identity, so it dies with the request and never crosses one |
388
+ | Never shared with | another tenant, another soft-delete visibility, another projection, another entity, another client. The coalesced statement has to be one each single lookup would have been served by |
389
+ | Scope | the tenant predicate and `deleted_at is null` are **inside** the statement, so an id that is missing, soft-deleted or another tenant's still reads as `null` — never another caller's row |
390
+ | Wide batches | past 500 ids, several whole statements rather than one Postgres refuses for its bind count |
391
+ | Outside a request | a job, a script, a migration: the single statement it always was |
392
+
393
+ ## A page batches the loop it causes
394
+
395
+ A `for … of` loop awaits between iterations, so no two of its lookups share a microtask. The page
396
+ they came from batches them instead, `As of 2026-08`:
397
+
398
+ ```ts
399
+ const page = await db.posts.findMany({ orgId });
400
+ for (const post of page.rows) {
401
+ // Two statements for the whole loop: the page, then one `where "id" in (…)` over every author
402
+ // on it. Every lookup after the first is memory.
403
+ const author = await db.users.findById(post.authorId, { orgId });
404
+ }
405
+ ```
406
+
407
+ Nothing new to write, and nothing to opt into: the relation is the `references()` the column
408
+ already declares, so the fix reaches loops that are already written.
409
+
410
+ | Property | Behavior |
411
+ |---|---|
412
+ | Trigger | the first `findById` whose id is a foreign key value on a page this request read. It resolves that key for **every** row of the page, in one statement |
413
+ | Never shared with | a different scope key (another tenant, another soft-delete visibility, another projection, another entity), another client, or the same entity after a write to it |
414
+ | Scope | the tenant predicate and `deleted_at is null` are inside the preload statement — it is the statement each single lookup would have sent, widened to the page's ids — so a page's ids can never resolve rows outside the reader's own scope |
415
+ | A write | drops what was preloaded for that entity before the statement goes out, so a row this request changed is re-read, never served from a page read before it |
416
+ | Lifetime | one request. What is held is the ids, not the rows, keyed by context identity |
417
+ | Wide pages | past 500 ids, whole statements — the same bound the microtask batch has |
418
+ | Outside a request | a job, a script: a page leaves nothing behind, and every lookup is the statement it always was |
419
+
420
+ ## Preload states a relation the loop would infer
421
+
422
+ The eager, declarative third of the family: the relation is named on the chain and resolved
423
+ before the caller sees a row, `As of 2026-08`:
424
+
425
+ ```ts
426
+ export const db = database({ orgs, posts, members });
427
+
428
+ // Two statements: the page, then one `select … where "id" in (…)` over its authors.
429
+ const page = await db.posts.where({ orgId }).preload('author').page();
430
+ page.rows[0].author; // the member row, or null — always present
431
+ ```
432
+
433
+ Nothing new to declare: `'author'` is the relation the `authorId` foreign key on `posts` already
434
+ produces — `preload()` names it, it does not define it. A name no foreign key produces is
435
+ `X_PRELOAD_UNKNOWN_RELATION`, resolved and thrown when `preload()` is called, on the chain and not
436
+ a page later.
437
+
438
+ | Property | Behavior |
439
+ |---|---|
440
+ | Shape | a `belongsTo` attaches the row or `null`; a `hasMany` attaches an array, empty when there are none. Always present, so "no author" and "nobody preloaded the author" cannot read the same |
441
+ | Statements | one extra statement per named relation, resolved **concurrently** — two `preload()` calls are two statements in flight, never one after the other. Naming one relation twice is one statement |
442
+ | Ceiling | `preload('<relation>', { max })` — the most related rows one relation may attach to one page; default 10,000 (`MAX_PRELOADED_ROWS`, the largest page a read may ask for). Past it the read is refused with `X_INVARIANT_VIOLATED`, never truncated |
443
+ | Terminals | `page()`, `all()`, `one()` resolve every named relation. `count()`, `countBy()` and `plan()` do not — a count reads no row to attach one to |
444
+ | Projection | attached after `select()`. `select()` is widened internally with the relation's own key, so a projection can drop neither the key the preload reads nor the relation it attaches — `plan().select` reports the widened list, the one that actually ran |
445
+ | Tenancy | the page's own tenant predicate carries onto the related read only when **both** entities are scoped by a column of that same name — a value that scopes one entity is a guess on another, and a source scoped by `workspaceId` may carry an ordinary `orgId` predicate that is a filter and not its tenancy. Carrying nothing is not silence: the related read builds its own plan, so it is refused as `X_TENANCY_UNSCOPED` rather than answered with a guess |
446
+ | Soft delete | the related read is `findMany`, so `deleted_at is null` applies exactly as it does to any other read |
447
+ | Wide relations | past 500 ids, whole statements — the same bound the two batches above share. A `hasMany` wider than one page costs another keyset page rather than a silent truncation |
448
+ | Reach | a table reads only the entities its own `database()` call named. A relation to one outside the set is `X_INVARIANT_VIOLATED`; the fix names the missing entity in that same `database({ … })` call |
449
+
450
+ Reach for it when the relation is part of what the page *is* — a list rendered with its authors,
451
+ rows handed to something that will not call back into the repo, or a read a reviewer should see
452
+ stated rather than inferred from a loop. The other two members of the family ask for nothing:
453
+ `findById` batches a same-microtask fan-out for itself, and a `for … of` loop over a page is
454
+ already two statements, above.
455
+
456
+ ## Reading a whole table is a loop of pages
457
+
458
+ A page is bounded on purpose, so reading everything is iteration — and the iteration is a terminal
459
+ on the same chain, not something written around `page()`, `As of 2026-08`:
460
+
461
+ ```ts
462
+ // One statement per batch, one page of rows in memory at a time.
463
+ for await (const batch of db.posts.where({ orgId }).preload('author').inBatches(500)) {
464
+ await search.index(batch);
465
+ }
466
+ ```
467
+
468
+ A batch **is** the page `page()` would have returned at that position: same filters, same tenancy,
469
+ same soft-delete visibility, same `select()`, same `preload()`. Nothing here is a second read path.
470
+
471
+ Stopping early keeps the position, which is what makes a long backfill a job that can run out of
472
+ time and pick up where it stopped:
473
+
474
+ ```ts
475
+ await using batches = db.posts.where({ orgId }).after(checkpoint).inBatches(500);
476
+ for await (const batch of batches) {
477
+ await search.index(batch);
478
+ if (ctx.clock.now() > deadline) break;
479
+ }
480
+ await db.checkpoints.update(id, { cursor: batches.cursor }); // resume with .after(cursor)
481
+ ```
482
+
483
+ | Property | Behavior |
484
+ |---|---|
485
+ | Statements | one per batch, each asking for one row past it exactly as a page does. An empty batch is never yielded, so a consumer never checks `batch.length` |
486
+ | Position | keyset, never OFFSET — a row written mid-iteration cannot make the loop skip or repeat one. `.after(cursor)` starts it, `.cursor` is where the next batch starts, `null` once exhausted |
487
+ | Closing | `break`, `return` and a throw all stop the next statement; `await using` is the same guarantee for a handle held in a variable, and `close()` is idempotent. One handle is one iteration — a second `for await` continues it rather than restarting the table |
488
+ | Batch size | a whole number of rows, at least one. A chain that also called `limit()` is `X_INVARIANT_VIOLATED`: one number with two meanings, and neither reading is safe to guess |
489
+ | Sort order | an ordering no cursor can carry is refused at `inBatches()`, not one batch later — a result that fits in a single batch mints no cursor, so deferring it would hide the mistake until the table grew. **An ordinary nullable sort column is no longer one of them, `As of 2026-08-24`**: NULL has a written-down place (`asc nulls last` / `desc nulls first`), the cursor carries it and the seek reaches it. What is refused is a **nullable primary-key column** — the tiebreak `totalOrder` appends precisely so two rows sharing a sort value cannot straddle a page boundary, and `null = null` is unknown, so it cannot do that job. Reachable only through `primaryKey: [...]`; drop `.nullable()` from the column |
490
+ | Unsortable kinds | `orderBy` on a `json`, `bytes` or array column is refused at plan time, at any row count (`X_INVARIANT_VIOLATED`). It paged wrongly or threw past page 1. Order by a scalar |
491
+ | Patches | `update`, `updateWhere` validate named columns, with `insert`'s issues, before the statement, on both drivers. Absent stays absent, `null` clears a nullable column |
492
+ | Tenancy | the plan's, as everywhere else: an unscoped chain is `X_TENANCY_UNSCOPED` on its first batch |
493
+
494
+ ## A count per row is one grouped count
495
+
496
+ The batching family above collapses a *lookup* repeated per row. A `count()` per row is not a
497
+ lookup — each statement asks a different question, so no batch reaches it. One statement asks all of
498
+ them, `As of 2026-08`:
499
+
500
+ ```ts
501
+ // One statement for all of `ids`, not one `select count(*)` per post.
502
+ const counts = await db.likes.where({ orgId }).andWhere('postId', 'in', ids).countBy('postId');
503
+ for (const id of ids) await db.posts.update(id, { likeCount: counts.get(id) ?? 0 });
504
+ ```
505
+
506
+ A `ReadonlyMap` keyed by the column's own values and typed from the row, so `counts.get(postId)` is
507
+ a `number | undefined` — and the `undefined` is load-bearing.
508
+
509
+ | Property | Behavior |
510
+ |---|---|
511
+ | Counts | the whole predicate, exactly as `count()` does: the chain's filters, its tenancy and its soft-delete visibility are in the statement. `limit()` and `after()` bound a page, never a count |
512
+ | A value nothing matched | absent, never `0`. That is what `group by` returns, and it is what tells "none" apart from "never asked" — the default is the caller's `?? 0` |
513
+ | NULL | one group, keyed `null`, in both drivers: a property a row never carried is read as `null`, so it lands where Postgres puts its NULL rows. `0`, `''` and `false` stay the values they are |
514
+ | Order | biggest group first, ties by the value — numbers and bigints numerically, everything else by its text — and `null` last. Applied after the rows are in, not in the statement: a hash aggregate returns groups in whatever order it built them, so an `order by` there would let the two drivers disagree about a result they agree on |
515
+ | Groupable columns | `uuid`, `text`, `char`, `boolean`, `integer`, `bigint`. A timestamp, a `jsonb` or a `money` column is `X_INVARIANT_VIOLATED`, whose `fix` names a column of that entity that *is* groupable — a `Map` compares a non-primitive key by identity, so such a map could only ever answer `undefined` |
516
+ | More than 1000 groups | `X_INVARIANT_VIOLATED`, never a truncated map: the statement asks for one group past the bound, exactly as a page reads one row past its limit, and the `fix` spells the `andWhere('<column>', 'in', <values>)` that bounds it. A map that lost its tail reads like a complete one, and recounting from it writes the wrong number to every row it missed |
517
+ | Statement | `select "post_id" as group_value, count(*) as group_count … group by "post_id"` — both names are fixed aliases, so an entity may still declare a column called `count`, and the grouped value is re-parsed by the column that declared it |
518
+ | Tenancy | the plan's, as everywhere else: an unscoped chain on a tenant-scoped entity is `X_TENANCY_UNSCOPED`, never a count across tenants |
519
+
520
+ There is no `groupBy()` builder and no error code of its own: `countBy` is a terminal on the chain
521
+ that already exists, over exactly the rows `count()` counts.
522
+
523
+ ## A count per day is a series
524
+
525
+ A chart is a count per bucket of time, in a person's zone. One statement, under every guard the
526
+ chain applies, `As of 2026-11`:
527
+
528
+ ```ts
529
+ import { database, entity, timestamp, uuid } from '@ultimat3/entity';
530
+
531
+ const likes = entity('likes', {
532
+ columns: { id: uuid().primaryKey(), orgId: uuid().tenant(), createdAt: timestamp() },
533
+ });
534
+ const db = database({ likes });
535
+
536
+ // [{ bucket: Date, value: number }, …] ascending; `{ sum: 'amount' }` sums instead.
537
+ export const likesPerDay = (orgId: string, from: Date, timeZone: string) =>
538
+ db.likes
539
+ .where({ orgId })
540
+ .andWhere('createdAt', 'gte', from)
541
+ .series({ on: 'createdAt', unit: 'day', timeZone, value: 'count' });
542
+ ```
543
+
544
+ | Property | Behavior |
545
+ |---|---|
546
+ | Zone | required by the type, an IANA name (`X_ENTITY_SERIES_INVALID` for `'+09:00'` or an unknown one). The statement is `date_trunc(unit, col, zone)` with the zone bound — never the session's. 23:30 UTC is the next day in Tokyo; a spring-forward day has 23 hourly buckets |
547
+ | Units | `minute`, `hour`, `day`, `week` (from Monday, as `date_trunc`), `month`. Closed |
548
+ | `minute`/`hour` | keep the instant's own offset: the repeated hour of a fall-back day is two buckets, and `Asia/Kolkata`'s hour starts at :30 UTC |
549
+ | `day`/`week`/`month` | local midnight, resolved afresh: a midnight that never happens reads at the offset before the jump, one that happens twice at the offset after it — measured on Postgres 18 and matched by the memory driver (`time-bucket.ts`) |
550
+ | `value` | `'count'` (a `number`) or `{ sum: column }` — decimal text, or a `MoneyValue`, exactly as `sum()`; one currency across the whole series |
551
+ | A bucket with no rows | absent — the range is the caller's to fill |
552
+ | A row with no instant | in no bucket |
553
+ | More than 1000 buckets | `X_ENTITY_SERIES_INVALID`, never truncated: bound the range or widen the unit |
554
+ | A hand-written `Repo` | `series` is optional on it; the chain answers `X_ENTITY_SERIES_UNSUPPORTED` |
555
+
556
+ A recomputed report table is `rollup()` over this read ([Jobs and workflows](Jobs-And-Workflows#rollups-and-materialized-views)).
557
+
558
+ ## Writing many rows is one statement
559
+
560
+ The sections above make a read loop stop being N statements. A loop of `insert()` calls is
561
+ the same defect on the write side, and `insertAll`/`upsertAll` are its answer, `As of 2026-08`. Each
562
+ call is all or nothing, `As of 2026-11-02`:
563
+
564
+ ```ts
565
+ await db.tags.insertAll(names.map((name) => ({ orgId, name }))); // one statement, n rows
566
+
567
+ await db.likes.upsertAll(rows, { // insert, or leave what is there
568
+ onConflict: ['orgId', 'postId', 'memberId'],
569
+ onMatch: 'nothing',
570
+ });
571
+
572
+ await db.counters.upsertAll(rows, { onConflict: ['orgId', 'day'] }); // insert, or overwrite
573
+ ```
574
+
575
+ Rows are `Insertable` and go through `$parse` exactly as one row does, so declared defaults are
576
+ filled here rather than by the caller; `upsertAll` also stamps `onUpdateNow()` columns through the
577
+ same `touch()` `update(id, patch)` uses, because an upsert that lands on a stored row *is* an
578
+ update. Both resolve with **the rows this call wrote**, in order — under `onMatch: 'nothing'` a row
579
+ already stored is skipped and absent, which is how a caller counts what it actually inserted.
580
+
581
+ | Property | Behavior |
582
+ |---|---|
583
+ | One builder | every insert in the framework compiles through the same function, so `insertAll([row])` is the text `insert(row)` always produced. There is no second insert path to drift |
584
+ | What a collision writes | every column the batch's rows **named** (defaults `$parse` fills reach a new row only, so an unnamed `createdAt` or `label` keeps its stored value; `onUpdateNow()` columns move), minus the conflict target (how the row was found), minus the primary key (where it lives) and minus the soft-delete stamp (whether the row is there at all). Moving either of the first two moves a row nobody asked to move, and every foreign key pointing at that id misses it |
585
+ | A soft-deleted row it lands on | stays deleted, and takes the batch's other columns. A stamped row still occupies its conflict target — that unique index is not partial — so writing `deletedAt` from the incoming row would bring back a row the app deleted, which is the resurrection `update()` and `updateWhere()` refuse by carrying `deleted_at is null`. `insertAll` is unaffected: a row that collides with nothing writes the stamp it carries |
586
+ | Conflict target | properties of a **declared** unique constraint — the primary key, a `unique()` column, an `indexes: [{ on, unique: true }]` entry, or an `invariant(name, c.unique([…]))`. Anything else is `X_INVARIANT_VIOLATED` here rather than `42P10` from the server |
587
+ | Tenancy | on a tenant-scoped entity, `onMatch: 'update'` requires the tenant column *inside* the conflict target, else `X_TENANCY_UNSCOPED`: an upsert builds no read plan, so a target that omits it matches another tenant's row and rewrites it. `'nothing'` is allowed — it writes nothing to a row it does not own |
588
+ | A batch repeating itself | two rows with one conflict target under `'update'` is refused: Postgres answers that statement `ON CONFLICT DO UPDATE command cannot affect row a second time`, so it cannot pass in memory either |
589
+ | Uneven batches | under `'update'` every row names the same columns — `excluded.<column>` for a row that omitted one is that column's *default*, not the stored value. `insertAll` and `'nothing'` accept an uneven batch and render `default` in the missing cell. A property present with `undefined` is absent here as in every write: refused beside a row writing a value, and left out of the set list when no row writes one |
590
+ | Nulls | a null in the conflict target collides with nothing, in both drivers — a Postgres unique index is `NULLS DISTINCT` |
591
+ | Size | past 65535 bind parameters the batch is several statements inside one transaction (a savepoint inside an open `withTransaction`), never one the server refuses. A failure in any chunk leaves no row of the batch, in both drivers; one chunk stays one autocommit statement, no `BEGIN`. Partial progress is yours to ask for: split the batch and call once per part |
592
+
593
+ There is no `updateAll`: `updateWhere(filter, patch)` and `deleteWhere(filter)` are already the
594
+ bulk forms of `update` and `delete`, and a second spelling of one of them would be a second path.
595
+
596
+ The composite unique index the tenancy rule requires is emitted correctly since 2.0.0 — declare it
597
+ on the entity and generate. Through 1.2.0 `indexes: [{ on: ['orgId','createdAt'] }]` emitted one
598
+ mangled name (`("org_id_created_at")`) and the SQL would not apply, so an app pinned there writes
599
+ that `create unique index` by hand.
600
+
601
+ ## Seeding
602
+
603
+ A seed is the fixture graph, written once and **replayed anywhere**: run it twice and the second
604
+ run writes nothing and raises nothing. `x db seed [<name>]` applies it; `defineSeed` declares it,
605
+ in `packages/<pkg>/seeds/<name>.ts` or `packages/<pkg>/src/seed.ts` — the two places `x db seed`
606
+ looks.
607
+
608
+ ```ts
609
+ export const dev = defineSeed('dev', async ({ insert, upsert, exists, id }) => {
610
+ // The table owns the id, so the NATURAL key is the only key: insert, or update what is there.
611
+ await upsert(plans, { by: ['code', 'currency'] }, { code: 'team', currency: 'EUR', monthly });
612
+
613
+ // The SEED owns the id, so a replay finds its own rows: `id('org:acme')` is a uuid v5 of the
614
+ // label — the same value on every machine, in CI and in the container.
615
+ await insert(orgs, [{ id: id('org:acme'), slug: 'acme', name: 'Acme Editorial' }]);
616
+
617
+ // Bulk volume data has no natural key worth upserting ten thousand rows against. The unit of
618
+ // idempotency is the FILE, and the guard is a sentinel.
619
+ if (await exists(reports)) return;
620
+ await insert(reports, generated);
621
+ });
622
+ ```
623
+
624
+ | Verb | Use it when | What a replay does |
625
+ |---|---|---|
626
+ | `insert(entity, rows)` | the seed chose the ids (`id('label')`) | one `on conflict … do nothing` statement per call — a stored row is left exactly as it is, and counted `skipped` |
627
+ | `upsert(entity, { by }, values)` | the table owns the id and a natural key identifies the row | reads first so an unchanged row answers `'skipped'` with no statement, then one `on conflict … do update` — never a read-decide-write race between two containers booting at once |
628
+ | `exists(entity, where?)` / `count(entity, where?)` | volume data, where the file is the unit | the sentinel returns early and nothing is written |
629
+ | `deleteWhere(entity, where)` | a scoped wipe before a regenerate | **refused on a soft-deleting entity** — see the trap below |
630
+
631
+ Rows go through `entity.$parse` and the invariants either way, so a seed is a test of the schema as
632
+ well as data for one.
633
+
634
+ | Rule | Detail |
635
+ |---|---|
636
+ | Tier | `reference` ships to production through this same command; `dev` is fixture data and is the default. `x db seed` runs `reference` alone under `ULTIMATE_ENV=production` — naming `--tier dev` (or `ULTIMATE_SEED_TIER=dev`) is both the selection and the consent → [CLI reference](CLI-Reference#x-db) |
637
+ | Transaction | one per seed, never one around the run: a seed that throws must not roll back the seeds that already landed |
638
+ | `createdAt` | an `upsert` that lands on a stored row does **not** overwrite it. A replay must not move when a row first arrived; `preserve: [...]` names other columns to spare |
639
+ | Tenancy | `upsert` on a tenant-scoped entity needs the tenant column inside `by`, else `X_TENANCY_UNSCOPED` — the same rule `upsertAll` carries. `insert` has no such requirement: `do nothing` writes nothing to a row it does not own |
640
+ | Generated key | `insert` refuses a row that leaves a `uuid().primaryKey()` unnamed. `$parse` would fill it with a fresh uuid, the conflict target would match nothing, and run five would leave five copies — the one duplication nothing else can see |
641
+ | `now` | one instant per run, so every row a bulk pass stamps carries the same timestamp |
642
+ | `metrics` | `{ inserted, updated, skipped }`, returned by `run()` and reported per seed by `x db seed --json` |
643
+
644
+ **The soft-delete trap, and why `deleteWhere` refuses.** Deleting a seeded row on a soft-deleting
645
+ entity *stamps* it rather than removing it. The stamped row still occupies its unique key — that
646
+ index is not partial — and `upsertPlan` spares the soft-delete column on purpose, so no replay can
647
+ clear the stamp: the rows are invisible to every read and no re-seed brings them back. A wipe-then-
648
+ replay that looked idempotent left a demo's feed permanently empty. Inside a seed the call is
649
+ therefore `X_INVARIANT_VIOLATED` naming `x db reset`, which is the only wipe such an entity has.
650
+
651
+ **A generated seed follows ownership, not just the tenant column.** `x db seed --generate` and the
652
+ exercise pass write `@ultimat3/testing`'s `seedGraph`: a table with no tenant column whose
653
+ references reach a tenant-owned one — and no `shared` reason — gets its rows per tenant, each under
654
+ its own tenant's parent, so the tenant proof has a row of each org to attack through. A `shared`
655
+ entity is seeded once and is not walked through ([A whole graph](Testing#a-whole-graph-seedgraph)).
656
+
657
+ ## Column types
658
+
659
+ The blessed set is a decision the framework made for a table it was going to create. The wide set
660
+ is the shape a table already has, `As of 2026-08`.
661
+
662
+ | Builder | Postgres | Row type |
663
+ |---|---|---|
664
+ | `uuid()`, `uuid<PostId>()` | `uuid` | `string`, branded if declared. `.primaryKey()` generates a v7 when omitted |
665
+ | `text({ max })` · `integer()` · `boolean()` · `url()` | `text` · `integer` · `boolean` · `text` + CHECK | `string` / `number` / `boolean` |
666
+ | `enumerated(v)` · `tz(zones)` · `locale(tags)` | `text` + CHECK | the union of the declared values |
667
+ | `timestamp()` | `timestamptz` | `Date` — an instant, stored UTC |
668
+ | `money()` | `<n>_minor bigint` + `<n>_currency char(3)` + `<n>_scale integer null` | `MoneyValue` |
669
+ | `json(schema)` | `jsonb` | whatever the schema infers. The schema is **required** — a `json()` returning `unknown` is an `any` hole, and the value arrives from the database as often as from a caller |
670
+ | `decimal({ precision, scale })` | `numeric(p, s)` | `string`, the exact digits. A value with more decimal places than the column stores is refused, not rounded |
671
+ | `date()` | `date` | `PlainDate` — a calendar date, no time, no zone |
672
+ | `bigint()` | `bigint` | `string`. A JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53, which is where a legacy `int8` key lives |
673
+ | `bytes()` | `bytea` | `Uint8Array`, normalised across both drivers |
674
+ | `arrayOf(column)` | `<element>[]` | `readonly T[]`, each member parsed by the element column. Elements: `uuid`, `text` (and `url`, `enumerated`, `tz`, `locale`), `integer`, `boolean`, `timestamp`, `decimal`, `date`, `bigint`, each optionally `.nullable()`. The row is the same on both drivers: a driver that returns an array as its text literal (`Bun.SQL` does for `uuid[]`) is read by the column's declaration |
675
+
676
+ **No NUL in text.** `text()` and `url()` refuse a string carrying U+0000 (`X_INVARIANT_VIOLATED`,
677
+ `column.format`) — the one character a Postgres `text` value cannot hold (SQLSTATE 22021), so the
678
+ memory driver no longer stores what production answers `X_DB_STATEMENT_FAILED` for. `t.string`
679
+ refuses it at the wire first, as a 400. `As of 2026-09-29`.
680
+
681
+ Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
682
+ `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)` ·
683
+ `.sealed()` on `text()` — [Sealed columns](#sealed-columns).
684
+
685
+ **A date is not an instant.** `effective_on` is the date a rate applies, not a moment; stored as a
686
+ `timestamptz` it is a different date on either side of midnight for half the planet. `PlainDate`
687
+ (`@ultimat3/time`) is a branded `YYYY-MM-DD` string — it sorts chronologically because it sorts
688
+ lexicographically, round-trips through JSON as itself, and is the literal Postgres accepts. The
689
+ framework's "never a date without an IANA zone" rule is about instants: a calendar date needs no
690
+ zone because it names no instant.
691
+
692
+ ## Sealed columns
693
+
694
+ A credential column is `text().sealed()`. The row reads `string`; the database holds ciphertext.
695
+
696
+ ```ts
697
+ export const connections = entity('connections', {
698
+ columns: {
699
+ id: uuid().primaryKey(),
700
+ orgId: uuid().tenant(),
701
+ password: text({ max: 200 }).sealed(), // opaque
702
+ email: text().sealed({ lookup: true }).unique(), // equality only
703
+ },
704
+ });
705
+
706
+ const row = await db.connections.where({ email }).one(); // found by the stored string
707
+ row?.password; // the plaintext, on the server
708
+ ```
709
+
710
+ | Fact | |
711
+ |---|---|
712
+ | Builder | `text()` only; `.sealed()` reads the same before or after `.nullable()`, `.unique()`, `.column()` |
713
+ | Stored | `x1.<keyId>.<iv>.<ciphertext+tag>` in a plain `text` column — no new SQL type, and `x db gen` writes no migration for sealing a `text()` with no `max`. A `max` is checked against the plaintext and its CHECK is dropped |
714
+ | Key | the master key `x secrets` manages; purpose `entity:<table>.<column>`, derived |
715
+ | Drivers | memory and Postgres both seal |
716
+ | Manifest | `x.manifest.json` lists the column as `"sealed": "opaque"` or `"lookup"` |
717
+
718
+ **A sealed property is server-only.** On every row a repository answers it is an own, readable,
719
+ writable property that is **not enumerable**: `row.password` is the plaintext, and nothing that
720
+ walks the row sees it. `As of 2026-10`.
721
+
722
+ | Reads it | Does not |
723
+ |---|---|
724
+ | `row.password`, `const { password } = row`, `'password' in row` | `JSON.stringify(row)`, `{ ...row }`, `Object.keys` / `entries` / `assign`, `structuredClone(row)` |
725
+ | `update(id, row)` — the row passed back whole | the logger, an error's `meta`, an island's props, a cache tier, a job's stored input and step output |
726
+ | `console.log(row)` / `Bun.inspect(row)` — Bun prints own properties, enumerable or not | — log through `logger`, never `console`, on a path that holds one |
727
+
728
+ | Leaves the server through | Sealed column |
729
+ |---|---|
730
+ | `entity.$schema` as an `output` — HTTP, the typed client, MCP, a job result | absent, in the schema and the value |
731
+ | an `output` that is not the entity's schema (`t.record(…)`, a hand-written Standard Schema) | absent — the row enumerates none |
732
+ | a `query`'s rows — HTTP list, `Page`, `single: true`, the record envelope's `data`, the MCP read, a `cache:` entry | absent — a query has no output parse, so this is the row's own doing |
733
+ | a live query's snapshot | absent |
734
+ | a live query's change, on the socket and on the bus | absent — the row decoded off the write-ahead log drops the stored string |
735
+ | `entity.$view([...])` | `X_ENTITY_SEALED_IN_VIEW` |
736
+ | a record, the record envelope, a `persist: true` browser store | absent |
737
+ | an island's props, a log line, an error's `meta` | absent |
738
+ | a job's input and a `step.run()` output holding the row | absent from the stored JSON — pass the id and re-read |
739
+ | `{ token: row.token }` — the secret named by hand | **leaves.** Naming it is the one read there is; a serialised copy is the author's statement |
740
+
741
+ | It costs | What happens | Write instead |
742
+ |---|---|---|
743
+ | a spread patch | `update(id, { ...row, label })` leaves the stored secret alone — an absent property is not a write | nothing |
744
+ | a spread insert or upsert, required column | `X_INVARIANT_VIOLATED` — `is required, sealed, and missing from the row to insert`, with the rewrite as its `fix:` | `insert({ ...row, password: row.password })` |
745
+ | a spread insert, **nullable** column | stored NULL, silently — an absent nullable column is a legal insert | `insert({ ...row, token: row.token })` |
746
+ | equality in a test | `expect(row).toEqual({ …, password })` fails with an empty diff; `toEqual(otherRow)` cannot see a wrong secret | `expect(row).toEqualRow({ …, password })` |
747
+ | a row out of a shared `cache:` tier or a replayed `step.run()` | came back through JSON, so the property is gone | read the secret from the repository, by id |
748
+
749
+ `insert(row)` and `update(id, row)` with the row passed whole write what it holds; `select({
750
+ password: true })` and `preload()` keep the property, still server-only.
751
+
752
+ **Testing a sealed row.** `toEqual` walks enumerable properties. Write one of:
753
+
754
+ ```ts
755
+ expect(row).toEqualRow({ id, label: 'primary', password: 'hunter2' }); // every own property
756
+ expect(row.password).toBe('hunter2');
757
+ expect(row).toMatchObject({ password: 'hunter2' });
758
+ ```
759
+
760
+ `toEqualRow` ([Testing](Testing#matchers)) names what differs — a sealed property by name, never
761
+ by value. A test needs no key: the testing preload installs a throwaway one.
762
+
763
+ A live read may not filter or order on a sealed column — no change row carries one
764
+ (`X_MATCHER_UNSUPPORTED` at subscribe).
765
+
766
+ | The database may | `.sealed()` | `.sealed({ lookup: true })` |
767
+ |---|---|---|
768
+ | test for NULL (`is-null`, `is-not-null`) | yes — `null` is stored as NULL, never sealed | yes |
769
+ | match by `eq` / `in` | no — a compile error, and `X_ENTITY_SEALED_PREDICATE` | yes |
770
+ | order, range, `like`, group, aggregate | no | no |
771
+ | hold `.unique()` or an index | no | yes |
772
+ | run an invariant over it | no | only `unique` |
773
+
774
+ `lookup` reveals equality: equal values store equal strings. Never for a low-entropy value.
775
+
776
+ **Rotation.** `x secrets rotate` keeps the old key in the ring. Until the rows are rewritten a
777
+ lookup column matches through `in (…)` over every key, `.unique()` holds per key, and `x doctor`
778
+ reports `X_SEAL_RESEAL_PENDING`. Re-seal with a `backfill()` (`update(id, { col: row.col })`), then
779
+ `x secrets rotate --drop <keyId>`.
780
+
781
+ ### Sealing a column that already holds data
782
+
783
+ There is no reading of an unsealed value: a stored string that is not sealed is `X_SEAL_INVALID`.
784
+ A column with existing plaintext is migrated — expand, backfill, contract.
785
+
786
+ | # | Step | Command or edit |
787
+ |---|---|---|
788
+ | 1 | expand: a NEW nullable sealed column beside the old one | `passwordSealed: text().nullable().sealed()` · `x db gen "add sealed password"` · `x db migrate` |
789
+ | 2 | write both: every writer sets `passwordSealed` as well as `password` | deploy |
790
+ | 3 | backfill the rows written before step 2 | the declaration below · `x db backfill seal-connection-passwords --write` |
791
+ | 4 | switch readers to `passwordSealed`; stop writing `password` | deploy |
792
+ | 5 | contract: drop the plaintext, take the name back | delete `password`; `password: text().sealed().column('password_sealed')` · `x db gen "drop plaintext password"` · `x db migrate` |
793
+
794
+ ```ts
795
+ // apps/web/app/connection/backfills/seal-connection-passwords.ts — a backfill IS a job: register
796
+ // the module in defineApi({ jobs }) beside the slice's other jobs
797
+ import { backfill } from '@ultimat3/jobs';
798
+
799
+ export const sealConnectionPasswords = backfill({
800
+ name: 'seal-connection-passwords',
801
+ tenant: 'none', // every tenant; the pass opens the cross-tenant scope
802
+ requires: '20261001120000_add_sealed_password',
803
+ // Only the rows still to seal: a NULL test is the one predicate an opaque column answers.
804
+ // The second filter is for a NULLABLE old column — a row whose `password` is NULL has nothing to
805
+ // seal and would never leave the set. Delete that line when `password` is NOT NULL.
806
+ source: () =>
807
+ db.connections.andWhere('passwordSealed', 'is-null').andWhere('password', 'is-not-null'),
808
+ count: () =>
809
+ db.connections.andWhere('passwordSealed', 'is-null').andWhere('password', 'is-not-null').count(),
810
+ handle: async ({ rows, signal }) => {
811
+ for (const row of rows) {
812
+ signal.throwIfAborted();
813
+ // Reads the old column, writes the sealed one. Idempotent: a replayed page seals again.
814
+ await db.connections.update(row.id, { passwordSealed: row.password });
815
+ }
816
+ },
817
+ });
818
+ ```
819
+
820
+ | Why | |
821
+ |---|---|
822
+ | a new column, not `.sealed()` on the old one | reads of the old rows would be `X_SEAL_INVALID` from the first request to the last row of the backfill |
823
+ | step 5 needs no re-seal | the purpose follows the PHYSICAL column (`password_sealed`), and `.column()` pins it |
824
+ | `count` | the same predicate, counted: the pass has converged when it answers 0, and `X_BACKFILL_STALLED` says so if it has not. `source` and `count` must filter alike — the `password` `is-not-null` line goes in both or neither |
825
+ | a `lookup` column | same recipe; add `.unique()` in step 5, once every row holds a sealed value |
826
+
827
+ ## Adopting an existing database
828
+
829
+ Three overrides, `As of 2026-08`, and together they are what makes a schema Ultimate did not
830
+ generate declarable at all. An entity that uses none of them emits exactly what it always did.
831
+
832
+ ```ts
833
+ export const accounts = entity('account', {
834
+ table: 'legacy_accounts',
835
+ columns: {
836
+ id: uuid().primaryKey().column('account_id'),
837
+ githubLogin: text({ max: 40 }).column('gh_login'),
838
+ balance: money({ columns: { minor: 'amount_cents', currency: 'currency', scale: null } }),
839
+ openedOn: date().column('opened_on'),
840
+ },
841
+ });
842
+ ```
843
+
844
+ | Override | What follows it | What does not |
845
+ |---|---|---|
846
+ | `entity(name, { table })` | every statement, index name and foreign key | the entity NAME stays the key: the registry, the cache tag `entity:account`, `x entities show`, every relation and every policy |
847
+ | `.column(name)` | the DDL, the binding, the decoder, predicates, sort keys, cursors | nothing — name it LAST in a chain, since the link returns the general column; `uuid()` and `timestamp()` keep their own methods across it |
848
+ | `money({ columns })` | the three physical columns, per part, merged over the defaults | `scale: null` says the table has no scale column: every amount is then at the currency's own minor unit, which is what an absent scale already meant |
849
+
850
+ A physical name is validated where it is written — lower-case letters, digits and underscores, at
851
+ most the 63 bytes Postgres truncates at — because it is spliced into every statement as an
852
+ identifier.
853
+
854
+ **What is not adoptable yet**, `As of 2026-08`. Each of these is a real table shape that this
855
+ vocabulary cannot express, said precisely rather than approximated:
856
+
857
+ | Shape | Why, and what to do instead |
858
+ |---|---|
859
+ | a `numeric` money column with no currency column beside it | `money()` is an amount **and** a currency by construction, and a single implied currency is the bug it exists to prevent. Declare it `decimal()` and keep the currency where the app already keeps it, or add the column |
860
+ | a Postgres `enum` TYPE | `enumerated()` emits a CHECK, never `CREATE TYPE`, so a column of a real `pg_enum` type has no declaration. `text()` reads and writes it correctly today; what is missing is the DDL and the closed set |
861
+ | a composite type, a range, `hstore` | no builder, and no way to spell one |
862
+ | a live query over a renamed column | `@ultimat3/realtime` rebuilds a row from the physical names alone (it holds no dependency on this package) and would deliver `ghLogin` where the repository says `githubLogin`. Renamed columns are safe everywhere else |
863
+ | `x db gen` over an adopted table | the generator would emit `create table` for a table that exists. Baselining an existing schema is its own command and is not built |
864
+
865
+ ## Invariants
866
+
867
+ | Rule | Behavior |
868
+ |---|---|
869
+ | Named | `inv('published-post-has-title', …)` — the name is the error text and the test name |
870
+ | Enforced on write | via the repo and, where expressible, a Postgres CHECK generated into the migration |
871
+ | Violation | throws `X_INVARIANT_VIOLATED` with the invariant name and the offending row id |
872
+ | Not authz | an invariant is about data shape. Permission belongs in [Policies and authz](Policies-And-Authz) |
873
+ | Duplicate entity | two entities on one table name is `X_ENTITY_DUPLICATE` — rename or merge |
874
+
875
+ ## Embeddings
876
+
877
+ ```ts
878
+ embed: { field: 'body', model: 'text-embedding-3-large' },
879
+ ```
880
+
881
+ | Piece | What is generated |
882
+ |---|---|
883
+ | Column | a pgvector column sized from the model, in the same Postgres — no second datastore |
884
+ | Index | **HNSW**, created by the generated migration |
885
+ | Backfill | a `job` with steps: resumable, rate-limited per tenant, safe to re-run (at-least-once) |
886
+ | Re-embed | content-hash change enqueues a job; unchanged text is never re-embedded |
887
+ | Exact cache | embeddings cached by content hash + model in cache tier 3 |
888
+ | Search | one hybrid `query` fusing pgvector cosine + Postgres FTS with Reciprocal Rank Fusion; weights are config |
889
+
890
+ More in [MCP and AI](MCP-And-AI).
891
+
892
+ ## Lifecycle: retention, history, counters
893
+
894
+ Three declarations on the entity, each compiled to a trigger or a purge target, each replacing a hand-written one. As of 2026-11.
895
+
896
+ | Declaration | Compiles to | Refused when |
897
+ |---|---|---|
898
+ | `retain: { after: '90d', on: 'createdAt' }` | a `PurgeTarget` per entity — batched, cross-tenant `delete … where created_at < now - after` | no window (`X_ENTITY_RETAIN_INVALID`), `on` not an indexed `timestamp()` (`X_ENTITY_RETAIN_UNINDEXED`), beside `appendOnly`, or on a table the catalog holds partitioned (drift, `X_ENTITY_RETAIN_INVALID`: detach partitions in a `task`) |
899
+ | `history: true` · `history: { retain: { after: '365d' } }` | `<table>_history (id, <tenant>, row_id, op, at, actor_id, request_id, before, after)` — a registered entity (`posts.$history`), written by the `ultimate_history` trigger, guarded by `ultimate_history_guard` | a composite primary key, `appendOnly` (`X_INVARIANT_VIOLATED`) |
900
+ | `references(() => posts.id, { countedIn: () => posts.likeCount })` | a trigger on the child moving `posts.like_count` by one in the write's own transaction | the column is no `integer()`, or the key points at no single primary key (`X_ENTITY_COUNTS_INVALID`) |
901
+
902
+ ```ts
903
+ import { entity, integer, timestamp, uuid } from '@ultimat3/entity';
904
+
905
+ export const posts = entity('posts', {
906
+ columns: { id: uuid().primaryKey(), orgId: uuid().tenant(), likeCount: integer().default(0) },
907
+ history: { retain: { after: '365d' } },
908
+ });
909
+ export const likes = entity('likes', {
910
+ columns: {
911
+ id: uuid().primaryKey(),
912
+ postId: uuid().references(() => posts.id, { countedIn: () => posts.likeCount }),
913
+ createdAt: timestamp().defaultNow(),
914
+ },
915
+ indexes: [{ on: ['createdAt'] }],
916
+ retain: { after: '90d', on: 'createdAt' },
917
+ });
918
+ ```
919
+
920
+ - **Retention is a promise `x verify` checks.** A `retain` no `purge()` covers is `X_ENTITY_RETAIN_UNSWEPT` in the `manifest` step. `purge({ entities: 'declared' })` covers every one, history tables included, and needs no line per entity.
921
+ - **History is recorded by trigger**, so `updateWhere`, a cascade and raw SQL are recorded too. An update that changes nothing records nothing; a change to a counted column alone records nothing. `.sealed()` columns stay ciphertext in the images. `actor_id`/`request_id` are what the WRITE'S transaction set (`x.actor`, `x.request`): the repository sets them before its first write to a history entity inside a transaction; an autocommit write records `null`, never a guess. Update of a history row is refused always; delete only under `x.purge` naming that table, which the retention sweep sets (`X_ENTITY_APPEND_ONLY`, SQLSTATE `23001`). Reading it is an ordinary, tenant-scoped, cursor-paged read: `database({ posts, postsHistory: posts.$history })`.
922
+ - **A counter locks its parent row.** A hot parent serializes its writers; where that matters, `countBy` plus a cache is the alternative. A soft-deleted child is not counted. A new counter's migration recounts once, so an existing table starts true; `x db recount posts.likeCount` reports drift and `--write` repairs it.
923
+ - **A counter's write is the trigger's, not the repository's.** The WAL replicator reports it; the in-process row observer (`setRowObserver`, what `x dev` on PGlite and the memory driver feed live queries from) does not, so a live view of the counter on those does not move until the parent row is written again. A counter a page shows live stays the mutator's job until that observer reports trigger writes. As of 2026-11.
924
+ - **Memory answers as Postgres does**: the memory driver records history and moves counters in its repository (`counter.test.ts`, `history.test.ts` are the parity tables).
925
+ - Drift names a missing or disabled lifecycle trigger: `X_LIFECYCLE_TRIGGER_MISSING`, its fix the statements that re-create it.
926
+
927
+ ## One person's data reaches through the foreign keys
928
+
929
+ `defineSubject(members, { tables })` (`@ultimat3/jobs`) reads the keys every `references()` already
930
+ declares: each entity with a key into the subject — or into a table its erase deletes — needs a
931
+ treatment, `'delete'`, `{ anonymize: { col: value } }` or `{ retain: '<why>' }`. **A table added
932
+ with a key to the subject and no line there is red on `x verify`** (`X_SUBJECT_TABLE_UNDECLARED`,
933
+ naming the path). A table holding the person's key with no `references()` is invisible to the
934
+ walk until `columns: { <table>: '<column>' }` lists it. `'delete'` on an `appendOnly` or
935
+ soft-delete entity is refused: one keeps every row, the other every value. Long form:
936
+ [Jobs and Workflows](Jobs-And-Workflows#one-persons-data--subjectexport-and-subjecterase).
937
+
938
+ ## Views
939
+
940
+ `entity(name, { view: { sql, materialized? }, columns })` declares a relation Postgres derives. The
941
+ chain reads it like a table — tenancy, the codec, `$view` — and every write is
942
+ `X_ENTITY_VIEW_READ_ONLY`, in both drivers, before a statement exists. `As of 2026-11`:
943
+
944
+ ```ts
945
+ import { bigint, entity, uuid } from '@ultimat3/entity';
946
+
947
+ export const postTotals = entity('post_totals', {
948
+ view: {
949
+ sql: 'select org_id, post_id, count(*) as total from likes group by org_id, post_id',
950
+ materialized: true,
951
+ },
952
+ columns: { orgId: uuid().tenant(), postId: uuid().unique(), total: bigint() },
953
+ primaryKey: ['postId'],
954
+ });
955
+ ```
956
+
957
+ | Rule | |
958
+ |---|---|
959
+ | `columns` | the view's output columns, by physical name; `primaryKey` is the read identity, no constraint |
960
+ | `x db gen` | emits `create [materialized] view` after every table; a materialized view's indexes with it |
961
+ | A change | the query, an index, `materialized`, or a retype/drop under it: every recorded view is dropped before the table statements and every declared one recreated after — declare a view after a view it reads |
962
+ | Refused at declaration | a blank query, a second statement, `appendOnly`, a `.sealed()` column, an index on a plain view |
963
+ | Refresh | `refreshView()` — a job ([Jobs and workflows](Jobs-And-Workflows#rollups-and-materialized-views)) |
964
+ | Drift, source | the snapshot records each view's statements; `x verify`'s `drift` step compares them to the entities, so a view declared and never generated, changed since, or recorded and no longer declared is `X_DB_SCHEMA_UNMIGRATED` / `X_DB_SCHEMA_UNDECLARED` naming `view "<name>"` — one code per direction, as for a table |
965
+ | Drift, columns | only a database knows what a query outputs, so the same step's scratch replay reads each created view's columns and holds `columns` to them: one named and not output (every read a `42703`), or output and named by none, is `X_ENTITY_VIEW_DRIFT` |
966
+ | Schema dump | `07_views/<name>.sql`, a materialized view's indexes in the same file; load equals replay, as for a table |
967
+ | Seeds | `x db seed --generate` and `x exercise` seed tables only — a view's rows are derived |
968
+ | Admin, MCP | listed read-only: `list`, `detail`, `search`, and no create/edit/delete route, button or admin MCP tool. An `operations` override naming a write is `X_ADMIN_VIEW_WRITE` ([Admin dashboard](Admin-Dashboard)) |
969
+ | Manifest | the entity's fact carries `view: { materialized }`; a table becoming a view is a **breaking** change, a view becoming a table additive |
970
+
971
+ ## Migration workflow
972
+
973
+ | Step | Command | Behavior |
974
+ |---|---|---|
975
+ | Generate | `x db gen "add publish_at"` | diffs entities vs migrations, writes a named, ordered migration + its `down` |
976
+ | Apply (dev) | `x db migrate` | runs pending migrations against the dev DB; live queries resubscribe |
977
+ | Check | `x db migrate --json` | the live schema against the ledger it just wrote; exits non-zero on a difference |
978
+ | Inspect | `x db studio` | tables, columns, indexes, FKs, generated SQL — also the `/_x` **Schema** panel |
979
+ | Pre-deploy | `ROLE=migrate` container | run-once hook, same image; waits on the session-pinned migration lock while another version's migration is in flight, then applies. The wait is bounded at 60s (`As of 2026-08`) — past that it exits non-zero with `X_MIGRATE_CONCURRENT` instead of hanging the rollout |
980
+ | Test template | automatic | migrate + seed once into `myapp_test_tpl`, then clone per worker |
981
+
982
+ Prod ordering is fixed: `ROLE=migrate` completes, then `web` / `sync` / `worker` / `scheduler` roll. A generated migration is forward-compatible with the previous release, so a rolling restart never serves a request against a schema it cannot read: a column drop takes two releases through `retired: ['<c>']` (refused otherwise, `X_MIGRATION_ROLLING_UNSAFE`), and a key or CHECK on a live table is added without stopping writes → [Migrations And Backfills](Migrations-And-Backfills). See [Deployment](Deployment).
983
+
984
+ ## Drift is a `x verify` failure
985
+
986
+ ```
987
+ X_DB_DRIFT: schema differs from migrations
988
+ cause: table "posts" has column "publish_at" not present in any migration
989
+ fix: x db gen "add publish_at"
990
+ ```
991
+
992
+ | Direction | Meaning | Caught by |
993
+ |---|---|---|
994
+ | Entity has what migrations lack | you edited an entity and did not generate — run the `fix` | `x verify` |
995
+ | DB has what migrations lack | someone changed the database by hand; generate a migration or revert the change | `x db migrate` |
996
+ | Migrations have what the entity lacks | a stale migration or a deleted column; reconcile before shipping. Deleting the **last** entity is this direction, not "nothing is declared so nothing drifts": `x db gen "<name>"` refuses with `X_MIGRATION_IRREVERSIBLE`, and the same command plus `--allow-destructive` writes the drop | `x verify` |
997
+ | Neither declares anything | not drift. Zero entities against zero migrations is agreement, which is what keeps a scaffold with no `entity()` green until its first one | — |
998
+
999
+ One code, two detectors, because one check cannot be both. `x verify`'s `drift` step reads the
1000
+ entity source and the `.hash` a migration recorded — no database of yours, which is what lets the
1001
+ gate run in CI (it boots a scratch embedded one for the schema dump, below). `x db migrate` diffs the live catalog against the `x_migrations` ledger on the connection it
1002
+ just migrated over — the only place a hand-edited column is visible at all. A pending migration is
1003
+ not drift, and neither is a table in the framework's `x_` namespace.
1004
+
1005
+ **The schema dump.** `packages/db/schema/` is the schema as committed SQL — one file per table, the
1006
+ framework's `x_` tables under `framework/` — written by `x db gen` and `x db migrate`, never by hand.
1007
+ From the first migration on it is owed: `x verify`'s `drift` step replays the migrations on a
1008
+ scratch database, compares bytes, and loads the dump back. A missing, stale or hand-edited dump is
1009
+ `X_SCHEMA_DUMP_DRIFT`, `fix: x db gen`. The `.snapshot.json` sidecar is a different thing: the
1010
+ generator's diff base in the entity's vocabulary. Layout, the engine rule and limits:
1011
+ [Migrations and backfills → The schema dump](Migrations-And-Backfills#the-schema-dump). `As of 2026-10`.
1012
+
1013
+ ### What the catalog diff compares
1014
+
1015
+ Thirteen `DriftKind` values, `As of 2026-10-02` — the executable list is `DriftKind` in
1016
+ [`packages/db/src/drift-findings.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/db/src/drift-findings.ts).
1017
+ Twelve are raised by the snapshot comparison in the table below; the thirteenth, `unexpected-object`,
1018
+ by the schema-dump comparison ([Migrations and backfills](Migrations-And-Backfills#the-schema-dump)).
1019
+ **For indexes, CHECKs and foreign keys only the declared side is judged**: a live index or foreign
1020
+ key no snapshot names is not drift, because Postgres creates an index for every primary key and
1021
+ every unique constraint and an index a DBA added is a planner decision. **The primary key is the
1022
+ exception — both sides are compared**: a key the database holds and the snapshot does not, or the
1023
+ reverse, is `changed-primary-key`.
1024
+
1025
+ | Kind | Raised when |
1026
+ |---|---|
1027
+ | `unexpected-column` · `missing-column` · `changed-column` | a modelled table's columns disagree with the snapshot. A column of the **declared** primary key reads as `NOT NULL` whether or not anything declared it; a column only the database keys does not, `As of 2026-10-02`. `changed-column`'s `fix:` is one command, `psql "$DATABASE_URL" -c 'alter table … alter column … set not null;'` (or `drop not null`), run against the drifted database, then `x db migrate` to re-check |
1028
+ | `changed-primary-key` | new `As of 2026-10-02`. The two key column lists differ — a different column, a different **order**, or a key on one side only. Its `fix:` is one command — `psql "$DATABASE_URL" -c '<the drop constraint / add constraint … primary key (…) pair>'`, naming the constraint the database holds — run against the drifted database, then `x db migrate` to re-check. Until this kind existed a re-keyed table read `ok: true` |
1029
+ | `unexpected-table` · `missing-table` · `unknown-schema` | a modelled table is absent, or a table outside `x_` is present that no migration declares |
1030
+ | `missing-index` | the snapshot names an index the catalog does not hold |
1031
+ | `missing-check` | the snapshot names a CHECK constraint the catalog does not hold. Compared **by name only** — `pg_get_constraintdef` answers Postgres' own rewriting (`status in ('draft','published')` comes back as `CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text])))`), so the predicate's text can never be compared, exactly as an index's `where` cannot. Only the **declared** side is judged, so a `NOT NULL`, an `enumerated()` column's old anonymous constraint and an extension's own are all silent. Its `fix:` is the `add constraint … check (…)` statement as one `psql "$DATABASE_URL" -c '…'` command |
1032
+ | `changed-index` | the index is there and one of **four** facts about it differs: its column list, its uniqueness, its **direction**, or whether it is **partial**. The last two are new `As of 2026-08-19` — a `desc` index rebuilt ascending served a feed's newest page off the wrong end, and a partial index recreated as a total one silently widened the constraint, and both read `ok: true`. `asc` is normalised to `null` first, because Postgres stores an ascending index as not-descending. The predicate's **text** is deliberately never compared → [Known gaps](Known-Gaps) |
1033
+ | `missing-foreign-key` | no key points those columns at that table. Matched on **where the key points**, never on its name: a hand-written `constraint fk_posts_org` is the same constraint as a generated `posts_org_id_fkey` |
1034
+ | `changed-foreign-key` | new `As of 2026-08-19`. The key points where it was declared to point and one side's `on delete` rule is not the other's. Reported apart from `missing-foreign-key` because it is a different repair — the constraint is there, and what changed is what happens to the child rows. Its `fix:` is the **pair** as one `psql "$DATABASE_URL" -c '…'` command, not `x db migrate`: a rule cannot be altered in place, `add constraint` alone is `42710` on a name already taken, and no `x db gen` diff emits either statement. Both sides go through one normalisation, so the catalog's `c` and a snapshot's `cascade` agree, and Postgres' `a` (`no action`) on every undeclared key reads as no rule |
1035
+
1036
+ As of 2026-11-07 the `missing-column`, `missing-table`, `missing-index`, `changed-index` and `missing-foreign-key` findings carry one `psql "$DATABASE_URL" -c '…'` repair built from the snapshot (a `psql` session when no name can be written), never `x db migrate`: the migration declaring it is already in the ledger, so re-running the migrator applies nothing. Run the repair, then `x db migrate` re-checks.
1037
+
1038
+ There is no separate migration tool and no "regenerate types" step. `drift` is one of `x verify`'s 21 steps — the list, in order, is in [Testing](Testing).
1039
+
1040
+ ## Reversible or marked
1041
+
1042
+ Two questions, two answers. **Reversible** is asked at generation: can the `down` put the rows
1043
+ back? **Destructive** is asked at the gate: does applying the `up` destroy data, and does the file
1044
+ say so? A retype is reversible in DDL and still rewrites every row, which is why one flag cannot
1045
+ answer both.
1046
+
1047
+ | Rule | Detail |
1048
+ |---|---|
1049
+ | Every migration has a `down` | `x db gen` writes both halves of one file, split by a lone `-- down` line |
1050
+ | A generated column drop | takes two releases. Not retired first: `X_MIGRATION_ROLLING_UNSAFE`, whose fix prints the path. Retired: `X_MIGRATION_IRREVERSIBLE` — its `down` cannot restore the rows — until `x db gen "<name>" --allow-destructive` |
1051
+ | A changed primary key | `As of 2026-10-02`: `alter table … drop constraint if exists "<table>_pkey"` ahead of the table's column statements and `add constraint "<table>_pkey" primary key (…)` after them, reversed in `down`. Until then it wrote **no statement** and `x db gen` re-recorded the hash sidecar as if nothing had changed. A key another table's foreign key is written against refuses with `X_MIGRATION_IRREVERSIBLE` naming those constraints: drop the keys in one migration, change the key in the next, restore them in a third. A key over a column the same migration adds with no default refuses the same way — every existing row would hold `NULL` there: add the column, backfill it, then key it. A nullable column leaving the key gets `drop not null`, in `up` and in `down`, because Postgres keeps a key column `NOT NULL` after the key is gone |
1052
+ | A destructive `up` | must carry `-- destructive: true` as a line in the file. `x db gen` writes it for you; an unmarked one fails `x verify`'s `drift` step with `X_MIGRATION_DESTRUCTIVE` |
1053
+ | What counts as destructive | `drop table`, `drop column`, `truncate`, `alter column … type` — a closed list. `drop constraint`, `drop default`, `drop not null` and `drop index` do not: the database rebuilds those. A truncate is a statement that **starts with** `TRUNCATE`. `create trigger … before truncate` and `grant`/`revoke … truncate` only name the operation to guard or permit it, and are not truncates (`As of 2026-09-24`, #522) |
1054
+ | Only `up` is judged | reversing a `create table` is a `drop table`, so a rail that read `down` would mark every migration ever generated |
1055
+ | Mark it before it is applied | the marker is SQL the checksum covers, so adding it to an applied migration is an edit — `X_MIGRATION_CONFLICT`, correctly |
1056
+ | Rollout | expand → migrate → contract across two releases is what the generator enforces for a column: `retired`, deploy, drop |
1057
+
1058
+ ```sql
1059
+ -- 20260814120000_drop_legacy
1060
+ -- GENERATED by `x db gen` from the app's entities — do not edit.
1061
+ -- Editing an applied migration changes its checksum: X_MIGRATION_CONFLICT on the next apply.
1062
+ -- destructive: true
1063
+
1064
+ alter table "posts" drop column "legacy";
1065
+
1066
+ -- down
1067
+ alter table "posts" add column "legacy" text; -- data is not restored
1068
+ ```
1069
+
1070
+ ```text
1071
+ X_MIGRATION_DESTRUCTIVE: this migration destroys data and does not say so
1072
+ cause: packages/db/migrations/20260814120000_drop_legacy.sql drops a column and does not
1073
+ declare it: alter table "posts" drop column "legacy"
1074
+ fix: add the line "-- destructive: true" to packages/db/migrations/20260814120000_drop_legacy.sql,
1075
+ or regenerate it: x db gen "<name>" --allow-destructive
1076
+ ```
1077
+
1078
+ ## Branch DBs for agents
1079
+
1080
+ The shipped command is `x db branch`, and it takes a verb from a closed set — `list`, `create <name>`, `delete <name>`. `x branch` (no `db`) is **planned** and exits `X_NOT_IMPLEMENTED`; the build and MCP-socket halves of the design below are what it will add.
1081
+
1082
+ ```bash
1083
+ x db branch create feat-new-billing --json
1084
+ ```
1085
+
1086
+ ```json
1087
+ {"ok":true,"command":"db","data":{"branch":"feat-new-billing",
1088
+ "database":"myapp_branch_feat_new_billing",
1089
+ "preview":"http://feat-new-billing.localhost:3000","mode":"external"}}
1090
+ ```
1091
+
1092
+ | Property | Detail | `As of 2026-08` |
1093
+ |---|---|---|
1094
+ | Mechanism | `CREATE DATABASE "<source>_branch_<slug>" TEMPLATE "<source>"` — Postgres file-copies, cheap, isolated, disposable. `<slug>` is the name with every character outside `[A-Za-z0-9_]` replaced by `_` (`branchDatabaseName`), because a hyphen is not legal in an unquoted identifier — hence `myapp_branch_feat_new_billing` above. On the embedded database it is `branchPglite()`, a data-directory copy named `pgdata-<name>`, which keeps the name as typed | **shipped** |
1095
+ | Writes | the MCP `db.migrate` tool applies **only** in a branch DB, never the shared dev DB (`X_MCP_NOT_BRANCH_DB`) | **shipped** |
1096
+ | Preview URL | reported in `data.preview`, subdomain-routed off `PORT` | **the URL is computed**; nothing routes that subdomain for you |
1097
+ | Listing | `x db branch list` — name, location, created-at, size, over either database. **Managed branches only**: external, a database carrying `createBranch()`'s marker comment (`listBranches()` is that read); embedded, a `pgdata-<name>` directory under the state dir (`listPgliteBranches()`, in the CLI). A branch cloned by the pre-1.2.x `psql` path carries no marker and is invisible → [Known gaps](Known-Gaps). `created-at` and `size` read `unknown` where nothing recorded one — always the size on the embedded side, since measuring it is a full directory walk | **shipped** |
1098
+ | Teardown | `x db branch delete <name>`. It may only delete what `list` shows, and that guard lives in the **CLI** (`runDelete`): the shared database this session is connected to carries no marker, so it is not in the set, and there is no `--force` to get it wrong with. `@ultimat3/db`'s `dropBranch()` is the statement underneath, not the same operation — it takes the **database** name (`<source>_branch_<slug>`), reads no marker, and will drop any database but the current one. Its `force: true` means "terminate other sessions first", which the CLI always passes; it is not an override of the guard | **shipped** |
1099
+ | Reaping | `reapBranches({ maxAgeMs })` drops **branches of this database only**, `As of 2026-08-19`. The marker is `ultimate:branch:<base>:<iso>` and `BranchInfo` carries `base`; a branch whose base is not `current_database()` is skipped, and so is a pre-3.x marker that records no base at all. A `createdAt` that is not finite, or that does not round-trip through `toISOString()`, is also skipped — a truncated comment used to read as an infinitely old branch and be dropped on the next sweep whatever `maxAgeMs` said | **shipped** |
1100
+ | Build + scoped MCP socket | a per-branch build id scoping the service worker, and `ws://localhost:9229/<branch>` | **planned**, part of `x branch` |
1101
+
1102
+ The same clone mechanism powers test parallelism: each worker gets its own `ultimate_test_template_w<N>` cloned from the migrated template `ultimate_test_template`, typically 100–400ms. Never mock the database — clone it.
1103
+
1104
+ ## Errors
1105
+
1106
+ | Code | Cause | Fix |
1107
+ |---|---|---|
1108
+ | `X_DB_DRIFT` | schema differs from migrations | `x db gen "<name>"` |
1109
+ | `X_ENTITY_DUPLICATE` | two entities on the same table | rename one, or merge them |
1110
+ | `X_INVARIANT_VIOLATED` | a write broke a named invariant | fix the caller, or change the invariant and generate a migration |
1111
+ | `X_TENANCY_UNSCOPED` | a query without a tenant predicate | go through the repo |
1112
+ | `X_PRELOAD_UNKNOWN_RELATION` | `preload('<name>')` named a relation no `references()` produces | pick one of the relation names the error lists, or add the `.references()` call that creates it |
1113
+ | `X_ENTITY_SEALED_PREDICATE` | a `.sealed()` column named in a filter, an order, a group, an index or an invariant | find the row by another column, or declare `text().sealed({ lookup: true })` for equality |
1114
+ | `X_ENTITY_SEALED_IN_VIEW` | a `.sealed()` column listed in `$view([...])` | remove it from the list |
1115
+
1116
+ Full list with `--json` shapes: [Error codes](Error-Codes). Source: [`docs/idea/02-primitives.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/02-primitives.md), [`docs/idea/10-testing.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/10-testing.md).