bazis 0.111.2 → 0.113.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/main.ts CHANGED
@@ -210,7 +210,10 @@ async function runGenerate(cwd: string, args: ParsedGenerateArgs, codegen: CliRu
210
210
  if (result.registered) s.line(` ${s.strong("↗", PALETTE.export)} ${s.text(t.module.registered(host ?? "App.module.ts"))}`);
211
211
  else s.warning(t.module.notRegistered);
212
212
  // What the module needs from the application to run.
213
- if (args.generator === "module" && args.profile !== "empty") s.line(` ${s.strong("◆", PALETTE.service)} ${s.muted(t.module.needsDb)}`);
213
+ if (args.generator === "module" && args.profile !== "empty") {
214
+ s.line(` ${s.strong("◆", PALETTE.service)} ${s.muted(t.module.needsDb)}`);
215
+ s.line(` ${s.strong("◆", PALETTE.service)} ${s.muted(t.module.needsTable(`${args.name}.module.ts`))}`);
216
+ }
214
217
  if (args.generator === "module" && args.profile === "full") s.line(` ${s.strong("◆", PALETTE.service)} ${s.muted(t.module.needsCache)}`);
215
218
  s.line();
216
219
  if (result.dryRun) {
@@ -0,0 +1,21 @@
1
+ import { DiError } from "./DiError";
2
+
3
+ const CONFIGURATION_ERROR = Symbol.for("bazis.configurationError");
4
+
5
+ /**
6
+ * Marks an error found in the application's declarations — a module, a
7
+ * connector, a config — as a configuration error: the start prints it as one
8
+ * `[bazis] configuration error` line instead of a stack. The error keeps its
9
+ * class; the same error is returned.
10
+ */
11
+ export function markConfigurationError<T>(error: T): T {
12
+ if (error instanceof Error && !Object.prototype.hasOwnProperty.call(error, CONFIGURATION_ERROR)) {
13
+ Object.defineProperty(error, CONFIGURATION_ERROR, { value: true });
14
+ }
15
+ return error;
16
+ }
17
+
18
+ /** A DI error, or an error marked with {@link markConfigurationError}. */
19
+ export function isConfigurationError(error: unknown): error is Error {
20
+ return error instanceof DiError || (error instanceof Error && (error as unknown as Record<symbol, unknown>)[CONFIGURATION_ERROR] === true);
21
+ }
@@ -12,3 +12,4 @@ export { ProviderNotFoundError } from "./ProviderNotFoundError";
12
12
  export { ScopeDisposedError } from "./ScopeDisposedError";
13
13
  export { ScopedServiceFromRootError } from "./ScopedServiceFromRootError";
14
14
  export { ServiceValidationError } from "./ServiceValidationError";
15
+ export { isConfigurationError, markConfigurationError } from "./configurationError";
package/core/di/index.ts CHANGED
@@ -10,6 +10,8 @@ export {
10
10
  CircularDependencyError,
11
11
  ClassDependenciesMismatchError,
12
12
  DiError,
13
+ isConfigurationError,
14
+ markConfigurationError,
13
15
  InvalidProviderError,
14
16
  ModuleEncapsulationError,
15
17
  ModuleOwnedProviderConflictError,
@@ -35,6 +37,7 @@ export {
35
37
  registerModuleOwnedContributionValidator,
36
38
  registerModuleOwnedProviderContributor,
37
39
  expandModuleMetadata,
40
+ deferModuleDeclarationError,
38
41
  createModuleOwnedMetadataChannel,
39
42
  createModuleOwnedProviderChannel,
40
43
  scoped,
@@ -6,6 +6,7 @@ export { ModuleRegistrar } from "./ModuleRegistrar";
6
6
  export {
7
7
  registerModuleMetadataExpander,
8
8
  expandModuleMetadata,
9
+ deferModuleDeclarationError,
9
10
  type ModuleMetadataExpander,
10
11
  } from "./moduleExtensions";
11
12
  export {
@@ -1,3 +1,4 @@
1
+ import { markConfigurationError } from "../errors/configurationError";
1
2
  import type { BazisModuleMetadata, BazisModuleRef } from "./types";
2
3
 
3
4
  /**
@@ -21,15 +22,37 @@ export function registerModuleMetadataExpander(expander: ModuleMetadataExpander)
21
22
  }
22
23
  }
23
24
 
25
+ const declarationErrors = new WeakMap<object, unknown>();
26
+
27
+ /**
28
+ * Keeps a problem found while a module was declared (a bad `@Infra` manifest,
29
+ * …) instead of throwing it at import time. The application reports it as a
30
+ * configuration error when it builds this module.
31
+ */
32
+ export function deferModuleDeclarationError(moduleRef: object, error: unknown): void {
33
+ if (!declarationErrors.has(moduleRef)) {
34
+ declarationErrors.set(moduleRef, markConfigurationError(error));
35
+ }
36
+ }
37
+
24
38
  /** Collects extra module refs contributed by registered expanders for a module. */
25
39
  export function expandModuleMetadata(metadata: BazisModuleMetadata): readonly BazisModuleRef[] {
40
+ if (declarationErrors.has(metadata)) {
41
+ throw declarationErrors.get(metadata);
42
+ }
26
43
  if (expanders.length === 0) {
27
44
  return [];
28
45
  }
29
46
  const extra: BazisModuleRef[] = [];
30
47
  for (let index = 0; index < expanders.length; index += 1) {
31
48
  const expander = expanders[index] as ModuleMetadataExpander;
32
- extra.push(...expander(metadata));
49
+ try {
50
+ extra.push(...expander(metadata));
51
+ } catch (error) {
52
+ // A bad declarative key (`ormBazis` options, …) is a configuration
53
+ // problem of the module, not an application crash.
54
+ throw markConfigurationError(error);
55
+ }
33
56
  }
34
57
  return extra;
35
58
  }
@@ -1,4 +1,4 @@
1
- import { ModuleOwnedProviderConflictError } from "../errors";
1
+ import { markConfigurationError, ModuleOwnedProviderConflictError } from "../errors";
2
2
  import type { ServiceCollection } from "../ServiceCollection";
3
3
  import {
4
4
  isClassProvider,
@@ -380,7 +380,11 @@ export function applyModuleOwnedProviderContributors(
380
380
  };
381
381
 
382
382
  for (let index = 0; index < contributors.length; index += 1) {
383
- (contributors[index] as ModuleOwnedProviderContributor)(metadata, context);
383
+ try {
384
+ (contributors[index] as ModuleOwnedProviderContributor)(metadata, context);
385
+ } catch (error) {
386
+ throw markConfigurationError(error);
387
+ }
384
388
  }
385
389
  }
386
390
 
@@ -404,6 +408,12 @@ export function validateModuleOwnedContributions(
404
408
  ): readonly ModuleOwnedMetadataContribution<TPayload>[] => store.getMetadata(channel),
405
409
  });
406
410
  for (let index = 0; index < validators.length; index += 1) {
407
- (validators[index] as ModuleOwnedContributionValidator)(snapshot);
411
+ try {
412
+ (validators[index] as ModuleOwnedContributionValidator)(snapshot);
413
+ } catch (error) {
414
+ // Conflicting owned stores or connector tokens across modules are
415
+ // declaration problems: report them as configuration errors.
416
+ throw markConfigurationError(error);
417
+ }
408
418
  }
409
419
  }
@@ -0,0 +1,8 @@
1
+ import { KernelError } from "../kernel/errors";
2
+
3
+ /**
4
+ * A gRPC controller that cannot be served as declared (an RPC without
5
+ * @GrpcMethod, a duplicate RPC). A configuration error of the code: the start
6
+ * prints it as one line and exits with code 1.
7
+ */
8
+ export class GrpcSetupError extends KernelError {}
@@ -1,4 +1,5 @@
1
1
  import type { ServiceDefinition } from "./serviceDefinition";
2
+ import { GrpcSetupError } from "./GrpcSetupError";
2
3
  import type { Class } from "../di";
3
4
  import type { GrpcControllerDefinition } from "./contracts";
4
5
  import type { RequestModelClass } from "../http/Binding/requestModelRegistry";
@@ -10,6 +11,8 @@ interface ControllerMetadata {
10
11
  service?: ServiceDefinition;
11
12
  methods: Map<string | symbol, string>;
12
13
  requestModels: Map<string | symbol, RequestModelClass>;
14
+ /** Declaration problems, reported by grpcControllerDefinition at start. */
15
+ problems: string[];
13
16
  }
14
17
  interface Carrier { [GRPC_META]?: ControllerMetadata }
15
18
 
@@ -17,7 +20,12 @@ function ownMetadata(metadata: object): ControllerMetadata {
17
20
  const carrier = metadata as Carrier;
18
21
  if (!Object.hasOwn(carrier, GRPC_META)) {
19
22
  const parent = carrier[GRPC_META];
20
- carrier[GRPC_META] = { service: parent?.service, methods: new Map(parent?.methods), requestModels: new Map(parent?.requestModels) };
23
+ carrier[GRPC_META] = {
24
+ service: parent?.service,
25
+ methods: new Map(parent?.methods),
26
+ requestModels: new Map(parent?.requestModels),
27
+ problems: [...(parent?.problems ?? [])],
28
+ };
21
29
  }
22
30
  return carrier[GRPC_META]!;
23
31
  }
@@ -25,32 +33,44 @@ function ownMetadata(metadata: object): ControllerMetadata {
25
33
  /** Bind a controller to a generated codec definition or grpcService(). */
26
34
  export function GrpcController(service: ServiceDefinition) {
27
35
  return (_value: Class<object>, context: ClassDecoratorContext): void => {
36
+ const meta = ownMetadata(context.metadata);
28
37
  if (!service || typeof service !== "object" || Object.keys(service).length === 0) {
29
- throw new TypeError("@GrpcController requires a non-empty gRPC service definition.");
38
+ meta.problems.push("@GrpcController requires a non-empty gRPC service definition.");
39
+ return;
30
40
  }
31
41
  const snapshot: Record<string, ServiceDefinition[string]> = Object.create(null);
32
42
  for (const [name, method] of Object.entries(service)) {
33
43
  if (!method || typeof method.path !== "string" || !/^\/[^/]+\/[^/]+$/.test(method.path)
34
44
  || typeof method.requestStream !== "boolean" || typeof method.responseStream !== "boolean"
35
45
  || typeof method.requestDeserialize !== "function" || typeof method.responseSerialize !== "function") {
36
- throw new TypeError(`Invalid gRPC service method: ${name}.`);
46
+ meta.problems.push(`invalid gRPC service method: ${name}.`);
47
+ return;
37
48
  }
38
49
  snapshot[name] = Object.freeze({ ...method });
39
50
  }
40
- ownMetadata(context.metadata).service = Object.freeze(snapshot);
51
+ meta.service = Object.freeze(snapshot);
41
52
  };
42
53
  }
43
54
 
44
55
  /** Request/response values for unary; AsyncIterable values for streaming sides. */
45
56
  export function GrpcMethod(rpcName?: string, requestModel?: RequestModelClass) {
46
- if (requestModel !== undefined && (typeof requestModel !== "function" || !requestModel.prototype)) {
47
- throw new TypeError("gRPC requestModel must be a DTO class.");
48
- }
49
57
  return (_value: unknown, context: ClassMethodDecoratorContext): void => {
50
- if (context.static || context.private) throw new TypeError("@GrpcMethod requires a public instance method.");
51
- const name = rpcName ?? (typeof context.name === "string" ? context.name : "");
52
- if (typeof name !== "string" || name.trim().length === 0) throw new TypeError("gRPC method name must not be empty.");
53
58
  const meta = ownMetadata(context.metadata);
59
+ // Problems are reported when the controller is registered, not at import.
60
+ const method = String(context.name);
61
+ if (requestModel !== undefined && (typeof requestModel !== "function" || !requestModel.prototype)) {
62
+ meta.problems.push(`${method}: gRPC requestModel must be a DTO class.`);
63
+ return;
64
+ }
65
+ if (context.static || context.private) {
66
+ meta.problems.push(`${method}: @GrpcMethod requires a public instance method.`);
67
+ return;
68
+ }
69
+ const name = rpcName ?? (typeof context.name === "string" ? context.name : "");
70
+ if (typeof name !== "string" || name.trim().length === 0) {
71
+ meta.problems.push(`${method}: gRPC method name must not be empty.`);
72
+ return;
73
+ }
54
74
  meta.methods.set(context.name, name);
55
75
  // A redecorated override must not silently retain the base method's DTO.
56
76
  if (requestModel === undefined) meta.requestModels.delete(context.name);
@@ -62,21 +82,23 @@ export function GrpcMethod(rpcName?: string, requestModel?: RequestModelClass) {
62
82
  export function grpcControllerDefinition(controller: Class<object>): GrpcControllerDefinition {
63
83
  const carrier = (controller as unknown as { [Symbol.metadata]?: Carrier })[Symbol.metadata];
64
84
  const meta = carrier?.[GRPC_META];
65
- if (!meta?.service) throw new TypeError(`${controller.name} must use @GrpcController().`);
85
+ const problem = meta?.problems[0];
86
+ if (problem !== undefined) throw new GrpcSetupError(`${controller.name}: ${problem}`);
87
+ if (!meta?.service) throw new GrpcSetupError(`${controller.name} must use @GrpcController().`);
66
88
  const methods = new Map<string, string | symbol>();
67
89
  for (const [methodName, rpcName] of meta.methods) {
68
90
  const keys = Object.keys(meta.service).filter((key) => {
69
91
  const rpc = meta.service![key]!;
70
92
  return key === rpcName || rpc.originalName === rpcName || rpc.path.split("/").at(-1) === rpcName;
71
93
  });
72
- if (keys.length !== 1) throw new TypeError(`${controller.name}.${String(methodName)}: unknown or ambiguous RPC ${rpcName}.`);
94
+ if (keys.length !== 1) throw new GrpcSetupError(`${controller.name}.${String(methodName)}: unknown or ambiguous RPC ${rpcName}.`);
73
95
  const key = keys[0]!;
74
- if (methods.has(key)) throw new TypeError(`${controller.name}: duplicate RPC ${rpcName}.`);
75
- if (typeof controller.prototype[methodName] !== "function") throw new TypeError(`Missing RPC handler ${String(methodName)}.`);
96
+ if (methods.has(key)) throw new GrpcSetupError(`${controller.name}: duplicate RPC ${rpcName}.`);
97
+ if (typeof controller.prototype[methodName] !== "function") throw new GrpcSetupError(`Missing RPC handler ${String(methodName)}.`);
76
98
  methods.set(key, methodName);
77
99
  }
78
100
  for (const key of Object.keys(meta.service)) {
79
- if (!methods.has(key)) throw new TypeError(`${controller.name}: RPC ${key} is missing @GrpcMethod().`);
101
+ if (!methods.has(key)) throw new GrpcSetupError(`${controller.name}: RPC ${key} is missing @GrpcMethod().`);
80
102
  }
81
103
  return Object.freeze({ controller, service: meta.service, methods, requestModels: new Map(meta.requestModels) });
82
104
  }
@@ -1,5 +1,6 @@
1
1
  export { GrpcController, GrpcMethod } from "./decorators";
2
2
  export { GrpcError } from "./GrpcError";
3
+ export { GrpcSetupError } from "./GrpcSetupError";
3
4
  export { grpcModule } from "./Grpc.module";
4
5
  export { GrpcServer } from "./GrpcServer";
5
6
  export { GrpcClient } from "./GrpcClient";
@@ -4,6 +4,7 @@ import {
4
4
  Module,
5
5
  createToken,
6
6
  createModuleOwnedMetadataChannel,
7
+ deferModuleDeclarationError,
7
8
  registerModuleOwnedProviderContributor,
8
9
  registerModuleOwnedContributionValidator,
9
10
  type ModuleConfig,
@@ -14,6 +15,7 @@ import {
14
15
  import { ConfigRegistry, HEALTH_CHECK, StartupReport } from "../kernel";
15
16
  import { InfraError, type InfraConnector } from "./InfraConnector";
16
17
  import { InfraLifecycle } from "./InfraLifecycle";
18
+ import { connectorDeclarationError } from "./connectorIdentity";
17
19
 
18
20
  /**
19
21
  * Infrastructure manifest: a readable "logical name → connector" map.
@@ -57,6 +59,8 @@ function buildInfraDefinitions(manifest: InfraManifest): {
57
59
  const seenTokens = new Map<InfraConnector["token"], string>();
58
60
 
59
61
  for (const [name, connector] of Object.entries(manifest)) {
62
+ const invalid = connectorDeclarationError(connector);
63
+ if (invalid !== undefined) throw invalid.error;
60
64
  if (!name.trim() || name !== name.trim()) throw new InfraError("Infra instance names must be non-empty and trimmed.");
61
65
  if (connector.phase !== undefined && !Number.isSafeInteger(connector.phase)) {
62
66
  throw new InfraError(`Infra instance "${name}": phase must be a finite safe integer.`);
@@ -135,8 +139,15 @@ function buildInfraDefinitions(manifest: InfraManifest): {
135
139
  * `@Infra`, but without a class. Handy for `imports` or `runApp({ infra })`.
136
140
  */
137
141
  export function infraModule(manifest: InfraManifest): BazisModuleRef {
138
- const { providers, exports, config } = buildInfraDefinitions(manifest);
139
- return { global: true, providers, exports, config };
142
+ try {
143
+ const { providers, exports, config } = buildInfraDefinitions(manifest);
144
+ return { global: true, providers, exports, config };
145
+ } catch (error) {
146
+ // Reported when the application is built, not while the file is imported.
147
+ const moduleRef: BazisModuleRef = { global: true };
148
+ deferModuleDeclarationError(moduleRef, error);
149
+ return moduleRef;
150
+ }
140
151
  }
141
152
 
142
153
  /**
@@ -157,8 +168,21 @@ export function infraModule(manifest: InfraManifest): BazisModuleRef {
157
168
  * ```
158
169
  */
159
170
  export function Infra(manifest: InfraManifest) {
160
- const { providers, exports, config } = buildInfraDefinitions(manifest);
171
+ let definitions: ReturnType<typeof buildInfraDefinitions> | undefined;
172
+ let declarationError: unknown;
173
+ try {
174
+ definitions = buildInfraDefinitions(manifest);
175
+ } catch (error) {
176
+ declarationError = error;
177
+ }
161
178
  return (target: abstract new (...args: never) => unknown, _context: ClassDecoratorContext): void => {
179
+ if (definitions === undefined) {
180
+ // Reported when the application is built, not while the file is imported.
181
+ Module({ global: true })(target, _context);
182
+ deferModuleDeclarationError(target, declarationError);
183
+ return;
184
+ }
185
+ const { providers, exports, config } = definitions;
162
186
  Module({ global: true, providers, exports, config })(target, _context);
163
187
  };
164
188
  }
@@ -9,6 +9,31 @@ const lifecycles = new WeakMap<object, LifecycleBinding>();
9
9
  const values = (c: InfraConnector) => [c.token, c.config, c.phase, c.create, c.connect, c.dispose, c.healthCheck, c.providers, c.exports] as const;
10
10
  const unchanged = (c: InfraConnector, identity: ConnectorIdentity) => values(c).every((value, index) => Object.is(value, identity.fingerprint[index]));
11
11
 
12
+ const declarationErrors = new WeakMap<object, unknown>();
13
+
14
+ /**
15
+ * @internal A connector factory whose options are invalid returns a connector
16
+ * that carries the problem: `@Infra` reports it when the application is built
17
+ * instead of crashing the import of the manifest file.
18
+ */
19
+ export function invalidConnector<T>(token: InfraConnector<T>["token"], error: unknown): InfraConnector<T> {
20
+ const connector: InfraConnector<T> = {
21
+ token,
22
+ create() {
23
+ throw error;
24
+ },
25
+ connect() {},
26
+ dispose() {},
27
+ };
28
+ declarationErrors.set(connector, error);
29
+ return connector;
30
+ }
31
+
32
+ /** @internal The declaration problem of a connector made by `invalidConnector`. */
33
+ export function connectorDeclarationError(connector: InfraConnector): { readonly error: unknown } | undefined {
34
+ return declarationErrors.has(connector) ? { error: declarationErrors.get(connector) } : undefined;
35
+ }
36
+
12
37
  /** @internal A factory identifies its original connector; copies inherit no identity. */
13
38
  export function identifyConnector<T>(connector: InfraConnector<T>, role: string): InfraConnector<T> {
14
39
  identities.set(connector, Object.freeze({ role, fingerprint: values(connector) }));
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.111.2" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.113.0" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -12,7 +12,7 @@ import {
12
12
  type AgentModelResponse,
13
13
  } from "../../agent";
14
14
  import { InfraError, type InfraConnector } from "../InfraConnector";
15
- import { identifyConnector } from "../connectorIdentity";
15
+ import { identifyConnector, invalidConnector } from "../connectorIdentity";
16
16
  import { redactSensitiveText } from "../../../library/redaction";
17
17
  import { awaitAbortable } from "../../kernel/internal/awaitAbortable";
18
18
  import {
@@ -422,9 +422,16 @@ export function llmRouter(
422
422
  profiles: Readonly<Record<string, LlmModelProfile>>,
423
423
  options: LlmRouterOptions = {},
424
424
  ): InfraConnector<AgentModelProvider> {
425
- const entries = profileEntries(profiles);
426
- const defaultProfile = defaultProfileName(entries, options);
427
- ensureFallbacksValid(entries);
425
+ let entries: ReturnType<typeof profileEntries>;
426
+ let defaultProfile: string;
427
+ try {
428
+ entries = profileEntries(profiles);
429
+ defaultProfile = defaultProfileName(entries, options);
430
+ ensureFallbacksValid(entries);
431
+ } catch (error) {
432
+ // Reported by `@Infra` when the application is built, not at import.
433
+ return invalidConnector(AGENT_MODEL_PROVIDER, error);
434
+ }
428
435
  const configs = Object.freeze(Array.from(new Set(entries.map(([, profile]) => profile.config))));
429
436
 
430
437
  // The framework LLM connector, like llmConnect: it never touches the schema,
@@ -1,4 +1,4 @@
1
- import { DiError } from "../di";
1
+ import { isConfigurationError } from "../di";
2
2
  import { Kernel } from "./Kernel";
3
3
  import { KernelBuilder, type RootModuleInput } from "./KernelBuilder";
4
4
  import { KernelError, ShutdownTimeoutError } from "./errors";
@@ -76,7 +76,7 @@ export class Bazis {
76
76
  // deserve the same friendly output as build-time ones.
77
77
  if (screen !== undefined) {
78
78
  screen.failed(error, kernel);
79
- } else if (error instanceof DiError || error instanceof KernelError) {
79
+ } else if (error instanceof KernelError || isConfigurationError(error)) {
80
80
  console.error(`[bazis] configuration error: ${redactSensitiveText(error.message)}`);
81
81
  } else {
82
82
  console.error(`[bazis] application failed: ${formatStartupFailure(error)}`);
@@ -103,7 +103,7 @@ export function reportBuildFailure(
103
103
  process.exitCode = 1;
104
104
  return 1;
105
105
  }
106
- if (error instanceof DiError || error instanceof KernelError) {
106
+ if (error instanceof KernelError || isConfigurationError(error)) {
107
107
  console.error(`[bazis] configuration error: ${redactSensitiveText(error.message)}`);
108
108
  process.exitCode = 1;
109
109
  return 1;
@@ -1,6 +1,6 @@
1
1
  import { KernelError } from "../errors";
2
2
 
3
- export type ConfigIssueKind = "missing-secret" | "development-secret" | "number" | "boolean" | "string" | "enum" | "validator" | "conflict";
3
+ export type ConfigIssueKind = "missing-secret" | "empty-secret" | "development-secret" | "number" | "boolean" | "string" | "enum" | "validator" | "conflict";
4
4
 
5
5
  /** One invalid configuration key, with what can change it. */
6
6
  export interface ConfigIssue {
@@ -30,11 +30,14 @@ export interface ConfigEnum<T extends string | number = string | number> {
30
30
  readonly default: T;
31
31
  }
32
32
  export function configEnum<const V extends readonly [string | number, ...(string | number)[]]>(values: V, defaultValue: V[number]): ConfigEnum<V[number]> {
33
+ const declared: ConfigEnum<V[number]> = Object.freeze({ __enum: true, values: Object.freeze([...values]), default: defaultValue });
33
34
  if (!values.length || !values.includes(defaultValue) || values.some(value => typeof value !== typeof defaultValue || (typeof value === "number" && !Number.isFinite(value)))) {
34
- throw new KernelError("configEnum requires non-empty homogeneous finite values and a declared default.");
35
+ // defineConfig reports it at start, with the other configuration errors.
36
+ invalidEnums.set(declared, new KernelError("configEnum requires non-empty homogeneous finite values and a declared default."));
35
37
  }
36
- return Object.freeze({ __enum: true, values: Object.freeze([...values]), default: defaultValue });
38
+ return declared;
37
39
  }
40
+ const invalidEnums = new WeakMap<object, KernelError>();
38
41
  type Cell = Primitive | SecretSpec | ConfigEnum;
39
42
  type Defaults = Record<string, Cell>;
40
43
  export type ConfigValueType = Primitive | Secret;
@@ -119,7 +122,11 @@ function isSecret(cell: Cell): cell is SecretSpec { return typeof cell === "obje
119
122
  function isEnum(cell: Cell): cell is ConfigEnum { return typeof cell === "object" && cell !== null && "__enum" in cell; }
120
123
  function copyCell(cell: Cell): Cell {
121
124
  if (isSecret(cell)) return secret(cell.default);
122
- if (isEnum(cell)) return Object.freeze({ ...cell, values: Object.freeze([...cell.values]) });
125
+ if (isEnum(cell)) {
126
+ const problem = invalidEnums.get(cell);
127
+ if (problem !== undefined) throw problem;
128
+ return Object.freeze({ ...cell, values: Object.freeze([...cell.values]) });
129
+ }
123
130
  if (["string", "number", "boolean"].includes(typeof cell)) return cell;
124
131
  throw new KernelError("Configuration supports only primitive values, secret() and configEnum().");
125
132
  }
@@ -141,6 +148,26 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
141
148
  // The file that declared the config, for "declared in" of a startup failure.
142
149
  const declaredIn = callerFile(new Error().stack);
143
150
  const input = typeof prefixOrSchema === "string" ? maybeSchema! : prefixOrSchema;
151
+ let definition: ConfigDefinition<ConfigShape>;
152
+ try {
153
+ definition = createDefinition(prefix, input, declaredIn);
154
+ } catch (error) {
155
+ if (!(error instanceof KernelError)) throw error;
156
+ // A declaration problem is reported at start, with the other configuration
157
+ // errors, instead of crashing the import of the file that declares it.
158
+ const fail = (): never => { throw error; };
159
+ definition = Object.freeze({
160
+ token: Object.freeze(createToken<ConfigView<ConfigShape>>(`Config:${prefix || "default"}`)),
161
+ resolve: fail,
162
+ get: fail,
163
+ has: fail,
164
+ ensureValid: fail,
165
+ });
166
+ }
167
+ definitions.add(definition);
168
+ return definition;
169
+ }
170
+ function createDefinition(prefix: string, input: ConfigSchema<Defaults>, declaredIn: string | undefined): ConfigDefinition<ConfigShape> {
144
171
  const schema = Object.freeze({
145
172
  default: Object.freeze(Object.fromEntries(Object.entries(input.default).map(([key, value]) => [key, copyCell(value)]))),
146
173
  ...Object.fromEntries((["development", "test", "production"] as const).map(stand => [stand, Object.freeze(Object.fromEntries(Object.entries(input[stand] ?? {}).map(([key, value]) => [key, copyCell(value as Cell)])))])),
@@ -202,7 +229,10 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
202
229
  const raw = selected?.raw ?? (isSecret(cell) || isEnum(cell) ? cell.default : cell);
203
230
  let value: ConfigValueType | undefined;
204
231
  if (type === "secret") {
205
- if (typeof raw !== "string" || !raw.trim()) issue(key, "missing-secret", `required non-empty secret is not set (${names.get(key)!.join(", ")})`);
232
+ // secret("") reads like an empty default but would mean "required":
233
+ // the declaration is ambiguous, so it is refused with the fix.
234
+ if (isSecret(cell) && cell.default !== undefined && !cell.default.trim()) issue(key, "empty-secret", 'secret("") is not a value: a secret cannot be empty. Write secret() for a required secret, or give a non-empty development value');
235
+ else if (typeof raw !== "string" || !raw.trim()) issue(key, "missing-secret", `required non-empty secret is not set (${names.get(key)!.join(", ")})`);
206
236
  // A secret default in the code is a development value; production must
207
237
  // get the secret from a source or declare its own value explicitly.
208
238
  // No "apiKey: …" in the hint: the console redaction would hide it.
@@ -242,13 +272,11 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
242
272
  inspect: () => rows,
243
273
  });
244
274
  };
245
- const definition: ConfigDefinition<ConfigShape> = Object.freeze({
275
+ return Object.freeze({
246
276
  token: Object.freeze(createToken<ConfigView<ConfigShape>>(`Config:${prefix || "default"}`)),
247
277
  resolve,
248
278
  get: <K extends keyof ConfigShape>(key: K): ConfigShape[K] => resolve().get(key),
249
279
  has: <K extends keyof ConfigShape>(key: K) => resolve().has(key),
250
280
  ensureValid(environment?: Stand) { resolve(environment); },
251
281
  });
252
- definitions.add(definition);
253
- return definition;
254
282
  }
@@ -1,4 +1,4 @@
1
- import { DiError } from "../../di";
1
+ import { DiError, isConfigurationError } from "../../di";
2
2
  import { ServiceValidationError } from "../../di/errors/ServiceValidationError";
3
3
  import { redactSensitiveText } from "../../../library/redaction";
4
4
  import type { Problem, Screen } from "../../../library/terminal";
@@ -68,7 +68,7 @@ export function diagnoseStartupFailure(error: unknown, screen: Screen, context:
68
68
  };
69
69
  }
70
70
  const failed = context.failedServices?.[0];
71
- const stage = error instanceof KernelError && failed === undefined ? "configuration" : stageOf(failed ?? "");
71
+ const stage = (error instanceof KernelError || isConfigurationError(error)) && failed === undefined ? "configuration" : stageOf(failed ?? "");
72
72
  return { stage, title: t.failure.app, problems: [genericProblem(error, failed)] };
73
73
  }
74
74
 
@@ -84,6 +84,7 @@ function configProblem(issue: ConfigIssue, screen: Screen): Problem {
84
84
  const variables = issue.env.map((name) => screen.code(name)).join(" / ");
85
85
  const causes: Record<ConfigIssue["kind"], string> = {
86
86
  "missing-secret": t.config.issue.missingSecret,
87
+ "empty-secret": t.config.issue.emptySecret,
87
88
  "development-secret": t.config.issue.devSecret,
88
89
  number: t.config.issue.number,
89
90
  boolean: t.config.issue.boolean,
@@ -94,6 +95,7 @@ function configProblem(issue: ConfigIssue, screen: Screen): Problem {
94
95
  };
95
96
  const fixes: string[] = [];
96
97
  if (issue.kind === "missing-secret") fixes.push(`${t.config.fix.setVariable(variables)}, ${t.config.fix.orSecretFile}`);
98
+ else if (issue.kind === "empty-secret") fixes.push(t.config.fix.emptySecret);
97
99
  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
100
  else fixes.push(t.config.fix.change(issue.source === undefined ? variables : screen.code(issue.source)));
99
101
  // The wrong value and the variable that brought it.
@@ -101,8 +101,11 @@ export class MigrationRunner {
101
101
 
102
102
  for (const id of toRollback) {
103
103
  const migration = this.migrations.find((m) => m.id === id);
104
- if (!migration?.down) {
105
- throw new Error(`Migration "${id}" has no down() method.`);
104
+ if (!migration) {
105
+ throw new OrmError(`Migration "${id}" is applied but is not in the migrations passed to rollbackVersioned(); pass the full list.`);
106
+ }
107
+ if (!migration.down) {
108
+ throw new OrmError(`Migration "${id}" has no down() method.`);
106
109
  }
107
110
  await this.provider.transaction(async (tx) => {
108
111
  await migration.down!(this.context(tx));
@@ -198,7 +198,7 @@ export async function transactionScope<TResult>(context: DbContext, provider: Da
198
198
  function assertRetryable(context: DbContext): void {
199
199
  const tracked = trackedEntryCount(context.changeTracker);
200
200
  if (tracked === 0) return;
201
- throw new OrmTransactionScopeError(`transactionScope({ retry }) needs ${contextName(context)} without tracked entities: every attempt starts from a clean context, and the ${tracked} entities tracked before the scope would lose their changes. Start the scope before loading anything, or create a new scope for it.`);
201
+ throw new OrmTransactionScopeError(`transactionScope({ retry }) needs ${contextName(context)} without tracked entities: every attempt starts from a clean context, and the ${tracked === 1 ? "1 entity" : `${tracked} entities`} tracked before the scope would lose ${tracked === 1 ? "its" : "their"} changes. Start the scope before loading anything, or create a new scope for it.`);
202
202
  }
203
203
 
204
204
  async function runScope<TResult>(context: DbContext, provider: DatabaseProvider, work: (transaction: OrmTransaction) => Promise<TResult>, options: OrmTransactionScopeOptions | undefined, retryContexts?: Set<DbContext>): Promise<TResult> {
@@ -111,6 +111,7 @@ const en = {
111
111
  invalid: (total: number) => `of ${total} sections invalid`,
112
112
  issue: {
113
113
  missingSecret: "required secret is not set",
114
+ emptySecret: 'secret("") is not a value: a secret cannot be empty',
114
115
  devSecret: "production would use the development default secret from the code",
115
116
  number: "expected a finite number",
116
117
  boolean: "expected a boolean (true, false, 1, 0)",
@@ -124,6 +125,7 @@ const en = {
124
125
  setVariable: (names: string) => `set ${names}`,
125
126
  orSecretFile: "or a file in a secretFilesSource directory",
126
127
  orProduction: (key: string) => `or declare ${key}: secret() with a value source in the production section`,
128
+ emptySecret: "write secret() for a required secret, or give a non-empty development value",
127
129
  change: (names: string) => `change ${names}`,
128
130
  },
129
131
  },
@@ -233,6 +235,7 @@ const en = {
233
235
  notRegistered: "not connected — codegen skips it until a module imports it",
234
236
  codegenSkipped: "codegen skipped (--no-codegen) — run bazis codegen",
235
237
  needsDb: "this module uses a database: connect one with @Infra({ db: ormBazisConnect(dbConfig) })",
238
+ needsTable: (moduleFile: string) => `its table is not created: add ensureCreated: true to ormBazis in ${moduleFile}, or write a migration`,
236
239
  needsCache: "this module caches: pass runApp(AppModule, { cache: memory() })",
237
240
  keptOnFailure: "scaffold files were kept — fix the error and run bazis codegen",
238
241
  },
@@ -273,6 +276,7 @@ const ru: Messages = {
273
276
  invalid: (total) => `из ${total} с ошибками`,
274
277
  issue: {
275
278
  missingSecret: "обязательный секрет не задан",
279
+ emptySecret: 'secret("") — не значение: секрет не может быть пустым',
276
280
  devSecret: "в production попадёт секрет для разработки из кода",
277
281
  number: "нужно конечное число",
278
282
  boolean: "нужно логическое значение (true, false, 1, 0)",
@@ -286,6 +290,7 @@ const ru: Messages = {
286
290
  setVariable: (names) => `задайте ${names}`,
287
291
  orSecretFile: "или файл в каталоге secretFilesSource",
288
292
  orProduction: (key) => `или объявите ${key}: secret() с источником значения в разделе production`,
293
+ emptySecret: "обязательный секрет пишется secret(), а для разработки нужно непустое значение",
289
294
  change: (names) => `исправьте ${names}`,
290
295
  },
291
296
  },
@@ -339,10 +344,10 @@ const ru: Messages = {
339
344
  typecheck: (count) => `проверка типов не прошла · ${count} ${formRu(count, NOUNS_RU.error)}`,
340
345
  tests: "тесты не прошли",
341
346
  command: "команда не выполнена",
342
- skipConfig: "не проверялось — конфигурация с ошибками",
343
- skipInfra: "не запускалось — инфраструктура недоступна",
344
- skipCodegen: "не запускалось — кодогенерация не удалась",
345
- skipEarlier: "не запускалось — предыдущий этап не удался",
347
+ skipConfig: "без проверки — конфигурация с ошибками",
348
+ skipInfra: "без запуска — инфраструктура недоступна",
349
+ skipCodegen: "без запуска — кодогенерация не удалась",
350
+ skipEarlier: "без запуска — предыдущий этап не удался",
346
351
  exitNothing: (code) => `код выхода ${code} · ничего не запущено`,
347
352
  exitWatch: (code, src) => `код выхода ${code} · слежу за ${src} — исправьте и сохраните для перезапуска`,
348
353
  exit: (code) => `код выхода ${code}`,
@@ -395,6 +400,7 @@ const ru: Messages = {
395
400
  notRegistered: "не подключён — кодогенерация пропускает его, пока модуль не импортирован",
396
401
  codegenSkipped: "кодогенерация пропущена (--no-codegen) — запустите bazis codegen",
397
402
  needsDb: "модулю нужна база данных: подключите её через @Infra({ db: ormBazisConnect(dbConfig) })",
403
+ needsTable: (moduleFile) => `таблицу шаблон не создаёт: добавьте ensureCreated: true в ormBazis в ${moduleFile} или напишите миграцию`,
398
404
  needsCache: "модуль кэширует: передайте runApp(AppModule, { cache: memory() })",
399
405
  keptOnFailure: "файлы модуля оставлены — исправьте ошибку и запустите bazis codegen",
400
406
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.111.2",
3
+ "version": "0.113.0",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",