bazis 0.98.19 → 0.98.20

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.
@@ -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.19" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.20" }, 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");
@@ -1,10 +1,10 @@
1
- import { inspect } from "node:util";
2
1
  import { DiError } from "../di";
3
2
  import { Kernel } from "./Kernel";
4
3
  import { KernelBuilder, type RootModuleInput } from "./KernelBuilder";
5
4
  import { KernelError, ShutdownTimeoutError } from "./errors";
6
5
  import type { KernelOptions } from "./types";
7
- import { redactSensitive, redactSensitiveText } from "../../library/redaction";
6
+ import { redactSensitiveText } from "../../library/redaction";
7
+ import { formatStartupFailure } from "./startupFailure";
8
8
 
9
9
  /**
10
10
  * Entry-point facade.
@@ -61,10 +61,7 @@ export class Bazis {
61
61
  if (error instanceof DiError || error instanceof KernelError) {
62
62
  console.error(`[bazis] configuration error: ${redactSensitiveText(error.message)}`);
63
63
  } else {
64
- console.error("[bazis] application failed:", inspect(redactSensitive(error), {
65
- depth: null, colors: false, customInspect: false, getters: false,
66
- maxArrayLength: null, maxStringLength: null,
67
- }));
64
+ console.error(`[bazis] application failed: ${formatStartupFailure(error)}`);
68
65
  }
69
66
  process.exitCode = 1;
70
67
  return 1;
@@ -0,0 +1,46 @@
1
+ import { inspect } from "node:util";
2
+ import { redactSensitive, redactSensitiveText } from "../../library/redaction";
3
+
4
+ const MAX_CAUSES = 5;
5
+ /** Properties already printed in the header, the frames or the cause chain. */
6
+ const SHOWN = new Set(["name", "message", "stack", "cause", "code", "errno", "errors"]);
7
+ const INSPECT = { depth: null, colors: false, customInspect: false, getters: false, maxArrayLength: null, maxStringLength: null } as const;
8
+
9
+ /**
10
+ * Text of an application startup failure: `Name [code]: message`, the
11
+ * stack frames, the remaining error properties (for example a schema
12
+ * verification report or a PostgreSQL `detail`) and the `cause` chain. The
13
+ * message is printed once as plain text instead of inside an inspected object.
14
+ */
15
+ export function formatStartupFailure(error: unknown): string {
16
+ if (!(error instanceof Error)) {
17
+ return inspect(redactSensitive(error), INSPECT);
18
+ }
19
+ const lines: string[] = [];
20
+ let current: unknown = error;
21
+ for (let depth = 0; depth <= MAX_CAUSES && current !== undefined; depth += 1) {
22
+ const prefix = depth === 0 ? "" : "Caused by: ";
23
+ if (!(current instanceof Error)) {
24
+ lines.push(prefix + inspect(redactSensitive(current), { depth: 2, colors: false, customInspect: false, getters: false }));
25
+ break;
26
+ }
27
+ lines.push(`${prefix}${current.name}${codeOf(current)}: ${current.message}`);
28
+ if (current instanceof AggregateError) {
29
+ for (const inner of current.errors) lines.push(` - ${inner instanceof Error ? `${inner.name}: ${inner.message}` : String(inner)}`);
30
+ }
31
+ lines.push(...(current.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)));
32
+ const details = Object.fromEntries(Object.entries(current).filter(([name]) => !SHOWN.has(name)));
33
+ if (Object.keys(details).length > 0) lines.push(inspect(redactSensitive(details), INSPECT));
34
+ current = current.cause;
35
+ }
36
+ return redactSensitiveText(lines.join("\n"));
37
+ }
38
+
39
+ function codeOf(error: Error): string {
40
+ // Node style: `Error [ERR_X]: message`; a database error code such as 42P01 follows it.
41
+ const fields = ["code", "errno"].flatMap((name) => {
42
+ const value = (error as unknown as Record<string, unknown>)[name];
43
+ return typeof value === "string" || typeof value === "number" ? [String(value)] : [];
44
+ });
45
+ return fields.length > 0 ? ` [${fields.join(", ")}]` : "";
46
+ }
@@ -21,28 +21,38 @@ function validateOrmHostedPlan(services: readonly HostedService[]): void {
21
21
  if (!framework && (service.phase ?? 0) < 0) throw new SchemaAdmissionError("ORM_SCHEMA_HOSTED_PHASE_CONFLICT", "Application hosted services must use phase 0 or later with ORM owned-store admission.");
22
22
  }
23
23
  }
24
- const strict = services.filter((service): service is HostedService & { readonly __bazisSchemaAdmission: { readonly unit: readonly string[]; readonly tables: readonly string[]; readonly foreignKeys: readonly { readonly source: string; readonly target: string }[] } } =>
24
+ const strict = services.filter((service): service is HostedService & { readonly __bazisSchemaAdmission: { readonly owner?: string; readonly unit: readonly string[]; readonly tables: readonly string[]; readonly foreignKeys: readonly { readonly source: string; readonly target: string }[] } } =>
25
25
  (service as { __bazisSchemaAdmission?: unknown }).__bazisSchemaAdmission !== undefined,
26
26
  );
27
27
  if (strict.length === 0) return;
28
- const owners = new Set<string>();
28
+ const owners = new Map<string, string>();
29
29
  for (const service of strict) {
30
30
  if ((service.phase ?? 0) !== -105) throw new SchemaAdmissionError("ORM_SCHEMA_HOSTED_PHASE_CONFLICT", "Schema admission has an invalid hosted phase.");
31
- for (const table of service.__bazisSchemaAdmission.tables) { if (owners.has(table)) throw new SchemaAdmissionError("ORM_SCHEMA_OWNERSHIP_CONFLICT", "Schema admission table ownership conflicts."); owners.add(table); }
31
+ const owner = service.__bazisSchemaAdmission.owner ?? "A DbContext";
32
+ for (const table of service.__bazisSchemaAdmission.tables) {
33
+ const other = owners.get(table);
34
+ if (other !== undefined) throw new SchemaAdmissionError("ORM_SCHEMA_OWNERSHIP_CONFLICT", `Schema admission table ownership conflicts: table ${table} is created by both ${other} and ${owner} with ensureCreated. One context must own the table: remove the entity from the other context and refer to it by a plain id column.`);
35
+ owners.set(table, owner);
36
+ }
32
37
  const unit = new Set(service.__bazisSchemaAdmission.unit);
33
38
  for (const foreignKey of service.__bazisSchemaAdmission.foreignKeys) {
34
39
  if (!unit.has(foreignKey.source) || !unit.has(foreignKey.target)) {
35
- throw new SchemaAdmissionError("ORM_SCHEMA_OWNERSHIP_CONFLICT", "Schema admission foreign keys must target the same explicit unit.");
40
+ throw new SchemaAdmissionError("ORM_SCHEMA_OWNERSHIP_CONFLICT", `Schema admission foreign keys must target the same explicit unit: ${owner} has a foreign key from ${foreignKey.source} to ${foreignKey.target}, which it does not create.`);
36
41
  }
37
42
  }
38
43
  }
39
44
  if (!services.some((service) => isOrmProviderLifecycle(service) && (service.phase ?? 0) === -110)) {
40
45
  throw new SchemaAdmissionError("ORM_SCHEMA_HOSTED_PHASE_CONFLICT", "Schema admission requires a provider lifecycle at phase -110.");
41
46
  }
47
+ const legacy = services.filter((service) => (service as { __bazisLegacySchemaAuthority?: unknown }).__bazisLegacySchemaAuthority === true);
48
+ if (legacy.length > 0) {
49
+ const describe = (service: HostedService) => {
50
+ const owner = (service as { __bazisSchemaOwner?: { context: string; mode: string } }).__bazisSchemaOwner;
51
+ return owner ? `${owner.context} uses ${owner.mode}` : "a module uses migrateOnStart or migrations";
52
+ };
53
+ throw new SchemaAdmissionError("ORM_SCHEMA_HOSTED_PHASE_CONFLICT", `Schema admission and legacy ORM schema authority cannot be composed together: ${[...strict, ...legacy].map(describe).join(", ")}. Use one schema mode in the application: ensureCreated in every module, or migrateOnStart/migrations in every module.`);
54
+ }
42
55
  for (const service of services) {
43
- if ((service as { __bazisLegacySchemaAuthority?: unknown }).__bazisLegacySchemaAuthority === true) {
44
- throw new SchemaAdmissionError("ORM_SCHEMA_HOSTED_PHASE_CONFLICT", "Schema admission and legacy ORM schema authority cannot be composed together.");
45
- }
46
56
  const framework = isOrmProviderLifecycle(service)
47
57
  || isStrictSchemaPrerequisiteLifecycle(service)
48
58
  || (service as { __bazisOrmLegacyLifecycle?: unknown }).__bazisOrmLegacyLifecycle === true
@@ -22,10 +22,13 @@ export class OrmLifecycle implements HostedService {
22
22
  readonly __bazisLegacySchemaAuthority: boolean;
23
23
  /** Internal hosted-plan marker: tables and foreign keys of the phase -105 exact `ensureCreated` admission on PostgreSQL. */
24
24
  readonly __bazisSchemaAdmission?: {
25
+ readonly owner: string;
25
26
  readonly unit: readonly string[];
26
27
  readonly tables: readonly string[];
27
28
  readonly foreignKeys: readonly { readonly source: string; readonly target: string }[];
28
29
  };
30
+ /** Internal hosted-plan marker: the context and its startup schema mode, for readable plan errors. */
31
+ readonly __bazisSchemaOwner: { readonly context: string; readonly mode: string };
29
32
  private connectionClosed = false;
30
33
  private closePromise?: Promise<void>;
31
34
 
@@ -42,13 +45,17 @@ export class OrmLifecycle implements HostedService {
42
45
  * must not close it at shutdown, or the shared pool would be closed twice.
43
46
  */
44
47
  private readonly ownsConnection: boolean = true,
48
+ contextName = "DbContext",
45
49
  ) {
50
+ const modes = [ensureCreated && "ensureCreated", migrateOnStart && "migrateOnStart", runMigrationsOnStart && migrations.length > 0 && "migrations"].filter(Boolean);
51
+ this.__bazisSchemaOwner = Object.freeze({ context: contextName, mode: modes.join(" + ") || "none" });
46
52
  this.phase = ensureCreated && options.provider.name === "postgres" ? -105 : -100;
47
53
  this.__bazisLegacySchemaAuthority = this.phase === -100 && (ensureCreated || migrateOnStart || (runMigrationsOnStart && migrations.length > 0));
48
54
  if (this.phase === -105) {
49
55
  const expected = compileExpectedSchema(options.model);
50
56
  const tables = Object.freeze(expected.tables.map((table) => `${table.schema}.${table.table}`));
51
57
  this.__bazisSchemaAdmission = Object.freeze({
58
+ owner: contextName,
52
59
  unit: tables,
53
60
  tables,
54
61
  foreignKeys: Object.freeze(expected.tables.flatMap((table) =>
@@ -66,6 +73,9 @@ export class OrmLifecycle implements HostedService {
66
73
  try {
67
74
  if (this.ensureCreated) {
68
75
  const result = await database.ensureCreated();
76
+ if (result.applied && result.applied.length > 0) {
77
+ console.info(`[orm:schema] applied ${result.applied.length} operation(s): ${result.applied.join(", ")}`);
78
+ }
69
79
  for (const warning of result.warnings) {
70
80
  console.warn(`[orm:schema] ${warning}`);
71
81
  }
@@ -39,6 +39,7 @@ function createOrmLifecycle<TContext extends DbContext>(
39
39
  config.migrations ?? [],
40
40
  config.runMigrationsOnStart === true,
41
41
  ownsConnection,
42
+ config.context.name,
42
43
  );
43
44
  }
44
45
 
@@ -72,7 +72,7 @@ export class DatabaseFacade {
72
72
  const types = this.provider.dialect.name === "postgres" ? physicalColumnTypes(this.models.entities) : undefined;
73
73
  const foreignKeys = new Map(targets.map((model) => [model, this.foreignKeysFor(model, types)]));
74
74
  const schema = await this.provider.introspect();
75
- const { operations, warnings } = new SchemaDiffer().diff(targets, schema);
75
+ const { operations, warnings } = new SchemaDiffer(types).diff(targets, schema);
76
76
  const migrator = new Migrator(this.provider, (model) => foreignKeys.get(model)!);
77
77
  const applied = await migrator.apply(operations);
78
78
  return { applied: applied.length, operations: applied, warnings };
@@ -54,7 +54,7 @@ export class ModelBuilder {
54
54
  }
55
55
  if (rawProp.index) {
56
56
  indexes.push({
57
- name: rawProp.index.name ?? `ix_${defaultTableName(ctor.name).toLowerCase()}_${property.columnName}`,
57
+ name: rawProp.index.name ?? `ix_${(raw.table ?? defaultTableName(ctor.name)).toLowerCase()}_${property.columnName}`,
58
58
  columns: [property.columnName],
59
59
  unique: rawProp.index.unique,
60
60
  });
@@ -2,7 +2,8 @@ import type { EntityModel } from "../Metadata/types";
2
2
  import type { OrmModel } from "../Metadata/OrmModel";
3
3
  import type { CanonicalDefault, GenerationStrategy } from "./introspection";
4
4
  import { projectCheckAstIdentifiers } from "./CheckExpression";
5
- import { physicalColumnTypes } from "./physicalColumnTypes";
5
+ import { crossUnitForeignKey, physicalColumnTypes } from "./physicalColumnTypes";
6
+ import { SchemaAdmissionError } from "../errors";
6
7
  import { physicalTableIdentity } from "./tableKey";
7
8
 
8
9
  export interface ExpectedColumn { readonly property: string; readonly column: string; readonly physicalType: string; readonly nullable: boolean; readonly default: CanonicalDefault; readonly generation: GenerationStrategy }
@@ -14,18 +15,19 @@ const schemaOf = (model: EntityModel) => physicalTableIdentity(model).schema?.tr
14
15
  /** Pure expected physical contract compiled solely from the explicit entity unit. */
15
16
  export function compileExpectedSchema(models: OrmModel): OrmExpectedSchema {
16
17
  const byCtor = new Map(models.entities.map((model) => [model.ctor, model]));
17
- const identities = new Set<string>();
18
+ const identities = new Map<string, string>();
18
19
  const types = physicalColumnTypes(models.entities);
19
20
  const tables = models.entities.map((model) => compileTable(model, byCtor, identities, types));
20
21
  return { tables: Object.freeze(tables.sort((a, b) => Buffer.compare(Buffer.from(`${a.schema}\0${a.table}`, "utf8"), Buffer.from(`${b.schema}\0${b.table}`, "utf8")))) };
21
22
  }
22
23
 
23
- function compileTable(model: EntityModel, byCtor: ReadonlyMap<EntityModel["ctor"], EntityModel>, identities: Set<string>, types: ReturnType<typeof physicalColumnTypes>): ExpectedTable {
24
+ function compileTable(model: EntityModel, byCtor: ReadonlyMap<EntityModel["ctor"], EntityModel>, identities: Map<string, string>, types: ReturnType<typeof physicalColumnTypes>): ExpectedTable {
24
25
  const schema = schemaOf(model);
25
26
  const table = physicalTableIdentity(model).table;
26
27
  const identity = `${schema}\0${table}`;
27
- if (identities.has(identity)) throw new Error("ORM_SCHEMA_OWNERSHIP_CONFLICT");
28
- identities.add(identity);
28
+ const other = identities.get(identity);
29
+ if (other !== undefined) throw new SchemaAdmissionError("ORM_SCHEMA_OWNERSHIP_CONFLICT", `Entities "${other}" and "${model.name}" both map table "${schema}"."${table}"; one table belongs to one entity.`);
30
+ identities.set(identity, model.name);
29
31
  const columns = model.properties.map((property) => ({
30
32
  property: property.propertyName,
31
33
  column: property.columnName,
@@ -36,7 +38,7 @@ function compileTable(model: EntityModel, byCtor: ReadonlyMap<EntityModel["ctor"
36
38
  }));
37
39
  const foreignKeys = model.foreignKeys.map((foreignKey) => {
38
40
  const target = byCtor.get(foreignKey.target());
39
- if (!target) throw new Error("ORM_SCHEMA_CROSS_UNIT_FOREIGN_KEY");
41
+ if (!target) throw crossUnitForeignKey(model, foreignKey.properties, foreignKey.target());
40
42
  return { name: foreignKey.name ?? `fk_${table}_${foreignKey.properties.join("_")}`, columns: foreignKey.properties.map((name) => model.propertyByName(name)!.columnName), target: { schema: schemaOf(target), table: physicalTableIdentity(target).table }, targetColumns: target.key.map((property) => property.columnName), onDelete: foreignKey.onDelete, onUpdate: foreignKey.onUpdate };
41
43
  });
42
44
  return Object.freeze({ schema, table, columns: Object.freeze(columns), primaryKey: Object.freeze({ name: model.keyName ?? `pk_${table}`, columns: Object.freeze(model.key.map((property) => property.columnName)) }), indexes: Object.freeze(model.indexes.map((index) => Object.freeze({ ...index, columns: Object.freeze([...index.columns]), method: "btree" as const }))), foreignKeys: Object.freeze(foreignKeys), checks: Object.freeze(model.checks.map((check) => Object.freeze({ name: check.name, expression: projectCheckAstIdentifiers(check.expression, (name) => model.propertyByName(name)!.columnName) }))) });
@@ -1,4 +1,6 @@
1
1
  import type { DatabaseProvider, DbExecutor, Row, SqlParam } from "../Providers/types";
2
+ import { OrmError } from "../errors";
3
+ import { isUnknownTransactionOutcome } from "../Providers/transactionOutcome";
2
4
 
3
5
  /** Execution context of a versioned migration. */
4
6
  export interface MigrationContext {
@@ -53,7 +55,7 @@ export class MigrationRunner {
53
55
  `INSERT INTO ${this.quote(HISTORY_TABLE)} (${this.quote("MigrationId")}, ${this.quote("AppliedAt")}) VALUES (${this.param(0)}, ${this.param(1)})`,
54
56
  [migration.id, new Date().toISOString()],
55
57
  );
56
- });
58
+ }).catch((error: unknown) => { throw migrationFailed(`Migration "${migration.id}"`, error); });
57
59
  applied.push(migration.id);
58
60
  }
59
61
  return { applied, rolledBack: [] };
@@ -86,7 +88,7 @@ export class MigrationRunner {
86
88
  await tx.execute(`DELETE FROM ${this.quote(HISTORY_TABLE)} WHERE ${this.quote("MigrationId")} = ${this.param(0)}`, [
87
89
  id,
88
90
  ]);
89
- });
91
+ }).catch((error: unknown) => { throw migrationFailed(`Rollback of migration "${id}"`, error); });
90
92
  rolledBack.push(id);
91
93
  }
92
94
  return { applied: [], rolledBack };
@@ -144,3 +146,11 @@ export class MigrationRunner {
144
146
  });
145
147
  }
146
148
  }
149
+
150
+ /** Names the failed migration; its transaction was rolled back, earlier ones stay applied. */
151
+ function migrationFailed(subject: string, error: unknown): unknown {
152
+ // An unknown COMMIT outcome must not be reported as a rollback.
153
+ if (isUnknownTransactionOutcome(error)) return error;
154
+ const reason = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
155
+ return new OrmError(`${subject} failed and was rolled back: ${reason}`, { cause: error });
156
+ }
@@ -3,7 +3,8 @@ import { SchemaAdmissionError, SchemaMigrationRequiredError, SchemaVerificationE
3
3
  import type { OrmModel } from "../Metadata/OrmModel";
4
4
  import { compileExpectedSchema, type OrmExpectedSchema } from "./ExpectedSchema";
5
5
  import { ExactSchemaVerifier, type SchemaVerificationResult } from "./ExactSchemaVerifier";
6
- import { classifySafeAdditive, renderSafeAdditivePostgres } from "./SafeAdditiveSchema";
6
+ import { classifySafeAdditive, renderSafeAdditivePostgres, type SafeAdditiveSchemaOperation } from "./SafeAdditiveSchema";
7
+ import type { IntrospectedSchema } from "./introspection";
7
8
 
8
9
  /** PostgreSQL-private trace boundary; deliberately absent from public capability types. */
9
10
  type TraceableSchemaAdmissionScope = SchemaAdmissionScope & {
@@ -13,6 +14,8 @@ type TraceableSchemaAdmissionScope = SchemaAdmissionScope & {
13
14
  /** Result of `ensureCreated`: tolerated differences the application should know about. */
14
15
  export interface EnsureCreatedResult {
15
16
  readonly warnings: readonly string[];
17
+ /** Additive changes made by this call, for example `add column products.sku`. */
18
+ readonly applied?: readonly string[];
16
19
  }
17
20
 
18
21
  /** PostgreSQL-only create-missing-whole-tables and exact admission. */
@@ -22,7 +25,8 @@ export class SchemaAdmissionEngine {
22
25
  let expected: OrmExpectedSchema;
23
26
  try {
24
27
  expected = compileExpectedSchema(this.models);
25
- } catch {
28
+ } catch (error) {
29
+ if (error instanceof SchemaAdmissionError) throw error;
26
30
  throw new SchemaAdmissionError("ORM_SCHEMA_MODEL_INVALID", "The declared ORM model cannot be admitted.");
27
31
  }
28
32
  const capability = this.provider.schemaAdmissionCapability;
@@ -30,23 +34,24 @@ export class SchemaAdmissionEngine {
30
34
  if (capability.version !== 1 || !capability.distributedLock || !capability.transactionalDdl || !capability.exactIntrospection) {
31
35
  throw new SchemaAdmissionError("ORM_SCHEMA_ATOMICITY_UNAVAILABLE", "PostgreSQL schema admission cannot guarantee atomic exact verification.");
32
36
  }
33
- let warnings: readonly string[] = [];
37
+ let result: EnsureCreatedResult = { warnings: [], applied: [] };
34
38
  try {
35
- await capability.withSchemaAdmission([...new Set(expected.tables.map((table) => table.schema))], async (scope) => { warnings = await this.admit(scope, expected); });
39
+ await capability.withSchemaAdmission([...new Set(expected.tables.map((table) => table.schema))], async (scope) => { result = await this.admit(scope, expected); });
36
40
  } catch (error) {
37
41
  if (error instanceof SchemaAdmissionError) throw error;
38
42
  throw new SchemaAdmissionError("ORM_SCHEMA_ATOMICITY_UNAVAILABLE", "PostgreSQL schema admission could not complete safely.");
39
43
  }
40
- return { warnings };
44
+ return result;
41
45
  }
42
- private async admit(scope: SchemaAdmissionScope, expected: OrmExpectedSchema): Promise<readonly string[]> {
43
- const before = await scope.introspectExpected(expected);
44
- const { verification: initial, warnings } = tolerateNameDrift(new ExactSchemaVerifier().verify(expected, before, true));
46
+ private async admit(scope: SchemaAdmissionScope, expected: OrmExpectedSchema): Promise<EnsureCreatedResult> {
47
+ const { schema: before, warnings: indexWarnings } = alignIndexNames(expected, await scope.introspectExpected(expected));
48
+ const { verification: initial, warnings: keyWarnings } = tolerateNameDrift(new ExactSchemaVerifier().verify(expected, before, true));
45
49
  const preflight = classifySafeAdditive(expected, before, initial);
46
50
  if (preflight.hardDifferences.length) {
47
51
  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.");
48
52
  throw new SchemaMigrationRequiredError(preflight.verification);
49
53
  }
54
+ const applied: string[] = [];
50
55
  for (const operation of preflight.plan ?? []) {
51
56
  try {
52
57
  const sql = renderSafeAdditivePostgres(operation);
@@ -54,18 +59,21 @@ export class SchemaAdmissionEngine {
54
59
  if (traceable.executeSchemaAdmission) await traceable.executeSchemaAdmission(sql, operation.kind);
55
60
  else await scope.execute(sql, []);
56
61
  }
57
- catch (error) { throw this.mapDdlError(operation.kind, error); }
62
+ catch (error) { throw this.mapDdlError(operation, error); }
63
+ applied.push(describeOperation(operation));
58
64
  }
59
- const { verification: final } = tolerateNameDrift(new ExactSchemaVerifier().verify(expected, await scope.introspectExpected(expected)));
65
+ const { verification: final } = tolerateNameDrift(new ExactSchemaVerifier().verify(expected, alignIndexNames(expected, await scope.introspectExpected(expected)).schema));
60
66
  if (!final.compatible) throw new SchemaVerificationError(final);
61
- return warnings;
67
+ return { warnings: [...keyWarnings, ...indexWarnings], applied };
62
68
  }
63
- private mapDdlError(kind: string, error: unknown): SchemaAdmissionError {
69
+ private mapDdlError(operation: SafeAdditiveSchemaOperation, error: unknown): SchemaAdmissionError {
64
70
  const sqlState = typeof error === "object" && error !== null
65
71
  ? String((error as { code?: unknown; sqlState?: unknown; errno?: unknown }).sqlState ?? (error as { errno?: unknown }).errno ?? (error as { code?: unknown }).code ?? "")
66
72
  : "";
67
- if (["23502", "23503", "23505", "23514"].includes(sqlState)) return new SchemaAdmissionError("ORM_SCHEMA_ADDITIVE_DATA_VIOLATION", "Existing PostgreSQL data violates the declared additive schema change.");
68
- return new SchemaAdmissionError(kind === "createSchema" || kind === "createTable" ? "ORM_SCHEMA_CREATE_FAILED" : "ORM_SCHEMA_ADDITIVE_DDL_FAILED", "PostgreSQL additive schema admission failed.");
73
+ // The PostgreSQL message names objects, not row values (those stay in its detail field).
74
+ const reason = `${describeOperation(operation)} failed${sqlState ? ` with PostgreSQL ${sqlState}` : ""}${error instanceof Error && error.message ? `: ${error.message}` : ""}`;
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
+ return new SchemaAdmissionError(operation.kind === "createSchema" || operation.kind === "createTable" ? "ORM_SCHEMA_CREATE_FAILED" : "ORM_SCHEMA_ADDITIVE_DDL_FAILED", `PostgreSQL additive schema admission failed: ${reason}.`);
69
77
  }
70
78
  }
71
79
 
@@ -89,3 +97,47 @@ function tolerateNameDrift(verification: SchemaVerificationResult): { readonly v
89
97
  });
90
98
  return { verification: { compatible: differences.length === 0, differences }, warnings };
91
99
  }
100
+
101
+ const tableName = (schema: string, table: string) => schema === "public" ? table : `${schema}.${table}`;
102
+
103
+ /** Short log form of an additive operation, in the style of `migrateOnStart`. */
104
+ function describeOperation(operation: SafeAdditiveSchemaOperation): string {
105
+ switch (operation.kind) {
106
+ case "createSchema": return `create schema ${operation.schema}`;
107
+ case "createTable": return `create table ${tableName(operation.table.schema, operation.table.table)}`;
108
+ case "addColumn": return `add column ${tableName(operation.table.schema, operation.table.table)}.${operation.column.column}`;
109
+ case "addCheck": return `add check ${operation.check.name} on ${tableName(operation.table.schema, operation.table.table)}`;
110
+ case "addForeignKey": return `add foreign key ${operation.foreignKey.name} on ${tableName(operation.table.schema, operation.table.table)}`;
111
+ case "createIndex": return `create ${operation.index.unique ? "unique " : ""}index ${operation.index.name} on ${tableName(operation.table.schema, operation.table.table)} (${operation.index.columns.join(", ")})`;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * An index that differs only by name (same columns, uniqueness and method)
117
+ * enforces the same thing; before 0.98.20 property-level `@Index` names ignored
118
+ * `@Entity({ table })`. Like a renamed primary key it is reported, not refused,
119
+ * and never duplicated.
120
+ */
121
+ function alignIndexNames(expected: OrmExpectedSchema, actual: IntrospectedSchema): { readonly schema: IntrospectedSchema; readonly warnings: readonly string[] } {
122
+ const warnings: string[] = [];
123
+ const tables = new Map(actual.tables);
124
+ const quote = (name: string) => `"${name.replaceAll('"', '""')}"`;
125
+ for (const table of expected.tables) {
126
+ const key = `${table.schema}.${table.table}`;
127
+ const found = actual.tables.get(key);
128
+ if (!found) continue;
129
+ const expectedNames = new Set(table.indexes.map((index) => index.name));
130
+ let indexes = found.indexes;
131
+ for (const index of table.indexes) {
132
+ if (indexes.some((item) => item.name === index.name)) continue;
133
+ const match = indexes.find((item) => !item.backingConstraint && !item.unsupported && !item.predicate && item.method === "btree" && !expectedNames.has(item.name)
134
+ && item.unique === index.unique && item.columns.length === index.columns.length && item.columns.every((column, position) => column === index.columns[position]));
135
+ if (!match) continue;
136
+ warnings.push(`table ${quote(table.schema)}.${quote(table.table)}: index is named ${quote(match.name)}, the model expects ${quote(index.name)}. `
137
+ + `It works as is; to align the name run: ALTER INDEX ${quote(table.schema)}.${quote(match.name)} RENAME TO ${quote(index.name)};`);
138
+ indexes = indexes.map((item) => item === match ? { ...item, name: index.name } : item);
139
+ }
140
+ if (indexes !== found.indexes) tables.set(key, { ...found, indexes });
141
+ }
142
+ return { schema: { ...actual, tables }, warnings };
143
+ }
@@ -1,5 +1,5 @@
1
1
  import { entityStorageKey } from "./tableKey";
2
- import type { EntityModel, IndexModel, PropertyModel } from "../Metadata/types";
2
+ import type { ColumnType, EntityModel, IndexModel, PropertyModel } from "../Metadata/types";
3
3
  import type { IntrospectedSchema, IntrospectedTable } from "./introspection";
4
4
 
5
5
  /**
@@ -23,6 +23,9 @@ export interface SchemaDiff {
23
23
  * from introspection; no separate snapshot file is needed.
24
24
  */
25
25
  export class SchemaDiffer {
26
+ /** `types` are the physical column types (UUID foreign keys follow their keys); without them types are not compared. */
27
+ constructor(private readonly types?: ReadonlyMap<PropertyModel, ColumnType | "uuid">) {}
28
+
26
29
  diff(targets: readonly EntityModel[], schema: IntrospectedSchema): SchemaDiff {
27
30
  const operations: AdditiveSchemaOperation[] = [];
28
31
  const warnings: string[] = [];
@@ -39,7 +42,7 @@ export class SchemaDiffer {
39
42
  continue;
40
43
  }
41
44
  this.diffColumns(model, table, operations, warnings);
42
- this.diffIndexes(model, table, operations);
45
+ this.diffIndexes(model, table, operations, warnings);
43
46
  }
44
47
 
45
48
  return { operations, warnings };
@@ -52,8 +55,21 @@ export class SchemaDiffer {
52
55
  warnings: string[],
53
56
  ): void {
54
57
  for (const property of model.properties) {
55
- if (!table.columns.has(property.columnName)) {
58
+ const column = table.columns.get(property.columnName);
59
+ if (!column) {
56
60
  operations.push({ kind: "addColumn", model, property });
61
+ continue;
62
+ }
63
+ // Changing a type or NULL-ability can lose data or fail on existing rows: report, never apply.
64
+ const expectedType = this.types?.get(property);
65
+ if (expectedType !== undefined && column.physicalType !== undefined && column.physicalType !== expectedType) {
66
+ warnings.push(`table "${model.tableName}": column "${property.columnName}" is ${column.physicalType} in the database but ${expectedType} in the model (left untouched; change it with an explicit migration).`);
67
+ }
68
+ if (!property.isKey && property.required && !column.notNull) {
69
+ warnings.push(`table "${model.tableName}": column "${property.columnName}" allows NULL in the database but is required in the model (left untouched; fill the NULL values and set NOT NULL with an explicit migration).`);
70
+ }
71
+ if (!property.isKey && !property.required && column.notNull) {
72
+ warnings.push(`table "${model.tableName}": column "${property.columnName}" is NOT NULL in the database but nullable in the model (left untouched; drop NOT NULL with an explicit migration).`);
57
73
  }
58
74
  }
59
75
  // Destructive: columns exist in the database but not in the model; never dropped automatically.
@@ -67,12 +83,19 @@ export class SchemaDiffer {
67
83
  }
68
84
  }
69
85
 
70
- private diffIndexes(model: EntityModel, table: IntrospectedTable, operations: AdditiveSchemaOperation[]): void {
86
+ private diffIndexes(model: EntityModel, table: IntrospectedTable, operations: AdditiveSchemaOperation[], warnings: string[]): void {
71
87
  const existing = new Set(table.indexes.map((index) => index.name));
88
+ const declared = new Set(model.indexes.map((index) => index.name));
72
89
  for (const index of model.indexes) {
73
- if (!existing.has(index.name)) {
74
- operations.push({ kind: "createIndex", model, index });
90
+ if (existing.has(index.name)) continue;
91
+ // The same index under another name (for example from before 0.98.20) is not duplicated.
92
+ const renamed = table.indexes.find((item) => !declared.has(item.name) && !item.backingConstraint && !item.predicate && item.unique === index.unique
93
+ && item.columns.length === index.columns.length && item.columns.every((column, position) => column === index.columns[position]));
94
+ if (renamed) {
95
+ warnings.push(`table "${model.tableName}": index "${renamed.name}" matches "${index.name}" from the model except for its name (left untouched; to align it run: ALTER INDEX "${renamed.name}" RENAME TO "${index.name}";).`);
96
+ continue;
75
97
  }
98
+ operations.push({ kind: "createIndex", model, index });
76
99
  }
77
100
  }
78
101
  }
@@ -1,4 +1,5 @@
1
1
  import { isUuidKeyGeneration, type ColumnType, type EntityModel, type PropertyModel } from "../Metadata/types";
2
+ import { SchemaAdmissionError } from "../errors";
2
3
 
3
4
  /** Resolve the physical FK contract without changing logical converter types.
4
5
  * UUID storage follows referenced keys, including PK/FK chains and cycles. */
@@ -10,7 +11,7 @@ export function physicalColumnTypes(models: readonly EntityModel[]): ReadonlyMap
10
11
  for (const property of model.properties) result.set(property, isUuidKeyGeneration(property.generation) ? "uuid" : property.type);
11
12
  for (const fk of model.foreignKeys) {
12
13
  const target = byCtor.get(fk.target());
13
- if (!target) throw new Error("ORM_SCHEMA_CROSS_UNIT_FOREIGN_KEY");
14
+ if (!target) throw crossUnitForeignKey(model, fk.properties, fk.target());
14
15
  if (fk.properties.length !== target.key.length) throw new Error("ORM_SCHEMA_FOREIGN_KEY_ARITY");
15
16
  fk.properties.forEach((name, index) => {
16
17
  const local = model.propertyByName(name);
@@ -35,3 +36,9 @@ export function physicalColumnTypes(models: readonly EntityModel[]): ReadonlyMap
35
36
  }
36
37
  return result;
37
38
  }
39
+
40
+ /** A database foreign key cannot leave the context that creates the schema. */
41
+ export function crossUnitForeignKey(model: EntityModel, properties: readonly string[], target: new () => object): SchemaAdmissionError {
42
+ const columns = properties.join(", ");
43
+ return new SchemaAdmissionError("ORM_SCHEMA_CROSS_UNIT_FOREIGN_KEY", `Entity "${model.name}" has a foreign key (${columns}) to "${target.name}", which is not an entity of the same DbContext. A database foreign key can only point to an entity of the same context: keep ${columns} as a plain column without @ManyToOne, or map both entities in one context.`);
44
+ }
@@ -10,7 +10,7 @@ export class OrmError extends Error {
10
10
  }
11
11
  }
12
12
 
13
- export type SchemaAdmissionErrorCode = "ORM_SCHEMA_PROVIDER_UNSUPPORTED" | "ORM_SCHEMA_LOCK_UNAVAILABLE" | "ORM_SCHEMA_LOCK_KEY_COLLISION" | "ORM_SCHEMA_ATOMICITY_UNAVAILABLE" | "ORM_SCHEMA_MODEL_INVALID" | "ORM_SCHEMA_CATALOG_UNSUPPORTED" | "ORM_SCHEMA_CREATE_FAILED" | "ORM_SCHEMA_ADDITIVE_DATA_VIOLATION" | "ORM_SCHEMA_ADDITIVE_DDL_FAILED" | "ORM_SCHEMA_MIGRATION_REQUIRED" | "ORM_SCHEMA_DRIFT" | "ORM_SCHEMA_OWNERSHIP_CONFLICT" | "ORM_SCHEMA_HOSTED_PHASE_CONFLICT";
13
+ export type SchemaAdmissionErrorCode = "ORM_SCHEMA_PROVIDER_UNSUPPORTED" | "ORM_SCHEMA_LOCK_UNAVAILABLE" | "ORM_SCHEMA_LOCK_KEY_COLLISION" | "ORM_SCHEMA_ATOMICITY_UNAVAILABLE" | "ORM_SCHEMA_MODEL_INVALID" | "ORM_SCHEMA_CATALOG_UNSUPPORTED" | "ORM_SCHEMA_CREATE_FAILED" | "ORM_SCHEMA_ADDITIVE_DATA_VIOLATION" | "ORM_SCHEMA_ADDITIVE_DDL_FAILED" | "ORM_SCHEMA_MIGRATION_REQUIRED" | "ORM_SCHEMA_DRIFT" | "ORM_SCHEMA_OWNERSHIP_CONFLICT" | "ORM_SCHEMA_HOSTED_PHASE_CONFLICT" | "ORM_SCHEMA_CROSS_UNIT_FOREIGN_KEY";
14
14
  /** Safe public admission failure; deliberately contains no driver cause or SQL. */
15
15
  export class SchemaAdmissionError extends OrmError {
16
16
  constructor(readonly code: SchemaAdmissionErrorCode, message: string) { super(message); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.19",
3
+ "version": "0.98.20",
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",