Architecture Overview¶
wlite is a SQLite migration and query tool built in C. It is split into two layers: the wlite CLI (a thin command-line wrapper) and libwlite (the C library that does all the real work). Language bindings talk directly to libwlite through its C ABI. The architecture is deliberately minimal: no build system generated code, no runtime dependencies beyond SQLite3, and no hidden behavior.
This document describes the system from top to bottom: principles, layout, modules, data flow, complexity hotspots, performance characteristics, build system, and testing approach.
Design Principles¶
wlite follows three design principles inherited from dbwarden. These principles are non-negotiable and every design decision flows from them.
1. Single Source of Truth¶
The .wlite model file is the only thing you edit. Everything else (SQL, diffs, snapshots, migration plans) is derived from it. There is no separate SQL file to maintain, no ORM configuration, and no generated migration artifacts that drift out of sync. When you change the model, wlite computes the diff and generates the SQL. When you run the SQL against a live database, wlite can introspect the result and confirm the state matches the model.
This eliminates an entire class of bugs: the ones where your migration file says one thing but your schema says another. There is only one truth, and it lives in the .wlite file.
2. Plain SQL Output¶
No runtime, no generated Python, no hidden ORM behavior, no DSL that only works inside a specific framework. The output is SQL you can read, review, and execute anywhere. You can pipe it into sqlite3, into a CI pipeline, into a deployment script, or into a manual review process. The SQL is standard SQLite and nothing else.
This means wlite is safe to adopt incrementally. You do not need to rewrite your deployment process. You just add wlite plan to the beginning and wlite migrate to the end.
3. Small and Composable¶
libwlite is a single C library with no dependencies beyond SQLite3. Bindings are thin wrappers. The CLI is a thin shell. Complexity lives in the algorithm, not the framework. The entire library is roughly 12 source files. Each file handles one stage of the pipeline. You can read the whole thing in an afternoon.
This principle also means wlite composes well with other tools. It does not try to be your ORM, your query builder, your connection pool, or your migration runner. It plans and executes SQL migrations, and it runs queries. That is it.
System Layout¶
The system has three layers: the CLI, the C library, and the language bindings. Here is the full picture:
*.wlite model file
|
v
+-------------------+
| wlite CLI |
| (thin shell) |
+--------+----------+
|
calls libwlite C API
|
v
+-------------------+
| libwlite |
| (C library) |
+--------+----------+
|
uses SQLite3 C API
|
v
+-------------------+
| SQLite3 |
| (embedded DB) |
+-------------------+
The CLI reads .wlite files, calls libwlite functions, and prints results. It contains no schema logic, no parsing, and no migration planning. All of that lives in libwlite.
Language bindings bypass the CLI entirely and talk directly to libwlite:
+-------------------+ +-------------------+ +-------------------+
| Python binding | | Rust binding | | Go binding |
| (ctypes) | | (FFI / cc) | | (cgo) |
+--------+----------+ +--------+----------+ +--------+----------+
| | |
+-------------------------+-------------------------+
|
v
+-------------------+
| libwlite |
| (C library) |
+--------+----------+
|
v
+-------------------+
| SQLite3 |
+-------------------+
Every binding wraps the same set of C functions. No binding reimplements wlite semantics. This is critical: the diff algorithm, the planner, the migration executor, and the query engine all live in exactly one place.
Language Bindings¶
wlite provides bindings for six languages, all going through the C ABI:
libwlite C ABI
|
+-- C/C++ (direct / header-only wrapper)
+-- Rust (FFI via cc crate)
+-- Python (ctypes)
+-- Go (cgo)
+-- C# (P/Invoke)
+-- Zig (@cImport)
Each binding exposes the same core operations:
| Operation | C Function | Description |
|---|---|---|
| Load model | wlite_model_load_file() |
Parse a .wlite file into a wlite_model |
| Open database | wlite_open() |
Open a SQLite database file |
| Validate | wlite_model_validate() |
Validate model structure |
| Diff | wlite_diff() |
Compare model against database schema |
| Plan | wlite_plan() |
Convert a diff into ordered SQL statements |
| Migrate | wlite_migrate() |
Execute migration within a transaction |
| Query | wlite_prepare() / wlite_step() |
Run a SELECT with parameter binding |
| Transaction | wlite_begin() / wlite_commit() / wlite_rollback() |
Manual transaction control |
| Close | wlite_close() |
Close database connection |
| Free model | wlite_model_free() |
Free model and all owned objects |
The C API is documented in the header file wlite.h. The binding wrappers are intentionally thin: they translate between the host language's types and the C types, handle memory ownership, and nothing else.
Module Map¶
libwlite is organized into focused modules. Each module handles one stage of the pipeline. The table below lists every module, the stage it belongs to, and its purpose.
| Module | Stage | Purpose |
|---|---|---|
parser.c |
Input | Parse .wlite DSL into a WlSchema tree |
schema.c |
Core | Schema lifecycle, memory management, model API |
introspect.c |
Input | Read a live SQLite database into a WlSchema |
diff.c |
Compare | Compare two WlSchema instances, produce a WlDiff |
planner.c |
Plan | Convert a WlDiff into an ordered WlPlan of SQL statements |
migrate.c |
Execute | Run the plan within a transaction, record checksums, verify |
query.c |
Query | Prepared statements, parameter binding, result iteration |
record.c |
Query | Generic row access, column by name or index |
tx.c |
Query | Transactions and savepoints |
compile.c |
Format | Compile .wlite to .wlitem binary model format |
serialize.c |
Format | JSON and DSL serialization of schemas and diffs |
schema_inspect.c |
Bridge | Live database to WlSchema conversion (shared with introspect) |
The modules have a strict dependency order. parser.c depends on schema.c. diff.c depends on schema.c. planner.c depends on diff.c. migrate.c depends on planner.c. query.c depends on schema.c. No module depends on a module that comes after it in the pipeline. This makes the dependency graph a clean DAG with no cycles.
Data Flow Summary¶
The complete pipeline from model to SQL follows five stages. Each stage is documented in detail in the linked subpages.
Stage 1: Parse¶
parser.c reads a .wlite file and produces a WlSchema. The parser is a hand-written recursive descent parser. It does not use lex or yacc. The grammar is small enough that a hand-written parser is clearer and more maintainable.
The output is a WlSchema struct containing an array of WlTable structs, each containing an array of WlColumn and WlIndex structs. All strings are interned in a single arena allocator, so freeing a schema is a single operation.
Stage 2: Introspect¶
introspect.c reads the live SQLite database using PRAGMA table_info() and PRAGMA index_list(). It produces a second WlSchema that represents the current state of the database. This schema is compared against the parsed schema in the next stage.
Introspection is read only. It never modifies the database. It handles SQLite's various quirks around type affinity, default values, and constraint representation.
Stage 3: Diff¶
diff.c compares the two WlSchema instances and produces a WlDiff. The diff contains lists of tables to add, tables to remove, tables to modify, and tables to rebuild. Each modified table has a list of column changes and index changes.
The diff algorithm walks both schemas in parallel, matching tables and columns by name. It does not try to detect renames or moves. Renames are ambiguous and unreliable to detect, so wlite treats them as drop plus add.
Stage 4: Plan¶
planner.c converts the diff into an ordered WlPlan of SQL statements. The planner handles the ordering constraints: tables must be created before they can be referenced, columns must be added before they can have defaults, indexes must be created after their tables exist.
The planner also handles the cases where SQLite's ALTER TABLE is insufficient. When a column type change, a NOT NULL constraint addition without a default, or a constraint modification is detected, the planner generates a full table rebuild instead of a simple ALTER TABLE. These rebuilds are described in detail in the SQLite Patterns page.
Stage 5: Migrate¶
migrate.c executes the plan within a transaction. If any statement fails, the entire migration is rolled back. On success, the plan is recorded in the wlite_meta table with a checksum, so subsequent runs can detect whether the migration has already been applied.
The migration executor is intentionally simple. It runs SQL statements in order. It does not retry, does not back off, and does not do anything clever. The cleverness is in the planner.
Where Complexity Lives¶
Most SQLite schema tools are simple: they diff two schemas and produce ALTER TABLE statements. The complexity in wlite (and its predecessor dbwarden) comes from the cases where SQLite's ALTER TABLE is not enough.
Table Rebuilds¶
SQLite supports a limited set of ALTER TABLE operations:
ALTER TABLE ... ADD COLUMNALTER TABLE ... RENAME COLUMNALTER TABLE ... RENAME TABLEALTER TABLE ... DROP COLUMN(SQLite 3.35.0+)
Everything else requires rebuilding the table. This includes:
- Changing a column type
- Adding a NOT NULL column without a DEFAULT value
- Changing a constraint (CHECK, UNIQUE, PRIMARY KEY)
- Reordering columns
- Dropping a column in older SQLite versions
A table rebuild in SQLite works like this:
- Create a new temporary table with the desired schema
- Copy all data from the old table into the new table
- Drop the old table
- Rename the temporary table to the original name
- Recreate all indexes
This is expensive for large tables, but it is the only way to handle these cases in SQLite. wlite automates this entirely.
Collapse Logic¶
When a table needs multiple changes (for example, both a column type change and a new index), wlite collapses them into a single rebuild. It would be incorrect and inefficient to rebuild the table once for the column change and then again for the constraint change. The collapse logic in planner.c detects when a table is scheduled for multiple rebuilds and merges them into one.
The collapse logic also handles the interaction between column changes and index changes. If a column is modified and an index on that column is also modified, both changes are applied in the same rebuild. The planner tracks which tables have been scheduled for rebuild and skips redundant operations.
Default Value Handling¶
SQLite represents DEFAULT values in PRAGMA table_info() using SQLite's own formatting. This means DEFAULT 0 might come back as DEFAULT '0', or DEFAULT CURRENT_TIMESTAMP might come back as a string. wlite normalizes these representations during introspection so they can be compared against the model's defaults. Without this normalization, every migration would detect a spurious diff on every column with a default value.
Size and Performance Characteristics¶
libwlite is designed to be small and fast.
Code Size¶
- Single C library, approximately 12 source files
- No dynamic memory allocation for schema operations (uses caller-provided buffers)
- Compiles in under a second on modern hardware
- The CLI binary is under 200KB when statically linked
- Total line count (including comments and whitespace) is roughly 8,000 to 10,000 lines
Memory Model¶
Schema operations use arena allocation. When you load a schema, all strings and structs are allocated in a single contiguous block. Freeing the schema frees the entire arena in one operation. There are no individual free() calls for schema components. This makes memory management predictable and leak free.
Query operations use SQLite's own memory management. Prepared statements are created and destroyed through the standard SQLite API. wlite adds no additional allocation layers.
Performance Profile¶
Migration speed is limited by SQLite I/O, not by wlite's diffing or planning. For typical schemas (under 100 tables), the planning phase takes microseconds. The actual migration time depends on the size of the tables being rebuilt.
Query performance is identical to raw SQLite. wlite adds no overhead to query execution beyond the cost of preparing statements and binding parameters.
The diff phase is O(n) where n is the total number of tables and columns in both schemas. For any realistic schema, this is effectively instantaneous.
How wlite Compares to dbwarden Architecturally¶
wlite is the C rewrite of dbwarden. The architecture is the same: single source of truth, plain SQL output, small and composable. The difference is the implementation language and the scope.
What Stayed the Same¶
- The diff algorithm is identical
- The planner logic is identical, including table rebuilds and collapse behavior
- The migration execution model is identical (transactional, checksum recorded)
- The design principles are identical
What Changed¶
| Aspect | dbwarden | wlite |
|---|---|---|
| Language | Python | C |
| Runtime dependency | Python 3, sqlite3 module | None (statically linked SQLite3) |
| Binary size | N/A (interpreted) | Under 200KB |
| Startup time | Python interpreter startup | Near instant |
| Bindings | Python only | C, C++, Rust, Python, Go, C#, Zig |
| Embeddability | Requires Python runtime | Can be linked into any C program |
| Memory model | Garbage collected | Arena allocation, manual but predictable |
Why the Rewrite¶
dbwarden is a good tool, but Python has limitations for this use case:
- Python startup time makes it unsuitable for being called many times in a build pipeline
- The Python runtime is a large dependency for a tool that should be a single binary
- Embedding Python in other programs is possible but awkward
- ctypes bindings from other languages to Python are fragile and slow
wlite solves all of these problems while keeping the same architecture and the same behavior.
Build System Overview¶
wlite uses two build systems: Make for development and CMake for packaging and distribution.
Makefile¶
The Makefile builds the wlite CLI. It requires libwlite to be installed or built nearby:
make # build the CLI
make test # build the CLI (prints a reminder to run it)
make clean # remove build artifacts
make install # install CLI to /usr/local/bin
The Makefile compiles cli/main.c and links it against libwlite and SQLite3. It uses pkg-config to find libwlite if installed system-wide, otherwise falls back to a local build directory.
Key Makefile targets:
| Target | Description |
|---|---|
all |
Build the wlite CLI executable |
test |
Build the CLI and print a usage reminder |
clean |
Remove the built wlite binary |
install |
Install the CLI to $(DESTDIR)/usr/local/bin |
Key Makefile variables:
| Variable | Default | Description |
|---|---|---|
LIBWLITE_DIR |
../libwlite |
Path to the local libwlite build directory |
LIBWLITE_CFLAGS |
-I$(LIBWLITE_DIR)/include |
Compiler flags for libwlite headers |
LIBWLITE_LDFLAGS |
-L$(LIBWLITE_DIR) -lwlite |
Linker flags for libwlite |
CC |
gcc |
C compiler |
CFLAGS |
-Wall -Wextra -std=c11 -pedantic -O2 |
Compiler flags |
LDFLAGS |
(empty) | Additional linker flags |
LIBS |
-lsqlite3 |
Libraries to link |
CMake¶
CMake is used for packaging and for integration with projects that already use CMake. The CMakeLists.txt file defines:
- The
wlitelibrary target (static and shared) - The
wlite-cliexecutable target - Header installation
- pkg-config file generation
- Integration with FetchContent for downstream projects
Downstream projects can use wlite via CMake's FetchContent:
FetchContent_Declare(
wlite
GIT_REPOSITORY https://github.com/dbwarden-org/wlite.git
GIT_TAG main
)
FetchContent_MakeAvailable(wlite)
target_link_libraries(myapp PRIVATE wlite)
CMake Options¶
The CMake build supports the following options:
| Option | Default | Description |
|---|---|---|
WLITE_BUILD_SHARED |
ON | Build the shared library |
WLITE_BUILD_STATIC |
ON | Build the static library |
WLITE_BUILD_CLI |
ON | Build the CLI executable |
WLITE_BUILD_TESTS |
ON | Build the test executables |
Compiler Requirements¶
wlite requires a C11 compiler. It uses no compiler extensions. It has been tested with:
- GCC 9 and later
- Clang 10 and later
- Apple Clang 12 and later
- MSVC 2019 and later (with CMake)
The only external dependency is SQLite3, which is found via pkg-config or CMake's find_package.
Testing Approach¶
wlite has three categories of tests: unit tests, integration tests, and binding tests.
Unit Tests¶
Unit tests exercise individual functions in isolation. The test suite is built from three files in tests/:
| Test File | Description |
|---|---|
test_wlite.c |
Core library unit tests covering parser, diff, planner, migrate, query, and introspect modules |
test_edge_cases.c |
Edge case tests for boundary conditions, error paths, and unusual inputs |
conformance.c |
Cross-language conformance tests verifying C API behavior matches bindings |
Unit tests use a minimal test framework that is part of wlite itself (no external test dependencies). The framework provides ASSERT, ASSERT_EQUAL, and ASSERT_STRING macros. Tests return 0 on success and 1 on failure.
Integration Tests¶
Integration tests exercise the full pipeline from .wlite file to SQL output. They are written as shell scripts in tests/integration/. Each test:
- Creates a temporary
.wlitefile - Runs
wlite planand captures the output - Runs
wlite migrateagainst a temporary database - Runs
wlite queryto verify the result - Compares output against expected results
Integration tests cover the common migration scenarios:
- Creating a new table
- Adding a column
- Changing a column type (table rebuild)
- Adding an index
- Removing a table
- Complex multi-table migrations
- Idempotent migrations (running the same plan twice)
Binding Tests¶
Each language binding has its own test suite that verifies the binding correctly wraps the C API. These tests are in their respective directories:
bindings/python/tests/
bindings/rust/tests/
bindings/go/tests/
bindings/csharp/tests/
bindings/zig/tests/
Binding tests focus on:
- Correct memory management (no leaks)
- Correct error propagation
- Correct type marshaling between the host language and C
Test Runner¶
CMake builds run tests via ctest after building with -DWLITE_BUILD_TESTS=ON. The CMake test suite includes:
test_wlite— core library unit teststest_edge— edge case teststest_conformance— conformance tests
The Makefile's test target builds the CLI but does not run any tests — it prints a reminder to run ./wlite version.
CI runs the full test suite on every push, across Linux, macOS, and Windows (via MSVC and MinGW).
Summary¶
wlite is a small, focused tool for SQLite schema management. Its architecture is a clean pipeline: parse, introspect, diff, plan, execute. The design principles (single source of truth, plain SQL output, small and composable) constrain every decision. The complexity lives in the planner, specifically in table rebuilds and collapse logic. The rest of the system is deliberately simple.
The C library is embeddable, the bindings are thin wrappers, and the build system is minimal. The testing approach covers unit, integration, and binding levels. The result is a tool that is fast, predictable, and safe to use in production.