Electric-style sync for SQLite apps, embeddable in your app.
Electrolite brings Electric-style reactive Shapes to SQLite. Your app defines a subset of rows once, the browser gets an initial snapshot, and then SQLite changes stream into the browser as live logical updates.
Example: a user opens project p1. The browser should see todos where
project_id = "p1". If another request inserts, updates, moves, or
deletes one of those todos, the browser should update without a full page
refresh.
ElectricSQL's Electric Sync does this for Postgres: it exposes selected subsets of database rows called Shapes, sends an initial snapshot, then sends live logical changes. Electrolite is that idea for SQLite.
Electrolite does the SQLite version inside your TypeScript app using SQLite triggers, a durable change log, and an ordinary HTTP endpoint. The backend runs in plain Node using Node's built-in SQLite API. There is no native build, sidecar, npm install step, or separate sync service. The same small protocol can also be implemented by other embedded engines. Experimental ports exist for Python, Rust, Go, and Elixir under engines/.
Experimental software.
SQLite becomes a live backend for browser state without letting the
browser send SQL. Auth stays in your app, the sync endpoint is just a
normal Request -> Response handler, and the code is small enough to
read. The first target is small and medium apps where shared team,
workspace, project, or document Shapes should be cheap over ordinary
HTTP long-polling.
You define a server-owned row set:
projectTodos: shape({
table: "todos",
columns: ["id", "project_id", "title", "done"],
params: ["projectId"],
where: ({ params }) => eq("project_id", params.projectId),
authorize: ({ params, context }) => {
return context.user.projects.has(params.projectId);
},
})Then a browser can subscribe to:
/electrolite/v1/projectTodos/p1
Meaning:
Give me todos for project p1, if this user is allowed to see p1.
Then keep me updated.
Electrolite is not published to npm yet. For now, the easiest path is to use this repository directly. You need Node 24 or newer.
git clone https://github.com/russellromney/electrolite.git
cd electrolite
npm run demo:webOpen http://localhost:3000. The left side writes todos to SQLite; the
right side is a live Electrolite subscriber. Add, rename, delete, and
batch-write todos to watch the Shape update.
Electrolite exposes selected subsets of database rows called Shapes.
A Shape is just:
table + columns + filter + auth scope
Example Shapes include todos for one project, photos owned by one user, events for one account, or likes on photos this user may see.
In Electrolite today, a Shape is server-defined. It contains a source
table, a column allowlist, a predicate, an authorization scope, and a
schema version. The predicate language is intentionally small right now:
equality, IN, and conjunctions.
Browsers do not send arbitrary SQL. They request named Shapes that the host application has already defined and authorized.
That is the point. The browser can say "I want projectTodos/p1." It
cannot say "run this SQL I made up."
Applications can also register dynamic, server-owned Shape routes such
as /projects/:project_id/todos. The route turns request path/auth
context into a concrete Shape, and the normal authorizer checks the
generated authorization scope before SQLite is touched.
Until packages are published, treat Electrolite like a vendored library:
add this repository to your app as a git submodule, subtree, or copied
vendor/electrolite folder, then import the TypeScript-facing backend
API by path:
import {
createElectrolite,
eq,
shape,
} from "./vendor/electrolite/packages/electrolite-node/electrolite-node.ts";Serve the browser client from your app, or copy
clients/browser/electrolite.js into your frontend bundle.
The examples in this repo use the same path-import setup. There is no registry account, install token, or sidecar service involved.
Electrolite uses Node's built-in SQLite engine:
const electrolite = createElectrolite({ dbPath: "./app.db" });Run the main test suite:
npm testRun every package test:
npm run test:allBackend:
import {
createElectrolite,
eq,
shape,
} from "./vendor/electrolite/packages/electrolite-node/electrolite-node.ts";
const electrolite = createElectrolite<{ user: { projects: Set<string> } }>({
dbPath: "./app.db",
shapes: {
projectTodos: shape({
table: "todos",
columns: ["id", "project_id", "title", "done"],
params: ["projectId"],
where: ({ params }) => eq("project_id", params.projectId),
scope: ({ params }) => `project:${params.projectId}`,
authorize: ({ params, context }) => {
return context.user.projects.has(params.projectId);
},
}),
},
});
electrolite.executeBatch(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT 0
);
`);
electrolite.installTriggers("todos");
export async function GET(request: Request) {
const session = await getSession(request);
return electrolite.handle(request, {
user: { projects: await projectsForUser(session.userId) },
});
}Browser:
import { ShapeClient } from "./vendor/electrolite/clients/browser/electrolite.js";
const todos = new ShapeClient("/electrolite/v1/projectTodos/project-123");
todos.subscribe((rows) => {
renderTodos(rows);
});
todos.start();What happens:
- Browser asks for
/electrolite/v1/projectTodos/project-123?offset=-1. - Your TypeScript app checks the user can see
project-123. - Electrolite returns the current matching rows.
- Browser asks again with the returned offset and
live=true. - When matching SQLite rows change, the browser receives the change.
Under the hood, Electrolite installs SQLite triggers, records a durable logical change log, normalizes Shape handles against the SQLite schema, and uses replay boundaries so browsers do not publish half-applied batches.
You do not need to run a separate sync service for this path.
Electrolite currently supports server-defined Shapes with app-owned
authorization, initial snapshots, live=true long-polling, inserts,
updates, deletes, primary-key changes, and explicit batches. The browser
client has IndexedDB persistence, retry, replay draining, multi-tab
coordination, and durable log_id / shape_handle validation so cached
rows are only reused against the right SQLite log and Shape definition.
The backend also handles key-column metadata, composite primary keys, retained-log resync, schema-normalized predicates, and targeted live wakeups for affected Shapes. This is still early, but the main TypeScript path works end to end, with E2E tests and a basic real web app example.
The fanout demo starts many in-process browser clients against the embedded handler, puts them all into a live wait, performs one SQLite write, and checks that every client materializes the new row.
npm run demo:fanout
ELECTROLITE_FANOUT_CLIENTS=1000 npm run demo:fanoutOn one local run, a single SQLite write woke 1000/1000 live Shape
clients and all 1000 materialized the new row in about 100ms. This is a
smoke test, not a network benchmark.
Next likely work: React hooks, tiny framework examples, benchmark numbers for snapshot/replay/live fanout, and Shape diagnostics that explain predicates, key columns, trigger status, and suggested SQLite indexes. Retention auto-compaction, better shared-Shape fanout, cacheable response chunks, and optional object-storage mode for immutable authorized Shape chunks are also on the table. Offline writes and conflict handling are a separate later track.
Electrolite is not trying to be Postgres replication, arbitrary client-provided SQL, offline writes or conflict resolution in the first version, or a required standalone sync daemon.
See ROADMAP.md. Completed work is tracked in CHANGELOG.md.
For the user-facing TypeScript API, start with packages/electrolite-node/README.md. For other embedded engines, see engines/.
