bazis 0.98.29 → 0.98.30
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/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/Kernel.ts +28 -7
- package/core/kernel/KernelBuilder.ts +14 -0
- package/core/kernel/LifecycleCoordinator.ts +8 -3
- package/core/kernel/types/KernelOptions.ts +8 -0
- package/library/orm/Schema/MigrationRunner.ts +20 -1
- package/library/orm/Schema/SchemaAdmission.ts +6 -1
- package/package.json +1 -1
|
@@ -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.98.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.98.30" }, 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");
|
package/core/kernel/Kernel.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface KernelRuntimeOptions {
|
|
|
28
28
|
readonly signals: readonly NodeJS.Signals[];
|
|
29
29
|
readonly unhandledErrorPolicy: UnhandledErrorPolicy;
|
|
30
30
|
readonly startupReport: boolean;
|
|
31
|
+
/** Start the infrastructure and the schema only, then stop (BAZIS_SCHEMA_ONLY). */
|
|
32
|
+
readonly schemaOnly?: boolean;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
interface StopRequest {
|
|
@@ -101,9 +103,19 @@ export class Kernel {
|
|
|
101
103
|
// Async singletons first: hosted services and controllers take them
|
|
102
104
|
// through constructors, which resolve synchronously.
|
|
103
105
|
await awaitAbortable(this.container.initializeAsyncSingletons(), abortController.signal);
|
|
104
|
-
await this.coordinator.start(abortController.signal, this.options.shutdownTimeoutMs);
|
|
106
|
+
await this.coordinator.start(abortController.signal, this.options.shutdownTimeoutMs, this.options.schemaOnly === true);
|
|
105
107
|
coordinatorStarted = true;
|
|
106
108
|
this.startupMs = performance.now() - this.timings.buildStartedAt;
|
|
109
|
+
if (this.options.schemaOnly === true) {
|
|
110
|
+
// The application itself never started: no lifetime callbacks, no
|
|
111
|
+
// STARTED event, no report. run() stops right away with exit code 0.
|
|
112
|
+
this.container.tryResolve(LOGGER)?.info("schema only: infrastructure and schema are ready, stopping (BAZIS_SCHEMA_ONLY)", {
|
|
113
|
+
hostedServices: this.coordinator.startedHostedCount,
|
|
114
|
+
startupMs: Number(this.startupMs.toFixed(1)),
|
|
115
|
+
});
|
|
116
|
+
this.state = "started";
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
107
119
|
await this.lifetime.notifyStarted(abortController.signal);
|
|
108
120
|
await this.container.resolve(EventBus).publish(
|
|
109
121
|
APPLICATION_STARTED,
|
|
@@ -222,16 +234,21 @@ export class Kernel {
|
|
|
222
234
|
|
|
223
235
|
private async runShutdownSequence(request: StopRequest): Promise<void> {
|
|
224
236
|
const errors: unknown[] = [];
|
|
237
|
+
// A schema-only start never announced the application, so it does not
|
|
238
|
+
// announce its stop either.
|
|
239
|
+
const announced = this.options.schemaOnly !== true;
|
|
225
240
|
try {
|
|
226
|
-
await this.lifetime.notifyStopping();
|
|
241
|
+
if (announced) await this.lifetime.notifyStopping();
|
|
227
242
|
} catch (error) {
|
|
228
243
|
errors.push(error);
|
|
229
244
|
}
|
|
230
245
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
246
|
+
if (announced) {
|
|
247
|
+
await this.container.resolve(EventBus).publish(APPLICATION_STOPPING, {
|
|
248
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
249
|
+
exitCode: request.exitCode,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
235
252
|
} catch (error) {
|
|
236
253
|
errors.push(error);
|
|
237
254
|
}
|
|
@@ -249,7 +266,7 @@ export class Kernel {
|
|
|
249
266
|
}
|
|
250
267
|
|
|
251
268
|
try {
|
|
252
|
-
await this.lifetime.notifyStopped();
|
|
269
|
+
if (announced) await this.lifetime.notifyStopped();
|
|
253
270
|
} catch (error) {
|
|
254
271
|
errors.push(error);
|
|
255
272
|
}
|
|
@@ -287,6 +304,10 @@ export class Kernel {
|
|
|
287
304
|
return request.exitCode;
|
|
288
305
|
}
|
|
289
306
|
|
|
307
|
+
if (this.options.schemaOnly === true) {
|
|
308
|
+
// The schema is in place: nothing to serve, stop as a finished job.
|
|
309
|
+
this.requestStop(this.stopRequested ?? { exitCode: 0 });
|
|
310
|
+
}
|
|
290
311
|
const request = await new Promise<StopRequest>((resolve) => {
|
|
291
312
|
// lifetime.stop() or a process signal may have fired during start().
|
|
292
313
|
if (this.stopRequested) {
|
|
@@ -6,6 +6,7 @@ import { ConfigRegistry } from "./config/ConfigRegistry";
|
|
|
6
6
|
import { argsSource, envSource } from "./config/sources";
|
|
7
7
|
import { Environment } from "./Environment";
|
|
8
8
|
import { EventBus } from "./events/EventBus";
|
|
9
|
+
import { KernelError } from "./errors";
|
|
9
10
|
import { HealthService } from "./health/HealthService";
|
|
10
11
|
import { Kernel, type KernelTimings } from "./Kernel";
|
|
11
12
|
import { ConsoleLogger } from "./logging/ConsoleLogger";
|
|
@@ -46,6 +47,7 @@ export class KernelBuilder {
|
|
|
46
47
|
private validateOnBuild = true;
|
|
47
48
|
private overrides: readonly ProviderDefinition[] = [];
|
|
48
49
|
private startupReport?: boolean;
|
|
50
|
+
private schemaOnly?: boolean;
|
|
49
51
|
private logger?: Logger;
|
|
50
52
|
|
|
51
53
|
public constructor(private readonly rootModule: RootModuleInput) {}
|
|
@@ -65,6 +67,7 @@ export class KernelBuilder {
|
|
|
65
67
|
if (options.unhandledErrorPolicy !== undefined) this.unhandledErrorPolicy = options.unhandledErrorPolicy;
|
|
66
68
|
if (options.validateOnBuild !== undefined) this.validateOnBuild = options.validateOnBuild;
|
|
67
69
|
if (options.startupReport !== undefined) this.startupReport = options.startupReport;
|
|
70
|
+
if (options.schemaOnly !== undefined) this.schemaOnly = options.schemaOnly;
|
|
68
71
|
return this;
|
|
69
72
|
}
|
|
70
73
|
|
|
@@ -177,6 +180,17 @@ export class KernelBuilder {
|
|
|
177
180
|
signals: this.signals,
|
|
178
181
|
unhandledErrorPolicy: this.unhandledErrorPolicy,
|
|
179
182
|
startupReport: this.startupReport ?? !environment.isProduction,
|
|
183
|
+
schemaOnly: this.schemaOnly ?? schemaOnlyFromProcess(),
|
|
180
184
|
});
|
|
181
185
|
}
|
|
182
186
|
}
|
|
187
|
+
|
|
188
|
+
/** BAZIS_SCHEMA_ONLY: `1`/`true` or `0`/`false`/unset; anything else is a mistake worth stopping for. */
|
|
189
|
+
function schemaOnlyFromProcess(): boolean {
|
|
190
|
+
const raw = process.env.BAZIS_SCHEMA_ONLY;
|
|
191
|
+
if (raw === undefined || raw === "") return false;
|
|
192
|
+
const normalized = raw.trim().toLowerCase();
|
|
193
|
+
if (normalized === "1" || normalized === "true") return true;
|
|
194
|
+
if (normalized === "0" || normalized === "false") return false;
|
|
195
|
+
throw new KernelError(`BAZIS_SCHEMA_ONLY must be 1, true, 0 or false, got ${JSON.stringify(raw.slice(0, 40))}.`);
|
|
196
|
+
}
|
|
@@ -55,16 +55,21 @@ export class LifecycleCoordinator {
|
|
|
55
55
|
return this.rollbackStartedAt === undefined ? 0 : performance.now() - this.rollbackStartedAt;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* `infrastructureOnly` starts only hosted services with a negative phase
|
|
60
|
+
* (connections and the database schema) and no lifecycle hooks.
|
|
61
|
+
*/
|
|
62
|
+
public async start(signal?: AbortSignal, rollbackTimeoutMs = 0, infrastructureOnly = false): Promise<void> {
|
|
59
63
|
signal?.throwIfAborted();
|
|
60
64
|
validateOptionsOnStart(this.resolver);
|
|
61
|
-
const
|
|
65
|
+
const resolved = resolveHostedServices(this.resolver);
|
|
66
|
+
const hosted = infrastructureOnly ? resolved.filter((service) => (service.phase ?? 0) < 0) : resolved;
|
|
62
67
|
// Background failures go to the application logger, like HTTP errors.
|
|
63
68
|
const logger = this.resolver.tryResolve?.(LOGGER);
|
|
64
69
|
if (logger) for (const service of hosted) service.useDiagnostics?.(logger);
|
|
65
70
|
const validation = validateHostedServicePlan(hosted, signal);
|
|
66
71
|
if (validation) await awaitAbortable(validation, signal);
|
|
67
|
-
this.hooks = this.resolver.resolveAll(LIFECYCLE_HOOK);
|
|
72
|
+
this.hooks = infrastructureOnly ? [] : this.resolver.resolveAll(LIFECYCLE_HOOK);
|
|
68
73
|
|
|
69
74
|
try {
|
|
70
75
|
for (let index = 0; index < this.hooks.length; index += 1) {
|
|
@@ -20,4 +20,12 @@ export interface KernelOptions {
|
|
|
20
20
|
readonly validateOnBuild?: boolean;
|
|
21
21
|
/** Startup report in the console. Default: only outside production. */
|
|
22
22
|
readonly startupReport?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Start only the infrastructure and the database schema (hosted services with
|
|
25
|
+
* a negative phase: connections, migrations, `ensureCreated`), then stop with
|
|
26
|
+
* exit code 0. HTTP, background services and lifecycle hooks do not run.
|
|
27
|
+
* For a deployment step with the schema owner's credentials.
|
|
28
|
+
* Default: the BAZIS_SCHEMA_ONLY variable (`1`/`true`), otherwise false.
|
|
29
|
+
*/
|
|
30
|
+
readonly schemaOnly?: boolean;
|
|
23
31
|
}
|
|
@@ -130,10 +130,24 @@ export class MigrationRunner {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
private async ensureHistory(): Promise<void> {
|
|
133
|
+
// CREATE TABLE IF NOT EXISTS needs the CREATE privilege on the schema even
|
|
134
|
+
// when the table exists, so an application user with data rights only
|
|
135
|
+
// could never start. Create the table only when it is missing.
|
|
136
|
+
const [found] = await this.provider.query(`SELECT to_regclass(${this.param(0)}) IS NOT NULL AS present`, [this.quote(HISTORY_TABLE)]);
|
|
137
|
+
if (found?.present === true) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
133
140
|
await this.provider.execute(
|
|
134
141
|
`CREATE TABLE IF NOT EXISTS ${this.quote(HISTORY_TABLE)} (${this.quote("MigrationId")} TEXT PRIMARY KEY, ${this.quote("AppliedAt")} TEXT NOT NULL)`,
|
|
135
142
|
[],
|
|
136
|
-
)
|
|
143
|
+
).catch((error: unknown) => {
|
|
144
|
+
if (sqlState(error) !== "42501") throw error;
|
|
145
|
+
throw new OrmError(
|
|
146
|
+
`The migration history table "${HISTORY_TABLE}" does not exist and this database user may not create it (PostgreSQL 42501). ` +
|
|
147
|
+
"Run the first start with the schema owner's credentials (BAZIS_SCHEMA_ONLY=1 applies the schema and exits), or grant CREATE on the schema.",
|
|
148
|
+
{ cause: error },
|
|
149
|
+
);
|
|
150
|
+
});
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
private async loadApplied(): Promise<Set<string>> {
|
|
@@ -168,6 +182,11 @@ export class MigrationRunner {
|
|
|
168
182
|
}
|
|
169
183
|
}
|
|
170
184
|
|
|
185
|
+
function sqlState(error: unknown): string | undefined {
|
|
186
|
+
const code = (error as { errno?: unknown; code?: unknown } | null)?.errno ?? (error as { code?: unknown } | null)?.code;
|
|
187
|
+
return typeof code === "string" ? code : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
171
190
|
/** Names the failed migration; its transaction was rolled back, earlier ones stay applied. */
|
|
172
191
|
function migrationFailed(subject: string, error: unknown): unknown {
|
|
173
192
|
// An unknown COMMIT outcome must not be reported as a rollback.
|
|
@@ -73,7 +73,12 @@ export class SchemaAdmissionEngine {
|
|
|
73
73
|
// The PostgreSQL message names objects, not row values (those stay in its detail field).
|
|
74
74
|
const reason = `${describeOperation(operation)} failed${sqlState ? ` with PostgreSQL ${sqlState}` : ""}${error instanceof Error && error.message ? `: ${error.message}` : ""}`;
|
|
75
75
|
if (["23502", "23503", "23505", "23514"].includes(sqlState)) return new SchemaAdmissionError("ORM_SCHEMA_ADDITIVE_DATA_VIOLATION", `Existing PostgreSQL data violates the declared additive schema change: ${reason}. Fix the data, then start again.`);
|
|
76
|
-
|
|
76
|
+
// 42501: an application user with data rights only. The schema change is
|
|
77
|
+
// right; it has to be applied by a user that owns the tables.
|
|
78
|
+
const hint = sqlState === "42501"
|
|
79
|
+
? " This database user may not change the schema: apply the change with the schema owner's credentials (BAZIS_SCHEMA_ONLY=1 applies the schema and exits), or grant the rights."
|
|
80
|
+
: "";
|
|
81
|
+
return new SchemaAdmissionError(operation.kind === "createSchema" || operation.kind === "createTable" ? "ORM_SCHEMA_CREATE_FAILED" : "ORM_SCHEMA_ADDITIVE_DDL_FAILED", `PostgreSQL additive schema admission failed: ${reason}.${hint}`);
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.98.
|
|
3
|
+
"version": "0.98.30",
|
|
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",
|