Add MySQL support to Lib\Db via multiple simultaneous named connections

Redesigns Lib\Db from a single global connection to a config-driven map
of named connections (config['db_connections']), each independently
lazy-connected, each with its own optional migrations_dir and its own
schema_migrations table. A sidecar can use more than one connection in
the same request (e.g. Db::query(...) against SQLite alongside
Db::query(..., 'legacy') against MySQL) — sidecars have full access to
any Lib\ class, so nothing stops a request from needing two databases
at once, which the original single-driver spec didn't account for.

Db::query()/Db::connection() both default to the 'default' connection
name so the common single-database case is unchanged at the call site.
Supersedes the flat db_driver/db_path/db_migrations_dir keys shipped in
the SQLite groundwork commit (a3b9967) — no downstream consumers yet,
so no migration path needed.

db_connections needed one deliberate exception to the project's usual
shallow config-merge rule: merged one level deeper, by connection name,
so App/config.php adding a connection doesn't delete 'default'. Verified
end-to-end against a real local MariaDB instance running alongside the
existing SQLite connection, which caught a real ordering bug in the
initial merge implementation (capturing defaults after they'd already
been overwritten) before it shipped.

Closes the "MySQL support" backlog item in novaconium/ISSUES.md.
This commit is contained in:
code
2026-07-14 03:53:27 +00:00
parent a3b996719a
commit 3a59269eaa
8 changed files with 339 additions and 155 deletions
+58 -26
View File
@@ -130,34 +130,66 @@ management"); don't extend this class toward multi-user/session-based
auth — that's a separate, larger feature that
will replace it.
`Lib\Db` (`novaconium/lib/Db.php`) is the SQLite groundwork tracked in
`novaconium/ISSUES.md` — a thin, no-ORM PDO wrapper, `Lib\` (not `App\`) so
a project can override it via `App/lib/Db.php` like any other `Lib\` class.
`Db::query(string $sql, array $params = [])` (prepare+execute) is the only
query-running helper — never add a string-interpolation shortcut; see
`Lib\Db` (`novaconium/lib/Db.php`) is the SQLite/MySQL groundwork tracked
in `novaconium/ISSUES.md` — a thin, no-ORM PDO wrapper, `Lib\` (not `App\`)
so a project can override it via `App/lib/Db.php` like any other `Lib\`
class. It supports multiple, independently-configured, **simultaneously
open** named connections (`config['db_connections']`, keyed by name) rather
than one global connection — because sidecars are plain PHP with full
access to any `Lib\` class, a single request can legitimately need more
than one database at once (e.g. this site's own SQLite data alongside a
MySQL connection to a legacy database). `Db::query(string $sql, array
$params = [], string $connection = 'default')` (prepare+execute) is the
only query-running helper — never add a string-interpolation shortcut; see
`Lib\Input`'s doc-comment, which already commits this project to
parameterized queries as the sole SQL-injection defense. Connection is
lazy (opened on first `Db::query()`/`Db::connection()` call, same shape as
`Lib\Csrf`'s lazy session start), and migrates automatically at that point:
plain `.sql` files under `App/migrations/` (`config['db_migrations_dir']`),
filename-ordered, tracked in an auto-created `schema_migrations` table, run
once each. `novaconium/bin/migrate.php` runs the same migration step
explicitly (e.g. from a deploy script) without serving a request first.
**`config['db_path']` must stay outside both `public/` (would be
web-accessible) and `novaconium/`** — unlike `cache_dir`/`contact-log.txt`,
which are disposable, a SQLite file is data a project can't afford to lose,
and `novaconium/` gets wholesale-replaced by the "Updating the framework"
workflow (`/admin/docs/getting-started`: `rm -rf novaconium && cp -r
<new-novaconium>`). The default (`data/novaconium.sqlite`) lives in a new
top-level `data/` directory instead — project-owned like `App/`, gitignored
per-file (`*.sqlite`/`-journal`/`-wal`/`-shm`, with a tracked `.gitkeep` so
the directory exists in a fresh clone) rather than wholesale like
parameterized queries as the sole SQL-injection defense. Each connection is
lazy and independent (opened on first `Db::query()`/`Db::connection()` call
naming it, same shape as `Lib\Csrf`'s lazy session start), and migrates
automatically at that point: plain `.sql` files under that connection's own
`migrations_dir`, filename-ordered, tracked in that connection's own
auto-created `schema_migrations` table (each connection's applied
migrations are independent of any other connection's), run once each.
`novaconium/bin/migrate.php` loops every configured connection and runs the
same migration step explicitly (e.g. from a deploy script) without serving
a request first. Only `'sqlite'` and `'mysql'` drivers are implemented; a
connection's `migrations_dir` is optional — omit it to never run migrations
against that connection (e.g. a legacy database this project shouldn't
manage schema for).
**`db_connections` is the one config key in the project that isn't plain
shallow-merge** — `bootstrap.php`/`bin/*.php`'s usual `array_merge($config,
$appConfig)` would let a project's `App/config.php` silently delete the
framework's `default` connection just by adding a second named connection
(a shallow merge replaces the whole key, it doesn't merge inside it). So
`Lib\Db::config()` (and the copy of this logic duplicated in
`bin/migrate.php`, same duplication precedent as the two-step config load
already duplicated across `bootstrap.php`/`bin/clear-cache.php`) merges
`db_connections` one level deeper, by connection name, **after** capturing
the framework defaults — capture the defaults *before* the top-level
`array_merge()` overwrites `$config['db_connections']`, not after, or the
deeper merge silently operates on the already-overwritten value and the
`default` connection vanishes anyway. (This exact bug was hit once while
building this feature — verified by testing a real `App/config.php`
override end-to-end, not just reading the code — so it's worth re-checking
by hand if this logic is ever touched again.) See
`/admin/docs/database` for the worked example.
**`config['db_connections']['default']['path']` must stay outside both
`public/` (would be web-accessible) and `novaconium/`** — unlike
`cache_dir`/`contact-log.txt`, which are disposable, a SQLite file is data a
project can't afford to lose, and `novaconium/` gets wholesale-replaced by
the "Updating the framework" workflow (`/admin/docs/getting-started`: `rm
-rf novaconium && cp -r <new-novaconium>`). The default
(`data/novaconium.sqlite`) lives in a new top-level `data/` directory
instead — project-owned like `App/`, gitignored per-file
(`*.sqlite`/`-journal`/`-wal`/`-shm`, with a tracked `.gitkeep` so the
directory exists in a fresh clone) rather than wholesale like
`public/cache/`, since a project might reasonably want other non-DB files
there later. Only `App/migrations/` is scanned for now (the framework ships
no core tables of its own yet) — if a future framework feature needs a
shipped migration, extend this to the same App-over-novaconium two-root
scan `Overlay.php` already does for pages/lib, don't invent a second
mechanism.
there later. Only `App/migrations/` (the framework default connection's
`migrations_dir`) is scanned by default — the framework ships no core
tables of its own yet — if a future framework feature needs a shipped
migration, extend this to the same App-over-novaconium two-root scan
`Overlay.php` already does for pages/lib, don't invent a second mechanism.
## Running it