bazis 0.99.5 → 0.100.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 (50) hide show
  1. package/cli/build.ts +171 -36
  2. package/cli/codegen.ts +155 -4
  3. package/cli/generateProject.ts +8 -2
  4. package/cli/main.ts +134 -51
  5. package/cli/naming.ts +3 -0
  6. package/cli/ui.ts +86 -0
  7. package/core/agent/index.ts +8 -0
  8. package/core/app/runApp.ts +4 -1
  9. package/core/di/extensions/hosted-service.ts +8 -0
  10. package/core/http/HttpServer.ts +33 -2
  11. package/core/http/Routing/RouterBuilder.ts +21 -0
  12. package/core/infra/Infra.ts +8 -2
  13. package/core/infra/InfraConnector.ts +13 -0
  14. package/core/infra/InfraLifecycle.ts +30 -0
  15. package/core/infra/connectorConfig.ts +10 -0
  16. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  17. package/core/infra/connectors/llm.ts +23 -2
  18. package/core/infra/connectors/openaiCompatible.ts +26 -1
  19. package/core/infra/connectors/opensearch.ts +41 -10
  20. package/core/infra/connectors/postgres.ts +35 -6
  21. package/core/infra/connectors/redis.ts +64 -8
  22. package/core/infra/index.ts +5 -5
  23. package/core/kernel/Bazis.ts +21 -1
  24. package/core/kernel/Kernel.ts +55 -1
  25. package/core/kernel/KernelBuilder.ts +39 -1
  26. package/core/kernel/LifecycleCoordinator.ts +105 -4
  27. package/core/kernel/SupervisedHostedService.ts +5 -0
  28. package/core/kernel/config/ConfigRegistry.ts +5 -0
  29. package/core/kernel/config/ConfigValidationError.ts +37 -0
  30. package/core/kernel/config/callerFile.ts +23 -0
  31. package/core/kernel/config/defineConfig.ts +21 -17
  32. package/core/kernel/errors/StartupFailuresError.ts +17 -0
  33. package/core/kernel/errors/StartupTimeoutError.ts +8 -5
  34. package/core/kernel/errors/index.ts +1 -0
  35. package/core/kernel/index.ts +12 -0
  36. package/core/kernel/report/StartupReport.ts +134 -0
  37. package/core/kernel/report/StartupScreen.ts +459 -0
  38. package/core/kernel/report/moduleTree.ts +97 -0
  39. package/core/kernel/report/startupProblems.ts +186 -0
  40. package/core/orm/OrmLifecycle.ts +29 -7
  41. package/core/orm/buildOrmProviders.ts +7 -3
  42. package/core/orm/databaseConnector.ts +17 -1
  43. package/core/orm/index.ts +11 -0
  44. package/core/scripts/di-generate.ts +58 -9
  45. package/library/terminal/index.ts +4 -0
  46. package/library/terminal/language.ts +60 -0
  47. package/library/terminal/messages.ts +400 -0
  48. package/library/terminal/screen.ts +358 -0
  49. package/library/terminal/style.ts +143 -0
  50. package/package.json +2 -1
@@ -2,7 +2,7 @@ import type { AppConfig, ConfigRegistry, Secret } from "../../kernel";
2
2
  import { AGENT_MODEL_PROVIDER, AGENT_RUNTIME_OPTIONS, AgentRuntime, type AgentModelProvider } from "../../agent";
3
3
  import { DI, DiError, HOSTED_SERVICE, SERVICE_PROVIDER, type ProviderDefinition } from "../../di";
4
4
  import { LOGGER } from "../../kernel/logging/Logger";
5
- import { reader, requireValue } from "../connectorConfig";
5
+ import { reader, requireValue, settingNames } from "../connectorConfig";
6
6
  import { InfraError, type InfraConnector } from "../InfraConnector";
7
7
  import { identifyConnector } from "../connectorIdentity";
8
8
 
@@ -28,6 +28,12 @@ export interface LlmConnectionOptions {
28
28
  export interface LlmConnectorOptions {
29
29
  /** Request timeout. Defaults to 60s; `0` explicitly disables it. */
30
30
  readonly timeoutMs?: number;
31
+ /**
32
+ * Check the provider at start (the adapter's `connect`: `GET /models` for an
33
+ * OpenAI-compatible API, five seconds), so a wrong key or address stops the
34
+ * start instead of the first agent request. Default `true`.
35
+ */
36
+ readonly checkOnStart?: boolean;
31
37
  }
32
38
 
33
39
  export interface LlmProviderAdapter {
@@ -86,17 +92,32 @@ export function llmConnect<T extends LlmConfigShape>(
86
92
  adapter: LlmProviderAdapter,
87
93
  options: LlmConnectorOptions = {},
88
94
  ): InfraConnector<AgentModelProvider> {
95
+ const described = new WeakMap<AgentModelProvider, { connection: LlmConnectionOptions; settings: string[] }>();
89
96
  return identifyConnector({
90
97
  token: AGENT_MODEL_PROVIDER,
91
98
  config,
92
99
  providers: agentRuntimeProviders,
93
100
  exports: [AgentRuntime],
94
101
  create(configs) {
95
- return adapter.create(resolveLlmConnectionOptions(config, options, configs));
102
+ const connection = resolveLlmConnectionOptions(config, options, configs);
103
+ const provider = adapter.create(connection);
104
+ described.set(provider, { connection, settings: settingNames(config, configs, ["baseUrl", "apiKey"]) });
105
+ return provider;
96
106
  },
97
107
  connect(provider, signal) {
108
+ if (options.checkOnStart === false) return;
98
109
  return adapter.connect?.(provider, signal);
99
110
  },
111
+ describe(provider, { connected }) {
112
+ const known = described.get(provider);
113
+ if (known === undefined) return {};
114
+ return {
115
+ kind: `${known.connection.provider} · ${known.connection.model}`,
116
+ address: known.connection.baseUrl,
117
+ details: connected && options.checkOnStart !== false && adapter.connect !== undefined ? ["checked"] : [],
118
+ settings: known.settings,
119
+ };
120
+ },
100
121
  dispose(provider) {
101
122
  return adapter.dispose?.(provider);
102
123
  },
@@ -22,10 +22,14 @@ import {
22
22
  type LlmProviderAdapter,
23
23
  } from "./llm";
24
24
  import { readOpenAiTextStream } from "./openaiTextStream";
25
+ import { InfraError } from "../InfraConnector";
26
+ import { redactSensitiveText } from "../../../library/redaction";
25
27
 
26
28
  const DEFAULT_CHAT_COMPLETIONS_PATH = "/chat/completions";
27
29
  const DEFAULT_HEALTH_PATH = "/models";
28
30
  const DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
31
+ /** The start check is short: a provider that does not answer in five seconds is not ready. */
32
+ const START_CHECK_TIMEOUT_MS = 5_000;
29
33
 
30
34
  export interface OpenAiCompatibleAdapterOptions {
31
35
  readonly fetch?: OpenAiCompatibleFetch;
@@ -390,13 +394,23 @@ export class OpenAiCompatibleModelProvider implements AgentModelProvider {
390
394
 
391
395
  public async ping(signal?: AbortSignal): Promise<boolean> {
392
396
  try {
393
- await this.request(this.healthPath, "GET", undefined, signal);
397
+ await this.probe(signal);
394
398
  return true;
395
399
  } catch {
396
400
  return false;
397
401
  }
398
402
  }
399
403
 
404
+ /** Base URL of the provider, for diagnostics. */
405
+ public get address(): string {
406
+ return this.baseUrl;
407
+ }
408
+
409
+ /** The health request that rejects with the reason: wrong key, address, no answer. */
410
+ public async probe(signal?: AbortSignal): Promise<void> {
411
+ await this.request(this.healthPath, "GET", undefined, signal);
412
+ }
413
+
400
414
  /** End active requests and reject subsequent calls through this provider. */
401
415
  public dispose(): void {
402
416
  this.lifetime.abort(new OpenAiCompatibleProviderError("OpenAI-compatible provider is disposed."));
@@ -581,6 +595,17 @@ export function openAiCompatibleAdapter(options: OpenAiCompatibleAdapterOptions
581
595
  create(connection) {
582
596
  return new OpenAiCompatibleModelProvider(connection, options);
583
597
  },
598
+ async connect(provider, signal) {
599
+ if (!healthCheckEnabled || !(provider instanceof OpenAiCompatibleModelProvider)) return;
600
+ const deadline = AbortSignal.timeout(START_CHECK_TIMEOUT_MS);
601
+ try {
602
+ await provider.probe(signal === undefined ? deadline : AbortSignal.any([signal, deadline]));
603
+ } catch (error) {
604
+ if (signal?.aborted) throw error;
605
+ const reason = deadline.aborted ? `no answer within ${START_CHECK_TIMEOUT_MS} ms` : error instanceof Error ? error.message : String(error);
606
+ throw new InfraError(`Infra connector "llm": ${provider.address} is not available — ${redactSensitiveText(reason)}`);
607
+ }
608
+ },
584
609
  dispose(provider) {
585
610
  if (provider instanceof OpenAiCompatibleModelProvider) provider.dispose();
586
611
  },
@@ -1,7 +1,7 @@
1
1
  import { createToken, type InjectionToken } from "../../di";
2
2
  import type { AppConfig, ConfigRegistry, Secret } from "../../kernel";
3
- import { reader, requireValue } from "../connectorConfig";
4
- import { InfraError, type InfraConnector } from "../InfraConnector";
3
+ import { reader, requireValue, settingNames } from "../connectorConfig";
4
+ import { errorMessage, InfraError, type InfraConnector } from "../InfraConnector";
5
5
  import { redactSensitive, redactSensitiveText } from "../../../library/redaction";
6
6
 
7
7
  /** Default cluster request timeout (ms): fail fast instead of hanging. */
@@ -124,6 +124,11 @@ export class OpenSearchClient {
124
124
  }
125
125
 
126
126
  /** Healthy only for explicitly accepted cluster states. */
127
+ /** Cluster address without credentials. */
128
+ public get url(): string {
129
+ return this.base;
130
+ }
131
+
127
132
  public async ping(signal?: AbortSignal): Promise<boolean> {
128
133
  try {
129
134
  const health = await this.clusterHealth(signal);
@@ -134,8 +139,12 @@ export class OpenSearchClient {
134
139
  }
135
140
  }
136
141
 
137
- /** OpenSearch client token for injection into application services. */
138
- export const OPENSEARCH: InjectionToken<OpenSearchClient> = createToken<OpenSearchClient>("OpenSearch");
142
+ /**
143
+ * OpenSearch client of the `openSearchConnect()` connector, injected by type:
144
+ * `constructor(private readonly search: IOpenSearch)`.
145
+ */
146
+ export interface IOpenSearch extends OpenSearchClient {}
147
+ export const IOpenSearch = createToken<IOpenSearch>("IOpenSearch") as InjectionToken<OpenSearchClient>;
139
148
 
140
149
  /**
141
150
  * Config interface required by the OpenSearch connector. The search subsystem
@@ -278,25 +287,47 @@ async function readBoundedText(response: Response, maxBytes: number, label: stri
278
287
  * });
279
288
  * @Infra({ search: openSearchConnect(searchConfig) })
280
289
  * export class AppInfra {}
281
- * // injection by token: scoped(ISearchService, SearchService, [OPENSEARCH] as const)
290
+ * // by type: class SearchService { constructor(private readonly search: IOpenSearch) {} }
282
291
  * ```
283
292
  */
284
293
  export function openSearchConnect<T extends OpenSearchConfigShape>(
285
294
  config: AppConfig<T>,
286
295
  options: OpenSearchConnectorOptions = {},
287
296
  ): InfraConnector<OpenSearchClient> {
297
+ const settings = new WeakMap<OpenSearchClient, string[]>();
288
298
  return {
289
- token: options.token ?? OPENSEARCH,
299
+ token: options.token ?? IOpenSearch,
290
300
  config,
291
301
  create(configs) {
292
- return new OpenSearchClient(buildOpenSearchOptions(config, options, configs));
302
+ const client = new OpenSearchClient(buildOpenSearchOptions(config, options, configs));
303
+ settings.set(client, settingNames(config, configs, ["url", "username", "password"]));
304
+ return client;
293
305
  },
294
306
  async connect(client, signal) {
295
- const alive = await client.ping(signal);
296
- if (!alive) {
297
- throw new InfraError(`Infra connector "opensearch": cluster is not reachable or unhealthy.`);
307
+ let status: string | undefined;
308
+ try {
309
+ status = (await client.clusterHealth(signal))?.status;
310
+ } catch (error) {
311
+ throw new InfraError(`Infra connector "opensearch": cannot reach ${client.url} — ${errorMessage(error)}`);
312
+ }
313
+ if (status !== "green" && status !== "yellow") {
314
+ throw new InfraError(`Infra connector "opensearch": cluster ${client.url} is unhealthy (status ${status ?? "unknown"}).`);
298
315
  }
299
316
  },
317
+ async describe(client, { connected, signal }) {
318
+ if (!connected) return { kind: "OpenSearch", address: client.url, settings: settings.get(client) ?? [] };
319
+ const [info, health] = await Promise.all([
320
+ client.request<{ version?: { number?: string; distribution?: string } }>("GET", "/", undefined, signal),
321
+ client.clusterHealth(signal),
322
+ ]);
323
+ const product = info.version?.distribution === "opensearch" || info.version?.distribution === undefined ? "OpenSearch" : info.version.distribution;
324
+ return {
325
+ kind: info.version?.number ? `${product} ${info.version.number}` : product,
326
+ address: client.url,
327
+ details: [health.status],
328
+ settings: settings.get(client) ?? [],
329
+ };
330
+ },
300
331
  dispose(client) {
301
332
  client.dispose();
302
333
  },
@@ -1,11 +1,16 @@
1
1
  import { SQL } from "bun";
2
2
  import { createToken, type InjectionToken } from "../../di";
3
3
  import type { AppConfig, ConfigRegistry, Secret } from "../../kernel";
4
- import { reader, requireValue } from "../connectorConfig";
4
+ import { reader, requireValue, settingNames } from "../connectorConfig";
5
5
  import { errorMessage, InfraError, type InfraConnector } from "../InfraConnector";
6
6
 
7
- /** PostgreSQL client (native Bun `SQL`, no external dependencies). */
8
- export const POSTGRES: InjectionToken<SQL> = createToken<SQL>("Postgres");
7
+ /**
8
+ * PostgreSQL client (native Bun `SQL`, no external dependencies) of the
9
+ * `postgres()` connector, injected by type:
10
+ * `constructor(private readonly sql: IPostgres)`.
11
+ */
12
+ export interface IPostgres extends SQL {}
13
+ export const IPostgres = createToken<IPostgres>("IPostgres") as InjectionToken<SQL>;
9
14
 
10
15
  /**
11
16
  * Config interface required by the PostgreSQL connector. The database subsystem
@@ -69,7 +74,7 @@ export function postgresConnectionOptions(config: AppConfig<PostgresConfigShape>
69
74
  * });
70
75
  * @Infra({ db: postgres(dbConfig) })
71
76
  * export class AppInfra {}
72
- * // injection by token: scoped(IReportsService, ReportsService, [POSTGRES] as const)
77
+ * // by type: class ReportsService { constructor(private readonly sql: IPostgres) {} }
73
78
  * ```
74
79
  */
75
80
  export function postgres<T extends PostgresConfigShape>(config: AppConfig<T>, options: PostgresConnectorOptions = {}): InfraConnector<SQL> {
@@ -77,11 +82,23 @@ export function postgres<T extends PostgresConfigShape>(config: AppConfig<T>, op
77
82
  if (!Number.isSafeInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0 || shutdownTimeoutMs > 2147483647) {
78
83
  throw new InfraError("postgres shutdownTimeoutMs must be an integer 0..2147483647");
79
84
  }
85
+ const described = new WeakMap<SQL, { options: { hostname?: string; port?: number; database?: string; username?: string; max?: number }; settings: string[] }>();
80
86
  return {
81
- token: options.token ?? POSTGRES,
87
+ token: options.token ?? IPostgres,
82
88
  config,
83
89
  create(configs) {
84
- return new SQL(postgresConnectionOptions(config, configs));
90
+ const connection = postgresConnectionOptions(config, configs);
91
+ const client = new SQL(connection);
92
+ described.set(client, { options: connection as { hostname?: string; port?: number; database?: string; username?: string; max?: number }, settings: settingNames(config, configs, ["host", "port", "database", "username", "password"]) });
93
+ return client;
94
+ },
95
+ async describe(client, { connected }) {
96
+ const known = described.get(client);
97
+ const version = connected ? await serverVersion((sql) => client.unsafe(sql)) : undefined;
98
+ return {
99
+ kind: version === undefined ? "PostgreSQL" : `PostgreSQL ${version}`,
100
+ ...(known === undefined ? {} : { address: postgresAddress(known.options), details: [`pool ${known.options.max ?? 10}`], settings: known.settings }),
101
+ };
85
102
  },
86
103
  async connect(client) {
87
104
  try {
@@ -103,3 +120,15 @@ export function postgres<T extends PostgresConfigShape>(config: AppConfig<T>, op
103
120
  },
104
121
  };
105
122
  }
123
+
124
+ /** `postgres@127.0.0.1:5432/app`: user, host, port and database, never the password. */
125
+ export function postgresAddress(options: { readonly hostname?: string; readonly port?: number | string; readonly database?: string; readonly username?: string }): string {
126
+ return `${options.username ?? ""}${options.username ? "@" : ""}${options.hostname ?? "localhost"}:${options.port ?? 5432}/${options.database ?? ""}`;
127
+ }
128
+
129
+ /** `17.6` from `SHOW server_version`; undefined when the server does not say. */
130
+ export async function serverVersion(query: (sql: string) => Promise<unknown>): Promise<string | undefined> {
131
+ const rows = await query("SHOW server_version") as readonly Record<string, unknown>[];
132
+ const value = rows[0]?.server_version;
133
+ return typeof value === "string" ? value.split(" ")[0] : undefined;
134
+ }
@@ -9,11 +9,19 @@ import {
9
9
  import { createToken, DI, type InjectionToken, type ProviderDefinition } from "../../di";
10
10
  import type { AppConfig, Secret } from "../../kernel";
11
11
  import { RedisDistributedCacheBackend, type RedisDistributedCacheTuning } from "../cache/RedisDistributedCacheBackend";
12
- import { reader, requireValue } from "../connectorConfig";
12
+ import { reader, requireValue, settingNames } from "../connectorConfig";
13
13
  import { errorMessage, InfraError, type InfraConnector } from "../InfraConnector";
14
+ import { redactSensitiveText } from "../../../library/redaction";
14
15
 
15
- /** Redis/Valkey client (native Bun `RedisClient`, no external dependencies). */
16
- export const REDIS: InjectionToken<RedisClient> = createToken<RedisClient>("Redis");
16
+ const DEFAULT_REDIS_CONNECT_TIMEOUT_MS = 5_000;
17
+
18
+ /**
19
+ * Redis/Valkey client (native Bun `RedisClient`, no external dependencies) of
20
+ * the `redisConnect()` connector, injected by type:
21
+ * `constructor(private readonly redis: IRedis)`.
22
+ */
23
+ export interface IRedis extends RedisClient {}
24
+ export const IRedis = createToken<IRedis>("IRedis") as InjectionToken<RedisClient>;
17
25
 
18
26
  /**
19
27
  * Config interface required by the Redis connector. The subsystem config
@@ -30,6 +38,12 @@ export type RedisConnectorCacheMode =
30
38
 
31
39
  export interface RedisConnectorOptions {
32
40
  readonly token?: InjectionToken<RedisClient>;
41
+ /**
42
+ * How long the start waits for the first connection, ms (default 5000).
43
+ * The Bun client keeps retrying a refused connection; without a bound an
44
+ * unreachable Redis held the whole start until its timeout.
45
+ */
46
+ readonly connectTimeoutMs?: number;
33
47
  /**
34
48
  * Publishes a distributed cache backend on top of the client: `{ cache: "distributed" }`.
35
49
  * For fine tuning use `{ cache: { mode: "distributed", keyPrefix, ... } }`.
@@ -81,7 +95,7 @@ function distributedCacheProviders(tuning: RedisDistributedCacheTuning, token: I
81
95
  * export const redisConfig = defineConfig("redis", { default: { url: "redis://localhost:6379" } });
82
96
  * @Infra({ cache: redisConnect(redisConfig, { cache: "distributed" }) })
83
97
  * export class AppInfra {}
84
- * // injection by token: scoped(ISessionsService, SessionsService, [REDIS] as const)
98
+ * // by type: class SessionsService { constructor(private readonly redis: IRedis) {} }
85
99
  * ```
86
100
  */
87
101
  export function redisConnect<T extends RedisConfigShape>(
@@ -89,7 +103,13 @@ export function redisConnect<T extends RedisConfigShape>(
89
103
  options: RedisConnectorOptions = {},
90
104
  ): InfraConnector<RedisClient> {
91
105
  const cache = options.cache;
92
- const token = options.token ?? REDIS;
106
+ const token = options.token ?? IRedis;
107
+ const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_REDIS_CONNECT_TIMEOUT_MS;
108
+ if (!Number.isSafeInteger(connectTimeoutMs) || connectTimeoutMs <= 0) {
109
+ throw new InfraError("redisConnect connectTimeoutMs must be a positive integer.");
110
+ }
111
+ const urls = new WeakMap<RedisClient, string>();
112
+ const settings = new WeakMap<RedisClient, string[]>();
93
113
  const cacheProviders =
94
114
  cache === undefined
95
115
  ? undefined
@@ -101,13 +121,39 @@ export function redisConnect<T extends RedisConfigShape>(
101
121
  providers: cacheProviders,
102
122
  exports: cacheProviders ? [DISTRIBUTED_CACHE_BACKEND, DISTRIBUTED_OUTPUT_CACHE, DISTRIBUTED_SERVICE_CACHE, IDistributedCache] : undefined,
103
123
  create(configs) {
104
- return new RedisClient(redisUrl(reader(config, configs).get("url")));
124
+ const url = redisUrl(reader(config, configs).get("url"));
125
+ const client = new RedisClient(url);
126
+ urls.set(client, url);
127
+ settings.set(client, settingNames(config, configs, ["url"]));
128
+ return client;
129
+ },
130
+ async describe(client, { connected }) {
131
+ const info = connected ? String(await client.send("INFO", ["server"])) : "";
132
+ const valkey = /valkey_version:([^\r\n]+)/.exec(info)?.[1];
133
+ const redis = /redis_version:([^\r\n]+)/.exec(info)?.[1];
134
+ return {
135
+ kind: valkey ? `Valkey ${valkey}` : redis ? `Redis ${redis}` : "Redis",
136
+ address: redisAddress(urls.get(client) ?? ""),
137
+ settings: settings.get(client) ?? [],
138
+ };
105
139
  },
106
140
  async connect(client) {
141
+ // The address without a password: it names the server in the error.
142
+ const target = redactSensitiveText(urls.get(client) ?? "redis");
143
+ let timer: ReturnType<typeof setTimeout> | undefined;
144
+ const deadline = new Promise<never>((_resolve, reject) => {
145
+ timer = setTimeout(() => reject(new InfraError(`Infra connector "redis": failed to connect to ${target} within ${connectTimeoutMs} ms.`)), connectTimeoutMs);
146
+ });
107
147
  try {
108
- await client.connect();
148
+ await Promise.race([client.connect(), deadline]);
109
149
  } catch (error) {
110
- throw new InfraError(`Infra connector "redis": failed to connect — ${errorMessage(error)}`);
150
+ if (error instanceof InfraError) {
151
+ client.close();
152
+ throw error;
153
+ }
154
+ throw new InfraError(`Infra connector "redis": failed to connect to ${target} — ${errorMessage(error)}`);
155
+ } finally {
156
+ clearTimeout(timer);
111
157
  }
112
158
  },
113
159
  dispose(client) {
@@ -138,3 +184,13 @@ function withoutMode(cache: { readonly mode: "distributed" } & RedisDistributedC
138
184
  const { mode: _mode, ...tuning } = cache;
139
185
  return tuning;
140
186
  }
187
+
188
+ /** `127.0.0.1:6379/0` without the scheme, user and password. */
189
+ function redisAddress(url: string): string {
190
+ try {
191
+ const parsed = new URL(url);
192
+ return `${parsed.hostname}:${parsed.port || "6379"}${parsed.pathname === "/" ? "" : parsed.pathname}`;
193
+ } catch {
194
+ return redactSensitiveText(url);
195
+ }
196
+ }
@@ -4,16 +4,16 @@
4
4
  // (`SQL`, `RedisClient`) and `fetch`, with zero external dependencies.
5
5
 
6
6
  export { Infra, infraModule, type InfraManifest } from "./Infra";
7
- export { InfraError, errorMessage, type InfraConnector } from "./InfraConnector";
7
+ export { InfraError, errorMessage, type ConnectionDescription, type InfraConnector } from "./InfraConnector";
8
8
  export { InfraLifecycle } from "./InfraLifecycle";
9
- export { reader, requireValue, type ConfigReader } from "./connectorConfig";
9
+ export { reader, requireValue, settingNames, type ConfigReader } from "./connectorConfig";
10
10
  export { codexAppServerConnect, CODEX_APP_SERVER } from "./connectors/codex";
11
11
  export { CodexError, RU_CODEX_MESSAGES, type CodexErrorCode, CODEX_MODEL_ID, CODEX_REASONING_EFFORT, type CodexClient, type CodexConfigShape, type CodexLogin, type CodexLoginMethod, type CodexModel, type CodexStatus, type CodexRunInput, type CodexChatMessage, type CodexTool, type CodexToolCall, type CodexToolResult } from "./connectors/codex/contracts";
12
12
 
13
- export { postgres, POSTGRES, postgresConnectionOptions, type PostgresConfigShape, type PostgresSslMode, type PostgresConnectorOptions } from "./connectors/postgres";
13
+ export { postgres, IPostgres, postgresConnectionOptions, type PostgresConfigShape, type PostgresSslMode, type PostgresConnectorOptions } from "./connectors/postgres";
14
14
  export {
15
15
  redisConnect,
16
- REDIS,
16
+ IRedis,
17
17
  type RedisConfigShape,
18
18
  type RedisConnectorCacheMode,
19
19
  type RedisConnectorOptions,
@@ -26,7 +26,7 @@ export {
26
26
  } from "./cache";
27
27
  export {
28
28
  openSearchConnect,
29
- OPENSEARCH,
29
+ IOpenSearch,
30
30
  OpenSearchClient,
31
31
  type OpenSearchClientOptions,
32
32
  type OpenSearchConfigShape,
@@ -5,6 +5,7 @@ import { KernelError, ShutdownTimeoutError } from "./errors";
5
5
  import type { KernelOptions } from "./types";
6
6
  import { redactSensitiveText } from "../../library/redaction";
7
7
  import { formatStartupFailure } from "./startupFailure";
8
+ import { StartupScreen, startupScreenEnabled } from "./report/StartupScreen";
8
9
 
9
10
  /**
10
11
  * Entry-point facade.
@@ -31,12 +32,26 @@ export class Bazis {
31
32
  if (options) {
32
33
  builder.useOptions(options);
33
34
  }
35
+ // On a terminal the start is a screen: what started, or what failed and why.
36
+ const screen = startupScreenEnabled() ? new StartupScreen() : undefined;
37
+ let stopWaiting: (() => void) | undefined;
38
+ if (screen !== undefined && options?.startupReport !== false) {
39
+ builder.useStartupScreen((started) => {
40
+ stopWaiting?.();
41
+ screen.started(started);
42
+ });
43
+ }
34
44
  configure?.(builder);
35
45
 
36
46
  let kernel: Kernel;
37
47
  try {
38
48
  kernel = await builder.build();
39
49
  } catch (error) {
50
+ if (screen !== undefined && !(error instanceof ShutdownTimeoutError)) {
51
+ screen.failed(error, undefined, builder.report);
52
+ process.exitCode = 1;
53
+ return 1;
54
+ }
40
55
  if (error instanceof DiError || error instanceof KernelError) {
41
56
  console.error(`[bazis] configuration error: ${redactSensitiveText(error.message)}`);
42
57
  process.exitCode = 1;
@@ -46,10 +61,13 @@ export class Bazis {
46
61
  }
47
62
 
48
63
  try {
64
+ if (kernel.report.screen) stopWaiting = screen?.waiting(kernel);
49
65
  const exitCode = await kernel.run();
66
+ stopWaiting?.();
50
67
  process.exitCode = exitCode;
51
68
  return exitCode;
52
69
  } catch (error) {
70
+ stopWaiting?.();
53
71
  if (error instanceof ShutdownTimeoutError) {
54
72
  // Graceful shutdown is stuck: hanging handles would keep the process
55
73
  // alive forever, so this is the one place a hard exit is correct.
@@ -58,7 +76,9 @@ export class Bazis {
58
76
  }
59
77
  // Configuration problems surfacing at start (e.g. options fail-fast)
60
78
  // deserve the same friendly output as build-time ones.
61
- if (error instanceof DiError || error instanceof KernelError) {
79
+ if (screen !== undefined) {
80
+ screen.failed(error, kernel);
81
+ } else if (error instanceof DiError || error instanceof KernelError) {
62
82
  console.error(`[bazis] configuration error: ${redactSensitiveText(error.message)}`);
63
83
  } else {
64
84
  console.error(`[bazis] application failed: ${formatStartupFailure(error)}`);
@@ -15,6 +15,9 @@ import { validateTimeout } from "./internal/validateTimeout";
15
15
  import { awaitShutdown } from "./internal/awaitShutdown";
16
16
  import { awaitAbortable } from "./internal/awaitAbortable";
17
17
  import { reportDiagnosticFailure } from "./internal/reportDiagnosticFailure";
18
+ import { StartupReport } from "./report/StartupReport";
19
+ import type { BazisModuleRef } from "../di";
20
+ import type { StartupBatchTiming } from "./LifecycleCoordinator";
18
21
 
19
22
  export interface KernelTimings {
20
23
  readonly configMs: number;
@@ -30,6 +33,12 @@ export interface KernelRuntimeOptions {
30
33
  readonly startupReport: boolean;
31
34
  /** Start the infrastructure and the schema only, then stop (BAZIS_SCHEMA_ONLY). */
32
35
  readonly schemaOnly?: boolean;
36
+ /** Facts for the startup screen; a fresh one when absent. */
37
+ readonly report?: StartupReport;
38
+ /** The application root module, for the module tree of the startup screen. */
39
+ readonly root?: BazisModuleRef;
40
+ /** Renders the startup screen in place of the `bazis started` log line. */
41
+ readonly onStarted?: (kernel: Kernel) => void;
33
42
  }
34
43
 
35
44
  interface StopRequest {
@@ -56,6 +65,7 @@ export class Kernel {
56
65
  private shutdownPromise?: Promise<void>;
57
66
  private startupCleanupFailure?: unknown;
58
67
  private startupAbort?: AbortController;
68
+ private ownReport?: StartupReport;
59
69
 
60
70
  public constructor(
61
71
  public readonly container: DiContainer,
@@ -76,6 +86,42 @@ export class Kernel {
76
86
  return this.state === "started";
77
87
  }
78
88
 
89
+ /** What subsystems recorded while starting: connections, schema, routes. */
90
+ public get report(): StartupReport {
91
+ this.ownReport ??= this.options.report ?? new StartupReport();
92
+ return this.ownReport;
93
+ }
94
+
95
+ /** The application root module, when the builder knows it. */
96
+ public get rootModule(): BazisModuleRef | undefined {
97
+ return this.options.root;
98
+ }
99
+
100
+ /** Configuration and container build time, and the moment the build began. */
101
+ public get buildTimings(): KernelTimings {
102
+ return this.timings;
103
+ }
104
+
105
+ /** Wall time of every started group of hosted services. */
106
+ public get startTimings(): readonly StartupBatchTiming[] {
107
+ return this.coordinator.startTimings;
108
+ }
109
+
110
+ /** Hosted services whose start failed. */
111
+ public get failedServices(): readonly string[] {
112
+ return this.coordinator.failedServiceNames;
113
+ }
114
+
115
+ /** Hosted services whose start is running now. */
116
+ public get startingServices(): readonly string[] {
117
+ return this.coordinator.startingServiceNames;
118
+ }
119
+
120
+ /** From the start of the build to a started application, ms; 0 before. */
121
+ public get startupDurationMs(): number {
122
+ return this.startupMs;
123
+ }
124
+
79
125
  public start(): Promise<void> {
80
126
  if (this.startPromise !== undefined) {
81
127
  return this.startPromise;
@@ -95,7 +141,7 @@ export class Kernel {
95
141
  let startupDisposal: Promise<void> | undefined;
96
142
  if (this.options.startupTimeoutMs > 0) {
97
143
  startupTimer = setTimeout(
98
- () => abortController.abort(new StartupTimeoutError(this.options.startupTimeoutMs)),
144
+ () => abortController.abort(new StartupTimeoutError(this.options.startupTimeoutMs, this.coordinator.startingServiceName)),
99
145
  this.options.startupTimeoutMs,
100
146
  );
101
147
  }
@@ -396,6 +442,14 @@ export class Kernel {
396
442
  }
397
443
 
398
444
  private printStartupReport(): void {
445
+ if (this.options.onStarted !== undefined) {
446
+ try {
447
+ this.options.onStarted(this);
448
+ return;
449
+ } catch {
450
+ // A broken screen falls back to the log line below.
451
+ }
452
+ }
399
453
  const logger = this.container.tryResolve(LOGGER);
400
454
  const fields = {
401
455
  environment: this.environment.name,
@@ -13,6 +13,7 @@ import { ConsoleLogger } from "./logging/ConsoleLogger";
13
13
  import { LOGGER, type Logger } from "./logging/Logger";
14
14
  import type { ConfigSource, EnvironmentName, KernelOptions, UnhandledErrorPolicy } from "./types";
15
15
  import { validateTimeout } from "./internal/validateTimeout";
16
+ import { StartupReport } from "./report/StartupReport";
16
17
 
17
18
  /** Root module or a profile factory (Spring profiles, but explicit). */
18
19
  export type RootModuleInput = BazisModuleRef | ((environment: Environment) => BazisModuleRef);
@@ -49,6 +50,14 @@ export class KernelBuilder {
49
50
  private startupReport?: boolean;
50
51
  private schemaOnly?: boolean;
51
52
  private logger?: Logger;
53
+ private startupScreen?: (kernel: Kernel) => void;
54
+ private applicationRoot?: BazisModuleRef;
55
+ private builtReport?: StartupReport;
56
+
57
+ /** Facts of the last {@link build}, also when it failed after the configuration was read. */
58
+ public get report(): StartupReport | undefined {
59
+ return this.builtReport;
60
+ }
52
61
 
53
62
  public constructor(private readonly rootModule: RootModuleInput) {}
54
63
 
@@ -104,6 +113,25 @@ export class KernelBuilder {
104
113
  return this;
105
114
  }
106
115
 
116
+ /**
117
+ * Renders the startup screen instead of the `bazis started` log line, and
118
+ * makes subsystems record their facts for it rather than print them.
119
+ * `Bazis.run` sets it on an interactive terminal.
120
+ */
121
+ public useStartupScreen(render: (kernel: Kernel) => void): this {
122
+ this.startupScreen = render;
123
+ return this;
124
+ }
125
+
126
+ /**
127
+ * @internal The application's own root module, inside the hosts `runApp`
128
+ * wraps it in: the module tree of the startup screen starts from it.
129
+ */
130
+ public useApplicationRoot(root: BazisModuleRef): this {
131
+ this.applicationRoot = root;
132
+ return this;
133
+ }
134
+
107
135
  /**
108
136
  * Replacements for registered providers, for tests (see `createContainer`
109
137
  * `overrides`): registered after the whole graph and visible to every module.
@@ -138,6 +166,12 @@ export class KernelBuilder {
138
166
  const userRoot = resolveUserRoot(this.rootModule, environment);
139
167
  const configs = collectModuleConfigs([userRoot]);
140
168
  const configRegistry = new ConfigRegistry(configs, environment.name, configuration);
169
+ const startupReport = this.startupReport ?? !environment.isProduction;
170
+ // The screen replaces the report line, so it follows the same switch.
171
+ const screen = this.startupScreen !== undefined && startupReport;
172
+ const report = new StartupReport(screen);
173
+ report.configuration(configRegistry.inspect());
174
+ this.builtReport = report;
141
175
 
142
176
  // Kernel infrastructure is a global module: any user module can depend on
143
177
  // Environment / Configuration / ApplicationLifetime / EventBus without
@@ -146,6 +180,7 @@ export class KernelBuilder {
146
180
  @Module({
147
181
  providers: [
148
182
  DI.singleton(DI.valueProvider(ConfigRegistry, configRegistry)),
183
+ DI.singleton(DI.valueProvider(StartupReport, report)),
149
184
  ...configRegistry.providers,
150
185
  DI.singleton(DI.valueProvider(Environment, environment)),
151
186
  DI.singleton(DI.valueProvider(Configuration, configuration)),
@@ -179,8 +214,11 @@ export class KernelBuilder {
179
214
  shutdownTimeoutMs: this.shutdownTimeoutMs,
180
215
  signals: this.signals,
181
216
  unhandledErrorPolicy: this.unhandledErrorPolicy,
182
- startupReport: this.startupReport ?? !environment.isProduction,
217
+ startupReport,
183
218
  schemaOnly: this.schemaOnly ?? schemaOnlyFromProcess(),
219
+ report,
220
+ root: this.applicationRoot ?? userRoot,
221
+ ...(screen ? { onStarted: this.startupScreen } : {}),
184
222
  });
185
223
  }
186
224
  }