@ultimat3/cli 24.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +66 -87
  130. package/src/role-sync.ts +65 -6
  131. package/src/role-wake.ts +2 -3
  132. package/src/root-env.ts +157 -28
  133. package/src/route-islands.ts +69 -0
  134. package/src/runtime-admin.ts +4 -1
  135. package/src/runtime-assets.ts +15 -2
  136. package/src/runtime-bindings.ts +23 -3
  137. package/src/runtime-cache.ts +8 -8
  138. package/src/runtime-hooks.ts +2 -1
  139. package/src/runtime-isr-outcome.ts +100 -0
  140. package/src/runtime-isr.ts +2 -2
  141. package/src/runtime-jobs.ts +78 -18
  142. package/src/runtime-live-feed.ts +1 -1
  143. package/src/runtime-mail.ts +26 -0
  144. package/src/runtime-mfa-warning.ts +2 -2
  145. package/src/runtime-overrides.ts +3 -3
  146. package/src/runtime-purge.ts +1 -1
  147. package/src/runtime-queue.ts +19 -30
  148. package/src/runtime-realtime.ts +5 -3
  149. package/src/runtime-render.ts +77 -82
  150. package/src/runtime-replica.ts +3 -8
  151. package/src/runtime-services.ts +36 -15
  152. package/src/runtime-storage-upload.ts +2 -2
  153. package/src/runtime-storage.ts +3 -3
  154. package/src/schema-diff.ts +1 -1
  155. package/src/secrets-rotation.ts +8 -9
  156. package/src/seo-routes.ts +2 -2
  157. package/src/serve-boot.ts +22 -11
  158. package/src/serve-drain.ts +3 -1
  159. package/src/serve-retire.ts +95 -0
  160. package/src/serve-web.ts +19 -8
  161. package/src/serve.ts +27 -10
  162. package/src/server-barrels.ts +6 -3
  163. package/src/shot-browser.ts +1 -1
  164. package/src/shot-locale.ts +6 -15
  165. package/src/shot-settle.ts +3 -3
  166. package/src/shot-theme.ts +1 -1
  167. package/src/shot-viewport.ts +152 -0
  168. package/src/signal-shred.ts +26 -12
  169. package/src/site-asset-routes.ts +2 -2
  170. package/src/site-seo.ts +6 -4
  171. package/src/source-files.ts +13 -3
  172. package/src/static-report.ts +5 -0
  173. package/src/style-routes.ts +2 -2
  174. package/src/sw-artifacts.ts +8 -8
  175. package/src/sw-precache-plan.ts +34 -4
  176. package/src/sync-authenticator.ts +3 -3
  177. package/src/sync-url.ts +1 -1
  178. package/src/templates/action.ts +4 -4
  179. package/src/templates/backfill.ts +4 -4
  180. package/src/templates/entity.ts +2 -2
  181. package/src/templates/github/ci.yml.ts +28 -18
  182. package/src/templates/github/image.yml.ts +92 -0
  183. package/src/templates/github/runner.ts +18 -0
  184. package/src/templates/guard-bare-error.ts +16 -2
  185. package/src/templates/guard-unzoned-date.ts +56 -6
  186. package/src/templates/index.ts +1 -1
  187. package/src/templates/job-body-test.ts +2 -2
  188. package/src/templates/job.ts +6 -6
  189. package/src/templates/query.ts +9 -8
  190. package/src/templates/resource-page.ts +9 -7
  191. package/src/templates/resource-service.ts +2 -2
  192. package/src/templates/route.ts +23 -8
  193. package/src/templates/scaffold-api.ts +2 -3
  194. package/src/templates/scaffold-app.ts +2 -1
  195. package/src/templates/scaffold-auth.ts +205 -70
  196. package/src/templates/scaffold-bin.ts +122 -0
  197. package/src/templates/scaffold-claude-commands.ts +11 -11
  198. package/src/templates/scaffold-container.ts +12 -6
  199. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  200. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  201. package/src/templates/scaffold-dashboard-example.ts +112 -172
  202. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  203. package/src/templates/scaffold-dashboard.ts +9 -5
  204. package/src/templates/scaffold-db-package.ts +2 -2
  205. package/src/templates/scaffold-docs.ts +20 -77
  206. package/src/templates/scaffold-domain-package.ts +0 -4
  207. package/src/templates/scaffold-entries.ts +4 -3
  208. package/src/templates/scaffold-env.ts +6 -3
  209. package/src/templates/scaffold-gitattributes.ts +59 -0
  210. package/src/templates/scaffold-helm-templates.ts +74 -18
  211. package/src/templates/scaffold-helm.ts +31 -4
  212. package/src/templates/scaffold-http.ts +1 -1
  213. package/src/templates/scaffold-i18n.ts +15 -2
  214. package/src/templates/scaffold-icon.ts +2 -2
  215. package/src/templates/scaffold-repo.ts +18 -15
  216. package/src/templates/scaffold-shell.ts +17 -2
  217. package/src/test-only-shipped.ts +193 -0
  218. package/src/test-select.ts +2 -1
  219. package/src/test-shards.ts +1 -4
  220. package/src/theme-boot.ts +2 -2
  221. package/src/theme-brand.ts +66 -0
  222. package/src/trusted-hops.ts +49 -0
  223. package/src/unscanned-admin.ts +2 -2
  224. package/src/verify-checks.ts +12 -2
  225. package/src/verify-coverage-run.ts +3 -2
  226. package/src/verify-deadline.ts +87 -10
  227. package/src/verify-merge.ts +48 -16
  228. package/src/verify-part-step.ts +10 -2
  229. package/src/verify-role-load-probe.ts +12 -2
  230. package/src/verify-role-load.ts +9 -4
  231. package/src/verify-run.ts +3 -0
  232. package/src/verify-step.ts +2 -0
  233. package/src/verify-test-run.ts +3 -2
  234. package/src/verify-tests.ts +0 -1
  235. package/src/worker-bundle.ts +2 -2
  236. package/src/workspace-checks.ts +53 -7
  237. package/src/workspace-graph.ts +3 -2
  238. package/src/write-line.ts +82 -7
  239. package/src/browser-launcher-fake-html.ts +0 -80
  240. package/src/browser-launcher-fake.ts +0 -168
  241. package/src/jobs-drain.ts +0 -230
  242. package/src/scaffold-typecheck.ts +0 -227
  243. package/src/thrown-by.ts +0 -24
package/src/cmd-shot.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { mkdirSync } from 'node:fs';
9
9
  import { join, resolve } from 'node:path';
10
10
  import { renderFixShellArg } from '@ultimat3/core';
11
+ import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
11
12
  import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
12
13
  import { requireAppRoot } from './app-root';
13
14
  import { appBrowser } from './browser-launcher';
@@ -23,7 +24,13 @@ import {
23
24
  refuseSweepWithRoute,
24
25
  refuseSweepWithState,
25
26
  } from './cmd-shot-island';
26
- import { MATRIX_DIR, matrixRoutes, planShotMatrix, runShotMatrix } from './cmd-shot-matrix';
27
+ import {
28
+ MATRIX_DIR,
29
+ matrixRoutes,
30
+ planShotMatrix,
31
+ refuseMatrixWithComponent,
32
+ runShotMatrix,
33
+ } from './cmd-shot-matrix';
27
34
  import { shotSpec } from './cmd-shot-spec';
28
35
  import type { CliCommand, CommandContext } from './command';
29
36
  import { BadFlagError, MissingPositionalError } from './errors';
@@ -33,12 +40,7 @@ import type { ParsedArgs } from './parse';
33
40
  import { flagBool, flagString } from './parse';
34
41
  import { shotBrowserChoice } from './shot-browser';
35
42
  import { readCookieFlag, type ShotCookiePair, shotCookies } from './shot-cookie';
36
- import {
37
- acceptLanguageHeaders,
38
- loadShotLocales,
39
- localizedShotPath,
40
- readLocaleFlag,
41
- } from './shot-locale';
43
+ import { acceptLanguageHeaders, localizedShotPath, readLocaleFlag } from './shot-locale';
42
44
  import type { BootDevServer, ShotServer } from './shot-server';
43
45
  import { allowHostsFrom, devServerFor, SHOT_DIR } from './shot-server';
44
46
  import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
@@ -53,21 +55,12 @@ import {
53
55
  shotSummary,
54
56
  verdictJson,
55
57
  } from './shot-verdict';
58
+ import { parseViewports, refuseViewportWithComponent, runShotViewports } from './shot-viewport';
56
59
 
57
60
  /** Kernel-picked by default: :3000 is usually another project's dev server, not a free port. */
58
61
  const DEFAULT_PORT = 0;
59
62
 
60
- /**
61
- * How long the page is left alone after `load` before it is photographed: exactly the
62
- * `requestIdleCallback` deadline `@ultimat3/render`'s hydration runtime gives an `idle` island —
63
- * shoot sooner and the verdict reports `booted: 0` for a page that hydrates perfectly. READ from
64
- * that runtime rather than restated, because two copies of one number that must agree is the drift
65
- * axiom 2 refuses: the settle window is not "2 seconds", it is "the deadline the runtime uses".
66
- */
67
- export const DEFAULT_SETTLE_MS = IDLE_HYDRATE_TIMEOUT_MS;
68
-
69
- // Re-exported, not re-declared: `cmd-shot.test.ts` and the island path both name them, and a
70
- // second declaration of a path or an allow-list rule is a second answer.
63
+ // Re-exported, not re-declared: a second declaration of a path or a host rule is a second answer.
71
64
  export type { BootDevServer, ShotServer };
72
65
  export { allowHostsFrom, devServerFor, SHOT_DIR };
73
66
 
@@ -372,7 +365,8 @@ export const shotCommand: CliCommand = {
372
365
  const sweep = flagBool(ctx.args, 'all-islands');
373
366
  const matrix = flagBool(ctx.args, 'matrix');
374
367
  const cookies = readCookieFlag(flagString(ctx.args, 'cookie'));
375
- const app = await loadShotLocales(root);
368
+ const viewports = parseViewports(flagString(ctx.args, 'viewport'));
369
+ const app = await appLocaleSet(root);
376
370
  const locale = readLocaleFlag(flagString(ctx.args, 'locale'), app);
377
371
  // Every ambiguous pair refused BY NAME, before a value is read: a reader who typed two
378
372
  // subjects has a belief about which one runs, and half of them would be wrong.
@@ -385,14 +379,9 @@ export const shotCommand: CliCommand = {
385
379
  }
386
380
  const component = sweep || (island !== undefined && island !== '');
387
381
  // The matrix photographs ROUTES; a component beside it is two subjects.
388
- if (matrix && component) {
389
- throw new BadFlagError({
390
- flag: 'matrix',
391
- command: 'shot',
392
- reason: 'photographs every site route; --island and --all-islands photograph components',
393
- fix: 'x shot --matrix --json',
394
- });
395
- }
382
+ if (matrix && component) refuseMatrixWithComponent();
383
+ // An island state declares its own size, so a size asked for beside one is a second answer.
384
+ if (component && viewports !== undefined) refuseViewportWithComponent();
396
385
  // An island is photographed in BOTH themes by the harness, which owns its `data-theme` and
397
386
  // carries no boot script — so a theme asked for beside one is a request nothing could honour.
398
387
  if (component && locale !== undefined) {
@@ -414,7 +403,12 @@ export const shotCommand: CliCommand = {
414
403
  // `--matrix` alone is every site route; a route beside it narrows the matrix to that one.
415
404
  const route = component || (matrix && positional === undefined) ? '' : readRoute(positional);
416
405
  const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
417
- const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
406
+ // How long the page is left alone after `load` before it is photographed: exactly the
407
+ // `requestIdleCallback` deadline `@ultimat3/render`'s hydration runtime gives an `idle` island —
408
+ // shoot sooner and the verdict reports `booted: 0` for a page that hydrates perfectly. The
409
+ // runtime's own constant, under its own name: the settle window is not "2 seconds", it is "the
410
+ // deadline the runtime uses", and a second name for it is a second import path.
411
+ const settleMs = intFlag(ctx.args, 'settle', 0, IDLE_HYDRATE_TIMEOUT_MS);
418
412
  const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
419
413
  // Read only when given: its absence means "any 2xx", which no single default number can say.
420
414
  const expectStatus =
@@ -473,22 +467,24 @@ export const shotCommand: CliCommand = {
473
467
  locales: locale === undefined ? app.locales : [locale],
474
468
  defaultLocale: app.defaultLocale,
475
469
  ...(theme === undefined ? {} : { themes: [theme] }),
470
+ ...(viewports === undefined ? {} : { viewports }),
476
471
  });
477
472
  const outDir = shotOutDir(ctx.cwd, out, join(root, MATRIX_DIR));
478
473
  return runShotMatrix({ cells, outDir, boot, shoot: runShot, base });
479
474
  }
480
475
  const shown = locale ?? app.defaultLocale;
481
476
  const path = localizedShotPath(route, shown, app.defaultLocale);
482
- return shotResult(
483
- await runShot({
484
- ...base,
485
- route: path,
486
- outDir: shotOutDir(ctx.cwd, out, join(root, SHOT_DIR, shotSlug(path))),
487
- boot,
488
- // Pinned whether or not `--locale` was given: the default locale, never the box's own.
489
- acceptLanguage: shown,
490
- ...(theme === undefined ? {} : { colorScheme: theme }),
491
- }),
492
- );
477
+ const single = {
478
+ ...base,
479
+ route: path,
480
+ // Pinned whether or not `--locale` was given: the default locale, never the box's own.
481
+ acceptLanguage: shown,
482
+ ...(theme === undefined ? {} : { colorScheme: theme }),
483
+ };
484
+ const outDir = shotOutDir(ctx.cwd, out, join(root, SHOT_DIR, shotSlug(path)));
485
+ if (viewports !== undefined) {
486
+ return runShotViewports({ viewports, outDir, boot, shoot: runShot, base: single });
487
+ }
488
+ return shotResult(await runShot({ ...single, outDir, boot }));
493
489
  },
494
490
  };
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,7 +38,6 @@ 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
43
  * `--workers`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while `cmd-verify.ts`'s
@@ -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,6 +1,6 @@
1
- // Single responsibility: which tables `x db gen` must grant `REPLICA IDENTITY FULL` — the live
2
- // queries' DECLARED `subscribes:` plus the `records` tables of every channel with params — and the
3
- // refusal for a declared name no entity's table matches. Only this tier holds all the registries.
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
6
  import { paramsChannelTables } from '@ultimat3/realtime/server';
@@ -29,11 +29,10 @@ function offer(tables: ReadonlySet<string>): string {
29
29
  /**
30
30
  * A live read declares a relation this app has no entity for.
31
31
  *
32
- * Refused rather than dropped, and that is the whole point of the check: `@ultimat3/db` keeps only
33
- * the declared names an entity's table matches (`replica-identity.ts`), so an EXTRA name is
34
- * discarded in silence — and `@ultimat3/query` has no table catalog, so its own `subscribes:`
35
- * assertions cannot see one either. A typo therefore granted `REPLICA IDENTITY FULL` to nothing
36
- * 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.
37
36
  *
38
37
  * The name goes in the `cause` and never into a command: it is a string from the app's own source,
39
38
  * and the remedy is an edit to a field rather than anything to paste at a shell.
@@ -47,8 +46,8 @@ export class QuerySubscribesUnknownError extends UltimateError {
47
46
  'declares a table with that name',
48
47
  fix:
49
48
  `edit subscribes: on the query "${input.query}" to name a table this app declares ` +
50
- `(${offer(input.tables)}), or drop the name — x db gen grants REPLICA IDENTITY FULL to ` +
51
- '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)',
52
51
  meta: { query: input.query, table: input.table },
53
52
  });
54
53
  }
@@ -57,13 +56,19 @@ export class QuerySubscribesUnknownError extends UltimateError {
57
56
  /**
58
57
  * The tables to hand `GenerateOptions.replicaIdentityFull`, deduped and sorted.
59
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
+ *
60
66
  * Sorted so the same app generates the same bytes whatever order its modules registered in — the
61
67
  * rule `@ultimat3/db`'s own `pending()` states one layer down, kept here too because a caller that
62
68
  * ordered by registration would put a diff in a file for nothing.
63
69
  *
64
- * Every name is checked against `tables` BEFORE any of them is returned: a run that emitted the
65
- * good half and refused afterwards would leave an author with a migration that is right for one
66
- * 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.
67
72
  *
68
73
  * `channelTables` defaults to what the declared channels need (`paramsChannelTables()`): a channel
69
74
  * with params routes a DELETE to the topic the OLD row names, and under the default identity that
@@ -76,14 +81,12 @@ export function replicaIdentityTables(
76
81
  tables: ReadonlySet<string>,
77
82
  channelTables: readonly string[] = paramsChannelTables(),
78
83
  ): readonly string[] {
79
- const wanted = new Set<string>(channelTables.filter((table) => tables.has(table)));
80
84
  for (const query of queries) {
81
85
  for (const table of query.subscribes ?? []) {
82
86
  if (!tables.has(table)) {
83
87
  throw new QuerySubscribesUnknownError({ query: query.name, table, tables });
84
88
  }
85
- wanted.add(table);
86
89
  }
87
90
  }
88
- return [...wanted].sort();
91
+ return [...new Set(channelTables.filter((table) => tables.has(table)))].sort();
89
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
+ }