@ultimat3/cli 27.6.1 → 27.8.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.
@@ -8,9 +8,11 @@ import type { CacheTier, PurgeDriver } from '@ultimat3/cache';
8
8
  import {
9
9
  CacheDriverUnavailableError,
10
10
  cdnTier,
11
+ flushProcessTiers,
11
12
  isNoopPurgeDriver,
12
13
  lruTier,
13
14
  memoTier,
15
+ purgeEdgeAgain,
14
16
  receiveInvalidationBroadcast,
15
17
  redisTier,
16
18
  registerInvalidationBroadcast,
@@ -18,7 +20,13 @@ import {
18
20
  resetTiers,
19
21
  } from '@ultimat3/cache';
20
22
  import type { AppConfig, CacheTierName } from '@ultimat3/core';
21
- import { backoffDelay, defineConfig, logger, renderThrowable } from '@ultimat3/core';
23
+ import {
24
+ backoffDelay,
25
+ defineConfig,
26
+ isOutageMilestone,
27
+ logger,
28
+ renderThrowable,
29
+ } from '@ultimat3/core';
22
30
  import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
23
31
  import type { Env } from './runtime-bindings';
24
32
 
@@ -29,6 +37,19 @@ import type { Env } from './runtime-bindings';
29
37
  */
30
38
  export const CACHE_INVALIDATE_SUBJECT = 'x.cache.invalidate';
31
39
 
40
+ /**
41
+ * "Drop everything you hold in process", on the same subject. An object, where a bust is an array
42
+ * of wire tags: a replica from before this marker existed parses it, finds no array and ignores
43
+ * it — its entries wait for their TTL, which is what a lost bust always cost it.
44
+ */
45
+ export const CACHE_FLUSH_ALL = '{"flush":"all"}';
46
+
47
+ /**
48
+ * How many refused wire tags a process keeps for the bus's return. A bound, not a queue: past it
49
+ * the set is dropped and ONE flush-all is owed instead, so memory does not grow with the outage.
50
+ */
51
+ export const MAX_DEFERRED_TAGS = 1_024;
52
+
32
53
  export interface CacheTiersOptions {
33
54
  readonly env: Env;
34
55
  /** Already resolved by the boot — a `cdn` rung is built against a real edge or not at all. */
@@ -152,8 +173,92 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
152
173
  for (const tier of built) registerTier(tier);
153
174
  warnUnnamed(options);
154
175
 
176
+ const send = (payload: string): Promise<void> =>
177
+ options.transport.publish(CACHE_INVALIDATE_SUBJECT, payload);
178
+ // What the bus refused, kept for its return. A publish with no live connection is refused, not
179
+ // buffered (`NatsTransport`), so without this a bust made during a two-second NATS restart
180
+ // reached no peer at all. De-duplicated — a tag is a fact, not an event — and bounded.
181
+ const deferred = new Set<string>();
182
+ let flushOwed = false;
183
+ const defer = (wireTags: readonly string[]): void => {
184
+ if (flushOwed) return;
185
+ for (const wire of wireTags) deferred.add(wire);
186
+ if (deferred.size <= MAX_DEFERRED_TAGS) return;
187
+ deferred.clear();
188
+ flushOwed = true;
189
+ };
190
+ /**
191
+ * One pass over what is owed. It is TAKEN before the publish and put back if the bus refuses, so
192
+ * a bust refused while this publish is in flight is owed afresh rather than cleared by a success
193
+ * that never carried it — and a flush-all sent before that bust cannot stand in for it.
194
+ */
195
+ const settleOnce = async (): Promise<void> => {
196
+ if (flushOwed) {
197
+ flushOwed = false;
198
+ try {
199
+ await send(CACHE_FLUSH_ALL);
200
+ } catch (error) {
201
+ deferred.clear();
202
+ flushOwed = true;
203
+ throw error;
204
+ }
205
+ return;
206
+ }
207
+ if (deferred.size === 0) return;
208
+ const batch = [...deferred];
209
+ deferred.clear();
210
+ try {
211
+ await send(JSON.stringify(batch));
212
+ } catch (error) {
213
+ defer(batch);
214
+ throw error;
215
+ }
216
+ // The edge AGAIN, now that the peers are finally being told. It was purged when the bust ran,
217
+ // but every peer still held its copy then, and each request one of them answered since handed
218
+ // the edge the old page back — with no purge left to come. (Each peer purges after its own
219
+ // delete too; this is the sender's half, and does not depend on hearing its own publish.)
220
+ await purgeEdgeAgain(batch);
221
+ };
222
+ // ONE settle at a time. It is asked for by every reconnect and after every accepted publish,
223
+ // and two that overlapped both published the same batch (and the same flush-all). A call that
224
+ // arrives mid-run asks for one more pass instead, which finds whatever the run did not carry.
225
+ let settling: Promise<void> | undefined;
226
+ let again = false;
227
+ const settle = (): Promise<void> => {
228
+ if (settling !== undefined) {
229
+ again = true;
230
+ return settling;
231
+ }
232
+ settling = (async (): Promise<void> => {
233
+ try {
234
+ do {
235
+ again = false;
236
+ await settleOnce();
237
+ } while (again);
238
+ } finally {
239
+ again = false;
240
+ settling = undefined;
241
+ }
242
+ })();
243
+ return settling;
244
+ };
245
+ const settleQuietly = (): void => {
246
+ void settle().catch((error: unknown) => {
247
+ logger.warn('cache.broadcast.deferred-failed', { error: broadcastErrorText(error) });
248
+ });
249
+ };
250
+
155
251
  registerInvalidationBroadcast(async (wireTags) => {
156
- await options.transport.publish(CACHE_INVALIDATE_SUBJECT, JSON.stringify(wireTags));
252
+ try {
253
+ await send(JSON.stringify(wireTags));
254
+ } catch (error) {
255
+ defer(wireTags);
256
+ // Still thrown: the report must say the peers have not been told YET.
257
+ throw error;
258
+ }
259
+ // The bus took this one, so it takes what an earlier publish could not — the case no
260
+ // reconnect announces, a refusal with the connection up.
261
+ if (flushOwed || deferred.size > 0) settleQuietly();
157
262
  });
158
263
  // Not awaited HERE: the boot must not block on a subscribe. The PROMISE is held rather than a
159
264
  // handle assigned inside a `.then`, because the release ran first whenever `stop()` beat the round
@@ -162,28 +267,60 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
162
267
  // logged once and never asked again, so the process missed every peer invalidation for its life.
163
268
  const released = Promise.withResolvers<undefined>();
164
269
  let stopped = false;
270
+ let subscribed = false;
271
+ /** Cuts the retry's wait short: the bus coming up is a better signal than a backoff. */
272
+ let wake: () => void = () => undefined;
165
273
  const retryMs = options.subscribeRetryMs ?? defaultSubscribeRetryMs;
274
+ // This process heard no peer for a window nobody measured — since boot, or since the connection
275
+ // dropped — so nothing it holds in its own heap is known fresh. One cold cache, never a stale one.
276
+ const flushDeaf = (source: string): void => {
277
+ void flushProcessTiers(source);
278
+ };
166
279
  const subscribing = (async (): Promise<TransportSubscription | undefined> => {
167
280
  for (let attempt = 1; !stopped; attempt += 1) {
168
281
  try {
169
- return await options.transport.subscribe(CACHE_INVALIDATE_SUBJECT, (payload: string) => {
170
- void applyBroadcast(payload);
171
- });
282
+ const subscription = await options.transport.subscribe(
283
+ CACHE_INVALIDATE_SUBJECT,
284
+ (payload: string) => {
285
+ void applyBroadcast(payload);
286
+ },
287
+ );
288
+ subscribed = true;
289
+ if (attempt > 1) {
290
+ // The outage's one closing line, beside the thinned warnings it ends.
291
+ logger.info('cache.broadcast.subscribe-failed recovered', { after: attempt - 1 });
292
+ flushDeaf('cache.bus-subscribed');
293
+ }
294
+ return subscription;
172
295
  } catch (error) {
173
- logger.warn('cache.broadcast.subscribe-failed', {
174
- error: broadcastErrorText(error),
175
- attempt,
176
- });
177
- await Promise.race([Bun.sleep(retryMs(attempt)), released.promise]);
296
+ // Attempts 1, 2, 4, 8, …: the dial's own thinning (`NatsTransport`), so an outage is not
297
+ // one line per retry per pod for as long as it lasts.
298
+ if (isOutageMilestone(attempt)) {
299
+ logger.warn('cache.broadcast.subscribe-failed', {
300
+ error: broadcastErrorText(error),
301
+ attempt,
302
+ });
303
+ }
304
+ const woken = Promise.withResolvers<undefined>();
305
+ wake = (): void => woken.resolve(undefined);
306
+ await Promise.race([Bun.sleep(retryMs(attempt)), released.promise, woken.promise]);
178
307
  }
179
308
  }
180
309
  return undefined;
181
310
  })();
311
+ // The bus is (back) up. The subscription came back with the connection, so from here this
312
+ // process hears again: drop what it held while deaf, and say what it could not say.
313
+ const offReconnect = options.transport.onReconnect(() => {
314
+ wake();
315
+ if (subscribed) flushDeaf('cache.bus-reconnect');
316
+ settleQuietly();
317
+ });
182
318
 
183
319
  // `resetTiers()` drops the registry AND the broadcast in one call: this boot is the only thing
184
320
  // that registers either, and a tier left behind would purge for a process that has stopped.
185
321
  return async () => {
186
322
  stopped = true;
323
+ offReconnect();
187
324
  released.resolve(undefined);
188
325
  (await subscribing)?.unsubscribe();
189
326
  resetTiers();
@@ -200,6 +337,13 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
200
337
  */
201
338
  export const broadcastErrorText = (error: unknown): string => renderThrowable(error);
202
339
 
340
+ /** `CACHE_FLUSH_ALL`, parsed: a peer had more refused busts than it could keep. */
341
+ const isFlushAll = (parsed: unknown): boolean =>
342
+ typeof parsed === 'object' &&
343
+ parsed !== null &&
344
+ !Array.isArray(parsed) &&
345
+ Reflect.get(parsed, 'flush') === 'all';
346
+
203
347
  /**
204
348
  * A peer's wire tags, applied here. Never throws: a malformed frame or an undeclared tag must not
205
349
  * kill the subscriber loop, because that would silently end cross-instance invalidation for the
@@ -208,6 +352,10 @@ export const broadcastErrorText = (error: unknown): string => renderThrowable(er
208
352
  async function applyBroadcast(payload: string): Promise<void> {
209
353
  try {
210
354
  const parsed: unknown = JSON.parse(payload);
355
+ if (isFlushAll(parsed)) {
356
+ await flushProcessTiers('cache.broadcast');
357
+ return;
358
+ }
211
359
  if (!Array.isArray(parsed)) return;
212
360
  const wire = parsed.filter((value): value is string => typeof value === 'string');
213
361
  if (wire.length > 0) await receiveInvalidationBroadcast(wire);
@@ -2,25 +2,66 @@
2
2
  // decided. A returned discriminated outcome, never a throw: the controller's regeneration treats
3
3
  // anything thrown as a failure and logs it, and a redirect is an ordinary answer, not a failure.
4
4
 
5
+ import { logger } from '@ultimat3/core';
5
6
  import type { RedirectIntent, RequestContext, RouteParams } from '@ultimat3/http';
6
7
  import { asCtx, takeRedirect } from '@ultimat3/http';
7
8
  import type { RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
8
- import { routeDataFor, routeStatusOf } from '@ultimat3/render';
9
+ import { routeDataFor, routeNoStoreOf, routeStatusOf } from '@ultimat3/render';
9
10
  import type { IsrController, IsrEntry } from '@ultimat3/render/server';
10
- import { isrKey } from '@ultimat3/render/server';
11
+ import { isrKey, isrRequestUrl, undeclaredQuery } from '@ultimat3/render/server';
11
12
 
12
13
  export type IsrOutcome =
13
14
  | { readonly kind: 'page'; readonly result: RenderResult }
14
15
  | { readonly kind: 'redirect'; readonly to: RedirectIntent };
15
16
 
17
+ /** What `load` and `meta` are given; `url` is a string because that is what `ld.*` embeds. */
18
+ type IsrData = { readonly url: string; readonly params: RouteParams } & Record<string, unknown>;
19
+
16
20
  export interface IsrRequest {
17
21
  readonly entry: RouteEntry;
18
- /** What `load` and `meta` are given; `url` is a string because that is what `ld.*` embeds. */
19
- readonly data: { readonly url: string; readonly params: RouteParams } & Record<string, unknown>;
22
+ /** The request as it arrived. `load`, `meta` and the page get `keyedData`'s narrowing of it. */
23
+ readonly data: IsrData;
20
24
  readonly ctx: RequestContext;
21
25
  readonly isr: IsrController;
22
- /** The route's whole document for what `load` returned (`runtime-render.ts`'s `documentFrom`). */
23
- readonly document: (loaded: RouteData) => Promise<string>;
26
+ /**
27
+ * The route's whole document for what `load` returned (`runtime-render.ts`'s `documentFrom`),
28
+ * rendered AT the data this file hands it — the narrowed URL, never the request's own.
29
+ */
30
+ readonly document: (loaded: RouteData, at: IsrData) => Promise<string>;
31
+ }
32
+
33
+ /** Route files already told, once, about a request whose query they never declared. */
34
+ const toldUndeclared = new Set<string>();
35
+
36
+ /** Names are the visitor's: a few, cut short, is all a log line needs to name the edit. */
37
+ const NAMED_PARAMS = 5;
38
+ const NAMED_PARAM_CHARS = 40;
39
+
40
+ /**
41
+ * The request narrowed to what the route declared it varies on (`revalidate.query`). ONE narrowing
42
+ * feeds the store key, `load`, `meta` and the page, so a parameter outside the key cannot reach
43
+ * the document stored under it.
44
+ *
45
+ * A route that declared NOTHING still keys on the whole query, and is told so once, the first time
46
+ * a visitor uses that: every distinct query string is then a render and a stored page of its own.
47
+ */
48
+ function keyedData(entry: RouteEntry, data: IsrData): IsrData {
49
+ const url = new URL(data.url);
50
+ const declared = entry.config.revalidate?.query ?? null;
51
+ if (declared === null && !toldUndeclared.has(entry.file)) {
52
+ const minted = undeclaredQuery(url, declared);
53
+ if (minted.length > 0) {
54
+ toldUndeclared.add(entry.file);
55
+ logger.warn('isr.query.undeclared', {
56
+ route: entry.file,
57
+ params: minted.slice(0, NAMED_PARAMS).map((name) => name.slice(0, NAMED_PARAM_CHARS)),
58
+ cause:
59
+ 'the route declares no revalidate.query, so every distinct query string is rendered and stored as its own page',
60
+ fix: `revalidate: { query: [] } // in ${entry.file}; or query: ['<name>'] for each parameter the page varies on`,
61
+ });
62
+ }
63
+ }
64
+ return { ...data, url: isrRequestUrl(url, declared).href };
24
65
  }
25
66
 
26
67
  /**
@@ -54,11 +95,12 @@ function dropPage(isr: IsrController, key: string): void {
54
95
  * the request's own (its `load` may have read a cookie), so a request that joined a run whose load
55
96
  * redirected decides for itself, exactly as the static single-flight in `appRoutes` does.
56
97
  */
57
- export async function isrOutcome(request: IsrRequest): Promise<IsrOutcome> {
98
+ export async function isrOutcome(arrived: IsrRequest): Promise<IsrOutcome> {
99
+ const request: IsrRequest = { ...arrived, data: keyedData(arrived.entry, arrived.data) };
58
100
  const { entry, data, ctx, isr } = request;
59
- // `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — `meta`
60
- // reads `data.url` — so two URLs differing only in their query are two documents (#171). The
61
- // locale is `ctx.locale`, the one the `locale` stage negotiated for THIS request.
101
+ // `isrKey(url, locale)`, never `url.pathname`: the DECLARED query is part of what was rendered —
102
+ // `meta` reads `data.url` — so two URLs differing in it are two documents (#171). The locale is
103
+ // `ctx.locale`, the one the `locale` stage negotiated for THIS request.
62
104
  const key = isrKey(new URL(data.url), asCtx(ctx).locale);
63
105
  let decided: RedirectIntent | undefined;
64
106
  // `{ html, status }`, never the bare string: the entry stores the status beside the HTML and
@@ -66,8 +108,13 @@ export async function isrOutcome(request: IsrRequest): Promise<IsrOutcome> {
66
108
  const served = await isr.serve(key, async () => {
67
109
  const loaded = await routeDataFor(entry.config, data);
68
110
  const to = takeRedirect(ctx);
69
- if (to === undefined)
70
- return { html: await request.document(loaded), status: routeStatusOf(loaded) };
111
+ if (to === undefined) {
112
+ return {
113
+ html: await request.document(loaded, data),
114
+ status: routeStatusOf(loaded),
115
+ noStore: routeNoStoreOf(loaded),
116
+ };
117
+ }
71
118
  // Set by THIS request's producer only. On a stale page it runs behind the answer, and when it
72
119
  // has already decided by the time `serve` returns, the redirect is still this request's own.
73
120
  decided = to;
@@ -88,7 +135,7 @@ async function ownOutcome(request: IsrRequest): Promise<IsrOutcome> {
88
135
  const loaded = await routeDataFor(request.entry.config, request.data);
89
136
  const to = takeRedirect(request.ctx);
90
137
  if (to !== undefined) return { kind: 'redirect', to };
91
- const body = await request.document(loaded);
138
+ const body = await request.document(loaded, request.data);
92
139
  return {
93
140
  kind: 'page',
94
141
  result: {
@@ -2,6 +2,7 @@
2
2
  // and handed back with the detach the boot puts on its stop list. One function for `x dev` and the
3
3
  // container, so neither can build a controller `invalidateTags` never reaches (plan 101, s2-con #1).
4
4
 
5
+ import { describePages, RouteModeInvalidError } from '@ultimat3/render';
5
6
  import type { IsrController, IsrStore } from '@ultimat3/render/server';
6
7
  import { isrController } from '@ultimat3/render/server';
7
8
 
@@ -18,10 +19,16 @@ export interface AttachedIsr {
18
19
  export function attachedIsr(options: {
19
20
  readonly buildId: string;
20
21
  readonly store?: IsrStore;
22
+ /** `isrController`'s own; a test turns the cooldown off rather than waiting one out. */
23
+ readonly failureCooldownMs?: number;
21
24
  }): AttachedIsr {
25
+ if (options.store !== undefined) assertPurgeSafe(options.store);
22
26
  const isr = isrController({
23
27
  buildId: options.buildId,
24
28
  ...(options.store === undefined ? {} : { store: options.store }),
29
+ ...(options.failureCooldownMs === undefined
30
+ ? {}
31
+ : { failureCooldownMs: options.failureCooldownMs }),
25
32
  });
26
33
  const detach = isr.attach();
27
34
  let released = false;
@@ -34,3 +41,19 @@ export function attachedIsr(options: {
34
41
  },
35
42
  };
36
43
  }
44
+
45
+ /**
46
+ * A store the deployment supplied is one other processes may write. Under `onInvalidate: 'purge'`
47
+ * a replica that began a render before the purge, and has not heard of it yet, writes the purged
48
+ * page back unless the STORE refuses the write (`IsrStore.tagFence`) — its own fence only knows
49
+ * the busts it was told about. Refused here, by route, rather than left to happen in production.
50
+ */
51
+ function assertPurgeSafe(store: IsrStore): void {
52
+ if (store.tagFence !== undefined) return;
53
+ const purging = describePages().find((route) => route.revalidateOnInvalidate === 'purge');
54
+ if (purging === undefined) return;
55
+ throw new RouteModeInvalidError(
56
+ `${purging.file} declares revalidate.onInvalidate: 'purge', and the runtime's isrStore has no tagFence — a replica whose render began before a purge would write the purged page back into it`,
57
+ 'tagFence: { sample, bump, setIfCurrent } // implement IsrTagFence on the isrStore in apps/web/runtime.ts (setIfCurrent atomic in the store), or drop isrStore to keep one memory store per replica',
58
+ );
59
+ }
@@ -45,6 +45,7 @@ import {
45
45
  streamResult,
46
46
  } from '@ultimat3/render/server';
47
47
  import type { DocumentOptions } from './document-options';
48
+ import { routeQuery } from './isr-query-guard';
48
49
  import {
49
50
  type NavigationDocumentHead,
50
51
  navigationMetaOf,
@@ -193,7 +194,7 @@ export async function routeBody(
193
194
  data,
194
195
  params: ctx.params,
195
196
  url: ctx.url,
196
- query: Object.fromEntries(url.searchParams) as Readonly<Record<string, string>>,
197
+ query: routeQuery(entry, url),
197
198
  },
198
199
  entry.file,
199
200
  { islands },
@@ -486,8 +487,8 @@ async function renderIsr(
486
487
  options: DevRenderOptions,
487
488
  isr: IsrController,
488
489
  ): Promise<Rendered> {
489
- const document = (loaded: RouteData): Promise<string> =>
490
- documentFrom(entry, data, loaded, options);
490
+ const document = (loaded: RouteData, at: DevRouteData): Promise<string> =>
491
+ documentFrom(entry, at, loaded, options);
491
492
  const outcome = await isrOutcome({ entry, data, ctx, isr, document });
492
493
  if (outcome.kind === 'redirect') return { respond: () => loadRedirect(entry, outcome.to) };
493
494
  return { respond: () => responseOf(outcome.result) };
@@ -10,6 +10,7 @@ import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
10
10
  import {
11
11
  isLocal,
12
12
  type RealtimeConfig,
13
+ type Role,
13
14
  registerReadinessCheck,
14
15
  renderThrowable,
15
16
  resolveEnvironment,
@@ -26,14 +27,14 @@ import {
26
27
  resetMailDriver,
27
28
  setMailDriver,
28
29
  } from '@ultimat3/mail';
29
- import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
30
- import { selectTransport, setChannelTransport } from '@ultimat3/realtime/server';
30
+ import type { BusUse, Transport } from '@ultimat3/realtime/server';
31
31
  import type { Storage } from '@ultimat3/storage';
32
32
  import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
33
33
  import { loadAppConfig } from './app-config-load';
34
34
  import { LocalDiskUnsafeError, StorageUnwritableError } from './errors';
35
35
  import { msg } from './messages';
36
36
  import type { DevServices, Env } from './runtime-bindings';
37
+ import { selectBus } from './runtime-bus';
37
38
  import { cacheTiersOf, startCacheTiers } from './runtime-cache';
38
39
  import { type WorkerConfig, workerConfigOf } from './runtime-jobs';
39
40
  import { selectAppMailDriver } from './runtime-mail';
@@ -74,6 +75,13 @@ export interface RunningServices {
74
75
  readonly mailDetail: string;
75
76
  /** Same rule as `mailDetail`: the env key that selected the bus, never the url behind it. */
76
77
  readonly transportDetail: string;
78
+ /**
79
+ * What this process does with the bus, which decided whether the boot waited for it
80
+ * (`runtime-bus.ts`). Optional for the reason `workerConfig` is: a hand-built runtime has none.
81
+ */
82
+ readonly busUse?: BusUse;
83
+ /** `x dev` booted without a bus its `sync` role needs: that role waits for it (`role-sync.ts`). */
84
+ readonly busTolerated?: boolean;
77
85
  /**
78
86
  * What the sync role must give `PresenceRegistry`. It travels with the transport because the KV
79
87
  * bucket's age limit was derived from it — a registry given a longer TTL than the bucket honours
@@ -278,14 +286,6 @@ function probedReadinessCheck(name: string, probe: () => Promise<unknown>): () =
278
286
  });
279
287
  }
280
288
 
281
- /** A transport that says whether it is connected. NATS does; the in-process bus has nothing to be. */
282
- interface ConnectableTransport {
283
- readonly connected: boolean;
284
- }
285
-
286
- const saysConnected = (transport: Transport): transport is Transport & ConnectableTransport =>
287
- typeof (transport as Partial<ConnectableTransport>).connected === 'boolean';
288
-
289
289
  /**
290
290
  * Release what has already started, newest first, and return every failure instead of throwing on
291
291
  * the first: a step that rejects must not skip the ones after it, or one transport that will not
@@ -324,6 +324,13 @@ export async function startServices(
324
324
  overrides?: RuntimeOverrides,
325
325
  /** `'verify'` for a serving role on an external database — `runtime-queue.ts`'s `SchemaMode`. */
326
326
  schema: SchemaMode = 'apply',
327
+ /**
328
+ * The roles this process will start, which decide whether the boot waits for the bus: only a
329
+ * `sync` node or a replicator does (`runtime-bus.ts`). Unset is the strictest reading.
330
+ */
331
+ roles?: readonly Role[],
332
+ /** `x dev`: a bus the realtime roles need is waited for in the background, never refused. */
333
+ dev = false,
327
334
  ): Promise<RunningServices> {
328
335
  // The app's config is loaded ONCE, here, first, and every section below is read out of that one
329
336
  // validated object — mail's `retainMime` is the first reader, so it is loaded before mail.
@@ -341,13 +348,13 @@ export async function startServices(
341
348
  // service starts, rather than quietly keeping the in-process bus. Which transport, which KV
342
349
  // bucket and which presence TTL is `@ultimat3/realtime`'s decision, and it is the same call a
343
350
  // `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
344
- // container it stands in for.
351
+ // container it stands in for. What the bus is USED for is the roles' (`runtime-bus.ts`).
345
352
  const realtime = realtimeConfigOf(config);
346
353
  // Fourth: `pwa.push` and its VAPID pair (`runtime-push.ts`). Resolving the pair reads env and
347
354
  // signs one probe — a deploy with no keys, or two halves of different pairs, refuses here.
348
355
  const push = await selectWebPush(config, env);
349
356
  const workerConfig = workerConfigOf(config, env);
350
- const bus: TransportSelection = selectTransport(env, realtime);
357
+ const bus = selectBus({ env, realtime, roles, override: overrides?.transport, dev });
351
358
  // `env`, not the ambient one: this function is HANDED the boot's environment and every other
352
359
  // reader here already uses it, so a queue that asked `process.env` would decide the standby from
353
360
  // a different answer than the middleware that routes to it.
@@ -405,28 +412,14 @@ export async function startServices(
405
412
  // before `sync`, `worker` and `scheduler` start. The chart's `readinessProbe` and the container
406
413
  // healthcheck both route on it, so a replica whose pool was gone kept taking traffic.
407
414
  started.push(probedReadinessCheck('database', () => db.ping()));
408
- // Dialled here rather than at selection: an unreachable bus must fail at `x dev`, not on the
409
- // first change nobody receives, and the socket is a resource the unwind below has to release.
410
- // A supplied transport is already connected and is NOT closed here: whoever built it owns its
411
- // socket, which is why the override skips both halves rather than only the dial.
412
- let transport = overrides?.transport;
413
- if (transport === undefined) {
414
- await bus.connect();
415
- started.push(() => bus.transport.close());
416
- transport = bus.transport;
417
- }
418
- // The bus an app's own code publishes a channel event on (`publishChannelEvent`), in EVERY
419
- // role: the hub is the `sync` role's, so a job or an action reached none, and an events-only
420
- // channel — the one kind that needs no replicator — could be fed by nothing but a socket.
421
- started.push(setChannelTransport(transport));
422
- // Only a transport that can be disconnected gets a check. `NatsTransport.connected` is a
423
- // synchronous getter over the client's own state, so no probe is needed; the in-process bus
424
- // has nothing to lose a connection to, and a check that can only answer `true` is a number in
425
- // `registered` that means nothing.
426
- if (saysConnected(transport)) {
427
- const connectable = transport;
428
- started.push(registerReadinessCheck('transport', () => connectable.connected));
429
- }
415
+ // Dialled here rather than at selection: the socket is a resource the unwind below has to
416
+ // release. AWAITED only by a process that cannot work without the bus — a `sync` node, a
417
+ // replicator — where an unreachable one must fail the boot, not the first change nobody
418
+ // receives. Every other role dials in the background and boots regardless; the channel bus and
419
+ // the `transport` readiness check are installed either way (`runtime-bus.ts`).
420
+ const running = await bus.start();
421
+ started.push(() => running.stop());
422
+ const transport = running.transport;
430
423
  // Before `loadApp` for the auth limiters' reason: `pushSubscribe()` is declared when the app's
431
424
  // modules import, and reads this runtime per call.
432
425
  if (push !== undefined) started.push(await installAppWebPush(push, executor));
@@ -467,9 +460,9 @@ export async function startServices(
467
460
  // table: three readers of one executor, resolved once. `startWeb` is what an override
468
461
  // replaces it at.
469
462
  rateLimitStore,
470
- // The env key that selected the bus — or the honest answer that no env key did, because a
471
- // boot line reading `NATS_URL` over a transport the host handed in is a lie a script parses.
472
- transportDetail: overrides?.transport === undefined ? bus.detail : 'runtime override',
463
+ transportDetail: running.detail,
464
+ busUse: bus.use,
465
+ busTolerated: bus.tolerant,
473
466
  presenceTtlMs: bus.presenceTtlMs,
474
467
  purge,
475
468
  purgeDetail: cdn.detail,