bazis 0.98.11 → 0.98.13
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/config/defineConfig.ts +9 -5
- package/core/orm/OrmLifecycle.ts +4 -1
- package/core/orm/databaseConnector.ts +22 -3
- package/library/orm/DatabaseFacade.ts +4 -4
- package/library/orm/Providers/PostgresProvider.ts +5 -1
- package/library/orm/Providers/ormTransactionRuntime.ts +1 -0
- package/library/orm/Providers/types.ts +5 -0
- package/library/orm/Saving/ExecutionStrategy.ts +1 -0
- package/library/orm/Schema/SchemaAdmission.ts +35 -6
- package/library/orm/index.ts +1 -0
- 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.13" }, 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");
|
|
@@ -154,18 +154,22 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
|
|
|
154
154
|
const values = new Map<string, ConfigValueType>();
|
|
155
155
|
const inspection: ConfigInspection[] = [];
|
|
156
156
|
const issues: string[] = [];
|
|
157
|
+
// A wrong value names what came and where to change it: the variable that
|
|
158
|
+
// supplied it, or the variables that can override the value from the code.
|
|
159
|
+
const got = (raw: unknown) => raw === undefined ? "" : `, got ${JSON.stringify(String(raw)).slice(0, 80)}`;
|
|
160
|
+
const source = (key: string, selected: { readonly name: string } | undefined) => ` (${selected ? selected.name : names.get(key)!.join(", ")})`;
|
|
157
161
|
for (const key of keys) {
|
|
158
162
|
const base = schema.default[key]!;
|
|
159
163
|
const override = schema[environment]?.[key];
|
|
160
164
|
const cell = override ?? base;
|
|
161
165
|
const type = isSecret(base) ? "secret" : isEnum(base) ? typeof base.default : typeof base;
|
|
162
|
-
const candidates: { raw: string; source: string; priority: number }[] = [];
|
|
166
|
+
const candidates: { raw: string; source: string; priority: number; name: string }[] = [];
|
|
163
167
|
for (const name of names.get(key)!) {
|
|
164
168
|
const sourceKey = name.slice(ENV_PREFIX.length).toLowerCase().replaceAll("__", ".");
|
|
165
169
|
const raw = configuration.get(sourceKey);
|
|
166
170
|
if (raw === undefined) continue;
|
|
167
171
|
const origin = configuration.origin(sourceKey);
|
|
168
|
-
candidates.push({ raw, ...origin });
|
|
172
|
+
candidates.push({ raw, ...origin, name });
|
|
169
173
|
}
|
|
170
174
|
const priority = Math.max(...candidates.map(candidate => candidate.priority));
|
|
171
175
|
const winners = candidates.filter(candidate => candidate.priority === priority);
|
|
@@ -178,17 +182,17 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
|
|
|
178
182
|
else value = new Secret(raw);
|
|
179
183
|
} else if (type === "number") {
|
|
180
184
|
const parsed = Number(raw);
|
|
181
|
-
if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed)) issues.push(`${fullKey(key)} — expected a finite number`);
|
|
185
|
+
if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed)) issues.push(`${fullKey(key)} — expected a finite number${got(raw)}${source(key, selected)}`);
|
|
182
186
|
else value = parsed;
|
|
183
187
|
} else if (type === "boolean") {
|
|
184
188
|
const normalized = String(raw).toLowerCase();
|
|
185
189
|
if (normalized === "true" || normalized === "1") value = true;
|
|
186
190
|
else if (normalized === "false" || normalized === "0") value = false;
|
|
187
|
-
else issues.push(`${fullKey(key)} — expected a boolean`);
|
|
191
|
+
else issues.push(`${fullKey(key)} — expected a boolean (true, false, 1, 0)${got(raw)}${source(key, selected)}`);
|
|
188
192
|
} else if (typeof raw === "string") value = raw;
|
|
189
193
|
else issues.push(`${fullKey(key)} — expected a string`);
|
|
190
194
|
if (value === undefined) continue;
|
|
191
|
-
if (isEnum(base) && !base.values.includes(value as string | number)) issues.push(`${fullKey(key)} — value is not one of
|
|
195
|
+
if (isEnum(base) && !base.values.includes(value as string | number)) issues.push(`${fullKey(key)} — ${JSON.stringify(value)} is not allowed, use one of: ${base.values.join(", ")}${source(key, selected)}`);
|
|
192
196
|
try {
|
|
193
197
|
const issue = validators[key]?.(value);
|
|
194
198
|
if (issue) issues.push(`${fullKey(key)} — ${issue}`);
|
package/core/orm/OrmLifecycle.ts
CHANGED
|
@@ -65,7 +65,10 @@ export class OrmLifecycle implements HostedService {
|
|
|
65
65
|
const database = new DatabaseFacade(this.options.provider, this.options.model);
|
|
66
66
|
try {
|
|
67
67
|
if (this.ensureCreated) {
|
|
68
|
-
await database.ensureCreated();
|
|
68
|
+
const result = await database.ensureCreated();
|
|
69
|
+
for (const warning of result.warnings) {
|
|
70
|
+
console.warn(`[orm:schema] ${warning}`);
|
|
71
|
+
}
|
|
69
72
|
}
|
|
70
73
|
if (this.migrateOnStart) {
|
|
71
74
|
const result = await database.migrate();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InfraError, reader, requireValue, postgresConnectionOptions, type InfraConnector, type PostgresConfigShape } from "../infra";
|
|
1
|
+
import { errorMessage, InfraError, reader, requireValue, postgresConnectionOptions, type InfraConnector, type PostgresConfigShape } from "../infra";
|
|
2
2
|
import type { AppConfig, ConfigRegistry } from "../kernel";
|
|
3
3
|
import { postgres, type DatabaseProvider, type PostgresServerTimeouts } from "../../library/orm";
|
|
4
4
|
import { DATABASE_PROVIDER } from "./DATABASE_PROVIDER";
|
|
@@ -86,16 +86,26 @@ export function ormBazisConnect<T extends PostgresOrmConfigShape>(config: AppCon
|
|
|
86
86
|
export function ormBazisConnect(
|
|
87
87
|
config: AppConfig<PostgresOrmConfigShape>,
|
|
88
88
|
): InfraConnector<DatabaseProvider> {
|
|
89
|
+
// Where the connection goes, for the startup error; never the password.
|
|
90
|
+
let target = "PostgreSQL";
|
|
89
91
|
return {
|
|
90
92
|
token: DATABASE_PROVIDER,
|
|
91
93
|
config,
|
|
92
94
|
phase: DATABASE_PHASE,
|
|
93
95
|
create(configs) {
|
|
96
|
+
const c = reader(config, configs);
|
|
97
|
+
target = `PostgreSQL at ${String(c.get("host"))}:${String(c.get("port"))} (database "${String(c.get("database"))}", user "${String(c.get("username"))}")`;
|
|
94
98
|
return buildPostgresProvider(config, configs);
|
|
95
99
|
},
|
|
96
100
|
async connect(provider, signal) {
|
|
97
|
-
if (
|
|
98
|
-
throw new InfraError(
|
|
101
|
+
if (provider.probe === undefined) {
|
|
102
|
+
if (!(await provider.ping(signal))) throw new InfraError(`Infra connector "database": cannot connect to ${target}.`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
await provider.probe(signal);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new InfraError(`Infra connector "database": cannot connect to ${target}: ${connectionFailure(error)}`);
|
|
99
109
|
}
|
|
100
110
|
},
|
|
101
111
|
async dispose(provider) {
|
|
@@ -106,3 +116,12 @@ export function ormBazisConnect(
|
|
|
106
116
|
},
|
|
107
117
|
};
|
|
108
118
|
}
|
|
119
|
+
|
|
120
|
+
/** The driver's reason with its code: `password authentication failed for user "app" (28P01)`. */
|
|
121
|
+
function connectionFailure(error: unknown): string {
|
|
122
|
+
const details = error as { errno?: unknown; code?: unknown };
|
|
123
|
+
const code = typeof details.errno === "string" && details.errno ? details.errno
|
|
124
|
+
: typeof details.code === "string" && details.code ? details.code : undefined;
|
|
125
|
+
const message = errorMessage(error);
|
|
126
|
+
return code === undefined || message.includes(code) ? message : `${message} (${code})`;
|
|
127
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { DatabaseProvider, DbExecutor, ExecuteResult, ForeignKeyConstraint,
|
|
|
4
4
|
import { Migrator, type MigrationResult } from "./Schema/Migrator";
|
|
5
5
|
import { MigrationRunner, type Migration, type VersionedMigrationResult } from "./Schema/MigrationRunner";
|
|
6
6
|
import { SchemaDiffer } from "./Schema/SchemaDiffer";
|
|
7
|
-
import { SchemaAdmissionEngine } from "./Schema/SchemaAdmission";
|
|
7
|
+
import { SchemaAdmissionEngine, type EnsureCreatedResult } from "./Schema/SchemaAdmission";
|
|
8
8
|
import { SchemaAdmissionError } from "./errors";
|
|
9
9
|
import { physicalColumnTypes } from "./Schema/physicalColumnTypes";
|
|
10
10
|
import { bindResolvedForeignKey } from "./Providers/resolvedForeignKey";
|
|
@@ -26,10 +26,9 @@ export class DatabaseFacade {
|
|
|
26
26
|
* whole tables, then exact-verifies again before commit. Existing tables are
|
|
27
27
|
* never altered or repaired.
|
|
28
28
|
*/
|
|
29
|
-
async ensureCreated(): Promise<
|
|
29
|
+
async ensureCreated(): Promise<EnsureCreatedResult> {
|
|
30
30
|
if (this.provider.name === "postgres") {
|
|
31
|
-
|
|
32
|
-
return;
|
|
31
|
+
return new SchemaAdmissionEngine(this.provider, this.models).ensureCreated();
|
|
33
32
|
}
|
|
34
33
|
// The physical default contract belongs exclusively to exact PostgreSQL
|
|
35
34
|
// admission. Other providers must reject it rather than silently ignore it.
|
|
@@ -45,6 +44,7 @@ export class DatabaseFacade {
|
|
|
45
44
|
await this.provider.execute(indexSql, []);
|
|
46
45
|
}
|
|
47
46
|
}
|
|
47
|
+
return { warnings: [] };
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
@@ -409,13 +409,17 @@ export class PostgresProvider implements DatabaseProvider {
|
|
|
409
409
|
|
|
410
410
|
async ping(signal?: AbortSignal): Promise<boolean> {
|
|
411
411
|
try {
|
|
412
|
-
await
|
|
412
|
+
await this.probe(signal);
|
|
413
413
|
return true;
|
|
414
414
|
} catch {
|
|
415
415
|
return false;
|
|
416
416
|
}
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
async probe(signal?: AbortSignal): Promise<void> {
|
|
420
|
+
await withPostgresScopeOptions({ signal }, () => this.pooledOperation((session) => this.runQuery(session, "SELECT 1", [])));
|
|
421
|
+
}
|
|
422
|
+
|
|
419
423
|
async introspect(): Promise<IntrospectedSchema> {
|
|
420
424
|
const tableRows = await this.query(
|
|
421
425
|
`SELECT table_schema, table_name
|
|
@@ -96,6 +96,7 @@ export function createObservedProvider(provider: DatabaseProvider, observe: Tran
|
|
|
96
96
|
afterCommit: provider.afterCommit?.bind(provider),
|
|
97
97
|
afterRollback: provider.afterRollback?.bind(provider),
|
|
98
98
|
ping: (signal?: AbortSignal): Promise<boolean> => observe(() => provider.ping(signal)),
|
|
99
|
+
probe: provider.probe ? (signal?: AbortSignal): Promise<void> => observe(() => provider.probe!(signal)) : undefined,
|
|
99
100
|
introspect: () => observe(() => provider.introspect()),
|
|
100
101
|
schemaAdmissionCapability: schema ? {
|
|
101
102
|
version: schema.version,
|
|
@@ -178,6 +178,11 @@ export interface DatabaseProvider {
|
|
|
178
178
|
afterRollback?(callback: TransactionCallback): void;
|
|
179
179
|
/** Connection check for the health check. */
|
|
180
180
|
ping(signal?: AbortSignal): Promise<boolean>;
|
|
181
|
+
/**
|
|
182
|
+
* Connection check that rejects with the driver's error instead of `false`,
|
|
183
|
+
* so a startup failure can say why (wrong password, missing database, …).
|
|
184
|
+
*/
|
|
185
|
+
probe?(signal?: AbortSignal): Promise<void>;
|
|
181
186
|
/** Introspection of the actual database schema (for the auto-migration). */
|
|
182
187
|
introspect(): Promise<IntrospectedSchema>;
|
|
183
188
|
/**
|
|
@@ -103,6 +103,7 @@ export function withRetry(provider: DatabaseProvider, options?: ExecutionStrateg
|
|
|
103
103
|
afterCommit: provider.afterCommit ? (callback) => provider.afterCommit!(callback) : undefined,
|
|
104
104
|
afterRollback: provider.afterRollback ? (callback) => provider.afterRollback!(callback) : undefined,
|
|
105
105
|
ping: (signal) => provider.ping(signal),
|
|
106
|
+
probe: provider.probe ? (signal) => provider.probe!(signal) : undefined,
|
|
106
107
|
introspect: () => provider.introspect(),
|
|
107
108
|
schemaAdmissionCapability: provider.schemaAdmissionCapability,
|
|
108
109
|
withMigrationLock: provider.withMigrationLock ? (work) => provider.withMigrationLock!(work) : undefined,
|
|
@@ -2,7 +2,7 @@ import type { DatabaseProvider, SchemaAdmissionScope } from "../Providers/types"
|
|
|
2
2
|
import { SchemaAdmissionError, SchemaMigrationRequiredError, SchemaVerificationError } from "../errors";
|
|
3
3
|
import type { OrmModel } from "../Metadata/OrmModel";
|
|
4
4
|
import { compileExpectedSchema, type OrmExpectedSchema } from "./ExpectedSchema";
|
|
5
|
-
import { ExactSchemaVerifier } from "./ExactSchemaVerifier";
|
|
5
|
+
import { ExactSchemaVerifier, type SchemaVerificationResult } from "./ExactSchemaVerifier";
|
|
6
6
|
import { classifySafeAdditive, renderSafeAdditivePostgres } from "./SafeAdditiveSchema";
|
|
7
7
|
|
|
8
8
|
/** PostgreSQL-private trace boundary; deliberately absent from public capability types. */
|
|
@@ -10,10 +10,15 @@ type TraceableSchemaAdmissionScope = SchemaAdmissionScope & {
|
|
|
10
10
|
readonly executeSchemaAdmission?: (sql: string, operation: string) => ReturnType<SchemaAdmissionScope["execute"]>;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
/** Result of `ensureCreated`: tolerated differences the application should know about. */
|
|
14
|
+
export interface EnsureCreatedResult {
|
|
15
|
+
readonly warnings: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
/** PostgreSQL-only create-missing-whole-tables and exact admission. */
|
|
14
19
|
export class SchemaAdmissionEngine {
|
|
15
20
|
constructor(private readonly provider: DatabaseProvider, private readonly models: OrmModel) {}
|
|
16
|
-
async ensureCreated(): Promise<
|
|
21
|
+
async ensureCreated(): Promise<EnsureCreatedResult> {
|
|
17
22
|
let expected: OrmExpectedSchema;
|
|
18
23
|
try {
|
|
19
24
|
expected = compileExpectedSchema(this.models);
|
|
@@ -25,16 +30,18 @@ export class SchemaAdmissionEngine {
|
|
|
25
30
|
if (capability.version !== 1 || !capability.distributedLock || !capability.transactionalDdl || !capability.exactIntrospection) {
|
|
26
31
|
throw new SchemaAdmissionError("ORM_SCHEMA_ATOMICITY_UNAVAILABLE", "PostgreSQL schema admission cannot guarantee atomic exact verification.");
|
|
27
32
|
}
|
|
33
|
+
let warnings: readonly string[] = [];
|
|
28
34
|
try {
|
|
29
|
-
await capability.withSchemaAdmission([...new Set(expected.tables.map((table) => table.schema))], async (scope) => this.admit(scope, expected));
|
|
35
|
+
await capability.withSchemaAdmission([...new Set(expected.tables.map((table) => table.schema))], async (scope) => { warnings = await this.admit(scope, expected); });
|
|
30
36
|
} catch (error) {
|
|
31
37
|
if (error instanceof SchemaAdmissionError) throw error;
|
|
32
38
|
throw new SchemaAdmissionError("ORM_SCHEMA_ATOMICITY_UNAVAILABLE", "PostgreSQL schema admission could not complete safely.");
|
|
33
39
|
}
|
|
40
|
+
return { warnings };
|
|
34
41
|
}
|
|
35
|
-
private async admit(scope: SchemaAdmissionScope, expected: OrmExpectedSchema): Promise<
|
|
42
|
+
private async admit(scope: SchemaAdmissionScope, expected: OrmExpectedSchema): Promise<readonly string[]> {
|
|
36
43
|
const before = await scope.introspectExpected(expected);
|
|
37
|
-
const initial = new ExactSchemaVerifier().verify(expected, before, true);
|
|
44
|
+
const { verification: initial, warnings } = tolerateNameDrift(new ExactSchemaVerifier().verify(expected, before, true));
|
|
38
45
|
const preflight = classifySafeAdditive(expected, before, initial);
|
|
39
46
|
if (preflight.hardDifferences.length) {
|
|
40
47
|
if (preflight.hardDifferences.some((difference) => difference.code === "catalog.unsupported")) throw new SchemaAdmissionError("ORM_SCHEMA_CATALOG_UNSUPPORTED", "PostgreSQL catalog contains a schema form unsupported by exact admission.");
|
|
@@ -49,8 +56,9 @@ export class SchemaAdmissionEngine {
|
|
|
49
56
|
}
|
|
50
57
|
catch (error) { throw this.mapDdlError(operation.kind, error); }
|
|
51
58
|
}
|
|
52
|
-
const final = new ExactSchemaVerifier().verify(expected, await scope.introspectExpected(expected));
|
|
59
|
+
const { verification: final } = tolerateNameDrift(new ExactSchemaVerifier().verify(expected, await scope.introspectExpected(expected)));
|
|
53
60
|
if (!final.compatible) throw new SchemaVerificationError(final);
|
|
61
|
+
return warnings;
|
|
54
62
|
}
|
|
55
63
|
private mapDdlError(kind: string, error: unknown): SchemaAdmissionError {
|
|
56
64
|
const sqlState = typeof error === "object" && error !== null
|
|
@@ -60,3 +68,24 @@ export class SchemaAdmissionEngine {
|
|
|
60
68
|
return new SchemaAdmissionError(kind === "createSchema" || kind === "createTable" ? "ORM_SCHEMA_CREATE_FAILED" : "ORM_SCHEMA_ADDITIVE_DDL_FAILED", "PostgreSQL additive schema admission failed.");
|
|
61
69
|
}
|
|
62
70
|
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A primary key that differs only by name (`tasks_pkey` from an older schema vs
|
|
74
|
+
* the model's `pk_tasks`) enforces the same thing, and queries never reference
|
|
75
|
+
* the name (`ON CONFLICT` names columns). It is reported, not refused; the same
|
|
76
|
+
* table passes `migrateOnStart`. Every other difference stays exact.
|
|
77
|
+
*/
|
|
78
|
+
function tolerateNameDrift(verification: SchemaVerificationResult): { readonly verification: SchemaVerificationResult; readonly warnings: readonly string[] } {
|
|
79
|
+
const tolerated = verification.differences.filter((difference) => difference.code === "primaryKey.name");
|
|
80
|
+
if (tolerated.length === 0) return { verification, warnings: [] };
|
|
81
|
+
const differences = verification.differences.filter((difference) => difference.code !== "primaryKey.name");
|
|
82
|
+
const quote = (name: string) => `"${name.replaceAll('"', '""')}"`;
|
|
83
|
+
const warnings = tolerated.map((difference) => {
|
|
84
|
+
const table = `${quote(difference.schema)}.${quote(difference.table)}`;
|
|
85
|
+
const actual = difference.actual?.value ?? "";
|
|
86
|
+
const expected = difference.expected?.value ?? difference.objectName ?? "";
|
|
87
|
+
return `table ${table}: primary key is named ${quote(actual)}, the model expects ${quote(expected)}. `
|
|
88
|
+
+ `It works as is; to align the name run: ALTER TABLE ${table} RENAME CONSTRAINT ${quote(actual)} TO ${quote(expected)};`;
|
|
89
|
+
});
|
|
90
|
+
return { verification: { compatible: differences.length === 0, differences }, warnings };
|
|
91
|
+
}
|
package/library/orm/index.ts
CHANGED
|
@@ -131,6 +131,7 @@ export type {
|
|
|
131
131
|
IntrospectedCheck,
|
|
132
132
|
} from "./Schema/introspection";
|
|
133
133
|
export type { SchemaDifferenceCode, SafeSchemaDescriptor, SchemaDifference, SchemaVerificationResult } from "./Schema/ExactSchemaVerifier";
|
|
134
|
+
export type { EnsureCreatedResult } from "./Schema/SchemaAdmission";
|
|
134
135
|
export { SchemaAdmissionError, SchemaMigrationRequiredError, SchemaVerificationError } from "./errors";
|
|
135
136
|
export type { SchemaAdmissionErrorCode } from "./errors";
|
|
136
137
|
export { compileCheck, renderCheck } from "./Schema/CheckExpression";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.98.
|
|
3
|
+
"version": "0.98.13",
|
|
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",
|