bazis 0.98.19 → 0.98.21

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.21" }, 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 (with or without migrations) in every module, or migrateOnStart/migrations without ensureCreated 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
@@ -1,6 +1,6 @@
1
1
  import { ormHostedPlanValidator } from "./OrmHostedPlan.validator";
2
2
  import type { HostedService } from "../di";
3
- import { DatabaseFacade, type DbContextOptions, type Migration } from "../../library/orm";
3
+ import { DatabaseFacade, type DbContextOptions, type EnsureCreatedWithMigrationsResult, type Migration } from "../../library/orm";
4
4
  import { compileExpectedSchema } from "../../library/orm/Schema/ExpectedSchema";
5
5
 
6
6
  /**
@@ -22,10 +22,15 @@ 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 };
32
+ /** `ensureCreated` owns the versioned migrations of this context (run or baselined before the check). */
33
+ private readonly withMigrations: boolean;
29
34
  private connectionClosed = false;
30
35
  private closePromise?: Promise<void>;
31
36
 
@@ -42,13 +47,18 @@ export class OrmLifecycle implements HostedService {
42
47
  * must not close it at shutdown, or the shared pool would be closed twice.
43
48
  */
44
49
  private readonly ownsConnection: boolean = true,
50
+ contextName = "DbContext",
45
51
  ) {
52
+ const modes = [ensureCreated && "ensureCreated", migrateOnStart && "migrateOnStart", runMigrationsOnStart && migrations.length > 0 && "migrations"].filter(Boolean);
53
+ this.withMigrations = ensureCreated && runMigrationsOnStart && migrations.length > 0;
54
+ this.__bazisSchemaOwner = Object.freeze({ context: contextName, mode: modes.join(" + ") || "none" });
46
55
  this.phase = ensureCreated && options.provider.name === "postgres" ? -105 : -100;
47
56
  this.__bazisLegacySchemaAuthority = this.phase === -100 && (ensureCreated || migrateOnStart || (runMigrationsOnStart && migrations.length > 0));
48
57
  if (this.phase === -105) {
49
58
  const expected = compileExpectedSchema(options.model);
50
59
  const tables = Object.freeze(expected.tables.map((table) => `${table.schema}.${table.table}`));
51
60
  this.__bazisSchemaAdmission = Object.freeze({
61
+ owner: contextName,
52
62
  unit: tables,
53
63
  tables,
54
64
  foreignKeys: Object.freeze(expected.tables.flatMap((table) =>
@@ -65,7 +75,17 @@ export class OrmLifecycle implements HostedService {
65
75
  const database = new DatabaseFacade(this.options.provider, this.options.model);
66
76
  try {
67
77
  if (this.ensureCreated) {
68
- const result = await database.ensureCreated();
78
+ const result: Partial<EnsureCreatedWithMigrationsResult> & Pick<EnsureCreatedWithMigrationsResult, "warnings"> = this.withMigrations ? await database.ensureCreatedWithMigrations(this.migrations) : await database.ensureCreated();
79
+ // Logged in execution order: migrations, schema changes, then the baseline record.
80
+ if (result.migrated && result.migrated.length > 0) {
81
+ console.info(`[orm:migrations] applied: ${result.migrated.join(", ")}`);
82
+ }
83
+ if (result.applied && result.applied.length > 0) {
84
+ console.info(`[orm:schema] applied ${result.applied.length} operation(s): ${result.applied.join(", ")}`);
85
+ }
86
+ if (result.baselined && result.baselined.length > 0) {
87
+ console.info(`[orm:migrations] baseline: the schema was created from the model, recorded as applied without running: ${result.baselined.join(", ")}`);
88
+ }
69
89
  for (const warning of result.warnings) {
70
90
  console.warn(`[orm:schema] ${warning}`);
71
91
  }
@@ -79,7 +99,7 @@ export class OrmLifecycle implements HostedService {
79
99
  console.warn(`[orm:migrate] ${warning}`);
80
100
  }
81
101
  }
82
- if (this.runMigrationsOnStart && this.migrations.length > 0) {
102
+ if (this.runMigrationsOnStart && this.migrations.length > 0 && !this.withMigrations) {
83
103
  const result = await database.migrateVersioned(this.migrations);
84
104
  if (result.applied.length > 0) {
85
105
  console.info(`[orm:migrations] applied: ${result.applied.join(", ")}`);
@@ -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
 
@@ -121,7 +121,7 @@ export function ormModule<TContext extends DbContext>(config: OrmModuleConfig<TC
121
121
  attachOwnedStoreRegistration(module, buildConfig.context, buildConfig.entities ?? [], config.ownedStore);
122
122
  return module;
123
123
  }
124
- if (config.ensureCreated && (config.migrateOnStart || config.runMigrationsOnStart || (config.migrations?.length ?? 0) > 0)) throw new OrmError("ensureCreated is mutually exclusive with ORM migration startup options.");
124
+ if (config.ensureCreated && config.migrateOnStart) throw new OrmError("ensureCreated and migrateOnStart are mutually exclusive: ensureCreated already adds the safe changes. Put other changes into migrations with runMigrationsOnStart: true.");
125
125
  if (config.ensureCreated && config.provider?.name === "postgres" && config.context) throw new OrmError("PostgreSQL ensureCreated requires the shared DATABASE_PROVIDER from @Infra.");
126
126
  // "Connection" mode: a provider only, no context.
127
127
  if (!config.context) {
@@ -5,6 +5,15 @@ 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
7
  import { SchemaAdmissionEngine, type EnsureCreatedResult } from "./Schema/SchemaAdmission";
8
+ import { entityStorageKey } from "./Schema/tableKey";
9
+
10
+ /** Result of `ensureCreated` together with versioned migrations. */
11
+ export interface EnsureCreatedWithMigrationsResult extends EnsureCreatedResult {
12
+ /** Migrations run before the schema check. */
13
+ readonly migrated: readonly string[];
14
+ /** Migrations recorded as applied without running, because the schema was created from the model. */
15
+ readonly baselined: readonly string[];
16
+ }
8
17
  import { SchemaAdmissionError } from "./errors";
9
18
  import { physicalColumnTypes } from "./Schema/physicalColumnTypes";
10
19
  import { bindResolvedForeignKey } from "./Providers/resolvedForeignKey";
@@ -47,6 +56,32 @@ export class DatabaseFacade {
47
56
  return { warnings: [] };
48
57
  }
49
58
 
59
+ /**
60
+ * `ensureCreated` with versioned migrations, under the migration lock so that
61
+ * starting instances do not race:
62
+ *
63
+ * - none of the context tables exist yet: `ensureCreated` creates the current
64
+ * model and the migrations are recorded as applied without running (a
65
+ * baseline: the model already contains their result, so a seed `INSERT` in
66
+ * a migration is skipped here too);
67
+ * - otherwise pending migrations run first (`ALTER`, renames, data moves),
68
+ * then `ensureCreated` adds what is still missing and verifies the result.
69
+ */
70
+ async ensureCreatedWithMigrations(migrations: readonly Migration[]): Promise<EnsureCreatedWithMigrationsResult> {
71
+ const work = async (): Promise<EnsureCreatedWithMigrationsResult> => {
72
+ const runner = new MigrationRunner(this.provider, migrations);
73
+ const existing = await this.provider.introspect();
74
+ const fresh = this.models.entities.every((model) => !existing.tables.has(entityStorageKey(model)) && !existing.tables.has(model.tableName));
75
+ if (fresh) {
76
+ const created = await this.ensureCreated();
77
+ return { ...created, migrated: [], baselined: await runner.baseline() };
78
+ }
79
+ const { applied } = await runner.migrate();
80
+ return { ...(await this.ensureCreated()), migrated: applied, baselined: [] };
81
+ };
82
+ return this.provider.withMigrationLock ? this.provider.withMigrationLock(work) : work();
83
+ }
84
+
50
85
  /**
51
86
  * Additive auto-migration of the schema for all context entities (enabled in
52
87
  * the module: `ormBazis: { migrateOnStart: true }`): introspects the database,
@@ -72,7 +107,7 @@ export class DatabaseFacade {
72
107
  const types = this.provider.dialect.name === "postgres" ? physicalColumnTypes(this.models.entities) : undefined;
73
108
  const foreignKeys = new Map(targets.map((model) => [model, this.foreignKeysFor(model, types)]));
74
109
  const schema = await this.provider.introspect();
75
- const { operations, warnings } = new SchemaDiffer().diff(targets, schema);
110
+ const { operations, warnings } = new SchemaDiffer(types).diff(targets, schema);
76
111
  const migrator = new Migrator(this.provider, (model) => foreignKeys.get(model)!);
77
112
  const applied = await migrator.apply(operations);
78
113
  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,12 +55,33 @@ 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: [] };
60
62
  }
61
63
 
64
+ /**
65
+ * Records pending migrations as applied without running them. Used when the
66
+ * schema was just created from the current model, which already contains
67
+ * their result.
68
+ */
69
+ async baseline(): Promise<readonly string[]> {
70
+ return this.withMigrationLock(async () => {
71
+ await this.ensureHistory();
72
+ const appliedSet = await this.loadApplied();
73
+ const recorded: string[] = [];
74
+ for (const migration of this.migrations.filter((item) => !appliedSet.has(item.id))) {
75
+ await this.provider.execute(
76
+ `INSERT INTO ${this.quote(HISTORY_TABLE)} (${this.quote("MigrationId")}, ${this.quote("AppliedAt")}) VALUES (${this.param(0)}, ${this.param(1)})`,
77
+ [migration.id, new Date().toISOString()],
78
+ );
79
+ recorded.push(migration.id);
80
+ }
81
+ return recorded;
82
+ });
83
+ }
84
+
62
85
  /** Rolls back the last `steps` migrations (requires `down`). */
63
86
  async rollback(steps = 1): Promise<VersionedMigrationResult> {
64
87
  if (!Number.isSafeInteger(steps) || steps < 0) {
@@ -86,7 +109,7 @@ export class MigrationRunner {
86
109
  await tx.execute(`DELETE FROM ${this.quote(HISTORY_TABLE)} WHERE ${this.quote("MigrationId")} = ${this.param(0)}`, [
87
110
  id,
88
111
  ]);
89
- });
112
+ }).catch((error: unknown) => { throw migrationFailed(`Rollback of migration "${id}"`, error); });
90
113
  rolledBack.push(id);
91
114
  }
92
115
  return { applied: [], rolledBack };
@@ -144,3 +167,11 @@ export class MigrationRunner {
144
167
  });
145
168
  }
146
169
  }
170
+
171
+ /** Names the failed migration; its transaction was rolled back, earlier ones stay applied. */
172
+ function migrationFailed(subject: string, error: unknown): unknown {
173
+ // An unknown COMMIT outcome must not be reported as a rollback.
174
+ if (isUnknownTransactionOutcome(error)) return error;
175
+ const reason = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
176
+ return new OrmError(`${subject} failed and was rolled back: ${reason}`, { cause: error });
177
+ }
@@ -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); }
@@ -33,7 +33,7 @@ export class SchemaMigrationRequiredError extends SchemaAdmissionError {
33
33
 
34
34
  function schemaVerificationMessage(summary: string, verification: SchemaVerificationResult): string {
35
35
  return [summary, formatSchemaDifferences(verification),
36
- "Check the application version and target database. Align the ORM model and database schema; apply an explicit migration if a database change is intended.",
36
+ "Check the application version and target database. Align the ORM model and database schema; apply an explicit migration if a database change is intended (with ensureCreated: add it to the module's migrations with runMigrationsOnStart: true).",
37
37
  ].filter(Boolean).join("\n");
38
38
  }
39
39
 
@@ -132,6 +132,7 @@ export type {
132
132
  } from "./Schema/introspection";
133
133
  export type { SchemaDifferenceCode, SafeSchemaDescriptor, SchemaDifference, SchemaVerificationResult } from "./Schema/ExactSchemaVerifier";
134
134
  export type { EnsureCreatedResult } from "./Schema/SchemaAdmission";
135
+ export type { EnsureCreatedWithMigrationsResult } from "./DatabaseFacade";
135
136
  export { SchemaAdmissionError, SchemaMigrationRequiredError, SchemaVerificationError } from "./errors";
136
137
  export type { SchemaAdmissionErrorCode } from "./errors";
137
138
  export { compileCheck, renderCheck } from "./Schema/CheckExpression";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.19",
3
+ "version": "0.98.21",
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",