@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,493 @@
1
+ # Scraping
2
+
3
+ A scrape is a **`job`**, not a ninth primitive. `scrape()` returns a `JobHandle`, so it inherits `.enqueue()`, the retry policy, the worker's cancellation, the dead-letter path and `x jobs show` — the same factory shape `backfill()`, `llm()` and `agent()` use ([The eight primitives](The-Eight-Primitives)).
4
+
5
+ `As of 2026-10-01`: 53 source files, 32 owned `X_SCRAPE_*` codes, 2 borrowed. Zero runtime dependencies outside `@ultimat3/*` — puppeteer is **passed in**, never imported ([Drivers](#drivers)).
6
+
7
+ ## What ships
8
+
9
+ | Capability | Mechanism |
10
+ |---|---|
11
+ | Browser automation | `ScrapePage`, driver-blind — the same body runs on a browser, a recording, or a string of HTML |
12
+ | The second transport | `http` on the same session: the browser's cookies, headers, proxy, host list, rate limit and cancellation |
13
+ | Actionability | waits for visible, enabled, unobstructed and **still** before acting — not for the selector alone |
14
+ | Host allow list | `intercept.ts`, one decision, asked by every driver and the HTTP leg, before a byte leaves |
15
+ | robots.txt | obeyed by default, on both legs, deadlined and capped |
16
+ | Rate limiting | navigations per second, across both legs. There is no unpaced mode |
17
+ | Secrets | declared by name, resolved in the worker, and a typed secret **refuses** later pixel captures |
18
+ | Sessions | capture, persist **sealed**, restore, validate, burn |
19
+ | One session per connection | `concurrency: { key, limit: 1, whenBusy: 'fail' }` — the job's own keyed cap |
20
+ | An exit per run | `egress: (input, ctx) => proxyUrl`, resolved in the worker — never carried in the queue payload — and dialled by both legs and the robots read |
21
+ | A rented browser | `remoteBrowser({ cdpUrl: resolver })` — acquired per session, released exactly once |
22
+ | A prompt answered mid-run | `eventPrompt({ timeout })` + `answerPrompt()`, over the stored job event bus, with the browser open |
23
+ | Usage on every result | `ScrapeReport.usage` — a measurement, never a quota |
24
+ | The silent-green alarm | `expect: { minRows, maxDrop }` — a scrape that returns nothing fails instead of succeeding |
25
+ | Offline testing | two drivers that never touch the network, pinned against the real one by a parity suite |
26
+ | Wedge watchdog | ends a run whose browser has gone silent: kills the process and closes the browser at the fire, an attached one too |
27
+
28
+ ## The declaration
29
+
30
+ ```ts
31
+ export const dailyOrders = scrape({
32
+ name: 'orders.daily',
33
+ input: t.object({ page: t.number.int() }),
34
+ extract: t.object({ id: t.string, title: t.string }),
35
+ idempotencyKey: (input) => `orders:${input.page}`,
36
+ tenant: (input) => input.orgId,
37
+ allowHosts: ['shop.example'],
38
+ expect: { minRows: 1 },
39
+ async run({ page, http, step }) {
40
+ await step.run('list', () => page.goto('https://shop.example/orders'));
41
+ return (await page.values('.row')).map((row) => ({ id: row.attrs['data-id'], title: row.text }));
42
+ },
43
+ });
44
+ ```
45
+
46
+ | Field | Required | What it decides |
47
+ |---|---|---|
48
+ | `name` | yes | the durable queue key, never an export name |
49
+ | `input` | yes | the job's input schema |
50
+ | `extract` | yes | **every row, parsed.** A row the schema rejects is `X_SCRAPE_OUTPUT_INVALID`, never a stored partial |
51
+ | `idempotencyKey` | yes | derived from `input` alone, like every job's |
52
+ | `tenant` | yes | or `'none'` — a job runs with no request behind it, so an unscoped read would be unscoped for real |
53
+ | `allowHosts` | yes | `['*']` is legal and is a decision spelled out; `[]` is refused at declaration |
54
+ | `run` | yes | the body: `{ input, page, http, step, ctx, secrets }` |
55
+ | `block` | — | resource types to drop (`image`, `font`, …) |
56
+ | `rate` | — | navigations per second. Default `1`; `0` or negative is refused at declaration |
57
+ | `robots` | — | `'obey'` (default) or `{ ignore: '<reason>' }` — a reason is required |
58
+ | `expect` / `history` | — | the yield alarm, below |
59
+ | `secrets` | — | **names**, never values |
60
+ | `auth` / `prompt` | — | session lifecycle, and where an out-of-band code comes from — see [A session a service is built on](#a-session-a-service-is-built-on) |
61
+ | `egress` | — | `(input, ctx) => string \| undefined`, sync or async: the proxy URL THIS run leaves through, looked up in the worker. Wins over the driver's `proxy` |
62
+ | `onSettled` | — | how the run ended, told once: the `ScrapeReport` on `completed`, the `code` and the last attempt's `usage` otherwise — see [Usage](#usage) |
63
+ | `recover` | — | a function — see [Recovery](#recovery) |
64
+ | `artifacts` | — | where a failed run's HTML is written: `{ storage: () => disk('artifacts') }` — a thunk, read per write |
65
+ | `driver` | — | which browser. Absent uses the process-wide `setScrapeDriver()` |
66
+ | `watchdog` | — | `{ idleMs, graceMs }`, defaults `120000` / `5000` |
67
+ | `retry` / `timeout` / `pageTimeout` / `queue` / `concurrency` | — | the job knobs, unchanged — `concurrency` takes the job's keyed form too ([Jobs and workflows](Jobs-And-Workflows)) |
68
+ | `clock` | — | pins this definition's clock. Absent uses the process's — `setScrapeClock()`, which is what a test replaces |
69
+
70
+ The CLI has no scrape command and no scrape generator `As of 2026-08-20` — `g` ships thirteen positionals and `scrape` is not one of them. A scrape is reached through the job CLI:
71
+
72
+ ```bash
73
+ x jobs list --name orders.daily --json
74
+ x jobs show <id> --json
75
+ x jobs retry <id> --from-step list --json
76
+ ```
77
+
78
+ ## The page vocabulary
79
+
80
+ One interface, three drivers, no driver type in it.
81
+
82
+ | Call | Notes |
83
+ |---|---|
84
+ | `goto(url, { timeout })` | refused before a byte leaves on a blocked host or a disallowed path |
85
+ | `waitFor(selector, { state, timeout })` | `attached` → `visible` → `enabled` → `actionable`; each implies the ones above |
86
+ | `click` / `type` / `fill` / `select` | every one waits for `actionable` first |
87
+ | `values` / `text` / `html` / `count` | reads; `text()` with no argument is the whole document |
88
+ | `evaluate(expression)` | returns `unknown` — parse it |
89
+ | `frame(nameOrSelector)` | re-resolves on every call; nothing hands out a stale handle |
90
+ | `screenshot` | `{ fullPage }` or `{ clip }`, never both |
91
+ | `pdf` | the print layout: `format` (`a4`, `letter`, …) or `widthMm` + `heightMm`, `marginMm`, `printBackground`, `preferCssPageSize`, `headerHtml` / `footerHtml`. Two sizes, half a size or a size beside `preferCssPageSize` is `X_SCRAPE_PDF_OPTIONS_INVALID` (422), before any driver |
92
+ | `download({ timeout })` | whatever the last click produced, or `X_SCRAPE_DOWNLOAD_TIMEOUT` |
93
+ | `cookies` / `session` | the handoff to the HTTP leg, as a value you can inspect |
94
+ | `query(selector)` | every match as a snapshot — tag, attributes, text, **and `visible`**. The one definition of "visible" in the framework; nothing else may compute it |
95
+ | `offline(enabled)` | the browser's own offline mode. **Not** `@ultimat3/testing`'s sealed network, which patches `fetch` in the *test process* and a browser's requests never traverse — a test built on that would pass against a fully online app |
96
+ | `focus(selector, { timeout })` | waits for `actionable`, then moves focus — the setup for a `press()` |
97
+ | `press(chord)` | `'Meta+K'`, `'Escape'`, `'Shift+Tab'` on whatever holds focus. Modifiers are `Meta`, `Control`, `Alt`, `Shift` in the browser's own spelling; `'Ctrl+K'` is `X_SCRAPE_KEY_INVALID` on **every** driver, offline included — the parse is the one thing an offline driver can be wrong about, so it runs there too, then stops |
98
+ | `accessibility(selector, { max })` | what a screen reader is told about each match — the browser's **computed** role and name, after ARIA and label association, at most `max` (default 25) nodes. Refused with `X_NOT_IMPLEMENTED` on the offline drivers and on a frame of the real one, never answered from `role=` on the tag: a `<div onclick>` computing no role *is* the finding |
99
+ | `console()` / `network()` | bounded rings, 200 entries each |
100
+ | `pageErrors()` / `pageErrorsDropped()` | uncaught exceptions, a **third** ring — not console lines |
101
+ | `activity()` | tells the wedge watchdog the run is working on a channel this page cannot see — your own CDP session, a long warm-up. Every verb above already reports; this touches the idle clock only and counts no usage. On every driver |
102
+
103
+ **The wedge watchdog ends the run at the fire, on every driver** (`As of 2026-11`). Past
104
+ `watchdog.idleMs` with no activity it kills the OS process, starts the graceful close under
105
+ `graceMs` (a second kill past it), then aborts with `X_SCRAPE_WEDGED`. On `remoteBrowser()` there
106
+ is no process to kill, and a call blocked on the socket — `evaluate` takes no signal — never unwound
107
+ to `close()`: the run kept driving the site for minutes. Closing the browser is what rejects that
108
+ call, and it rejects as `X_SCRAPE_WEDGED`, not `X_SCRAPE_BROWSER_UNREACHABLE`. A run that drives the
109
+ browser through its own channel calls `page.activity()` between steps rather than routing
110
+ keep-alives through `page.evaluate`.
111
+
112
+ **A frame verb acts on the frame, and this had to be fixed.** `frame()` returns a target that
113
+ overrides every verb addressing a document — `click`, `type`, `select`, `clear`, `query`. Until
114
+ 2026-08-25 `clear` was missing from that list, so `frame(…).fill()` cleared the **parent's**
115
+ same-id field and then *appended* to the frame's: a remembered username submitted as
116
+ `oldUserNEWUSER` while a parent field was silently emptied. On the offline drivers one shared
117
+ overlay made it worse — `page.values('#password')` read back what was typed into the frame.
118
+
119
+ `driver-parity-frames.test.ts` pins every frame verb across all three drivers, because the parity
120
+ suite that exists to catch a CDP/offline divergence had no frame coverage at all.
121
+
122
+ `screenshot`/`pdf` take **no `timeout`** — `CaptureRequest.timeout` and the port's `CaptureOptions.timeoutMs` were deleted in 4.0.0 ([Upgrading](Upgrading)). No driver had ever honoured them, and a deadline enforced above the driver would have had to race `ScrapeClock.sleep`, which under `testScrapeClock` resolves on the first microtask — so every capture in every test would have timed out. The driver's own default is the honest bound.
123
+
124
+ **`pageErrors()` is a separate stream from `console()`, and that is not tidiness.** An uncaught
125
+ exception calls no console method, so a page whose script died can answer `console(): []` — gate on
126
+ console alone and it reads as clean. Nothing in this package observed `pageerror` before
127
+ 2026-08-21: `console` and the renderer-crash `error` event were subscribed, and a throw fell between
128
+ them. The two events are also not interchangeable — `error` latches the crash flag, after which every
129
+ call answers `X_SCRAPE_PAGE_CRASHED`, which is classified **terminal**; routing a `pageerror` there
130
+ would dead-letter a scrape of a page that still renders perfectly.
131
+
132
+ A `PageError` carries `message`, an optional `stack` and `at`. **Keep the stack** — it names the
133
+ module and line that threw (`at Cart (/app/islands/cart.tsx:31:18)`), which is the difference between
134
+ a report and a lead; `message` alone says what went wrong and never where. It is truncated at
135
+ `MAX_PAGE_ERROR_CHARS` (4,000) and marked when it is, because one blown stack is thousands of frames.
136
+ An absent `stack` means the payload carried none — a `throw 'string'` — never that the throw had no
137
+ origin.
138
+
139
+ `pageErrorsDropped()` makes the count a **floor**: the ring is bounded, so `pageErrors().length` is
140
+ what survived eviction and not what happened.
141
+
142
+ `click` takes **no index**. It clicks the first match, on every driver.
143
+
144
+ ## Two transports, one session
145
+
146
+ Drive the browser through login and navigation, then pull the bulk off the site's own JSON endpoints. `http.request()` carries the browser's cookies (scoped per RFC 6265 §5.1.3/§5.1.4), its headers, its proxy, the same `allowHosts`, the same robots gate, the same pacing and the same cancellation. A redirect is followed one hop at a time under the same gates — each `Location` is screened against `allowHosts` and robots, recorded in `page.network()` under the URL actually requested, and re-scoped for cookies — so a scraped endpoint cannot 302 the worker onto a host the allow list never named. A hop to another origin carries no credential you set (`authorization`, `proxy-authorization`, a hand-written `cookie`), and it stays dropped for the rest of the chain; a same-origin hop keeps them. A `301`/`302` re-asks a `POST` as a `GET` and leaves every other method — a `PUT` or a `DELETE` — intact with its body, `303` re-asks everything but `GET`/`HEAD`, and `307`/`308` carry both. `res.url` is the final URL, a chain past `MAX_REDIRECT_HOPS` (10) is `X_SCRAPE_REDIRECT_LOOP`, and a `javascript:` URL is refused outright rather than read as a hostless scheme. Header names are case-insensitive, so a header you declare **replaces** the session's in any spelling — `{ 'User-Agent': 'Mine', Cookie: '…' }` wins over the browser's agent and the jar instead of being joined to them.
147
+
148
+ Two hundred paginated pages clicked through is minutes and two hundred chances to break; the same data off the endpoint behind them is seconds, and a JSON endpoint changes far less often than a DOM.
149
+
150
+ Response bodies are counted **as they arrive**, capped at `DEFAULT_HTTP_MAX_BYTES` (32 MiB) — `.text()` on a hostile stream is a heap the worker never gets back.
151
+
152
+ ## Drivers
153
+
154
+ | Driver | Use | Network |
155
+ |---|---|---|
156
+ | `localBrowser({ launcher, executablePath })` | a browser in this container | real |
157
+ | `remoteBrowser({ launcher, cdpUrl })` | an attached CDP endpoint — a fixed URL, or a `CdpResolver` that rents one per session | real |
158
+ | `fixtureBrowser(dir)` | committed page recordings | none |
159
+ | `fakeBrowser(pages)` | inline pages, for a unit test | none |
160
+
161
+ **The launcher is passed in, never imported.** `CdpLauncherLike` is two methods — `launch` and `connect` — so `puppeteer`, `puppeteer-core` or anything with the same shape satisfies it, and this package declares no browser dependency at all. A launcher with no `launch` is `X_SCRAPE_REMOTE_REQUIRED`, not a crash. The browser it hands back must expose `target().createCDPSession()` — puppeteer's does — because `allowHosts` is enforced at the browser too, on every tab a scraped page opens; a session needs `cookies()` (and `setCookie()` to restore one), refused by name as `X_NOT_IMPLEMENTED` when absent rather than read as an empty jar.
162
+
163
+ `driver-parity.test.ts` runs one suite against all three and pins the single honest divergence: the offline drivers have no layout engine, so `ElementSnapshot.box` and `hitTarget` are absent rather than faked.
164
+
165
+ ## The silent-green alarm
166
+
167
+ A scraper that returns zero rows because the site changed its markup is the failure that does not look like one.
168
+
169
+ ```ts
170
+ expect: { minRows: 1, maxDrop: 0.5, window: 7 },
171
+ history: yourYieldHistory,
172
+ ```
173
+
174
+ | Rule | Needs `history` | Fires when |
175
+ |---|---|---|
176
+ | `minRows` | no | the run returned fewer rows than the floor |
177
+ | `maxDrop` | **yes** | the run fell more than this fraction below the trailing median |
178
+
179
+ `minRows` is an absolute floor, checked **before** the history gate, so it is legal on its own. `maxDrop` is a fraction in `[0, 1)` — `50` or `1` would make the alarm unable to fire, and a negative one fires on a run that matched its baseline, so each is refused (`X_INVARIANT`) where the scrape is declared. It is a fraction of a trailing median and only a `history:` store can supply one — declaring it alone is refused at declaration with `X_SCRAPE_YIELD_HISTORY_MISSING`, because with no store the baseline is `[]` forever and the alarm could never fire.
180
+
181
+ A collapsed run is **not recorded**. Three broken runs at 2 rows would make the median 2, and the fourth broken run would be within `maxDrop` of it — a scraper that re-baselines onto its own failure has silenced the alarm exactly when it was working. `maxDrop` needs `MIN_BASELINE_RUNS` (3) runs after it is declared before it can fire: a delay, not a hole.
182
+
183
+ ## Robots, hosts, rate
184
+
185
+ | Gate | Default | Refusal |
186
+ |---|---|---|
187
+ | `allowHosts` | required, no default | `X_SCRAPE_HOST_BLOCKED`, before a byte leaves — on the page, on every other tab the page opens, on the HTTP leg and on the robots read |
188
+ | `robots` | `'obey'` | `X_SCRAPE_ROBOTS_DISALLOWED` |
189
+ | `rate` | 1 navigation/second | none — it paces, across both legs |
190
+
191
+ **A wildcard admits names, then checks where they lead.** On the two legs this package dials itself — the HTTP leg and the robots read — a host admitted only by `'*'` or `'*.example.com'` is resolved, and an answer that is loopback, private, link-local or the metadata address is `X_SCRAPE_HOST_BLOCKED`; the request is then pinned to the approved address. An exact rule (`'intranet.example'`) is the decision to reach that host and is not resolved. Through a proxy the proxy resolves, so nothing is checked locally. The browser resolves its own names, and a WebSocket opened by a page is not intercepted at all — both are open → [Known gaps](Known-Gaps).
192
+
193
+ Ignoring robots takes a written reason: `robots: { ignore: 'contract with the operator, ticket OPS-441' }`. A bare `false` is a decision with no author.
194
+
195
+ The `/robots.txt` read is deadlined (10s), capped (500 KiB) and dialled through the session's own exit — the run's `egress`, else the driver's `proxy` — asked per read, because the exit is resolved inside `driver.open()` while the gate is an argument to it. An offline driver reports the run's `egress` too, so its one real request leaves the same way. A redirect is followed one hop at a time, at most `MAX_ROBOTS_REDIRECTS` (5, RFC 9309's floor), each hop asked of `allowHosts` first — a hop off the list is never requested. (`robotsFetcher` / `robotsGate` called with no `allowHosts` follow only a hop on the starting hostname — `http:` → `https:`, a moved path.) What the read answers decides the run, after the "access results" rules of RFC 9309 (`As of 2026-10`):
196
+
197
+ | The read | Means | The gate |
198
+ |---|---|---|
199
+ | `2xx` | the file | its rules, cached for the run |
200
+ | `4xx` (not `429`), a body past the cap, a redirect off the list or past the hop cap | **unavailable** | no restrictions, cached for the run |
201
+ | `5xx`, `429`, a refused connection, the deadline, the run cancelled, a `fetchText` that rejects | **unreachable** | complete disallow — every path on the origin is `X_SCRAPE_ROBOTS_DISALLOWED` — and **not** cached: the next navigation asks again, so the run recovers when the file answers |
202
+
203
+ `429` is the one row that is **our policy, not the RFC's**: RFC 9309 calls every `4xx` unavailable, `429` included. Ultimate refuses on it because a server rate-limiting the crawler has not said "no rules" — stricter than the RFC requires, never looser.
204
+
205
+ A hand-written `fetchText` answers the text, `undefined` (no file) or `{ unreachable: '<why>' }`.
206
+
207
+ Robots patterns are **walked**, not compiled — a wildcard-dense rule from a scraped site is linear rather than catastrophic backtracking on the worker's only thread.
208
+
209
+ ## Secrets
210
+
211
+ Declared by name, resolved in the worker, never in the definition:
212
+
213
+ ```ts
214
+ secrets: ['SHOP_PASSWORD'],
215
+ run: async ({ page, secrets }) => {
216
+ await page.type('#password', secrets.get('SHOP_PASSWORD'));
217
+ }
218
+ ```
219
+
220
+ **Redaction is by VALUE, on four surfaces**: `page.html()`, `page.console()`, `page.network()` URLs
221
+ and `page.pageErrors()` — plus the HTTP leg's response body, which reaches an error `cause` and
222
+ from there a dead-letter row that `x jobs show` prints. Three of those were unredacted until
223
+ 2026-08-25 while this package's own header promised all of them. **Every spelling of a value is
224
+ redacted**: raw, percent-encoded (hex escapes in either case), form-encoded (`+` for a space) and HTML-escaped as text, as an
225
+ attribute or by a template (`&#39;`) — a password in a query string is never the raw string.
226
+
227
+ A credential-bearing URL — a proxy exit, a provider's connect URL — hands the redaction set the
228
+ whole URL, its password, its query, and only those query values and path segments that are
229
+ credential-shaped (a key whose words — split on `_`, `-`, `.` and camelCase — include `token`, `key`,
230
+ `secret`, `auth`, `sig`, `session` and the like, so `apiKey` counts and `keyboard` does not; a long
231
+ letters-and-digits run; or the id after `/devtools/browser/` or `/devtools/page/`). `stealth=true&proxy=residential` does not blank every
232
+ `true` and `residential` in the artifact.
233
+
234
+ **One stated limit**: a secret shorter than 4 characters is not redacted. A 3-character token would
235
+ match too much ordinary page text to be safe to blank; declare a longer one.
236
+
237
+ Typing a `Secret` **taints the page**. A later `screenshot()` or `pdf()` is refused with `X_SCRAPE_SECRET_EXPOSED` — a screenshot of a filled login form *is* the password, in pixels, in object storage, forever. Refused rather than masked: a mask over pixels is a guess about layout, and `page.html()` already gives a redacted artifact that is exact.
238
+
239
+ ## Sessions
240
+
241
+ Reuse is both the fast path and the safe path — logging in on every run is slow and is itself the signal anti-bot systems look for.
242
+
243
+ | Call | What it does |
244
+ |---|---|
245
+ | `restorableSession(plan)` | the stored session this run may restore, or `undefined` |
246
+ | `ensureAuthenticated(plan)` | log in when there is nothing to restore |
247
+ | `burnSession(plan, seen)` | delete it — a flagged profile stays flagged, so a retry that reloads it re-trips the block. `seen` is the `version` of the record this run found — restorable or not — or saved (`recordVersion(restored)`; a record from before versions shipped reads its `savedAt`). A record another run on the same key saved since is left in place; the refusal tombstone a failed login writes follows the same rule. The compare narrows the race and does not close it: neither the store nor `StorageDriver` has a conditional write |
248
+ | `memorySessionStore()` / `storageSessionStore(() => disk('sessions'))` | where it lives. The disk is a THUNK, read per call: a scrape is declared when its module loads, and the app's disks exist only after boot ran `defineStorage()` |
249
+
250
+ **A stored session is sealed.** `storageSessionStore` writes `{ "sealed": "x1.…" }` — core's
251
+ `seal()` under the app's master key (`x secrets init`), purpose `scrape-session`. No cookie value,
252
+ `localStorage` token or origin is readable from the bucket.
253
+
254
+ | Found on `load()` | Answer |
255
+ |---|---|
256
+ | this store's sealed record, saved under this key | the session |
257
+ | nothing | `undefined` — the run logs in |
258
+ | an unsealed object, a corrupt one, one sealed under a key this process does not declare, one copied from another key's path | **burned**, then `undefined` — a session is a cache, so nothing is migrated |
259
+ | no master key at all | `X_SEAL_KEY_MISSING`, before the browser opens — run `x secrets init` |
260
+
261
+ **Upgrading into sealed sessions discards what was stored before**, refusal markers included. A
262
+ record written before sealing shipped (23.0.0) is unsealed, so its first `load()` burns it: the run
263
+ logs in again, and a credential the site had already refused is presented once more instead of
264
+ being refused before the browser opens. On a site that locks an account after repeated failures,
265
+ correct that credential before the first run after upgrading
266
+ ([Upgrading](Upgrading), `22.x → 23.0.0`).
267
+
268
+ **The key hashes each segment, and this changed on 2026-08-25.** A session key is
269
+ `<tenant>/s/<discriminator>`, and both segments used to be sanitised by collapsing every run of
270
+ non-`[a-zA-Z0-9._-]` to a single `-`. So `alice@corp.com` and `alice-corp.com` produced **one key**,
271
+ as did `acct/1` and `acct-1`, and tenants `acme corp` and `acme-corp`. The browser then loaded
272
+ account A's cookies, `auth.validate()` answered `true` — the session *is* valid, for the wrong
273
+ account — and A's rows were stored under B's tenant. Each segment now carries a hash of its raw
274
+ value, so two spellings can no longer collide.
275
+
276
+ **Migration: none to run.** Every stored key changes spelling, a miss reads as "no session", so the
277
+ run logs in again and writes the new key. One extra login per stored session, no error, and the old
278
+ objects are orphaned until the bucket's lifecycle rule collects them.
279
+
280
+ A session record survives a **refusal** rather than being deleted with it: `refusedAt` is read before `driver.open()`, so a replay, a manual `x jobs retry` or a second enqueue refuses without spending a browser, a CDP attach or one more wrong password at a site that locks accounts after three.
281
+
282
+ `parseSessionState` **completes** a stored cookie rather than asserting it. A jar entry carrying only `name` and `value` gets `domain: ''`, `path: '/'`, `httpOnly: false`, `secure: false`. An empty domain matches **no host** — inferring one from whichever URL is asking is exactly how a `bank.test` session cookie reaches `evilbank.test`. Before 4.0.0 the parser claimed such an entry was a whole `ScrapeCookie` and `cookieHeaderFor` then threw a bare `TypeError` on `domain.trim()`.
283
+
284
+ Session material is credential material: it is tenant-scoped, it never reaches a log line, an event field, an artifact or a screenshot, and `sessionDigest()` summarises it as counts and an origin.
285
+
286
+ ## A session a service is built on
287
+
288
+ One declaration: a caller opens a session, events stream out, input goes in mid-run, one result
289
+ comes back. Every piece is a field on `scrape()` or on its driver.
290
+
291
+ ```ts
292
+ const rent: CdpResolver = async ({ runId, egress, signal }) => {
293
+ const rented = await provider.create({ tag: runId, proxy: egress, signal });
294
+ return {
295
+ cdpUrl: rented.connectUrl,
296
+ release: () => provider.end(rented.id),
297
+ cost: { minor: rented.priceCents, currency: 'USD' },
298
+ };
299
+ };
300
+
301
+ export const syncAccounts = scrape({
302
+ name: 'accounts.sync',
303
+ // Ids only: the input is the queue row, and a proxy URL carries its account.
304
+ input: t.object({ connectionId: t.uuid, orgId: t.uuid }),
305
+ extract: t.object({ id: t.string }),
306
+ idempotencyKey: ({ connectionId }) => `accounts:${connectionId}`,
307
+ tenant: ({ orgId }) => orgId,
308
+ allowHosts: ['bank.example'],
309
+ driver: remoteBrowser({ launcher: puppeteer, cdpUrl: rent }),
310
+ // Resolved in the worker, under the job's tenant — a `.sealed()` column on the connection row.
311
+ egress: async ({ connectionId }) => (await connections.byId(connectionId))?.exit,
312
+ concurrency: { key: ({ connectionId }) => connectionId, limit: 1, whenBusy: 'fail' },
313
+ auth: {
314
+ store: storageSessionStore(() => disk('sessions')),
315
+ key: ({ connectionId }) => connectionId,
316
+ login,
317
+ },
318
+ prompt: eventPrompt({ timeout: 300_000 }),
319
+ run,
320
+ // How the run ended: the report with its usage, or the code it failed with.
321
+ async onSettled(settled) {
322
+ if (settled.input === undefined) return;
323
+ await runs.record(settled.runId, settled.outcome === 'completed' ? settled.result.usage : settled.usage);
324
+ },
325
+ });
326
+ ```
327
+
328
+ | Field | Rule |
329
+ |---|---|
330
+ | `egress` | the run's exit, resolved in the WORKER: `(input, ctx)`, sync or async, under the job's tenant. The input names a row; the row holds the exit. An exit whose password is also in the input rode the queue payload — `x_jobs` holds it in the clear, and `redactInput` hides a key by name for display only — so it is refused before the browser opens: `X_SCRAPE_EGRESS_IN_PAYLOAD`, terminal. Both legs dial it and the robots read leaves through it. `localBrowser({ options: { args } })` keeps the caller's args and appends `--proxy-server` after them; a proxy switch of the caller's own (`--proxy-server`, `--proxy-bypass-list`, `--proxy-pac-url`, `--proxy-auto-detect`, `--no-proxy-server`) or args that are not a list of strings are `X_SCRAPE_LAUNCH_ARGS_INVALID`, terminal, before anything launches. Credentials in the URL never reach the process arguments — a launched browser answers the proxy through `page.authenticate()` — and are redacted by value everywhere |
331
+ | `cdpUrl: resolver` | called once per `open()` with `{ scrape, runId, egress, signal }`. `release()` runs **exactly once**: in the session's `close()` after the browser is quit — on success, failure and cancellation — and straight away when the attach itself fails. The framework ships no vendor |
332
+ | `cdpUrl: 'ws://…'` + `egress` | `X_SCRAPE_EGRESS_UNSUPPORTED` when the exits differ: a fixed URL is a browser already bound to one |
333
+ | `concurrency: { key, limit, whenBusy }` | the job's field. `whenBusy: 'fail'` settles a second run of one connection `failed` with `X_JOB_KEY_BUSY`, body never run |
334
+ | `auth.store` | the declaration that persists a session. Absent means every run logs in. `auth.login` and `auth.validate` are handed `runId` beside `input` |
335
+ | `prompt: eventPrompt({ timeout })` | below |
336
+
337
+ ### A prompt answered while the browser stays open
338
+
339
+ `eventPrompt({ timeout, pollMs? })` polls the job event bus **in process**. It is not
340
+ `step.waitForEvent`: that suspends the run and closes the browser the site is waiting in.
341
+
342
+ The bus must be the **stored** one (`EventBus.stored`): the answer is published by a web process and read by the worker. Outside development and test, an in-memory bus is refused when the prompt is asked — `X_DRIVER_UNAVAILABLE`, fix `setEventBus(postgresEventBus({ executor }))` — rather than timing out minutes later as `X_SCRAPE_PROMPT_UNANSWERED`.
343
+
344
+ | Fact | Detail |
345
+ |---|---|
346
+ | event name | derived, never chosen: `promptEventName(runId, index)` → `scrape-prompt:<runId>:<n>`, `n` from 1 per attempt. `PromptRequest` carries both, and the run's `input` — what a handler ties the prompt to in the app's own tables |
347
+ | answering | `answerPrompt({ runId, index, answer })` from the app's own action. The name is **not** an authorization — the action's `policy` is |
348
+ | on the bus | the answer is **sealed** for that one event name and expires in 10 minutes (`ttl`). A payload `answerPrompt()` did not write is refused, never typed |
349
+ | stale answers | only an answer published **after** the prompt was asked is consumed — the run id is the same on every attempt |
350
+ | while it waits | the worker's heartbeat keeps the claim; one browser round trip per poll keeps the wedge watchdog and a rented browser alive |
351
+ | it ends on | the answer · `timeout` → `X_SCRAPE_PROMPT_UNANSWERED` (terminal) · the run's `signal` · a browser that died |
352
+ | the bus | must be the **stored** one (`postgresEventBus`) wherever the worker and the answering process differ. Every `x` boot installs it; a hand-written boot calls `setEventBus(postgresEventBus({ executor }))` |
353
+
354
+ Saying a prompt is pending is the app's — wrap the handler, it is a function:
355
+
356
+ ```ts
357
+ const waitForAnswer = eventPrompt({ timeout: 300_000 });
358
+ // on the definition:
359
+ prompt: async (request) => {
360
+ await runEvents.insert({ runId: request.runId, index: request.index, label: request.label });
361
+ return waitForAnswer(request);
362
+ },
363
+ ```
364
+
365
+ ### Usage
366
+
367
+ `ScrapeReport.usage`, and the same counts on the `scrape.ok` / `scrape.failed` log lines.
368
+
369
+ | Field | Counts |
370
+ |---|---|
371
+ | `browserMs` | from before `driver.open()` to the report, on the run's clock |
372
+ | `navigations` | `page.goto()` calls that reached the driver — a refused host is not one |
373
+ | `httpRequests` | requests the HTTP leg put on the wire, one per redirect hop |
374
+ | `bytesIn` | response-body bytes the **HTTP leg** read. The browser leg's traffic is not measured |
375
+ | `promptsAnswered` | prompts a handler answered |
376
+ | `browserCost` | the resolver's `cost`, a `Money` (`{ minor, currency, scale? }`) — a float is `X_VALIDATION_FAILED`. Absent when nothing was rented |
377
+
378
+ No quota, plan or ledger reads these numbers. What a number is worth is the app's.
379
+
380
+ **Reaching the app.** A worker hands a job's return value to one place: the job's `onSettled`
381
+ ([Jobs and workflows](Jobs-And-Workflows)). A scrape's adds what is scrape-specific.
382
+
383
+ | `settled.outcome` | Carries |
384
+ |---|---|
385
+ | `completed` | `result` — the whole `ScrapeReport`: `rows`, `artifacts`, `usage` |
386
+ | `dead-lettered` / `dropped` | `error`, `code`, and `usage`: what the last attempt used. A failed run has no report and was billed all the same |
387
+ | `refused` | `code: 'X_JOB_KEY_BUSY'`; `usage` is `undefined` — the body never ran |
388
+
389
+ After the row is settled, under the job's tenant, **at most once** across a crash; a hook that
390
+ throws is `X_JOB_ON_SETTLED_FAILED` and changes nothing. `run({ progress })` is the job's own
391
+ `progress(done, total, note?)`, passed through.
392
+
393
+ ### A test's clock
394
+
395
+ ```ts
396
+ import { noWaitClock, resetScrapeClock, setScrapeClock } from '@ultimat3/scraping';
397
+
398
+ beforeEach(() => setScrapeClock(noWaitClock));
399
+ afterEach(() => resetScrapeClock());
400
+ ```
401
+
402
+ | Clock | Sleeping | For |
403
+ |---|---|---|
404
+ | `noWaitClock` | one turn of the event loop; `now()` and deadlines are real | a run that waits on something the test does — an answer it publishes, a second run it starts |
405
+ | `testScrapeClock()` | advances virtual time | a test OF a timeout |
406
+
407
+ A run waits on the process's clock unless its definition pins one, so the declaration under test
408
+ carries no `clock:` written for the test's sake.
409
+
410
+ ### What never reaches a log, an error or an artifact
411
+
412
+ A prompt answer, the password of an `egress` URL and every credential-bearing part of a CDP URL are
413
+ added to the run's redaction set — `secrets.conceal(value)`, which a run body may call too.
414
+ `X_SCRAPE_CDP_ATTACH_FAILED` names **scheme and host only**, for a fixed URL as for a resolved one:
415
+ a provider's connect URL is its access token.
416
+
417
+ ## Recovery
418
+
419
+ ```ts
420
+ recover: async ({ page, failure, attempt }) => { /* return true to re-run the body once */ }
421
+ ```
422
+
423
+ A recover hook is a function, and only a function: `recover: 'agent'` was removed in 26.0.0, and anything but a function is refused at `scrape()`. A recovery that asks a model to re-derive the selector is your own hook calling your own `llm()` action with `page.html()`. A hook that answers anything other than a boolean is `X_SCRAPE_RECOVER_REFUSED`.
424
+
425
+ ## Error codes
426
+
427
+ Every owned code is split by whether the same request can succeed unchanged. `x errors explain <CODE> --json` prints the cause, a runnable fix and the docs URL for any of them ([Error codes](Error-Codes)).
428
+
429
+ | Retryable | Why |
430
+ |---|---|
431
+ | `X_SCRAPE_CDP_ATTACH_FAILED`, `X_SCRAPE_BROWSER_UNREACHABLE` | the browser, not the page |
432
+ | `X_SCRAPE_TIMEOUT`, `X_SCRAPE_WEDGED`, `X_SCRAPE_DOWNLOAD_TIMEOUT` | a moment, not a property of the site |
433
+ | `X_SCRAPE_HTTP_FAILED` | 408, 409, 425, 429, any 5xx, a deploy — transient far more often than not. **Which 4xx are permanent is `@ultimat3/core`'s `isRetryableStatus`, not this package's**, `As of 2026-08-23`: a private copy here called 408 and 425 terminal while the rest of the framework called them retryable, and a terminal classification dead-letters the run on the attempt that failed rather than spending its declared `attempts` |
434
+ | `X_SCRAPE_BLOCKED` | retryable **and it burns the session first**: retrying a block on the same flagged cookies re-trips it every time |
435
+
436
+ Everything else is terminal, including `X_SCRAPE_SELECTOR_MISSING` (the markup changed), `X_SCRAPE_OUTPUT_INVALID` (the rows are the wrong shape), `X_SCRAPE_YIELD_COLLAPSED`, `X_SCRAPE_SECRET_EXPOSED`, `X_SCRAPE_KEY_INVALID` (the chord is the caller's own literal), `X_SCRAPE_PROMPT_UNANSWERED`, `X_SCRAPE_BROWSER_MISSING` (there is no browser binary to launch), `X_SCRAPE_EGRESS_UNSUPPORTED` (the exit is the run's input and the driver is the deploy's constant) and `X_SCRAPE_LAUNCH_ARGS_INVALID` (the args are the deploy's literal).
437
+
438
+ ## Testing offline
439
+
440
+ `fakeBrowser` and `fixtureBrowser` never touch the network. An unrecorded page or request **throws** rather than falling through, so a green suite cannot be secretly live.
441
+
442
+ **One request escapes that, and it is the robots read.** Under the default `robots: 'obey'`, an offline driver still fires a real `fetch` at `https://<host>/robots.txt` before the first navigation — the gate is built in `scrape-run.ts` and knows nothing about which driver `open()` will return. Measured again `As of 2026-08-20`: one egress per origin per run. Under `bun test` the sealed network refuses it, and a refused read is **unreachable**, so the run fails `X_SCRAPE_ROBOTS_DISALLOWED` on its first navigation — the leak is now loud rather than green. Declare `robots: { ignore: 'offline fixture' }` on an offline scrape until it is closed → [Known gaps](Known-Gaps).
443
+
444
+ ## What it does not do
445
+
446
+ | Not shipped | Detail |
447
+ |---|---|
448
+ | Browser-leg bytes in `usage.bytesIn` | the CDP port subscribes to requests, not to their sizes. `bytesIn` is the HTTP leg alone |
449
+ | Usage on a FAILED run's result | a failed run has no report. Its counts are on the `scrape.failed` log line |
450
+ | A vendor | no browser provider, proxy inventory, captcha or OTP relay. `CdpResolver`, `egress` and `PromptHandler` are the seams |
451
+ | A capture deadline | deleted in 4.0.0; the driver's own default is the bound |
452
+ | CAPTCHA solving, fingerprint evasion, proxy rotation | none of it. `X_SCRAPE_BLOCKED` burns the session and re-raises |
453
+ | A scaffold | `g` has no `scrape` positional; write the declaration by hand |
454
+
455
+ ## Pages as files: PDF and cards
456
+
457
+ `page.pdf(layout)` is the invoice path, through the browser the app already brings. Who may reach
458
+ the page is the app's policy: print from a route a signed token opens, never from a page a secret
459
+ was typed into (`X_SCRAPE_SECRET_EXPOSED` keeps its rule). The offline drivers answer different
460
+ deterministic bytes per layout, so a dropped margin fails a test. `As of 2026-11`.
461
+
462
+ ```ts
463
+ import { t } from '@ultimat3/jobs';
464
+ import type { CdpLauncherLike } from '@ultimat3/scraping';
465
+ import { localBrowser, scrape } from '@ultimat3/scraping';
466
+ import { disk } from '@ultimat3/storage';
467
+
468
+ declare const puppeteer: CdpLauncherLike; // the app's own `puppeteer-core`, passed in
469
+
470
+ export const printInvoice = scrape({
471
+ name: 'invoices.print',
472
+ input: t.object({ orgId: t.uuid, invoiceId: t.uuid }),
473
+ extract: t.object({ key: t.string }),
474
+ idempotencyKey: ({ invoiceId }) => `invoices.print:${invoiceId}`,
475
+ tenant: ({ orgId }) => orgId,
476
+ allowHosts: ['app.example'],
477
+ driver: localBrowser({ launcher: puppeteer }),
478
+ async run({ input, page }) {
479
+ // The app's own print route, behind a token it signs and verifies — its policy, not this one.
480
+ await page.goto(`https://app.example/invoices/${input.invoiceId}/print`);
481
+ const pdf = await page.pdf({ format: 'a4', marginMm: { top: 15, bottom: 15 } });
482
+ const key = `org/${input.orgId}/invoices/${input.invoiceId}.pdf`;
483
+ await disk().put(key, pdf, { contentType: 'application/pdf' });
484
+ return [{ key }];
485
+ },
486
+ });
487
+ ```
488
+
489
+ A social card for a row published at runtime is the same shape: `page.screenshot()` of the row's
490
+ 1200 x 630 card page, `disk().put()`, then `og: { image }` — [SEO](SEO#a-card-the-build-photographs-ogcapture)
491
+ covers the cards a static build takes itself. `examples/dummy`'s `runs/jobs/print-statement.ts` is
492
+ the worked example over a recording.
493
+
package/wiki/Shapes.md ADDED
@@ -0,0 +1,63 @@
1
+ # Shapes
2
+
3
+ `x new <name> --shape <shape>` starts an app from a shape instead of the default dashboard and
4
+ example slice. One flag, still zero questions. A shape is an **overlay** over the same scaffold
5
+ `x new` writes (`packages/cli/src/templates/shapes/`): it leaves files out, edits a few, adds its
6
+ own, and runs the generator that owns a capability it needs. Every guard, every gate step and the 95% coverage floor are the default scaffold's. No shape
7
+ waives one.
8
+
9
+ ```sh
10
+ bunx create-ultimate myblog --shape blog
11
+ cd myblog && bun run setup && bun run dev
12
+ ```
13
+
14
+ | Shape | Writes | Leaves out |
15
+ |---|---|---|
16
+ | none (default) | the dashboard, the `post` example slice (two actions, a live query, a job, a form island), `/admin` | — |
17
+ | `api` | the `post` slice as an API: `createPost`, `archivePost`, the live `postList`, the `reindexPost` job; an org API key (`apps/web/app/keys/`) on a `/v1` bearer mount cut by `API_KEY_SCOPES`; the complete `openapi.json` (`defineApi({ openapi })`); MCP mounted; `/admin` | every page but the error documents and the offline fallback, every island, the shell, the dashboard |
18
+ | `dashboard` | the default app — dashboard, the `post` table at `/posts` (live list, a job), `/admin` — plus a detail page per row (`apps/web/app/posts/[id]/`, `render: 'ssr'`, read through a `single` query, `postById`) and sign-in, written by `x g sign-in --password` itself once the files land | nothing of the default; `site/` stays a home page, the offline fallback and the error documents |
19
+ | `blog` | a `post` entity with no tenant (`apps/web/app/post/`), two public reads (`publishedPosts`, `publishedPost`), an `isr` index at `/` and one page per post at `/posts/<slug>`, both `budget: { js: '0kb' }`, a seed with one published post and one draft. Posts are written at `/admin` | `app/` pages, every island, the shell, the dashboard |
20
+
21
+ What no shape writes, by rule ([axiom 8](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/19-mechanism-not-convention.md)):
22
+ no plan, tier or quota; no payment, mail or model provider and no provider SDK; no model id in
23
+ code. `packages/cli/src/templates/shapes/shapes.test.ts` refuses a provider name or a model id in
24
+ any shape's output, and `X_NOT_IMPLEMENTED` anywhere in it.
25
+
26
+ ## What happens next
27
+
28
+ Each shape's `README.md` names the shape in one line above the three commands every app starts
29
+ with (`bun run setup`, `bun run dev`, `bun run verify`). Its `CLAUDE.md` (and `AGENTS.md`, the
30
+ same file) adds exactly one line naming the shape. From there, the generators are the default
31
+ scaffold's: `bunx x g --help`.
32
+
33
+ | Shape | Your first feature |
34
+ |---|---|
35
+ | `api` | `bunx x g resource invoice`: a slice with actions, a query and a job. Add its operations to `API_KEY_SCOPES` to reach them on `/v1` |
36
+ | `blog` | write a post at `/admin`. A new page: `bunx x g route about --surface site` |
37
+ | `dashboard` | `bunx x g resource invoice --admin`: a table, its form and its `/admin` screens. A detail page for it: copy `apps/web/app/posts/[id]/` |
38
+
39
+ ## Refusals
40
+
41
+ | Code | When | Fix |
42
+ |---|---|---|
43
+ | `X_NEW_SHAPE_UNKNOWN` | `--shape` names no shape | `bunx @ultimat3/cli help new` lists them |
44
+ | `X_NEW_SHAPE_CONFLICT` | `--shape` with `--no-example` | drop `--no-example`: a shape is its own example |
45
+ | `X_NEW_SHAPE_GENERATE_FAILED` | a generator the shape runs in the new app (`dashboard`: `x g sign-in --password`) refused or failed | the `cd … && bunx x g … --json` it prints reproduces it; the framework's bug, so `bun test packages/cli/src/templates/shapes` |
46
+
47
+ The first two refuse before a file is written, `--dry-run` included. A shape whose capability a
48
+ generator owns runs that generator after its files land (`ShapeOverlay.generate`), never a copy of
49
+ its output: `--dry-run` lists those runs under `data.generate`.
50
+
51
+ ## Where they are proved
52
+
53
+ | Proof | Where |
54
+ |---|---|
55
+ | file set, leave-outs, no provider, every path `CLAUDE.md` names is written | `bun test packages/cli/src/templates/shapes` |
56
+ | each shape typechecks, lints and meets the error contract as written | the `x new --shape <shape>` variants in `packages/cli/src/scaffold-fixture.ts`, read by `emitted-contract`, `emitted-routes`, `emitted-suites` and `scaffold-typecheck.contract` |
57
+ | `bun run setup && bun run verify` green on the first pass, then again over every generator | `scaffold-smoke` in `.github/workflows/ci.yml`, one row per shape — `scripts/ci-workflow-shape.test.ts` refuses a shape with no row |
58
+
59
+ `As of 2026-11-06`. Five shapes are planned (`saas` and `agent` beside these three); a shape is
60
+ listed here, and accepted by `--shape`, only once its row gates green. `saas` waits on a member
61
+ invite and a billing seam no slice has landed; `agent` on a model and provider an app names by
62
+ environment alone, which no shipped seam takes without a provider SDK or a model row the scaffold
63
+ would have to invent.