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.
- 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 +8 -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 +30 -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 +41 -10
- package/core/infra/connectors/postgres.ts +35 -6
- package/core/infra/connectors/redis.ts +64 -8
- package/core/infra/index.ts +5 -5
- package/core/kernel/Bazis.ts +21 -1
- package/core/kernel/Kernel.ts +55 -1
- package/core/kernel/KernelBuilder.ts +39 -1
- package/core/kernel/LifecycleCoordinator.ts +105 -4
- package/core/kernel/SupervisedHostedService.ts +5 -0
- 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/StartupTimeoutError.ts +8 -5
- 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/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/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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { DiError } from "../../di";
|
|
2
|
+
import { ServiceValidationError } from "../../di/errors/ServiceValidationError";
|
|
3
|
+
import { redactSensitiveText } from "../../../library/redaction";
|
|
4
|
+
import type { Problem, Screen } from "../../../library/terminal";
|
|
5
|
+
import { ConfigValidationError, type ConfigIssue } from "../config/ConfigValidationError";
|
|
6
|
+
import { KernelError, StartupFailuresError, StartupTimeoutError } from "../errors";
|
|
7
|
+
import type { ConnectionFact, StartupReport } from "./StartupReport";
|
|
8
|
+
import { formatStartupFailure } from "../startupFailure";
|
|
9
|
+
|
|
10
|
+
/** Startup stages in execution order. */
|
|
11
|
+
export const STARTUP_STAGES = ["configuration", "modules", "infrastructure", "schema", "services"] as const;
|
|
12
|
+
export type StartupStage = (typeof STARTUP_STAGES)[number];
|
|
13
|
+
|
|
14
|
+
/** Which stage failed, the banner text and one problem per cause. */
|
|
15
|
+
export interface StartupDiagnosis {
|
|
16
|
+
readonly stage: StartupStage;
|
|
17
|
+
readonly title: string;
|
|
18
|
+
readonly problems: readonly Problem[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StartupFailureContext {
|
|
22
|
+
readonly report?: StartupReport;
|
|
23
|
+
/** Hosted services whose start failed. */
|
|
24
|
+
readonly failedServices?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Turns a startup failure into problems a person can act on: every invalid
|
|
29
|
+
* configuration key, every refused connection, a missing DI registration with
|
|
30
|
+
* what to connect. An unknown error keeps its text and stack.
|
|
31
|
+
*/
|
|
32
|
+
export function diagnoseStartupFailure(error: unknown, screen: Screen, context: StartupFailureContext = {}): StartupDiagnosis {
|
|
33
|
+
const t = screen.t;
|
|
34
|
+
if (error instanceof ConfigValidationError) {
|
|
35
|
+
const problems = error.issues.map((issue) => configProblem(issue, screen));
|
|
36
|
+
return { stage: "configuration", title: t.failure.config(problems.length), problems };
|
|
37
|
+
}
|
|
38
|
+
if (error instanceof StartupTimeoutError) {
|
|
39
|
+
const seconds = `${Math.round(error.timeoutMs / 100) / 10} s`;
|
|
40
|
+
const pending = error.pending ?? "";
|
|
41
|
+
return {
|
|
42
|
+
stage: stageOf(pending.split(", ")[0] ?? ""),
|
|
43
|
+
title: t.failure.timeout(seconds),
|
|
44
|
+
problems: [{
|
|
45
|
+
tag: "TIMEOUT",
|
|
46
|
+
where: pending || "start",
|
|
47
|
+
cause: t.failure.timeoutCause(pending || "start", seconds),
|
|
48
|
+
fixes: [t.infra.hint.deadline, screen.code(t.infra.hint.raiseLimit)],
|
|
49
|
+
}],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const down = (context.report?.connections ?? []).filter((connection) => connection.status === "down");
|
|
53
|
+
if (down.length > 0) {
|
|
54
|
+
const total = context.report?.connections.length ?? down.length;
|
|
55
|
+
return { stage: "infrastructure", title: t.failure.infra(down.length, total), problems: down.map((connection) => connectionProblem(connection, screen)) };
|
|
56
|
+
}
|
|
57
|
+
if (error instanceof ServiceValidationError) {
|
|
58
|
+
return { stage: "modules", title: t.failure.di, problems: diProblems(error.issues, screen) };
|
|
59
|
+
}
|
|
60
|
+
if (error instanceof DiError) {
|
|
61
|
+
return { stage: "modules", title: t.failure.di, problems: diProblems([error.message], screen) };
|
|
62
|
+
}
|
|
63
|
+
if (error instanceof StartupFailuresError) {
|
|
64
|
+
return {
|
|
65
|
+
stage: stageOf(error.failures[0]?.name ?? ""),
|
|
66
|
+
title: t.failure.app,
|
|
67
|
+
problems: error.failures.map((failure) => genericProblem(failure.error, failure.name)),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const failed = context.failedServices?.[0];
|
|
71
|
+
const stage = error instanceof KernelError && failed === undefined ? "configuration" : stageOf(failed ?? "");
|
|
72
|
+
return { stage, title: t.failure.app, problems: [genericProblem(error, failed)] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `infra:db` → infrastructure, `schema:TaskDbContext` → schema, the rest → services. */
|
|
76
|
+
function stageOf(name: string): StartupStage {
|
|
77
|
+
if (name.startsWith("infra:")) return "infrastructure";
|
|
78
|
+
if (name.startsWith("schema:")) return "schema";
|
|
79
|
+
return "services";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function configProblem(issue: ConfigIssue, screen: Screen): Problem {
|
|
83
|
+
const t = screen.t;
|
|
84
|
+
const variables = issue.env.map((name) => screen.code(name)).join(" / ");
|
|
85
|
+
const causes: Record<ConfigIssue["kind"], string> = {
|
|
86
|
+
"missing-secret": t.config.issue.missingSecret,
|
|
87
|
+
"development-secret": t.config.issue.devSecret,
|
|
88
|
+
number: t.config.issue.number,
|
|
89
|
+
boolean: t.config.issue.boolean,
|
|
90
|
+
string: t.config.issue.string,
|
|
91
|
+
enum: t.config.issue.enumValue((issue.allowed ?? []).join(", ")),
|
|
92
|
+
validator: issue.message,
|
|
93
|
+
conflict: t.config.issue.conflict,
|
|
94
|
+
};
|
|
95
|
+
const fixes: string[] = [];
|
|
96
|
+
if (issue.kind === "missing-secret") fixes.push(`${t.config.fix.setVariable(variables)}, ${t.config.fix.orSecretFile}`);
|
|
97
|
+
else if (issue.kind === "development-secret") fixes.push(t.config.fix.setVariable(variables), t.config.fix.orProduction(screen.code(issue.key.split(".").at(-1) ?? issue.key)));
|
|
98
|
+
else fixes.push(t.config.fix.change(issue.source === undefined ? variables : screen.code(issue.source)));
|
|
99
|
+
// The wrong value and the variable that brought it.
|
|
100
|
+
const got = /got (".*?") \((\S+)\)$|^(".*?") is not allowed.*\((\S+)\)$/.exec(issue.message);
|
|
101
|
+
const detail = got === null ? "" : `\n${t.config.issue.got(redactSensitiveText(got[1] ?? got[3] ?? ""), got[2] ?? got[4] ?? "")}`;
|
|
102
|
+
return {
|
|
103
|
+
tag: "CONFIG",
|
|
104
|
+
where: issue.key,
|
|
105
|
+
cause: `${causes[issue.kind]}${detail}`,
|
|
106
|
+
fixes,
|
|
107
|
+
...(issue.declaredIn === undefined ? {} : { declaredIn: issue.declaredIn }),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function connectionProblem(connection: ConnectionFact, screen: Screen): Problem {
|
|
112
|
+
const t = screen.t;
|
|
113
|
+
const message = redactSensitiveText(errorText(connection.error)).replace(/^Infra connector "[^"]+": /, "");
|
|
114
|
+
const pick = (pattern: RegExp) => (connection.settings ?? []).filter((name) => pattern.test(name)).map((name) => screen.code(name)).join(", ");
|
|
115
|
+
// Credentials for an authentication failure, the address for the rest.
|
|
116
|
+
const credentials = pick(/USER|PASSWORD|KEY|TOKEN|SECRET/i);
|
|
117
|
+
const settings = pick(/HOST|PORT|URL|DATABASE|ADDRESS/i);
|
|
118
|
+
const fixes: string[] = [];
|
|
119
|
+
const kind = (connection.kind ?? connection.name).split(" ")[0] ?? connection.name;
|
|
120
|
+
if (/password authentication failed|authentication failed|WRONGPASS|NOAUTH|401|403|invalid api key|unauthorized/i.test(message)) {
|
|
121
|
+
fixes.push(t.infra.hint.password(credentials || connection.name));
|
|
122
|
+
} else if (/database "[^"]+" does not exist|3D000/i.test(message)) {
|
|
123
|
+
fixes.push(t.infra.hint.database);
|
|
124
|
+
} else if (/ENOTFOUND|getaddrinfo|could not resolve|DNS/i.test(message)) {
|
|
125
|
+
fixes.push(t.infra.hint.dns);
|
|
126
|
+
if (settings) fixes.push(t.infra.hint.address(settings));
|
|
127
|
+
} else if (/within \d+ ?ms|timed? ?out|no answer/i.test(message)) {
|
|
128
|
+
fixes.push(t.infra.hint.timeout);
|
|
129
|
+
if (settings) fixes.push(t.infra.hint.address(settings));
|
|
130
|
+
} else if (/ECONNREFUSED|refused|Connection closed|not reachable|cannot reach|failed to connect|unable to connect/i.test(message)) {
|
|
131
|
+
fixes.push(t.infra.hint.refused(kind));
|
|
132
|
+
if (settings) fixes.push(t.infra.hint.address(settings));
|
|
133
|
+
} else if (settings) {
|
|
134
|
+
fixes.push(t.infra.hint.address(settings));
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
tag: "INFRA",
|
|
138
|
+
where: connection.address === undefined ? connection.name : `${connection.name} · ${connection.address}`,
|
|
139
|
+
cause: message,
|
|
140
|
+
fixes,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** One problem per DI issue; every consumer of a missing database is one problem. */
|
|
145
|
+
function diProblems(issues: readonly string[], screen: Screen): Problem[] {
|
|
146
|
+
const t = screen.t;
|
|
147
|
+
const problems: Problem[] = [];
|
|
148
|
+
const database: string[] = [];
|
|
149
|
+
for (const issue of issues) {
|
|
150
|
+
const missing = /Missing dependency "([^"]+)" for "([^"]+)"/.exec(issue);
|
|
151
|
+
if (missing?.[1] === "DatabaseProvider") {
|
|
152
|
+
// The ORM lifecycle is a consumer too; the contexts name the modules.
|
|
153
|
+
if (missing[2] !== "IHostedService") database.push(missing[2]!);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
problems.push({ tag: "DI", where: missing?.[2] ?? "container", cause: redactSensitiveText(issue) });
|
|
157
|
+
}
|
|
158
|
+
if (database.length > 0) {
|
|
159
|
+
problems.unshift({
|
|
160
|
+
tag: "DI",
|
|
161
|
+
where: "DatabaseProvider",
|
|
162
|
+
cause: `${t.failure.dbMissing(database.join(", "))}\nMissing dependency "DatabaseProvider"`,
|
|
163
|
+
fixes: [screen.code(t.failure.dbFix)],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return problems;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function genericProblem(error: unknown, where: string | undefined): Problem {
|
|
170
|
+
const text = errorText(error);
|
|
171
|
+
const name = error instanceof Error ? error.name : "Error";
|
|
172
|
+
const stack = formatStartupFailure(error).split("\n").filter((line) => /^\s+at /.test(line)).slice(0, 6);
|
|
173
|
+
return {
|
|
174
|
+
tag: name === "Error" ? "ERROR" : name.replace(/Error$/, "").toUpperCase() || "ERROR",
|
|
175
|
+
where: where ?? name,
|
|
176
|
+
cause: redactSensitiveText(text),
|
|
177
|
+
...(stack.length === 0 ? {} : { details: stack.map((line) => line.trim()) }),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function errorText(error: unknown): string {
|
|
182
|
+
if (error instanceof AggregateError && error.errors.length > 0) {
|
|
183
|
+
return [error.message, ...error.errors.map((inner) => `- ${inner instanceof Error ? inner.message : String(inner)}`)].join("\n");
|
|
184
|
+
}
|
|
185
|
+
return error instanceof Error ? error.message : String(error);
|
|
186
|
+
}
|
package/core/orm/OrmLifecycle.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { ormHostedPlanValidator } from "./OrmHostedPlan.validator";
|
|
|
2
2
|
import type { HostedService } from "../di";
|
|
3
3
|
import { DatabaseFacade, type DbContextOptions, type EnsureCreatedWithMigrationsResult, type Migration } from "../../library/orm";
|
|
4
4
|
import { compileExpectedSchema } from "../../library/orm/Schema/ExpectedSchema";
|
|
5
|
+
import type { StartupReport } from "../kernel/report/StartupReport";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* ORM lifecycle hosted service: at start it (optionally) creates the schema or
|
|
@@ -11,6 +12,8 @@ import { compileExpectedSchema } from "../../library/orm/Schema/ExpectedSchema";
|
|
|
11
12
|
export class OrmLifecycle implements HostedService {
|
|
12
13
|
readonly planValidator = ormHostedPlanValidator;
|
|
13
14
|
readonly phase: number;
|
|
15
|
+
/** `schema:TaskDbContext` in startup diagnostics and timings. */
|
|
16
|
+
readonly diagnosticName: string;
|
|
14
17
|
/** Internal hosted-plan marker; public hosted services never receive it. */
|
|
15
18
|
readonly __bazisOrmLegacyLifecycle = true;
|
|
16
19
|
/**
|
|
@@ -48,7 +51,9 @@ export class OrmLifecycle implements HostedService {
|
|
|
48
51
|
*/
|
|
49
52
|
private readonly ownsConnection: boolean = true,
|
|
50
53
|
contextName = "DbContext",
|
|
54
|
+
private readonly report?: StartupReport,
|
|
51
55
|
) {
|
|
56
|
+
this.diagnosticName = `schema:${contextName}`;
|
|
52
57
|
const modes = [ensureCreated && "ensureCreated", migrateOnStart && "migrateOnStart", runMigrationsOnStart && migrations.length > 0 && "migrations"].filter(Boolean);
|
|
53
58
|
this.withMigrations = ensureCreated && runMigrationsOnStart && migrations.length > 0;
|
|
54
59
|
this.__bazisSchemaOwner = Object.freeze({ context: contextName, mode: modes.join(" + ") || "none" });
|
|
@@ -73,38 +78,55 @@ export class OrmLifecycle implements HostedService {
|
|
|
73
78
|
|
|
74
79
|
async start(): Promise<void> {
|
|
75
80
|
const database = new DatabaseFacade(this.options.provider, this.options.model);
|
|
81
|
+
const startedAt = performance.now();
|
|
82
|
+
// The startup screen lists the changes; without it they are logged as before.
|
|
83
|
+
const log = this.report?.screen !== true;
|
|
84
|
+
const applied: string[] = [], migrated: string[] = [], baselined: string[] = [], warnings: string[] = [];
|
|
76
85
|
try {
|
|
77
86
|
if (this.ensureCreated) {
|
|
78
87
|
const result: Partial<EnsureCreatedWithMigrationsResult> & Pick<EnsureCreatedWithMigrationsResult, "warnings"> = this.withMigrations ? await database.ensureCreatedWithMigrations(this.migrations) : await database.ensureCreated();
|
|
79
88
|
// Logged in execution order: migrations, schema changes, then the baseline record.
|
|
80
89
|
if (result.migrated && result.migrated.length > 0) {
|
|
81
|
-
|
|
90
|
+
migrated.push(...result.migrated);
|
|
91
|
+
if (log) console.info(`[orm:migrations] applied: ${result.migrated.join(", ")}`);
|
|
82
92
|
}
|
|
83
93
|
if (result.applied && result.applied.length > 0) {
|
|
84
|
-
|
|
94
|
+
applied.push(...result.applied);
|
|
95
|
+
if (log) console.info(`[orm:schema] applied ${result.applied.length} operation(s): ${result.applied.join(", ")}`);
|
|
85
96
|
}
|
|
86
97
|
if (result.baselined && result.baselined.length > 0) {
|
|
87
|
-
|
|
98
|
+
baselined.push(...result.baselined);
|
|
99
|
+
if (log) console.info(`[orm:migrations] baseline: the schema was created from the model, recorded as applied without running: ${result.baselined.join(", ")}`);
|
|
88
100
|
}
|
|
89
101
|
for (const warning of result.warnings) {
|
|
90
|
-
|
|
102
|
+
warnings.push(warning);
|
|
103
|
+
if (log) console.warn(`[orm:schema] ${warning}`);
|
|
91
104
|
}
|
|
92
105
|
}
|
|
93
106
|
if (this.migrateOnStart) {
|
|
94
107
|
const result = await database.migrate();
|
|
95
108
|
if (result.applied > 0) {
|
|
96
|
-
|
|
109
|
+
applied.push(...result.operations);
|
|
110
|
+
if (log) console.info(`[orm:migrate] applied ${result.applied} operation(s): ${result.operations.join(", ")}`);
|
|
97
111
|
}
|
|
98
112
|
for (const warning of result.warnings) {
|
|
99
|
-
|
|
113
|
+
warnings.push(warning);
|
|
114
|
+
if (log) console.warn(`[orm:migrate] ${warning}`);
|
|
100
115
|
}
|
|
101
116
|
}
|
|
102
117
|
if (this.runMigrationsOnStart && this.migrations.length > 0 && !this.withMigrations) {
|
|
103
118
|
const result = await database.migrateVersioned(this.migrations);
|
|
104
119
|
if (result.applied.length > 0) {
|
|
105
|
-
|
|
120
|
+
migrated.push(...result.applied);
|
|
121
|
+
if (log) console.info(`[orm:migrations] applied: ${result.applied.join(", ")}`);
|
|
106
122
|
}
|
|
107
123
|
}
|
|
124
|
+
if (this.__bazisSchemaOwner.mode !== "none") this.report?.schema({
|
|
125
|
+
context: this.__bazisSchemaOwner.context,
|
|
126
|
+
mode: this.__bazisSchemaOwner.mode,
|
|
127
|
+
applied, migrated, baselined, warnings,
|
|
128
|
+
ms: performance.now() - startedAt,
|
|
129
|
+
});
|
|
108
130
|
} catch (error) {
|
|
109
131
|
if (!this.ownsConnection) {
|
|
110
132
|
throw error;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DI, HOSTED_SERVICE, type InjectionToken, type ModuleOwnedContributionSnapshot, type ProviderDefinition, type ProviderDependencyList, type ServiceResolver } from "../di";
|
|
2
2
|
import { getGeneratedClassDeps } from "../di/module/autoDeps";
|
|
3
3
|
import { resolveDependencyList } from "../di/internal/resolveDependencyList";
|
|
4
|
-
import { HEALTH_CHECK } from "../kernel";
|
|
4
|
+
import { HEALTH_CHECK, StartupReport } from "../kernel";
|
|
5
5
|
import { DbContextOptions, OrmError, type DatabaseProvider, type DbContext } from "../../library/orm";
|
|
6
6
|
import { OrmLifecycle } from "./OrmLifecycle";
|
|
7
7
|
import { ownedStoreLifecycleFor, ownedStoreLifecycleHealthFor } from "./OrmOwnedStoreLifecycle";
|
|
@@ -56,7 +56,10 @@ function createOrmLifecycle<TContext extends DbContext>(
|
|
|
56
56
|
config: OrmBuildConfig<TContext>,
|
|
57
57
|
options: DbContextOptions,
|
|
58
58
|
ownsConnection: boolean,
|
|
59
|
+
serviceProvider: unknown,
|
|
59
60
|
): OrmLifecycle {
|
|
61
|
+
const services = serviceProvider as { has?(token: unknown): boolean; resolve?(token: unknown): unknown };
|
|
62
|
+
const report = services.has?.(StartupReport) ? services.resolve?.(StartupReport) as StartupReport : undefined;
|
|
60
63
|
return new OrmLifecycle(
|
|
61
64
|
options,
|
|
62
65
|
config.ensureCreated === true,
|
|
@@ -65,6 +68,7 @@ function createOrmLifecycle<TContext extends DbContext>(
|
|
|
65
68
|
config.runMigrationsOnStart === true,
|
|
66
69
|
ownsConnection,
|
|
67
70
|
config.context.name,
|
|
71
|
+
report,
|
|
68
72
|
);
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -115,7 +119,7 @@ export function buildOrmModuleProviders<TContext extends DbContext>(
|
|
|
115
119
|
});
|
|
116
120
|
return ownedStoreLifecycleFor(container, provider, records);
|
|
117
121
|
}
|
|
118
|
-
return createOrmLifecycle(current, createContextOptions(current, provider), false);
|
|
122
|
+
return createOrmLifecycle(current, createContextOptions(current, provider), false, serviceProvider);
|
|
119
123
|
}),
|
|
120
124
|
),
|
|
121
125
|
];
|
|
@@ -138,7 +142,7 @@ export function buildOrmModuleProviders<TContext extends DbContext>(
|
|
|
138
142
|
// Standalone mode: the context owns its provider and closes it.
|
|
139
143
|
DI.singleton(DI.factoryProvider(HOSTED_SERVICE, [SERVICE_PROVIDER], (serviceProvider) => {
|
|
140
144
|
const current = effectiveConfigFor(serviceProvider, registrationIdentity, config);
|
|
141
|
-
return createOrmLifecycle(current, current === config && optionsAtModuleCall !== undefined ? optionsAtModuleCall : createContextOptions(current, provider), true);
|
|
145
|
+
return createOrmLifecycle(current, current === config && optionsAtModuleCall !== undefined ? optionsAtModuleCall : createContextOptions(current, provider), true, serviceProvider);
|
|
142
146
|
})),
|
|
143
147
|
];
|
|
144
148
|
return providers;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { errorMessage, InfraError, reader, requireValue, postgresConnectionOptions, type InfraConnector, type PostgresConfigShape } from "../infra";
|
|
1
|
+
import { errorMessage, InfraError, reader, requireValue, postgresConnectionOptions, settingNames, type InfraConnector, type PostgresConfigShape } from "../infra";
|
|
2
|
+
import { postgresAddress, serverVersion } from "../infra/connectors/postgres";
|
|
2
3
|
import type { AppConfig, ConfigRegistry } from "../kernel";
|
|
3
4
|
import { postgres, type DatabaseProvider, type PostgresServerTimeouts } from "../../library/orm";
|
|
4
5
|
import { DATABASE_PROVIDER } from "./DATABASE_PROVIDER";
|
|
@@ -88,6 +89,9 @@ export function ormBazisConnect(
|
|
|
88
89
|
): InfraConnector<DatabaseProvider> {
|
|
89
90
|
// Where the connection goes, for the startup error; never the password.
|
|
90
91
|
let target = "PostgreSQL";
|
|
92
|
+
let address: string | undefined;
|
|
93
|
+
let pool: number | undefined;
|
|
94
|
+
let settings: string[] = [];
|
|
91
95
|
return {
|
|
92
96
|
token: DATABASE_PROVIDER,
|
|
93
97
|
config,
|
|
@@ -95,6 +99,9 @@ export function ormBazisConnect(
|
|
|
95
99
|
create(configs) {
|
|
96
100
|
const c = reader(config, configs);
|
|
97
101
|
target = `PostgreSQL at ${String(c.get("host"))}:${String(c.get("port"))} (database "${String(c.get("database"))}", user "${String(c.get("username"))}")`;
|
|
102
|
+
address = postgresAddress({ hostname: String(c.get("host")), port: String(c.get("port")), database: String(c.get("database")), username: String(c.get("username")) });
|
|
103
|
+
pool = c.has("max") && typeof c.get("max") === "number" ? c.get("max") as number : undefined;
|
|
104
|
+
settings = settingNames(config, configs, ["host", "port", "database", "username", "password"]);
|
|
98
105
|
return buildPostgresProvider(config, configs);
|
|
99
106
|
},
|
|
100
107
|
async connect(provider, signal) {
|
|
@@ -111,6 +118,15 @@ export function ormBazisConnect(
|
|
|
111
118
|
async dispose(provider) {
|
|
112
119
|
await provider.close();
|
|
113
120
|
},
|
|
121
|
+
async describe(provider, { connected }) {
|
|
122
|
+
const version = connected ? await serverVersion((sql) => provider.query(sql, [])) : undefined;
|
|
123
|
+
return {
|
|
124
|
+
kind: version === undefined ? "PostgreSQL" : `PostgreSQL ${version}`,
|
|
125
|
+
...(address === undefined ? {} : { address }),
|
|
126
|
+
details: [`pool ${pool ?? 10}`],
|
|
127
|
+
settings,
|
|
128
|
+
};
|
|
129
|
+
},
|
|
114
130
|
healthCheck(provider, signal) {
|
|
115
131
|
return provider.ping(signal);
|
|
116
132
|
},
|
package/core/orm/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { paginate, type PageResult } from "./listQuery";
|
|
|
13
13
|
export { databaseErrorStatus } from "./databaseErrorStatus";
|
|
14
14
|
|
|
15
15
|
import { registerModuleMetadataExpander, type BazisModuleRef } from "../di";
|
|
16
|
+
import { registerModuleDescriber } from "../kernel/report/moduleTree";
|
|
16
17
|
import { OrmOwnedStoreAdmissionError, type DbContext } from "../../library/orm";
|
|
17
18
|
import { registerRepositoryEncapsulationHook } from "./encapsulationHook";
|
|
18
19
|
import { ormModule, type OrmModuleConfig } from "./ormModule";
|
|
@@ -60,6 +61,16 @@ function declaredOwnedStore(config: OrmModuleConfig<DbContext>): unknown {
|
|
|
60
61
|
return descriptor.value;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
// The module tree of the startup screen: `Task, Comment · TaskDbContext`.
|
|
65
|
+
registerModuleDescriber((metadata) => {
|
|
66
|
+
const orm = (metadata as { ormBazis?: OrmModuleConfig<DbContext> | readonly OrmModuleConfig<DbContext>[] }).ormBazis;
|
|
67
|
+
if (orm === undefined) return [];
|
|
68
|
+
return (Array.isArray(orm) ? orm : [orm]).map((config: OrmModuleConfig<DbContext>) => ({
|
|
69
|
+
kind: "table" as const,
|
|
70
|
+
text: `${(config.entities ?? []).map((entity) => (entity as { name?: string }).name ?? "entity").join(", ") || "no entities"} · ${(config.context as { name?: string } | undefined)?.name ?? "DbContext"}`,
|
|
71
|
+
}));
|
|
72
|
+
});
|
|
73
|
+
|
|
63
74
|
registerModuleMetadataExpander((metadata): readonly BazisModuleRef[] => {
|
|
64
75
|
const orm = (metadata as { ormBazis?: OrmModuleConfig<DbContext> | readonly OrmModuleConfig<DbContext>[] }).ormBazis;
|
|
65
76
|
if (orm === undefined) {
|
|
@@ -34,6 +34,29 @@ const isFrameworkSourcePath = (file: string): boolean => FRAMEWORK_SOURCE !== nu
|
|
|
34
34
|
const APP_GENERATED_DIR = "src/generated/bazis";
|
|
35
35
|
let activeGeneratedDir = APP_GENERATED_DIR;
|
|
36
36
|
const plannedWrites = new Map<string, string>();
|
|
37
|
+
/**
|
|
38
|
+
* `BAZIS_CODEGEN_REPORT=<file>`: the CLI renders the result itself. The
|
|
39
|
+
* generator then prints nothing and writes what it did, warned about and
|
|
40
|
+
* failed on as JSON to that file.
|
|
41
|
+
*/
|
|
42
|
+
const REPORT_FILE = process.env.BAZIS_CODEGEN_REPORT || undefined;
|
|
43
|
+
interface CodegenTargetReport {
|
|
44
|
+
readonly name: string; readonly sources: number; readonly deps: number; readonly controllers: number;
|
|
45
|
+
readonly requestModels: number; readonly listModels: number; readonly tools: number; readonly ms: number;
|
|
46
|
+
}
|
|
47
|
+
const codegenReport = {
|
|
48
|
+
ok: false,
|
|
49
|
+
ms: 0,
|
|
50
|
+
targets: [] as CodegenTargetReport[],
|
|
51
|
+
outputs: [] as string[],
|
|
52
|
+
warnings: [] as string[],
|
|
53
|
+
errors: [] as string[],
|
|
54
|
+
};
|
|
55
|
+
const reportInfo = (message: string): void => { if (REPORT_FILE === undefined) console.log(message); };
|
|
56
|
+
const reportWarning = (message: string): void => {
|
|
57
|
+
if (!codegenReport.warnings.includes(message)) codegenReport.warnings.push(message);
|
|
58
|
+
if (REPORT_FILE === undefined) console.warn(`[di:generate] WARNING: ${message}`);
|
|
59
|
+
};
|
|
37
60
|
const plannedRemovals = new Set<string>();
|
|
38
61
|
|
|
39
62
|
// Test-only fault injection for the generator's private disk transaction. It
|
|
@@ -146,7 +169,7 @@ const FRAMEWORK_INTERNAL_PREFIXES = FRAMEWORK_SOURCE === null ? [] : [
|
|
|
146
169
|
"core/di/", "core/kernel/", "core/http/", "core/grpc/", "core/http-client/", "core/background/",
|
|
147
170
|
"core/agent/", "core/websocket/", "core/cache/", "library/validation/", "library/boundary/",
|
|
148
171
|
"library/orm/", "library/jwt/", "library/jsonapi/", "library/openapi/", "core/orm/", "core/infra/",
|
|
149
|
-
"core/app/", "library/http-client/",
|
|
172
|
+
"core/app/", "library/http-client/", "library/terminal/",
|
|
150
173
|
].map((prefix) => `${FRAMEWORK_SOURCE}/${prefix}`);
|
|
151
174
|
|
|
152
175
|
// A declaration is an identity; short names are local to their source/module.
|
|
@@ -299,7 +322,24 @@ function toPascalIdentifier(value: string): string {
|
|
|
299
322
|
.join("");
|
|
300
323
|
}
|
|
301
324
|
|
|
302
|
-
|
|
325
|
+
const codegenStartedAt = performance.now();
|
|
326
|
+
try {
|
|
327
|
+
await runConfiguredTargets();
|
|
328
|
+
codegenReport.ok = true;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
// Errors are listed one per line; a stack says nothing to the application author.
|
|
331
|
+
if (codegenReport.errors.length === 0) {
|
|
332
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
333
|
+
codegenReport.errors.push(...message.split("\n").filter((line) => line.trim().length > 0));
|
|
334
|
+
}
|
|
335
|
+
if (REPORT_FILE === undefined) {
|
|
336
|
+
for (const line of codegenReport.errors) console.error(`[di:generate] ERROR: ${line}`);
|
|
337
|
+
}
|
|
338
|
+
process.exitCode = 1;
|
|
339
|
+
} finally {
|
|
340
|
+
codegenReport.ms = performance.now() - codegenStartedAt;
|
|
341
|
+
if (REPORT_FILE !== undefined) await Bun.write(REPORT_FILE, JSON.stringify(codegenReport));
|
|
342
|
+
}
|
|
303
343
|
|
|
304
344
|
async function runConfiguredTargets(): Promise<void> {
|
|
305
345
|
const totalStartedAt = performance.now();
|
|
@@ -361,9 +401,15 @@ async function runConfiguredTargets(): Promise<void> {
|
|
|
361
401
|
// slice and renderer state. Only a collision *inside* that slice is fatal.
|
|
362
402
|
const candidateIndex = createSourceCandidateIndex(programFiles);
|
|
363
403
|
const boundaryDiagnostics: string[] = [];
|
|
404
|
+
const unassigned: string[] = [];
|
|
364
405
|
for (const [file] of programFiles) {
|
|
365
406
|
if (isFrameworkSourcePath(file)) continue;
|
|
366
|
-
if (!owners.has(file) && candidateIndex.get(file))
|
|
407
|
+
if (!owners.has(file) && candidateIndex.get(file)) unassigned.push(file);
|
|
408
|
+
}
|
|
409
|
+
// A module that is not imported yet (bazis g module --no-register) is not
|
|
410
|
+
// part of any application: skip it with a warning instead of failing.
|
|
411
|
+
for (const file of unassigned.sort((left, right) => left.localeCompare(right))) {
|
|
412
|
+
reportWarning(`BAZIS_CODEGEN_SOURCE_UNASSIGNED: ${file}: not imported from the entrypoint of any target; codegen skipped it.`);
|
|
367
413
|
}
|
|
368
414
|
for (const [name, reachable] of reachableByTarget) {
|
|
369
415
|
for (const file of reachable) {
|
|
@@ -389,7 +435,8 @@ async function runConfiguredTargets(): Promise<void> {
|
|
|
389
435
|
const writeStartedAt = performance.now();
|
|
390
436
|
await commitPlannedWrites();
|
|
391
437
|
const writeMs = performance.now() - writeStartedAt;
|
|
392
|
-
|
|
438
|
+
codegenReport.outputs.push(...[...plannedWrites.keys()].sort((left, right) => left.localeCompare(right)));
|
|
439
|
+
reportInfo(`[di:generate] programFactories=1 programSources=${programFiles.size} targets=${names.join(",")} configMs=${configMs.toFixed(1)} programMs=${programMs.toFixed(1)} discoveryMs=${discoveryMs.toFixed(1)} analysisMs=${analysisMs.toFixed(1)} targetMs=${targetMs.toFixed(1)} renderMs=${renderMs.toFixed(1)} writeMs=${writeMs.toFixed(1)} totalMs=${(performance.now() - totalStartedAt).toFixed(1)} eligible=${eligible} candidates=${candidates} outputs=${plannedWrites.size}`);
|
|
393
440
|
}
|
|
394
441
|
|
|
395
442
|
function createSourceCandidateIndex(files: ReadonlyMap<string, ts.SourceFile>): ReadonlyMap<string, boolean> {
|
|
@@ -500,8 +547,12 @@ async function generateTarget(name: string, reachable: Set<string>, production:
|
|
|
500
547
|
if (!production) {
|
|
501
548
|
stageWrite(`${generatedDir}/bootstrap.ts`, 'import { registerBazisGeneratedRuntime } from "./runtime";\n\nawait registerBazisGeneratedRuntime();\n');
|
|
502
549
|
}
|
|
503
|
-
for (const warning of warnings)
|
|
504
|
-
|
|
550
|
+
for (const warning of warnings) reportWarning(warning);
|
|
551
|
+
codegenReport.targets.push({
|
|
552
|
+
name, sources: sourceEntries.length, deps: classDeps.size, controllers: Object.keys(httpBindings).length,
|
|
553
|
+
requestModels: requestModelImports.length, listModels: listModelImports.length, tools: tools.toolCount, ms: performance.now() - startedAt,
|
|
554
|
+
});
|
|
555
|
+
reportInfo(`[di:generate] target=${name} sources=${sourceEntries.length} deps=${classDeps.size} controllers=${Object.keys(httpBindings).length} requestModels=${requestModelImports.length} listModels=${listModelImports.length} tools=${tools.toolCount} prepassMs=${prepassMs.toFixed(1)} analysisMs=${analysisMs.toFixed(1)} totalMs=${(performance.now() - startedAt).toFixed(1)}`);
|
|
505
556
|
return {
|
|
506
557
|
analysisMs,
|
|
507
558
|
renderMs: performance.now() - renderStartedAt,
|
|
@@ -525,9 +576,7 @@ function failOnFatalErrors(): void {
|
|
|
525
576
|
if (fatalErrors.length === 0) {
|
|
526
577
|
return;
|
|
527
578
|
}
|
|
528
|
-
|
|
529
|
-
console.error(`[di:generate] ERROR: ${fatalErrors[index]}`);
|
|
530
|
-
}
|
|
579
|
+
codegenReport.errors.push(...fatalErrors);
|
|
531
580
|
throw new Error(`di:generate failed with ${fatalErrors.length} fatal codegen error(s).`);
|
|
532
581
|
}
|
|
533
582
|
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { detectTerminal, plain, widthOf, padEnd, padStart, colorAt, Paint, PALETTE, PLAIN, type TerminalStyle } from "./style";
|
|
2
|
+
export { detectLanguage, parseLanguage, projectLanguage, resolveLanguage, systemLanguage, LANGUAGES, type Language, type LanguageSources } from "./language";
|
|
3
|
+
export { count, messages, type Messages, type Noun } from "./messages";
|
|
4
|
+
export { formatBytes, formatDuration, MODE, Screen, type CodeFrame, type Problem, type ScreenOutput, type Status, type TreeRow } from "./screen";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Languages of the terminal output. */
|
|
5
|
+
export type Language = "en" | "ru";
|
|
6
|
+
|
|
7
|
+
export const LANGUAGES: readonly Language[] = Object.freeze(["en", "ru"]);
|
|
8
|
+
|
|
9
|
+
/** `ru`, `ru-RU`, `ru_RU.UTF-8` → `ru`; anything unknown → undefined. */
|
|
10
|
+
export function parseLanguage(value: string | undefined): Language | undefined {
|
|
11
|
+
const code = value?.trim().toLowerCase().split(/[-_.@]/)[0];
|
|
12
|
+
return LANGUAGES.find((language) => language === code);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LanguageSources {
|
|
16
|
+
/** `--lang` of the command line. */
|
|
17
|
+
readonly flag?: string;
|
|
18
|
+
/** `BAZIS_LANG`. */
|
|
19
|
+
readonly env?: string;
|
|
20
|
+
/** `"language"` of `bazis.config.json`. */
|
|
21
|
+
readonly project?: string;
|
|
22
|
+
/** The system locale: `LC_ALL`, `LC_MESSAGES` or `LANG`. */
|
|
23
|
+
readonly system?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The first language that is set and known, in order: the `--lang` flag,
|
|
28
|
+
* `BAZIS_LANG`, the project's `bazis.config.json`, the system locale. English
|
|
29
|
+
* otherwise. An unknown value is skipped, not an error: output language must
|
|
30
|
+
* never stop a command.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveLanguage(sources: LanguageSources): Language {
|
|
33
|
+
return parseLanguage(sources.flag) ?? parseLanguage(sources.env) ?? parseLanguage(sources.project) ?? parseLanguage(sources.system) ?? "en";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `"language"` of the nearest `bazis.config.json` from `cwd` up. */
|
|
37
|
+
export function projectLanguage(cwd = process.cwd()): string | undefined {
|
|
38
|
+
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
39
|
+
const file = join(dir, "bazis.config.json");
|
|
40
|
+
if (existsSync(file)) {
|
|
41
|
+
try {
|
|
42
|
+
const value = (JSON.parse(readFileSync(file, "utf8")) as { language?: unknown }).language;
|
|
43
|
+
return typeof value === "string" ? value : undefined;
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (dirname(dir) === dir) return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function systemLanguage(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
53
|
+
return env.LC_ALL || env.LC_MESSAGES || env.LANG || undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The language of this process: `BAZIS_LANG`, then the project, then the system. */
|
|
57
|
+
export function detectLanguage(options: { flag?: string; env?: NodeJS.ProcessEnv; cwd?: string } = {}): Language {
|
|
58
|
+
const env = options.env ?? process.env;
|
|
59
|
+
return resolveLanguage({ flag: options.flag, env: env.BAZIS_LANG, project: projectLanguage(options.cwd), system: systemLanguage(env) });
|
|
60
|
+
}
|