@ultimat3/cli 24.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 (242) 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 +64 -85
  130. package/src/role-sync.ts +65 -6
  131. package/src/root-env.ts +157 -28
  132. package/src/route-islands.ts +69 -0
  133. package/src/runtime-admin.ts +4 -1
  134. package/src/runtime-assets.ts +15 -2
  135. package/src/runtime-bindings.ts +23 -3
  136. package/src/runtime-cache.ts +8 -8
  137. package/src/runtime-hooks.ts +2 -1
  138. package/src/runtime-isr-outcome.ts +100 -0
  139. package/src/runtime-isr.ts +2 -2
  140. package/src/runtime-jobs.ts +78 -18
  141. package/src/runtime-live-feed.ts +1 -1
  142. package/src/runtime-mail.ts +26 -0
  143. package/src/runtime-mfa-warning.ts +2 -2
  144. package/src/runtime-overrides.ts +3 -3
  145. package/src/runtime-purge.ts +1 -1
  146. package/src/runtime-queue.ts +12 -12
  147. package/src/runtime-realtime.ts +5 -3
  148. package/src/runtime-render.ts +77 -82
  149. package/src/runtime-replica.ts +3 -8
  150. package/src/runtime-services.ts +33 -13
  151. package/src/runtime-storage-upload.ts +2 -2
  152. package/src/runtime-storage.ts +3 -3
  153. package/src/schema-diff.ts +1 -1
  154. package/src/secrets-rotation.ts +8 -9
  155. package/src/seo-routes.ts +2 -2
  156. package/src/serve-boot.ts +22 -11
  157. package/src/serve-drain.ts +3 -1
  158. package/src/serve-retire.ts +95 -0
  159. package/src/serve-web.ts +19 -8
  160. package/src/serve.ts +27 -10
  161. package/src/server-barrels.ts +6 -3
  162. package/src/shot-browser.ts +1 -1
  163. package/src/shot-locale.ts +6 -15
  164. package/src/shot-settle.ts +3 -3
  165. package/src/shot-theme.ts +1 -1
  166. package/src/shot-viewport.ts +152 -0
  167. package/src/signal-shred.ts +26 -12
  168. package/src/site-asset-routes.ts +2 -2
  169. package/src/site-seo.ts +6 -4
  170. package/src/source-files.ts +13 -3
  171. package/src/static-report.ts +5 -0
  172. package/src/style-routes.ts +2 -2
  173. package/src/sw-artifacts.ts +8 -8
  174. package/src/sw-precache-plan.ts +34 -4
  175. package/src/sync-authenticator.ts +3 -3
  176. package/src/sync-url.ts +1 -1
  177. package/src/templates/action.ts +4 -4
  178. package/src/templates/backfill.ts +4 -4
  179. package/src/templates/entity.ts +2 -2
  180. package/src/templates/github/ci.yml.ts +28 -18
  181. package/src/templates/github/image.yml.ts +92 -0
  182. package/src/templates/github/runner.ts +18 -0
  183. package/src/templates/guard-bare-error.ts +16 -2
  184. package/src/templates/guard-unzoned-date.ts +56 -6
  185. package/src/templates/index.ts +1 -1
  186. package/src/templates/job-body-test.ts +2 -2
  187. package/src/templates/job.ts +6 -6
  188. package/src/templates/query.ts +9 -8
  189. package/src/templates/resource-page.ts +9 -7
  190. package/src/templates/resource-service.ts +2 -2
  191. package/src/templates/route.ts +23 -8
  192. package/src/templates/scaffold-api.ts +2 -3
  193. package/src/templates/scaffold-app.ts +2 -1
  194. package/src/templates/scaffold-auth.ts +205 -70
  195. package/src/templates/scaffold-bin.ts +122 -0
  196. package/src/templates/scaffold-claude-commands.ts +11 -11
  197. package/src/templates/scaffold-container.ts +12 -6
  198. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  199. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  200. package/src/templates/scaffold-dashboard-example.ts +112 -172
  201. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  202. package/src/templates/scaffold-dashboard.ts +9 -5
  203. package/src/templates/scaffold-db-package.ts +2 -2
  204. package/src/templates/scaffold-docs.ts +20 -77
  205. package/src/templates/scaffold-domain-package.ts +0 -4
  206. package/src/templates/scaffold-entries.ts +4 -3
  207. package/src/templates/scaffold-env.ts +6 -3
  208. package/src/templates/scaffold-gitattributes.ts +59 -0
  209. package/src/templates/scaffold-helm-templates.ts +74 -18
  210. package/src/templates/scaffold-helm.ts +31 -4
  211. package/src/templates/scaffold-http.ts +1 -1
  212. package/src/templates/scaffold-i18n.ts +15 -2
  213. package/src/templates/scaffold-icon.ts +2 -2
  214. package/src/templates/scaffold-repo.ts +18 -15
  215. package/src/templates/scaffold-shell.ts +17 -2
  216. package/src/test-only-shipped.ts +193 -0
  217. package/src/test-select.ts +2 -1
  218. package/src/test-shards.ts +1 -4
  219. package/src/theme-boot.ts +2 -2
  220. package/src/theme-brand.ts +66 -0
  221. package/src/trusted-hops.ts +49 -0
  222. package/src/unscanned-admin.ts +2 -2
  223. package/src/verify-checks.ts +12 -2
  224. package/src/verify-coverage-run.ts +3 -2
  225. package/src/verify-deadline.ts +87 -10
  226. package/src/verify-merge.ts +48 -16
  227. package/src/verify-part-step.ts +10 -2
  228. package/src/verify-role-load-probe.ts +12 -2
  229. package/src/verify-role-load.ts +9 -4
  230. package/src/verify-run.ts +3 -0
  231. package/src/verify-step.ts +2 -0
  232. package/src/verify-test-run.ts +3 -2
  233. package/src/verify-tests.ts +0 -1
  234. package/src/worker-bundle.ts +2 -2
  235. package/src/workspace-checks.ts +53 -7
  236. package/src/workspace-graph.ts +3 -2
  237. package/src/write-line.ts +82 -7
  238. package/src/browser-launcher-fake-html.ts +0 -80
  239. package/src/browser-launcher-fake.ts +0 -168
  240. package/src/jobs-drain.ts +0 -230
  241. package/src/scaffold-typecheck.ts +0 -227
  242. package/src/thrown-by.ts +0 -24
@@ -1,60 +1,120 @@
1
- // `jobs.queues`, `jobs.concurrency` and `jobs.visibilityTimeoutMs`, read out of the app's own
2
- // `app.config.ts` for the `worker` role, out of the config `startServices` loads once. Until 22.0.0
3
- // `createWorker` was called with none of them, so a `mail` queue ran nothing.
1
+ // The queues a `worker` serves and the slots each gets: `jobs.queues`, `jobs.concurrency` and
2
+ // `jobs.visibilityTimeoutMs` off the app's own `app.config.ts`, and `WORKER_QUEUES` off the
3
+ // Deployment's env — the one per-Deployment answer, since every Deployment runs the same image and
4
+ // the same config (issues #673, #676).
4
5
 
5
- import type { AppConfig } from '@ultimat3/core';
6
+ import type { AppConfig, JobsConcurrency } from '@ultimat3/core';
7
+ import { ConfigInvalidError, logger, renderCauseValue, renderFixShellArg } from '@ultimat3/core';
6
8
  import { registeredJobs, type WorkerOptions } from '@ultimat3/jobs';
9
+ import type { Env } from './runtime-bindings';
10
+
11
+ /** The env key a worker Deployment names its queues with: `WORKER_QUEUES=banks,banks-long`. */
12
+ export const WORKER_QUEUES_ENV = 'WORKER_QUEUES';
7
13
 
8
14
  /** What the worker takes from the config. An absent number keeps the worker's own default. */
9
15
  export interface WorkerConfig {
10
16
  readonly queues: readonly string[];
11
- readonly concurrency: number | undefined;
17
+ /**
18
+ * `true` when `WORKER_QUEUES` named `queues`: they are served EXACTLY, never widened by the
19
+ * queues registered jobs are enqueued on. `false` is the union `workerQueuesFor` explains.
20
+ */
21
+ readonly exact: boolean;
22
+ readonly concurrency: JobsConcurrency | undefined;
12
23
  readonly visibilityTimeoutMs: number | undefined;
13
24
  }
14
25
 
15
26
  /** A root with no `app.config.ts`: no queue of its own, and the worker's own timings. */
16
27
  const NO_WORKER_CONFIG: WorkerConfig = Object.freeze({
17
28
  queues: [],
29
+ exact: false,
18
30
  concurrency: undefined,
19
31
  visibilityTimeoutMs: undefined,
20
32
  });
21
33
 
22
- export function workerConfigOf(config: AppConfig | undefined): WorkerConfig {
23
- if (config === undefined) return NO_WORKER_CONFIG;
24
- const { queues, concurrency, visibilityTimeoutMs } = config.jobs;
25
- return { queues, concurrency, visibilityTimeoutMs };
34
+ /**
35
+ * `WORKER_QUEUES`, read: `undefined` when unset or blank (`ROLE`'s rule — an empty value is a
36
+ * template that said nothing), else its names trimmed and deduplicated in the order written. An
37
+ * empty entry (`banks,,long`) is REFUSED, not skipped: it is a queue somebody meant to name, and
38
+ * the worker that dropped it would leave that queue's jobs waiting with nothing in the log.
39
+ */
40
+ export function workerQueuesFromEnv(env: Env): readonly string[] | undefined {
41
+ const raw = env[WORKER_QUEUES_ENV];
42
+ if (raw === undefined || raw.trim() === '') return undefined;
43
+ const names = raw.split(',').map((name) => name.trim());
44
+ const named = [...new Set(names.filter((name) => name !== ''))];
45
+ if (!names.includes('')) return named;
46
+ throw new ConfigInvalidError({
47
+ cause: `${WORKER_QUEUES_ENV} ${renderCauseValue(raw)} contains an empty queue name — every comma-separated entry must name a queue`,
48
+ fix: `WORKER_QUEUES=${renderFixShellArg(named.join(','), '<queue>,<queue>')} on this Deployment — or unset WORKER_QUEUES to serve jobs.queues and every registered job's queue`,
49
+ meta: { key: WORKER_QUEUES_ENV },
50
+ });
51
+ }
52
+
53
+ export function workerConfigOf(config: AppConfig | undefined, env: Env = {}): WorkerConfig {
54
+ const fromEnv = workerQueuesFromEnv(env);
55
+ const base =
56
+ config === undefined
57
+ ? NO_WORKER_CONFIG
58
+ : {
59
+ queues: config.jobs.queues,
60
+ exact: false,
61
+ concurrency: config.jobs.concurrency,
62
+ visibilityTimeoutMs: config.jobs.visibilityTimeoutMs,
63
+ };
64
+ return fromEnv === undefined ? base : { ...base, queues: fromEnv, exact: true };
26
65
  }
27
66
 
28
67
  /**
29
68
  * The queues a worker serves: the configured ones, then every queue a registered job is enqueued
30
69
  * on. The union and not the list, decided 2026-09-23: `x new` and the deployed demo configure
31
70
  * `['<app>-default']` while a `job()` naming no queue lands on `default`, so obeying the list alone
32
- * would leave every such job queued for ever — the silent direction. `undefined` when both are
33
- * empty, which is `createWorker`'s own `['default']`.
71
+ * would leave every such job queued for ever — the silent direction. `exact` (a Deployment that
72
+ * set `WORKER_QUEUES`) is the list alone: that operator split the queues across Deployments on
73
+ * purpose, and widening one to the rest is what made their grace periods mean nothing (#673).
74
+ * `undefined` when nothing is served, which is `jobWorker`'s own `['default']`.
34
75
  */
35
76
  export function workerQueuesFor(
36
77
  configured: readonly string[],
37
78
  registered: readonly string[],
79
+ exact = false,
38
80
  ): readonly string[] | undefined {
39
- const served = [...new Set([...configured, ...registered])];
81
+ const served = [...new Set(exact ? configured : [...configured, ...registered])];
40
82
  return served.length === 0 ? undefined : served;
41
83
  }
42
84
 
85
+ /**
86
+ * An exact worker's one boot line about what it leaves: the queues registered jobs are enqueued
87
+ * on that it will never claim. A warning and not a refusal — another Deployment serving them is
88
+ * the whole point of the split — but this process cannot see that one, so it says what to check.
89
+ */
90
+ function reportUnserved(served: readonly string[], registered: readonly string[]): void {
91
+ const queues = [...new Set(registered)].filter((queue) => !served.includes(queue)).sort();
92
+ if (queues.length === 0) return;
93
+ logger.warn('jobs.worker.queue-unserved', {
94
+ queues,
95
+ served,
96
+ fix: `x jobs ls --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one's`,
97
+ });
98
+ }
99
+
43
100
  type WorkerConfigOptions = Pick<WorkerOptions, 'queues' | 'concurrency' | 'visibilityTimeoutMs'>;
44
101
 
45
102
  /**
46
- * The `createWorker` options the config supplies, read at START so every job the app registered
103
+ * The `jobWorker` options the config supplies, read at START so every job the app registered
47
104
  * on import is counted. A key the config leaves unset is omitted, never passed as `undefined`, so
48
105
  * the worker's own default applies (`exactOptionalPropertyTypes`).
49
106
  */
50
107
  export function workerOptionsFor(config: WorkerConfig | undefined): WorkerConfigOptions {
51
- const queues = workerQueuesFor(
52
- config?.queues ?? [],
53
- registeredJobs().map((handle) => handle.queue),
54
- );
108
+ const registered = registeredJobs().map((handle) => handle.queue);
109
+ const exact = config?.exact === true;
110
+ const queues = workerQueuesFor(config?.queues ?? [], registered, exact);
111
+ if (exact) reportUnserved(queues ?? [], registered);
112
+ // Passed as written: `jobWorker` reads a table by own key and runs a queue it does not name at
113
+ // core's `JOBS_CONCURRENCY_DEFAULT` — the one slot default, so no second table is built here.
114
+ const concurrency = config?.concurrency;
55
115
  return {
56
116
  ...(queues === undefined ? {} : { queues }),
57
- ...(config?.concurrency === undefined ? {} : { concurrency: config.concurrency }),
117
+ ...(concurrency === undefined ? {} : { concurrency }),
58
118
  ...(config?.visibilityTimeoutMs === undefined
59
119
  ? {}
60
120
  : { visibilityTimeoutMs: config.visibilityTimeoutMs }),
@@ -1,5 +1,5 @@
1
1
  // Where a `sync` node's changes come from when this process boots one. Production decodes the
2
- // write-ahead log (`x dev --role replicator`, `PgLogicalReplicationFeed`, a real `DATABASE_URL`).
2
+ // write-ahead log (`x dev --role replicator`, `postgresChangeFeed()`, a real `DATABASE_URL`).
3
3
  // The embedded database has no walsender, and until 2026-09-05 nothing stood in for it: a live
4
4
  // subscription under `x dev` took its snapshot and then heard nothing, so every `--live` query in
5
5
  // every scaffolded app was dead in development — which is where an author first tries one.
@@ -0,0 +1,26 @@
1
+ // The `mail` section of `app.config.ts` the boot obeys: `mail.retainMime`, handed to
2
+ // `@ultimat3/mail`'s `selectMailDriver` — the one option an environment cannot carry. Selection
3
+ // still reads env for WHICH transport; the config only says what the chosen one keeps.
4
+
5
+ import type { AppConfig } from '@ultimat3/core';
6
+ import type { MailEnvironment, MailSelection, MailSelectOptions } from '@ultimat3/mail';
7
+ import { selectMailDriver } from '@ultimat3/mail';
8
+
9
+ /** No config file, or `retainMime: false`, is no option at all — never `retainMime: undefined`. */
10
+ export function mailSelectOptionsOf(config: AppConfig | undefined): MailSelectOptions {
11
+ const retain = config?.mail.retainMime ?? false;
12
+ if (retain === false) return {};
13
+ return { retainMime: retain.maxBytes === undefined ? {} : { maxBytes: retain.maxBytes } };
14
+ }
15
+
16
+ /**
17
+ * The boot's mail selection. Pure, as `selectMailDriver` is: it builds a transport and dials
18
+ * nothing, so a cap above mail's ceiling or retention asked of Resend refuses the boot here.
19
+ */
20
+ export const selectAppMailDriver = (
21
+ env: MailEnvironment,
22
+ config: AppConfig | undefined,
23
+ ): MailSelection =>
24
+ // `retainMimeKey`: mail judges the cap before it picks a transport — the ceiling is its number —
25
+ // and names the `app.config.ts` key in the refusal, so the boot restates no check of its own.
26
+ selectMailDriver(env, { ...mailSelectOptionsOf(config), retainMimeKey: 'mail.retainMime' });
@@ -2,7 +2,7 @@
2
2
  // the operator step for sealed MFA secrets). `x doctor` asks the same question only when someone
3
3
  // runs it; an unsealed secret is a user refused at the second factor, so the boot says so too.
4
4
 
5
- import { BuiltinAdapter, countUnsealedMfaSecrets } from '@ultimat3/auth';
5
+ import { countUnsealedMfaSecrets, postgresAuthAdapter } from '@ultimat3/auth';
6
6
  import { logger } from '@ultimat3/core';
7
7
  import type { DbClient } from '@ultimat3/db';
8
8
 
@@ -14,7 +14,7 @@ import type { DbClient } from '@ultimat3/db';
14
14
  export async function warnUnsealedMfaSecrets(client: DbClient): Promise<number> {
15
15
  let unsealed = 0;
16
16
  try {
17
- unsealed = await countUnsealedMfaSecrets({ adapter: new BuiltinAdapter(client) });
17
+ unsealed = await countUnsealedMfaSecrets({ adapter: postgresAuthAdapter(client) });
18
18
  } catch {
19
19
  return 0;
20
20
  }
@@ -35,7 +35,7 @@ export interface RuntimeOverrides {
35
35
  readonly mail?: MailDriver;
36
36
  /**
37
37
  * Replaces the `NATS_URL` selection. Already connected when it arrives, and NOT closed by
38
- * `stop()`: whoever built it owns its socket, exactly as `createServer` does not close a
38
+ * `stop()`: whoever built it owns its socket, exactly as `httpServer` does not close a
39
39
  * `rateLimitStore` it was handed.
40
40
  */
41
41
  readonly transport?: Transport;
@@ -53,12 +53,12 @@ export interface RuntimeOverrides {
53
53
  */
54
54
  readonly rateLimitStore?: RateLimitStore;
55
55
  /**
56
- * Where regenerated ISR pages live. Omitted, `createIsrController` keeps a per-process memory
56
+ * Where regenerated ISR pages live. Omitted, `isrController` keeps a per-process memory
57
57
  * store — twelve replicas then hold twelve of them, and a purge tag reaches one twelfth of the
58
58
  * fleet.
59
59
  */
60
60
  readonly isrStore?: IsrStore;
61
- /** Prepended to the pipeline by `createServer`, which `startRoles` never passed one. */
61
+ /** Prepended to the pipeline by `httpServer`, which `startRoles` never passed one. */
62
62
  readonly middleware?: readonly Middleware[];
63
63
  /**
64
64
  * Plain HTTP routes at paths no primitive projects to — `/.well-known/oauth-authorization-server`,
@@ -96,7 +96,7 @@ function notifyTargets(retention: InboxRetention): readonly PurgeTarget[] {
96
96
  },
97
97
  {
98
98
  name: 'x_notify_digests',
99
- // The job's clock, and the store's own window (`createPgDigestStore({ retentionMs })`, a week
99
+ // The job's clock, and the store's own window (`postgresDigestStore({ retentionMs })`, a week
100
100
  // by default) — a closed window only its dead-lettered flush would ever have drained.
101
101
  purgeExpired: (nowMs: number): Promise<number> => purgeNotifyDigests(nowMs),
102
102
  },
@@ -14,19 +14,19 @@ import {
14
14
  import type { DbClient, PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
15
15
  import {
16
16
  baseClient,
17
- createPgliteClient,
18
- createPostgresClient,
19
17
  currentTx,
18
+ pgliteClient,
20
19
  pgliteDataDir,
20
+ postgresClient,
21
21
  setDbClient,
22
22
  } from '@ultimat3/db';
23
23
  import type { Tx } from '@ultimat3/entity';
24
24
  import type { EventBus, JobDriver, OutboxStore, PgExecutor } from '@ultimat3/jobs';
25
25
  import {
26
- createJobsFacade,
27
- createPgDriver,
28
- createPgEventBus,
29
- createPgOutboxStore,
26
+ outboxJobsFacade,
27
+ postgresEventBus,
28
+ postgresJobDriver,
29
+ postgresOutboxStore,
30
30
  resetEventBus,
31
31
  resetJobDriver,
32
32
  resetJobsFacade,
@@ -99,11 +99,11 @@ export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
99
99
  // Linked at boot, from the app's own migrations: PGlite cannot `create extension` one it
100
100
  // was not handed, and the schema dump's scratch replay already asks this same reader — an
101
101
  // app that replayed in the gate and failed in `x dev` was the two disagreeing.
102
- createPgliteClient({
102
+ pgliteClient({
103
103
  dataDir: pgliteDataDir(binding.url),
104
104
  extensions: () => appExtensions(services.root),
105
105
  })
106
- : createPostgresClient({ url: binding.url });
106
+ : postgresClient({ url: binding.url });
107
107
  const attached = attachReplica(client, replicaUrlFor(binding, env));
108
108
  setDbClient(attached.client);
109
109
  return { client, replica: attached.replica };
@@ -184,17 +184,17 @@ async function startJobs(
184
184
  ): Promise<RunningQueue> {
185
185
  await applySchema(client, schema);
186
186
  const executor = pgExecutorFor(client);
187
- const driver = overrides?.jobs ?? createPgDriver({ executor });
187
+ const driver = overrides?.jobs ?? postgresJobDriver({ executor });
188
188
  setJobDriver(driver);
189
- const outbox = createPgOutboxStore({
189
+ const outbox = postgresOutboxStore({
190
190
  executor,
191
191
  // The open transaction is a client on its own connection; the `Tx` token is not that object.
192
192
  txExecutor: () => pgExecutorFor(currentTx() ?? client),
193
193
  });
194
194
  setJobsFacade(
195
- createJobsFacade({ store: outbox, driver }, () => currentTx() as unknown as Tx | undefined),
195
+ outboxJobsFacade({ store: outbox, driver }, () => currentTx() as unknown as Tx | undefined),
196
196
  );
197
- const events = createPgEventBus({ executor });
197
+ const events = postgresEventBus({ executor });
198
198
  setEventBus(events);
199
199
  const idempotency = postgresIdempotencyStore({
200
200
  executor,
@@ -1,6 +1,6 @@
1
- // `realtime.enabled`, `realtime.transport` and `realtime.urlEnv` for the boot, out of the config
2
- // `startServices` loads once (`app-config-load.ts`). Until 22.0.0 no boot read any of the three
3
- // and `NATS_URL` alone decided the bus.
1
+ // The `realtime` section for the boot, out of the config `startServices` loads once
2
+ // (`app-config-load.ts`). Until 22.0.0 no boot read `enabled`, `transport` or `urlEnv` and
3
+ // `NATS_URL` alone decided the bus; the two per-actor caps are read by the `sync` role.
4
4
 
5
5
  import type { AppConfig, RealtimeConfig } from '@ultimat3/core';
6
6
 
@@ -9,6 +9,8 @@ export const REALTIME_DEFAULTS: RealtimeConfig = Object.freeze({
9
9
  enabled: true,
10
10
  transport: 'memory',
11
11
  urlEnv: undefined,
12
+ maxSubscriptionsPerActor: undefined,
13
+ maxSocketsPerActor: undefined,
12
14
  });
13
15
 
14
16
  /**
@@ -3,12 +3,10 @@
3
3
  // document (head + the route's component, the surface's CSS LINKED, `style-bundle.ts`), it never
4
4
  // decides what a mode means or what headers it earns.
5
5
 
6
- // why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
7
- import { posix } from 'node:path';
8
6
  import { actionPathStyle } from '@ultimat3/action';
9
7
  import { clientScopeOf } from '@ultimat3/auth';
10
- import type { Ctx } from '@ultimat3/core';
11
- import { CLIENT_SCOPE_HEADER, createSingleFlight } from '@ultimat3/core';
8
+ import type { Actor, Ctx } from '@ultimat3/core';
9
+ import { CLIENT_SCOPE_HEADER, singleFlight } from '@ultimat3/core';
12
10
  import type {
13
11
  RouteMeta as HttpRouteMeta,
14
12
  RedirectIntent,
@@ -18,20 +16,16 @@ import type {
18
16
  UltimateRequest,
19
17
  } from '@ultimat3/http';
20
18
  import { asCtx, html, NO_STORE, redirect, stream, takeRedirect } from '@ultimat3/http';
21
- import { currentLocale, localeConfig } from '@ultimat3/i18n';
19
+ import { currentLocale } from '@ultimat3/i18n';
22
20
  import type { IslandCollector, RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
23
21
  import {
24
- clientBootTags,
25
22
  clientPathStyleTags,
26
23
  clientPersistTags,
27
24
  clientScopeTag,
28
25
  clientSyncTags,
29
- createIslandCollector,
30
26
  documentCarriesScope,
31
27
  headFromMeta,
32
28
  hydrateRuntime,
33
- islandModuleId,
34
- islandModuleIds,
35
29
  metaContextFor,
36
30
  renderHead,
37
31
  routeDataFor,
@@ -43,7 +37,6 @@ import {
43
37
  import type { IsrController } from '@ultimat3/render/server';
44
38
  import {
45
39
  contentHash,
46
- isrKey,
47
40
  ROOT_ELEMENT_ID,
48
41
  renderComponent,
49
42
  renderSsr,
@@ -52,14 +45,15 @@ import {
52
45
  streamResult,
53
46
  } from '@ultimat3/render/server';
54
47
  import type { DocumentOptions } from './document-options';
55
- import { realtimeIslandFiles } from './island-realtime';
56
48
  import {
57
49
  type NavigationDocumentHead,
58
50
  navigationMetaOf,
59
51
  navigationTagsOf,
60
52
  principalRelocation,
61
53
  } from './page-navigation';
54
+ import { bootScript, collectorFor } from './route-islands';
62
55
  import { attachedIsr } from './runtime-isr';
56
+ import { isrOutcome } from './runtime-isr-outcome';
63
57
  import type { StaticResult } from './static-document';
64
58
  import { createStaticMemo, staticMemoKey, staticResponse } from './static-document';
65
59
  import { styleBundle } from './style-bundle';
@@ -119,9 +113,11 @@ const headFor = async (
119
113
  seoRenderers({
120
114
  path: url.pathname,
121
115
  baseUrl: options.origin ?? url.origin,
116
+ // The default is the cluster's own head (`alternates` is default-first by contract), so
117
+ // `x-default` comes from the list the hreflang tags do — never a second locale read.
122
118
  localization: {
123
119
  locale: meta.locale,
124
- defaultLocale: localeConfig().fallback,
120
+ defaultLocale: meta.alternates[0]?.locale ?? meta.locale,
125
121
  alternates: meta.alternates,
126
122
  },
127
123
  }),
@@ -170,6 +166,10 @@ const styleTag = (entry: RouteEntry): string => {
170
166
  return href === undefined ? '' : `<link rel="stylesheet" href="${href}">`;
171
167
  };
172
168
 
169
+ /** The surface stylesheet, then the app's brand — the order that lets the brand win the cascade. */
170
+ const stylesFor = (entry: RouteEntry, options: DocumentOptions): string =>
171
+ styleTag(entry) + (options.brandHead ?? '');
172
+
173
173
  /**
174
174
  * The route's rendered body, inside the hydration root. Every mode goes through here — an empty
175
175
  * root is what a module exporting no component renders, and nothing else: `spa` was the one mode
@@ -201,46 +201,6 @@ export async function routeBody(
201
201
  return `<div id="${ROOT_ELEMENT_ID}">${html}</div>`;
202
202
  }
203
203
 
204
- /**
205
- * One collector per RENDER, never module-global: two requests render different params, and a
206
- * shared collector would bill one page for the other's islands. `hydrate` comes off the route, so
207
- * an island never declares its own timing, and `resolve` is the build's — identity when nothing
208
- * built any, which fails at the first island by name rather than emitting an unusable entry.
209
- */
210
- /**
211
- * Realtime's page boot, as one deferred script — or nothing. Two conditions, both exact: the
212
- * document carries a principal scope (restoring persisted records and replaying queued writes are
213
- * per principal; a shareable document has neither), AND one of the islands this render emitted
214
- * reaches `@ultimat3/realtime` (a page whose islands never touch a record has nothing to restore
215
- * into and no write to replay). After the body, because which islands rendered is a fact the walk
216
- * just recorded; still before the hydration runtime, so it runs first among the deferred scripts.
217
- */
218
- function bootScript(
219
- entry: RouteEntry,
220
- islands: IslandCollector,
221
- options: DocumentOptions,
222
- scope: string | undefined,
223
- ): string {
224
- if (scope === undefined || options.sync === undefined) return '';
225
- const rendered = new Set(islandModuleIds(islands.directives));
226
- if (rendered.size === 0) return '';
227
- // An island's module id is derived from its `src`, written relative to the page that renders it:
228
- // each realtime island file, spelled from THIS page, is the id its directive would carry.
229
- const pageDir = posix.dirname(entry.file);
230
- const reaches = [...realtimeIslandFiles()].some((file) => {
231
- const src = posix.relative(pageDir, file);
232
- return rendered.has(islandModuleId(src.startsWith('.') ? src : `./${src}`));
233
- });
234
- return reaches ? renderHead(clientBootTags(options.sync)) : '';
235
- }
236
-
237
- const collectorFor = (entry: RouteEntry, options: DocumentOptions): IslandCollector =>
238
- createIslandCollector({
239
- file: entry.file,
240
- hydrate: entry.config.hydrate,
241
- ...(options.resolveIsland === undefined ? {} : { resolve: options.resolveIsland(entry.file) }),
242
- });
243
-
244
204
  /**
245
205
  * Head + body for one route render. Exported because the build's prerenderer must emit the same
246
206
  * document `x dev` serves — two document builders is how a page that works in dev ships broken.
@@ -253,6 +213,42 @@ export async function routeDocument(
253
213
  return documentFrom(entry, ctx, await routeDataFor(entry.config, ctx), options);
254
214
  }
255
215
 
216
+ /**
217
+ * The document a request by `actor` is SERVED: `routeDocument` plus the scope the response would
218
+ * carry, which is what earns the page boot. The build weighs an app/ page through this, because a
219
+ * budget is a promise about what that page's browser downloads — and an unscoped render of a
220
+ * scoped page weighed a runtime chunk it never fetches and no boot it always does (#505).
221
+ */
222
+ export async function servedDocument(
223
+ entry: RouteEntry,
224
+ ctx: DevRouteData,
225
+ actor: Actor,
226
+ options: DocumentOptions & { readonly buildId: string },
227
+ ): Promise<string> {
228
+ const data = await routeDataFor(entry.config, ctx);
229
+ return documentFrom(entry, ctx, data, options, documentScope(entry, options.buildId, actor));
230
+ }
231
+
232
+ /**
233
+ * Whether, and as whom, a mode's response is scoped — ONE decision for the request path and for
234
+ * `servedDocument`. A stream is always `private, no-store`; an ssr page carries the scope exactly
235
+ * when the headers `renderSsr` sends are private (an ungated page is `public, s-maxage`, and a CDN
236
+ * may hand it to anyone); a static or isr document is shareable by construction.
237
+ */
238
+ function documentScope(entry: RouteEntry, buildId: string, actor: Actor): string | undefined {
239
+ switch (entry.config.render) {
240
+ case 'static':
241
+ case 'isr':
242
+ return undefined;
243
+ case 'stream':
244
+ return clientScopeOf(actor);
245
+ default:
246
+ return documentCarriesScope(ssrHeaders(entry, { buildId }))
247
+ ? clientScopeOf(actor)
248
+ : undefined;
249
+ }
250
+ }
251
+
256
252
  /**
257
253
  * The document from data ALREADY resolved. Split from `routeDocument` so one request resolves
258
254
  * `load` exactly once: `stream` renders head and body separately, and resolving in each would let
@@ -269,23 +265,23 @@ async function documentFrom(
269
265
  options: DocumentOptions,
270
266
  scope?: string,
271
267
  ): Promise<string> {
272
- const islands = collectorFor(entry, options);
268
+ const islands = collectorFor(entry, options, scope);
273
269
  const [head, body] = await Promise.all([
274
270
  headFor(entry, ctx, data, options, scope),
275
271
  routeBody(entry, ctx, data, islands),
276
272
  ]);
277
273
  return (
278
- `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head>` +
274
+ `<!doctype html><html lang="${lang()}"><head>${head}${stylesFor(entry, options)}</head>` +
279
275
  `<body>${body}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}</body></html>`
280
276
  );
281
277
  }
282
278
 
279
+ /** Every mode but `isr`, which `renderIsr` answers: its `load` runs only when the store misses. */
283
280
  async function resultFor(
284
281
  entry: RouteEntry,
285
282
  request: DevRouteData,
286
283
  data: RouteData,
287
284
  options: DevRenderOptions,
288
- isr: IsrController,
289
285
  ctx: Ctx,
290
286
  ): Promise<RenderResult> {
291
287
  const url = new URL(request.url);
@@ -299,57 +295,40 @@ async function resultFor(
299
295
  const body = await documentFrom(entry, request, data, options);
300
296
  return { status, headers: staticHeaders(contentHash(body), options.buildId), body };
301
297
  }
302
- case 'isr': {
303
- // `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — this
304
- // route's own `meta` reads `data.url` — so two URLs differing only in their query are two
305
- // documents. Keyed on the pathname alone, the first render answered every later query
306
- // string (#171). Render owns the derivation so no second caller can invent another.
307
- // The locale is the second dimension and it is `ctx.locale`, the answer the `locale` stage
308
- // already negotiated for THIS request — never `currentLocale()`, which would read the same
309
- // value through an ambient store the key does not need.
310
- // `{ html, status }`, never the bare string: the entry stores the status beside the HTML
311
- // and serves it on every hit, so a 404 under `isr` is a 404 for its whole TTL.
312
- const served = await isr.serve(isrKey(url, ctx.locale), async () => ({
313
- html: await documentFrom(entry, request, data, options),
314
- status,
315
- }));
316
- return served.result;
317
- }
318
298
  case 'stream': {
319
299
  // The shell IS the component: nothing can yet mark a subtree as a hole. Solid's `Suspense`
320
300
  // is not the missing piece and never will be here — it calls `getContextId()`, which throws
321
301
  // outside a Solid renderer, and this package's JSX factory is inert by design. A hole marker
322
302
  // has to be the framework's own. Until it exists the first flush carries the whole body —
323
303
  // correct output, no streaming benefit.
324
- const islands = collectorFor(entry, options);
304
+ // A stream is always `private, no-store` (`streamResult`), so it always carries the scope.
305
+ const scope = documentScope(entry, options.buildId, ctx.actor);
306
+ const islands = collectorFor(entry, options, scope);
325
307
  const [head, shell] = await Promise.all([
326
- // A stream is always `private, no-store` (`streamResult`), so it always carries the scope.
327
- headFor(entry, request, data, options, clientScopeOf(ctx.actor)),
308
+ headFor(entry, request, data, options, scope),
328
309
  routeBody(entry, request, data, islands),
329
310
  ]);
330
311
  return withScope(
331
312
  streamResult(
332
313
  {
333
- head: `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head><body>`,
314
+ head: `<!doctype html><html lang="${lang()}"><head>${head}${stylesFor(entry, options)}</head><body>`,
334
315
  // The runtime rides the first flush, with the shell it boots. A later chunk would leave
335
316
  // the window between flush one and the close with inert islands and no listeners on
336
317
  // them — which is exactly the first-click-lost failure `interaction` replay exists for.
337
- shell: `${shell}${bootScript(entry, islands, options, clientScopeOf(ctx.actor))}${hydrateRuntime(islands.directives)}`,
318
+ shell: `${shell}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}`,
338
319
  holes: [],
339
320
  },
340
321
  { buildId: options.buildId },
341
322
  status,
342
323
  ),
343
- clientScopeOf(ctx.actor),
324
+ scope,
344
325
  );
345
326
  }
346
327
  default: {
347
328
  // Asked of the headers `renderSsr` is about to send: a gated page is private and carries the
348
329
  // scope; an ungated one is `public, s-maxage` and a CDN may hand it to anyone, so it carries
349
330
  // none — absent, which core reads as "not rendered for anyone", never as anonymous.
350
- const scope = documentCarriesScope(ssrHeaders(entry, { buildId: options.buildId }))
351
- ? clientScopeOf(ctx.actor)
352
- : undefined;
331
+ const scope = documentScope(entry, options.buildId, ctx.actor);
353
332
  return withScope(
354
333
  await renderSsr(
355
334
  { entry, params: request.params, url, ctx },
@@ -429,7 +408,7 @@ export function appRoutes(options: DevRenderOptions): readonly Route[] {
429
408
  const memo = createStaticMemo();
430
409
  // A cold static key is ONE render however many requests arrive during it: the memo alone was
431
410
  // check-then-act, so a burst at boot ran `load` and the render once per request.
432
- const firstRender = createSingleFlight();
411
+ const firstRender = singleFlight();
433
412
  return routeEntries().map((registered) => ({
434
413
  method: 'GET' as const,
435
414
  path: registered.path,
@@ -482,6 +461,7 @@ async function renderEntry(
482
461
  isr: IsrController,
483
462
  ): Promise<Rendered> {
484
463
  const data: DevRouteData = { url: request.url.href, params: ctx.params };
464
+ if (entry.config.render === 'isr') return renderIsr(entry, data, ctx, options, isr);
485
465
  // ONCE per request, before the mode is chosen: every branch of `resultFor` reads this same
486
466
  // object, so a route's `load` runs exactly once however its mode splits head from body.
487
467
  const loaded = await routeDataFor(entry.config, data);
@@ -490,10 +470,25 @@ async function renderEntry(
490
470
  // 3xx because a rendered document has no `Location`; this is the path that has one.
491
471
  const to = takeRedirect(ctx);
492
472
  if (to !== undefined) return { respond: () => loadRedirect(entry, to) };
493
- const result = await resultFor(entry, data, loaded, options, isr, asCtx(ctx));
473
+ const result = await resultFor(entry, data, loaded, options, asCtx(ctx));
494
474
  if (entry.config.render !== 'static' || typeof result.body !== 'string') {
495
475
  return { respond: () => responseOf(result) };
496
476
  }
497
477
  const document: StaticResult = { ...result, body: result.body };
498
478
  return { document, respond: () => responseOf(document) };
499
479
  }
480
+
481
+ /** `isr`'s outcome (`runtime-isr-outcome.ts`) as this file's answer: a page, or the redirect. */
482
+ async function renderIsr(
483
+ entry: RouteEntry,
484
+ data: DevRouteData,
485
+ ctx: RequestContext,
486
+ options: DevRenderOptions,
487
+ isr: IsrController,
488
+ ): Promise<Rendered> {
489
+ const document = (loaded: RouteData): Promise<string> =>
490
+ documentFrom(entry, data, loaded, options);
491
+ const outcome = await isrOutcome({ entry, data, ctx, isr, document });
492
+ if (outcome.kind === 'redirect') return { respond: () => loadRedirect(entry, outcome.to) };
493
+ return { respond: () => responseOf(outcome.result) };
494
+ }
@@ -7,7 +7,7 @@
7
7
  // 1. `defaultClient()` is the one place db composes `replicatedClient(primary, replica)` from
8
8
  // `DATABASE_REPLICA_URL`, and it runs only from `baseClient()` — "the client an app installed
9
9
  // none for". Every process the framework boots installs one: `runtime-queue.ts` calls
10
- // `setDbClient(createPgliteClient(…) | createPostgresClient({ url }))`, so `defaultClient()` was
10
+ // `setDbClient(pgliteClient(…) | postgresClient({ url }))`, so `defaultClient()` was
11
11
  // unreachable from `x dev`, from `apps/web/server.ts` and from every container role.
12
12
  // 2. Routing needs an OPEN scope as well as a configured replica, and nothing opened one.
13
13
  //
@@ -17,12 +17,7 @@
17
17
  // what a database is.
18
18
 
19
19
  import type { DbClient, PostgresClient } from '@ultimat3/db';
20
- import {
21
- createPostgresClient,
22
- REPLICA_URL_ENV,
23
- replicatedClient,
24
- withReplicaReads,
25
- } from '@ultimat3/db';
20
+ import { postgresClient, REPLICA_URL_ENV, replicatedClient, withReplicaReads } from '@ultimat3/db';
26
21
  import type { Middleware } from '@ultimat3/http';
27
22
  import type { ServiceBinding } from './runtime-bindings';
28
23
  import type { RuntimeOverrides } from './runtime-overrides';
@@ -61,7 +56,7 @@ export function attachReplica(
61
56
  replicaUrl: string | undefined,
62
57
  ): ReplicaAttachment {
63
58
  if (replicaUrl === undefined) return { client: primary, replica: undefined };
64
- const replica = createPostgresClient({ url: replicaUrl, applicationName: 'ultimate-replica' });
59
+ const replica = postgresClient({ url: replicaUrl, applicationName: 'ultimate-replica' });
65
60
  return { client: replicatedClient(primary, replica), replica };
66
61
  }
67
62