Add MariaDB WASM support to the Playground CLI#3474
Conversation
WordPress Playground has always used SQLite as its database engine. This commit introduces an alternative: a MariaDB server compiled to WebAssembly, running in-process alongside PHP. A new @wp-playground/mariadb package wraps the mariadb-wasm Emscripten module with a clean JavaScript bridge and implements enough of the MySQL wire protocol over TCP for PHP's mysqli extension to connect. The CLI gains --database=mariadb and --mariadb-wasm-module flags. When selected, the server starts before worker threads spawn, and WordPress gets real MySQL credentials in wp-config.php instead of the SQLite integration plugin.
22 tests covering the MariaDBBridge (C API wrapping, init/destroy lifecycle, query execution, error handling) and the MySQL protocol server (TCP handshake, COM_QUERY for SELECT and non-SELECT, COM_PING, COM_QUIT, error packets, server shutdown).
The embedded server needs data directories in the Emscripten virtual filesystem and specific startup flags (--skip-grant-tables, --datadir, --default-storage-engine=MyISAM) to work without mysql_install_db. The init() method now creates /usr/local/mysql/data in MEMFS, builds the argv array for mysql_server_init, and passes the flags so the server starts cleanly. This was tested end-to-end with the compiled WASM module from github.com/adamziel/mariadb-wasm — CREATE DATABASE, CREATE TABLE, INSERT, and SELECT all work correctly.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds MariaDB 11.4 (WASM) as an alternative database backend for WordPress Playground CLI, enabling WordPress to connect via MySQL wire protocol instead of the SQLite integration plugin.
Changes:
- Introduces new
@wp-playground/mariadbpackage (WASM bridge + MySQL protocol TCP server) with Vitest coverage and NX/Vite build config. - Extends Playground CLI with
--database=mariadband--mariadb-wasm-module, starting the embedded MariaDB server before spawning PHP workers. - Updates WordPress boot to persist runtime DB constants into
wp-config.phpfor pre-boot credential checks.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.base.json | Adds TS path alias for the new @wp-playground/mariadb package. |
| packages/playground/wordpress/src/boot.ts | Writes DB constants into wp-config.php when provided at runtime. |
| packages/playground/mariadb/* | New MariaDB WASM bridge, MySQL protocol server, build/test config, and package metadata. |
| packages/playground/cli/src/run-cli.ts | Adds CLI flags, starts/stops MariaDB WASM server, passes port to workers. |
| packages/playground/cli/src/cli-output.ts | Prints selected DB engine in the CLI summary. |
| packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts | Injects MySQL credentials/constants for MariaDB into worker options. |
| packages/playground/cli/project.json | Adds implicit dependency on playground-mariadb. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import { defineConfig } from 'vite'; | ||
| import dts from 'vite-plugin-dts'; | ||
| import { viteTsConfigPaths } from '../../vite-extensions/vite-ts-config-paths'; | ||
| import { getExternalModules } from '../../vite-extensions/vite-external-modules'; | ||
| import viteGlobalExtensions from '../../vite-extensions/vite-global-extensions'; | ||
|
|
||
| const path = (filename: string) => new URL(filename, import.meta.url).pathname; |
There was a problem hiding this comment.
new URL(...).pathname is not reliably usable as a filesystem path on Windows (leading slash + URL encoding). Use a file-URL-to-path conversion (e.g., Node's fileURLToPath(new URL(...))) so tsconfigPath resolution works cross-platform.
| import { defineConfig } from 'vite'; | |
| import dts from 'vite-plugin-dts'; | |
| import { viteTsConfigPaths } from '../../vite-extensions/vite-ts-config-paths'; | |
| import { getExternalModules } from '../../vite-extensions/vite-external-modules'; | |
| import viteGlobalExtensions from '../../vite-extensions/vite-global-extensions'; | |
| const path = (filename: string) => new URL(filename, import.meta.url).pathname; | |
| import { fileURLToPath } from 'node:url'; | |
| import { defineConfig } from 'vite'; | |
| import dts from 'vite-plugin-dts'; | |
| import { viteTsConfigPaths } from '../../vite-extensions/vite-ts-config-paths'; | |
| import { getExternalModules } from '../../vite-extensions/vite-external-modules'; | |
| import viteGlobalExtensions from '../../vite-extensions/vite-global-extensions'; | |
| const path = (filename: string) => fileURLToPath(new URL(filename, import.meta.url)); |
| const rc = this.api.mysql_server_init( | ||
| serverArgs.length, | ||
| argv, | ||
| 0 | ||
| ); | ||
| if (rc !== 0) { | ||
| throw new Error(`mysql_server_init failed with code ${rc}`); |
There was a problem hiding this comment.
WASM heap allocations for argv and each argPtrs[i] are never freed. Even though init() is typically called once, this is still a leak (and becomes larger if init ever retries/fails). After mysql_server_init returns, free argv and each argPtrs[i] in a finally to avoid leaking WASM memory.
| const rc = this.api.mysql_server_init( | |
| serverArgs.length, | |
| argv, | |
| 0 | |
| ); | |
| if (rc !== 0) { | |
| throw new Error(`mysql_server_init failed with code ${rc}`); | |
| try { | |
| const rc = this.api.mysql_server_init( | |
| serverArgs.length, | |
| argv, | |
| 0 | |
| ); | |
| if (rc !== 0) { | |
| throw new Error(`mysql_server_init failed with code ${rc}`); | |
| } | |
| } finally { | |
| this.module._free(argv); | |
| for (const argPtr of argPtrs) { | |
| this.module._free(argPtr); | |
| } |
| const len = lengthsPtr | ||
| ? this.module.getValue(lengthsPtr + i * 4, 'i32') | ||
| : 0; | ||
| if (len > 0) { | ||
| row.push(this.module.UTF8ToString(strPtr)); | ||
| } else { | ||
| row.push(''); | ||
| } |
There was a problem hiding this comment.
The code/comment says it uses the reported len to avoid relying on null-termination, but UTF8ToString(strPtr) ignores len. This will truncate values containing embedded NULs and can mis-handle non-text/binary data. Use a length-aware string decode (Emscripten supports UTF8ToString(ptr, maxBytesToRead)), or explicitly read bytes from the heap using len.
| const DATA_DIR = '/usr/local/mysql/data'; | ||
| const requiredDirs = [ | ||
| '/usr', | ||
| '/usr/local', | ||
| '/usr/local/mysql', | ||
| DATA_DIR, | ||
| DATA_DIR + '/mysql', | ||
| '/tmp', | ||
| ]; |
There was a problem hiding this comment.
loadMariaDBModule() mounts persistence at /var/lib/mysql, but init() configures MariaDB --datadir to /usr/local/mysql/data. As a result, the mounted directory won't actually persist MariaDB data. Align the mountpoint with DATA_DIR (mount the host directory at /usr/local/mysql/data), or change --datadir to use /var/lib/mysql when dataDir is provided.
| private switchDatabase(db: string) { | ||
| try { | ||
| this.bridge.query(`CREATE DATABASE IF NOT EXISTS \`${db}\``); | ||
| this.bridge.query(`USE \`${db}\``); | ||
| this.currentDb = db; | ||
| } catch { | ||
| // Ignore errors — the database may already exist. | ||
| } | ||
| } |
There was a problem hiding this comment.
Database names come from the client (handshake CONNECT_WITH_DB and COM_INIT_DB). Interpolating db into backtick-quoted SQL without escaping allows malformed names (including backticks) to break the statement and potentially inject additional SQL. Either validate db against a safe identifier regex, or escape backticks (` → ``) before constructing the query.
| function buildPacket(sequenceId: number, payload: Buffer): Buffer { | ||
| const header = Buffer.alloc(4); | ||
| header.writeUIntLE(payload.length, 0, 3); | ||
| header[3] = sequenceId & 0xff; | ||
| return Buffer.concat([header, payload]); | ||
| } |
There was a problem hiding this comment.
MySQL packet headers encode payload length in 3 bytes (max 0xFFFFFF). If payload.length exceeds that, writeUIntLE(..., 3) will truncate, corrupting the stream. Add an explicit guard to throw (or implement multi-packet fragmentation) when payloads exceed 16MB (e.g., large result sets).
| // Auth response (length-encoded or fixed based on caps). | ||
| if (capFlags & CLIENT_SECURE_CONNECTION) { | ||
| const authLen = payload[offset]; | ||
| offset += 1 + authLen; | ||
| } else { | ||
| while ( | ||
| offset < payload.length && | ||
| payload[offset] !== 0 | ||
| ) { | ||
| offset++; | ||
| } | ||
| offset++; | ||
| } |
There was a problem hiding this comment.
Handshake Response 41 parsing assumes CLIENT_SECURE_CONNECTION implies a 1-byte auth length. Some clients can send auth data as a length-encoded integer (e.g., when CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA is set), which would make offset wrong and break database-name extraction. Consider handling that capability explicitly (parse len-enc int) before reading the optional DB name.
| if (args.database === 'mariadb') { | ||
| const { loadMariaDBModule, startMySQLProtocolServer } = | ||
| await import('@wp-playground/mariadb'); | ||
|
|
||
| cliOutput.updateProgress('Starting MariaDB WASM'); | ||
| const bridge = await loadMariaDBModule( | ||
| args['mariadb-wasm-module']! | ||
| ); | ||
| mariadbServer = await startMySQLProtocolServer({ | ||
| bridge, | ||
| defaultDatabase: 'wordpress', | ||
| }); | ||
| args.mariadbPort = mariadbServer.port; | ||
| logger.debug( | ||
| `MariaDB WASM server listening on port ${mariadbServer.port}` | ||
| ); | ||
| } |
There was a problem hiding this comment.
The CLI keeps a reference to mariadbServer but drops the bridge. On shutdown you close the TCP server, but you never call bridge.destroy() to mysql_close() + mysql_server_end(). Store the bridge alongside mariadbServer and destroy it in the same cleanup path to avoid leaking WASM/server resources during long-running CLI sessions.
| let buf = Buffer.alloc(0); | ||
| function onData(data: Buffer) { | ||
| buf = Buffer.concat([buf, data]); | ||
| // Give it a moment to collect all packets for multi-packet responses | ||
| setTimeout(() => { | ||
| socket.removeListener('data', onData); | ||
| resolve(buf); | ||
| }, 50); | ||
| } |
There was a problem hiding this comment.
The fixed setTimeout(50) makes tests timing-dependent and can be flaky on slower CI runners. Prefer reading deterministically: parse packets incrementally and resolve when you’ve received the expected terminator packet (e.g., EOF/OK/Error) or a known number of packets for the command.
| return Buffer.concat(parts); | ||
| } | ||
|
|
||
| describe('MySQL Protocol Server', () => { |
There was a problem hiding this comment.
The protocol server implements COM_INIT_DB and COM_FIELD_LIST (and DB selection during handshake), but the spec file currently doesn't assert those behaviors. Add tests that: (1) send a handshake response with CLIENT_CONNECT_WITH_DB and verify the bridge sees USE/CREATE DATABASE, (2) send COM_INIT_DB and verify it switches DB and returns OK, and (3) send COM_FIELD_LIST and verify an EOF packet response.
MariaDB's Aria storage engine is stubbed for WASM but the query optimizer still tries to use it for internal temp tables. Switch the default-tmp-storage-engine to MyISAM and add loose-aria flag to prevent Aria temp table creation attempts.
Summary
WordPress Playground has always used SQLite as its database. This PR adds a second option: MariaDB 11.4 compiled to WebAssembly, running as an embedded server inside the same Node.js process as PHP.
A new
@wp-playground/mariadbpackage wraps the mariadb-wasm Emscripten module with a JavaScript bridge and implements enough of the MySQL wire protocol over TCP for PHP's mysqli extension to connect. The CLI gains--database=mariadband--mariadb-wasm-moduleflags — when selected, the embedded MariaDB server starts before PHP worker threads, and WordPress gets real MySQL credentials instead of the SQLite integration plugin.The mariadb-wasm repo itself was updated with build fixes for Emscripten (curses, GnuTLS, PCRE2 toolchain, Aria threading, timer thread patches) and now ships pre-built
dist/mariadb.js+dist/mariadb.wasmartifacts.This is a working proof-of-concept: CREATE DATABASE, CREATE TABLE, INSERT, and SELECT all execute correctly through the full stack (PHP → mysqli → MySQL wire protocol → MariaDB WASM).
Test plan
npx vitest runinpackages/playground/mariadb— 22 tests pass (bridge + protocol server)npx nx dev playground-cli server --database=mariadb --mariadb-wasm-module=/path/to/mariadb.jsstarts the server