Skip to content

Add MariaDB WASM support to the Playground CLI#3474

Draft
adamziel wants to merge 4 commits intotrunkfrom
adamziel/mariadb-wasm-cli
Draft

Add MariaDB WASM support to the Playground CLI#3474
adamziel wants to merge 4 commits intotrunkfrom
adamziel/mariadb-wasm-cli

Conversation

@adamziel
Copy link
Copy Markdown
Collaborator

@adamziel adamziel commented Apr 8, 2026

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/mariadb package 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=mariadb and --mariadb-wasm-module flags — 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.wasm artifacts.

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 run in packages/playground/mariadb — 22 tests pass (bridge + protocol server)
  • Manual: npx nx dev playground-cli server --database=mariadb --mariadb-wasm-module=/path/to/mariadb.js starts the server
  • Verify WordPress installation completes with MariaDB backend

adamziel added 3 commits April 7, 2026 21:46
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.
@adamziel adamziel requested review from a team, Copilot and zaerl April 8, 2026 11:00
@adamziel adamziel marked this pull request as draft April 8, 2026 11:02
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/mariadb package (WASM bridge + MySQL protocol TCP server) with Vitest coverage and NX/Vite build config.
  • Extends Playground CLI with --database=mariadb and --mariadb-wasm-module, starting the embedded MariaDB server before spawning PHP workers.
  • Updates WordPress boot to persist runtime DB constants into wp-config.php for 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.

Comment on lines +2 to +8
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;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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));

Copilot uses AI. Check for mistakes.
Comment on lines +248 to +254
const rc = this.api.mysql_server_init(
serverArgs.length,
argv,
0
);
if (rc !== 0) {
throw new Error(`mysql_server_init failed with code ${rc}`);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);
}

Copilot uses AI. Check for mistakes.
Comment on lines +414 to +421
const len = lengthsPtr
? this.module.getValue(lengthsPtr + i * 4, 'i32')
: 0;
if (len > 0) {
row.push(this.module.UTF8ToString(strPtr));
} else {
row.push('');
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +216
const DATA_DIR = '/usr/local/mysql/data';
const requiredDirs = [
'/usr',
'/usr/local',
'/usr/local/mysql',
DATA_DIR,
DATA_DIR + '/mysql',
'/tmp',
];
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +472 to +480
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.
}
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +77
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]);
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +440 to +452
// 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++;
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1096 to +1112
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}`
);
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +104 to +112
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);
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
return Buffer.concat(parts);
}

describe('MySQL Protocol Server', () => {
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants