diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx
index 8158a8d3f2..af27d75d9b 100644
--- a/content/docs/data-modeling/drivers.mdx
+++ b/content/docs/data-modeling/drivers.mdx
@@ -75,7 +75,7 @@ libSQL data stayed untouched. Pass the token with `--database-auth-token`
| Driver | npm package | Class | Explicit `OS_DATABASE_DRIVER` value |
| :--- | :--- | :--- | :--- |
| **PostgreSQL** | `@objectstack/driver-sql` (peer: `pg`) | `SqlDriver` | `postgres` \| `postgresql` \| `pg` |
-| **MySQL** | `@objectstack/driver-sql` (peer: `mysql2`) | `SqlDriver` | `mysql` \| `mysql2` |
+| **MySQL** | `@objectstack/driver-sql` (peer: `mysql2`) | `SqlDriver` | `mysql` \| `mysql2` (supported, with three dialect caveats — see [below](#mysql-dialect-caveats)) |
| **SQLite** | `@objectstack/driver-sql` (peer: `better-sqlite3`) | `SqlDriver` | `sqlite` \| `sql` |
| **SQLite (WASM)** | `@objectstack/driver-sqlite-wasm` | `SqliteWasmDriver` | `sqlite-wasm` \| `wasm-sqlite` \| `wasm` |
| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` \| `mongo` (single-tenant only — see [below](#multi-tenancy-not-supported)) |
@@ -297,12 +297,25 @@ new SqlDriver({
});
```
-Everything on this page's PostgreSQL section applies unchanged — the same
-`SqlDriver`, the same connect-timeout defaults, the same tenant scoping. One
-behaviour genuinely differs, and it is a limit of the dialect rather than of this
-driver.
+MySQL is a **supported deployment target**, and everything on this page's
+PostgreSQL section applies unchanged — the same `SqlDriver`, the same
+connect-timeout defaults, the same tenant scoping.
-### `upsert` conflict targets: the one dialect limit
+### MySQL dialect caveats
+
+Three behaviours genuinely differ, and each is a limit of the dialect rather than
+of this driver. None of them is a defect, none has an in-dialect fix planned, and
+two of the three have **no workaround at all** except running the object — or the
+platform — on SQLite/PostgreSQL. They are listed here rather than left to a boot
+log because they are worth knowing *before* you choose MySQL, not after.
+
+| Caveat | What MySQL does not give you | Way out |
+| :--- | :--- | :--- |
+| [`upsert` cannot honour a conflict target](#upsert-conflict-targets-the-target-mysql-cannot-honour) | `ON DUPLICATE KEY UPDATE` carries no target, so a merge can land on a UNIQUE key you never named. The driver refuses the call rather than let it. | Keep one UNIQUE key per table, or run the object on SQLite/PostgreSQL. |
+| [Three runtime uniqueness indexes cannot be built](#uniqueness-indexes-mysql-cannot-build) | Database enforcement of three platform integrity guarantees. The weaker index stays in force and every boot logs an `error`. | None in-dialect. Run the platform on SQLite/PostgreSQL for the guarantee. |
+| [A unique violation does not name the column](#the-conflicting-column-is-not-named) | The conflicting **field** in import errors and form-field conflict messages. The `409 UNIQUE_VIOLATION` itself is unaffected. | None. The message falls back to generic copy. |
+
+### `upsert` conflict targets: the target MySQL cannot honour
On MySQL, `upsert(object, data, conflictKeys)` cannot promise that the merge
@@ -392,6 +405,85 @@ cannot have every merge honoured — keep one UNIQUE key per table, or run the
object on SQLite/PostgreSQL.
+### Uniqueness indexes MySQL cannot build
+
+Three platform tables get their uniqueness from an index issued as raw SQL at
+boot by a `metadata-protocol` runtime migration, because the shape each one needs
+cannot be expressed through the declaration surface. Two of the three are
+**partial** indexes (`CREATE UNIQUE INDEX … WHERE state = '…'`), and all three
+use **functional key parts** (`COALESCE(…)`) to fold a nullable column's NULLs
+into one bucket that is unique among itself.
+
+MySQL/MariaDB has no partial indexes at any version, and rejects the functional
+key parts as these statements spell them. So on MySQL none of the three is built,
+and the guarantee each one backs is not enforced by the database:
+
+| Table | The guarantee that is not enforced on MySQL |
+| :--- | :--- |
+| `sys_metadata` | ADR-0005 overlay uniqueness. Package-less rows (`package_id` NULL) stay NULL-distinct and can duplicate, and `getMetaItem` then has no defined answer for which row wins. |
+| `sys_view_definition` | Active-row view-name uniqueness. An archived view keeps occupying its name slot, and two same-name **active** shared views (`owner` NULL) or environment-level views (`organization_id` NULL) can coexist even though the platform states they cannot. |
+| `sys_setting` | NULL-safe row identity. `user_id` is NULL on every row that is not `scope='user'`, so two tenant-scope rows for one `(namespace, key)` in one organization — or two platform defaults on the global layer — can coexist, and `SettingsService` has no defined answer for which one wins. |
+
+What happens instead is the same in all three cases, and it is deliberate. Each
+migration proves the new index is possible under a throwaway probe name *before*
+dropping anything, so a dialect that cannot take it is left holding **exactly the
+index it already had** — never an unconstrained table, and never a failed boot.
+The gap is then announced at `error` level on every boot, along with a query that
+lists any rows already violating the guarantee.
+
+
+**There is no in-dialect fix**, and none is planned — the DDL these migrations
+need does not exist in MySQL/MariaDB. If you need these three guarantees enforced
+by the database, run the platform on SQLite or PostgreSQL. On MySQL, treat the
+boot's `[metadata-protocol] this database cannot build …` lines as expected
+rather than as a failure, and run the duplicate-listing query each one prints to
+find out whether the gap has actually been hit in your data.
+
+
+### The conflicting column is not named
+
+A write that collides with a unique key comes back as `409 UNIQUE_VIOLATION` on
+every dialect, MySQL included — that verdict is not degraded. What differs is the
+follow-up question: *which column* collided.
+
+SQLite and PostgreSQL name the column, so the platform can read it back:
+
+```text
+sqlite UNIQUE constraint failed: sys_user.email
+postgres Key (email)=(acme@example.com) already exists.
+mysql Duplicate entry 'acme@example.com' for key 'idx_email_unique'
+```
+
+MySQL's sentence names the **index**, never the column. There is nothing in it to
+read, so on MySQL the platform answers "not determinable" and the conflicting
+field goes unnamed.
+
+That is an accepted cost rather than an oversight (maintainer ruling 2026-08-08).
+Deriving `email` from `idx_email_unique` would be a guess — index names are
+free-form and a deployment's may match no column at all — and both consumers of
+the answer are worse off with a plausible wrong column than with none:
+
+- The **import runner** renders it into a form field: *"A record with this
+ `email` already exists."* An index name there points the user at a field that
+ does not exist on the object, so they cannot act on it.
+- The **autonumber retry** asks a yes/no question of it — *is the conflicting
+ column the autonumber field?* — where a wrong name produces a wrong retry
+ decision rather than merely a vaguer message.
+
+So on MySQL, expect import errors and form-field conflict messages to fall back
+to generic copy — *"A record with this value already exists"* — instead of naming
+the field. Nothing is misreported; the message is less specific.
+
+
+This is not MySQL-only in principle. SQLite and PostgreSQL also decline to name a
+column when the violation reports an index rather than a column
+(`UNIQUE constraint failed: index 'idx_lower_email'`,
+`violates unique constraint "sys_user_email_key"`), and when the key is
+**composite**, since there is then no single offending column to name. MySQL is
+the dialect where going unnamed is the *normal* outcome rather than the
+exception, because its duplicate-entry message never carries a column at all.
+
+
## MongoDB
Configuration properties for the MongoDB driver.
diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx
index 30a4024f6e..1605388288 100644
--- a/content/docs/deployment/environment-variables.mdx
+++ b/content/docs/deployment/environment-variables.mdx
@@ -49,7 +49,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
|:---|:---|:---|:---|
| `OS_DATABASE_URL` | url | — | Database connection string (e.g. `file:./data.sqlite`, `postgres://…`, `mongodb://…`, `memory://`). `libsql://` / `*.turso.io` (Turso) is inferred too, but its driver is an **optional** package: `npm install @objectstack/driver-turso`, otherwise the boot fails loudly with that command — it never falls back to SQLite. |
| `OS_DATABASE_AUTH_TOKEN` | string | — | Auth token for a libSQL/Turso connection (`--database-auth-token`). The vendor's own `TURSO_AUTH_TOKEN` is read as a fallback and is **not** renamed (see the third-party names note above). Ignored by every other driver — their credentials live in the URL. |
-| `OS_DATABASE_DRIVER` | enum | inferred | Force a specific driver when the URL is ambiguous. `memory` \| `sqlite` \| `sqlite-wasm` \| `postgres` \| `mysql` \| `mongodb` \| `turso`. |
+| `OS_DATABASE_DRIVER` | enum | inferred | Force a specific driver when the URL is ambiguous. `memory` \| `sqlite` \| `sqlite-wasm` \| `postgres` \| `mysql` \| `mongodb` \| `turso`. `mysql` is a supported deployment target that carries three dialect caveats — two of them integrity guarantees MySQL cannot enforce at the database. Read [MySQL dialect caveats](/docs/data-modeling/drivers#mysql-dialect-caveats) before choosing it. |
| `OS_DATABASE_SQLITE_JOURNAL_MODE` | enum | `wal` | Journal mode for **file-backed** SQLite. `wal` (default) lets a dev server and CLI commands share one file without blocking each other, and is what makes the `os migrate` occupancy check reliable. Set to `delete` for SQLite's rollback journal — required when the database lives on a **network filesystem** (NFS/SMB), where WAL cannot work. The setting is applied, not merely skipped: `delete` converts a database that already adopted WAL back. Ignored for `:memory:`, for the WASM SQLite driver, and for non-SQLite drivers. A per-datasource `sqliteJournalMode` in driver config outranks it. See [Journal mode](/docs/data-modeling/drivers#journal-mode-wal-and-cross-process-access). |
| `OS_ALLOW_DRIVER_CONNECT_FAILURE` | boolean | `false` | Escape hatch for the driver-connect boot guard. By default a data driver that fails to connect at startup **refuses the boot** — a server that cannot reach its database must not report itself started and then fail every request. The same guard covers a **declared datasource** that objects bind to via `datasource: '…'`, or an `external` one with `validation.onMismatch: 'fail'`: those objects have no fallback datasource, so an unconnected one means they are all dead. Set to `1` to boot anyway, in an explicitly degraded state logged loudly at startup. There is **no reconnection**: whatever failed stays dead for the process lifetime and every query and schema sync routed to it fails. |
| `OS_STORAGE_LOCAL_ROOT` | path | `./.objectstack/data/uploads` | Root directory for the local file storage adapter, relative to the process cwd (used by `os serve`'s default `storage` capability wiring). This is the same value as **Setup → Settings → File Storage → Root directory**; setting it here pins that field (it shows as locked-by-env). Renamed from `OS_STORAGE_ROOT` — see below. |
diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx
index 4ff3958578..aecb72437a 100644
--- a/content/docs/plugins/packages.mdx
+++ b/content/docs/plugins/packages.mdx
@@ -157,6 +157,7 @@ import { InMemoryDriver } from '@objectstack/driver-memory';
- **Purpose**: Production-ready SQL database support with migrations
- **Supports**: PostgreSQL, MySQL, SQLite, and all Knex-compatible databases
+- **MySQL caveats**: MySQL is supported, with three dialect caveats — `upsert` cannot honour a conflict target, three runtime uniqueness indexes cannot be built, and a unique violation does not name the conflicting column. See [Drivers → MySQL dialect caveats](/docs/data-modeling/drivers#mysql-dialect-caveats)
- **When to use**: Traditional relational database deployments
- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/drivers/driver-sql/README.md)