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 +4 -1
- package/core/di/errors/configurationError.ts +21 -0
- package/core/di/errors/index.ts +1 -0
- package/core/di/index.ts +3 -0
- package/core/di/module/index.ts +1 -0
- package/core/di/module/moduleExtensions.ts +24 -1
- package/core/di/module/moduleOwnedProviderContributors.ts +13 -3
- package/core/grpc/GrpcSetupError.ts +8 -0
- package/core/grpc/decorators.ts +37 -15
- package/core/grpc/index.ts +1 -0
- package/core/infra/Infra.ts +27 -3
- package/core/infra/connectorIdentity.ts +25 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/llmRouter.ts +11 -4
- package/core/kernel/Bazis.ts +3 -3
- package/core/kernel/config/ConfigValidationError.ts +1 -1
- package/core/kernel/config/defineConfig.ts +35 -7
- package/core/kernel/report/startupProblems.ts +4 -2
- package/library/orm/Schema/MigrationRunner.ts +5 -2
- package/library/orm/Transactions/TransactionScopeCoordinator.ts +1 -1
- package/library/terminal/messages.ts +10 -4
- package/package.json +1 -1
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")
|
|
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
|
+
}
|
package/core/di/errors/index.ts
CHANGED
|
@@ -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,
|
package/core/di/module/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {}
|
package/core/grpc/decorators.ts
CHANGED
|
@@ -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] = {
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
meta.problems.push(`invalid gRPC service method: ${name}.`);
|
|
47
|
+
return;
|
|
37
48
|
}
|
|
38
49
|
snapshot[name] = Object.freeze({ ...method });
|
|
39
50
|
}
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
75
|
-
if (typeof controller.prototype[methodName] !== "function") throw new
|
|
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
|
|
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
|
}
|
package/core/grpc/index.ts
CHANGED
|
@@ -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";
|
package/core/infra/Infra.ts
CHANGED
|
@@ -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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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,
|
package/core/kernel/Bazis.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
throw new
|
|
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.
|
|
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",
|