Skip to content

russellromney/electrolite

Repository files navigation

electrolite

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.

Tiny Example

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.

Try It

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:web

Open 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 demo showing SQLite writes on the left and a live browser subscriber on the right

What Is A Shape?

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.

Use It Before npm

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 test

Run every package test:

npm run test:all

TypeScript Quick Start

Backend:

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:

  1. Browser asks for /electrolite/v1/projectTodos/project-123?offset=-1.
  2. Your TypeScript app checks the user can see project-123.
  3. Electrolite returns the current matching rows.
  4. Browser asks again with the returned offset and live=true.
  5. 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.

What Works Now

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.

Fanout Smoke Test

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:fanout

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

Future Direction

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.

Non-goals

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.

Roadmap

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

About

ElectricSQL-style reactive sync from SQLite to browsers

Resources

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors