bazis 0.98.12 → 0.98.14
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/orm/OrmLifecycle.ts +4 -1
- package/library/orm/DatabaseFacade.ts +4 -4
- package/library/orm/Metadata/ModelBuilder.ts +39 -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.14" }, 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/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();
|
|
@@ -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
|
/**
|
|
@@ -38,8 +38,10 @@ export class ModelBuilder {
|
|
|
38
38
|
const indexes: IndexModel[] = [];
|
|
39
39
|
const foreignKeys: ForeignKeyModel[] = [];
|
|
40
40
|
let explicitKey: PropertyModel | undefined;
|
|
41
|
+
const sample = ModelBuilder.sampleOf(ctor);
|
|
41
42
|
|
|
42
43
|
for (const rawProp of raw.properties.values()) {
|
|
44
|
+
ModelBuilder.assertTypeMatchesInitializer(ctor.name, rawProp, sample);
|
|
43
45
|
const property = ModelBuilder.buildProperty(ctor.name, rawProp);
|
|
44
46
|
properties.push(property);
|
|
45
47
|
if (property.isKey) {
|
|
@@ -184,6 +186,43 @@ export class ModelBuilder {
|
|
|
184
186
|
return model;
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
/** A fresh instance shows the initial values; an entity whose constructor throws skips the check. */
|
|
190
|
+
private static sampleOf(ctor: new () => object): Record<string, unknown> | undefined {
|
|
191
|
+
try {
|
|
192
|
+
return new ctor() as Record<string, unknown>;
|
|
193
|
+
} catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A column without an explicit type maps to `text` (a key to an integer
|
|
200
|
+
* identity). When the initial value says otherwise (`pages = 0`,
|
|
201
|
+
* `code = ""` on a key), the mapping would silently change the value's type
|
|
202
|
+
* or drop it, so the model refuses to build and names the type to set.
|
|
203
|
+
*/
|
|
204
|
+
private static assertTypeMatchesInitializer(entityName: string, raw: RawProperty, sample: Record<string, unknown> | undefined): void {
|
|
205
|
+
if (sample === undefined || raw.type !== undefined || raw.convention !== undefined || raw.converter !== undefined) return;
|
|
206
|
+
const initial = sample[raw.propertyName];
|
|
207
|
+
if (initial === undefined || initial === null) return;
|
|
208
|
+
const kind = initial instanceof Date ? "Date" : Array.isArray(initial) ? "array" : typeof initial;
|
|
209
|
+
if (raw.isKey === true) {
|
|
210
|
+
if (kind === "number" || kind === "bigint") return;
|
|
211
|
+
throw new ModelBuildError(
|
|
212
|
+
`Entity "${entityName}": key "${raw.propertyName}" has no column type and maps to an integer identity, but its initial value is a ${kind}. `
|
|
213
|
+
+ `For a text key add @Column({ type: "text" }) next to @Key(); for a UUID key use @UUID().`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (kind === "string") return;
|
|
217
|
+
const suggestion = kind === "number" || kind === "bigint" ? `@Column({ type: "integer" }) or @Column({ type: "real" })`
|
|
218
|
+
: kind === "boolean" ? `@Column({ type: "boolean" })`
|
|
219
|
+
: kind === "Date" ? `@Column({ type: "datetime" })`
|
|
220
|
+
: `@Column({ type: "json" })`;
|
|
221
|
+
throw new ModelBuildError(
|
|
222
|
+
`Entity "${entityName}": property "${raw.propertyName}" has no column type and maps to text, but its initial value is a ${kind}. Set the type: ${suggestion}.`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
187
226
|
private static buildProperty(entityName: string, raw: RawProperty): PropertyModel {
|
|
188
227
|
const isKey = raw.isKey === true;
|
|
189
228
|
const resolved = resolveColumnType(raw.type, isKey);
|
|
@@ -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.14",
|
|
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",
|