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
@@ -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
+ }
@@ -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
- console.info(`[orm:migrations] applied: ${result.migrated.join(", ")}`);
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
- console.info(`[orm:schema] applied ${result.applied.length} operation(s): ${result.applied.join(", ")}`);
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
- console.info(`[orm:migrations] baseline: the schema was created from the model, recorded as applied without running: ${result.baselined.join(", ")}`);
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
- console.warn(`[orm:schema] ${warning}`);
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
- console.info(`[orm:migrate] applied ${result.applied} operation(s): ${result.operations.join(", ")}`);
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
- console.warn(`[orm:migrate] ${warning}`);
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
- console.info(`[orm:migrations] applied: ${result.applied.join(", ")}`);
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
- await runConfiguredTargets();
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)) boundaryDiagnostics.push(`BAZIS_CODEGEN_SOURCE_UNASSIGNED: ${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
- console.log(`[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}`);
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) console.warn(`[di:generate] WARNING: ${warning}`);
504
- console.log(`[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)}`);
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
- for (let index = 0; index < fatalErrors.length; index += 1) {
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
+ }