uql-orm 0.80.0 → 0.81.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.
Files changed (56) hide show
  1. package/dist/dialect/vectorSqlDialect.d.ts +2 -0
  2. package/dist/dialect/vectorSqlDialect.js +4 -0
  3. package/dist/migrate/builder/expressions.d.ts +2 -0
  4. package/dist/migrate/builder/expressions.js +24 -0
  5. package/dist/migrate/cli.js +1 -1
  6. package/dist/migrate/codegen/entityCodeGenerator.js +2 -2
  7. package/dist/migrate/codegen/indexDecoratorSource.d.ts +3 -2
  8. package/dist/migrate/codegen/indexDecoratorSource.js +5 -23
  9. package/dist/migrate/ddl/mssqlTableDdl.d.ts +4 -4
  10. package/dist/migrate/ddl/mssqlTableDdl.js +20 -14
  11. package/dist/migrate/ddl/mysqlIndexDdl.d.ts +2 -2
  12. package/dist/migrate/ddl/mysqlIndexDdl.js +7 -6
  13. package/dist/migrate/ddl/pgIndexDdl.d.ts +2 -1
  14. package/dist/migrate/ddl/pgIndexDdl.js +8 -6
  15. package/dist/migrate/ddl/tableDdl.d.ts +3 -2
  16. package/dist/migrate/ddl/tableDdl.js +9 -7
  17. package/dist/migrate/drift/driftDetector.d.ts +4 -5
  18. package/dist/migrate/drift/driftDetector.js +21 -21
  19. package/dist/migrate/generator/definitionToNode.d.ts +1 -1
  20. package/dist/migrate/generator/definitionToNode.js +9 -20
  21. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +1 -1
  22. package/dist/migrate/generator/mongoSchemaGenerator.js +11 -19
  23. package/dist/migrate/index.d.ts +2 -1
  24. package/dist/migrate/index.js +1 -0
  25. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.d.ts +5 -7
  26. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +11 -18
  27. package/dist/migrate/introspection/baseSqlIntrospector.js +7 -18
  28. package/dist/migrate/introspection/mongoIntrospector.d.ts +1 -1
  29. package/dist/migrate/introspection/mongoIntrospector.js +3 -3
  30. package/dist/migrate/introspection/mssqlIntrospector.js +2 -1
  31. package/dist/migrate/introspection/mysqlIntrospector.d.ts +15 -5
  32. package/dist/migrate/introspection/mysqlIntrospector.js +32 -4
  33. package/dist/migrate/introspection/postgresIntrospector.d.ts +29 -21
  34. package/dist/migrate/introspection/postgresIntrospector.js +63 -46
  35. package/dist/migrate/introspection/sqliteIntrospector.js +11 -9
  36. package/dist/migrate/migrator.d.ts +5 -0
  37. package/dist/migrate/migrator.js +33 -44
  38. package/dist/migrate/schemaChange.d.ts +18 -0
  39. package/dist/migrate/schemaChange.js +37 -0
  40. package/dist/migrate/schemaGenerator.d.ts +13 -14
  41. package/dist/migrate/schemaGenerator.js +83 -177
  42. package/dist/schema/indexDifferences.d.ts +22 -6
  43. package/dist/schema/indexDifferences.js +23 -8
  44. package/dist/schema/matchByKey.d.ts +10 -0
  45. package/dist/schema/matchByKey.js +18 -0
  46. package/dist/schema/schemaAST.d.ts +6 -2
  47. package/dist/schema/schemaAST.js +7 -3
  48. package/dist/schema/schemaASTBuilder.d.ts +2 -0
  49. package/dist/schema/schemaASTBuilder.js +15 -10
  50. package/dist/schema/schemaASTDiffer.d.ts +2 -3
  51. package/dist/schema/schemaASTDiffer.js +15 -36
  52. package/dist/schema/types.d.ts +14 -15
  53. package/dist/type/migration.d.ts +32 -50
  54. package/dist/util/ddlExpression.util.d.ts +5 -1
  55. package/dist/util/ddlExpression.util.js +6 -2
  56. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { INDEX_TYPES } from '../../schema/types.js';
2
+ import { isVectorIndexType } from '../../type/vector.js';
2
3
  import { AbstractSqlSchemaIntrospector } from './abstractSqlSchemaIntrospector.js';
3
4
  /**
4
5
  * PostgreSQL schema introspector
@@ -15,15 +16,15 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
15
16
  'opsClass',
16
17
  'accessMethod',
17
18
  'include',
19
+ 'distance',
18
20
  ]);
19
21
  triggersQuery() {
20
22
  return /*sql*/ `
21
- SELECT c.relname AS "table", t.tgname AS name, pg_get_triggerdef(t.oid) AS definition,
22
- pg_get_functiondef(t.tgfoid) AS requires
23
+ SELECT t.tgname AS name, pg_get_triggerdef(t.oid) AS definition, pg_get_functiondef(t.tgfoid) AS requires
23
24
  FROM pg_trigger t
24
25
  JOIN pg_class c ON c.oid = t.tgrelid
25
26
  JOIN pg_namespace n ON n.oid = c.relnamespace
26
- WHERE NOT t.tgisinternal AND n.nspname = ${this.schemaExpr}
27
+ WHERE NOT t.tgisinternal AND n.nspname = ${this.schemaExpr} AND c.relname = ${this.dialect.placeholder(1)}
27
28
  `;
28
29
  }
29
30
  getTableNamesQuery() {
@@ -54,7 +55,11 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
54
55
  * database scans meet that table every time something else is migrating.
55
56
  *
56
57
  * `attgenerated` rather than `is_generated`, which cannot part a stored generated column from the
57
- * virtual one Postgres 18 added and uql never declares. CockroachDB states it too.
58
+ * virtual one Postgres 18 added and uql never declares. CockroachDB states it too. `format_type` for an
59
+ * extension type's modifier, which `information_schema` drops: a `vector(256)` read back as `vector`.
60
+ * CockroachDB names that type `vector` where Postgres says `USER-DEFINED`.
61
+ *
62
+ * A column is unique by a unique index over it alone, a constraint's or its own, as every engine reads it.
58
63
  */
59
64
  getColumnsQuery(_tableName) {
60
65
  return /*sql*/ `
@@ -69,11 +74,9 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
69
74
  c.numeric_scale,
70
75
  c.is_identity,
71
76
  c.identity_generation,
72
- CASE WHEN (
73
- SELECT a.attgenerated FROM pg_catalog.pg_attribute a
74
- WHERE a.attrelid = to_regclass(quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))
75
- AND a.attname = c.column_name
76
- ) = 's' THEN c.generation_expression END AS generated_as,
77
+ CASE WHEN a.attgenerated = 's' THEN c.generation_expression END AS generated_as,
78
+ CASE WHEN c.data_type IN ('USER-DEFINED', 'vector') AND a.atttypmod > -1
79
+ THEN format_type(a.atttypid, a.atttypmod) END AS formatted_type,
77
80
  EXISTS (
78
81
  SELECT 1 FROM information_schema.table_constraints tc
79
82
  JOIN information_schema.key_column_usage kcu USING (constraint_schema, constraint_name)
@@ -83,19 +86,19 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
83
86
  AND kcu.column_name = c.column_name
84
87
  ) AS is_primary_key,
85
88
  EXISTS (
86
- SELECT 1 FROM information_schema.table_constraints tc
87
- JOIN information_schema.key_column_usage kcu USING (constraint_schema, constraint_name)
88
- WHERE tc.table_schema = c.table_schema
89
- AND tc.table_name = c.table_name
90
- AND tc.constraint_type = 'UNIQUE'
91
- GROUP BY tc.constraint_name
92
- HAVING COUNT(*) = 1 AND MIN(kcu.column_name) = c.column_name
89
+ SELECT 1 FROM pg_catalog.pg_index ix
90
+ WHERE ix.indrelid = a.attrelid AND ix.indisunique AND NOT ix.indisprimary
91
+ AND ix.indnkeyatts = 1 AND ix.indkey[0] = a.attnum
92
+ AND ix.indpred IS NULL AND ix.indexprs IS NULL
93
93
  ) AS is_unique,
94
94
  pg_catalog.col_description(
95
95
  to_regclass(quote_ident(c.table_schema) || '.' || quote_ident(c.table_name)),
96
96
  c.ordinal_position
97
97
  ) AS column_comment
98
98
  FROM information_schema.columns c
99
+ LEFT JOIN pg_catalog.pg_attribute a
100
+ ON a.attrelid = to_regclass(quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))
101
+ AND a.attname = c.column_name
99
102
  WHERE c.table_schema = ${this.schemaExpr}
100
103
  AND c.table_name = $1
101
104
  ORDER BY c.ordinal_position
@@ -108,23 +111,23 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
108
111
  * while `pg_get_indexdef` reprints an identifier *quoted*, so a camelCase column came back as
109
112
  * `"tenantId"` and matched no column of the table. Prisma and drizzle-kit both split it this way.
110
113
  *
111
- * Indexes backing a constraint are left out, primary keys among them: `@Field({ unique })` emits a
112
- * `UNIQUE` constraint and no index, so reporting the index Postgres builds underneath it told every
113
- * project it had an index its entities never asked for.
114
+ * The key's index and an `EXCLUDE`'s are left out. A `UNIQUE` constraint's index stays, as SQL Server
115
+ * and the MySQL family report theirs: the diff reads one over a single column as that column's
116
+ * uniqueness, and one over several as the unique `@Index` it is.
114
117
  */
115
118
  getIndexesQuery(_tableName) {
116
119
  return /*sql*/ `
117
120
  SELECT
118
121
  i.relname AS index_name,
119
122
  ix.indisunique AS is_unique,
120
- am.amname AS method,
123
+ ${this.indexMethodSql} AS method,
121
124
  pg_get_expr(ix.indpred, ix.indrelid, true) AS predicate,
122
125
  k.n <= ix.indnkeyatts AS is_key,
123
126
  k.attnum = 0 AS is_expression,
124
127
  COALESCE(a.attname::text, pg_get_indexdef(ix.indexrelid, k.n::int, true)) AS entry,
125
128
  (ix.indoption[k.n - 1] & 1) <> 0 AS descending,
126
- (ix.indoption[k.n - 1] & 2) <> 0 AS nulls_first,
127
- CASE WHEN op.opcdefault THEN NULL ELSE op.opcname END AS ops_class
129
+ ${this.nullsFirstSql} AS nulls_first,
130
+ ${this.opsClassSql} AS ops_class
128
131
  FROM pg_class t
129
132
  JOIN pg_index ix ON t.oid = ix.indrelid
130
133
  JOIN pg_class i ON i.oid = ix.indexrelid
@@ -137,22 +140,17 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
137
140
  AND n.nspname = ${this.schemaExpr}
138
141
  AND NOT ix.indisprimary
139
142
  AND NOT EXISTS (
140
- SELECT 1 FROM pg_constraint con
141
- WHERE con.conindid = ix.indexrelid
142
- AND con.contype IN (${this.constraintIndexTypes.map((type) => `'${type}'`).join(', ')})
143
+ SELECT 1 FROM pg_constraint con WHERE con.conindid = ix.indexrelid AND con.contype = 'x'
143
144
  )
144
145
  ORDER BY i.relname, k.n
145
146
  `;
146
147
  }
147
- /**
148
- * Constraint kinds whose backing index is the constraint itself rather than an index anyone asked
149
- * for. Postgres builds one for `PRIMARY KEY`, `UNIQUE` and `EXCLUDE`, and only for those: a plain
150
- * `CREATE UNIQUE INDEX` has no `pg_constraint` row at all, so it survives.
151
- *
152
- * Nothing to do with {@link indexFacets}, which says which *attributes* of an index diffing may
153
- * compare. This one decides which indexes are reported at all.
154
- */
155
- constraintIndexTypes = ['p', 'u', 'x'];
148
+ /** Whether an entry sorts nulls first, which Postgres states on every entry. */
149
+ nullsFirstSql = '(ix.indoption[k.n - 1] & 2) <> 0';
150
+ /** An index's access method, which is the type it declares. */
151
+ indexMethodSql = 'am.amname';
152
+ /** An entry's operator class, where it is not the default for its type. */
153
+ opsClassSql = 'CASE WHEN op.opcdefault THEN NULL ELSE op.opcname END';
156
154
  /** From `pg_constraint`, whose key arrays keep each column paired with the one it references. */
157
155
  getForeignKeysQuery(_tableName) {
158
156
  const columnsOf = (keys, table) => /*sql*/ `ARRAY_TO_JSON(ARRAY(
@@ -194,7 +192,7 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
194
192
  async mapColumnsResult(_read, _tableName, results) {
195
193
  return results.map((row) => ({
196
194
  name: row.column_name,
197
- type: this.normalizeType(row.data_type, row.udt_name),
195
+ type: row.formatted_type?.toUpperCase() ?? this.normalizeType(row.data_type, row.udt_name),
198
196
  nullable: row.is_nullable === 'YES',
199
197
  defaultValue: this.parseDefaultValue(row.column_default),
200
198
  isPrimaryKey: row.is_primary_key,
@@ -207,11 +205,25 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
207
205
  generatedAs: row.generated_as ?? undefined,
208
206
  }));
209
207
  }
208
+ /**
209
+ * A vector index keeps its distance as its vector column's operator class, `{type}_{metric}_ops`, which
210
+ * is how `PgIndexDdl` writes the entity's `distance`; read back as that `distance`, so the two compare.
211
+ * No class named is the engine's default, L2.
212
+ */
213
+ withVectorDistance(index) {
214
+ if (!isVectorIndexType(index.type)) {
215
+ return index;
216
+ }
217
+ const opsClass = index.entries.find((entry) => entry.opsClass)?.opsClass;
218
+ const distance = this.dialect.indexedDistance(opsClass ? /_([a-z0-9]+)_ops$/.exec(opsClass)?.[1] : 'l2');
219
+ const entries = index.entries.map(({ opsClass: _opsClass, ...entry }) => entry);
220
+ return distance ? { ...index, distance, entries } : index;
221
+ }
210
222
  async mapIndexesResult(_read, _tableName, results) {
211
223
  // One row per index entry, ordered by position, so the rows of an index are its entries in order.
212
224
  return [...Map.groupBy(results, (row) => row.index_name)].map(([name, rows]) => {
213
225
  const include = rows.filter((row) => !row.is_key).map((row) => row.entry);
214
- return {
226
+ return this.withVectorDistance({
215
227
  name,
216
228
  entries: rows.filter((row) => row.is_key).map(mapIndexEntry),
217
229
  unique: rows[0].is_unique,
@@ -219,7 +231,7 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
219
231
  where: rows[0].predicate ?? undefined,
220
232
  include: include.length > 0 ? include : undefined,
221
233
  ...fulltextIndex(rows),
222
- };
234
+ });
223
235
  });
224
236
  }
225
237
  async mapForeignKeysResult(_read, _tableName, results) {
@@ -315,7 +327,7 @@ function mapIndexEntry(row) {
315
327
  column: row.entry,
316
328
  ...(row.is_expression && { expression: true }),
317
329
  order: row.descending ? 'desc' : 'asc',
318
- nulls: row.nulls_first ? 'first' : 'last',
330
+ ...(row.nulls_first !== null && { nulls: row.nulls_first ? 'first' : 'last' }),
319
331
  ...(row.ops_class && { opsClass: row.ops_class }),
320
332
  };
321
333
  }
@@ -323,16 +335,21 @@ function mapIndexEntry(row) {
323
335
  * CockroachDB answers the same catalogue queries and differs only in what it can express: v26.2.5
324
336
  * still rejects `NULLS FIRST/LAST` and operator classes as "unimplemented", and it sorts nulls first
325
337
  * on an ASC column where Postgres sorts them last. Reading a nulls order back would therefore report
326
- * every ascending index as drifted, against an entity that could not have asked for one. Its access
327
- * method, `prefix`, needs nothing: a method that is not a known index type is reported as no type.
338
+ * every ascending index as drifted, against an entity that could not have asked for one.
328
339
  */
329
340
  export class CockroachSchemaIntrospector extends PostgresSchemaIntrospector {
330
- indexFacets = new Set(['order', 'include']);
341
+ indexFacets = new Set([
342
+ 'order',
343
+ 'include',
344
+ 'vector',
345
+ 'distance',
346
+ ]);
347
+ /** None: it rejects a stated nulls order, so reading one back gives an index it would refuse to rebuild. */
348
+ nullsFirstSql = 'NULL::BOOL';
331
349
  /**
332
- * `'u'` is missing on purpose. CockroachDB registers a `UNIQUE` constraint for a plain `CREATE
333
- * UNIQUE INDEX` too, naming it after the index, so filtering on it would hide every unique index a
334
- * user asked for and report it missing forever. It leaves no way to tell the two apart, so the
335
- * index a `@Field({ unique })` builds underneath itself stays visible there.
350
+ * Every index reports the access method `prefix` and no operator class, so a vector index is read off
351
+ * its definition, `USING cspann (vec vector_cosine_ops)`: its type, and its last key's class.
336
352
  */
337
- constraintIndexTypes = ['p', 'x'];
353
+ indexMethodSql = `CASE WHEN pg_get_indexdef(ix.indexrelid) LIKE '% USING cspann %' THEN 'vector' ELSE am.amname END`;
354
+ opsClassSql = `CASE WHEN k.n = ix.indnkeyatts THEN substring(pg_get_indexdef(ix.indexrelid) from '(\\w+_ops)\\)') END`;
338
355
  }
@@ -5,10 +5,10 @@ import { AbstractSqlSchemaIntrospector } from './abstractSqlSchemaIntrospector.j
5
5
  */
6
6
  export class SqliteSchemaIntrospector extends AbstractSqlSchemaIntrospector {
7
7
  /** Whether an index is libSQL's vector index, where the engine has one; elsewhere a declared one is built plain. */
8
- indexFacets = new Set(this.dialect.hasVectorIndex() ? ['vector'] : []);
8
+ indexFacets = new Set(this.dialect.hasVectorIndex() ? ['vector', 'distance'] : []);
9
9
  /** Not SQLite's own tables, nor the ones libSQL keeps a vector index in: its metadata and `<index>_shadow`. */
10
10
  triggersQuery() {
11
- return /*sql*/ `SELECT tbl_name AS \`table\`, name, sql AS definition FROM sqlite_master WHERE type = 'trigger'`;
11
+ return /*sql*/ `SELECT name, sql AS definition FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ?`;
12
12
  }
13
13
  getTableNamesQuery() {
14
14
  return /*sql*/ `
@@ -89,15 +89,12 @@ export class SqliteSchemaIntrospector extends AbstractSqlSchemaIntrospector {
89
89
  const indexSchemas = [];
90
90
  for (const index of results) {
91
91
  const columns = await this.getIndexColumns(read, index.name);
92
- // Include user-created indexes ('c') and multi-column unique constraints ('u')
93
- // Skip primary key indexes ('pk') and single-column unique constraints
94
- const isUserCreated = index.origin === 'c';
95
- const isCompositeUnique = index.origin === 'u' && columns.length > 1;
92
+ // A unique constraint's index ('u') is reported as every engine reports it, and only the key's ('pk') left out.
96
93
  // `PRAGMA index_info` names an expression entry `null` (its `cid` is -2), and the expression text
97
94
  // lives only in `sqlite_master.sql`. Reporting `{ column: null }` put a column literally named
98
95
  // `null` into the diff, so an index UQL cannot describe is left out, libSQL's vector index aside.
99
96
  const named = columns.filter((column) => column.name !== null);
100
- if (!isUserCreated && !isCompositeUnique) {
97
+ if (index.origin === 'pk') {
101
98
  continue;
102
99
  }
103
100
  if (named.length === columns.length) {
@@ -171,8 +168,13 @@ export class SqliteSchemaIntrospector extends AbstractSqlSchemaIntrospector {
171
168
  return undefined;
172
169
  }
173
170
  const metric = row.sql?.match(/'metric=(\w+)'/i)?.[1]?.toLowerCase();
174
- const distances = new Map([...this.dialect.vectorMetrics].map(([distance, { index }]) => [index, distance]));
175
- return { name: indexName, entries: [{ column }], unique: false, type: 'vector', distance: distances.get(metric) };
171
+ return {
172
+ name: indexName,
173
+ entries: [{ column }],
174
+ unique: false,
175
+ type: 'vector',
176
+ distance: this.dialect.indexedDistance(metric),
177
+ };
176
178
  }
177
179
  /** The statement that created the table, which is where SQLite keeps every expression it was given. */
178
180
  async getTableDdl(read, tableName) {
@@ -128,6 +128,11 @@ export declare class Migrator {
128
128
  * never asks for the rollback, which on SQLite cannot even be expressed (no `ALTER COLUMN`).
129
129
  */
130
130
  private pendingChanges;
131
+ /**
132
+ * Safe mode only adds: a change with a `from` drops or rebuilds what the table holds, so it is held,
133
+ * and so is a key whole, which rebuilds an index over every row and fails where a column holds a null.
134
+ * Without `drop`, a column's drop is held too.
135
+ */
131
136
  protected filterDiff(diff: SchemaDiff, options: {
132
137
  safe?: boolean;
133
138
  drop?: boolean;
@@ -8,6 +8,7 @@ import { LoggerWrapper } from '../util/index.js';
8
8
  import { buildMigrationModule } from './codegen/migrationFile.js';
9
9
  import { introspectorFor } from './introspection/registry.js';
10
10
  import { migrationBuilderFor, migrationTargetFor } from './migrationTarget.js';
11
+ import { dropped, nonEmpty, reverseDiff, sides } from './schemaChange.js';
11
12
  /**
12
13
  * Main class for managing database migrations
13
14
  */
@@ -215,17 +216,10 @@ export class Migrator {
215
216
  // Tables this plan creates are left out: their `CREATE` carries their triggers, and asking the
216
217
  // catalogue about a table that is not there yet fails outright on some engines.
217
218
  const wanted = entities.filter((entity) => !fresh.has(this.tableOf(entity)));
218
- const bySchema = new Map();
219
219
  const state = [];
220
220
  for (const entity of wanted) {
221
221
  const meta = getMeta(entity);
222
- const schema = dialect.resolveSchema(meta);
223
- let owned = bySchema.get(schema);
224
- if (!owned) {
225
- owned = await this.schemaIntrospectorFor(schema).ownedTriggers();
226
- bySchema.set(schema, owned);
227
- }
228
- const installed = owned.get(dialect.resolveTableAlias(meta)) ?? new Map();
222
+ const installed = await this.schemaIntrospectorFor(dialect.resolveSchema(meta)).ownedTriggers(dialect.resolveTableAlias(meta));
229
223
  // An installed trigger alone keeps it: an entity that stopped declaring one has it to drop.
230
224
  if (installed.size || hasTriggers(meta)) {
231
225
  state.push({ entity, installed });
@@ -239,7 +233,7 @@ export class Migrator {
239
233
  * its entity declares goes back on after. `down` is lazy: SQLite cannot express every alter's inverse.
240
234
  */
241
235
  alterPlan(generator, altered, state) {
242
- const changing = new Set(altered.filter((diff) => diff.columnsToAlter?.length || diff.columnsToDrop?.length).map((diff) => diff.tableName));
236
+ const changing = new Set(altered.filter((diff) => sides(diff.columns, 'from').length).map((diff) => diff.tableName));
243
237
  const cleared = state.filter(({ entity }) => changing.has(this.tableOf(entity)));
244
238
  const after = state.map((it) => (cleared.includes(it) ? { entity: it.entity, installed: new Map() } : it));
245
239
  return {
@@ -250,7 +244,7 @@ export class Migrator {
250
244
  ],
251
245
  down: () => [
252
246
  ...this.revertedTriggers(generator, after),
253
- ...altered.toReversed().flatMap((diff) => generator.generateAlterTableDown(diff)),
247
+ ...altered.toReversed().flatMap((diff) => generator.generateAlterTable(reverseDiff(diff))),
254
248
  ...cleared.flatMap(({ installed }) => [...installed.values()].flat().map((sql) => `${sql};`)),
255
249
  ],
256
250
  };
@@ -400,45 +394,40 @@ export class Migrator {
400
394
  altered: diffs.filter((diff) => diff.type === 'alter'),
401
395
  };
402
396
  }
397
+ /**
398
+ * Safe mode only adds: a change with a `from` drops or rebuilds what the table holds, so it is held,
399
+ * and so is a key whole, which rebuilds an index over every row and fails where a column holds a null.
400
+ * Without `drop`, a column's drop is held too.
401
+ */
403
402
  filterDiff(diff, options) {
404
- const filteredDiff = { ...diff };
405
- if (options.safe !== false) {
406
- // In safe mode, we only allow additions (creating tables/columns)
407
- // We block drops and alterations to prevent accidental data loss
408
- if (filteredDiff.columnsToDrop?.length) {
409
- this.logger.logSkippedMigration(`[AutoSync] Skipped dropping ${filteredDiff.columnsToDrop.length} columns in table '${diff.tableName}': ${filteredDiff.columnsToDrop.join(', ')} (safe mode active)`);
410
- delete filteredDiff.columnsToDrop;
411
- }
412
- if (filteredDiff.columnsToAlter?.length) {
413
- this.logger.logSkippedMigration(`[AutoSync] Skipped altering ${filteredDiff.columnsToAlter.length} columns in table '${diff.tableName}': ${filteredDiff.columnsToAlter.map((c) => c.to.name).join(', ')} (safe mode active). Use a migration or { safe: false } to apply.`);
414
- delete filteredDiff.columnsToAlter;
415
- }
416
- if (filteredDiff.primaryKey) {
417
- // Rewriting a key drops a constraint and rebuilds an index over the whole table, and fails
418
- // outright where the new columns are null on rows that already exist. Firmly not additive.
419
- this.logger.logSkippedMigration(`[AutoSync] Skipped changing the primary key of '${diff.tableName}' from (${filteredDiff.primaryKey.from.join(', ')}) to (${filteredDiff.primaryKey.to.join(', ')}) (safe mode active). Use a migration or { safe: false } to apply.`);
420
- delete filteredDiff.primaryKey;
403
+ const safe = options.safe !== false;
404
+ const skip = (what, names, fix) => {
405
+ if (names.length) {
406
+ this.logger.logSkippedMigration(`[AutoSync] Skipped ${names.length} ${what} in table '${diff.tableName}': ${names.join(', ')} (${fix}).`);
421
407
  }
422
- if (filteredDiff.foreignKeysToAlter?.length) {
423
- // Altering one is dropping it and adding it back, so letting the add through while the drop
424
- // is held would emit `ADD CONSTRAINT` for a constraint the table still has.
425
- this.logger.logSkippedMigration(`[AutoSync] Skipped altering ${filteredDiff.foreignKeysToAlter.length} foreign keys in table '${diff.tableName}': ${filteredDiff.foreignKeysToAlter.map((fk) => fk.to.name).join(', ')} (safe mode active). Use a migration or { safe: false } to apply.`);
426
- delete filteredDiff.foreignKeysToAlter;
427
- }
428
- if (filteredDiff.indexesToDrop?.length) {
429
- // An index recreated under its old name is a drop and an add, held back together.
430
- const dropped = new Set(filteredDiff.indexesToDrop.map((index) => index.name));
431
- this.logger.logSkippedMigration(`[AutoSync] Skipped dropping ${dropped.size} indexes in table '${diff.tableName}': ${[...dropped].join(', ')} (safe mode active). Use a migration or { safe: false } to apply.`);
432
- filteredDiff.indexesToAdd = filteredDiff.indexesToAdd?.filter((index) => !dropped.has(index.name));
433
- delete filteredDiff.indexesToDrop;
408
+ };
409
+ const safeFix = 'safe mode active. Use a migration or { safe: false } to apply';
410
+ const additive = (what, changes, nameOf) => {
411
+ if (!safe) {
412
+ return changes;
434
413
  }
435
- delete filteredDiff.foreignKeysToDrop;
414
+ skip(`${what} changes`, sides(changes, 'from').map(nameOf), safeFix);
415
+ return nonEmpty((changes ?? []).filter((change) => change.from === undefined));
416
+ };
417
+ const columns = additive('column', diff.columns, (column) => column.name);
418
+ if (safe && diff.primaryKey) {
419
+ skip('primary key changes', [diff.tableName], safeFix);
436
420
  }
437
- if (!options.drop && filteredDiff.columnsToDrop?.length) {
438
- this.logger.logSkippedMigration(`[AutoSync] Skipped dropping ${filteredDiff.columnsToDrop.length} columns in table '${diff.tableName}' (drop: false). Use { drop: true } to apply.`);
439
- delete filteredDiff.columnsToDrop;
421
+ if (!options.drop) {
422
+ skip('column drops', dropped(columns).map((column) => column.name), 'drop: false. Use { drop: true } to apply');
440
423
  }
441
- return filteredDiff;
424
+ return {
425
+ ...diff,
426
+ primaryKey: safe ? undefined : diff.primaryKey,
427
+ columns: options.drop ? columns : nonEmpty((columns ?? []).filter((change) => change.to !== undefined)),
428
+ indexes: additive('index', diff.indexes, (index) => index.name),
429
+ foreignKeys: additive('foreign key', diff.foreignKeys, (foreignKey) => foreignKey.name ?? foreignKey.columns.join(', ')),
430
+ };
442
431
  }
443
432
  /** Runs the statements a generator wrote, in one transaction where the engine takes DDL in one. */
444
433
  async executeSyncStatements(statements, options) {
@@ -0,0 +1,18 @@
1
+ import type { Change, SchemaDiff } from '../type/index.js';
2
+ /** Each change's end on `side`, where it has one: what a drop half removes (`from`), or an add half creates (`to`). */
3
+ export declare function sides<T>(changes: readonly Change<T>[] | undefined, side: 'from' | 'to'): T[];
4
+ /** What the changes add: each `to` with no `from`. */
5
+ export declare function added<T>(changes?: readonly Change<T>[]): T[];
6
+ /** What the changes drop: each `from` with no `to`. */
7
+ export declare function dropped<T>(changes?: readonly Change<T>[]): T[];
8
+ /** The changes that alter an object in place, which only a column can. */
9
+ export declare function alterations<T>(changes?: readonly Change<T>[]): {
10
+ readonly from: T;
11
+ readonly to: T;
12
+ }[];
13
+ /** `items`, or nothing where it has none, so an empty change list is left off a diff. */
14
+ export declare function nonEmpty<T>(items: readonly T[]): readonly T[] | undefined;
15
+ /** `change` undone: an add becomes a drop, a drop an add, and an alter runs the other way. */
16
+ export declare function swap<T>({ from, to }: Change<T>): Change<T>;
17
+ /** `diff` undone: every change swapped, which is what a migration's `down` runs. */
18
+ export declare function reverseDiff(diff: SchemaDiff): SchemaDiff;
@@ -0,0 +1,37 @@
1
+ /** Each change's end on `side`, where it has one: what a drop half removes (`from`), or an add half creates (`to`). */
2
+ export function sides(changes, side) {
3
+ return (changes ?? []).flatMap((change) => {
4
+ const end = change[side];
5
+ return end === undefined ? [] : [end];
6
+ });
7
+ }
8
+ /** What the changes add: each `to` with no `from`. */
9
+ export function added(changes = []) {
10
+ return changes.flatMap(({ from, to }) => (from === undefined && to !== undefined ? [to] : []));
11
+ }
12
+ /** What the changes drop: each `from` with no `to`. */
13
+ export function dropped(changes = []) {
14
+ return changes.flatMap(({ from, to }) => (to === undefined && from !== undefined ? [from] : []));
15
+ }
16
+ /** The changes that alter an object in place, which only a column can. */
17
+ export function alterations(changes = []) {
18
+ return changes.flatMap(({ from, to }) => (from === undefined || to === undefined ? [] : [{ from, to }]));
19
+ }
20
+ /** `items`, or nothing where it has none, so an empty change list is left off a diff. */
21
+ export function nonEmpty(items) {
22
+ return items.length ? items : undefined;
23
+ }
24
+ /** `change` undone: an add becomes a drop, a drop an add, and an alter runs the other way. */
25
+ export function swap({ from, to }) {
26
+ return { from: to, to: from };
27
+ }
28
+ /** `diff` undone: every change swapped, which is what a migration's `down` runs. */
29
+ export function reverseDiff(diff) {
30
+ return {
31
+ ...diff,
32
+ primaryKey: diff.primaryKey && swap(diff.primaryKey),
33
+ columns: diff.columns?.map(swap),
34
+ indexes: diff.indexes?.map(swap),
35
+ foreignKeys: diff.foreignKeys?.map(swap),
36
+ };
37
+ }
@@ -68,18 +68,19 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
68
68
  */
69
69
  private orderedTables;
70
70
  generateDropTable(tableName: string, options?: DropSchemaOptions): string;
71
+ /**
72
+ * The statements taking a table through `diff`, in the one order both directions need: whatever holds
73
+ * something down goes before it and comes back after it. A foreign key holds its columns and the key it
74
+ * points at, so it goes first and comes back last; the key holds its columns; and some engines drop an
75
+ * index along with its column, which would leave nothing to name. An alter is its drop, then its add.
76
+ */
71
77
  generateAlterTable(diff: SchemaDiff): string[];
72
78
  /** `ADD CONSTRAINT` for each of `foreignKeys`. */
73
79
  private addForeignKeyStatements;
74
- /** `DROP CONSTRAINT` for each of `constraintNames`, the mirror of {@link addForeignKeyStatements}. */
75
- private dropForeignKeyStatements;
76
- /** The `ALTER COLUMN` restating each of `columns`. */
77
- private alterColumnStatements;
78
80
  /** An index added to a table that may already have rows: its `CREATE`, then what the engine needs after. */
79
81
  private addIndexStatements;
80
- /** `DROP INDEX` for each of `indexes`, the mirror of {@link addIndexStatements}. */
81
- private dropIndexStatements;
82
- generateAlterTableDown(diff: SchemaDiff): string[];
82
+ /** A column added to a table that exists, and its comment where the engine keeps one apart. */
83
+ private addColumnStatements;
83
84
  generateCreateIndex(tableName: string, index: IndexSchema, options?: {
84
85
  ifNotExists?: boolean;
85
86
  }): string;
@@ -96,8 +97,8 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
96
97
  /**
97
98
  * The one place a column definition is spelled, so the `ColumnSchema` and `ColumnNode` paths cannot
98
99
  * drift. A key column states `NOT NULL` rather than leave it to the key: SQLite lets a key column hold
99
- * NULL otherwise, and SQL Server adds no key over a nullable column. `UNIQUE` is left to the key. An
100
- * enum's `CHECK` comes last, the only place MariaDB takes it.
100
+ * NULL otherwise, and SQL Server adds no key over a nullable column. Never `UNIQUE`: a unique column is
101
+ * a unique index, which the table creates beside it. An enum's `CHECK` comes last, the only place MariaDB takes it.
101
102
  */
102
103
  private renderColumn;
103
104
  /**
@@ -124,10 +125,8 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
124
125
  protected diffOptions(): DiffOptions;
125
126
  /** Spread, not copied field by field, so a field the node gains cannot go missing here. */
126
127
  private columnNodeToSchema;
127
- /**
128
- * Compare two default values for equality
129
- */
130
- protected isDefaultValueEqual(current: unknown, desired: unknown): boolean;
128
+ /** Whether a column's stored default is the one the entity declares, as this engine reprints it. */
129
+ readonly defaultsEqual: (desired: unknown, current: unknown) => boolean;
131
130
  generateCreateTableFromNode(table: TableNode, options?: {
132
131
  ifNotExists?: boolean;
133
132
  }): string[];
@@ -186,4 +185,4 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
186
185
  * The entities as an AST, named by `generator`'s resolvers rather than a naming strategy, which would
187
186
  * also rename an explicit `@Entity({ name })` and so compare each table under another name.
188
187
  */
189
- export declare function buildEntityAST(generator: Pick<SchemaGenerator, 'resolveTableAlias' | 'resolveSchema' | 'resolveColumnName' | 'compileDdl' | 'compileIndexPredicate'>, entities: readonly Type<object>[], options?: Pick<BuildSchemaASTOptions, 'defaultForeignKeyAction' | 'textScoreIndexes'>): SchemaAST;
188
+ export declare function buildEntityAST(generator: Pick<SchemaGenerator, 'resolveTableAlias' | 'resolveSchema' | 'resolveColumnName' | 'compileDdl' | 'compileIndexPredicate'>, entities: readonly Type<object>[], options?: Pick<BuildSchemaASTOptions, 'defaultForeignKeyAction' | 'textScoreIndexes' | 'vectorIndexRequiresNotNull'>): SchemaAST;