The database is a library
The engine compiles into your binary or runtime — or into a C shared library any FFI language loads. No server, no socket: the file is yours, reads and writes happen in-process.
PHP · Python · Ruby · C# · Java (C ABI) · Rust · JavaScript (WASM)
$handle = $ffi->inlaysql_open('app.inlay');
The database is a server
inlaysql serve --mysql opens the file and speaks MySQL's
protocol. Every driver that already knows MySQL connects — no new
client library, no new configuration surface.
PHP · Python · Ruby · C#/.NET · Java · Node.js · Go
inlaysql serve --mysql app.inlay \ --tls-cert … --tls-required
Direction 1 — embed it, like SQLite
Download the prebuilt library from the release (macOS Apple silicon and Linux x86_64 today — the file layer is Unix-only), copy the loader for your language, and run plain SQL with bound parameters. Every loader below is complete — that really is the whole integration.
// PHP 7.4+: FFI is built in. The whole binding — copy into your project. $db = new InlaySQL('app.inlay'); $db->run('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)'); $db->run('INSERT INTO users (name) VALUES (?)', ['Ada']); $rows = $db->run('SELECT id, name FROM users'); // The ~40-line class this uses: final class InlaySQL { private \FFI $ffi; private $handle; public function __construct(string $path) { $this->ffi = \FFI::cdef(' typedef struct InlaysqlHandle InlaysqlHandle; InlaysqlHandle *inlaysql_open(const char *path); void inlaysql_close(InlaysqlHandle *handle); int inlaysql_exec(InlaysqlHandle *, const char *sql, const char *params, char **out_json); const char *inlaysql_last_error(void); void inlaysql_free_string(char *s); ', 'libinlaysql_ffi.dylib'); // .so on Linux $this->handle = $this->ffi->inlaysql_open($path); if (\FFI::isNull($this->handle)) throw new RuntimeException($this->ffi->inlaysql_last_error()); } public function __destruct() { $this->ffi->inlaysql_close($this->handle); } public function run(string $sql, array $params = []): array { $out = $this->ffi->new('char *'); $code = $this->ffi->inlaysql_exec($this->handle, $sql, $params === [] ? null : json_encode($params), \FFI::addr($out)); if ($code !== 0) throw new RuntimeException($this->ffi->inlaysql_last_error() . " — $sql"); $result = json_decode(\FFI::string($out), true); $this->ffi->inlaysql_free_string($out); return $result; // {"columns":[…],"rows":[[…]]} } }
From the release archive, poc.php is a runnable version with the error path shown. Laravel/Eloquent over this same file: the MySQL wire.
# Python: standard library only (ctypes). The whole binding. db = InlaySQL('./libinlaysql_ffi.so', 'app.inlay') # .dylib on macOS db.run('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)') db.run('INSERT INTO users (name) VALUES (?)', ['Ada']) print(db.run('SELECT id, name FROM users')['rows']) import ctypes, json class InlaySQL: def __init__(self, lib_path, db_path): self.lib = ctypes.CDLL(lib_path) lib = self.lib lib.inlaysql_open.argtypes = [ctypes.c_char_p]; lib.inlaysql_open.restype = ctypes.c_void_p lib.inlaysql_exec.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)] lib.inlaysql_exec.restype = ctypes.c_int lib.inlaysql_last_error.restype = ctypes.c_char_p lib.inlaysql_free_string.argtypes = [ctypes.c_char_p] lib.inlaysql_close.argtypes = [ctypes.c_void_p] self.handle = lib.inlaysql_open(db_path.encode()) if not self.handle: raise RuntimeError(lib.inlaysql_last_error().decode()) def run(self, sql, params=None): out = ctypes.c_char_p() code = self.lib.inlaysql_exec(self.handle, sql.encode(), json.dumps(params).encode() if params is not None else None, ctypes.byref(out)) if code != 0: raise RuntimeError(self.lib.inlaysql_last_error().decode()) result = json.loads(out.value) self.lib.inlaysql_free_string(out) return result
Runnable: poc.py in the release archive.
# Ruby: one gem (gem install ffi). The whole binding. db = InlaySQL.new('app.inlay') db.run('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)') db.run('INSERT INTO users (name) VALUES (?)', ['Ada']) p db.run('SELECT id, name FROM users')['rows'] require 'ffi'; require 'json' class InlaySQL module Native extend FFI::Library ffi_lib File.expand_path('./libinlaysql_ffi.dylib', __dir__) attach_function :inlaysql_open, [:string], :pointer attach_function :inlaysql_close, [:pointer], :void attach_function :inlaysql_exec, [:pointer, :string, :string, :pointer], :int attach_function :inlaysql_last_error, [], :string attach_function :inlaysql_free_string, [:pointer], :void end def initialize(db_path) @handle = Native.inlaysql_open(db_path) raise "open failed: #{Native.inlaysql_last_error}" if @handle.null? end def run(sql, params = nil) out = FFI::MemoryPointer.new(:pointer) code = Native.inlaysql_exec(@handle, sql, params && JSON.generate(params), out) raise Native.inlaysql_last_error unless code.zero? result = JSON.parse(out.read_pointer.read_string) Native.inlaysql_free_string(out.read_pointer) result end def close = Native.inlaysql_close(@handle) end
Runnable: poc.rb in the release archive.
use inlaysql::{Database, Value}; let mut db = Database::open("app.inlay")?; db.execute("CREATE TABLE IF NOT EXISTS docs ( id INTEGER PRIMARY KEY, title TEXT, body TEXT)", &[])?; db.execute("INSERT INTO docs (title, body) VALUES (?, ?)", &[Value::Text(title.into()), Value::Text(body.into())])?; let rows = db.query("SELECT id, title FROM docs WHERE id = ?", &[Value::Integer(id)])?;
Crate: inlaySQL/inlaysql — #![forbid(unsafe_code)], thread-per-handle MVCC.
import { openDatabase } from "@inlaysql/core"; import { opfs } from "@inlaysql/storage"; import { defineModel, field, install, repo } from "@inlaysql/orm"; const Page = defineModel("pages", { id: field.integer().primaryKey(), body: field.text().index("bm25"), embedding: field.vector(384).index("hnsw").embedFrom("body"), }); const db = await openDatabase({ source: opfs("app.inlay"), create: true }); await install(db, Page); await repo(db, Page).insert({ body: "full text" }); // embedding computed const hits = await repo(db, Page).search("full text", { mode: "hybrid" });
Runs in Node, Deno, Bun, the browser and edge runtimes. Live: the SDK demo. Repo: inlaySQL/inlaysql-js.
Direction 2 — the MySQL wire, for your ORM
Start the server once; every sample below assumes it is running. The
connection is plaintext until you hand it a certificate — and beyond
localhost that is not a suggestion: a --bind that reaches
another machine is refused unless the database has
accounts of its own, the bootstrap password is not empty, and
--tls-cert plus --tls-required are given.
inlaysql serve --mysql app.inlay --password-env INLAYSQL_PASSWORD # Beyond localhost — the server refuses to start without all of this: inlaysql user add app.inlay --user app --password-env INLAYSQL_PASSWORD --superuser inlaysql serve --mysql app.inlay --bind 10.0.1.14 \ --tls-cert server.pem --tls-key key.pem --tls-required
// PDO — the same shape Laravel/Eloquent runs on. A stock Laravel 11 // skeleton migrates and serves against this server today. $pdo = new PDO( 'mysql:host=127.0.0.1;port=3306;dbname=app;charset=utf8mb4', 'inlaysql', getenv('INLAYSQL_PASSWORD'), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ); $stmt = $pdo->prepare('INSERT INTO docs (title, body) VALUES (?, ?)'); $stmt->execute([$title, $body]); foreach ($pdo->query('SELECT id, title FROM docs ORDER BY id LIMIT 20') as $row) { echo $row['title'], PHP_EOL; } // Upsert and RETURNING work over the wire too: $pdo->prepare('INSERT INTO docs (id, title) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET title = excluded.title') ->execute([$id, $title]);
PHP binds integers as strings; the engine applies SQLite's comparison affinity, so WHERE id = ? with '1' finds the integer row.
import pymysql # or mysqlclient; SQLAlchemy: mysql+pymysql://… conn = pymysql.connect( host="127.0.0.1", port=3306, user="inlaysql", password=os.environ["INLAYSQL_PASSWORD"], ) with conn.cursor() as cur: cur.execute("INSERT INTO docs (title, body) VALUES (%s, %s)", (title, body)) cur.execute("SELECT id, title FROM docs ORDER BY id LIMIT 20") for row in cur.fetchall(): print(row)
Django/SQLAlchemy point their mysql backend at the server; the SQL surface caveats are documented.
require "mysql2" client = Mysql2::Client.new( host: "127.0.0.1", port: 3306, username: "inlaysql", password: ENV["INLAYSQL_PASSWORD"], ) statement = client.prepare("INSERT INTO docs (title, body) VALUES (?, ?)") statement.execute(title, body) client.query("SELECT id, title FROM docs ORDER BY id LIMIT 20") .each { |row| puts row["title"] }
Rails: point database.yml at the server with the mysql2 adapter.
using MySqlConnector; await using var conn = new MySqlConnection( "Server=127.0.0.1;Port=3306;User ID=inlaysql;Password=…"); await conn.OpenAsync(); await using var cmd = new MySqlCommand( "INSERT INTO docs (title, body) VALUES (@t, @b)", conn); cmd.Parameters.AddWithValue("@t", title); cmd.Parameters.AddWithValue("@b", body); await cmd.ExecuteNonQueryAsync();
Entity Framework: the Pomelo.EntityFrameworkCore.MySql provider rides the same wire. MySqlConnector speaks the binary protocol, which is how BLOB values are written.
try (Connection c = DriverManager.getConnection( "jdbc:mysql://127.0.0.1:3306/app", "inlaysql", password); PreparedStatement insert = c.prepareStatement( "INSERT INTO docs (title, body) VALUES (?, ?)")) { insert.setString(1, title); insert.setString(2, body); insert.executeUpdate(); }
Hibernate, jOOQ, MyBatis ride the same wire. JDBC uses the binary protocol — the one path that accepts BLOB writes today.
import mysql from "mysql2/promise"; // The other Node direction is embedding — see the tabs above, or the // live SDK demo. Over the wire: const conn = await mysql.createConnection({ host: "127.0.0.1", port: 3306, user: "inlaysql", password: process.env.INLAYSQL_PASSWORD, }); await conn.execute("INSERT INTO docs (title, body) VALUES (?, ?)", [title, body]); const [rows] = await conn.query("SELECT id, title FROM docs ORDER BY id LIMIT 20");
Node has both directions: the WASM SDK in-process, or mysql2 when one server process should own the file.
The fine print, stated
What crosses the C-ABI boundary: every statement returns JSON in one of
three shapes ({"kind":"ddl"},
{"kind":"written","rows":n,"last_insert_id":k},
{"columns":…,"rows":…}) — the same shapes on every surface
(WASM, wire, FFI). Params are a JSON array; a nested array of numbers is
a vector. Errors return non-zero and
inlaysql_last_error() carries the engine's message — there
are no numeric codes to learn. Over the wire, the SQL surface is the
limit, and the rule of this project is that a statement it cannot honour
is refused, never accepted and ignored.
| Works today | Plan around |
|---|---|
Joins, subqueries, CTEs (WITH RECURSIVE), set operations | Foreign keys are recorded but not enforced — SQLite's own default |
Upserts, INSERT … SELECT, RETURNING on INSERT/UPDATE/DELETE | ADD CONSTRAINT … FOREIGN KEY after creation can't be recorded — declare keys inside CREATE TABLE |
Full constraint DDL in CREATE TABLE; standalone ADD {INDEX|UNIQUE|CONSTRAINT}, TRUNCATE, RENAME | One handle, one thread (C ABI); BLOB writes need a binary-protocol driver (Java, .NET) |
Any declared type name — TIMESTAMP, JSON, LONGTEXT resolve under SQLite's affinities | No Windows library yet; the WASM module runs anywhere a browser or Node does |
Full detail: docs/server.md and docs/clients.md.