Guidance for AI agents working in electric-circuits — an Electric-style reactive sync engine. App
writes go to Postgres; a Rust engine turns logical-replication changes into live shapes
(incrementally maintained, fully de-duplicated); durable streams is the log between them. Two
client surfaces: the Electric-compatible GET /v1/shape (works with the ElectricSQL TS client) and
the extended @electric-circuits/client API (shapes + subset queries + live aggregations — the surface
the project is growing toward).
| Path | What |
|---|---|
apps/engine |
Rust engine. Key files: engine/ (the engine module — sequencer.rs the LSN-ordered sequencer, lifecycle.rs shape creation/sharing/retention, circuit_serving.rs circuit-tier serving, executors.rs routers/filters/folds, planning.rs circuit placement, catalog.rs durable catalog, introspection.rs graph/state, membership.rs the shared membership kernel (flips, query-backs), output.rs envelope codec, mod.rs the Engine handle), arrangements.rs (the circuit: in-memory counts pipelines, group-aggregated boot seeding), subquery.rs (cross-table registry: shared inner-set nodes, flips, absolute emission), replication.rs (streaming pgoutput ingestor) + pgoutput.rs (message decoder), pg.rs (backfill + SnapshotGate), electric.rs (/v1/shape), where_sql.rs/sql.rs (SQL⇄predicate), ds.rs (streams client incl. append_reliable). |
apps/api |
tRPC API (router.ts) over the engine + durable-streams (core.ts). |
packages/protocol |
Shared types + the change-event envelope (types.ts, envelope.ts). |
packages/client |
Browser client: shape(), subset() (see subset.ts — LSN watermarks + tombstones), aggregate(). All lifecycles tracked; close() is one-shot and deletes server-side with retry. |
packages/conformance |
The real test suite — engine vs oracle, incl. live replication, fuzz, NULLs, concurrency, shape sharing. |
packages/oracle |
Reference implementation shapes are checked against. |
packages/bench |
Benchmarks incl. the benchmarking-fleet runner (electric-bench-runner.ts, pnpm bench:fleet — auto-clones electric-sql/benchmarking-fleet). |
packages/loadgen |
Headless load generator (state-machine users; memory/CPU/disk sampling; Docker-scalable clients). |
electric-conformance/ |
Electric's own oracle/property/integration tests pointed at our /v1/shape. |
docker/ |
Containerized stack: compose.yaml (postgres + ds + engine + api), Dockerfile.engine, Dockerfile.node. pnpm docker:up. |
apps/pipeline-viz |
Live pipeline explorer (shapes, shared families/nodes, reactive per-node state + index dumps) over GET /graph + /state + /trace. |
examples/linearlite |
The flagship demo. scripts/linearlite.sh start <size> boots everything. |
README.md— the system in one page + the consistency model summary.docs/ARCHITECTURE.md— the as-built architecture: ingest,SnapshotGatefencing, sharing, subquery registry, reliability model, Electric adapter, client layer.docs/ivm-engine-internals.md— engine execution strategies + the analytical cost model, including the three-tier serving model (circuit/routing/fallback): seedocs/ivm-engine-internals.md#serving-tiers-compiled-routed-fallback.docs/live-queries-guide.md— user/integrator guide.docs/deployment-postgres.md— Postgres-as-source-of-record setup.- Each package has its own
README.md(surface, commands, env knobs).
The load-bearing mental model: pipelines are few and fixed; shapes are many and dynamic —
and the fan-out between them lives outside the circuit. A pipeline's output is keyed by
cohort groups (project, (project, status), aggregate group, …). A shape is a selection or
union over those groups, materialized as a per-shape stream at the delivery edge. Shape
cardinality can vastly exceed pipeline cardinality: a subquery shape filtering issues exists
per combination of projects a client asks for, yet every combination is fed from the same
issues_by_project pipeline — the circuit never grows with shape count, only the routing
table does. If a design makes the circuit's structure scale with shapes, users, or parameter
combinations, it is wrong (the circuit-per-shape trap: structure must never scale with
subscriptions).
The recipe for capturing an app's query set in one circuit:
- Enumerate call sites → collapse to templates. Parameters become data (keys in the output index, rows in an input relation) — never circuit structure.
- Find the access cohort (LinearLite: the project) and key every pipeline output by it,
never by user or shape. Per-shape work happens only at the fan-out edge: a shape = the set
of cohort groups its parameters select, unioned by delivery. The union is correct only when
the cohort key partitions the table (a row lives in exactly one group) — overlapping
groups would double-emit and need dedup at the edge. Genuinely per-user predicates
(
username = $me) get their own keyed feed — same pattern, cohort of size one. - Visibility relations drive membership through the registry: a membership table's deltas flip shared inner-set nodes, and move-in/move-out are parallel pooled Postgres query-backs with absolute per-pk emission through ordered lanes (row data lives in Postgres — there are no local row snapshots to read).
- Linear operators are free (filter / project /
map_index); aggregates knowingly — counts pipelines hold O(distinct groups) in memory (ARCHITECTURE.md§6b). Aggregate at the finest useful group grain and let the reader sum groups, so one pipeline serves every filter combination. - Structure ships with deploys. New templates = circuit rebuild + reseed (layout
fingerprint) or
Mode::Persistentbootstrap. Ad-hoc predicates that match no template fall back to the dynamic-shape path (standalone evaluator / KeyRouter / registry) — the circuit is an optimization tier, never a correctness dependency.
pnpm engine:build # cargo build -p electric-circuits-engine
pnpm engine:test # cargo test -p electric-circuits-engine (fast)
pnpm test # vitest run — full suite incl. conformance (~60s; boots its own PG)
pnpm test:conformance # just the conformance package
pnpm test:fuzz # random-predicate fuzz vs oracle
pnpm loop [N] # fuzz until failure; replay with SEED=<n>
pnpm demo:linearlite # LinearLite demo (ephemeral PG + engine + ds + api + vite + caddy)
pnpm bench:fleet # ElectricSQL benchmarking-fleet vs our /v1/shape (auto-clones)
pnpm docker:up # containerized stackBenchmarking against other Electric versions. The fleet runner also drives any Electric-compatible server instead of our stack — use this to baseline stock Electric releases against the same workloads:
# 1. Boot the target, e.g. stock Electric:
docker run -d --name electric-baseline -p 3000:3000 \
-e DATABASE_URL=postgresql://postgres:password@host.docker.internal:54321/electric \
-e ELECTRIC_INSECURE=true electricsql/electric:latest
# 2. Point the fleet at it (both vars required together; tables are dropped/recreated in that DB):
EXTERNAL_ELECTRIC_URL=http://localhost:3000 \
EXTERNAL_DATABASE_URL=postgresql://postgres:password@localhost:54321/electric \
BENCH_OUT=docs/bench/electric-fleet-results-baseline.md pnpm bench:fleetUse a distinct BENCH_OUT per target and diff the reports; BENCH_ONLY/BENCH_SCALE apply the
same way. (Our own image can also be the target — pnpm docker:up, then point at port 7010.)
There is no tsc typecheck gate — CI is vitest (esbuild, transpile-only). To check TS: run
pnpm test, or transpile-load a module with npx tsx -e "import(...)". Always run
pnpm engine:test + pnpm test before claiming done.
scripts/linearlite.sh start <size> — size = small|medium|large|xlarge|<issue count>; users and
projects scale with it (users ~√issues). Boots PG + ds + engine + API + web UI + the pipeline
explorer; stop tears down cleanly; status reports. One instance at a time (teardown is
pattern-based). Ports: DEMO_HTTPS_PORT (8443), DEMO_VIZ_PORT (5180), DEMO_VIZ=0 to skip.
packages/loadgen — USERS=100 SEED_ISSUES=20000 DURATION_S=90 pnpm --filter @electric-circuits/loadgen loadgen; SWEEP_USERS=… for comparison tables; Docker client scaling in packages/loadgen/docker/.
The streams layer is the Rust durable-streams server (group-commit WAL — appends batch under
concurrency); DS_MEMORY=1 still removes durability entirely for max-throughput runs
(--durability memory, Linux-only).
Start everything with one command (rebuilds the engine, boots an ephemeral throwaway Postgres
with wal_level=logical, durable-streams, the API, LinearLite, and the pipeline visualizer wired
to the engine):
pnpm demo:linearlite > /tmp/demo.log 2>&1 & # agents: run in background, tail the log
# ready when the log prints "👉 Open a URL above"Fixed URLs: LinearLite http://localhost:5174 (HTTPS/HTTP-2 https://localhost:8443), visualizer
http://localhost:5180 (https://localhost:5443). Ephemeral ports for the rest — grep the log:
postgres →, engine →, api →. DEMO_SEED_COUNT=<n> scales the faker seed (default 512 issues).
Data resets every run. Restarting: kill the previous run first or Vite silently binds 5175 —
pkill -f electric-circuits-engine; pkill -f caddy; pkill -f linearlite/start.ts, then relaunch (if a
port lingers: lsof -ti :5174 -ti :5180 | xargs kill).
The visualizer can also attach to any running engine on its own:
ELECTRIC_CIRCUITS_ENGINE_URL=http://127.0.0.1:<port> pnpm --filter @electric-circuits/pipeline-viz dev.
Its dev server proxies /engine/* → the engine control plane, so browser-side fetch('/engine/graph')
etc. work from the page — the backbone of the verification workflow below. A third way is the
containerized visualizer (docker/Dockerfile.viz): docker build -f docker/Dockerfile.viz -t electric-circuits-viz . && docker run -p 5180:5180 -p 5443:5443 electric-circuits-viz serves
http://localhost:5180 with Caddy proxying /engine/* to the engine; set ENGINE_UPSTREAM to
point it at another engine.
Use the Playwright MCP browser to drive both apps; keep LinearLite and the visualizer in two tabs
(browser_tabs to create/select, browser_navigate to each URL).
- Make the pipeline do something. Drive LinearLite: switch the "Viewing as" user
(
browser_select_optionon the sidebar<select>) to create/join that user's shapes; open the Board view or an issue detail for more; drag cards / edit issues for live writes. For surgical writes,psqlstraight at the demo Postgres (URL from the log) — replication picks it up. - Verify engine state from the viz page with
browser_evaluate— no CORS friction thanks to the proxy:await (await fetch('/engine/graph')).json()(shapes/nodes/edges),/engine/shapes/{id}(incl. retentionstate),/engine/metrics,/engine/state. - Verify the canvas against the engine. Count DOM vs graph:
document.querySelectorAll('.react-flow__node').length/'.react-flow__edge'vsgraph.shapes— nodes render immediately, edges must too (regression test: clear shapes via the trash button, switch user, edges must appear without a reload). - Verify animations deterministically via the sidebar Activity log (last 50 replicated
changes): click an entry with
browser_evaluateand sample in the same script — dot positions over time (.react-flow__edge g circle→getBoundingClientRect()), staged flash delays (.flash→--flash-delay), pulse stagger. Replay beats racing a live write's timing. - Eyeball it.
browser_take_screenshotafter driving — a blank or stale frame is a failure even when the DOM probes pass. Check the browser console output for React/engine errors.
Retention interplay while testing: an open LinearLite tab holds subscriptions (refcount ≥ 1), which
blocks dormancy for its shapes; GET /shapes/{id} is deliberately NOT a retention touch, so
polling it never keeps a shape alive. To exercise dormancy/eviction fast, boot with second-scale
knobs (ELECTRIC_CIRCUITS_SHAPE_IDLE_SECS=1 ELECTRIC_CIRCUITS_RETENTION_SWEEP_SECS=1 …) — see
packages/conformance/src/conformance-retention.test.ts for the canonical sequence.
This is a task-completion requirement, not a suggestion: an engine-touching task is not "done" until all three suites below have run green, and agents must run them (or report exactly which ran and why the rest could not) before closing the task.
pnpm engine:test # Rust unit + integration (fast)
ELECTRIC_CIRCUITS_ENGINE_PREBUILT=1 pnpm test # full vitest suite incl. oracle conformance (set the var iff you already built)
ASDF_ELIXIR_VERSION=1.18.4-otp-28 ASDF_ERLANG_VERSION=28.1 \
./electric-conformance/run.sh oracle # Electric's own oracle vs /v1/shape (needs elixir + ../electric)The vitest suite includes packages/conformance — the engine-vs-oracle harness — and runs
against the always-on circuit on every run (there is no off mode). The
ELECTRIC_CIRCUITS_DBSP_INDEXES/_COUNTS tunables decide which shapes the circuit actually serves
versus which fall through to the routing/fallback tiers. The electric-conformance line is
Electric's own oracle suite pointed at our /v1/shape — a separate tier from our conformance
package; run both. (The ASDF pins matter: ../electric asks for an Elixir that may not be
installed locally.)
E2E (browser) tier: for anything touching the engine's live path, shapes, or the visualizer,
finish by driving the demo as above (Playwright MCP runbook, §"Demo + visualizer") — the
suites don't render a canvas or exercise the browser, so a green run does not prove the live
UI path. A quick pass = boot pnpm demo:linearlite, drive a write, verify the shape stream and
canvas update, screenshot.
- Postgres is the system of record; the engine's hot path holds no table copy. Backfills read
matching rows in a
REPEATABLE READsnapshot. (The always-on circuit tier holds disk-spillable derived state — table arrangements + counts pipelines — rebuildable, with Postgres fallback for lookups, never the record of truth; seeARCHITECTURE.md§6b.) - Backfill↔live is fenced by xid visibility, NOT by LSN. Every seeded structure carries a
pg::SnapshotGate(frompg_current_snapshot()); a replicated change is skipped iff its xid was visible to that snapshot.commit_lsn < seed_lsnis only the fallback for changes without an xid. If you add a read path, use the gate. (Why: a commit's WAL record exists before it becomes snapshot-visible; LSN comparison drops rows in that window and duplicates at the boundary.) - Ingest is at-least-once; consumers restore exactly-once effect. The ingestor stamps
(commit lsn, xid, seq); the sequencer de-duplicates by(lsn, seq). Aggregates and subquery contributor weights are NOT idempotent under duplicates — never bypass the highwater. - Live shape appends must not drop. Use
ds.append_reliable(retry/backoff; 404 = shape dropped, discard). The sequencer's processed offset is published only after the whole batch landed, and each source transaction's appends are flushed before the next transaction is processed (per-transaction atomic emission). - Subqueries: emit outer membership absolutely — per touched pk,
upsertif the row matches now else idempotentdelete. Flip-driven query-backs run deferred on the flip-propagator task (out of commit order relative to the sequencer), so delta-based emission would miss move-outs. Symptom when wrong: op-by-op converges, batched mutations diverge. - NULL flips re-derive any dependent whose
INleaf is negated OR under aNot{…}(edgenull_sensitive). Plain-INdependents can't change (monotonicity over FALSE<UNKNOWN<TRUE). - Shape creation is atomic. On any failure, everything (record, share entries, registry refcounts/edges, stream) rolls back and the error propagates — including to joiners waiting on the share's ready-watch. Never leave a signature pointing at a dead stream.
- Sharing lifecycle: equal shapes share one id+stream, ref-counted; N joiners each release
exactly once. The final release does NOT delete anything: the shape stays active/warm and is
retired by the retention lifecycle (idle → dormant → evicted;
engine/src/retention.rs). Clientclose()must still be one-shot (a double delete steals another subscriber's refcount). - Shapes vs subset queries stay distinct. Ranges/
orderBy/limitlive ONLY in subset queries (never live-tailed); achanges_onlyfeed uses a passthrough gate and the client reads from the offset captured before the page snapshot. - Aggregations follow SQL NULL semantics (ignore NULLs;
COUNT(col)= non-NULLs; empty SUM/AVG/MIN/MAX = NULL). Extended API only — the Electric surface doesn't cover them. - Branch before committing if on the default branch.
pg_current_wal_lsn()is not a visibility fence. A commit's WAL record exists (and the LSN moves past it) before the transaction becomes visible to snapshots — the gap includes a WAL fsync. The xid gate (pg_current_snapshot()) decides both the dropped-row and boundary-duplicate cases exactly. Seepg.rs::SnapshotGate.- The client should still release what it creates (
track()inpackages/client/src/index.tsis the pattern), but the engine now has a server-side reaper: an unpaired create pins the shape active only until the retention sweeper's idle/dormancy/eviction layers retire it (engine/src/retention.rs). A leaked refcount (double create, missed release) DOES still pin a shape active forever — releases must stay paired one-to-one with creates. - Backfill and replication must produce byte-identical text values.
to_jsonb(t)renders timestamps ISO-T-style; pgoutput text-mode tuples use Postgres text output. Same cell, different string → broken retractions/routing/MIN-MAX. Backfill casts text-mapped columns with::text(pg.rs::row_json_expr). If you add a read path, match it. - Ingest is streaming pgoutput (walsender protocol via
pgwire-replication+ our ownpgoutput.rsdecoder, text-mode tuples, never thebinaryoption — binary values would break the byte-identity above). The slot ispgoutput-plugin; a publication<slot>_pub(FOR ALL TABLES, needs superuser) is created at setup. - Read raw stream envelopes, not stream-db's reconciled view, when you need every delta. A
subset's live feed must apply move-outs; stream-db no-ops a delete for a key it never inserted.
(
packages/client/subset.tsreads rawStreamEnvelopes.) - Deletes must leave tombstones across the page/live seam. An in-flight
loadMorewhose snapshot predates a delete would resurrect the row (or insert a ghost for a never-seen pk) unless the per-pk watermark survives the delete. (subset.tskeeps LSN tombstones, pruned when no page is in flight.) - Shape rows stringify the primary key (TanStack DB keys are strings); non-pk ints stay numbers. Normalize ids when cross-referencing shape rows against query-back rows.
- A subset whose predicate folds in live UI filters re-creates the engine feed on every click. Prefer per-facet feeds reused across filter changes + a client merge (identical predicates across users ⇒ shared engine families). LinearLite's browse list does this.
- The demo boots an ephemeral Postgres each run (
mkdtemp); data does not persist. Kill stale demos before restarting — a leftovertsx start.ts/caddykeeps the ports and serves stale code, which reads as a mysterious schema mismatch.scripts/linearlite.sh stop, orpkill -f electric-circuits-engine,pkill -f "tsx start.ts",pkill -f caddy. If two demos run, scope kills by port (ps -o ppid= -p $(lsof -ti :<httpsPort>)) — a SIGKILL mid-shutdown leaks the ephemeral Postgres. - Vite binds IPv6
[::1]only — prefer thehttps://localhost:8443Caddy proxy (HTTP/2 also dodges the browser's ~6-connection HTTP/1.1 cap that freezes multi-stream apps). The pipeline visualizer needs its Caddy front too (https://localhost:5443, auto-started by the demo): its/traceSSE + engine polling compete for the same connection budget, so open it over HTTPS in a browser — plainhttp://localhost:5180is forcurlonly. See.claude/skills/run-linearlite. - The durable-streams server is the Rust binary (crates.io
durable-streams, spawned by the drop-in wrapperpackages/ds-rust; self-provisions viacargo install, override withDS_RUST_BIN). Appends are group-commit WALfdatasync— no per-append fsync ceiling like the old Node test server.DS_MEMORY=1still works (ephemeral data dir;--durability memoryis Linux-only, macOS falls back towal). - Docker + pnpm: scripts that import workspace deps must live in a workspace package
(
docker/package.json) — runningtsx docker/x.tsfrom the repo root can't resolve them. - Verify against the live stack, not just types. A headless
tsxscript driving the real client against a running demo catches what the (absent) typechecker can't. Changing code means realigning docs in the same pass.
Do not commit or push unless explicitly asked. At handoff, report changed files, validation run, and suggested next commands.