From 3a59269eaae8a80d8dfcfaf688bba92017d47fec Mon Sep 17 00:00:00 2001 From: code Date: Tue, 14 Jul 2026 03:53:27 +0000 Subject: [PATCH] Add MySQL support to Lib\Db via multiple simultaneous named connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 84 +++++++--- App/config.php | 20 ++- README.md | 4 +- novaconium/ISSUES.md | 117 +++++++------ novaconium/bin/migrate.php | 24 ++- novaconium/config.php | 32 ++-- novaconium/lib/Db.php | 156 +++++++++++++----- .../pages/admin/docs/database/index.twig | 57 +++++-- 8 files changed, 339 insertions(+), 155 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index daf2e70..56a7cfb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 -`). 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 `). 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 diff --git a/App/config.php b/App/config.php index 00a4c8c..65b8fa8 100644 --- a/App/config.php +++ b/App/config.php @@ -21,8 +21,20 @@ return [ // 'admin_username' => 'admin', // 'admin_password_hash' => '$2y$10$...', - // Docs: /admin/docs/database - // 'db_driver' => 'sqlite', - // 'db_path' => __DIR__ . '/../data/novaconium.sqlite', - // 'db_migrations_dir' => __DIR__ . '/migrations', + // Docs: /admin/docs/database — adds (or overrides) named Lib\Db + // connections. This merges into db_connections by name rather than + // replacing the whole map, so adding 'legacy' here doesn't require + // repeating 'default' — see Lib\Db::config(). + // 'db_connections' => [ + // 'legacy' => [ + // 'driver' => 'mysql', + // 'host' => 'localhost', + // 'port' => 3306, + // 'database' => 'legacy_app', + // 'username' => 'root', + // 'password' => '...', + // 'charset' => 'utf8mb4', // optional, defaults to utf8mb4 + // 'migrations_dir' => __DIR__ . '/migrations/legacy', // optional + // ], + // ], ]; diff --git a/README.md b/README.md index 155dc55..705290e 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@ A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages - **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else. - **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form. - **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`. -- **SQLite database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) with a plain-SQL migration convention (`App/migrations/*.sql`, applied automatically on first use, or via `php novaconium/bin/migrate.php`). Data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`. +- **SQLite/MySQL database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) supporting multiple named connections open at once — e.g. this site's own SQLite data plus a MySQL connection to a legacy database, usable in the same sidecar — each with its own plain-SQL migration convention, applied automatically on first use or via `php novaconium/bin/migrate.php`. SQLite data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`. - **No build step, no Composer** — clone it, point Apache (or `php -S`) at `public/`, and it runs. Twig is vendored as source; see `/admin/docs/upgrading-twig` for upgrading it. ## Getting started -**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) with the `pdo_sqlite` extension (bundled with PHP, just needs to be enabled — no separate install), and, for production, Apache with `mod_rewrite` and `AllowOverride All`. +**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) with the `pdo_sqlite` extension (bundled with PHP, just needs to be enabled — no separate install; add `pdo_mysql` too if using a MySQL connection), and, for production, Apache with `mod_rewrite` and `AllowOverride All`. ### Run it locally (no Apache needed) diff --git a/novaconium/ISSUES.md b/novaconium/ISSUES.md index 749e2cb..7da695a 100644 --- a/novaconium/ISSUES.md +++ b/novaconium/ISSUES.md @@ -54,58 +54,37 @@ expected vs. actual behavior. For features, include the motivating use case.> Suggested build order (foundations first, since admin login builds on two of the others): -1. **MySQL support** — builds directly on SQLite groundwork's `Db` - abstraction (see Done — shipped 2026-07-14); do it soon since the - abstraction was designed driver-agnostic from the start specifically to - avoid a retrofit here. -2. **Session handling** — no dependencies. -3. **Blog tags/categories** — no dependencies, but now needs a metadata +1. **Session handling** — no dependencies. +2. **Blog tags/categories** — no dependencies, but now needs a metadata source design decision first (`PostRepository` was removed when `hello-world`/`second-post` became plain Twig pages — see the entry below), so worth doing first or together with Blog RSS feed. -4. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest +3. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest once tags/categories exist. -5. **Internal search** — SQLite groundwork it needed is done; ready to +4. **Internal search** — SQLite groundwork it needed is done; ready to build. -6. **XML sitemap** — no hard dependency, but shares crawling logic with +5. **XML sitemap** — no hard dependency, but shares crawling logic with Internal search, so easiest right after (or alongside) it. -7. **Media/file manager** — no hard dependency; usable standalone, though +6. **Media/file manager** — no hard dependency; usable standalone, though best gated behind admin login once that exists. -8. **Draft pages (admin-only preview)** — no hard dependency; the admin +7. **Draft pages (admin-only preview)** — no hard dependency; the admin authentication it reuses already shipped (see `/admin/docs/admin-auth`). -9. **Syntax highlighting on code blocks** — no hard dependency on the +8. **Syntax highlighting on code blocks** — no hard dependency on the copy button (see Done — shipped 2026-07-14), but touches the same `
` markup it did.
-10. **Admin login & user management** — SQLite groundwork it needed is
-    done; still needs session handling (logged-in state).
-11. **Ecommerce functionality** — needs session handling (cart) and admin
+9. **Admin login & user management** — SQLite groundwork it needed is
+   done; still needs session handling (logged-in state).
+10. **Ecommerce functionality** — needs session handling (cart) and admin
     login & user management (order/product admin, and customer accounts);
     SQLite groundwork it needed is done.
-12. **Paywall functionality** — needs everything Ecommerce needs, plus
+11. **Paywall functionality** — needs everything Ecommerce needs, plus
     Ecommerce itself for the recurring-billing/payment-gateway plumbing;
     build after it rather than in parallel.
 
+MySQL support shipped 2026-07-14 — see Done.
+
 See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
 
-### MySQL support
-
-- **Type:** Feature
-- **Status:** Backlog
-- **Priority:** Medium
-- **Depends on:** SQLite groundwork (Done)
-- **Added:** 2026-07-12
-
-Let a project point the `Db` wrapper at MySQL instead of SQLite — via a
-`db_driver` (or similar) `App/config.php` key plus connection settings
-(host/user/password/database) — for projects that want a real MySQL
-server rather than an embedded file, without maintaining two separate data
-layers. PDO already supports both drivers under one API, so this should be
-mostly a matter of: (1) not writing SQLite-only SQL in the groundwork
-above, (2) a config-driven DSN builder, (3) a migration convention that
-works on both (or per-driver migration files if syntax genuinely diverges).
-No new persistence features depend on this — it's an alternate backend for
-the same `Db` abstraction, not a separate feature surface.
-
 ### Session handling (with flash sessions)
 
 - **Type:** Feature
@@ -370,6 +349,44 @@ _Nothing yet._
 
 ## Done
 
+### MySQL support
+
+- **Type:** Feature
+- **Status:** Done
+- **Priority:** Medium
+- **Depends on:** SQLite groundwork (Done)
+- **Added:** 2026-07-12
+- **Shipped:** 2026-07-14
+
+Let a project point `Lib\Db` at MySQL — but went further than the original
+spec ("point the `Db` wrapper at MySQL instead of SQLite"): a real
+requirement surfaced during implementation that a single request may need
+**both** at once (sidecars have full access to any `Lib\` class, so nothing
+stops one from querying this site's own SQLite data and a legacy MySQL
+database in the same request). So `Lib\Db` was redesigned around multiple,
+independently-configured, simultaneously-open named connections
+(`config['db_connections']`, keyed by name — `'default'` is the only
+required one) rather than one global connection switched by a single
+`db_driver` key. This superseded the flat `db_driver`/`db_path`/
+`db_migrations_dir` keys the SQLite groundwork entry above originally
+shipped with (which had no downstream consumers yet, so no migration path
+was needed). `Db::query(string $sql, array $params = [], string
+$connection = 'default')` and `Db::connection(string $name = 'default')`
+both default to `'default'` so the common single-database case reads the
+same as before; a third/first argument targets any other configured
+connection. Each connection has its own lazy PDO connect (`'sqlite'` and
+`'mysql'` drivers implemented), its own optional `migrations_dir`, and its
+own independent `schema_migrations` table — verified for real (not just by
+inspection) by running a local MariaDB instance alongside the existing
+SQLite connection and executing queries against both from the same script.
+`db_connections` needed one deliberate exception to the project's usual
+shallow config-merge rule — merged one level deeper, by connection name, so
+an `App/config.php` adding a `legacy` connection doesn't silently delete
+the framework's `default` one — documented in `AGENTS.md` and
+`/admin/docs/database`, including the exact "capture defaults before the
+top-level `array_merge()` overwrites them" ordering bug hit once while
+building this (caught by the end-to-end MySQL test, not by review).
+
 ### SQLite groundwork
 
 - **Type:** Feature
@@ -379,28 +396,22 @@ _Nothing yet._
 - **Shipped:** 2026-07-14
 
 Laid the groundwork for optional SQLite storage: `Lib\Db`
-(`novaconium/lib/Db.php`) is a thin, no-ORM PDO wrapper (`Db::query(string
-$sql, array $params = [])` — prepared statements only, no
-string-interpolation helper ever, per `Lib\Input`'s existing documented
-security stance) so features that need persistence — 404 tracking (see
-Won't Do; superseded by Matomo before this shipped), admin login, blog
-tags, internal search, and anything future — have a common place to store
-data instead of ad hoc flat files. Connection is lazy (opens on first real
-call, same shape as `Lib\Csrf`'s lazy session start). Migration convention:
-plain numbered `.sql` files under `App/migrations/`
-(`config['db_migrations_dir']`), applied in filename order and tracked in
-an auto-created `schema_migrations` table, run automatically on first
-connection or explicitly via `php novaconium/bin/migrate.php`. Data lives
-in a new top-level `data/` directory — deliberately outside both `public/`
+(`novaconium/lib/Db.php`) is a thin, no-ORM PDO wrapper (prepared
+statements only, no string-interpolation helper ever, per `Lib\Input`'s
+existing documented security stance) so features that need persistence —
+404 tracking (see Won't Do; superseded by Matomo before this shipped),
+admin login, blog tags, internal search, and anything future — have a
+common place to store data instead of ad hoc flat files. Data lives in a
+new top-level `data/` directory — deliberately outside both `public/`
 (would be web-accessible) and `novaconium/` (gets wholesale-replaced by the
 "Updating the framework" workflow documented at
 `/admin/docs/getting-started`, so anything persisted there would be
 destroyed by the next update) — gitignored per-file, with a tracked
-`.gitkeep`. New config keys: `db_driver` (only `'sqlite'` implemented),
-`db_path`, `db_migrations_dir`. Documented at `/admin/docs/database` and in
-`AGENTS.md` next to the two-root-split section. Designed driver-agnostic
-(no SQLite-only SQL in the mechanism itself) so MySQL support, next up in
-the build order, doesn't need a retrofit.
+`.gitkeep`. Designed driver-agnostic (no SQLite-only SQL in the mechanism
+itself) specifically so MySQL support wouldn't need a retrofit — see that
+entry below (shipped 2026-07-14) for the connection/config/migration API,
+which superseded the single-connection shape (`db_driver`/`db_path`/
+`db_migrations_dir` config keys) this entry originally shipped with.
 
 ### Copy-to-clipboard button on code blocks
 
diff --git a/novaconium/bin/migrate.php b/novaconium/bin/migrate.php
index 4302646..98e78cd 100644
--- a/novaconium/bin/migrate.php
+++ b/novaconium/bin/migrate.php
@@ -4,10 +4,22 @@ use Lib\Db;
 
 require __DIR__ . '/../autoload.php';
 
-// Db::connection() applies any pending App/migrations/*.sql as a side
-// effect of opening the connection (see Lib\Db::migrate()) — this script
-// just triggers that explicitly, e.g. from a deploy script, without
-// serving a request first.
-Db::connection();
+// Db::connection() applies any pending migrations for that connection as a
+// side effect of opening it (see Lib\Db::migrate()) — this script triggers
+// that explicitly for every configured connection, e.g. from a deploy
+// script, without serving a request first.
+$config = require __DIR__ . '/../config.php';
 
-echo "Migrations applied.\n";
+$appConfigFile = __DIR__ . '/../../App/config.php';
+if (is_file($appConfigFile)) {
+    $appConfig = require $appConfigFile;
+    $defaultConnections = $config['db_connections'];
+    $appConnections = $appConfig['db_connections'] ?? [];
+    $config = array_merge($config, $appConfig);
+    $config['db_connections'] = array_merge($defaultConnections, $appConnections);
+}
+
+foreach (array_keys($config['db_connections']) as $name) {
+    Db::connection($name);
+    echo "Migrated connection '{$name}'.\n";
+}
diff --git a/novaconium/config.php b/novaconium/config.php
index cea5a8a..b9607f0 100644
--- a/novaconium/config.php
+++ b/novaconium/config.php
@@ -39,14 +39,26 @@ return [
     'admin_username'      => 'admin',
     'admin_password_hash' => '',
 
-    // Lib\Db (see /admin/docs/database). Only 'sqlite' is implemented today
-    // — MySQL support is tracked as a separate Backlog item in
-    // novaconium/ISSUES.md. db_path deliberately lives outside both
-    // public/ (must never be web-accessible) and novaconium/ (gets wholly
-    // replaced on a framework update — see /admin/docs/getting-started's
-    // "Updating the framework" section) — a top-level data/ directory,
-    // project-owned like App/, is the only safe place for it.
-    'db_driver'         => 'sqlite',
-    'db_path'           => __DIR__ . '/../data/novaconium.sqlite',
-    'db_migrations_dir' => __DIR__ . '/../App/migrations',
+    // Lib\Db (see /admin/docs/database) — named, simultaneously-usable
+    // connections, keyed by name; 'default' is the only one required. A
+    // sidecar can use more than one at once, e.g. Db::query(...) (default)
+    // alongside Db::query(..., 'legacy'). Supported drivers: 'sqlite',
+    // 'mysql'. The default connection's path deliberately lives outside
+    // both public/ (must never be web-accessible) and novaconium/ (gets
+    // wholly replaced on a framework update — see
+    // /admin/docs/getting-started's "Updating the framework" section) — a
+    // top-level data/ directory, project-owned like App/, is the only safe
+    // place for it. migrations_dir is optional per connection; omit it to
+    // never run migrations against that connection (e.g. a read-only
+    // legacy database). NOTE: unlike every other key here, App/config.php
+    // merges into db_connections one level deeper than a normal shallow
+    // override — see the comment on Lib\Db::config() — so adding a second
+    // connection there doesn't require repeating 'default'.
+    'db_connections' => [
+        'default' => [
+            'driver'         => 'sqlite',
+            'path'           => __DIR__ . '/../data/novaconium.sqlite',
+            'migrations_dir' => __DIR__ . '/../App/migrations',
+        ],
+    ],
 ];
diff --git a/novaconium/lib/Db.php b/novaconium/lib/Db.php
index bb2674b..9322948 100644
--- a/novaconium/lib/Db.php
+++ b/novaconium/lib/Db.php
@@ -6,10 +6,20 @@ use PDO;
 use RuntimeException;
 
 /**
- * A thin PDO wrapper — the SQLite groundwork tracked in novaconium/ISSUES.md.
- * No ORM, no query builder, consistent with this project's no-Composer,
- * no-build-step philosophy: just a lazily-opened PDO connection plus a
- * minimal migration runner.
+ * A thin PDO wrapper — the SQLite/MySQL groundwork tracked in
+ * novaconium/ISSUES.md. No ORM, no query builder, consistent with this
+ * project's no-Composer, no-build-step philosophy: just lazily-opened PDO
+ * connections plus a minimal per-connection migration runner.
+ *
+ * Supports multiple, simultaneously-open, independently-configured 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 may legitimately need more than one
+ * database at once (e.g. this site's own SQLite data plus a MySQL
+ * connection to a legacy/external database). The common single-database
+ * case still reads the same as a single-connection API would:
+ * Db::query('SELECT ...', [...]) always targets the 'default' connection
+ * unless a different connection name is passed explicitly.
  *
  * Db::query() is the only query-running helper, and it only ever accepts a
  * SQL string plus a params array for PDO to bind — there is deliberately no
@@ -18,76 +28,125 @@ use RuntimeException;
  * string concatenation or sanitize-then-interpolate, however "cleaned" input
  * looks. Call Db::connection() directly for anything Db::query() doesn't
  * cover (transactions, lastInsertId(), etc.) — it returns the raw PDO
- * instance.
+ * instance for the named connection.
  *
  * Lazy-connect, same shape as Lib\Csrf's lazy session start: nothing opens
- * a database file or runs a migration until the first real call, so a
- * request that never touches the database never pays for it.
+ * a database connection or runs a migration until the first real call to a
+ * given connection name, so a request that never touches a particular
+ * database never pays for it.
  */
 final class Db
 {
-    private static ?PDO $connection = null;
+    /** @var array */
+    private static array $connections = [];
 
-    public static function connection(): PDO
+    public static function connection(string $name = 'default'): PDO
     {
-        return self::$connection ??= self::connect();
+        return self::$connections[$name] ??= self::connect($name);
     }
 
     /**
      * @param array $params
      */
-    public static function query(string $sql, array $params = []): \PDOStatement
+    public static function query(string $sql, array $params = [], string $connection = 'default'): \PDOStatement
     {
-        $statement = self::connection()->prepare($sql);
+        $statement = self::connection($connection)->prepare($sql);
         $statement->execute($params);
 
         return $statement;
     }
 
-    private static function connect(): PDO
+    private static function connect(string $name): PDO
     {
-        $config = self::config();
+        $connections = self::config()['db_connections'];
 
-        if ($config['db_driver'] !== 'sqlite') {
+        if (!isset($connections[$name])) {
             throw new RuntimeException(
-                "Unsupported db_driver '{$config['db_driver']}' — only 'sqlite' is implemented so far " .
-                '(MySQL support is tracked separately in novaconium/ISSUES.md).'
+                "No db_connections entry named '{$name}' in config — configured connections: " .
+                (empty($connections) ? '(none)' : implode(', ', array_keys($connections)))
             );
         }
 
-        $path = $config['db_path'];
-        $dir = dirname($path);
-        if (!is_dir($dir)) {
-            mkdir($dir, 0775, true);
-        }
+        $connectionConfig = $connections[$name];
+        $driver = $connectionConfig['driver'] ?? null;
 
-        $pdo = new PDO('sqlite:' . $path);
-        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
-        $pdo->exec('PRAGMA foreign_keys = ON');
+        $pdo = match ($driver) {
+            'sqlite' => self::connectSqlite($connectionConfig),
+            'mysql' => self::connectMysql($connectionConfig),
+            default => throw new RuntimeException(
+                "Connection '{$name}' has unsupported driver " .
+                (is_string($driver) ? "'{$driver}'" : 'null') . " — only 'sqlite' and 'mysql' are implemented."
+            ),
+        };
 
-        self::migrate($pdo, $config['db_migrations_dir']);
+        self::migrate($pdo, $connectionConfig['migrations_dir'] ?? null);
 
         return $pdo;
     }
 
     /**
-     * Applies any *.sql file under $migrationsDir not yet recorded in
-     * schema_migrations, in filename order — so migrations are named with a
-     * numeric prefix (0001_create_x.sql, 0002_add_y.sql, ...) to control
-     * apply order. Each file is tracked by filename once applied and never
-     * re-run. Runs automatically on every first connection() call per
-     * process — cheap (one query plus a directory glob), so no separate
-     * "migrate" step is required, matching the framework's zero-config
-     * philosophy elsewhere (e.g. static caching). novaconium/bin/migrate.php
-     * exists for running it explicitly (e.g. from a deploy script) without
-     * serving a request first.
+     * @param array $config
      */
-    private static function migrate(PDO $pdo, string $migrationsDir): void
+    private static function connectSqlite(array $config): PDO
     {
+        $path = $config['path'];
+        $dir = dirname($path);
+        if (!is_dir($dir)) {
+            mkdir($dir, 0775, true);
+        }
+
+        $pdo = new PDO('sqlite:' . $path, options: [
+            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+            PDO::ATTR_EMULATE_PREPARES => false,
+        ]);
+        $pdo->exec('PRAGMA foreign_keys = ON');
+
+        return $pdo;
+    }
+
+    /**
+     * @param array $config
+     */
+    private static function connectMysql(array $config): PDO
+    {
+        $charset = $config['charset'] ?? 'utf8mb4';
+        $dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']};charset={$charset}";
+
+        return new PDO($dsn, $config['username'], $config['password'], [
+            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+            PDO::ATTR_EMULATE_PREPARES => false,
+        ]);
+    }
+
+    /**
+     * Applies any *.sql file under $migrationsDir not yet recorded in this
+     * connection's own schema_migrations table, in filename order — so
+     * migrations are named with a numeric prefix (0001_create_x.sql,
+     * 0002_add_y.sql, ...) to control apply order. Each file is tracked by
+     * filename once applied and never re-run. Each connection tracks its
+     * own schema_migrations table in its own database, independent of any
+     * other configured connection. A connection with no migrations_dir set
+     * skips this entirely — e.g. a connection to a legacy database this
+     * project shouldn't manage schema for.
+     *
+     * Runs automatically on every first connection() call per process, per
+     * connection name — cheap (one query plus a directory glob), so no
+     * separate "migrate" step is required, matching the framework's
+     * zero-config philosophy elsewhere (e.g. static caching).
+     * novaconium/bin/migrate.php exists to run it explicitly for every
+     * configured connection (e.g. from a deploy script) without serving a
+     * request first.
+     */
+    private static function migrate(PDO $pdo, ?string $migrationsDir): void
+    {
+        if ($migrationsDir === null) {
+            return;
+        }
+
         $pdo->exec(
             'CREATE TABLE IF NOT EXISTS schema_migrations (' .
-            'filename TEXT PRIMARY KEY, ' .
-            'applied_at TEXT NOT NULL' .
+            'filename VARCHAR(255) PRIMARY KEY, ' .
+            'applied_at VARCHAR(32) NOT NULL' .
             ')'
         );
 
@@ -115,7 +174,18 @@ final class Db
     }
 
     /**
-     * @return array{db_driver: string, db_path: string, db_migrations_dir: string}
+     * Loads config the same way bootstrap.php/bin scripts do (framework
+     * defaults shallow-merged with App/config.php), except for
+     * db_connections specifically: a shallow array_merge would let a
+     * project's App/config.php silently drop the framework's 'default'
+     * connection just by adding a second named connection (array_merge
+     * replaces the whole key, it doesn't merge inside it). db_connections
+     * is therefore merged one level deeper, by connection name, so adding
+     * e.g. 'legacy' in App/config.php doesn't require repeating 'default'.
+     * This is the one config key in the project that isn't plain
+     * shallow-merge — see AGENTS.md.
+     *
+     * @return array{db_connections: array>}
      */
     private static function config(): array
     {
@@ -123,7 +193,11 @@ final class Db
 
         $appConfigFile = __DIR__ . '/../../App/config.php';
         if (is_file($appConfigFile)) {
-            $config = array_merge($config, require $appConfigFile);
+            $appConfig = require $appConfigFile;
+            $defaultConnections = $config['db_connections'];
+            $appConnections = $appConfig['db_connections'] ?? [];
+            $config = array_merge($config, $appConfig);
+            $config['db_connections'] = array_merge($defaultConnections, $appConnections);
         }
 
         return $config;
diff --git a/novaconium/pages/admin/docs/database/index.twig b/novaconium/pages/admin/docs/database/index.twig
index d525c26..4ac2121 100644
--- a/novaconium/pages/admin/docs/database/index.twig
+++ b/novaconium/pages/admin/docs/database/index.twig
@@ -4,44 +4,75 @@
 
 {% block title %}Database{% endblock %}
 
-{% block description %}Lib\Db — a thin PDO/SQLite wrapper with a plain-SQL migration convention.{% endblock %}
+{% block description %}Lib\Db — a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.{% endblock %}
 
 {% block robots %}noindex, nofollow{% endblock %}
 
 {% block docs_content %}
     

Database

-

Lib\Db (novaconium/lib/Db.php) is the SQLite groundwork tracked in novaconium/ISSUES.md — a thin PDO wrapper plus a minimal migration runner, no ORM and no query builder, consistent with this project's no-Composer, no-build-step philosophy. It's a {{ icons.book() }}Lib\ class like Input/Csrf/Mailer, so a project can override it entirely by dropping its own App/lib/Db.php.

+

Lib\Db (novaconium/lib/Db.php) is the SQLite/MySQL groundwork tracked in novaconium/ISSUES.md — a thin PDO wrapper plus a minimal migration runner, no ORM and no query builder, consistent with this project's no-Composer, no-build-step philosophy. It's a {{ icons.book() }}Lib\ class like Input/Csrf/Mailer, so a project can override it entirely by dropping its own App/lib/Db.php.

+ +

It supports multiple, independently-configured, simultaneously open named connections rather than a single global one — because a sidecar is 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 or external database.

Using it

use Lib\Db;
 
-$rows = Db::query('SELECT * FROM posts WHERE published = ?', [1])->fetchAll();
+// Targets the 'default' connection — reads exactly like a single-database API. +$rows = Db::query('SELECT * FROM posts WHERE published = ?', [1])->fetchAll(); -

Db::query(string $sql, array $params = []) prepares and executes in one call, returning the PDOStatement. It's the only query-running helper this class exposes — there is deliberately no string-interpolation convenience method. Db::connection() returns the raw PDO instance for anything query() doesn't cover (transactions, lastInsertId(), etc.).

+// A third argument targets any other configured connection by name, and can +// be used in the same request/script as the default connection above. +$legacyRows = Db::query('SELECT * FROM widgets', [], 'legacy')->fetchAll();
+ +

Db::query(string $sql, array $params = [], string $connection = 'default') prepares and executes against the named connection in one call, returning the PDOStatement. It's the only query-running helper this class exposes — there is deliberately no string-interpolation convenience method. Db::connection(string $name = 'default') returns the raw PDO instance for anything query() doesn't cover (transactions, lastInsertId(), etc.).

Always use parameter binding, never string-concatenate values into SQL — the same rule {{ icons.book() }}Lib\Input's own documentation already commits to: cleaning input is defense-in-depth against HTML/script injection, not SQL injection, and no string transform makes arbitrary input safe to concatenate into a query. Parameterized queries are the only real defense, so Db never grows an sqlSafe()-style shortcut.

-

The connection is opened lazily — nothing touches the database file or runs a migration until the first real call to Db::query() or Db::connection(), so a request that never needs the database never pays for it.

+

Each connection is opened lazily and independently — nothing touches a given database or runs its migrations until the first real call naming that connection, so a request that only ever uses default never pays to open legacy.

Configuration

+

Connections are a named map under a single db_connections key. The framework default defines only default (SQLite):

+ +
// novaconium/config.php (framework default)
+'db_connections' => [
+    'default' => [
+        'driver'         => 'sqlite',
+        'path'           => __DIR__ . '/../data/novaconium.sqlite',
+        'migrations_dir' => __DIR__ . '/../App/migrations',
+    ],
+],
+ +

Add a MySQL connection alongside it from App/config.php:

+
<?php
 // App/config.php
 return [
-    'db_driver'         => 'sqlite',
-    'db_path'           => __DIR__ . '/../data/novaconium.sqlite',
-    'db_migrations_dir' => __DIR__ . '/migrations',
+    'db_connections' => [
+        'legacy' => [
+            'driver'         => 'mysql',
+            'host'           => 'localhost',
+            'port'           => 3306,
+            'database'       => 'legacy_app',
+            'username'       => 'root',
+            'password'       => '...',
+            'charset'        => 'utf8mb4',            // optional, defaults to utf8mb4
+            'migrations_dir' => __DIR__ . '/migrations/legacy',  // optional
+        ],
+    ],
 ];
-

Only 'sqlite' is implemented for db_driver today — MySQL support is tracked as a separate item in novaconium/ISSUES.md, and the wrapper avoids SQLite-only SQL where a MySQL-compatible equivalent exists so that lands without a retrofit.

+

This is the one config key in the project that doesn't follow the usual shallow-merge rule. Every other App/config.php key replaces the framework default outright (see {{ icons.book() }}Configuration) — but a plain shallow merge on db_connections would let the snippet above silently delete the framework's default connection just by adding legacy. So Lib\Db merges db_connections one level deeper, by connection name: the example above ends up with both default (SQLite, from the framework) and legacy (MySQL, from App/config.php) configured at once. To actually replace default, redeclare a default key yourself.

-

db_path defaults to a top-level data/ directory — a sibling of App/, novaconium/, and public/, not nested inside any of them. This is deliberate: it can't live under public/ (would be directly web-accessible), and it can't live under novaconium/ either, since {{ icons.book() }}updating the framework means overwriting that whole directory — anything persisted there would be destroyed by the next update. data/ is project-owned, like App/, and untouched by a framework update. Its contents (*.sqlite and the SQLite journal/WAL/SHM sidecar files) are gitignored; only a .gitkeep is tracked so the directory exists in a fresh clone.

+

Only 'sqlite' and 'mysql' are implemented as driver values. migrations_dir is optional per connection — omit it to never run migrations against that connection (e.g. a legacy database this project shouldn't manage schema for).

+ +

The default connection's path lives in a top-level data/ directory — a sibling of App/, novaconium/, and public/, not nested inside any of them. This is deliberate: it can't live under public/ (would be directly web-accessible), and it can't live under novaconium/ either, since {{ icons.book() }}updating the framework means overwriting that whole directory — anything persisted there would be destroyed by the next update. data/ is project-owned, like App/, and untouched by a framework update. Its contents (*.sqlite and the SQLite journal/WAL/SHM sidecar files) are gitignored; only a .gitkeep is tracked so the directory exists in a fresh clone.

Migrations

-

Plain .sql files under App/migrations/ (db_migrations_dir), applied in filename order — name them with a numeric prefix to control ordering:

+

Plain .sql files under each connection's own migrations_dir, applied in filename order — name them with a numeric prefix to control ordering:

-- App/migrations/0001_create_posts.sql
 CREATE TABLE posts (
@@ -50,9 +81,9 @@ CREATE TABLE posts (
     body TEXT NOT NULL
 );
-

Each file is tracked by filename in a schema_migrations table (created automatically) and only ever run once. Migrations apply automatically the first time Db::connection() is called in a process — zero-config, the same "just works" philosophy as static caching — or explicitly, without serving a request first:

+

Each file is tracked by filename in that connection's own schema_migrations table (created automatically in that connection's database) and only ever run once — default and legacy each track their own applied migrations independently. Migrations for a given connection apply automatically the first time it's used in a process — zero-config, the same "just works" philosophy as static caching — or explicitly for every configured connection at once, without serving a request first:

php novaconium/bin/migrate.php
-

Only App/migrations/ is scanned — the framework itself ships no core tables yet, so there's no second novaconium/migrations/ root to merge in. If a future framework feature needs a shipped migration (e.g. admin login's user table), this can extend to the same App-over-novaconium two-root scan used for pages and lib.

+

Point two connections' migrations_dir at different directories (e.g. App/migrations/ for default, App/migrations/legacy/ for legacy) if their SQL genuinely diverges between drivers; otherwise the same directory works for both as long as the SQL in it is portable. The framework itself ships no core tables yet, so there's no second novaconium/migrations/ root to merge in — if a future framework feature needs a shipped migration (e.g. admin login's user table), this can extend to the same App-over-novaconium two-root scan used for pages and lib.

{% endblock %}