bazis 0.111.1 → 0.112.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/grpc/GrpcSetupError.ts +8 -0
- package/core/grpc/decorators.ts +6 -5
- package/core/grpc/index.ts +1 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/LifecycleCoordinator.ts +4 -2
- package/core/kernel/config/ConfigValidationError.ts +1 -1
- package/core/kernel/config/defineConfig.ts +4 -1
- package/core/kernel/report/startupProblems.ts +2 -0
- 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,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";
|
|
@@ -62,21 +63,21 @@ export function GrpcMethod(rpcName?: string, requestModel?: RequestModelClass) {
|
|
|
62
63
|
export function grpcControllerDefinition(controller: Class<object>): GrpcControllerDefinition {
|
|
63
64
|
const carrier = (controller as unknown as { [Symbol.metadata]?: Carrier })[Symbol.metadata];
|
|
64
65
|
const meta = carrier?.[GRPC_META];
|
|
65
|
-
if (!meta?.service) throw new
|
|
66
|
+
if (!meta?.service) throw new GrpcSetupError(`${controller.name} must use @GrpcController().`);
|
|
66
67
|
const methods = new Map<string, string | symbol>();
|
|
67
68
|
for (const [methodName, rpcName] of meta.methods) {
|
|
68
69
|
const keys = Object.keys(meta.service).filter((key) => {
|
|
69
70
|
const rpc = meta.service![key]!;
|
|
70
71
|
return key === rpcName || rpc.originalName === rpcName || rpc.path.split("/").at(-1) === rpcName;
|
|
71
72
|
});
|
|
72
|
-
if (keys.length !== 1) throw new
|
|
73
|
+
if (keys.length !== 1) throw new GrpcSetupError(`${controller.name}.${String(methodName)}: unknown or ambiguous RPC ${rpcName}.`);
|
|
73
74
|
const key = keys[0]!;
|
|
74
|
-
if (methods.has(key)) throw new
|
|
75
|
-
if (typeof controller.prototype[methodName] !== "function") throw new
|
|
75
|
+
if (methods.has(key)) throw new GrpcSetupError(`${controller.name}: duplicate RPC ${rpcName}.`);
|
|
76
|
+
if (typeof controller.prototype[methodName] !== "function") throw new GrpcSetupError(`Missing RPC handler ${String(methodName)}.`);
|
|
76
77
|
methods.set(key, methodName);
|
|
77
78
|
}
|
|
78
79
|
for (const key of Object.keys(meta.service)) {
|
|
79
|
-
if (!methods.has(key)) throw new
|
|
80
|
+
if (!methods.has(key)) throw new GrpcSetupError(`${controller.name}: RPC ${key} is missing @GrpcMethod().`);
|
|
80
81
|
}
|
|
81
82
|
return Object.freeze({ controller, service: meta.service, methods, requestModels: new Map(meta.requestModels) });
|
|
82
83
|
}
|
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";
|
|
@@ -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.112.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");
|
|
@@ -174,8 +174,10 @@ export class LifecycleCoordinator {
|
|
|
174
174
|
const names = batch.map(serviceName);
|
|
175
175
|
for (const name of names) this.pending.add(name);
|
|
176
176
|
const startedAt = performance.now();
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
// Each service leaves the pending list as soon as it has started, so a
|
|
178
|
+
// startup timeout names only the ones still starting.
|
|
179
|
+
const results = await Promise.allSettled(batch.map((service, index) =>
|
|
180
|
+
this.startService(service, signal).finally(() => this.pending.delete(names[index] as string))));
|
|
179
181
|
this.timings.push(Object.freeze({ names: Object.freeze(names), phase: batch[0]?.phase ?? 0, ms: performance.now() - startedAt }));
|
|
180
182
|
const failures: StartupFailure[] = [];
|
|
181
183
|
results.forEach((result, index) => {
|
|
@@ -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 {
|
|
@@ -202,7 +202,10 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
|
|
|
202
202
|
const raw = selected?.raw ?? (isSecret(cell) || isEnum(cell) ? cell.default : cell);
|
|
203
203
|
let value: ConfigValueType | undefined;
|
|
204
204
|
if (type === "secret") {
|
|
205
|
-
|
|
205
|
+
// secret("") reads like an empty default but would mean "required":
|
|
206
|
+
// the declaration is ambiguous, so it is refused with the fix.
|
|
207
|
+
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');
|
|
208
|
+
else if (typeof raw !== "string" || !raw.trim()) issue(key, "missing-secret", `required non-empty secret is not set (${names.get(key)!.join(", ")})`);
|
|
206
209
|
// A secret default in the code is a development value; production must
|
|
207
210
|
// get the secret from a source or declare its own value explicitly.
|
|
208
211
|
// No "apiKey: …" in the hint: the console redaction would hide it.
|
|
@@ -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.112.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",
|