uql-orm 0.42.1 → 0.43.0

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.
@@ -1,5 +1,5 @@
1
1
  import { SOFT_DELETE_FILTER } from '../../type/index.js';
2
- import { getKeys, ddlText, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, upperFirst, } from '../../util/index.js';
2
+ import { fieldOptionConflict, getKeys, ddlText, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, upperFirst, } from '../../util/index.js';
3
3
  import { ownRegistrations } from '../decorator/bag.js';
4
4
  // Held on `globalThis` via the global symbol registry so a single metadata map survives multiple
5
5
  // evaluations of this module (HMR, duplicated/federated bundles, ESM+CJS dual-loading). Version-suffixed
@@ -10,11 +10,15 @@ const metaKey = Symbol.for('uql-orm/entity/metadata/v1');
10
10
  const metas = holder[metaKey] ?? new Map();
11
11
  holder[metaKey] = metas;
12
12
  export function defineField(entity, key, opts = {}) {
13
- const meta = ensureMeta(entity);
13
+ const meta = ensureWritableMeta(entity);
14
14
  if (!opts.type && !opts.references && !opts.virtual) {
15
15
  throw new TypeError(`'${entity.name}.${key}' needs a 'type'. Declare it - '@Field({ type: String })' - or point the field ` +
16
16
  "at another entity with 'references', which resolves the column type from its primary key.");
17
17
  }
18
+ const conflict = fieldOptionConflict(opts);
19
+ if (conflict) {
20
+ throw new TypeError(`'${entity.name}.${key}' ${conflict}.`);
21
+ }
18
22
  const fieldKey = key;
19
23
  // Flagged when the author gave `references` but no `type`, so schema generation knows to resolve the
20
24
  // column from the referenced primary key (picking up its `columnType`, length and chained keys)
@@ -32,7 +36,7 @@ export function defineRelation(entity, key, opts) {
32
36
  if (!opts.entity) {
33
37
  throw new TypeError(`'${entity.name}.${key}' needs an 'entity' getter, e.g. '@ManyToOne({ entity: () => Company })'.`);
34
38
  }
35
- const meta = ensureMeta(entity);
39
+ const meta = ensureWritableMeta(entity);
36
40
  // Registration writes the authored shape into a map declared as resolved: `getMeta` runs
37
41
  // `fillRelations`, which settles `entity`, `references` and `mappedBy` or throws. Bridging the two
38
42
  // shapes here is what lets every consumer read `RelationMeta` without asserting.
@@ -41,7 +45,7 @@ export function defineRelation(entity, key, opts) {
41
45
  return meta;
42
46
  }
43
47
  export function defineHook(entity, methodName, event) {
44
- const meta = ensureMeta(entity);
48
+ const meta = ensureWritableMeta(entity);
45
49
  if (!meta.hooks)
46
50
  meta.hooks = {};
47
51
  if (!meta.hooks[event])
@@ -54,7 +58,7 @@ export function defineHook(entity, methodName, event) {
54
58
  * lets the dialects render one shape instead of re-parsing it.
55
59
  */
56
60
  export function defineIndex(entity, index) {
57
- const meta = ensureMeta(entity);
61
+ const meta = ensureWritableMeta(entity);
58
62
  if (!meta.indexes)
59
63
  meta.indexes = [];
60
64
  meta.indexes.push({
@@ -66,7 +70,7 @@ export function defineIndex(entity, index) {
66
70
  return meta;
67
71
  }
68
72
  export function defineFilter(entity, name, opts) {
69
- const meta = ensureMeta(entity);
73
+ const meta = ensureWritableMeta(entity);
70
74
  if (name === SOFT_DELETE_FILTER) {
71
75
  throw TypeError(`'${entity.name}' filter name '${SOFT_DELETE_FILTER}' is reserved; it is auto-registered from @Field({ softDelete })`);
72
76
  }
@@ -112,7 +116,7 @@ export function defineEntity(entity, opts = {}) {
112
116
  throw new TypeError(`'${entity.name}' has a dotted name '${opts.name}'. Name the schema separately as ` +
113
117
  `{ schema: '${schema}', name: '${rest.join('.')}' }.`);
114
118
  }
115
- const meta = ensureMeta(entity);
119
+ const meta = ensureWritableMeta(entity);
116
120
  // Covers `defineEntity(Decorated)` called on a class whose members carry decorators. `@Entity()`
117
121
  // drains `context.metadata` itself, because TypeScript only attaches `Symbol.metadata` to the class
118
122
  // after class decorators return; draining empties the bag, so whichever runs second is a no-op.
@@ -132,8 +136,9 @@ export function defineEntity(entity, opts = {}) {
132
136
  if (!hasKeys(meta.fields)) {
133
137
  throw TypeError(`'${entity.name}' must have fields`);
134
138
  }
135
- meta.name = opts.name ?? entity.name;
136
- meta.schema = opts.schema;
139
+ // A later call composes onto the entity, so saying nothing about the table retracts nothing.
140
+ meta.name = opts.name ?? meta.name ?? entity.name;
141
+ meta.schema = opts.schema ?? meta.schema;
137
142
  let proto = Object.getPrototypeOf(entity.prototype);
138
143
  while (proto.constructor !== Object) {
139
144
  const parent = proto.constructor;
@@ -211,12 +216,21 @@ export function getEntities() {
211
216
  return acc;
212
217
  }, []);
213
218
  }
219
+ /**
220
+ * The metadata of `entity`, marked as changed. Every `define*` goes through this, and nothing outside
221
+ * this file writes to a meta, so it is the one place a derived cache can be told it has gone stale.
222
+ */
223
+ function ensureWritableMeta(entity) {
224
+ const meta = ensureMeta(entity);
225
+ meta.revision++;
226
+ return meta;
227
+ }
214
228
  function ensureMeta(entity) {
215
229
  let meta = metas.get(entity);
216
230
  if (meta) {
217
231
  return meta;
218
232
  }
219
- meta = { entity, ids: [], fields: {}, relations: {} };
233
+ meta = { entity, ids: [], fields: {}, relations: {}, revision: 0 };
220
234
  metas.set(entity, meta);
221
235
  return meta;
222
236
  }
@@ -225,10 +239,13 @@ export function getMeta(entity) {
225
239
  if (!meta) {
226
240
  throw TypeError(`'${entity.name}' is not an entity`);
227
241
  }
228
- if (meta.processed) {
242
+ if (meta.processedAt === meta.revision) {
229
243
  return meta;
230
244
  }
231
- meta.processed = true;
245
+ // Stamped before finalizing, not after: `fillInverseSide` reads the other side through `getMeta`,
246
+ // and with each side mapped by the other that recursion has to find this half-filled meta rather
247
+ // than run again. Finalizing twice is harmless anyway - every step of it skips what it settled.
248
+ meta.processedAt = meta.revision;
232
249
  return fillRelations(meta);
233
250
  }
234
251
  function fillRelations(meta) {
@@ -1,7 +1,7 @@
1
1
  import { jsonPath } from '../dialect/jsonSql.js';
2
2
  import { MysqlLikeSqlDialect } from '../dialect/mysqlLikeSqlDialect.js';
3
- import { isVectorFieldType } from '../dialect/vectorCast.js';
4
3
  import { getMeta } from '../entity/index.js';
4
+ import { columnFamily } from '../util/field.util.js';
5
5
  import { MARIA_VECTOR_METRICS } from './mariaVectorMetrics.js';
6
6
  export class MariaDialect extends MysqlLikeSqlDialect {
7
7
  dialectName = 'mariadb';
@@ -76,6 +76,6 @@ export class MariaDialect extends MysqlLikeSqlDialect {
76
76
  }
77
77
  /** The reverse: selecting a `VECTOR` column raw yields that blob, so it is read back as text. */
78
78
  selectFieldExpr(escapedColumn, field) {
79
- return isVectorFieldType(field.type) ? `VEC_ToText(${escapedColumn})` : escapedColumn;
79
+ return columnFamily(field.type) === 'vector' ? `VEC_ToText(${escapedColumn})` : escapedColumn;
80
80
  }
81
81
  }
@@ -4,7 +4,7 @@
4
4
  * Detects schema drift between expected schema (from entities) and
5
5
  * actual database schema.
6
6
  */
7
- import { canonicalToSql } from '../../schema/canonicalType.js';
7
+ import { canonicalToSql, engineType } from '../../schema/canonicalType.js';
8
8
  import { diffSchemas } from '../../schema/schemaASTDiffer.js';
9
9
  function resolveOptions(options) {
10
10
  return {
@@ -24,11 +24,15 @@ function resolveOptions(options) {
24
24
  */
25
25
  export function detectDrift(expectedAST, actualAST, options = {}) {
26
26
  const opts = resolveOptions(options);
27
+ const { dialect } = opts;
27
28
  const diff = diffSchemas(expectedAST, actualAST, {
28
29
  compareIndexes: opts.checkIndexes,
29
30
  indexFacets: opts.indexFacets,
30
31
  compareRelationships: opts.checkForeignKeys,
31
32
  excludeTables: opts.excludeTables,
33
+ // Without a dialect there is no engine to compare through, and `formatType` below then reports no
34
+ // type drift at all.
35
+ ...(dialect && { normalizeType: engineType(dialect) }),
32
36
  });
33
37
  const drifts = [
34
38
  ...detectTableDrifts(diff),
@@ -39,7 +39,7 @@ export declare class MongoSchemaGenerator extends AbstractDialect implements Sch
39
39
  */
40
40
  generateCreateIndex(tableName: string, index: IndexSchema): string;
41
41
  generateDropIndex(tableName: string, indexName: string): string;
42
- getSqlType(fieldOptions: FieldOptions, fieldType?: unknown): string;
42
+ getSqlType(fieldOptions: FieldOptions): string;
43
43
  generateCreateTableFromNode(table: TableNode, _options?: {
44
44
  ifNotExists?: boolean;
45
45
  }): string[];
@@ -120,7 +120,7 @@ export class MongoSchemaGenerator extends AbstractDialect {
120
120
  name: indexName,
121
121
  });
122
122
  }
123
- getSqlType(fieldOptions, fieldType) {
123
+ getSqlType(fieldOptions) {
124
124
  return '';
125
125
  }
126
126
  generateCreateTableFromNode(table, _options) {
@@ -27,10 +27,6 @@ export declare class SqlSchemaGenerator implements SqlDdlGenerator {
27
27
  * Primary key type for auto-increment integer IDs
28
28
  */
29
29
  protected get serialType(): string;
30
- /**
31
- * Convert FieldOptions to CanonicalType using the unified type system.
32
- */
33
- protected getCanonicalType(field: FieldOptions, fieldType?: unknown): CanonicalType;
34
30
  protected canonicalTypeToSql(type: CanonicalType): string;
35
31
  /**
36
32
  * Every `CREATE TABLE` for `entities`, then their foreign keys.
@@ -85,7 +81,7 @@ export declare class SqlSchemaGenerator implements SqlDdlGenerator {
85
81
  /** ` DEFAULT <sql>`, or nothing where the column declares none. Empty rather than `DEFAULT NULL`
86
82
  * so an absent default stays absent - `defaultValue: null` is the way to ask for one. */
87
83
  private defaultClause;
88
- getSqlType(field: FieldMeta, fieldType?: unknown, isSoleKey?: boolean): string;
84
+ getSqlType(field: FieldMeta): string;
89
85
  /**
90
86
  * Generate ALTER COLUMN statements (database-specific)
91
87
  */
@@ -1,6 +1,6 @@
1
1
  import { AbstractSqlDialect } from '../dialect/index.js';
2
2
  import { getMeta, soleIdOf } from '../entity/index.js';
3
- import { areTypesEqual, canonicalToSql, fieldOptionsToCanonical, isVectorCategory, sqlToCanonical, } from '../schema/canonicalType.js';
3
+ import { areTypesEqual, canonicalToSql, engineType, fieldOptionsToCanonical, isVectorCategory, } from '../schema/canonicalType.js';
4
4
  import { indexSignature } from '../schema/indexDifferences.js';
5
5
  import { buildSchemaAST } from '../schema/schemaASTBuilder.js';
6
6
  import { diffTable } from '../schema/schemaASTDiffer.js';
@@ -52,12 +52,6 @@ export class SqlSchemaGenerator {
52
52
  get serialType() {
53
53
  return this.dialect.serialType;
54
54
  }
55
- /**
56
- * Convert FieldOptions to CanonicalType using the unified type system.
57
- */
58
- getCanonicalType(field, fieldType) {
59
- return fieldOptionsToCanonical(field, fieldType);
60
- }
61
55
  canonicalTypeToSql(type) {
62
56
  return canonicalToSql(type, this.dialect);
63
57
  }
@@ -275,18 +269,20 @@ export class SqlSchemaGenerator {
275
269
  ? ''
276
270
  : ` DEFAULT ${formatDefaultValue(column.defaultValue, this.dialect, column.type)}`;
277
271
  }
278
- getSqlType(field, fieldType, isSoleKey = field.isId === true) {
279
- // If field has a reference, inherit type from the target primary key
272
+ getSqlType(field) {
273
+ // A foreign key takes the type of the key it points at. A `referencedKey` the target does not
274
+ // have falls through to this column's own options, as the AST builder does with the same case.
280
275
  if (field.references) {
281
- const refEntity = field.references();
282
- const refMeta = getMeta(refEntity);
276
+ const refMeta = getMeta(field.references());
283
277
  const refIdField = refMeta.fields[field.referencedKey ?? soleIdOf(refMeta, 'a foreign key')];
284
- return this.getSqlType({ ...refIdField, references: undefined, isId: undefined, autoIncrement: false }, refIdField.type);
278
+ if (refIdField) {
279
+ return this.getSqlType({ ...refIdField, references: undefined, isId: undefined, autoIncrement: false });
280
+ }
285
281
  }
286
282
  // Get canonical type and convert to SQL
287
- const canonical = this.getCanonicalType(field, fieldType);
283
+ const canonical = fieldOptionsToCanonical(field);
288
284
  // Special case for serial primary keys
289
- if (isAutoIncrement(field, isSoleKey)) {
285
+ if (isAutoIncrement(field, field.isId === true)) {
290
286
  return this.dialect.serialType;
291
287
  }
292
288
  return this.canonicalTypeToSql(canonical);
@@ -408,7 +404,7 @@ export class SqlSchemaGenerator {
408
404
  }
409
405
  diffOptions() {
410
406
  return {
411
- normalizeType: (type) => sqlToCanonical(this.canonicalTypeToSql(type)),
407
+ normalizeType: engineType(this.dialect),
412
408
  defaultsEqual: (expected, actual) => this.isDefaultValueEqual(actual, expected),
413
409
  };
414
410
  }
@@ -36,17 +36,32 @@ export declare function canonicalToSql(type: CanonicalType, dialect: AbstractDia
36
36
  * Convert a canonical type to a TypeScript type string.
37
37
  */
38
38
  export declare function canonicalToTypeScript(type: CanonicalType): string;
39
+ /**
40
+ * A type as `dialect` would actually store it: rendered to that engine's SQL and read back.
41
+ *
42
+ * Several canonical types share one storage type per engine - a `boolean` is `TINYINT(1)` on MySQL and
43
+ * `INTEGER` on SQLite - and only the engine settles an unstated bound, since `VARCHAR` is 255 on MySQL
44
+ * and `TEXT` on Postgres. Both paths that diff a schema compare through this, so a migration and a
45
+ * drift report cannot disagree about what changed.
46
+ */
47
+ export declare function engineType(dialect: AbstractDialect): (type: CanonicalType) => CanonicalType;
39
48
  /**
40
49
  * Convert UQL FieldOptions to a canonical type.
41
50
  */
42
- export declare function fieldOptionsToCanonical(options: FieldOptions, tsType?: unknown): CanonicalType;
51
+ export declare function fieldOptionsToCanonical(options: FieldOptions): CanonicalType;
43
52
  /**
44
- * Compare two canonical types for equality.
45
- * Used for schema diffing.
53
+ * Compare two canonical types for equality. Used for schema diffing.
54
+ *
55
+ * Nothing here guesses an engine's own default for an unstated bound: `VARCHAR` is 255 on MySQL and
56
+ * `TEXT` on Postgres, and a comparison that assumed either was blind to that difference on the other.
57
+ * `DiffOptions.normalizeType` is what settles it, by putting both sides through the engine first.
46
58
  */
47
59
  export declare function areTypesEqual(a: CanonicalType, b: CanonicalType): boolean;
48
60
  /**
49
- * Check if changing from type A to type B could cause data loss.
61
+ * Whether changing a column from one type to the other can lose what it holds.
62
+ *
63
+ * An unstated bound is the engine's widest, so stating one for the first time narrows the column just
64
+ * as lowering one does: `TEXT` to `VARCHAR(50)` truncates, and `NUMERIC` to `NUMERIC(5,2)` rounds.
50
65
  */
51
66
  export declare function isBreakingTypeChange(from: CanonicalType, to: CanonicalType): boolean;
52
67
  /**
@@ -6,6 +6,7 @@
6
6
  * - Canonical types (dialect-agnostic)
7
7
  * - TypeScript types (for entity generation)
8
8
  */
9
+ import { columnFamily } from '../util/field.util.js';
9
10
  /** Whether a category is one of the vector types, narrowing it to the cast pgvector names use. */
10
11
  export function isVectorCategory(category) {
11
12
  return category === 'vector' || category === 'halfvec' || category === 'sparsevec';
@@ -108,66 +109,57 @@ const PG_TYPE_MAP = {
108
109
  halfvec: 'HALFVEC',
109
110
  sparsevec: 'SPARSEVEC',
110
111
  };
112
+ const MYSQL_SCALAR_MAP = {
113
+ integer: 'INT',
114
+ float: 'FLOAT',
115
+ decimal: 'DECIMAL',
116
+ string: 'VARCHAR',
117
+ boolean: 'TINYINT(1)',
118
+ date: 'DATE',
119
+ time: 'TIME',
120
+ timestamp: 'DATETIME',
121
+ json: 'JSON',
122
+ uuid: 'CHAR(36)',
123
+ blob: 'BLOB',
124
+ };
125
+ const SQLITE_SCALAR_MAP = {
126
+ integer: 'INTEGER',
127
+ float: 'REAL',
128
+ decimal: 'REAL',
129
+ string: 'TEXT',
130
+ boolean: 'INTEGER',
131
+ date: 'TEXT',
132
+ time: 'TEXT',
133
+ timestamp: 'TEXT',
134
+ json: 'TEXT',
135
+ uuid: 'TEXT',
136
+ blob: 'BLOB',
137
+ };
138
+ /** MongoDB uses BSON types, not SQL types. These are placeholders for compatibility. */
139
+ const MONGO_SCALAR_MAP = {
140
+ integer: 'int',
141
+ float: 'double',
142
+ decimal: 'decimal128',
143
+ string: 'string',
144
+ boolean: 'bool',
145
+ date: 'date',
146
+ time: 'string',
147
+ timestamp: 'timestamp',
148
+ json: 'object',
149
+ uuid: 'binData',
150
+ blob: 'binData',
151
+ };
152
+ /** Every engine's scalars, and the one spelling it gives all three vector widths. */
111
153
  const CANONICAL_TO_SQL = {
112
154
  postgres: PG_TYPE_MAP,
113
155
  // CockroachDB's VECTOR is native, no extension needed.
114
156
  cockroachdb: withVectorType(PG_SCALAR_MAP, 'VECTOR'),
115
- mysql: withVectorType({
116
- integer: 'INT',
117
- float: 'FLOAT',
118
- decimal: 'DECIMAL',
119
- string: 'VARCHAR',
120
- boolean: 'TINYINT(1)',
121
- date: 'DATE',
122
- time: 'TIME',
123
- timestamp: 'DATETIME',
124
- json: 'JSON',
125
- uuid: 'CHAR(36)',
126
- blob: 'BLOB',
127
- },
128
157
  // MySQL does have a `VECTOR` type (26.7), but no distance function outside HeatWave and no vector
129
158
  // index, so JSON keeps the column queryable with the JSON operators and needs no conversion.
130
- 'JSON'),
131
- sqlite: withVectorType({
132
- integer: 'INTEGER',
133
- float: 'REAL',
134
- decimal: 'REAL',
135
- string: 'TEXT',
136
- boolean: 'INTEGER',
137
- date: 'TEXT',
138
- time: 'TEXT',
139
- timestamp: 'TEXT',
140
- json: 'TEXT',
141
- uuid: 'TEXT',
142
- blob: 'BLOB',
143
- }, 'TEXT'),
144
- mariadb: withVectorType({
145
- integer: 'INT',
146
- float: 'FLOAT',
147
- decimal: 'DECIMAL',
148
- string: 'VARCHAR',
149
- boolean: 'TINYINT(1)',
150
- date: 'DATE',
151
- time: 'TIME',
152
- timestamp: 'DATETIME',
153
- json: 'JSON',
154
- uuid: 'CHAR(36)',
155
- blob: 'BLOB',
156
- }, 'VECTOR'),
157
- // MongoDB uses BSON types, not SQL types. These are placeholders for compatibility.
158
- mongodb: withVectorType({
159
- integer: 'int',
160
- float: 'double',
161
- decimal: 'decimal128',
162
- string: 'string',
163
- boolean: 'bool',
164
- date: 'date',
165
- time: 'string',
166
- timestamp: 'timestamp',
167
- json: 'object',
168
- uuid: 'binData',
169
- blob: 'binData',
170
- }, 'array'),
159
+ mysql: withVectorType(MYSQL_SCALAR_MAP, 'JSON'),
160
+ mariadb: withVectorType(MYSQL_SCALAR_MAP, 'VECTOR'),
161
+ sqlite: withVectorType(SQLITE_SCALAR_MAP, 'TEXT'),
162
+ mongodb: withVectorType(MONGO_SCALAR_MAP, 'array'),
171
163
  };
172
164
  /**
173
165
  * Size variant modifiers for SQL types.
@@ -239,7 +231,7 @@ const CANONICAL_TO_TS = {
239
231
  timestamp: 'Date',
240
232
  json: 'unknown',
241
233
  uuid: 'string',
242
- blob: 'Buffer',
234
+ blob: 'Uint8Array',
243
235
  vector: 'number[]',
244
236
  halfvec: 'number[]',
245
237
  sparsevec: 'number[]',
@@ -387,136 +379,94 @@ function formatDecimalSqlType(type, dialect, baseType) {
387
379
  export function canonicalToTypeScript(type) {
388
380
  return CANONICAL_TO_TS[type.category];
389
381
  }
382
+ /**
383
+ * A type as `dialect` would actually store it: rendered to that engine's SQL and read back.
384
+ *
385
+ * Several canonical types share one storage type per engine - a `boolean` is `TINYINT(1)` on MySQL and
386
+ * `INTEGER` on SQLite - and only the engine settles an unstated bound, since `VARCHAR` is 255 on MySQL
387
+ * and `TEXT` on Postgres. Both paths that diff a schema compare through this, so a migration and a
388
+ * drift report cannot disagree about what changed.
389
+ */
390
+ export function engineType(dialect) {
391
+ return (type) => sqlToCanonical(canonicalToSql(type, dialect));
392
+ }
390
393
  /**
391
394
  * Convert UQL FieldOptions to a canonical type.
392
395
  */
393
- export function fieldOptionsToCanonical(options, tsType) {
394
- // If explicit columnType is specified, use it
396
+ export function fieldOptionsToCanonical(options) {
397
+ // An explicit column type is read exactly as an introspected one is: the SQL type, plus whatever
398
+ // bounds are stated beside it.
395
399
  if (options.columnType) {
396
- const base = sqlToCanonical(options.columnType);
397
- return {
398
- ...base,
399
- length: options.length ?? base.length,
400
- precision: options.precision ?? base.precision,
401
- scale: options.scale ?? base.scale,
402
- };
403
- }
404
- // Infer from type (could be constructor or string)
405
- const type = options.type || tsType;
406
- if (type === String) {
407
- return {
408
- category: 'string',
409
- length: options.length,
410
- };
411
- }
412
- if (type === Number) {
413
- if (options.precision || options.scale) {
414
- return {
415
- category: 'decimal',
416
- precision: options.precision,
417
- scale: options.scale,
418
- };
419
- }
420
- // BIGINT for every `Number`, key or not: a 32-bit column is a migration waiting to happen, and
421
- // the pools decode it back to a JS number at the wire (see `pgNumericTypes`).
422
- return { category: 'integer', size: 'big' };
423
- }
424
- if (type === Boolean) {
425
- return { category: 'boolean' };
426
- }
427
- if (type === Date) {
428
- return { category: 'timestamp' };
429
- }
430
- if (type === BigInt) {
431
- return { category: 'integer', size: 'big' };
400
+ return canonicalColumnType(options.columnType, options);
432
401
  }
402
+ // Infer from type, which is a SQL type string or one of the constructors.
403
+ const { type } = options;
433
404
  if (typeof type === 'string') {
434
405
  const canonical = sqlToCanonical(type);
435
406
  // Propagate explicit dimensions into CanonicalType.length for vector types
436
- if (options.dimensions && isVectorCategory(canonical.category)) {
437
- return { ...canonical, length: options.dimensions };
438
- }
439
- return canonical;
407
+ return options.dimensions && isVectorCategory(canonical.category)
408
+ ? { ...canonical, length: options.dimensions }
409
+ : canonical;
410
+ }
411
+ switch (columnFamily(type)) {
412
+ case 'numeric':
413
+ // BIGINT for every `Number` without a scale, key or not: a 32-bit column is a migration waiting
414
+ // to happen, and the pools decode it back to a JS number at the wire (see `pgNumericTypes`).
415
+ return type === Number && (options.precision || options.scale)
416
+ ? { category: 'decimal', precision: options.precision, scale: options.scale }
417
+ : { category: 'integer', size: 'big' };
418
+ case 'boolean':
419
+ return { category: 'boolean' };
420
+ case 'date':
421
+ return { category: 'timestamp' };
422
+ // `String`, and anything a reflected type left unrecognised.
423
+ default:
424
+ return { category: 'string', length: options.length };
440
425
  }
441
- // Default to string
442
- return { category: 'string', length: options.length };
443
426
  }
444
427
  /**
445
- * Compare two canonical types for equality.
446
- * Used for schema diffing.
428
+ * Compare two canonical types for equality. Used for schema diffing.
429
+ *
430
+ * Nothing here guesses an engine's own default for an unstated bound: `VARCHAR` is 255 on MySQL and
431
+ * `TEXT` on Postgres, and a comparison that assumed either was blind to that difference on the other.
432
+ * `DiffOptions.normalizeType` is what settles it, by putting both sides through the engine first.
447
433
  */
448
434
  export function areTypesEqual(a, b) {
449
- // Category must match
450
- if (a.category !== b.category) {
451
- return false;
452
- }
453
- // Size must match (if specified)
454
- if (a.size !== b.size) {
455
- return false;
456
- }
457
- // For strings, compare length (allowing for default 255)
458
- if (a.category === 'string') {
459
- const lengthA = a.length ?? 255;
460
- const lengthB = b.length ?? 255;
461
- if (lengthA !== lengthB) {
462
- return false;
463
- }
464
- }
465
- // For decimals, compare precision and scale (allowing for default 10, 2)
466
- if (a.category === 'decimal') {
467
- const pA = a.precision ?? 10;
468
- const pB = b.precision ?? 10;
469
- const sA = a.scale ?? 2;
470
- const sB = b.scale ?? 2;
471
- if (pA !== pB || sA !== sB) {
472
- return false;
473
- }
474
- }
475
- // For timestamps, compare timezone
476
- if (a.category === 'timestamp') {
477
- if (!!a.withTimezone !== !!b.withTimezone) {
478
- return false;
479
- }
480
- }
481
- // Unsigned must match
482
- if (!!a.unsigned !== !!b.unsigned) {
483
- return false;
484
- }
485
- return true;
435
+ return (a.category === b.category &&
436
+ a.size === b.size &&
437
+ a.length === b.length &&
438
+ a.precision === b.precision &&
439
+ a.scale === b.scale &&
440
+ !!a.withTimezone === !!b.withTimezone &&
441
+ !!a.unsigned === !!b.unsigned);
486
442
  }
487
443
  /**
488
- * Check if changing from type A to type B could cause data loss.
444
+ * Whether changing a column from one type to the other can lose what it holds.
445
+ *
446
+ * An unstated bound is the engine's widest, so stating one for the first time narrows the column just
447
+ * as lowering one does: `TEXT` to `VARCHAR(50)` truncates, and `NUMERIC` to `NUMERIC(5,2)` rounds.
489
448
  */
490
449
  export function isBreakingTypeChange(from, to) {
491
- // Changing category is always potentially breaking
492
450
  if (from.category !== to.category) {
493
451
  return true;
494
452
  }
495
- // Reducing size is breaking
496
- const sizeOrder = ['tiny', 'small', 'medium', 'big'];
497
- if (from.size && to.size) {
498
- const fromIndex = sizeOrder.indexOf(from.size);
499
- const toIndex = sizeOrder.indexOf(to.size);
500
- if (toIndex < fromIndex) {
501
- return true;
502
- }
503
- }
504
- // Reducing string length is breaking
505
- if (from.category === 'string' && from.length && to.length) {
506
- if (to.length < from.length) {
507
- return true;
508
- }
509
- }
510
- // Reducing precision/scale is breaking
511
- if (from.category === 'decimal') {
512
- if (from.precision && to.precision && to.precision < from.precision) {
513
- return true;
514
- }
515
- if (from.scale && to.scale && to.scale < from.scale) {
516
- return true;
517
- }
453
+ // Only where both state a size: an unsized member of a family is the engine's base type, wider than
454
+ // `tiny` and narrower than `big`, and which of those it is depends on a family this does not know.
455
+ if (from.size && to.size && SIZE_ORDER.indexOf(to.size) < SIZE_ORDER.indexOf(from.size)) {
456
+ return true;
518
457
  }
519
- return false;
458
+ return (narrows(from.length, to.length) ||
459
+ narrows(from.precision, to.precision) ||
460
+ narrows(from.scale, to.scale) ||
461
+ // Either direction drops half the range: signed loses the top bit, unsigned the negatives.
462
+ !!from.unsigned !== !!to.unsigned ||
463
+ // An offset the column stops keeping cannot be recovered from what is left.
464
+ (!!from.withTimezone && !to.withTimezone));
465
+ }
466
+ const SIZE_ORDER = ['tiny', 'small', 'medium', 'big'];
467
+ /** A bound narrowed, where an unstated one is unbounded. */
468
+ function narrows(from, to) {
469
+ return to !== undefined && (from === undefined || to < from);
520
470
  }
521
471
  /**
522
472
  * Get the UQL ColumnType that best matches a canonical type.
@@ -65,7 +65,7 @@ function resolveColumnCanonicalType(field, seen = new Set()) {
65
65
  return resolveColumnCanonicalType(referencedIdField, seen);
66
66
  }
67
67
  }
68
- return fieldOptionsToCanonical(field, field.type);
68
+ return fieldOptionsToCanonical(field);
69
69
  }
70
70
  /**
71
71
  * Add a table from entity metadata.
@@ -175,7 +175,8 @@ function diffColumn(tableName, source, target, opts) {
175
175
  // key column" rule these two replace used to hide.
176
176
  const generatedType = source.isAutoIncrement && target.isAutoIncrement;
177
177
  const impliedNotNull = source.isPrimaryKey && target.isPrimaryKey;
178
- if (!generatedType && !areTypesEqual(opts.normalizeType(source.type), opts.normalizeType(target.type))) {
178
+ const typeChanged = !generatedType && !areTypesEqual(opts.normalizeType(source.type), opts.normalizeType(target.type));
179
+ if (typeChanged) {
179
180
  differences.push(`type: ${formatType(source.type)} → ${formatType(target.type)}`);
180
181
  }
181
182
  if (!impliedNotNull && source.nullable !== target.nullable) {
@@ -202,7 +203,10 @@ function diffColumn(tableName, source, target, opts) {
202
203
  type: 'alter',
203
204
  expected: source,
204
205
  actual: target,
205
- isBreaking: isBreakingTypeChange(target.type, source.type),
206
+ // Only the type this diff actually reports: a column altered for its default carries no data loss,
207
+ // and a generated key's type - never compared above - reads as unsigned against an entity that
208
+ // cannot say so.
209
+ isBreaking: typeChanged && isBreakingTypeChange(target.type, source.type),
206
210
  description: differences.join(', '),
207
211
  };
208
212
  }