@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
@@ -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
 
@@ -15,15 +15,15 @@ import {
15
15
  resolveEnvironment,
16
16
  systemClock,
17
17
  } from '@ultimat3/core';
18
+ import { dbExecutor } from '@ultimat3/db';
18
19
  import type { RateLimitStore } from '@ultimat3/http';
19
- import { postgresRateLimitStore } from '@ultimat3/http';
20
+ import { adoptRateLimitStore, postgresRateLimitStore } from '@ultimat3/http';
20
21
  import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
21
22
  import type { MailDriver } from '@ultimat3/mail';
22
23
  import {
23
24
  isMemoryDriver,
24
25
  isUnconfiguredDriver,
25
26
  resetMailDriver,
26
- selectMailDriver,
27
27
  setMailDriver,
28
28
  } from '@ultimat3/mail';
29
29
  import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
@@ -36,11 +36,12 @@ import { msg } from './messages';
36
36
  import type { DevServices, Env } from './runtime-bindings';
37
37
  import { cacheTiersOf, startCacheTiers } from './runtime-cache';
38
38
  import { type WorkerConfig, workerConfigOf } from './runtime-jobs';
39
+ import { selectAppMailDriver } from './runtime-mail';
39
40
  import { inboxRetentionOf } from './runtime-notify-retention';
40
41
  import type { RuntimeOverrides } from './runtime-overrides';
41
42
  import { installRetentionSweep } from './runtime-purge';
42
43
  import type { DevDbClient, SchemaMode } from './runtime-queue';
43
- import { pgExecutorFor, startQueue } from './runtime-queue';
44
+ import { startQueue } from './runtime-queue';
44
45
  import { realtimeConfigOf } from './runtime-realtime';
45
46
 
46
47
  export interface RunningServices {
@@ -57,7 +58,8 @@ export interface RunningServices {
57
58
  */
58
59
  readonly realtime: RealtimeConfig;
59
60
  /**
60
- * `app.config.ts`'s `jobs.queues` / `concurrency` / `visibilityTimeoutMs`, for the `worker` role.
61
+ * `app.config.ts`'s `jobs.queues` / `concurrency` / `visibilityTimeoutMs`, for the `worker` role —
62
+ * the queues replaced by the boot env's `WORKER_QUEUES` when it is set (`runtime-jobs.ts`).
61
63
  * Optional because a `RunningServices` can be hand-built, and a test runtime with none keeps the
62
64
  * worker's own defaults — the answer it always got. `startServices` always sets it.
63
65
  */
@@ -87,7 +89,7 @@ export interface RunningServices {
87
89
  * `roles.web.replicas: 3` and `x new` scaffolds two.
88
90
  *
89
91
  * OPTIONAL because a `RunningServices` can be hand-built: a test's fixture runtime has a stub
90
- * client and no shared store, which is `createServer`'s memory store and `scope: 'process'` —
92
+ * client and no shared store, which is `httpServer`'s memory store and `scope: 'process'` —
91
93
  * the honest answer for it, and the one `startWeb` derives.
92
94
  */
93
95
  readonly rateLimitStore?: RateLimitStore;
@@ -301,6 +303,20 @@ async function release(steps: readonly (() => void | Promise<void>)[]): Promise<
301
303
  return failures;
302
304
  }
303
305
 
306
+ /**
307
+ * Every STOP path's form of `release`: every step runs, newest first, and the FIRST failure is
308
+ * rethrown — it is the cause, the rest its consequences. The one helper for this process's stops
309
+ * (`startServices`, `startRoles`, `bootServing`, `x dev`): a chain of `await`s with no `try`
310
+ * skipped every step after one rejection, and the replicator rejects a refused close on purpose.
311
+ * Steps are listed in BOOT order, as an unwind list is.
312
+ */
313
+ export async function releaseOrThrow(
314
+ steps: readonly (() => void | Promise<void>)[],
315
+ ): Promise<void> {
316
+ const failures = await release(steps);
317
+ if (failures.length > 0) throw failures[0];
318
+ }
319
+
304
320
  export async function startServices(
305
321
  services: DevServices,
306
322
  env: Env,
@@ -308,10 +324,14 @@ export async function startServices(
308
324
  /** `'verify'` for a serving role on an external database — `runtime-queue.ts`'s `SchemaMode`. */
309
325
  schema: SchemaMode = 'apply',
310
326
  ): Promise<RunningServices> {
327
+ // The app's config is loaded ONCE, here, first, and every section below is read out of that one
328
+ // validated object — mail's `retainMime` is the first reader, so it is loaded before mail.
329
+ const config = await loadAppConfig(services.root);
311
330
  // Before the queue: selection is pure — it parses `SMTP_URL` and builds a transport, it does
312
331
  // not dial. A typo'd credential must fail on the spot rather than after PGlite has started and
313
332
  // been unwound again, and it must fail at boot rather than on the first mail nobody receives.
314
- const selection = selectMailDriver(env);
333
+ // `mail.retainMime` rides along (`runtime-mail.ts`), refused here the same way.
334
+ const selection = selectAppMailDriver(env, config);
315
335
  // Same reason, same place: building a purge driver reads env and dials nothing, so a half-set
316
336
  // `FASTLY_API_TOKEN` without its service id fails here rather than on the first stale page.
317
337
  const cdn = selectPurgeDriver(env);
@@ -320,11 +340,9 @@ export async function startServices(
320
340
  // service starts, rather than quietly keeping the in-process bus. Which transport, which KV
321
341
  // bucket and which presence TTL is `@ultimat3/realtime`'s decision, and it is the same call a
322
342
  // `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
323
- // container it stands in for. The app's config is loaded ONCE, here, and every section below is
324
- // read out of that one validated object.
325
- const config = await loadAppConfig(services.root);
343
+ // container it stands in for.
326
344
  const realtime = realtimeConfigOf(config);
327
- const workerConfig = workerConfigOf(config);
345
+ const workerConfig = workerConfigOf(config, env);
328
346
  const bus: TransportSelection = selectTransport(env, realtime);
329
347
  // `env`, not the ambient one: this function is HANDED the boot's environment and every other
330
348
  // reader here already uses it, so a queue that asked `process.env` would decide the standby from
@@ -334,7 +352,7 @@ export async function startServices(
334
352
  // The same executor the jobs driver, the outbox, the event bus and the idempotency store run
335
353
  // on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
336
354
  // `undefined`), one wrapper.
337
- const executor = pgExecutorFor(db);
355
+ const executor = dbExecutor(() => db);
338
356
  const rateLimitStore = postgresRateLimitStore({ executor });
339
357
  // Boot is a sequence of external resources, and every step after the first can reject — the
340
358
  // queue is already up, so from here an unwind must release it exactly like everything after it.
@@ -358,6 +376,10 @@ export async function startServices(
358
376
  postgresAuthLimiter({ executor, clock: systemClock, policy }),
359
377
  );
360
378
  started.push(() => resetAuthLimiters());
379
+ // Where an action's or a query's declared `rateLimit:` is counted — on every surface, so a
380
+ // task or a one-off command spends from the fleet's table too, not one process' memory. Before
381
+ // `loadApp` for the auth limiters' reason; `startRoles` stacks a deployment's override over it.
382
+ started.push(adoptRateLimitStore(rateLimitStore));
361
383
  // The hourly sweep over the framework tables this boot is responsible for. Every one of them
362
384
  // ships a `purgeExpired()` that nothing called, so every row written was a row kept —
363
385
  // `x_rate_limit` takes one upsert per request the web role serves, assets included.
@@ -407,8 +429,8 @@ export async function startServices(
407
429
  started.push(() => resetMailDriver());
408
430
  const purge = overrides?.purge ?? cdn.driver;
409
431
  // Every tier this process reads through, plus the cross-instance invalidation hop, in one
410
- // call. The CDN tier used to be the only one registered here — so `createRedisTier`,
411
- // `createLruTier` and `createMemoTier` shipped with no caller at all and every replica
432
+ // call. The CDN tier used to be the only one registered here — so `redisTier`,
433
+ // `lruTier` and `memoTier` shipped with no caller at all and every replica
412
434
  // recomputed every cached read. Released with `resetTiers()`, which drops the whole registry:
413
435
  // this boot is the only thing that registers one, and a tier left behind would purge for a
414
436
  // process that has stopped.
@@ -446,8 +468,7 @@ export async function startServices(
446
468
  // the FIRST failure is rethrown because it is the cause and the rest are its consequences,
447
469
  // and every step still runs, so a refused shutdown never leaks into the next boot.
448
470
  async stop() {
449
- const failures = await release(started);
450
- if (failures.length > 0) throw failures[0];
471
+ await releaseOrThrow(started);
451
472
  },
452
473
  };
453
474
  } catch (error) {
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { finiteCount, readWithinLimit } from '@ultimat3/core';
7
7
  import type { RequestContext, Route, UltimateRequest } from '@ultimat3/http';
8
- import { json, NO_STORE } from '@ultimat3/http';
8
+ import { jsonResponse, NO_STORE } from '@ultimat3/http';
9
9
  import type { Storage } from '@ultimat3/storage';
10
10
  import {
11
11
  acceptSignedUpload,
@@ -74,7 +74,7 @@ async function acceptUpload(
74
74
  : { allowedContentTypes: [constraints.contentType] }),
75
75
  }),
76
76
  });
77
- return json({ key: stored.key }, { status: 201 });
77
+ return jsonResponse({ key: stored.key }, { status: 201 });
78
78
  }
79
79
 
80
80
  /**
@@ -7,12 +7,12 @@
7
7
  // `localDriver` SIGNS `/_storage/<disk>/<key>`, so a local `'/_storage'` here is a second statement
8
8
  // of one constant — and a signer and a reader that disagree serve 404 for every signed URL.
9
9
 
10
- import { actorOf } from '@ultimat3/action';
11
10
  import type { Actor } from '@ultimat3/core';
11
+ import { actorOf } from '@ultimat3/core';
12
12
  import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
13
13
  import { asCtx, unauthenticated } from '@ultimat3/http';
14
14
  import type { KnownPermission } from '@ultimat3/policy';
15
- import { can, codeOf, evaluate, forbidden, reasonOf } from '@ultimat3/policy';
15
+ import { can, codeOf, evaluate, policyForbidden, reasonOf } from '@ultimat3/policy';
16
16
  import type { Storage, StorageRead } from '@ultimat3/storage';
17
17
  import {
18
18
  assertSafeKey,
@@ -88,7 +88,7 @@ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContex
88
88
  const reason = reasonOf(evaluation.decision) ?? 'denied';
89
89
  throw codeOf(evaluation.decision) === 'X_UNAUTHENTICATED'
90
90
  ? unauthenticated(ctx.url.pathname)
91
- : forbidden(policy.label, reason);
91
+ : policyForbidden(policy.label, reason);
92
92
  }
93
93
 
94
94
  /**
@@ -43,7 +43,7 @@ const RECORDED_ONLY = 'was recorded by the newest migration and no entity declar
43
43
  /** The difference's name — what `x db gen "record …"` is told to write down. */
44
44
  const REPLICA_IDENTITY_FULL = 'replica identity full';
45
45
  const REPLICA_IDENTITY_UNRECORDED =
46
- 'needs REPLICA IDENTITY FULL — a channel with params or a live query subscribes to it — and no ' +
46
+ 'needs REPLICA IDENTITY FULL — a channel with params routes deletes by its old row — and no ' +
47
47
  'migration recorded it';
48
48
 
49
49
  const columns = (names: readonly string[]): string => names.join(', ');
@@ -1,18 +1,19 @@
1
1
  // A master-key rotation in an order a crash cannot turn into data loss. The new key is STAGED at
2
- // `<key>.next` (0600) before anything is sealed with it, the committed file is sealed second, and
2
+ // `<key>.next` (owner-only: 0600, and an ACL on Windows) before anything is sealed with it, the committed file is sealed second, and
3
3
  // the rename that makes the new key live is last. Interrupted anywhere, some key on disk opens the
4
4
  // committed file — and `recoverRotation`, which every `x secrets` command runs first, finishes or
5
5
  // abandons the move by asking the file which key it answers to.
6
6
 
7
- import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; // why: the staged key is written and renamed synchronously at 0600, and Bun has no mode-setting write or rename.
7
+ import { existsSync, readFileSync, rmSync } from 'node:fs'; // why: Bun has no sync exists/remove; the key is WRITTEN and RENAMED only by core's owner-only writers.
8
8
  import type { MasterKeyRef, SecretValues } from '@ultimat3/core';
9
9
  import {
10
10
  generateMasterKey,
11
11
  isUltimateError,
12
12
  masterKeyPath,
13
+ promoteStagedMasterKey,
13
14
  readSecretsFile,
14
- SECRETS_KEY_MODE,
15
15
  stagedMasterKeyPath,
16
+ stageMasterKeyFile,
16
17
  writeSecretsFile,
17
18
  } from '@ultimat3/core';
18
19
 
@@ -24,14 +25,12 @@ const fileKey = (root: string, hex: string): MasterKeyRef => ({
24
25
 
25
26
  /** Stage, seal, then make live. Returns the key now in force. */
26
27
  export async function rotateMasterKey(root: string, values: SecretValues): Promise<MasterKeyRef> {
27
- const staged = stagedMasterKeyPath(root);
28
28
  const hex = generateMasterKey();
29
- rmSync(staged, { force: true });
30
- // `wx`: the mode applies only when a write CREATES the file, and a leftover could be 0644.
31
- writeFileSync(staged, `${hex}\n`, { encoding: 'utf-8', mode: SECRETS_KEY_MODE, flag: 'wx' });
29
+ // A fresh temp file renamed over any leftover: a staged key left 0644 is replaced, never reused.
30
+ stageMasterKeyFile(root, hex);
32
31
  const next = fileKey(root, hex);
33
32
  await writeSecretsFile(root, values, next);
34
- renameSync(staged, masterKeyPath(root));
33
+ promoteStagedMasterKey(root);
35
34
  return next;
36
35
  }
37
36
 
@@ -53,7 +52,7 @@ export async function recoverRotation(root: string, key: MasterKeyRef): Promise<
53
52
  // Throws the staged key's own mismatch when neither opens it — the committed file's problem,
54
53
  // which `X_SECRETS_KEY_MISMATCH`'s fix (restore it from git) answers.
55
54
  await readSecretsFile(root, candidate);
56
- renameSync(staged, masterKeyPath(root));
55
+ promoteStagedMasterKey(root);
57
56
  return candidate;
58
57
  }
59
58
  }
package/src/seo-routes.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from '@ultimat3/core';
6
6
  import type { Route, UltimateRequest } from '@ultimat3/http';
7
- import { applyCacheHeaders, json, NO_STORE } from '@ultimat3/http';
7
+ import { applyCacheHeaders, jsonResponse, NO_STORE } from '@ultimat3/http';
8
8
  import { SITEMAP_PARTS_DIR } from '@ultimat3/seo';
9
9
  import { quoteArg } from './shell-quote';
10
10
  import { NO_SITE_SETTINGS, publicOrigin, type SiteSettings } from './site-config';
@@ -125,7 +125,7 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
125
125
  );
126
126
  }
127
127
  const index = new URL(SITEMAP_PATH, originOf(options, request)).href;
128
- const missing = json(
128
+ const missing = jsonResponse(
129
129
  {
130
130
  ok: false,
131
131
  error: {
package/src/serve-boot.ts CHANGED
@@ -16,7 +16,7 @@ import { warnIfIdempotencyProcessScoped } from './runtime-idempotency-scope';
16
16
  import { warnUnsealedMfaSecrets } from './runtime-mfa-warning';
17
17
  import { replicaOverrides } from './runtime-replica';
18
18
  import type { RunningServices } from './runtime-services';
19
- import { startServices } from './runtime-services';
19
+ import { releaseOrThrow, startServices } from './runtime-services';
20
20
  import { loadDrainConfig, loadHealthConfig } from './serve-drain';
21
21
  import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
22
22
  import { adoptPrebuiltStyles, reportBootBuilds, watchBootBuilds } from './serve-prebuilt';
@@ -49,16 +49,22 @@ const LISTENING_ROLES: readonly Role[] = ['web', 'sync'];
49
49
 
50
50
  /**
51
51
  * `app.config.ts`'s `drain`, applied to the WHOLE process rather than only to the web server that
52
- * `createServer` hands it to. A role nothing routes to gets no readiness grace at all: the grace
52
+ * `httpServer` hands it to. A role nothing routes to gets no readiness grace at all: the grace
53
53
  * holds `/readyz` at 503 with the listener open so the endpoints catch up, and a worker has neither
54
54
  * — it only went on claiming jobs for those seconds, then aborted them at the drain (s1-con #7).
55
+ *
56
+ * The budget (`deadlineMs`) is every role's: it is the time a worker's running job has to finish
57
+ * on a deploy, so it is the one key here a non-listening role needs MOST.
55
58
  */
56
59
  export function lifecycleForRole(
57
60
  role: Role,
58
61
  drain: Partial<DrainConfig> | undefined,
59
62
  ): LifecycleOptions {
60
- if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0 };
61
- return drain?.readinessGraceMs === undefined ? {} : { readinessGraceMs: drain.readinessGraceMs };
63
+ const budget = drain?.deadlineMs === undefined ? {} : { deadlineMs: drain.deadlineMs };
64
+ if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0, ...budget };
65
+ return drain?.readinessGraceMs === undefined
66
+ ? budget
67
+ : { readinessGraceMs: drain.readinessGraceMs, ...budget };
62
68
  }
63
69
 
64
70
  /**
@@ -181,13 +187,18 @@ async function bootRoles(boot: {
181
187
  running,
182
188
  runtime,
183
189
  async stop() {
184
- await running.stop();
185
- // After the server drained: a bust landing mid-drain still marks the pages it is serving.
186
- web?.release();
187
- await runtime.stop();
188
- // Last: the exporters outlive the roles they were recording, so the drain's own spans and
189
- // the final counter snapshot still have somewhere to go.
190
- stopOtlp();
190
+ // Every step runs and the first failure is rethrown (`releaseOrThrow`): a refused role stop
191
+ // used to leave the services, the PGlite lock and the exporter behind it. Listed in boot
192
+ // order, released newest first — the roles; then the web surface, after the server drained
193
+ // (a bust landing mid-drain still marks the pages it is serving); then the services; LAST
194
+ // the exporter, so the drain's own spans and the final counter snapshot still have
195
+ // somewhere to go.
196
+ await releaseOrThrow([
197
+ stopOtlp,
198
+ () => runtime.stop(),
199
+ () => web?.release(),
200
+ () => running.stop(),
201
+ ]);
191
202
  },
192
203
  };
193
204
  }
@@ -7,7 +7,9 @@ import { loadAppConfig } from './app-config-load';
7
7
  /** The declared section, or `undefined` for a root with no config file — core keeps its default. */
8
8
  export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig> | undefined> {
9
9
  const drain = (await loadAppConfig(root))?.drain;
10
- return drain === undefined ? undefined : { readinessGraceMs: drain.readinessGraceMs };
10
+ return drain === undefined
11
+ ? undefined
12
+ : { readinessGraceMs: drain.readinessGraceMs, deadlineMs: drain.deadlineMs };
11
13
  }
12
14
 
13
15
  /** `health.readiness`, or `undefined` for a root with no config file (core keeps its default). */
@@ -0,0 +1,95 @@
1
+ // `ROLE=worker`'s retire, on SIGUSR2 (issue #669): stop claiming, finish every held job however
2
+ // long it takes, abort nothing — then the ordinary drain, so the process exits 0. It is what a
3
+ // Kubernetes `preStop` sends before the SIGTERM a rollout ends in, for a job whose side effect may
4
+ // happen at most once and may outlast `drain.deadlineMs`. Framework-owned: no port, no app code.
5
+
6
+ import { drain, logger, renderThrowable } from '@ultimat3/core';
7
+ import type { Worker } from '@ultimat3/jobs';
8
+
9
+ /** The signal a worker retires on. Free in Bun and Node (SIGUSR1 is the inspector's). */
10
+ export const RETIRE_SIGNAL = 'SIGUSR2';
11
+
12
+ /** The two calls a retire makes on the worker. */
13
+ export type RetiringWorker = Pick<Worker, 'stats' | 'stop'>;
14
+
15
+ /** Where the signal is heard — `process` by default, a hand-raised source in a test. */
16
+ export interface RetireSignals {
17
+ on(signal: typeof RETIRE_SIGNAL, listener: () => void): void;
18
+ off(signal: typeof RETIRE_SIGNAL, listener: () => void): void;
19
+ }
20
+
21
+ export interface WorkerRetireOptions {
22
+ /** Default `process.platform`. `win32` installs nothing: Windows has no SIGUSR2. */
23
+ readonly platform?: string;
24
+ readonly signals?: RetireSignals;
25
+ /** What the retire ends in. Default core's `drain` — the hold then releases and exits 0. */
26
+ readonly drain?: (signal: string) => Promise<void>;
27
+ }
28
+
29
+ export interface WorkerRetire {
30
+ /** The worker the boot started (`null` when it started none) — a signal before this waits. */
31
+ adopt(worker: RetiringWorker | null): void;
32
+ /** Give the listener back: a boot that failed, or a hold that is over. */
33
+ disarm(): void;
34
+ }
35
+
36
+ const NOTHING: WorkerRetire = Object.freeze({ adopt: () => undefined, disarm: () => undefined });
37
+
38
+ /**
39
+ * Listen for `RETIRE_SIGNAL` NOW, before the boot: Bun's default for an unhandled SIGUSR2 ends the
40
+ * process, so a `preStop` that lands while the pod still boots must meet this listener, which waits
41
+ * for the worker and then retires it. One retire per process; a second signal joins it.
42
+ *
43
+ * Exits rather than holds, decided 2026-10-07: the `preStop` has no channel back but PID 1 itself,
44
+ * so "retired" has to be observable as "PID 1 is gone" — and a pod that has stopped claiming serves
45
+ * nothing it could keep holding for. A SIGTERM landing mid-retire is the ordinary drain: it binds
46
+ * the retire to `drain.deadlineMs` and cuts off at the margin (`packages/jobs/src/worker.ts`).
47
+ */
48
+ export function armWorkerRetire(options: WorkerRetireOptions = {}): WorkerRetire {
49
+ if ((options.platform ?? process.platform) === 'win32') return NOTHING;
50
+ const signals: RetireSignals = options.signals ?? process;
51
+ const finish = options.drain ?? drain;
52
+ let adopt: (worker: RetiringWorker | null) => void = () => undefined;
53
+ const adopted = new Promise<RetiringWorker | null>((resolve) => {
54
+ adopt = resolve;
55
+ });
56
+ let retiring = false;
57
+
58
+ const retire = async (): Promise<void> => {
59
+ const worker = await adopted;
60
+ try {
61
+ if (worker !== null) {
62
+ const before = await worker.stats();
63
+ logger.info('jobs.worker.retiring', {
64
+ signal: RETIRE_SIGNAL,
65
+ workerId: before.workerId,
66
+ inFlight: before.inFlight,
67
+ });
68
+ await worker.stop(RETIRE_SIGNAL);
69
+ const after = await worker.stats();
70
+ logger.info('jobs.worker.retired', {
71
+ signal: RETIRE_SIGNAL,
72
+ workerId: after.workerId,
73
+ state: after.state,
74
+ inFlight: after.inFlight,
75
+ });
76
+ }
77
+ } catch (error) {
78
+ logger.warn('jobs.worker.retire-failed', { error: renderThrowable(error) });
79
+ } finally {
80
+ await finish(RETIRE_SIGNAL);
81
+ }
82
+ };
83
+
84
+ const listener = (): void => {
85
+ if (retiring) return;
86
+ retiring = true;
87
+ // Never rejects: `retire` catches its own failure and always reaches the drain.
88
+ void retire();
89
+ };
90
+ signals.on(RETIRE_SIGNAL, listener);
91
+ return {
92
+ adopt: (worker) => adopt(worker),
93
+ disarm: () => signals.off(RETIRE_SIGNAL, listener),
94
+ };
95
+ }
package/src/serve-web.ts CHANGED
@@ -4,16 +4,18 @@
4
4
  // spared: render's server half, the PWA, SEO, MCP and island subsystems.
5
5
 
6
6
  import type { Route } from '@ultimat3/http';
7
- import { describeRoutes } from '@ultimat3/render';
7
+ import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
8
+
9
+ import { describePages } from '@ultimat3/render';
8
10
  import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
9
11
  import { loadSignInPath } from './app-auth';
10
12
  import { mountAppMcp } from './app-mcp';
11
- import { errorPageStyleSources } from './error-page-csp';
13
+ import { errorPageStyleBodies } from './error-page-csp';
12
14
  import { islandRoutes } from './island-routes';
13
15
  import type { LoadedIslands } from './island-store';
14
16
  import { loadOrBuildIslands } from './island-store';
15
17
  import { loadNavigation, pageNavigation } from './page-navigation';
16
- import { loadSpeculation, pageSpeculation } from './page-speculation';
18
+ import { loadSpeculation, otherLocaleSegments, pageSpeculation } from './page-speculation';
17
19
  import { pageSync } from './page-sync';
18
20
  import { loadPwaArtifacts } from './pwa-artifacts';
19
21
  import { adminMountRoutes } from './runtime-admin';
@@ -30,13 +32,14 @@ import { styleRoutes } from './style-routes';
30
32
  import { serviceWorkerArtifacts } from './sw-artifacts';
31
33
  import { serviceWorkerRoutes } from './sw-routes';
32
34
  import { loadThemeMode, themeBoot } from './theme-boot';
35
+ import { loadThemeBrand } from './theme-brand';
33
36
 
34
37
  /** Everything `startRoles` is handed that only a process serving documents has. */
35
38
  export interface WebSurface {
36
39
  readonly routes: readonly Route[];
37
40
  /** The theme boot and the speculation rules: the two inline bodies every document may carry. */
38
41
  readonly inlineScripts: readonly string[];
39
- /** The app's own error pages' `<style>` bodies, which the enforced policy must admit. */
42
+ /** The app's error pages' and its brand's `<style>` bodies, which the enforced policy admits. */
40
43
  readonly inlineStyles: readonly string[];
41
44
  /** Where a browser that opened a guarded page is sent — the declaration `x dev` reads. */
42
45
  readonly signInPath: string | null;
@@ -60,6 +63,8 @@ export async function webSurface(
60
63
  // prevent, and it is the one an operator cannot see without installing the app.
61
64
  const pwa = await loadPwaArtifacts(options.root);
62
65
  const theme = themeBoot(await loadThemeMode(options.root));
66
+ // The app's brand (`apps/web/shared/theme.ts`), or nothing: the same reader `x dev` calls.
67
+ const brand = await loadThemeBrand(options.root);
63
68
  // `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
64
69
  const site = await loadSiteSettings(options.root);
65
70
  const origin = publicOrigin(options.env, site);
@@ -73,8 +78,9 @@ export async function webSurface(
73
78
  const speculation = pageSpeculation({
74
79
  config: await loadSpeculation(options.root),
75
80
  client: declared.surfaces,
81
+ localeSegments: otherLocaleSegments(await appLocaleSet(options.root)),
76
82
  });
77
- // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
83
+ // The worker, from the SAME route table this process is about to serve — `describePages()` is
78
84
  // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
79
85
  // route added here cannot be missing from the precache manifest.
80
86
  const serviceWorker =
@@ -83,7 +89,7 @@ export async function webSurface(
83
89
  : serviceWorkerArtifacts({
84
90
  pwa,
85
91
  buildId,
86
- routes: describeRoutes(),
92
+ routes: describePages(),
87
93
  islands,
88
94
  styles: styleBundle(),
89
95
  scripts: [
@@ -135,6 +141,7 @@ export async function webSurface(
135
141
  ...adminMountRoutes({
136
142
  buildId: buildId,
137
143
  themeHead: theme.head,
144
+ ...(brand === undefined ? {} : { brandHead: brand.head }),
138
145
  ...(origin === undefined ? {} : { origin }),
139
146
  }),
140
147
  ...appRoutes({
@@ -144,6 +151,7 @@ export async function webSurface(
144
151
  persisted: sync.persisted,
145
152
  ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
146
153
  themeHead: theme.head,
154
+ ...(brand === undefined ? {} : { brandHead: brand.head }),
147
155
  ...(speculation === undefined ? {} : { speculationHead: speculation.head }),
148
156
  // A `static` page is the same bytes for every request of this process: rendered once.
149
157
  memoStatic: true,
@@ -158,8 +166,11 @@ export async function webSurface(
158
166
  routes,
159
167
  inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
160
168
  // The enforced policy this process sends must admit the app's own error pages' `<style>` and
161
- // the theme boot the documents carry; `x dev` is report-only, so only here was it a blank page.
162
- inlineStyles: await errorPageStyleSources(options.root),
169
+ // the brand's; `x dev` is report-only, so only here was a missing hash an unstyled page.
170
+ inlineStyles: [
171
+ ...(await errorPageStyleBodies(options.root)),
172
+ ...(brand === undefined ? [] : [brand.style]),
173
+ ],
163
174
  // Same declaration `x dev` reads. Without it a container answers a browser that opened a
164
175
  // guarded page with the problem document, rendered as raw JSON in the viewport.
165
176
  signInPath: await loadSignInPath(options.root),
package/src/serve.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  // interface — every one by default, because a container is reached through a port mapping.
6
6
 
7
7
  import { assertNoDevSecretsOutsideLocal, logger } from '@ultimat3/core';
8
- import { assertNoDrift, checkDrift, migrate } from '@ultimat3/db';
8
+ import { assertNoSchemaDrift, checkDrift, migrate } from '@ultimat3/db';
9
9
  import { loadAppRuntime } from './app-runtime';
10
10
  import { acceptCreatedTables } from './db-accept-created';
11
- import { holdUntilShutdown } from './hold';
11
+ import { type BootedHold, holdWhileBooting } from './hold';
12
12
  import { startMetricsEndpoint } from './metrics-endpoint';
13
13
  import { readMigrations } from './migrations';
14
14
  import { resolveServices } from './runtime-bindings';
@@ -174,19 +174,36 @@ export async function runRole(input: ServeOptions): Promise<StartedApp> {
174
174
  // returned. `x db migrate` calls the same `runMigrations` and renders every difference as a
175
175
  // finding before exiting non-zero; a container that logged one and exited 0 would let the
176
176
  // deploy roll on over a schema nobody can reconstruct, which is the failure drift exists for.
177
- assertNoDrift(migrated.drift);
177
+ assertNoSchemaDrift(migrated.drift);
178
178
  return migrated;
179
179
  }
180
- const app = await serveApp({ ...options, role });
181
- logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
180
+ // The drain's handlers BEFORE the boot (`holdWhileBooting`): the worker claims inside
181
+ // `startRoles`, so a SIGTERM during boot has to be a drain of what started, never Bun's kill.
182
182
  // `exit` because this is the one entry point with nothing above it: `bin.ts` ends in
183
183
  // `process.exit(code)` and `apps/web/server.ts` — which is what awaits this — does not. One
184
184
  // non-unref'd interval anywhere in the app then holds an event loop that has nothing left to do,
185
185
  // until `terminationGracePeriodSeconds` runs out and the kubelet SIGKILLs a drained process.
186
- await holdUntilShutdown('serve', () => app.stop(), {
187
- exit: (code) => {
188
- process.exit(code);
189
- },
190
- })();
186
+ //
187
+ // A worker also retires on SIGUSR2 (`serve-retire.ts`) — armed before the boot for the same
188
+ // reason: Bun's default for an unhandled SIGUSR2 ends the process, and a `preStop` may land
189
+ // while the pod still boots. The retire ends in the same drain, so the same hold exits 0.
190
+ // Behind `await import()`, as the serving graph is: `ROLE=migrate` never loads it.
191
+ const retire = role === 'worker' ? (await import('./serve-retire')).armWorkerRetire() : undefined;
192
+ let booted: BootedHold<ServedApp>;
193
+ try {
194
+ booted = await holdWhileBooting('serve', () => serveApp({ ...options, role }), {
195
+ exit: (code) => {
196
+ retire?.disarm();
197
+ process.exit(code);
198
+ },
199
+ });
200
+ } catch (error) {
201
+ retire?.disarm();
202
+ throw error;
203
+ }
204
+ const { app, hold } = booted;
205
+ retire?.adopt(app.running.worker);
206
+ logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
207
+ await hold();
191
208
  return app;
192
209
  }
@@ -27,9 +27,12 @@ export interface BarrelImport {
27
27
  readonly entry: string;
28
28
  }
29
29
 
30
- /** `import` / `export … from` / `import()` of a VALUE from the barrel. `import type` runs nothing. */
30
+ /**
31
+ * `import` / `export … from` / `import()` of a VALUE from the barrel, and the side-effect
32
+ * `import '…'` that binds nothing yet evaluates the whole module. `import type` runs nothing.
33
+ */
31
34
  const IMPORTS =
32
- /\b(?:import|export)\s+(?!type\s)[^;'"]*?\bfrom\s*(['"])([^'"]+)\1|\bimport\s*\(\s*(['"])([^'"]+)\3\s*\)/g;
35
+ /\b(?:import|export)\s+(?!type\s)[^;'"]*?\bfrom\s*(['"])([^'"]+)\1|\bimport\s*\(\s*(['"])([^'"]+)\3\s*\)|\bimport\s*(['"])([^'"]+)\5/g;
33
36
 
34
37
  export function barrelImports(
35
38
  source: string,
@@ -41,7 +44,7 @@ export function barrelImports(
41
44
  const lineAt = lineIndex(code);
42
45
  const found: BarrelImport[] = [];
43
46
  for (const match of code.matchAll(IMPORTS)) {
44
- const spec = match[2] ?? match[4] ?? '';
47
+ const spec = match[2] ?? match[4] ?? match[6] ?? '';
45
48
  const entry = barrels.get(spec);
46
49
  if (entry === undefined) continue;
47
50
  // `import { type A, type B } from …` binds nothing at runtime either.