Skip to content
NewComposite primary keys3 min read

Upgrade guide

Only releases that may require something on your side. Everything else is in the changelog.

An anime warrior with spiky hair straining and glowing with energy as he powers up amid flames

One pair applies the schema, sync and planSync, over one options shape:

Before Now
migrator.autoSync(options) migrator.sync(options)
migrator.syncForce() migrator.sync({ force: true })
migrator.syncEntity(entity, options) migrator.sync({ entity, ...options })

uql-migrate sync --dry-run now prints the statements beside --force instead of ignoring the flag and recreating your tables.

A naming strategy no longer rewrites a table you named yourself: @Entity({ name: 'UserProfile' }) on class UserProfile used to be snake-cased like a default. Entities that name no table are unaffected.

An option a column cannot use is now an error where it used to be silently ignored, at compile time and again when the entity registers. Delete whichever your entities state:

  • length outside a string column, precision, scale and autoIncrement outside a numeric one, dimensions and distance outside a vector.
  • Any DDL or generator option on a virtual field, which is never in the schema.
  • onUpdate beside updatable: false, and nullable: true on a key. nullable: false on a key still compiles: it states what the key already is.
  • defaultValue that is not the value the column holds. A JSON column keeps the SQL literal it stores, defaultValue: '{}'.

Writing a dialect or reading metadata? getSqlType(field) and fieldOptionsToCanonical(options) no longer take a second argument, and columnFamily(type) replaces isNumericType, isBooleanType and isJsonType.

Nothing in your database is renamed or rewritten: keys and indexes are recognised by the columns they cover.

  • Derived names now read <table>__<columns>_<kind>, on new tables only.
  • A migration can change a primary key. SQLite refuses — rebuilding the table is its only route.
  • Adding a key column to a table with rows fails until you fill it in.
  • Writing a dialect? serialPrimaryKey is serialType (the type alone), plus serialDeclaresPrimaryKey.
  • A second @Id composes the key instead of replacing the first, so an entity relying on that gains a column and a two-column PRIMARY KEY. Narrowing an inherited key is unaffected.
  • EntityMeta.id is now ids, a list.
  • Insert and save return IdValue<E> | undefined, as they already did on MySQL.

The pool runs every operation, so a function that writes can take “a querier, or the pool”:

const save = (querier: UniversalQuerier, user: User) =>
querier.saveOne(User, user);
await save(pool, user);
await pool.transaction((querier) => save(querier, user));

Only a hand-written QuerierPool has anything to do: insertMany, updateMany, upsertOne, upsertMany and saveMany are no longer optional.

Decorators are the standard TC39 ones now. No experimentalDecorators, no emitDecoratorMetadata, no reflect-metadata. A codemod does most of it:

Terminal window
npx uql-codemod --dry-run

It adds type to every @Field/@Id and entity to every relation, unwraps Relation<T>, drops declare from decorated fields, and removes the decorator flags. Where the choice is yours it reports instead: @Transactional(), @InjectQuerier(), @Log(), @Serialized(), and a branded id type it writes as String (the column your database already has — 'uuid' needs a migration).

Then, by hand:

  • target must not be esnext (you can set any modern target other than that), the one target TypeScript leaves decorators untransformed.
  • Your build must transform them. esbuild, SWC, Babel version: '2023-11', Bun and tsc do. Oxc does not, so Vite 8 needs one of the others.
  • uql.config.ts needs bun or node --import tsx if it imports entities — decorators are not erasable syntax.
  • NestJS: use defineEntity. Nest’s DI needs parameter decorators, and one tsconfig.json cannot mix specs. See NestJS.
  • Node 24 is the minimum.