Local-First Prior Art β How Others Did It, Their Weaknesses, Our Workarounds¶
β Back to the User Guide Β· Home
Scope. Research companion to ERP.md Β§0.20 (secured/durable next phase). For each production
local-first system: how it works, its documented weakness, and the workaround our architecture
(kernel_ops deterministic semantic verbs + two-domain async split + native SQL + BIM/ERP unification)
opens. Researched 2026-05-31; sources at end. Non-invent: weaknesses are each system's own documented
trade-offs, not our characterisation.
The one lever that changes everything: deterministic replay of semantic verbs¶
Every system below pays a tax our prime directive (deterministic, non-invent) waives for free. Their
common problem: the server and the client run different code (or different merge rules), so they must
either (a) hand-code conflict logic per mutation, (b) overwrite the user's optimistic state when the server
disagrees, or (c) implement business logic twice (client + backend). Because kernel_ops verbs are
deterministic and replayable (proven: replay-hash == live-hash, Β§0.16/Β§0.19), the same kernel runs
in both domains (ERP.md Β§701 β "the same core runs under sql.js (browser) and server"). Server result
== client result by construction. That single property neutralises the central weakness of Replicache,
PowerSync, and ElectricSQL at once.
Per-system: mechanism β weakness β our workaround¶
1. Replicache β server re-runs mutations as authority¶
- How: client mutators run optimistically; push endpoint re-executes them on the canonical store; pull rebases (rewind to last confirmed state β apply server patch β replay pending mutations).
- Documented weakness: the docs state the server "is not necessarily expected to compute the same result" as the client β on rebase, optimistic changes can be overwritten (flicker / lost work); mutators must be re-execution-safe (run β₯3Γ); conflict logic is hand-coded per mutator; no zero-server mode β a backend of authority is mandatory (offline operation works, but a server remains the source of truth).
- Our workaround: determinism removes the divergence β our verbs compute the same effect on both sides, so there is no "server disagrees β overwrite" case and no per-mutator conflict code. And we do have a true offline-only mode (single-user, zero server) that Replicache structurally cannot.
2. ElectricSQL β retreated to read-path-only sync¶
- How: syncs data out of Postgres to clients via HTTP "Shapes" (read path). Writes are NOT in the sync engine β you send them through "your existing REST API."
- Documented weakness: this is an explicit retreat β the blog admits bidirectional sync's "conflict resolution / consistency / operational complexity" is "fundamentally difficult," so they sidestepped writes entirely. Result: two disjoint paths (sync for reads, your API for writes), no unified model β reads sync from Postgres via logical replication, while writes live outside the sync engine in your own API.
- Our workaround: the op-log is symmetric β the ops you push are the same ops that drive reads (one model, the log). We never need a separate write API, and there's no Postgres coupling (sql.js is the store).
3. PowerSync β server authority, writes through your backend¶
- How: PostgresβSQLite via logical replication; op-history kept in the PowerSync Service (not your DB); writes routed through your own backend so you apply business logic / authz / validation; causal consistency.
- Trade-off (by design): the backend is the write authority (default last-write-wins), so write-side business logic and conflict policy live server-side β the client writes optimistically to local SQLite but is not the authority. Symmetric, offline-authoritative write logic is out of scope.
- Our workaround: the "business logic on the write path" is our deterministic kernel β authored once, run on both sides. No double-implementation, no client/server rule drift (the thing PowerSync makes you maintain by hand).
4. LiveStore β nearest neighbour on the data layer (event-sourced, SQLite-materialised)¶
- How: events in an eventlog β materialised into reactive client SQLite via materialisers; git-inspired sync; pluggable sync backend (Cloudflare / Electric / S2).
- Documented weakness: BETA (v0.4); requires a sync-provider backend (no hosted service); "doesn't sync with existing databases," "doesn't scale for unbounded data," "no P2P," and explicitly "not batteries-included β no built-in auth or file uploads."
- Our position (not a workaround β an honest delta): LiveStore is our nearest neighbour on the data layer (event β reactive SQLite materialisation), the way SQLSync (Β§6) is our nearest neighbour on the determinism lever (one reducer both sides) β together they show we hold no technique novelty on either axis in isolation. Conceptually almost identical at the data layer. Differentiators are application-level: we span BIM geometry + ERP under one log, ship a real domain reduction (iDempiere AD β 5 tables + verbs), and are batteries-included (AD-derived rules/validation). LiveStore is infrastructure; we are an application on the same idea. Its "no auth / doesn't scale unbounded" gaps confirm these are industry-wide, not ours alone.
5. CRDTs (Automerge 3.0 / Yjs) β guaranteed convergence¶
- How: mathematical merge β all replicas converge with no central referee.
- Documented weakness (from the CRDT literature): "CRDTs can only handle invariants that do not depend on the most up-to-date version" β they cannot enforce business invariants (transactional edits, permissions); the field says use OT or server-authoritative sequencing for those. Also: no built-in access control, tombstone/GC overhead, and "hard to use beyond what the library anticipated."
- Our workaround: we already chose the right tool (Β§0.18c) β a semantic op-CRDT (CmRDT) carrying ERP intent, with a designated-owner node at document-handoff seams (Β§18.7). That is the "server-authoritative sequencing for invariants" the literature prescribes. We do not put money- touching invariants under generic LWW (which silently loses an allocation). So the #1 CRDT weakness is sidestepped by design, not patched.
6. SQLSync (Carl Sverre) β deterministic reducer + git-style rebase (our true nearest neighbour on the lever)¶
- How: mutations are ops; a reducer compiled to WASM runs identically on client and server; on reconnect the client rewinds β applies the server-ordered log β replays pending local ops (a git-style rebase). The server is the sequencer/authority that assigns the canonical op order.
- Documented weakness: the reducer is Rust compiled to WASM (custom build, opaque to inspection); a server of authority is mandatory (it owns the sequence β no zero-server total order); the SQLite is a replica driven by the reducer, so you don't query/extend it with arbitrary SQL the way a native store allows.
- Our position (validation, not a workaround): SQLSync's "same reducer both sides β server result ==
client result by construction" is our determinism lever, already shipped by someone else β it is a
closer neighbour than LiveStore (Β§4) for the one-kernel-both-sides claim, and it tempers any
"first to our knowledge" framing of that lever specifically. Our deltas: kernel verbs are plain JS over
real sql.js (full joins/FK, runs server-side as-is, no WASM toolchain) and we add W-CHAIN/W-SIGN
(SQLSync has no tamper-evidence). What we lack that SQLSync has: the multi-writer total order β we
order by local
idrowid (two devices both emit id 1,2,3 with diverging chains and no merge path inkernel_ops.js). Borrow: adopt SQLSync's rebase loop as our Β§0.20 owner-seam protocol β the server assigns the sequence, the client rebases pending ops on top. We are already set up for it:sealChain()is a full recompute, so after a rebase re-orders ops under the server's sequence we simply re-seal and the hash chain stays valid. This gives multi-writer ordering without a row-level CRDT and preserves invariant rejection (the server reducer can refuse an op) β the thing cr-sqlite (Β§7) structurally cannot.
7. cr-sqlite / Vulcan (vlcn.io) β row-level CRDT, decentralized, no sequencer¶
- How: a C extension compiled to WASM turns ordinary tables into CRRs (conflict-free replicated
relations) β per-row/per-column causal merge keyed on a
site_id+ a logical clock (crsql_db_version). Two offline writers merge automatically with no central referee. - Documented weakness: it is state/LWW-flavoured per column β it carries no intent, so it inherits the core CRDT limit (Β§5): it cannot enforce business invariants (it will converge to a consistent but wrong number β e.g. silently allow a double-allocation of the same pallet); no built-in access control; tombstone/GC overhead.
- Our workaround / fit: for money-touching ops, cr-sqlite is the wrong tool β same reason we rejected
generic LWW (Β§5, Β§0.18c): we keep
documents/journalinvariants at the deterministic kernel + owner seam, not under automatic row-merge. But its primitive is the right one for the other half of our unified log: BIM geometry edits are commutative-ish and LWW-safe (a moved grid line has no double-spend invariant), so a two-tier merge β cr-sqlite-stylesite_id+clock auto-merge for geometry ops, owner-seam serialization for money ops β fits our BIM+ERP-under-one-log unification cleanly. This is the concrete decentralized-multi- writer path we currently lack, applied only where it's safe. Never routeFact_Acctthrough it.
Cross-cutting weaknesses (RxDB's "downsides" list) β ours vs theirs¶
| Industry weakness (RxDB) | Their exposure | Our angle |
|---|---|---|
| Initial full-dataset download | must replicate whole dataset to client | Already solved β split-DB streaming (positions/metadata/geometry tiers; initbubble.json 2KB) β lazy fetch, never download all |
| Non-persistent storage (Safari evicts after 7 days) | universal browser gap | Mitigate same way: navigator.storage.persist() + the signed op-log spilled to any durable replica β cloud or the user's own email/social (DistributedERP.md Β§5.2b). The source of truth is the signed log itself, not a host β determinism replays any replica to identical state, so the local copy is disposable (W-PERSIST). Shared gap, no magic |
| Conflict resolution complexity | hand-rolled per collection | Semantic op-CRDT + owner node (Β§18.7) β concentrated at few seams, not per-collection |
| Eventual consistency unsafe for finance | "risk in banking/financial apps" | money-touching invariants are serialised at the one total-order / CAS seam (DistributedERP.md Β§5-6) + enforced by the deterministic kernel on replay β not routed through a re-running server; single-user offline is safe (one writer). We do not pretend offline multi-writer finance is safe |
| Schema migration to N offline clients | "weeks-long unpredictable windows" | Partial lever: compiled-AD manifest (recompile UI structure) + forward-only rules / frozen-effects replay (Β§0.16). Shared hard problem β honest about it |
| No SQL joins / relational limits | document-CRDT systems can't join | We are stronger here β sql.js is real SQLite: full joins + FK (the AD/BOM spine). Native relational, not a document store |
Summary β where we genuinely win, and where we just share the pain¶
Real workarounds (our architecture removes their tax): 1. Deterministic semantic verbs β one kernel both sides β kills Replicache's overwrite-on-rebase, PowerSync's double-implementation, and ElectricSQL's split read/write model in one stroke. 2. Symmetric op-log β reads and writes are the same ops; no separate write API. 3. Semantic op-CRDT + owner node β enforces business invariants CRDTs structurally cannot. 4. Native SQL + relational β joins/FK that document-CRDT systems lack. 5. Split streaming β instant first paint vs full-dataset download.
Shared weaknesses β no magic, be honest (these become the Β§0.20 phase work):
- Browser-storage eviction β persist() + cloud-of-truth (W-PERSIST).
- Cross-client schema migration β compiled-AD manifest + forward-only rules (partial).
- Tamper-evidence is nobody's default β our distinctive add: hash-chain/sign the log (W-CHAIN/W-SIGN).
- Secured multi-user needs a trust anchor β every system above has a server; ours is the async
server domain (Β§0.20), reached only when single-user/offline no longer suffices.
Concrete borrows for the Β§0.20 phase (from Β§6/Β§7, the two systems we hadn't logged):
1. Multi-writer ordering = SQLSync's rebase loop (Β§6) β server assigns canonical sequence, client
rewinds β applies server log β replays pending local ops. We currently order by local id only; this fills
the deferred owner-seam total order and keeps invariant rejection (server reducer refuses bad ops).
Cheap for us because sealChain() already full-recomputes β rebase, then re-seal.
2. Two-tier merge = cr-sqlite primitive only for geometry (Β§7) β site_id+logical-clock auto-merge for
BIM/geometry ops (LWW-safe, no invariant); owner-seam serialization for money ops. Decentralized multi-writer
exactly where it's safe, never on journal/Fact_Acct.
3. Incremental storage = OPFS (PowerSync/Notion, storage axis below) β replace kernel_ops.js's
whole-DB db.export()βIndexedDB blob (O(db size) per debounce, the I-D cost) with OPFS SAHPool incremental
writes. Free perf win, independent of any sync work.
The other axis β SQLite-WASM at scale (storage/engine, not sync) β added 2026-06-03¶
The systems above are sync frameworks. A separate question: who runs a large relational app fully in SQLite-WASM, and what does it cost? The canonical production example is Notion β and its architecture is our doctrine, shipped at scale.
- Notion (the existence proof): SQLiteβWASM in a Web Worker (UI thread unburdened), a SharedWorker
routing every tab's queries to one active tab's worker, on the OPFS SAHPool VFS. Postgres stays the
canonical source of truth (logical replication + WAL β the browser SQLite is a disposable projection).
(Notion blog,
HN) β this validates DistributedERP's
"the operator's install is the system of record; the browser is a projection" (
ENGINE_FULL_ERP_ISSUES.mdΒ§I-A). Notion chose server-of-record + local projection, NOT local-first-authoritative.
The hard constraints (each maps to one of our engine issues):
| SQLite-WASM finding | Source | Maps to |
|---|---|---|
| ~2 GB DB cap (up from 512 MB) | sqlite.org/wasm persistence | I-A storage cap is hard-bounded β gravity-sharding (DistributedERP Β§13) not optional at full-AD scale |
| OPFS = one read/write txn at a time | PowerSync state-of-persistence | single-writer holds at the storage layer too β reinforces G-SINGLE-WRITER / I-E from below |
| Multi-MB JS strings = measurable WASM-bridge cost | sqlite.org/wasm | exactly I-D (projection 52β336 KB re-export) β keep state in SQLite, don't marshal big strings out |
| SAHPool VFS = 3β4Γ I/O; 8β16 MB page cache = big win | PowerSync | free wins for the engine-perf lane (Agent E) before any algorithmic change |
| Needs SharedArrayBuffer + COOP/COEP headers; Safari <17 out | sqlite.org/wasm persistence | a deploy gate (OCI/GH-pages must send the headers) |
Why SQLite, not DuckDB-WASM or PGlite. DuckDB-WASM is OLAP (TB via Parquet range-requests) β wrong shape for transactional ERP. PGlite (full Postgres-in-WASM) is tempting for server-side logic in-browser, but iDempiere's business logic is Java, not PL/pgSQL, so it does not shortcut the callout port β and it is heavier than we need. SQLite-WASM (relational + R-tree + small footprint) is the right tier; the prior art confirms it.
Sources (storage axis): Notion WASM SQLite Β· SQLite WASM persistence Β· PowerSync β SQLite persistence on the web Β· DuckDB-WASM Β· PGlite.
Sources: RxDB β downsides of offline-first Β· Replicache β how it works Β· ElectricSQL β local-first with your existing API Β· PowerSync vs ElectricSQL Β· LiveStore Β· SQLSync Β· cr-sqlite (vlcn.io) Β· CRDT survey (Weidner) Β· Automerge.