Files
Termix/docs/database-backends.md
T
ZacharyZcR 975b271a11 resolve the dialect in the repository factory instead of assuming sqlite (#1143)
createCurrentRepositoryContext() hardcoded `dialect: "sqlite"` while the
runtime already carried all three engines. That field is not decoration:
returning.ts reads it to decide whether it can ask for RETURNING, and
whether an upsert spells itself onConflictDoUpdate or onDuplicateKeyUpdate.
Reporting sqlite while connected to MySQL means the first upsert calls
onConflictDoUpdate on a mysql2 insert builder, which does not have it --
a TypeError, not a rejected query, as the note in returning.ts warned.

So MySQL never worked outside the tests, and Postgres worked only because
it also supports RETURNING and shares the conflict syntax.

Three things were supposed to catch this and none could. The repository
suite builds its own DatabaseContext in test-support.ts, verify-dialects.mjs
builds its own, and the CI matrix runs both against real Postgres and MySQL
containers -- all of them bypassing the one function the application calls.
Green on three engines, broken on two.

Resolve it from the environment, and test the factory itself rather than a
hand-built context: the default, each configured dialect, the write hook it
installs only for sqlite, and that an unsupported value throws rather than
falling back. Reverting the fix fails two of them.

Fixes Termix-SSH/Support#282
2026-07-31 16:42:57 +08:00

241 lines
10 KiB
Markdown

# Database backends
Termix runs on SQLite by default. Postgres and MySQL are supported for
self-hosted deployments; this document records how the three differ, because the
differences are not only about SQL.
## This is multi-backend, not a migration
SQLite is not going away. The desktop app embeds its own backend and cannot ship
a database server, so it will always run on SQLite. Postgres and MySQL exist for
self-hosted deployments that need more than one process to reach the data —
multiple replicas, an external backup story, or an existing database estate.
Anything that assumes a single engine is wrong.
## Where the schema comes from
`src/backend/database/db/schema.ts` is the single source of truth, written
against `drizzle-orm/sqlite-core`.
`schema.pg.ts` and `schema.mysql.ts` are **generated** from it:
```bash
npm run schema:generate # rewrite the generated modules
npm run schema:check # fail if they are out of date (runs as part of lint)
```
Never edit the generated files. `npm run lint` fails if they drift from the
source, so a schema change that forgets to regenerate cannot reach main.
The transforms are mechanical:
| sqlite | postgres | mysql |
| ------------------------------------------------ | ----------------- | ----------------------- |
| `integer(…, { mode: "boolean" })` | `boolean` | `boolean` |
| `integer(…).primaryKey({ autoIncrement: true })` | `serial` | `int().autoincrement()` |
| `integer` | `integer` | `int` |
| `real` | `doublePrecision` | `double` |
| `text` used as a key | `varchar(255)` | `varchar(255)` |
A column becomes `varchar` if it is a primary key, is unique, or sits on either
end of a foreign key — MySQL cannot index an unbounded `TEXT`, and both sides of
a foreign key must agree.
## Durability
On SQLite the database is loaded into memory and serialised back to an encrypted
file, so every write needs an explicit flush. That is what the `onWrite` hook
each repository receives is for.
On Postgres and MySQL a committed write is already durable. No hook is installed
at all — see `needsExplicitPersist` in `db/dialect.ts`.
## Encryption: what changes, and what does not
This is the part most likely to be misread, so it is spelled out.
### Unchanged on every backend
**Field-level encryption still applies.** Credentials and other sensitive values
are encrypted in the application before they reach the database, under a
per-user data key:
- `ssh_data` — passwords, private keys, key passphrases, sudo/RDP/VNC/Telnet
secrets
- `ssh_credentials` — passwords, private and public keys
- `users` — TOTP secret and backup codes
- `vault_tokens`, `opkssh_tokens`, `termix_identity_ca` — certificates and keys
- `shared_host_secrets` — re-encrypted per recipient
Installation-level secrets — the OIDC client secret and LDAP bind password —
are encrypted under the system key, since they have no owning user and must be
readable during login.
This is the protection that matters most, and it is identical on all three
engines.
### Different on Postgres and MySQL
**Whole-file encryption does not exist.** On SQLite the database file itself is
encrypted at rest. There is no equivalent for a client-server engine: the data
lives in the server's storage, not in a file Termix owns.
Concretely, on Postgres/MySQL the following are readable by anyone with database
access, where on SQLite they were covered by the file encryption:
- host names, addresses, ports and usernames
- folder and snippet names, and **snippet contents**
- audit log entries
- session recording metadata and paths
- user names, roles and API key hashes
None of these are credentials — those stay encrypted — but together they
describe your estate.
**If you run Postgres or MySQL, encryption at rest is your responsibility**:
transparent data encryption, an encrypted volume, or an encrypted filesystem.
Termix does not provide it and cannot.
### Threat model, side by side
| | SQLite | Postgres / MySQL |
| ----------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------ |
| Stolen database file / volume | credentials encrypted, everything else encrypted | credentials encrypted, **rest depends on your storage encryption** |
| Database access without app access | credentials unreadable | credentials unreadable |
| Application compromise while a user is unlocked | that user's secrets readable | same |
| Backups | inherit file encryption | **plain unless you encrypt them** |
The second row is the point of field-level encryption, and it holds everywhere.
The first and last rows are where the backends genuinely differ.
## Running on Postgres or MySQL
Two variables. Unset, nothing changes and SQLite is used exactly as before.
```
DATABASE_DIALECT=postgres
DATABASE_URL=postgres://user:password@host:5432/termix
```
```
DATABASE_DIALECT=mysql
DATABASE_URL=mysql://user:password@host:3306/termix
```
`mariadb://` is accepted for MySQL. The scheme is checked against the dialect
before a connection is attempted, so a mismatch fails with a readable message
rather than a driver error deep in a stack.
Point it at an **empty** database. Migrations are applied at startup, from
`drizzle/postgres` or `drizzle/mysql`, and drizzle records what it has applied —
so several instances against one database are safe, and so is restarting.
There is no migration path from an existing SQLite database. Exporting one and
importing it into Postgres is not something this branch does.
### Docker
`drizzle/` ships in the image. A compose service needs only the two variables:
Added to the compose file in the README, that is one service and two variables:
```yaml
services:
termix:
image: ghcr.io/lukegus/termix:latest
environment:
PORT: "8080"
DATABASE_DIALECT: postgres
DATABASE_URL: postgres://termix:termix@db:5432/termix
depends_on:
- db
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: termix
POSTGRES_PASSWORD: termix
POSTGRES_DB: termix
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
```
`DATA_DIR` is still used for uploads and recordings on every backend. Only the
database itself moves.
## What is verified, and how
`npm run verify:dialect -- <url>` applies the migrations to an empty database and
drives the real repository classes against it, asserting values rather than the
absence of exceptions.
The repository test suite also runs against each engine:
```
TEST_DIALECT=postgres TEST_DATABASE_URL=<url> npx vitest run \
src/backend/tests/database/repositories --no-file-parallelism
```
CI runs both, against PostgreSQL 16 and MySQL 8 service containers. Eighteen
tests assert on bytes stored by the SQLite driver and skip on other engines;
they still run in the SQLite pass.
Tested against PostgreSQL 16 and MySQL 8. **MariaDB is not a substitute for
MySQL when testing** — it accepts DDL that MySQL 8 rejects, which has hidden a
real defect here more than once.
### What neither of them covers
Both harnesses build a `DatabaseContext` of their own, so neither runs
`createCurrentRepositoryContext()` — the one the application actually uses.
That gap hid a hardcoded `dialect: "sqlite"` in it: every engine reported
itself as SQLite at runtime while all three test passes stayed green, which on
MySQL meant `upsert` reached for `onConflictDoUpdate` and died with a
TypeError on the first write.
Anything the factory decides from the dialect needs its own test against the
factory. Asserting it through a hand-built context proves nothing about what
runs in production.
## Known limits
- The desktop app always uses SQLite. It embeds its own backend and cannot ship
a database server.
- Repositories import the SQLite table definitions on every engine. That is
correct — the query builder needs identifiers and value encoders, and those
agree — but it means `PortableDatabase` is a named approximation rather than a
guarantee. See `repositories/database-context.ts`.
- `getCurrentSettingValue` is a synchronous read. On Postgres and MySQL it comes
from a cache primed at startup and kept current by `SettingsRepository`,
because those drivers have no synchronous query.
That cache is per-process, so on a **multi-replica** deployment a setting
changed on one instance does not reach the others through the write path. Each
replica re-reads the settings table every 30 seconds
(`SETTINGS_CACHE_REFRESH_SECONDS`, 0 to disable), which does not make settings
immediately consistent — it bounds how long they can disagree. Changing a
setting takes effect on the replica that made the change at once, and on the
others within the interval.
- **Importing a backup is SQLite-only.** The restore writes tables in an order
that is not dependency-safe and relies on `PRAGMA foreign_keys = OFF`, which
has no equivalent here: Postgres needs superuser to disable triggers, and
MySQL's session-scoped switch is not guaranteed across a pool. It refuses with
a message rather than failing partway through and leaving a half-restored
database. Restore into Postgres or MySQL with their own tooling.
- **`LIKE` is case-insensitive on SQLite and case-sensitive on Postgres.** The
four places that use it match folder path prefixes and settings keys, so the
practical effect is that renaming a folder `prod` on SQLite also catches
`PROD / api` and on Postgres does not. Postgres is arguably the more correct
of the two; nothing was changed to make them agree, because that would alter
SQLite behaviour for existing deployments.
- The SQLite-era data migrations — legacy shared-credential cleanup, the
shared-host-secrets rebuild, per-user field-encryption backfill — do not run on
the other engines. A database created by the drizzle migrations never had the
shapes they repair.