# Changelog — @zerotal/orm

All notable changes to this package are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/); this package
follows the Zerotal monorepo's unified versioning.

**Maturity: `stable`**

## [Unreleased]

## [1.15.1] — 2026-09-13

### Fixed

- **A model with a primary key other than `id` could not be updated, deleted or
  reloaded.** `static primaryKey = "uuid"` (and the `@(table("…").primaryKey("…"))` chain)
  configured the column name everywhere the key was written into SQL, but the value bound
  against it came from `this.id` — a property such a model never has. Every statement
  therefore went out as `WHERE uuid = NULL`: `save()` on a loaded instance and `delete()`
  each matched zero rows and returned as though they had written, `refresh()` and `fresh()`
  threw `ModelNotFoundError` for a row that was sitting in the table, and `increment()`,
  `decrement()`, `loadCount()` and the relation aggregate loads no-opped or reported zero.

  The insert was worse than a silent no-op. It wrote the row correctly, then read it back
  by `last_insert_rowid()` / `LAST_INSERT_ID()`, which answer with a rowid that no `TEXT`
  key ever equals — so the re-read matched nothing and the instance came back with no
  timestamps, no database defaults, and still marked as not yet resident. The next `save()`
  on it was a second INSERT and a duplicate-key error rather than the UPDATE the caller
  asked for.

  Every one of those paths now reads the key by its declared name, preferring the loaded
  value so reassigning a key updates the row it was read from. An insert whose key the app
  minted skips the generated-id round-trip and reads the row back by the key it just wrote.
  `replicate()` drops the declared key instead of only `id`, so the copy is insertable.
  A write against an instance whose key is genuinely absent — a row hydrated by
  `select("title")`, say — now throws `E_NO_PRIMARY_KEY` instead of binding NULL and
  looking like a success.

- **A primary key the app mints was stripped from the INSERT.** `id` was treated as
  database-generated unconditionally, so a table whose key is a `TEXT id` the application
  supplies took the value on the instance, dropped it from the statement, and stored NULL
  while the in-memory model went on holding the value it thought it had written. The key is
  now omitted only when it has no value, which is what leaves AUTOINCREMENT to the database.

- **Hardening: a MySQL adapter that offers `reserve()` may still refuse it.**
  `LAST_INSERT_ID()` is scoped per connection, so a MySQL insert pins its INSERT and the
  SELECT that reads the id to one connection, reserving one where the adapter supports it
  and falling back to a short transaction where it does not. The check was
  `typeof conn.reserve === "function"`, and presence is not support — an adapter can expose
  the method and throw `"This adapter doesn't support connection reservation"` when it is
  called, which is what Bun's SQLite adapter does.

  The reservation is now attempted and the transaction path used when it refuses. Only the
  acquisition is guarded: a failure inside the insert is a real error and is not retried
  against a second connection. `try`/`catch` rather than `.catch()`, since an adapter that
  refuses may throw synchronously.

  No released version was affected — this path was only reachable through a test that had
  leaked a dialect (see below). Changed because the detection was wrong either way.

- **A dialect-coverage test left the ORM in MySQL mode for the rest of the process.**
  `server.smoke.test.ts` sets four globals in `beforeAll` and restored three — the
  BaseModel dialect stayed on whatever server it had just tested. Every later file in that
  process then took the MySQL insert branch, which reads the new row's id with
  `LAST_INSERT_ID()`; against the SQLite connection those tests actually use, that is
  `no such function`.

  Test-only, and it never reached a release: the leak needs `ZT_MYSQL_URL` set, which
  happens in one CI job. Postgres hid it for months — its branch uses `RETURNING`, which
  SQLite also supports — so only the MySQL job could go red, and only when the file order
  put the smoke test first. Bun walks test files in directory order, which is arbitrary on
  Linux, so "when" was a coin flip on every run.

## [1.15.0] — 2026-09-04

### Changed — **BREAKING**

- **BREAKING: `createdAt`, `updatedAt` and `deletedAt` are `Carbon`, not `Date`.** Every
  `datetime` column hydrates as `Carbon`, these three included — so the declared type was
  wrong on a freshly loaded row, and `order.createdAt.getTime()` type-checked and threw,
  because `Carbon` has `toISOString()` and `valueOf()` but no `getTime()`.

  Worse, the runtime class depended on how you got there: `Carbon` after a load, a raw
  `Date` after a `save()`, a `touch()`, or a soft `delete()` wrote one straight back onto
  the instance. The same property on the same model, matching its declared type in neither
  case, and with no `@column` declaration to check the truth against.

  All three are now `Carbon` everywhere, declared and at runtime. Code calling a `Date`
  method on them was already failing at runtime and now fails to compile; `.toISOString()`
  gives a string, `.valueOf()` epoch milliseconds, and `.toDate()` a native `Date`.

  Compile and follow the errors — every one is a line that would have thrown against a
  loaded row. See the [Upgrade Guide](/docs/upgrade#114-to-115).

## [1.12.0] — 2026-08-31

### Changed — **BREAKING**

- **A boolean written to a column declared to hold text is refused.** A bare
  `@column()` resolves to `{ type: "string" }`, so a boolean property decorated with
  one was stored as text — and a text column has text affinity, so `false` was stored
  as `"0"`, which is truthy in JavaScript. Every `if (model.flag)` took the wrong
  branch for a stored `false`, on every row, with nothing in the app or the database
  registering a fault. An app found it when a feature flag read as enabled for every
  record that had it turned off.

  There is no correct coercion — `0` becomes `"0"` and `"false"` is truthy too — so
  the value cannot survive the round trip and the write raises `ColumnTypeError`
  instead, naming the property and the fix: `@column("boolean")`.

  The decorator cannot infer it for you. `declare active: boolean` erases the
  TypeScript type at runtime, so a bare `@column()` on a boolean is indistinguishable
  from one on a string until a value arrives. Declaring the type is the only signal
  there is.

  An explicit `@column({ type: "string", cast: "boolean" })` is honoured — that is
  someone saying what they meant. The guard is for the column that says nothing.

  **This release stops new bad writes; it does not migrate old rows.** See
  [the upgrade guide](https://zerotal.dev/docs/upgrade#1-11-to-1-12).

### Added

- **`ColumnTypeError`**, for the above.

## [1.11.0] — 2026-08-31

### Changed

- **BREAKING: SQLite enforces foreign keys.** `database.sqlite.foreignKeys` now
  defaults to `true`, so `PRAGMA foreign_keys = ON` is set on every connection.
  Until now SQLite ignored them, which made `constrained()` and `cascadeOnDelete()`
  in a migration a statement of intent the database would not perform — deleting a
  parent left its children, silently, and an app's data-erasure path missed three
  tables because of it.

  **What can break:** a child row whose parent is missing was legal without
  enforcement and is a violation with it, so a write touching one now fails. Run
  `zt db:check-foreign-keys` before deploying — it lists every offending row by table
  and rowid and exits non-zero, so a release script can gate on it. `zt doctor`
  reports the same. To take the release without dealing with it yet, set
  `sqlite: { foreignKeys: false }`, and remove the override afterwards; with it in
  place `cascadeOnDelete()` does nothing. See
  [the upgrade guide](/docs/upgrade#1-10-to-1-11).

- **BREAKING: a renumbered migration is refused rather than re-run.** A migration is
  recorded under its filename, so renaming one makes an applied migration look
  pending — the runner tried it again and failed on `table already exists`, which is a
  failed boot with an error naming a table rather than the rename. An app renumbered
  `001_` to `0001_` to match this framework's own scaffold convention and would have
  made all nine of its production migrations look unrun.

  `migrate` now recognises a pending migration whose name matches a recorded one once
  the leading digits are stripped, and stops with both spellings and the fix. It
  refuses rather than warns because the alternative is running it, and running it is
  the outage.

### Added

- **`Migration.id`** — a declared identity that decouples a migration from its
  filename. Set it to what the database already recorded and the file is free to be
  renamed. Deliberately not a content hash: a migration's content is edited far more
  often than its name, and a hash would make every edit look like a new migration.

- **`zt db:check-foreign-keys`** — lists the rows enforcement would reject, by table
  and rowid, and exits non-zero when there are any.

### Changed

- **`@column({ type: "integer" })` compiles.** `ColumnShorthand` has twelve names and
  `ColumnOptions["type"]` had six, so `@column("integer")` worked and
  `@column({ type: "integer", default: 0 })` did not — the vocabulary shrank by half
  exactly when a caller needed `default`, `nullable` or `unique`, which is most real
  columns. The error listed the six without mentioning that `integer` resolves to
  `{ type: "number", cast: "integer" }`, so the way out was reading `SHORTHAND_MAP` in
  the source.

  The object form now takes either vocabulary and resolves a shorthand the way the
  string form does. An explicit `cast` alongside one still wins: somebody who wrote
  both meant the one they spelled out.

### Fixed

- **Migration names no longer carry the platform that recorded them.** `Bun.Glob`
  yields native separators, and the prefix strip was a forward-slash-only pattern —
  so on Windows the whole joined path went into the `migrations` table. An app found
  `database\migrations\010_add_tenant_limits` in ten dev rows and
  `010_add_tenant_limits` in production, from the same files.

  A database moved between the two re-runs every migration: every recorded name
  misses, all of them look pending, and the first fails on `table already exists` —
  a failed boot, not a graceful skip. The name is now the bare filename on both.

  Note the half this does _not_ fix: the recorded identity is still the filename, so
  renaming a migration makes it look unrun. The same app nearly took an outage
  renumbering `001_` to `0001_` to match this framework's own scaffold convention.

### Added

- **`database.sqlite.foreignKeys`**, and a `zt doctor` check for the schemas that
  need it. **SQLite ignores foreign keys unless the connection asks it not to**, so
  `constrained()` and `cascadeOnDelete()` in a migration described behaviour the
  database would not perform: deleting a parent left its children, silently, and
  every child had to be removed by hand in the right order by code that remembered
  to. An app's data-erasure path swept fifteen tables and missed three — including
  both holding uploaded files — so an erasure left the paperwork on disk.

  Off by default, because turning it on can fail writes an existing database already
  permits: a row whose parent is missing is legal without enforcement and a violation
  with it. Run `PRAGMA foreign_key_check` before enabling on an existing database. The
  doctor check warns only when the schema actually declares a foreign key, so an app
  with none hears nothing.

## [1.9.0] — 2026-08-29

### Changed

- **INTERNAL: 41 framework-wiring exports are marked `@internal`.** The connection and context
  plumbing (`_getConnection`, `setConnectionResolver`, `useOrmContext`, `createReadWriteRouter`,
  `registerImplicitBinding`, …), the schema-diff machinery behind `migrate:generate` and
  `synchronize` (`SchemaDiffer`, `SchemaInspector`, `ModelInspector`, `synchronizeSchema`, and
  their result shapes), and the dialect layer (`SqliteDialect`, `PostgresDialect`,
  `MysqlDialect`, `getDialect`).

  **Nothing is removed and nothing breaks** — they are still exported and still work. What
  changes is the promise: they leave the recorded API surface, because an app never calls any of
  them, and the only callers outside `@zerotal/orm` are `@zerotal/testing`, `@zerotal/tenancy`
  and `@zerotal/arch` wiring themselves in.

  Everything an app does use is unaffected, and the parts of it that were undocumented are now
  documented: the relation types and option shapes in
  [Relationships](/docs/orm/relationships#types), and the column builders plus `MigrationRunner`
  in [Migrations](/docs/migrations#types).

- **`doctor` warns when migrations have not run**, and names them. The development error
  overlay already answers this after a request has failed with `no such table`; the check asks
  it before anything breaks, which is the cheaper moment to hear it. A warning rather than a
  failure, because pending migrations are the ordinary state of a checkout that just pulled,
  and a doctor that fails there is one people learn to ignore.

### Added

- **`zt db:backup`** — a verified snapshot of the SQLite database. SQLite is the default
  driver, which makes the database one file and makes `cp` look like a backup; it is not one,
  because copying a live database can capture a half-written page and produce a file that
  restores as corrupt, months later, from the one file you were relying on. This uses
  `VACUUM INTO`, which takes a read lock and writes a complete database while the server
  keeps serving, and needs no `sqlite3` binary on the box.

  Every snapshot is opened and integrity-checked the moment it is written, and every failure
  path exits non-zero — a backup job that reports success while writing nothing buys the
  confidence without the file. `--require-rows` names the tables whose loss would end the
  business and fails when the snapshot has none of them; `--rehearse` performs the actual
  restore, because a backup nobody has restored is a hope. A snapshot that fails any check is
  removed rather than left in the retention directory, where it would be indistinguishable
  from a good one and, being newest, would push a verified older one out on the next prune.
  Retention (`--keep`) only ever touches files this command wrote.

## [1.7.4] — 2026-08-21

### Fixed

- **A string column could not carry an index on MySQL.** `table.string()` compiled to `TEXT`
  on every engine and discarded its `length` argument — the parameter existed and was
  documented as "accepted for multi-DB compatibility", wired to nothing. MySQL refuses to key
  a TEXT column without a prefix length, so `table.string("email").unique()` failed at
  `CREATE TABLE`:

  ```text
  BLOB/TEXT column 'email' used in key specification without a key length
  ```

  Any natural key — an email, a slug — was unusable on MySQL, and `index()` the same. The
  storage type now comes from the dialect, beside `booleanType` and `autoIncrementColumn`:
  MySQL gets `VARCHAR(length)`, while SQLite and PostgreSQL keep `TEXT`, which PostgreSQL
  indexes happily. `char()` had the identical bug and the identical fix.

  Found by the new MySQL smoke suite on its first run against a real server.

## [1.7.3] — 2026-08-20

### Fixed

- **A boolean column could not hold a boolean on PostgreSQL.** `table.boolean()` compiled to
  `INTEGER` on every engine — correct on SQLite, which has no boolean type, and rejected outright
  by PostgreSQL: `column "…" is of type integer but expression is of type boolean` (42804) on the
  first insert, and again on any `where(column, true)`. `DEFAULT` clauses failed the same way, a
  boolean default having been serialised to `1`. The storage type now comes from the dialect, as
  the auto-increment column already did. SQLite and MySQL are unchanged; existing PostgreSQL
  tables keep their integer columns until a migration alters them. Found by the new smoke suite
  that runs the ORM against a real PostgreSQL in CI.

## [1.7.2] — 2026-08-18

### Fixed

- **A seeder that failed partway left its rows behind.** `Seeder.call()` has always wrapped
  _composed_ seeders in a transaction, so a `DatabaseSeeder` that delegates was atomic and one that
  does its work inline — which is most of them — was not. A failure on the fourth table committed
  the first three, so the obvious next move, running it again, died on a unique constraint, and the
  only way out was `migrate:fresh`. Migrations became transactional in 1.7.0; this closes the
  asymmetry.

  `db:seed` now wraps the whole run. Nesting is safe — `DB.transaction` opens a `SAVEPOINT` when one
  is already open, so an inner `call()` still rolls back independently. The wrapper is skipped when
  no connection is bound, because a seeder is not obliged to touch the database and an app that has
  not configured one should not fail to seed over a transaction it never needed.

- **`DatabaseProvider` now runs in `worker`, so `zt queue:work` can boot.** It did not, and the
  consequence was total rather than partial: `QueueProvider` _does_ run in `worker`, the
  queue's own default driver is `sqlite`, and so the worker asked for a connection this
  provider had not made and died on startup —

  ```
  error: [Zerotal ORM] No database connection. Is DatabaseProvider registered?
  ```

  — while it plainly was registered.

  It was never only the queue. Nine providers run in `worker` — notifications, audit, media,
  tenancy, scheduler among them — and a job exists to do work with models. `AuthProvider` and
  `SessionProvider` are absent from `worker` correctly, having neither a request nor a
  session; the ORM being absent was an oversight, dating to 1.0.2.

  Found building the first cookbook app, whose first queued job could not run.

## [1.7.1] — 2026-08-16

### Fixed

- **Relation keys now accept the JS spelling, like every other identifier.** The convention is
  camelCase in the application and snake_case in the database, converted on the way through —
  and relation keys were the one place it did not happen. `@hasMany(() => Issue, { foreignKey:
"projectId" })` type-checked and then emitted `no such column: issues.projectId`, with the
  error naming a column rather than the relation that produced it.

  `_relationSubquery()` builds its subquery with a plain `QueryBuilder`, and the `_column()`
  hook that converts is an override on `ModelQueryBuilder`, so nothing was converting these.
  The keys are now converted where they are qualified, which covers `withCount`, `withSum`,
  `has`/`whereHas` and `withExists` for `hasMany`, `belongsTo`, `manyToMany` and the morph
  relations. Both spellings resolve to the column, so apps already passing `project_id` are
  unaffected.

  Found building the first cookbook app, where a project list would not count its issues.

## [1.7.0] — 2026-08-16

### Fixed

- **Migrations are now actually transactional.** The runner wrapped each `up()` in
  `begin()` and the docblock promised all-or-nothing, but the wrapper governed nothing:
  `Schema` resolved the _global_ connection, so the migration's DDL ran on a pooled
  connection and committed independently of the transaction around it. On PostgreSQL a
  migration that failed on its third statement left the first two behind, and the enclosing
  `ROLLBACK` had nothing to undo.

  Three changes close it. `Schema` resolves the enclosing transaction when there is one
  (new `_getScopedDbConnection`), so DDL issued inside `DB.transaction()` joins it —
  migrations included. The tracking-table insert moved _inside_ the transaction, because
  recording after the commit leaves a window where the schema has moved and nothing says so,
  and the next deploy re-runs the migration against a schema it already changed. And
  rollback got the same treatment: a `down()` that fails part-way now undoes nothing rather
  than leaving the schema and the tracking table disagreeing.

  This was invisible to the test suite by construction — `new SQL(":memory:")` is a single
  handle, so the "global" and transaction connections are the same object and DDL joined the
  transaction by accident. The new tests use a fake that keeps them distinguishable.

### Added

- **`MigrationRunner.willRollBackOnFailure`** and `SqlDialect.supportsTransactionalDdl`.
  MySQL and MariaDB implicitly commit on every DDL statement, so a transaction around a
  migration there is a promise that cannot be kept — the runner no longer opens one, and
  `bun zt migrate` warns before it starts rather than after something breaks. PostgreSQL
  and SQLite report `true`.

- **Two DevTools tabs the ORM already had the data for.** `ModelChanged`,
  `TransactionCommitted` and `TransactionRolledBack` were on the framework event bus and went
  nowhere: a request that wrote four rows and one that wrote none looked identical in the
  panel, and a transaction that rolled back showed only as queries that appeared to succeed.

  The observability bridge now declares a **Models** channel (grouped per model) and a
  **Transactions** channel (marking a rollback as a warning, with its reason). Both are
  declared as data, so DevTools ships no ORM-specific code — and both are skipped entirely
  when DevTools is not installed, as every other bridge here is.

## [1.6.0] — 2026-08-15

### Fixed

- **Auto-`synchronize` was never hard-off in production.** The guard compared
  `APP_ENV` against `"production"`, but `setAppEnv()` replaces that with the runtime
  mode before the app boots — so it read `"web"` and never fired. The only thing standing
  between a production database and boot-time schema sync was the config default in
  `config/database.ts`. It reads `deployEnv()` now, and covers `staging` too.

- **`forceState()` did not refuse to run in production.** Its throw exists so a
  state-machine escape hatch cannot be used on live data; the same `APP_ENV` comparison
  meant it never triggered.

- **The N+1 detector's own environment gate never matched**, so the detector returned early
  even in development. (The provider-level gate that installs it was fixed in 1.5.0; this is
  the second gate inside the detector itself.)

## [1.5.0] — 2026-08-15

### Fixed

- **N+1 detection was running in production.** The gate read `Bun.env.APP_ENV`, which
  by the time a provider boots holds the runtime mode (`web`) rather than the
  deployment name — `setAppEnv()` overwrote it. So the check that exists to help in
  development was wrapping every query on live apps, to warn about something nobody
  was there to read. It now asks `deployEnv()`, which is what the deployment name
  survives in.

### Added

- **A missing table now offers to run the migration that would create it.**
  When a query fails because a table or column does not exist, the development
  error page reports which migrations have not run and offers to run them.
  Detection is by driver error code where there is one — `42P01` / `42703` on
  PostgreSQL, `1146` / `1054` on MySQL — and by message on SQLite, which has
  none worth branching on.

  **The half that matters is when it does _not_ offer the button.** With nothing
  pending, running every migration changes nothing and leaves the developer back
  where they started, so instead it says whether any migration on disk even
  mentions the missing name — if none does, the migration was probably never
  written, which is a different problem with a different fix.

  The endpoint behind the button carries three guards, each checked on its own
  rather than inferred from the overlay being dev-only: `devSurfacesEnabled()`
  at request time (which **fails closed** — unlike `!isProdLike()`, an unset
  `APP_ENV` does not qualify), a single-use token minted into the page, and the
  same origin check the raw Flow endpoints use, since a raw route sits outside
  CSRF middleware. Outside development the route is never registered at all.

- **`migrate:refresh`** — the same command as `migrate:fresh`, under the name it has
  elsewhere. Nothing otherwise pushes anyone to run their `down()` methods, and a
  rollback nobody has exercised is a rollback that does not work.

- **`--seed` on `migrate` and `migrate:fresh`.** Wiping a database and repopulating it is
  one thought, and it took two commands — `bun zt migrate:fresh && bun zt db:seed` — with
  the second easy to forget and nothing to remind you. The flag closes that:

  ```bash
  bun zt migrate:fresh --seed        # rebuild the schema, then seed it
  bun zt migrate --fresh --seed      # the same thing
  bun zt migrate --seed              # apply pending migrations, then seed
  ```

  `migrate --seed` seeds even when nothing was pending, because topping up an
  already-current dev database is a normal reason to run it.

  A seeding failure is reported but does not fail the command. The migrations above have
  already committed by then, and exiting non-zero would suggest the whole operation needs
  repeating when only the seeders do — so the output says the schema was rebuilt and
  points at `bun zt db:seed` for the retry.

  The seeder-loading logic is now shared with `db:seed` rather than duplicated, so all
  three commands accept the same shapes: a class-based `DatabaseSeeder` (named or default
  export) and the legacy `database/seeders/index.ts` default function.

### Changed

- **The N+1 detector reads the bindings, not just the SQL text.** Grouping by SQL alone
  made a legitimate loop over six months — identical SQL, a different `period` each
  time — indistinguishable from a per-row lookup, so it told you to eager-load a
  relation that does not exist. The warning now says which of the two it found: _same
  SQL, different arguments_ points at eager loading or `whereIn`; _same SQL, same
  arguments_ points at `RequestContext.remember()`, because there is nothing to
  eager-load when the answer never changes. `NPlusOneError.distinctArgs` carries the
  count.

### Fixed

- **A `Date` in a query-builder write is no longer silently discarded.**
  `update({ read_at: new Date() })` bound the `Date` object straight through; SQLite
  dropped it and **reported no error**, so a "mark all as read" feature shipped as a
  latent no-op whose source read correctly. The asymmetry made it easy to write, too —
  `model.save()` applies casts, so the identical value through a model worked. Dates
  and `Carbon` instances are now serialised at the single point every bind passes
  through, dialect-aware (MySQL DATETIME rejects ISO 8601's `T`/`Z`), which covers
  `update`, `insert`, `where` and every builder at once. The comparison path had
  already learned this lesson separately; now there is one place it lives.

- **`foreignId(...).nullable().constrained()` type-checks.** `nullable()` returned
  `ColumnBuilder`, so the chain left `ForeignIdColumnBuilder` and `.constrained()` was
  gone — the form the class's own docblock documents, and the first one anyone reaches
  for, since a nullable foreign key is the commonest kind. The two modifiers now
  preserve the subclass while keeping the `nullability` lock, so
  `.nullable().notNullable()` is still a compile error.

- **SQLite refuses an impossible `dropColumn` before applying anything.** SQLite cannot
  drop a column a foreign key still names, and it says so _after_ every earlier
  statement in the same `Schema.table()` block has run — the difference between a
  migration that did nothing and one that has to be unpicked by hand. A `PRAGMA
foreign_key_list` check now runs first and throws a message naming the column, the
  table it references, and the table-rebuild way out. The rebuild itself is still not
  implemented; this makes its absence safe rather than expensive.

- **Altering a Postgres column no longer silently drops its NOT NULL and DEFAULT.**
  The regexes that split a column definition into `ALTER COLUMN` sub-commands
  carried literal backspace characters (0x08) where `\b` word boundaries were
  meant — invisible in any editor, and impossible for either pattern to match. So
  `table.string("email").notNullable().alter()` emitted `DROP NOT NULL`, and a
  declared default emitted `DROP DEFAULT`, on every alter, regardless of the
  definition. Found by the lint ratchet (`no-control-regex`); the statements are
  now pinned by tests, not just the column name.

## [1.4.0] — 2026-08-10

### Added

- **Encrypted columns.** A column can now hold ciphertext at rest and plaintext on
  the model, keyed by `APP_KEY` with AES-256-GCM. Declare it per-column or as a
  list; the two mean the same thing and resolve to the same cast:

  ```ts
  @column("encrypted", { nullable: true }) idNumber?: string;
  @column("encrypted:json") medical?: MedicalInfo;

  // …the same, spelled out — `encrypted` is a cast, not a storage type:
  @column({ type: "text", nullable: true, cast: "encrypted" }) passportNumber?: string;

  static encryptable = ["idNumber", "passportNumber"];
  ```

  `encrypted` and `encrypted:json` join the `@column("…")` shorthands, resolving
  to `{ type: "text", cast: "encrypted" }` — so the storage type is right without
  having to know that ciphertext outgrows its plaintext.

  Encryption happens on the way to the database rather than to the instance, so —
  unlike `hashable` — it is non-destructive: after `save()` the property still
  holds what you assigned. `$dirty` compares plaintext, so an unchanged column is
  not rewritten with a fresh IV on every unrelated save.

  A column listed in `encryptable` whose `@column({ type })` is `json` encrypts as
  `encrypted:json`, so it round-trips as the structure it was instead of reaching
  the cipher as `"[object Object]"`.

  **`where()` on an encrypted column throws** rather than returning nothing. The
  bind path runs a column's cast over the search value, which would encrypt it
  under a fresh IV and compare it against ciphertext written with a different one:
  zero rows, no error, and a screen reading "no such client" for a client who is
  right there. `EncryptedColumnError` says so and points at a blind index.

  **A value the key cannot open fails the read**, naming the model, the column and
  the two causes (a rotated `APP_KEY`, or plaintext that predates the cast).
  Returning the ciphertext instead would put an unreadable value where the
  application expects a real one — displayed, reported on, or re-encrypted by the
  next save, which destroys the original.

  `migrate:generate` and `synchronize()` widen an encrypted column to TEXT
  whatever it was declared as. A payload is ~1.4× the plaintext plus 28 bytes, and
  MySQL outside strict mode truncates rather than failing — a truncated payload
  never decrypts, so the row would be destroyed silently at write time.

## [1.3.0] — 2026-08-09

### Changed — BREAKING

- **`BaseModelWith(...)` is replaced by the `Model.using(...)` static.** Mixin composition is now
  a property of the base class rather than a helper shipped alongside it, so there is one idiom to
  learn and nothing extra to import.

  ```ts
  // before
  import { BaseModelWith } from "@zerotal/orm";
  class User extends BaseModelWith(Authenticatable, Permissions, Roles) {}

  // after
  import { Model } from "@zerotal/orm";
  class User extends Model.using(Authenticatable, Permissions, Roles) {}
  ```

  Run `bun run scripts/codemod-mixin-composition.ts` to rewrite call sites and imports.

  How mixins are **authored** is unchanged — `<T extends Constructor>(Base: T) => class extends Base`
  still works exactly as before, and every shipped mixin (`SoftDeletes`, `State`, `Authenticatable`,
  `Roles`, `Permissions`, `Notifiable`, `Tenantable`, `Auditable`, …) keeps its signature. The
  `Constructor` and `Mixin` types are still exported; `Compose` (the type of `Model.using`) joins
  them. Mixin authors declaring columns still call `registerColumn` imperatively.

  `with` was deliberately **not** used for this. It is reserved for the eager-load static
  (`User.with("posts")`), the one conspicuous gap in the model's existing query-forwarder family
  (`where`, `whereIn`, `orderBy`, `latest`, `first`, `paginate`, `find`, `all`, `count`, …).

### Changed

- **`Model` is now the canonical name for the base class; `BaseModel` is the alias.** They are the
  same class object and both remain exported, so no code breaks — but `class User extends Model {}`
  is the documented form from here, mirroring Flow's `class PostsPage extends Component {}`.
  `BaseModel` was previously the canonical name and `Model` an unused compat alias added in 1.0.2.

### Added

- **`using` composes onto any class in the chain, not just the root.** An app-level base model can
  now carry mixins without being flattened out of the prototype chain — `AppModel.using(SoftDeletes)`
  keeps `AppModel` and its statics in the lineage. `BaseModelWith` hardcoded `BaseModel`, so this
  previously required hand-nesting.
- **Composition chains.** The composed class carries `using` itself, so
  `Model.using(a, b).using(c, d)` works past the 8-mixin overload set — which is why the overload
  set shrank from 20 hand-written arities to 8 without losing any capability.

## [1.1.0] — 2026-08-08

### Fixed

- **A `json` column returns the type it was given.** Writing skipped `JSON.stringify` for values that were already strings, so a string went into the column as bare characters — `62812345678`, not `"62812345678"` — and the read side's `JSON.parse` turned it back into a number. A `json`-cast setting holding an account number came back as a number, and only for _some_ values, since a string that fails to parse fell through unchanged. Encoding is now symmetric in both directions, and `where()` against a `json` column encodes the same way, so a query finds what a write stores. **Upgrade note:** rows written by an older version hold bare scalars, so a string column may still read back as a number, and a `where()` on a string will not match those older rows — they are stored unquoted. Only affects bare scalars in `json`/`array` columns; objects and arrays were always encoded and are untouched.
- **`bun zt make:model` generates a file that parses.** The stub emitted `@table('posts').withTimestamps()`, which is not valid decorator syntax — the grammar allows a call at the end of the chain, not in the middle — so every generated model failed with `Expected "class" but found "."`. The stub now emits plain `@table('posts')`; timestamps are on by default and the chained form needs outer parentheses, `@(table("x").withoutTimestamps())`. The same broken form is corrected in the `BaseModel` docblocks, and every generated stub is now parsed by a test rather than checked for substrings.
- **A column `default` is applied on insert.** A declared field that was never assigned was written as an explicit `NULL`, so the INSERT named the column, the database never applied its own default, and a `NOT NULL` column failed outright — on a model and migration that both declared `default: 0`. `undefined` now means "I didn't say": the declared default is used, or the column is omitted so the database decides. An explicit `null` still stores `NULL`.
- **A `Date` compared against a timestamp column matches again.** Bound values are serialised through the column's cast metadata, but the framework-managed `created_at` / `updated_at` / `deleted_at` carry no `@column` registration — so a `Date` was bound raw and matched nothing. `where("created_at", ">=", monthStart)` is the commonest reporting query there is, and it silently returned zero rows: a dashboard reading "0 this month" looks like a quiet month, not a broken query.

### Added

- `Schema.alter(...)` as an alias of `Schema.table(...)`, and `table.datetime(...)` as an alias of `table.dateTime(...)`. Both are the names other frameworks use, neither was a type error because the blueprint callback is loosely typed, and both therefore failed as a `TypeError` mid-migration — after earlier statements had already run, leaving the schema half-changed.
- `@column("string", { nullable: true })` — a two-argument form. The shorthand keeps its type and cast; the options cannot contradict them.
- `@column({ unique: true })` and `@column({ index: true })`, carried through to both `migrate:generate` and `synchronize`. Uniqueness is usually a correctness property, and it was not expressible at all.
- Generated migrations index any `*_id` column. The reference cannot always be inferred, but the index can, and an unindexed foreign key is a table scan on every join.
- `"text"` is its own storage type rather than an alias for `"string"`, so a real `TEXT` column is expressible — the distinction matters on Postgres and MySQL.

### Changed

- **`create()` narrows its payload to the mass-assignable columns** when a model declares `fillable` as a literal tuple (`as const`). A required column deliberately kept out of `fillable` was demanded by `InsertPayload` and refused by `fill()` at runtime: the type required exactly what the runtime forbade, and there was no spelling of `create()` that satisfied both. Models without a literal list are unaffected.
- `static fillable` / `static guarded` accept `readonly string[]`.
- A migration that fails with "already exists" now says that `database.synchronize` is the usual cause, since the raw driver error names nothing actionable.
- `make:model` generates `fillable` as a literal tuple and documents that a nullable column is declared `?: T | undefined` — under the scaffold's `exactOptionalPropertyTypes`, `?: T` cannot be assigned `undefined`, so the field could never be cleared.

## [1.0.3] — 2026-08-07

### Changed

- Re-released from a rebuilt repository so the build provenance resolves. The
  1.0.2 attestation names a repository that was renamed away, which leaves the
  signature valid but the trace back to source dangling. No code changed.

## [1.0.0] — 2026-08-05

_First public release._

### Notes

- Conforms to the Zerotal package conventions (provider in `src/provider/`, PascalCase config factory, `ZerotalError`-based errors, test coverage).
