bazis 0.99.6 → 0.100.1
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.
- package/cli/build.ts +171 -36
- package/cli/codegen.ts +155 -4
- package/cli/generateProject.ts +8 -2
- package/cli/main.ts +134 -51
- package/cli/naming.ts +3 -0
- package/cli/ui.ts +86 -0
- package/core/agent/index.ts +8 -0
- package/core/app/runApp.ts +4 -1
- package/core/di/extensions/hosted-service.ts +6 -0
- package/core/http/HttpServer.ts +33 -2
- package/core/http/Routing/RouterBuilder.ts +21 -0
- package/core/infra/Infra.ts +8 -2
- package/core/infra/InfraConnector.ts +13 -0
- package/core/infra/InfraLifecycle.ts +27 -0
- package/core/infra/connectorConfig.ts +10 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/llm.ts +23 -2
- package/core/infra/connectors/openaiCompatible.ts +26 -1
- package/core/infra/connectors/opensearch.ts +36 -8
- package/core/infra/connectors/postgres.ts +29 -4
- package/core/infra/connectors/redis.ts +26 -3
- package/core/infra/index.ts +2 -2
- package/core/kernel/Bazis.ts +21 -1
- package/core/kernel/Kernel.ts +54 -0
- package/core/kernel/KernelBuilder.ts +39 -1
- package/core/kernel/LifecycleCoordinator.ts +101 -9
- package/core/kernel/config/ConfigRegistry.ts +5 -0
- package/core/kernel/config/ConfigValidationError.ts +37 -0
- package/core/kernel/config/callerFile.ts +23 -0
- package/core/kernel/config/defineConfig.ts +21 -17
- package/core/kernel/errors/StartupFailuresError.ts +17 -0
- package/core/kernel/errors/index.ts +1 -0
- package/core/kernel/index.ts +12 -0
- package/core/kernel/report/StartupReport.ts +134 -0
- package/core/kernel/report/StartupScreen.ts +459 -0
- package/core/kernel/report/moduleTree.ts +97 -0
- package/core/kernel/report/startupProblems.ts +186 -0
- package/core/orm/OrmHostedPlan.validator.ts +3 -3
- package/core/orm/OrmLifecycle.ts +29 -7
- package/core/orm/buildOrmProviders.ts +7 -3
- package/core/orm/databaseConnector.ts +17 -1
- package/core/orm/index.ts +11 -0
- package/core/orm/strictSchemaHostedIdentity.ts +16 -1
- package/core/scripts/di-generate.ts +58 -9
- package/library/terminal/index.ts +4 -0
- package/library/terminal/language.ts +60 -0
- package/library/terminal/messages.ts +400 -0
- package/library/terminal/screen.ts +358 -0
- package/library/terminal/style.ts +143 -0
- package/package.json +2 -1
|
@@ -3,7 +3,7 @@ import type { Token } from "../di";
|
|
|
3
3
|
import { LIFECYCLE_HOOK } from "./lifecycleHooks";
|
|
4
4
|
import { LOGGER } from "./logging/Logger";
|
|
5
5
|
import type { LifecycleHook } from "./types";
|
|
6
|
-
import { ShutdownTimeoutError } from "./errors";
|
|
6
|
+
import { ShutdownTimeoutError, StartupFailuresError, type StartupFailure } from "./errors";
|
|
7
7
|
import { awaitAbortable } from "./internal/awaitAbortable";
|
|
8
8
|
import { resolveHostedServices, validateHostedServicePlan } from "../di/extensions/hosted-service";
|
|
9
9
|
import { awaitShutdown } from "./internal/awaitShutdown";
|
|
@@ -21,11 +21,28 @@ interface LifecycleResolver {
|
|
|
21
21
|
* the {@link Kernel}, which bounds the whole graceful-stop sequence.
|
|
22
22
|
*/
|
|
23
23
|
export class LifecycleCoordinator {
|
|
24
|
-
private
|
|
24
|
+
private readonly pending = new Set<string>();
|
|
25
|
+
private readonly timings: StartupBatchTiming[] = [];
|
|
26
|
+
private readonly failed: string[] = [];
|
|
25
27
|
|
|
26
|
-
/**
|
|
28
|
+
/** Hosted services whose start failed, for the failure screen. */
|
|
29
|
+
public get failedServiceNames(): readonly string[] {
|
|
30
|
+
return this.failed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The hosted services whose `start` is running now, for the startup timeout. */
|
|
27
34
|
public get startingServiceName(): string | undefined {
|
|
28
|
-
return this.
|
|
35
|
+
return this.pending.size === 0 ? undefined : [...this.pending].join(", ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The same as a list, for the waiting indicator of the startup screen. */
|
|
39
|
+
public get startingServiceNames(): readonly string[] {
|
|
40
|
+
return [...this.pending];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Wall time of every started batch, in start order. */
|
|
44
|
+
public get startTimings(): readonly StartupBatchTiming[] {
|
|
45
|
+
return this.timings;
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
private hooks: readonly LifecycleHook[] = [];
|
|
@@ -85,10 +102,17 @@ export class LifecycleCoordinator {
|
|
|
85
102
|
|
|
86
103
|
const phases = groupByPhase(hosted);
|
|
87
104
|
for (let phaseIndex = 0; phaseIndex < phases.length; phaseIndex += 1) {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
for (const batch of batchesOf(phases[phaseIndex] as HostedService[])) {
|
|
106
|
+
if (batch.length > 1) {
|
|
107
|
+
await this.startBatch(batch, signal);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// One service starts inline: a failed start reaches the rollback
|
|
111
|
+
// below before a late stop of an abandoned start is scheduled.
|
|
112
|
+
const service = batch[0] as HostedService;
|
|
113
|
+
const name = serviceName(service);
|
|
114
|
+
const startedAt = performance.now();
|
|
115
|
+
this.pending.add(name);
|
|
92
116
|
const starting = Promise.resolve().then(() => {
|
|
93
117
|
signal?.throwIfAborted();
|
|
94
118
|
return service.start(signal);
|
|
@@ -96,6 +120,7 @@ export class LifecycleCoordinator {
|
|
|
96
120
|
try {
|
|
97
121
|
await awaitAbortable(starting, signal);
|
|
98
122
|
} catch (error) {
|
|
123
|
+
this.failed.push(name);
|
|
99
124
|
if (signal?.aborted) {
|
|
100
125
|
// A legacy service may ignore the signal and finish after startup
|
|
101
126
|
// has already been cancelled. Observe it and stop it immediately
|
|
@@ -103,9 +128,11 @@ export class LifecycleCoordinator {
|
|
|
103
128
|
void starting.then(() => service.stop()).catch(() => undefined);
|
|
104
129
|
}
|
|
105
130
|
throw error;
|
|
131
|
+
} finally {
|
|
132
|
+
this.pending.delete(name);
|
|
133
|
+
this.timings.push(Object.freeze({ names: Object.freeze([name]), phase: service.phase ?? 0, ms: performance.now() - startedAt }));
|
|
106
134
|
}
|
|
107
135
|
this.started.push(service);
|
|
108
|
-
this.startingName = undefined;
|
|
109
136
|
}
|
|
110
137
|
}
|
|
111
138
|
this.hostedCount = this.started.length;
|
|
@@ -129,6 +156,50 @@ export class LifecycleCoordinator {
|
|
|
129
156
|
}
|
|
130
157
|
}
|
|
131
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Starts one service, or several `concurrentStart` services together. Every
|
|
161
|
+
* failure of the batch is reported at once: three unreachable connections
|
|
162
|
+
* are three problems on one screen, not three restarts.
|
|
163
|
+
*/
|
|
164
|
+
private async startBatch(batch: readonly HostedService[], signal?: AbortSignal): Promise<void> {
|
|
165
|
+
const names = batch.map(serviceName);
|
|
166
|
+
for (const name of names) this.pending.add(name);
|
|
167
|
+
const startedAt = performance.now();
|
|
168
|
+
const results = await Promise.allSettled(batch.map((service) => this.startService(service, signal)));
|
|
169
|
+
for (const name of names) this.pending.delete(name);
|
|
170
|
+
this.timings.push(Object.freeze({ names: Object.freeze(names), phase: batch[0]?.phase ?? 0, ms: performance.now() - startedAt }));
|
|
171
|
+
const failures: StartupFailure[] = [];
|
|
172
|
+
results.forEach((result, index) => {
|
|
173
|
+
if (result.status === "fulfilled") this.started.push(batch[index] as HostedService);
|
|
174
|
+
else {
|
|
175
|
+
failures.push(Object.freeze({ name: names[index] as string, error: result.reason }));
|
|
176
|
+
this.failed.push(names[index] as string);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
if (failures.length === 0) return;
|
|
180
|
+
// A timeout or a stop request aborts every pending start with one reason.
|
|
181
|
+
if (signal?.aborted || failures.length === 1) throw (failures[0] as StartupFailure).error;
|
|
182
|
+
throw new StartupFailuresError(failures);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async startService(service: HostedService, signal?: AbortSignal): Promise<void> {
|
|
186
|
+
const starting = Promise.resolve().then(() => {
|
|
187
|
+
signal?.throwIfAborted();
|
|
188
|
+
return service.start(signal);
|
|
189
|
+
});
|
|
190
|
+
try {
|
|
191
|
+
await awaitAbortable(starting, signal);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (signal?.aborted) {
|
|
194
|
+
// A legacy service may ignore the signal and finish after startup
|
|
195
|
+
// has already been cancelled. Observe it and stop it immediately
|
|
196
|
+
// so the late listener/connection cannot escape rollback.
|
|
197
|
+
void starting.then(() => service.stop()).catch(() => undefined);
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
132
203
|
private invokeHook(callback: () => void | Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
133
204
|
this.hookWork = Promise.resolve().then(() => {
|
|
134
205
|
signal?.throwIfAborted();
|
|
@@ -206,6 +277,27 @@ export class LifecycleCoordinator {
|
|
|
206
277
|
}
|
|
207
278
|
}
|
|
208
279
|
|
|
280
|
+
export interface StartupBatchTiming {
|
|
281
|
+
readonly names: readonly string[];
|
|
282
|
+
readonly phase: number;
|
|
283
|
+
readonly ms: number;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function serviceName(service: HostedService): string {
|
|
287
|
+
return service.diagnosticName ?? service.constructor?.name ?? "HostedService";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Neighbouring `concurrentStart` services start together; the rest one by one, in order. */
|
|
291
|
+
function batchesOf(group: readonly HostedService[]): HostedService[][] {
|
|
292
|
+
const batches: HostedService[][] = [];
|
|
293
|
+
for (const service of group) {
|
|
294
|
+
const last = batches.at(-1);
|
|
295
|
+
if (service.concurrentStart === true && last !== undefined && last[0]?.concurrentStart === true) last.push(service);
|
|
296
|
+
else batches.push([service]);
|
|
297
|
+
}
|
|
298
|
+
return batches;
|
|
299
|
+
}
|
|
300
|
+
|
|
209
301
|
/** Groups by ascending `phase` (default 0), keeping registration order inside a group. */
|
|
210
302
|
function groupByPhase(services: readonly HostedService[]): HostedService[][] {
|
|
211
303
|
const byPhase = new Map<number, HostedService[]>();
|
|
@@ -2,6 +2,7 @@ import { DI, type ProviderDefinition } from "../../di";
|
|
|
2
2
|
import { KernelError } from "../errors";
|
|
3
3
|
import type { EnvironmentName } from "../types";
|
|
4
4
|
import type { Configuration } from "./Configuration";
|
|
5
|
+
import { ConfigValidationError } from "./ConfigValidationError";
|
|
5
6
|
import { isConfigDefinition, type AppConfig, type ConfigDefinition, type ConfigInspection, type ConfigView, type ValidatableConfig } from "./defineConfig";
|
|
6
7
|
|
|
7
8
|
/** One immutable set of resolved module configurations per kernel. */
|
|
@@ -29,6 +30,10 @@ export class ConfigRegistry {
|
|
|
29
30
|
issues.push(error instanceof Error ? error.message : "Configuration validation failed.");
|
|
30
31
|
}
|
|
31
32
|
}
|
|
33
|
+
// Invalid keys of every config come as one error, so they are fixed in one pass.
|
|
34
|
+
if (errors.length > 1 && errors.every((error) => error instanceof ConfigValidationError)) {
|
|
35
|
+
throw new ConfigValidationError(environment, errors.flatMap((error) => (error as ConfigValidationError).issues));
|
|
36
|
+
}
|
|
32
37
|
if (errors.length === 1) throw errors[0];
|
|
33
38
|
if (errors.length > 1) throw new AggregateError(errors, `Configuration validation failed:\n${issues.join("\n")}`);
|
|
34
39
|
this.providers = Object.freeze(providers);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { KernelError } from "../errors";
|
|
2
|
+
|
|
3
|
+
export type ConfigIssueKind = "missing-secret" | "development-secret" | "number" | "boolean" | "string" | "enum" | "validator" | "conflict";
|
|
4
|
+
|
|
5
|
+
/** One invalid configuration key, with what can change it. */
|
|
6
|
+
export interface ConfigIssue {
|
|
7
|
+
/** Full key: `db.password`. */
|
|
8
|
+
readonly key: string;
|
|
9
|
+
/** Config prefix: `db`; empty for a config without one. */
|
|
10
|
+
readonly section: string;
|
|
11
|
+
readonly kind: ConfigIssueKind;
|
|
12
|
+
/** The problem without the key: `expected a finite number, got "x" (BAZIS_DB__PORT)`. */
|
|
13
|
+
readonly message: string;
|
|
14
|
+
/** Environment variables that set the key. */
|
|
15
|
+
readonly env: readonly string[];
|
|
16
|
+
/** The variable that supplied the wrong value. */
|
|
17
|
+
readonly source?: string;
|
|
18
|
+
readonly allowed?: readonly string[];
|
|
19
|
+
/** The file with the `defineConfig` call. */
|
|
20
|
+
readonly declaredIn?: string;
|
|
21
|
+
readonly environment: "development" | "test" | "production";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The configuration is invalid. `issues` lists every invalid key of every
|
|
26
|
+
* config at once, so the startup screen shows them together.
|
|
27
|
+
*/
|
|
28
|
+
export class ConfigValidationError extends KernelError {
|
|
29
|
+
public readonly issues: readonly ConfigIssue[];
|
|
30
|
+
|
|
31
|
+
public constructor(public readonly environment: string, issues: readonly ConfigIssue[]) {
|
|
32
|
+
// "key — text", not "key: text": the console redaction would read
|
|
33
|
+
// "db.password: required" as a secret value and hide the word "required".
|
|
34
|
+
super(`Invalid configuration (environment "${environment}"): ${issues.map((issue) => `${issue.key} — ${issue.message}`).join("; ")}.`);
|
|
35
|
+
this.issues = Object.freeze([...issues]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
const FRAME = /\(?((?:file:\/\/)?(?:\/|[A-Za-z]:\\)[^():]+):(\d+):\d+\)?\s*$/;
|
|
4
|
+
|
|
5
|
+
/** Frames of the configuration code itself are skipped: the caller is what matters. */
|
|
6
|
+
const OWN_DIRECTORY = import.meta.dir;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `src/app/config/db.config.ts:3` of the first stack frame outside the
|
|
10
|
+
* configuration code, relative to the working directory; undefined when the
|
|
11
|
+
* stack has no such frame (a compiled executable keeps only virtual paths).
|
|
12
|
+
*/
|
|
13
|
+
export function callerFile(stack: string | undefined, cwd = process.cwd()): string | undefined {
|
|
14
|
+
for (const line of (stack ?? "").split("\n").slice(1)) {
|
|
15
|
+
const match = FRAME.exec(line.trim());
|
|
16
|
+
if (!match) continue;
|
|
17
|
+
const file = match[1]!.replace(/^file:\/\//, "");
|
|
18
|
+
if (file.startsWith(OWN_DIRECTORY) || file.includes("$bunfs")) continue;
|
|
19
|
+
const relative = path.relative(cwd, file);
|
|
20
|
+
return `${relative.startsWith("..") ? file : relative}:${match[2]}`;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
@@ -5,6 +5,8 @@ import { Configuration } from "./Configuration";
|
|
|
5
5
|
import { Secret } from "./Secret";
|
|
6
6
|
import { ENV_PREFIX } from "./sources";
|
|
7
7
|
import { isSensitiveKey, redactSensitiveText } from "../../../library/redaction";
|
|
8
|
+
import { ConfigValidationError, type ConfigIssue, type ConfigIssueKind } from "./ConfigValidationError";
|
|
9
|
+
import { callerFile } from "./callerFile";
|
|
8
10
|
|
|
9
11
|
type Stand = "development" | "test" | "production";
|
|
10
12
|
type Primitive = string | number | boolean;
|
|
@@ -126,6 +128,8 @@ export function defineConfig<T extends object>(schema: SchemaFor<T>): ConfigDefi
|
|
|
126
128
|
export function defineConfig<T extends object>(prefix: string, schema: SchemaFor<T>): ConfigDefinition<T>;
|
|
127
129
|
export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, maybeSchema?: ConfigSchema<Defaults>): ConfigDefinition<ConfigShape> {
|
|
128
130
|
const prefix = typeof prefixOrSchema === "string" ? prefixOrSchema : "";
|
|
131
|
+
// The file that declared the config, for "declared in" of a startup failure.
|
|
132
|
+
const declaredIn = callerFile(new Error().stack);
|
|
129
133
|
const input = typeof prefixOrSchema === "string" ? maybeSchema! : prefixOrSchema;
|
|
130
134
|
const schema = Object.freeze({
|
|
131
135
|
default: Object.freeze(Object.fromEntries(Object.entries(input.default).map(([key, value]) => [key, copyCell(value)]))),
|
|
@@ -153,12 +157,17 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
|
|
|
153
157
|
if (!["development", "test", "production"].includes(environment)) throw new KernelError("Unknown configuration environment.");
|
|
154
158
|
const values = new Map<string, ConfigValueType>();
|
|
155
159
|
const inspection: ConfigInspection[] = [];
|
|
156
|
-
const issues:
|
|
160
|
+
const issues: ConfigIssue[] = [];
|
|
161
|
+
const issue = (key: string, kind: ConfigIssueKind, message: string, extra: Partial<ConfigIssue> = {}) => issues.push(Object.freeze({
|
|
162
|
+
key: fullKey(key), section: prefix, kind, message, env: names.get(key)!, environment,
|
|
163
|
+
...(declaredIn === undefined ? {} : { declaredIn }), ...extra,
|
|
164
|
+
}));
|
|
157
165
|
// A wrong value names what came and where to change it: the variable that
|
|
158
166
|
// supplied it, or the variables that can override the value from the code.
|
|
159
167
|
// A key with a sensitive name never shows its value.
|
|
160
168
|
const got = (key: string, raw: unknown) => raw === undefined || isSensitiveKey(fullKey(key)) ? "" : `, got ${JSON.stringify(String(raw)).slice(0, 80)}`;
|
|
161
169
|
const source = (key: string, selected: { readonly name: string } | undefined) => ` (${selected ? selected.name : names.get(key)!.join(", ")})`;
|
|
170
|
+
const from = (selected: { readonly name: string } | undefined): Partial<ConfigIssue> => selected ? { source: selected.name } : {};
|
|
162
171
|
for (const key of keys) {
|
|
163
172
|
const base = schema.default[key]!;
|
|
164
173
|
const override = schema[environment]?.[key];
|
|
@@ -175,46 +184,41 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
|
|
|
175
184
|
const priority = Math.max(...candidates.map(candidate => candidate.priority));
|
|
176
185
|
const winners = candidates.filter(candidate => candidate.priority === priority);
|
|
177
186
|
const selected = winners[0];
|
|
178
|
-
if (winners.some(candidate => candidate.raw !== selected?.raw))
|
|
187
|
+
if (winners.some(candidate => candidate.raw !== selected?.raw)) issue(key, "conflict", "conflicting environment aliases");
|
|
179
188
|
const raw = selected?.raw ?? (isSecret(cell) || isEnum(cell) ? cell.default : cell);
|
|
180
189
|
let value: ConfigValueType | undefined;
|
|
181
190
|
if (type === "secret") {
|
|
182
|
-
if (typeof raw !== "string" || !raw.trim())
|
|
191
|
+
if (typeof raw !== "string" || !raw.trim()) issue(key, "missing-secret", `required non-empty secret is not set (${names.get(key)!.join(", ")})`);
|
|
183
192
|
// A secret default in the code is a development value; production must
|
|
184
193
|
// get the secret from a source or declare its own value explicitly.
|
|
185
194
|
// No "apiKey: …" in the hint: the console redaction would hide it.
|
|
186
|
-
else if (environment === "production" && selected === undefined && override === undefined)
|
|
195
|
+
else if (environment === "production" && selected === undefined && override === undefined) issue(key, "development-secret", `production would use the development default secret from the code; set ${names.get(key)!.join(" or ")}, or declare ${key} in the production section`);
|
|
187
196
|
else value = new Secret(raw);
|
|
188
197
|
} else if (type === "number") {
|
|
189
198
|
const parsed = Number(raw);
|
|
190
|
-
if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed))
|
|
199
|
+
if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed)) issue(key, "number", `expected a finite number${got(key, raw)}${source(key, selected)}`, from(selected));
|
|
191
200
|
else value = parsed;
|
|
192
201
|
} else if (type === "boolean") {
|
|
193
202
|
const normalized = String(raw).toLowerCase();
|
|
194
203
|
if (normalized === "true" || normalized === "1") value = true;
|
|
195
204
|
else if (normalized === "false" || normalized === "0") value = false;
|
|
196
|
-
else
|
|
205
|
+
else issue(key, "boolean", `expected a boolean (true, false, 1, 0)${got(key, raw)}${source(key, selected)}`, from(selected));
|
|
197
206
|
} else if (typeof raw === "string") value = raw;
|
|
198
|
-
else
|
|
207
|
+
else issue(key, "string", "expected a string");
|
|
199
208
|
if (value === undefined) continue;
|
|
200
|
-
if (isEnum(base) && !base.values.includes(value as string | number))
|
|
209
|
+
if (isEnum(base) && !base.values.includes(value as string | number)) issue(key, "enum", `${isSensitiveKey(fullKey(key)) ? "the value" : JSON.stringify(value)} is not allowed, use one of: ${base.values.join(", ")}${source(key, selected)}`, { ...from(selected), allowed: Object.freeze(base.values.map(String)) });
|
|
201
210
|
try {
|
|
202
|
-
const
|
|
211
|
+
const problem = validators[key]?.(value);
|
|
203
212
|
// A validator may quote its own value; hide it in that message only.
|
|
204
213
|
// Replacing secrets across the whole text turned a one-letter secret
|
|
205
214
|
// into "m***il.***piKey".
|
|
206
215
|
const sensitive = value instanceof Secret ? value.reveal() : typeof value === "string" && isSensitiveKey(fullKey(key)) ? value : "";
|
|
207
|
-
if (
|
|
208
|
-
} catch {
|
|
216
|
+
if (problem) issue(key, "validator", sensitive.trim() ? problem.replaceAll(sensitive, "***") : problem, from(selected));
|
|
217
|
+
} catch { issue(key, "validator", "validator failed", from(selected)); }
|
|
209
218
|
values.set(key, value);
|
|
210
219
|
inspection.push(Object.freeze({ key: fullKey(key), type: type as ConfigInspection["type"], env: names.get(key)!, source: selected?.source ?? (override === undefined ? "default" : environment), value: safeValue(fullKey(key), value) }));
|
|
211
220
|
}
|
|
212
|
-
if (issues.length)
|
|
213
|
-
// "key — text", not "key: text": the console redaction would read
|
|
214
|
-
// "db.password: required" as a secret value and hide the word "required".
|
|
215
|
-
const message = issues.join("; ");
|
|
216
|
-
throw new KernelError(`Invalid configuration (environment "${environment}"): ${message}.`);
|
|
217
|
-
}
|
|
221
|
+
if (issues.length) throw new ConfigValidationError(environment, issues);
|
|
218
222
|
const rows = Object.freeze(inspection);
|
|
219
223
|
return Object.freeze({
|
|
220
224
|
environment,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** One hosted service that failed to start, by its diagnostic name. */
|
|
2
|
+
export interface StartupFailure {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly error: unknown;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Several services that start together (connections of the infrastructure
|
|
9
|
+
* manifest) failed. `failures` keeps every one with its name, so the startup
|
|
10
|
+
* screen shows all of them at once.
|
|
11
|
+
*/
|
|
12
|
+
export class StartupFailuresError extends AggregateError {
|
|
13
|
+
public constructor(public readonly failures: readonly StartupFailure[]) {
|
|
14
|
+
super(failures.map((failure) => failure.error), `${failures.length} services failed to start: ${failures.map((failure) => failure.name).join(", ")}.`);
|
|
15
|
+
this.name = "StartupFailuresError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -3,3 +3,4 @@ export { KernelError } from "./KernelError";
|
|
|
3
3
|
export { ShutdownTimeoutError } from "./ShutdownTimeoutError";
|
|
4
4
|
export { StartupAbortedError } from "./StartupAbortedError";
|
|
5
5
|
export { StartupTimeoutError } from "./StartupTimeoutError";
|
|
6
|
+
export { StartupFailuresError, type StartupFailure } from "./StartupFailuresError";
|
package/core/kernel/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export { ConsoleLogger, type ConsoleLoggerOptions } from "./logging/ConsoleLogge
|
|
|
11
11
|
|
|
12
12
|
export { Configuration, loadConfiguration } from "./config/Configuration";
|
|
13
13
|
export { ConfigRegistry } from "./config/ConfigRegistry";
|
|
14
|
+
export { ConfigValidationError, type ConfigIssue, type ConfigIssueKind } from "./config/ConfigValidationError";
|
|
14
15
|
export { Secret } from "./config/Secret";
|
|
15
16
|
export {
|
|
16
17
|
defineConfig,
|
|
@@ -86,7 +87,18 @@ export {
|
|
|
86
87
|
ShutdownTimeoutError,
|
|
87
88
|
StartupAbortedError,
|
|
88
89
|
StartupTimeoutError,
|
|
90
|
+
StartupFailuresError,
|
|
91
|
+
type StartupFailure,
|
|
89
92
|
} from "./errors";
|
|
93
|
+
export {
|
|
94
|
+
StartupReport,
|
|
95
|
+
type ConnectionDescription,
|
|
96
|
+
type ConnectionFact,
|
|
97
|
+
type ListenerFact,
|
|
98
|
+
type RouteFact,
|
|
99
|
+
type SchemaFact,
|
|
100
|
+
type TimingFact,
|
|
101
|
+
} from "./report/StartupReport";
|
|
90
102
|
export type {
|
|
91
103
|
ConfigSource,
|
|
92
104
|
EnvironmentName,
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ConfigInspection } from "../config/defineConfig";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the application found and did while it started: connections, schema
|
|
5
|
+
* changes, routes, listeners and warnings. Subsystems record facts here
|
|
6
|
+
* (`InfraLifecycle`, `OrmLifecycle`, `HttpServer`); the kernel renders them as
|
|
7
|
+
* the startup screen, or as one log line when the output is not a terminal.
|
|
8
|
+
*
|
|
9
|
+
* One report per kernel. It is registered in the container, so a subsystem
|
|
10
|
+
* takes it with `resolver.has(StartupReport)` and records only when present.
|
|
11
|
+
*/
|
|
12
|
+
export class StartupReport {
|
|
13
|
+
private readonly connectionList = new Map<string, ConnectionFact>();
|
|
14
|
+
private readonly schemaList: SchemaFact[] = [];
|
|
15
|
+
private readonly routeList: RouteFact[] = [];
|
|
16
|
+
private readonly listenerList: ListenerFact[] = [];
|
|
17
|
+
private readonly warningList: string[] = [];
|
|
18
|
+
private readonly timingList: TimingFact[] = [];
|
|
19
|
+
private configRows: readonly ConfigInspection[] = [];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether facts are rendered on the startup screen. Subsystems that used to
|
|
23
|
+
* print their own lines (ORM schema changes) print them only when it is off.
|
|
24
|
+
*/
|
|
25
|
+
constructor(public readonly screen: boolean = false) {}
|
|
26
|
+
|
|
27
|
+
connection(fact: ConnectionFact): void {
|
|
28
|
+
this.connectionList.set(fact.name, { ...this.connectionList.get(fact.name), ...fact });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
schema(fact: SchemaFact): void {
|
|
32
|
+
this.schemaList.push(fact);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
routes(routes: readonly RouteFact[]): void {
|
|
36
|
+
this.routeList.push(...routes);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
listener(fact: ListenerFact): void {
|
|
40
|
+
this.listenerList.push(fact);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
warn(text: string): void {
|
|
44
|
+
if (!this.warningList.includes(text)) this.warningList.push(text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
timing(fact: TimingFact): void {
|
|
48
|
+
this.timingList.push(fact);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resolved configuration keys with masked secrets, recorded when the kernel is built. */
|
|
52
|
+
configuration(rows: readonly ConfigInspection[]): void {
|
|
53
|
+
this.configRows = rows;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get config(): readonly ConfigInspection[] { return this.configRows; }
|
|
57
|
+
|
|
58
|
+
get connections(): readonly ConnectionFact[] { return [...this.connectionList.values()]; }
|
|
59
|
+
get schemas(): readonly SchemaFact[] { return this.schemaList; }
|
|
60
|
+
get routeFacts(): readonly RouteFact[] { return this.routeList; }
|
|
61
|
+
get listeners(): readonly ListenerFact[] { return this.listenerList; }
|
|
62
|
+
get warnings(): readonly string[] { return this.warningList; }
|
|
63
|
+
get timings(): readonly TimingFact[] { return this.timingList; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A connection of the infrastructure manifest. */
|
|
67
|
+
export interface ConnectionFact {
|
|
68
|
+
/** Manifest key: `db`, `cache`. */
|
|
69
|
+
readonly name: string;
|
|
70
|
+
readonly status: "up" | "down" | "wait";
|
|
71
|
+
/** What it connects to, from the connector's `describe()`: `PostgreSQL 17.6`. */
|
|
72
|
+
readonly kind?: string;
|
|
73
|
+
/** Address without credentials: `postgres@127.0.0.1:55432/shop`. */
|
|
74
|
+
readonly address?: string;
|
|
75
|
+
/** Short facts: `pool 10`, `green`. */
|
|
76
|
+
readonly details?: readonly string[];
|
|
77
|
+
/** Environment variables that change the address, for fix hints. */
|
|
78
|
+
readonly settings?: readonly string[];
|
|
79
|
+
readonly ms?: number;
|
|
80
|
+
readonly error?: unknown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What a connector says about its connection, for the startup screen and failures. */
|
|
84
|
+
export interface ConnectionDescription {
|
|
85
|
+
/** Product and version: `PostgreSQL 17.6`, `Redis 7.4`. */
|
|
86
|
+
readonly kind?: string;
|
|
87
|
+
/** Address without credentials. */
|
|
88
|
+
readonly address?: string;
|
|
89
|
+
readonly details?: readonly string[];
|
|
90
|
+
/** Environment variables or config keys that change the address, for fix hints. */
|
|
91
|
+
readonly settings?: readonly string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Schema work of one database context at start. */
|
|
95
|
+
export interface SchemaFact {
|
|
96
|
+
readonly context: string;
|
|
97
|
+
readonly mode: string;
|
|
98
|
+
readonly applied: readonly string[];
|
|
99
|
+
readonly migrated: readonly string[];
|
|
100
|
+
readonly baselined: readonly string[];
|
|
101
|
+
readonly warnings: readonly string[];
|
|
102
|
+
readonly ms?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RouteFact {
|
|
106
|
+
readonly method: string;
|
|
107
|
+
readonly path: string;
|
|
108
|
+
/** `TaskController.list`. */
|
|
109
|
+
readonly handler: string;
|
|
110
|
+
/** Not protected by `@Authorize`. */
|
|
111
|
+
readonly open: boolean;
|
|
112
|
+
/** Policy or roles for a protected route. */
|
|
113
|
+
readonly access?: string;
|
|
114
|
+
/** Request model, list query, cache. */
|
|
115
|
+
readonly notes?: readonly string[];
|
|
116
|
+
/** Built-in endpoints (`/health`, `/docs`) are listed separately. */
|
|
117
|
+
readonly builtIn?: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ListenerFact {
|
|
121
|
+
readonly protocol: "http" | "grpc";
|
|
122
|
+
readonly url: string;
|
|
123
|
+
readonly hostname: string;
|
|
124
|
+
readonly port: number;
|
|
125
|
+
readonly health?: string;
|
|
126
|
+
readonly docs?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Wall time of one startup stage. */
|
|
130
|
+
export interface TimingFact {
|
|
131
|
+
readonly stage: "infrastructure" | "schema" | "services";
|
|
132
|
+
readonly names: readonly string[];
|
|
133
|
+
readonly ms: number;
|
|
134
|
+
}
|