LiveStore 0.5.0 Integration & Architectural Spike
This document records the empirical results and architectural verification for integrating LiveStore 0.5.0 into @actdim/livestruct. Conducted under issue spike--livestore-integration.
Executive Summary: GO Verdict
All technical feasibility questions (Q1 through Q9) have been evaluated with concrete evidence, runnable tests, and benchmark measurements in both Node.js (Vitest) and headless browser (Chrome) environments:
- Verdict: GO for LiveStore 0.5.0 in
feat--livestore-engine. - UI Thread Commit Latency: p95 = 0.55 ms on a 50-shape CAD document (well below the 4.0 ms mitigation threshold).
- Long Tasks: Zero long tasks (>16.6 ms) observed during continuous 60 fps commit streams.
- Cross-Context Sync: Validated across host and 2 same-origin iframes with dedicated session IDs (median latency 18.0 ms).
- RFC 7396 Merge Patch: SQLite native
json_patch(body, ?)fully verified for deep nested merges, null deletes, and array replacement.
Detailed Investigation (Q1 - Q9)
Q1: Package Version Pinning & Monorepo Build Cleanliness
Question: Exact versions to pin (@livestore/livestore, @livestore/adapter-web, @livestore/wa-sqlite, effect + peers) through the pnpm catalog; does pnpm install + tsc -b of the whole workspace stay green?
Evidence & Configuration: The following exact versions are pinned in pnpm-workspace.yaml catalog and package overrides:
@livestore/livestore:0.5.0@livestore/adapter-web:0.5.0@livestore/wa-sqlite:0.5.0effect:4.0.3@effect/platform-node:4.0.3@effect/platform-browser:4.0.3(overridden inpackage.jsonto prevent resolution of mismatched 4.1.x)@effect/platform-node-shared:4.0.3@effect/opentelemetry:4.0.3@opentelemetry/api:1.9.0@standard-schema/spec:1.1.0
Outcome:
pnpm installcompleted with code 0 and zero unmet peer dependency warnings for Effect.pnpm -r --if-present run typecheckacross all 18 workspace projects passed with 0 errors.
Q2: Node Environment & Vitest In-Memory Store
Question: A Vitest node test creates a store with makeInMemoryAdapter(), commits, queries synchronously, subscribes (record the vitest config needed).
Evidence & Test Path:
- Test file:
spikes/livestore/store-node.test.ts - Verified:
createStorePromiseinstantiates in-memory store in Node;store.commitexecutes synchronously;store.queryreturns updated rows immediately;store.subscribedelivers synchronous callback notifications. - Vitest configuration needed (
vitest.node.config.ts): LiveStore's@livestore/sqlite-wasmmodule defaults to browser wasm loader (mod.browser.js), which imports@livestore/wa-sqlite/dist/wa-sqlite.mjs. In Node.js, this fails without the Node wa-sqlite factory. Resolved by adding the alias tovitest.node.config.ts:tsimport { createRequire } from 'module'; const require = createRequire(import.meta.url); const waSqliteNodePath = require.resolve('@livestore/wa-sqlite/dist/wa-sqlite.node.mjs'); export default defineConfig({ resolve: { alias: { ...config.resolveAliases(), '@livestore/wa-sqlite/dist/wa-sqlite.mjs': waSqliteNodePath, }, }, });
Q3: RFC 7396 Merge Patch Materializer
Question: Does json_patch(body, ?) work in a raw-SQL materializer, including null-deletes and nested objects; if not, measure the alternative (read via ctx.query, merge in JS, write full body)?
Evidence & Test Path:
- Test file:
spikes/livestore/rfc7396-patch.test.ts - Verified: SQLite built-in
json_patch(body, ?)function (from JSON1 extension compiled in@livestore/wa-sqlite) completely adheres to RFC 7396:- Deep nested merges: updating
{ viewport: { center: { x: 150 } } }preserves{ viewport: { center: { y: 200 } } }and{ viewport: { zoom: 1.0 } }. - Null deletes: setting
{ metadata: { author: null } }completely removes theauthorkey from the JSON object. - Array replacement: setting
{ items: [10, 20] }replaces[1, 2, 3]entirely per RFC 7396 rules.
- Deep nested merges: updating
- Implementation detail: LiveStore's
prepareBindValuestreats array arguments as positional parameters (?) directly. Passing positional parameters[patch, mutationId, origin, id]avoids object$paramnaming discrepancies. - Benchmark Comparison (500 commits in Node):
json_patch(raw SQL): 173.96 ms total (~347.9 us / commit).- JS read-merge-write: 138.07 ms total (~276.1 us / commit). Both achieve identical final document states.
json_patchin raw SQL is selected as the primary engine pattern because it avoids multiple IPC / query roundtrips from materializer to JS.
Q4: Worker Shipping Strategy
Question: Can @actdim/livestruct/worker (a dist file calling makeWorker({ schema }) with livestruct's built-in schema) be consumed by livestruct-demo in Vite dev AND vite build, via (a) ?worker / ?sharedworker imports in the app, (b) new Worker(new URL(..., import.meta.url), { type: 'module' }) inside the library? Pick one; record required Vite config (worker.format, optimizeDeps.exclude).
Decision & Evidence:
- Selected Strategy: Option (a) (
?workerand?sharedworkerVite module imports). - Reason: Option (b) fails in Vite development when consumed from pre-bundled node_modules dependencies because Vite cannot rewrite dynamic
import.meta.urlworker paths without bundling them as separate chunks. Option (a) instructs Vite's bundler to emit dedicated worker entry chunks with proper chunk hashes indist/assets/. - Required Vite configuration in consuming application:ts
export default defineConfig({ worker: { format: 'es', // Required: LiveStore workers use ES module syntax and top-level imports }, optimizeDeps: { exclude: ['@livestore/wa-sqlite'], // Required: prevents Vite from attempting to pre-bundle WebAssembly loader }, }); - Bundling verification in
livestruct-demo:worker.ts?workercompiled todist/assets/worker-CA0xLbw7.js(494.37 kB)@livestore/adapter-web/shared-worker?sharedworkercompiled todist/assets/make-shared-worker-CHFnPVOd.js(278.89 kB)wa-sqlite.wasmemitted as static assetdist/assets/wa-sqlite-CWco9gcB.wasm(619.00 kB)
Q5: Multi-Iframe Session Handling
Question: Host + 2 same-origin iframes with explicit unique sessionId each: all three see each other's commits; measure commit-to-remote-subscription latency (median, p95, n>=100).
Evidence & Measurements:
- Headless Chrome test harness:
livestruct-demorunning host page + 2 same-origin iframes. - Discovery: Same-origin iframes share the same
window.sessionStorage. Without explicitly supplying a uniquesessionId, all iframes and host collapse onto the same session ID, leading to session conflicts and dropped echo events. - Solution: Each context explicitly passes
sessionId: 'session-' + role + '-' + crypto.randomUUID()tomakePersistedAdapter({ ... }). - Benchmark Results (n = 152 cross-context mutations delivered from Host to Iframe 1 and Iframe 2):
- Median latency (p50): 18.0 ms
- 95th percentile latency (p95): 35.7 ms
- Long tasks on main thread: 0
- Behavior: All 3 contexts maintain bidirectional sync through the SharedWorker and Leader Worker with zero data loss.
Q6: UI Thread Commit Cost & Long Task Verification
Question: Time store.commit of one patch event on a 50-shape document with 1, 5, 20 active subscriptions (median, p95, n>=500) in Chrome; time 60 commits/s for 3 s and record long tasks.
Evidence & Measurements:
- Test file:
spikes/livestore/commit-perf.test.ts - Document payload: 50 vector shapes (each shape having id, type, x, y, width, height, fill, stroke, rotation).
- Latency per commit across subscription scales (n = 500 commits per run):
| Active Subscriptions | Mean Latency | Median (p50) | p95 Latency | p99 Latency | Max Latency |
|---|---|---|---|---|---|
| 1 subscription | 0.391 ms | 0.348 ms | 0.550 ms | 1.131 ms | 4.218 ms |
| 5 subscriptions | 0.341 ms | 0.324 ms | 0.418 ms | 0.724 ms | 2.000 ms |
| 20 subscriptions | 0.320 ms | 0.300 ms | 0.371 ms | 0.578 ms | 2.159 ms |
- Threshold Evaluation: The acceptance criterion requires reporting if 1-subscription p95 > 4.0 ms. The measured p95 is 0.550 ms, which is 7.2x faster than the 4.0 ms threshold.
- Continuous 60 fps Test (3 seconds, 180 frames):
- Mean commit time: 0.907 ms
- Median commit time: 0.935 ms
- p95 commit time: 1.206 ms
- Long tasks (>16.6 ms): 0 / 180 (zero frame drops)
Q7: Remote Change Attribution & CDC Semantics
Question: Can a remote context recover mutationId and origin of a change (include them in event args; read via store.events() or table subscription) to keep livestruct CDC semantics?
Evidence & Test Path:
- Test file:
spikes/livestore/cdc-attribution.test.ts - Finding on
store.events(): In LiveStore 0.5.0,store.events()delegates tostreamEventsWithSyncState, which suspends on an internal sliding queue waiting for future state head advancements. If subscribed after a sync batch finishes, it remains suspended until subsequent events are committed. - Recommended Livestruct CDC Pattern: Store
mutation_idandorigindirectly as table columns (mutation_id TEXT, origin TEXT) populated by the materializer. When remote events arrive,store.subscribe(tables.docs)emits the updated row with exactmutation_idandoriginsynchronously and deterministically. This guarantees 100% reliable Change Data Capture (CDC) without dependency on background stream queues.
Q8: Embedding Defaults & Storage Mode Fallback
Question: Confirm confirmUnsavedChanges: false, disableDevtools: true remove the beforeunload prompt and devtools; check store.storageMode fallback in a private window.
Evidence & Test Path:
- Test file:
spikes/livestore/embedding-defaults.test.ts - Source lines in
@livestore/livestore:create-store.jsline 58:confirmUnsavedChangesdefaults totrue.ClientSessionSyncProcessor.jsline 70: whenconfirmUnsavedChanges === true, LiveStore registers awindow.addEventListener('beforeunload', ...)listener that callsevent.preventDefault()if pending commits exist.- Setting
confirmUnsavedChanges: falsecompletely bypasses thebeforeunloadlistener, preventing livestruct from hijacking host page navigation. - Setting
disableDevtools: truedisables devtools webmesh channels and eliminates debug communication overhead.
- Storage Mode Fallback: When running in private browsing or non-secure contexts where OPFS (
navigator.storage.getDirectory) is unavailable,makePersistedAdapterchecks OPFS availability and automatically falls back tostorageMode: 'in-memory'.
Q9: Bundle Impact
Question: Added gzip size to livestruct-demo build (JS + WASM).
Evidence & Build Output: Measured before and after LiveStore integration in livestruct-demo:
| Asset | Baseline (Before LiveStore) | With LiveStore 0.5.0 | Net Impact |
|---|---|---|---|
| Main App JS | 363.50 kB (108.60 kB gzip) | 882.89 kB (272.91 kB gzip) | +164.31 kB gzip |
WASM Binary (wa-sqlite.wasm) | 0 kB | 619.00 kB (303.53 kB gzip) | +303.53 kB gzip |
| Leader Worker | 0 kB | 494.37 kB (uncompressed) | Separate Worker Chunk |
| Shared Worker | 0 kB | 278.89 kB (uncompressed) | Separate Worker Chunk |
Analysis: The client bundle increase (+164 kB gzip) is attributable to the LiveStore client session runtime, Effect standard library, and OpenTelemetry instrumentation. The SQLite WASM binary (303 kB gzip) is loaded asynchronously by the worker loader and cached by browser storage.
Architectural Decisions for feat--livestore-engine
- Document Storage Table: Maintain a single universal
docstable (id TEXT PRIMARY KEY, body TEXT, mutation_id TEXT, origin TEXT) in SQLite WASM. - Materializers: Use native SQLite
json_patch(body, ?)with positional bind values for all document patch operations. - Worker Shipping: Export
@actdim/livestruct/workeras an ES module entry point; instruct consuming apps to import via?workerwith Viteworker.format: 'es'. - Local Optimistic Overlay:
doc.patch()writes immediately to a local in-memory overlay for instant O(1) reads, while scheduling a debouncedstore.commit()to SQLite to coalesce fast drag/typing events into a single transaction. - Session Identity: Require passing an explicit unique
sessionIdon engine initialization to ensure proper isolation in same-origin multi-iframe topologies.
Implementation Notes (as shipped)
The decisions above were recorded during the spike. The shipped engine (src/engine/schema.ts, src/engine/liveStoreEngine.ts) refines them as follows:
- Table shape: the
docstable uses a composite primary keykey = makeDocKey(collection, id)(`${collection}\u0001${id}`) and camelCase columns:collection,id,body,rev,updatedAt,blobs,mutationId,overlayId,origin, plus the indexidx_docs_collection_updatedAt. The snake_casemutation_idnaming used in the spike notes (Q7 and Decision 1) is not what shipped. - Events: five synced events under the
livestruct.v1.namespace -DocInserted,DocReplaced,DocPatched(json_patch),DocPathSet(json_set),DocDeleted. - Session identity:
sessionIdis optional inLiveStructConfig; when omitted,resolveLiveStoreAdaptergenerates one (<name>-<random>-<time>). Passing an explicit unique id per context is still recommended. - Worker shipping: both the shipped
@actdim/livestruct/workerentry and an app-local worker that importsschemafrom@actdim/livestruct/engine(aslivestruct-demodoes) are supported. - Lazy commits and overlays: Decision 4 is implemented by
CommitSchedulerandOverlayChannel; see Architecture & Engine Design, section 7, and Architecture & Wiring.