Zero Cliff in the Browser: Decoupling Database Migrations in WebAssembly
WebAssembly (WASM) and browser-based runtimes like Pyodide are quietly redefining the boundaries of application portability. Running full-stack, enterprise-grade Python frameworks directly inside the browser—with zero server costs, instant boots, and offline-first capabilities—is no longer a theoretical exercise. It is reality.
But porting database-driven applications to the browser sandbox exposes a massive "productivity cliff" where traditional server-side tools break down. Among these, database schema migrations present one of the most stubborn hurdles.
Here is how we bypassed the typical runtime hacks to implement first-class, dual-mode migrations inside Framework M using clean, decoupled architecture.
The WASM Productivity Cliff: Why Backend Tools Break
Standard enterprise web stacks are built under server-centric assumptions: multiple CPU cores, multi-threading, persistent block storage, and background task queues. The browser’s WebAssembly sandbox strips these away:
- C-Extensions as Brick Walls: Core cryptography or parsing libraries require compilation.
- Strict Single-Threading: Web Workers run in a strictly single-threaded environment. Python libraries that rely on greenlets (like SQLAlchemy's async layer) or standard thread pools to execute concurrency will fail or deadlock.
- Ephemeral Memory Filesystems: Emscripten's virtual filesystem (MEMFS) is in-memory by default. If your SQLite database writes to memory, all data disappears the second the user reloads the browser.
- Asyncio Loop Conflicts: Runtimes like Pyodide run on an active, persistent JavaScript event loop. Running standard Python CLI scripts that call
asyncio.run()raises the fatalRuntimeError: asyncio.run() cannot be called from a running event loop.
Faced with these constraints, standard migrations (e.g., Alembic) immediately choke.
The Decoupling Solution: Swapping Adapters at the Edge
Framework M is built on Hexagonal Architecture (Ports and Adapters). The core business logic is completely isolated from physical details like the database engine or concurrency model. It only interacts with abstract interfaces (Ports).
This decoupling is our operational superpower. To get the monolith booting client-side inside a Web Worker, we didn't rewrite any core code. Instead, we swapped out the adapters at the application boundaries:
- Persisted SQLite: We mounted Emscripten's IndexedDB filesystem (
IDBFS) at/dataand coordinatedpyodide.FS.syncfsto persist the SQLitedev.dbfile across page reloads. - Synchronous Concurrency: We bypassed async SQLAlchemy drivers (like
aiosqlite) and greenlet dependencies by translating them to synchronous, single-threaded connection execution in Pyodide.
But database migrations still had a major problem: Alembic's env.py execution flow.
Bypassing the Hacks: From Regex to Dual-Mode Migrations
Alembic migrations are executed by running an environment script (env.py). In standard async web setups, env.py executes using asyncio.run(run_async_migrations()).
Under Pyodide, this causes an event-loop conflict. To make it work in the browser, our initial experimental worker loaded the generated env.py file, read its text, and dynamically replaced the code using regex to strip the async runner:
// The old dynamic runtime hack
const content = env_py_path.read_text();
content = content.replace("asyncio.run(run_async_migrations())", sync_run_code);
env_py_path.write_text(content);
While this unblocked the demo, regex-patching compiled library scripts is fragile. If the Alembic version changes or the migration template shifts slightly, the regex breaks, and the playground crashes.
To make this enterprise-grade, we moved the complexity out of JavaScript and built Dual-Mode Migrations directly into the framework.
1. Connection-Sharing via config.attributes
Alembic provides a native, clean API to pass active connections from the runner (our CLI or test suite) down to the environment script using config.attributes. We updated the framework's template env.py to check for this shared connection first:
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
# Check if a connection was shared by the MigrationManager
connection = config.attributes.get("connection", None)
if connection is not None:
do_run_migrations(connection)
else:
# Standard flow: Resolve URL and connect asynchronously
asyncio.run(run_async_migrations())
2. Environment Auto-Routing via RuntimeMode.SYNC_WORKER
To distinguish this in-browser execution context from structural deployment patterns (like monolithic vs macroservice modes), we introduced a dedicated configuration enum: RuntimeMode.SYNC_WORKER.
The framework's MigrationManager detects this mode at runtime. If active, it strips the async driver prefix (e.g. +aiosqlite) from the database connection string, creates a standard synchronous SQLAlchemy engine, and passes the active connection directly to Alembic's config.attributes["connection"].
3. Idempotent Schema Tracking via TableRegistry.reset()
Unlike transient CLI invocations on a server, a browser Web Worker is long-running and manages state across multiple operations (including database resets and schema syncs).
To prevent metadata collisions and duplicate table registration crashes, we made the schema initialization idempotent. Calling initialize_database now dynamically clears prior states using TableRegistry.reset() before rebuilding metadata.
4. Lazy-Loading Heavy Enterprise Dependencies
In a browser WASM environment, third-party libraries that rely on native socket loops or platform-specific libraries will crash on import.
To solve this, we refactored adapters that depend on non-WASM libraries. For example, NatsEventBusAdapter now lazy-loads the nats package at call time instead of module-import time. This ensures that the framework's core codebase can be imported and initialized inside Pyodide without throwing ImportError exceptions for missing enterprise-scale drivers.
With these changes, the javascript regex hotpatching was completely eliminated. The same codebase now boots, runs, and migrates database schemas unmodified—whether it is running on a client's local IndexedDB filesystem in Pyodide, or scaling out across a distributed Kubernetes cluster.
Closing the Loop: The Edge-Native Database Lifecycle
By extending our ports and adapters down to the migration engine and connection pools, we've demonstrated that even stateful, lifecycle-heavy components like database migrations don't have to bind your application to a specific machine or runtime.
A truly zero-cliff codebase is one where the infrastructure bends to the environment, not the other way around. By supporting both async PostgreSQL clusters and client-side synchronous SQLite out of the box, Framework M is ready to run wherever your users are—from the browser tab to the cloud cluster—without changing a single line of your schema configurations.
