@ultimat3/cli 23.0.0 → 25.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 (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
package/src/cmd-test.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // there, because it is a fact about a git diff and not about a path: what the diff touches is
6
6
  // `affected.ts`, and this file only maps that answer onto the paths discovery yields.
7
7
 
8
+ import { TEST_TYPES } from '@ultimat3/testing';
8
9
  import type { AffectedScope } from './affected';
9
10
  import { affectedScope, affectedScopeJson, DEFAULT_BASE, inScope } from './affected';
10
11
  import { testSpec } from './cmd-test-spec';
@@ -37,25 +38,22 @@ import {
37
38
  } from './test-workers';
38
39
  import { readVerifyFloor } from './verify-floor';
39
40
  import type { TestType } from './verify-tests';
40
- import { TEST_TYPES } from './verify-tests';
41
41
 
42
42
  /**
43
- * `--workers` and `--shard`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while
44
- * `cmd-verify.ts`'s own comment claimed `x test --workers` refused the same values `x verify`
45
- * does — so the two commands disagreed about the same flag. One reader now answers for both.
43
+ * `--workers`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while `cmd-verify.ts`'s
44
+ * own comment claimed `x test --workers` refused the same values `x verify` does — so the two
45
+ * commands disagreed about the same flag. One reader now answers for both.
46
46
  */
47
- const readIndex = (args: ParsedArgs, name: string, min: number): number | undefined =>
47
+ const readWorkers = (args: ParsedArgs): number | undefined =>
48
48
  readIntFlag(args, {
49
- name,
49
+ name: 'workers',
50
50
  command: 'test',
51
- min,
51
+ min: 1,
52
52
  // The ceiling the summary already claimed and the reader never enforced: `--workers 5000` was
53
53
  // accepted and the run clamps only to the file count, which is one Bun worker per test FILE,
54
- // each with the framework module graph and a cloned database. `--worker` is an index into an
55
- // N-way split, so the same bound holds it (the exact upper index is `workers - 1`, refused a
56
- // line below by the check that knows the real width).
54
+ // each with the framework module graph and a cloned database.
57
55
  max: WORKER_CEILING,
58
- example: `x test --${name} ${Math.max(min, 1)}`,
56
+ example: 'x test --workers 1',
59
57
  });
60
58
 
61
59
  /**
@@ -168,29 +166,17 @@ export const testCommand: CliCommand = {
168
166
  });
169
167
  }
170
168
  const files = sample === undefined ? selected : sampleFiles(selected, sample);
171
- const explicit = readIndex(ctx.args, 'workers', 1);
169
+ const explicit = readWorkers(ctx.args);
172
170
  const plan =
173
171
  explicit === undefined ? workerPlan(availableCpus(), totalMemory(), ctx.env) : undefined;
174
172
  const requested = explicit ?? (plan as WorkerPlan).workers;
175
173
  const floor = await readVerifyFloor(ctx.cwd);
176
174
  const isolate = flagBool(ctx.args, 'isolate') || floor?.isolate === true;
177
- // The width a `--worker` index is judged against, and nothing else: which files really run one
178
- // at a time is `test-passes.ts`, off the FILES rather than off the positional. This line read
179
- // the positional alone until 2026-09 and the comment here claimed it made a serial type serial
180
- // — true for `x test live`, false for the bare `x test --workers 8` that selects every type,
181
- // which ran the same `live` and `e2e` files eight at a time. It stays because a `--worker` run
182
- // is one process per CI JOB: sharding a serial type across N of them is the cluster-wide race
183
- // in a second disguise, and refusing the index is where that is caught.
175
+ // A serial type is one worker whatever `--workers` says. `test-passes.ts` makes the same call
176
+ // off the FILES, which is what decides which files really run one at a time; this keeps the
177
+ // width the run reports and reproduces with the one it ran at.
184
178
  const ceiling = type !== undefined && SERIAL_TYPES.includes(type) ? 1 : files.length;
185
179
  const workers = Math.max(1, Math.min(requested, ceiling));
186
- const only = readIndex(ctx.args, 'worker', 0);
187
- if (only !== undefined && only >= workers) {
188
- throw new BadFlagError({
189
- flag: 'worker',
190
- command: 'test',
191
- reason: `shard ${only} does not exist in a ${workers}-worker split (0..${workers - 1})`,
192
- });
193
- }
194
180
  const result = await runShards({
195
181
  root: ctx.cwd,
196
182
  runner: ctx.runner,
@@ -201,11 +187,9 @@ export const testCommand: CliCommand = {
201
187
  // A default-width run leases its workers from the machine pool, so a second `x test` or
202
188
  // gate on the same box runs narrower instead of doubling its memory.
203
189
  ...(plan === undefined ? {} : withLease(machineLease(plan.workers, ctx.env))),
204
- ...(only === undefined ? {} : { only }),
205
190
  ...(filter === undefined ? {} : { filter }),
206
191
  ...(type === undefined ? {} : { type }),
207
- // `kept` is the corpus the split saw; a `--worker` rerun must name it, not its own shard.
208
- // `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
192
+ // `kept` is the corpus the run saw. `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
209
193
  // set, and reporting the whole tree as its total would name a corpus no run ever had.
210
194
  ...(sample === undefined ? {} : { sample: { kept: files.length, total: selected.length } }),
211
195
  // The fourth input to the split. Without it a failing shard's `fix:` re-splits the whole
package/src/cmd-verify.ts CHANGED
@@ -20,14 +20,13 @@ import type { ParsedArgs } from './parse';
20
20
  import { flagBool, flagString } from './parse';
21
21
  import { WORKER_CEILING, WORKER_FLOOR } from './test-workers';
22
22
  import { VERIFY_STEPS } from './verify-checks';
23
- import { VerifyMergeInputError, VerifyShardInvalidError } from './verify-errors';
24
23
  import { readVerifyFloor } from './verify-floor';
25
24
  import type { VerifyPart } from './verify-merge';
26
- import { coverageRiders, mergeParts, parsePart } from './verify-merge';
25
+ import { coverageRiders, mergeParts, parsePart, VerifyMergeInputError } from './verify-merge';
27
26
  import { stepStream } from './verify-progress';
28
27
  import { runVerify } from './verify-run';
29
28
  import type { ShardSpec, Timings } from './verify-shard';
30
- import { assertShardable, parseShard, readTimings } from './verify-shard';
29
+ import { assertShardable, parseShard, readTimings, VerifyShardInvalidError } from './verify-shard';
31
30
  import type { VerifyStepName } from './verify-step';
32
31
  import { VERIFY_STEP_NAMES } from './verify-step';
33
32
  import { writeErrorLine } from './write-line';
@@ -150,8 +149,9 @@ export const readOnlySteps = (args: ParsedArgs): readonly VerifyStepName[] | und
150
149
  if (unknown.length === 0) {
151
150
  return VERIFY_STEP_NAMES.filter((name) => items.includes(name));
152
151
  }
152
+ // An empty item corrects to nothing — never to whichever step is nearest to ''.
153
153
  const corrected = items.map((item) =>
154
- isStepName(item) ? item : nearestName(item, VERIFY_STEP_NAMES),
154
+ isStepName(item) ? item : item === '' ? undefined : nearestName(item, VERIFY_STEP_NAMES),
155
155
  );
156
156
  const fix = corrected.every((item) => item !== undefined)
157
157
  ? `x verify --only ${renderFixShellArg([...new Set(corrected)].join(','), '<step,step>')} --json`
@@ -4,7 +4,7 @@
4
4
 
5
5
  /** One `SF:` record, as written: its own totals and every `DA:` line. */
6
6
  export interface LcovRecord {
7
- /** The `SF:` path, verbatim — Bun writes it relative to the directory the run started in. */
7
+ /** The `SF:` path with `/` separators — Bun writes it relative to the directory the run started in. */
8
8
  readonly file: string;
9
9
  /** `LF` / `LH` / `FNF` / `FNH`. Bun's lcov carries no `FN:` lines, so functions are counts only. */
10
10
  readonly linesFound: number;
@@ -25,7 +25,9 @@ export function parseLcov(text: string): readonly LcovRecord[] {
25
25
  let lines = new Map<number, number>();
26
26
  for (const line of text.split('\n')) {
27
27
  if (line.startsWith('SF:')) {
28
- file = line.slice(3);
28
+ // `/` on every OS: Bun on Windows writes `apps\web\…`, and the app's source walk keys its
29
+ // files with `/`, so no record matched and the floor read 0% of a fully tested app.
30
+ file = line.slice(3).replaceAll('\\', '/');
29
31
  lf = 0;
30
32
  lh = 0;
31
33
  fnf = 0;
@@ -10,7 +10,7 @@
10
10
  // which names were asked for, whether `--write` was passed, and what `x_migrations` says.
11
11
 
12
12
  import type { Environment } from '@ultimat3/core';
13
- import { createContext } from '@ultimat3/core';
13
+ import { ctxOf } from '@ultimat3/core';
14
14
  import { db, isLedgerMissing, readLedger } from '@ultimat3/db';
15
15
  import type {
16
16
  BackfillDeclaration,
@@ -218,7 +218,7 @@ async function remainingFor(declaration: BackfillDeclaration): Promise<number |
218
218
  const count = handle === undefined ? undefined : backfillOrigin(handle)?.count;
219
219
  if (count === undefined) return null;
220
220
  try {
221
- return await count({ ctx: createContext({ role: 'migrate' }) });
221
+ return await count({ ctx: ctxOf({ role: 'migrate' }) });
222
222
  } catch {
223
223
  return null;
224
224
  }
@@ -6,13 +6,7 @@
6
6
  // why: Bun ships no path joiner.
7
7
  import { join } from 'node:path';
8
8
  import type { DbClient, LinkedExtensions, Migration } from '@ultimat3/db';
9
- import {
10
- createPgliteClient,
11
- createPostgresClient,
12
- DbError,
13
- linkPgliteExtensions,
14
- raw,
15
- } from '@ultimat3/db';
9
+ import { DbError, linkPgliteExtensions, pgliteClient, postgresClient, raw } from '@ultimat3/db';
16
10
  import { migrationExtensions } from './migration-extensions';
17
11
 
18
12
  type Env = Readonly<Record<string, string | undefined>>;
@@ -73,14 +67,14 @@ async function withPostgresScratch<T>(
73
67
  work: (client: DbClient) => Promise<T>,
74
68
  ): Promise<T> {
75
69
  const database = scratchName();
76
- const admin = createPostgresClient({ url: adminUrl });
70
+ const admin = postgresClient({ url: adminUrl });
77
71
  try {
78
72
  // `template0`: the server's `template1` may carry objects an operator put there, and they
79
73
  // would be dumped as the app's.
80
74
  await admin.execute(raw(`create database ${database} template template0`));
81
75
  const target = new URL(adminUrl);
82
76
  target.pathname = `/${database}`;
83
- const client = createPostgresClient({ url: target.toString() });
77
+ const client = postgresClient({ url: target.toString() });
84
78
  try {
85
79
  return await work(client);
86
80
  } finally {
@@ -103,7 +97,7 @@ export async function withScratchDatabase<T>(
103
97
  work: (client: DbClient) => Promise<T>,
104
98
  ): Promise<T> {
105
99
  if (engine.kind === 'postgres') return withPostgresScratch(engine.adminUrl, work);
106
- const client = createPgliteClient({
100
+ const client = pgliteClient({
107
101
  extensions: engine.extensions,
108
102
  ...(stateDir === undefined ? {} : { snapshotDir: join(stateDir, 'cache') }),
109
103
  });
@@ -1,8 +1,9 @@
1
- // Single responsibility: which tables `x db gen` must grant `REPLICA IDENTITY FULL`, read off the
2
- // live queries' DECLARED `subscribes:` — and the refusal for a declared name no entity's table
3
- // matches. This tier is the only one holding both registries, so it is the only one that can ask.
1
+ // Single responsibility: which tables `x db gen` must grant `REPLICA IDENTITY FULL` — the `records`
2
+ // tables of every channel with params, and since #518 never a live query's `subscribes:` — and the
3
+ // refusal for a declared `subscribes:` name no entity's table matches. Only this tier holds both.
4
4
 
5
5
  import { UltimateError } from '@ultimat3/core';
6
+ import { paramsChannelTables } from '@ultimat3/realtime/server';
6
7
 
7
8
  /**
8
9
  * The two fields this reads, and nothing else. Structurally satisfied by `QueryDescriptor` (whose
@@ -28,11 +29,10 @@ function offer(tables: ReadonlySet<string>): string {
28
29
  /**
29
30
  * A live read declares a relation this app has no entity for.
30
31
  *
31
- * Refused rather than dropped, and that is the whole point of the check: `@ultimat3/db` keeps only
32
- * the declared names an entity's table matches (`replica-identity.ts`), so an EXTRA name is
33
- * discarded in silence — and `@ultimat3/query` has no table catalog, so its own `subscribes:`
34
- * assertions cannot see one either. A typo therefore granted `REPLICA IDENTITY FULL` to nothing
35
- * while its author read the declaration as granted, which is the failure #357 exists to end.
32
+ * Refused rather than dropped: `@ultimat3/query` has no table catalog, so its own `subscribes:`
33
+ * assertions cannot see a name no table has — and a typo there is a declaration its author reads
34
+ * as true. Until #518 the list also chose which tables got `REPLICA IDENTITY FULL`, so the typo
35
+ * granted it to nothing (#357); the grant is gone, the declaration's honesty still matters.
36
36
  *
37
37
  * The name goes in the `cause` and never into a command: it is a string from the app's own source,
38
38
  * and the remedy is an edit to a field rather than anything to paste at a shell.
@@ -46,8 +46,8 @@ export class QuerySubscribesUnknownError extends UltimateError {
46
46
  'declares a table with that name',
47
47
  fix:
48
48
  `edit subscribes: on the query "${input.query}" to name a table this app declares ` +
49
- `(${offer(input.tables)}), or drop the name — x db gen grants REPLICA IDENTITY FULL to ` +
50
- 'exactly the tables it lists, and would have granted it to nothing',
49
+ `(${offer(input.tables)}), or drop the name, then x db gen "fix subscribes" --json — a ` +
50
+ 'name no table has can never match what the read selects from (X_QUERY_SUBSCRIBES_DRIFT)',
51
51
  meta: { query: input.query, table: input.table },
52
52
  });
53
53
  }
@@ -56,26 +56,37 @@ export class QuerySubscribesUnknownError extends UltimateError {
56
56
  /**
57
57
  * The tables to hand `GenerateOptions.replicaIdentityFull`, deduped and sorted.
58
58
  *
59
+ * A live query's `subscribes:` is VALIDATED here and never granted (#518, owner decision O-518):
60
+ * every entity has a primary key — `entity()` refuses one without — and a live query over a keyed
61
+ * table is correct on DEFAULT identity, because the shared window holds the whole row the
62
+ * in/out/delete decision needs (`realtime/src/pg-identity-window.live.test.ts`, on real WAL). FULL
63
+ * there only made every UPDATE and DELETE log the whole old row. A table already on FULL keeps it:
64
+ * `replica-identity.ts` never reverts, and the drift step reads a recorded FULL as agreement.
65
+ *
59
66
  * Sorted so the same app generates the same bytes whatever order its modules registered in — the
60
67
  * rule `@ultimat3/db`'s own `pending()` states one layer down, kept here too because a caller that
61
68
  * ordered by registration would put a diff in a file for nothing.
62
69
  *
63
- * Every name is checked against `tables` BEFORE any of them is returned: a run that emitted the
64
- * good half and refused afterwards would leave an author with a migration that is right for one
65
- * table and silently absent for the other.
70
+ * Every `subscribes:` name is checked against `tables` BEFORE anything is returned, so a refused
71
+ * declaration never leaves a migration half-written behind it.
72
+ *
73
+ * `channelTables` defaults to what the declared channels need (`paramsChannelTables()`): a channel
74
+ * with params routes a DELETE to the topic the OLD row names, and under the default identity that
75
+ * image is the key alone — no topic, no `remove`, and members keep the deleted record. They are
76
+ * derived from entity projections rather than typed by an author, so one outside `tables` is
77
+ * dropped, not refused: there is no declaration to send anyone back to.
66
78
  */
67
79
  export function replicaIdentityTables(
68
80
  queries: readonly SubscribingQuery[],
69
81
  tables: ReadonlySet<string>,
82
+ channelTables: readonly string[] = paramsChannelTables(),
70
83
  ): readonly string[] {
71
- const wanted = new Set<string>();
72
84
  for (const query of queries) {
73
85
  for (const table of query.subscribes ?? []) {
74
86
  if (!tables.has(table)) {
75
87
  throw new QuerySubscribesUnknownError({ query: query.name, table, tables });
76
88
  }
77
- wanted.add(table);
78
89
  }
79
90
  }
80
- return [...wanted].sort();
91
+ return [...new Set(channelTables.filter((table) => tables.has(table)))].sort();
81
92
  }
@@ -0,0 +1,290 @@
1
+ // `x dev`'s boot: every role in one Bun process, embedded Postgres/events/storage started for real,
2
+ // the app's own modules loaded into the framework's registries, the route table they hold served
3
+ // over HTTP with `/_x` beside it, and the watcher that rebuilds on save (`dev-rebuild.ts`). The
4
+ // command that prints it and holds the process is `cmd-dev.ts`.
5
+
6
+ import { devShellStyle } from '@ultimat3/admin/dev';
7
+ import type { Role } from '@ultimat3/core';
8
+ import { configureTelemetry, noopExporter } from '@ultimat3/core';
9
+ import { setStatementObserver } from '@ultimat3/db';
10
+ import type { OverlayNotice, RequestContext } from '@ultimat3/http';
11
+ import { asCtx } from '@ultimat3/http';
12
+ import { loadSignInPath } from './app-auth';
13
+ import { appManifest } from './app-manifest';
14
+ import type { StalePin } from './app-reload-graph';
15
+ import { enableReloadTracking } from './app-reload-graph';
16
+ import { loadAppRuntime } from './app-runtime';
17
+ import type { DevDashboardInput, DevStatus } from './dev-dashboard';
18
+ import { devPanels } from './dev-dashboard';
19
+ import { declareDevEnvironment, needsDevEnvironmentDeclaration } from './dev-environment';
20
+ import { createStatementLedger } from './dev-n-plus-one';
21
+ import type { DevState } from './dev-rebuild';
22
+ import { devRebuilder } from './dev-rebuild';
23
+ import { devRouteTable } from './dev-route-table';
24
+ import { createTraceRecorder } from './dev-traces';
25
+ import { watchTree } from './dev-watch-tree';
26
+ import { buildIslands } from './island-bundle';
27
+ import { FRAME_STYLE } from './island-harness';
28
+ import type { Finding } from './output';
29
+ import type { RunningRoles } from './role-start';
30
+ import { DEV_ROLES, startRoles } from './role-start';
31
+ import type { DevServices } from './runtime-bindings';
32
+ import { resolveServices, withRealtimeEvents } from './runtime-bindings';
33
+ import { attachedIsr } from './runtime-isr';
34
+ import type { RuntimeOverrides } from './runtime-overrides';
35
+ import { replicaOverrides } from './runtime-replica';
36
+ import type { RunningServices } from './runtime-services';
37
+ import { releaseOrThrow, startServices } from './runtime-services';
38
+ import { metricsPortFor, releaseBoot } from './serve';
39
+ import { loopFacts, loopFinding, loopNotice } from './statement-loop';
40
+
41
+ export interface DevServer {
42
+ readonly url: string;
43
+ readonly services: DevServices;
44
+ readonly roles: readonly Role[];
45
+ /**
46
+ * `BUILD_ID` when stamped — the id every response carries. Otherwise the manifest as it stands
47
+ * now, so a reload that registers a new route moves it.
48
+ */
49
+ readonly buildId: string;
50
+ /**
51
+ * Modules that would not import, primitives that would not register, reloads that would not
52
+ * build — and the statement loops this process has counted so far, which is what puts an N+1 in
53
+ * `x dev`'s own output and in `--json` without a channel of its own.
54
+ */
55
+ readonly findings: readonly Finding[];
56
+ readonly running: RunningRoles;
57
+ readonly runtime: RunningServices;
58
+ /** Panel keys `/_x` mounted, in tab order. Reported so `--json` names what is reachable. */
59
+ readonly panels: readonly string[];
60
+ /** `POST <path>` of the app's default MCP endpoint, or `null` when nothing was mounted. */
61
+ readonly mcp: string | null;
62
+ /** `POST <path>` of every mounted MCP endpoint, default first — `[]` when none was. */
63
+ readonly mcpPaths: readonly string[];
64
+ stop(): Promise<void>;
65
+ }
66
+
67
+ export interface StartDevOptions {
68
+ readonly root: string;
69
+ readonly port: number;
70
+ readonly env: Readonly<Record<string, string | undefined>>;
71
+ readonly roles?: readonly Role[];
72
+ readonly onReload?: (file: string, durationMs: number) => void;
73
+ /**
74
+ * A save reached a module that defines a primitive, so this process cannot serve it
75
+ * (`takeStalePins`). Given, it is called INSTEAD of `onReload` — `x dev`'s child drains and exits
76
+ * for its supervisor to boot a fresh one. Absent, the save is `X_DEV_RESTART_REQUIRED` on `/_x`.
77
+ */
78
+ readonly onRestart?: (pins: readonly StalePin[]) => void;
79
+ }
80
+
81
+ /**
82
+ * The environment `devDashboard` refuses to mount in. Spread conditionally rather than passed as
83
+ * `undefined`: `exactOptionalPropertyTypes` makes "absent" and "explicitly undefined" different
84
+ * answers, and only the absent one lets the dashboard read the process environment itself.
85
+ */
86
+ const envOf = (env: StartDevOptions['env']): { env?: string } => {
87
+ const value = env['NODE_ENV'] ?? env['X_ENV'];
88
+ return value === undefined ? {} : { env: value };
89
+ };
90
+
91
+ /**
92
+ * Boot order is the production order: services first, then the app's modules (importing them IS
93
+ * the registration), then the roles that serve what those modules registered. A module that
94
+ * fails to import becomes a finding rather than a dead process — the point of the dev loop is to
95
+ * still be reachable while something is broken.
96
+ */
97
+ export async function startDev(options: StartDevOptions): Promise<DevServer> {
98
+ // EVERY boot: a scratch server (`x shot`, `ui.shot`) boots here too, and without this a
99
+ // fail-closed dev actor installs nothing — the picture is of a 401. Idempotent.
100
+ declareDevEnvironment(options.env);
101
+ // Before the first scan: the watcher's rescans evict a saved module AND everything importing it,
102
+ // and that needs the import graph recorded as each module is first loaded (`app-reload-graph.ts`).
103
+ enableReloadTracking(true);
104
+ // The declaration lands on `process.env`; the env this boot passes on must carry it too, or every
105
+ // service resolved from `options.env` still reads no `ULTIMATE_ENV`.
106
+ const env = needsDevEnvironmentDeclaration(options.env)
107
+ ? { ...options.env, ULTIMATE_ENV: 'development' }
108
+ : options.env;
109
+ const resolved = resolveServices(options.root, env);
110
+ // The app's `apps/<app>/runtime.ts`, read ONCE and handed to `startServices` exactly as
111
+ // `serve.ts`'s `withAppRuntime` does — so the disks (`storage`), the queue, the mail driver and
112
+ // every other override a deployment declares are the ones development runs too. Until 22.4 this
113
+ // boot passed nothing here and built its own embedded disk, so an app's `/_storage` routes and its
114
+ // actions wrote to two different places in `x dev` and to one in production.
115
+ const appRuntime = await loadAppRuntime(options.root);
116
+ const runtime: RunningServices = await startServices(resolved, env, appRuntime);
117
+ // The events binding the boot line reports is the bus the runtime chose, read off it.
118
+ const services = withRealtimeEvents(resolved, env, runtime.realtime);
119
+ // `serve.ts`'s `releaseBoot` shape: everything acquired from here on is released, newest first,
120
+ // if the boot throws — a failed `x dev` left PGlite holding `.x/pgdata` for the retry to meet.
121
+ const acquired: (() => void | Promise<void>)[] = [() => runtime.stop()];
122
+ try {
123
+ return await bootDev(options, services, runtime, acquired, appRuntime);
124
+ } catch (error) {
125
+ await releaseBoot(acquired);
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ async function bootDev(
131
+ options: StartDevOptions,
132
+ services: ReturnType<typeof resolveServices>,
133
+ runtime: RunningServices,
134
+ acquired: (() => void | Promise<void>)[],
135
+ appRuntime: RuntimeOverrides | undefined,
136
+ ): Promise<DevServer> {
137
+ // Installed before the app loads, so a span opened during registration is already recorded.
138
+ // Tracing is always on in the framework and free until an exporter is configured; `x dev` is
139
+ // what configures one, which is the whole reason `/_x/timeline` has anything to draw.
140
+ const traces = createTraceRecorder();
141
+ configureTelemetry({ exporter: traces.exporter });
142
+ acquired.push(() => {
143
+ configureTelemetry({ exporter: noopExporter });
144
+ traces.reset();
145
+ });
146
+ // Installed at the same moment and for the same reason: an observer is the single switch that
147
+ // turns statement instrumentation on at all (`@ultimat3/db`'s `observe.ts`), so the timeline's
148
+ // SQL rows and the repeat counts arrive together rather than through two toggles. `serve.ts`
149
+ // installs neither — a production process pays the one `undefined` branch the seam costs
150
+ // uninstalled, and nothing more (axiom 6).
151
+ const statements = createStatementLedger();
152
+ setStatementObserver(statements.observer);
153
+ acquired.push(() => {
154
+ setStatementObserver(undefined);
155
+ statements.reset();
156
+ });
157
+ // ONE load at boot, the same call the rebuild below makes: the manifest and the findings are
158
+ // two projections of one scan. Until 2026-09-07 this was `loadApp` for the findings and then
159
+ // `appManifest` — which loads again — for the manifest, so a save landing between the two put
160
+ // `/_x`'s findings and its manifest on different registration states.
161
+ const app = await appManifest(options.root);
162
+ const state: DevState = {
163
+ manifest: app.manifest,
164
+ reloads: 0,
165
+ reloadFinding: undefined,
166
+ appFindings: app.findings,
167
+ islands: await buildIslands(options.root),
168
+ };
169
+ // The manifest's build id is a content hash of every fact below it, so a dev document's
170
+ // `x-ultimate-build` header names the exact shape the client was served against. Pinned at
171
+ // boot on purpose: the header is handed to the HTTP config and the render modes once, and a
172
+ // reload cannot re-pin it — `state.manifest.buildId` is what `/_x` and `--json` report, so a
173
+ // divergence between the two is visible rather than silent, and a restart closes it.
174
+ // `BUILD_ID` wins when set — `serve.ts`'s rule, so an e2e `deploy.newBuild()` can restart `x dev`
175
+ // as a new build with the same sources.
176
+ // Stamped, it is ALSO what `server.buildId` answers below: the process serves no other build.
177
+ const rawStamp = options.env['BUILD_ID'];
178
+ const stamped = rawStamp !== undefined && rawStamp !== '' ? rawStamp : undefined;
179
+ const buildId = stamped ?? state.manifest.buildId;
180
+
181
+ let server: DevServer;
182
+ // Read at request time, never captured at boot: `/_x/services` must report the reload counter
183
+ // and the findings as they are now, not as they were when the route table was built.
184
+ const dashboard: DevDashboardInput = {
185
+ root: options.root,
186
+ runtime,
187
+ status: (): DevStatus => ({
188
+ url: server.url,
189
+ services: server.services,
190
+ roles: server.roles,
191
+ findings: server.findings,
192
+ reloads: state.reloads,
193
+ }),
194
+ traces,
195
+ statements,
196
+ ...envOf(options.env),
197
+ };
198
+ const panels = devPanels(dashboard).map((panel) => panel.key);
199
+
200
+ // `x dev`'s own, so a reload can empty it (a save otherwise kept a page's first render for its
201
+ // ttl); attached, so a tag bust reaches it too. Released with the roles.
202
+ const { isr, release: releaseIsr } = attachedIsr({ buildId });
203
+ acquired.push(releaseIsr);
204
+ const { routes, theme, speculation, inlineStyles, mcpPath, mcpPaths } = await devRouteTable({
205
+ isr,
206
+ root: options.root,
207
+ env: options.env,
208
+ buildId,
209
+ storage: runtime.storage,
210
+ dashboard,
211
+ islands: () => state.islands,
212
+ realtime: runtime.realtime,
213
+ rateLimitStore: appRuntime?.rateLimitStore ?? runtime.rateLimitStore,
214
+ appRoutes: appRuntime?.routes,
215
+ images: appRuntime?.images,
216
+ });
217
+
218
+ // The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
219
+ // `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
220
+ // first argument was `undefined` here and an app's middleware reached no development process.
221
+ const replicaOverride = replicaOverrides(appRuntime, services.db, options.env);
222
+ const running = await startRoles({
223
+ roles: options.roles ?? DEV_ROLES,
224
+ port: options.port,
225
+ // `serve.ts`'s expression, called rather than restated: `METRICS_PORT` was read in the
226
+ // container and ignored here, so the scrape port an operator moved was the one port `x dev`
227
+ // could not move — and the second `x dev` on a box died binding the hardcoded 9090.
228
+ metricsPort: metricsPortFor(options.env, options.port),
229
+ buildId,
230
+ runtime,
231
+ routes,
232
+ env: options.env,
233
+ // Read from `app.config.ts` rather than threaded through `DevOptions`: `x dev` and `serve.ts`
234
+ // must not be able to disagree about where the app's sign-in page is.
235
+ signInPath: await loadSignInPath(options.root),
236
+ // The same seam `serve.ts` passes: the app's own error page is a FILE under this root.
237
+ root: options.root,
238
+ // The `/_x` shell, the harness's frame, the app's own error pages and its brand — the inline
239
+ // bodies this process serves; the app's surfaces are content-hashed files `'self'` admits.
240
+ inlineStyles: [await devShellStyle(), FRAME_STYLE, ...inlineStyles],
241
+ inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
242
+ // The overlay renders this request's own loops under the error it is already showing.
243
+ // `serve.ts` boots through the same `startRoles` and passes nothing (axiom 6).
244
+ devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
245
+ statements.repeatsFor(asCtx(ctx)).map(loopFacts).map(loopNotice),
246
+ // The read-replica scope, opened per request. Absent for every app that names no
247
+ // `DATABASE_REPLICA_URL` — which is every embedded boot by construction, since PGlite has no
248
+ // standby — so this key does not exist on a homework app's boot at all.
249
+ ...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
250
+ });
251
+ acquired.push(() => running.stop());
252
+
253
+ const rebuild = devRebuilder(options, state, isr);
254
+ // Watched one directory at a time, so an ignored one costs no descriptor at all — `dev-watch.ts`
255
+ // decides which, from the app's own `.gitignore`. A recursive watch on the root registered one
256
+ // inotify descriptor per directory in the tree, `.git/`, `node_modules/` and the `.x/` this
257
+ // process writes to included, and filtered the events afterwards.
258
+ const watcher = watchTree({ root: options.root, onChange: rebuild });
259
+
260
+ server = {
261
+ url: running.url ?? `http://localhost:${options.port}`,
262
+ services,
263
+ roles: running.roles,
264
+ mcp: mcpPath,
265
+ mcpPaths,
266
+ get buildId(): string {
267
+ return stamped ?? state.manifest.buildId;
268
+ },
269
+ // A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
270
+ // loop the last request tripped, not the findings as they were when the route table was built.
271
+ // The loops come last and carry their request id, so a boot report reads as a boot report and
272
+ // a diagnostic that arrived a minute later reads as one too.
273
+ get findings(): readonly Finding[] {
274
+ const loops = statements.repeats().map(loopFacts).map(loopFinding);
275
+ return state.reloadFinding === undefined
276
+ ? [...state.appFindings, ...loops]
277
+ : [...state.appFindings, state.reloadFinding, ...loops];
278
+ },
279
+ running,
280
+ runtime,
281
+ panels,
282
+ // The boot's own unwind list IS the stop list — one list, so the two cannot drift — plus the
283
+ // watcher. Every step runs and the first failure is rethrown (`releaseOrThrow`): one refused
284
+ // role stop used to leave PGlite's lock held for the next `x dev`. Newest first: the watcher,
285
+ // the roles, ISR, the statement ledger and the exporter (after the roles, so an in-flight
286
+ // request still has both), then the services.
287
+ stop: () => releaseOrThrow([...acquired, () => watcher.close()]),
288
+ };
289
+ return server;
290
+ }