File sync for LiveStore apps. This missing piece for local-first apps that need to sync files.
[!] This is still under active development and not yet ready for production. The maintained React/OPFS path and image package are covered by unit and browser E2E tests. The Node example includes an adapter store-creation smoke test. Expo has filesystem/image module contract tests and iOS simulator checks for old-store recovery, image resize and upload. Physical-device and Android behavior are not yet verified. pnpm test includes both maintained React examples in Chromium.
[!] This project currently targets an exact LiveStore main snapshot and Effect 4 RC cohort. The pins in pnpm-workspace.yaml keep local development and CI on one reproducible package graph. These snapshot pins are development inputs, not a published compatibility promise.
-
Local-first: Files are written to local storage first ensuring best UX and offline support.
-
Background sync: Files as synced in background by reacting to LiveStore events.
-
Cross platform support: Web, desktop and mobile support through file system adapters based on the Effect Platform Filesystem interface.
-
R2 and S3 remote storage: Built in support for Cloudflare R2 and any S3 compatible remote storage service.
-
Media type support: An optional image package is available for pre-processing helpers and local thumbnail generation. Artchitecture supports extending to other media types.
See the 9 September correctness report for changes, diagrams, validation and remaining limits.
| Package | Description |
|---|---|
@livestore-filesync/core |
Framework-agnostic API and schema helpers |
@livestore-filesync/opfs |
OPFS filesystem adapter for browsers |
@livestore-filesync/expo |
Expo/React Native filesystem and image processing adapters |
@livestore-filesync/r2 |
Cloudflare R2 storage handler (Worker-proxied) |
@livestore-filesync/s3-signer |
S3-compatible presigned URL signer (direct-to-storage) |
@livestore-filesync/image |
Image preprocessing and thumbnail generation |
Clone the repo and install dependencies:
git clone https://github.com/<org>/livestore-filesync.git
cd livestore-filesync
pnpm installThis repo follows LiveStore main through exact npm snapshots so installs are reproducible:
- LiveStore core, web, React, sync, and development packages use
0.0.0-snapshot-97407c6622c93eb1ae4c02a40c79743426ee101f - Effect packages use
4.0.0-rc.111 @livestore/adapter-nodeuses published contrib snapshot0.0.0-snapshot-9fd312cd51d0c38b9e53a78bb4fc211ee4f9c5d2.8aa073fb46b5977e8dbae2be00e8af3b85abbd2f, with transitive core dependencies overridden to the selected core snapshot.
Update the complete LiveStore and Effect catalog cohorts together and run pnpm install whenever intentionally moving to a newer snapshot.
Downstream apps must install the same LiveStore snapshot directly, through their own pnpm catalog, or through equivalent package-manager overrides. This repo forces transitive LiveStore and Effect packages onto the selected cohort to avoid duplicate runtime generations. @livestore-filesync/core and @livestore-filesync/image expose LiveStore as peer dependencies, so consumers remain responsible for resolving a compatible LiveStore version.
Install @livestore/peer-deps from the same snapshot cohort in downstream apps to keep Effect and OpenTelemetry peer versions aligned with LiveStore.
# Web app (React/Vue/etc)
pnpm add @livestore-filesync/core @livestore-filesync/opfs
# Expo/React Native
pnpm add @livestore-filesync/core @livestore-filesync/expo
npx expo install expo-file-system expo-image-manipulator
# Node.js
pnpm add @livestore-filesync/core @effect/platform-nodeimport { createFileSyncSchema } from '@livestore-filesync/core/schema'
const fileSyncSchema = createFileSyncSchema()
export const tables = { ...fileSyncSchema.tables, /* your tables */ }
export const events = { ...fileSyncSchema.events, /* your events */ }
const materializers = State.SQLite.materializers(events, {
...fileSyncSchema.createMaterializers(tables)
})import { initFileSync } from '@livestore-filesync/core'
import { layer as opfsLayer } from '@livestore-filesync/opfs'
const dispose = initFileSync(store, {
fileSystem: opfsLayer(),
remote: { signerBaseUrl: '/api' }
})
// Later, to clean up:
// await dispose()For unauthenticated/local-first use, pass remote: false. Files are stored and resolved locally,
and FileSync will not call the signer API or enqueue uploads/downloads:
const dispose = initFileSync(store, {
fileSystem: opfsLayer(),
remote: false
})import { saveFile, resolveFileUrl } from '@livestore-filesync/core'
const result = await saveFile(file)
const url = await resolveFileUrl(result.fileId)See examples/ for complete implementations:
examples/react-filesync— React exampleexamples/react-thumbnail— React example with image thumbnails using the canvas processor (no WASM)examples/node-filesync— Node.js usageexamples/vue-filesyncandexamples/vue-thumbnail— historical Vue examples; these are not actively maintained
Note on examples: I've been using examples to test out the implementation on different platforms and frameworks. The examples are also used to run the e2e tests against which makes them more complicated than they need to be for testing and debugging purposes. As the project matures the examples will probably migrate towards a few simpler examples and the e2e testing targets into seperate apps.
The core package has a pluggable filesystem architecture. It expects any layer that provides the required subset of Effect 4's effect/FileSystem interface.
Browser (OPFS): Use the provided @livestore-filesync/opfs package:
import { layer as opfsLayer } from '@livestore-filesync/opfs'
initFileSync(store, { fileSystem: opfsLayer(), ... })Expo/React Native: Use the provided @livestore-filesync/expo package:
import { layer as expoLayer } from '@livestore-filesync/expo'
initFileSync(store, { fileSystem: expoLayer(), ... })Node.js: Use @effect/platform-node directly:
import { NodeFileSystem } from '@effect/platform-node'
createFileSync({ fileSystem: NodeFileSystem.layer, ... })The published Node adapter uses the same Effect RC cohort. Workspace overrides align its core dependencies with the selected snapshot. The Node example smoke test checks startup; its migration harness checks old persisted metadata, cached bytes, and resumed uploads. Node uploads use fetch when XMLHttpRequest is unavailable.
Backend storage is optional. Use remote: false for local-only apps or guest sessions where files
should be saved, read, updated, and deleted only from local storage. In local-only mode, remoteKey
stays as an empty string and localFileState reports local files as uploadStatus: "done" and
downloadStatus: "done".
The client expects a signer service running in a server-side process that mints short-lived URLs for uploads/downloads and handles deletes. In the examples, we leverage the existing LiveStore Cloudflare Worker to host this signer service alongside LiveStore sync — see the src/cf-worker/ folder in each example for the implementation.
The API contract:
GET /health— Health checkPOST /v1/sign/upload— Returns{ url, method, headers?, expiresAt }POST /v1/sign/download— Returns{ url, headers?, expiresAt }POST /v1/delete— Deletes an object (returns 204)
We provide two backend implementations. To switch between them, update the main entry point in your wrangler.toml to point to either index.r2.ts or index.s3.ts — both files are provided in each example's cf-worker/ folder:
Files are proxied through your Cloudflare Worker using R2 bucket bindings.
import { createR2Handler, composeFetchHandlers } from '@livestore-filesync/r2'
export default {
fetch: composeFetchHandlers(
createR2Handler({
bucket: (env) => env.FILE_BUCKET,
// Secret for HMAC-signing presigned URLs
getSigningSecret: (env) => env.FILE_SIGNING_SECRET,
// Async auth validation with optional key prefix restrictions
validateAuth: async (request, env) => {
const token = request.headers.get("Authorization")?.replace("Bearer ", "")
if (!token || token !== env.WORKER_AUTH_TOKEN) return null // Deny
return [] // Allow all keys (or return ["user123/"] to restrict)
}
})
)
}Generates presigned URLs — clients upload/download directly to S3-compatible storage.
import { createS3SignerHandler } from '@livestore-filesync/s3-signer'
export default {
fetch: createS3SignerHandler({
basePath: '/api',
// Async auth validation with optional key prefix restrictions
validateAuth: async (request, env) => {
const token = request.headers.get("Authorization")?.replace("Bearer ", "")
if (!token || token !== env.WORKER_AUTH_TOKEN) return null // Deny
return [] // Allow all keys (or return ["user123/"] to restrict)
}
})
}Requires S3 credentials (S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY) and CORS configuration on the bucket for browser uploads.
| R2 Adapter | S3 Signer | |
|---|---|---|
| How it works | Worker proxies all file data | Worker signs URLs, clients transfer directly |
| Setup | Simple — just R2 binding | Requires S3 credentials + CORS |
| Best for | Local dev, small deployments | High traffic, large files |
| Trade-offs | Bandwidth through Worker (~128MB limit) | More complex initial setup |
Use resolveFileUrl() to get a displayable URL for each file. This approach integrates with the download queue and automatically prioritizes files that are being displayed.
import { resolveFileUrl } from '@livestore-filesync/core'
const url = await resolveFileUrl(file.id)
// Use url in your componentAutomatic prioritization: When resolveFileUrl() is called for a file that's queued for download, that file is automatically moved to the front of the queue. This ensures visible files are downloaded before background files.
In local-only mode, resolveFileUrl() only resolves local files and returns null if the local
bytes are missing; it never signs a remote download URL.
See examples/react-filesync or examples/vue-filesync for complete implementations.
When files sync across clients, the file metadata may arrive before the file content is uploaded. Use getFileDisplayState() to determine if a file can be displayed:
import { getFileDisplayState } from '@livestore-filesync/core'
import { queryDb } from '@livestore/livestore'
// In your React component — query only this file's local state for targeted reactivity
const localFileState = store.useQuery(
queryDb(tables.localFileState.where({ fileId: file.id }).first())
)
const { canDisplay, isUploading } = getFileDisplayState(file, localFileState ?? undefined)
// canDisplay is true when:
// - The file exists locally (originating client can display immediately)
// - OR the file has been uploaded (remoteKey is set, other clients can download)
const url = await resolveFileUrl(file.id)
return canDisplay && url
? <img src={url} />
: <div>{isUploading ? 'Uploading...' : 'Waiting for file...'}</div>This ensures a good user experience:
- The originating client displays files immediately from local storage
- Other clients show a placeholder until the upload completes
- After edits, the correct version is displayed (based on content hash matching)
Transfers validate the captured content hash, path, remote key, and deletion state before
publishing completion. Editing during an upload or download schedules the latest version;
a stale attempt cannot attach its remote key or completion state to the replacement.
Downloads verify SHA-256 before writing locally. A checksum mismatch follows the normal
retry policy and remains an error if retries are exhausted; retryErrors() can retry after
the remote content is repaired. Deleting a file cancels its queued or active download.
Records with identical content share a local path and remote key. Deleting or updating one record preserves bytes owned by another live record. Local cleanup also protects active transfers and serializes deletion with writes and metadata publication within a FileSync instance. It rechecks references after asynchronous deletion and restores bytes if a new owner appeared while the adapter was deleting.
resolveFileUrl() returns null for deleted records and rejects results from reads
or signing if the source version changes while resolution is in progress. With the
core files table configured, thumbnail generation, state, and URL lookup similarly
reject deleted or outdated source content; polling generates the current version.
See thumbnail guarantees and compatibility limits.
Remote blobs are retained, including replaced content and stale uploads. A client cannot know about references on offline devices, so it cannot safely garbage-collect remote keys. Applications needing reclamation must implement a server-authoritative retention/GC policy; FileSync does not currently provide one. Local cleanup is best effort, and future references arriving after reclamation require a download (or re-saving bytes in local-only mode).
FileSync is designed to work correctly when multiple browser tabs are open to the same app. It uses LiveStore's built-in leader election (via Web Locks API) to ensure only one tab runs the sync loop at a time. This prevents race conditions and duplicate operations.
- Leader tab: Runs the sync loop, handles uploads/downloads
- Non-leader tabs: Can still save/update/delete files — operations are synced to the leader via SharedWorker
- Automatic failover: If the leader tab closes, another tab automatically becomes leader
No configuration required — this works automatically.
start() and stop() are awaitable; existing fire-and-forget calls remain supported.
dispose() is permanent and idempotent. Overlapping singleton mounts share the first
configuration for the same store object, user, and remote mode. Each disposer belongs to
its original instance, so an old mount cannot dispose a newer user's instance. Explicitly
dispose and reinitialize to change configuration, or supply an authToken getter for
same-user token refresh. Failed startup can be retried with start().
Background workers run only while this instance is a running leader. Stop and leadership loss invalidate old transfer progress/completion and interrupt owned work; restart rebuilds persisted work. Fetch/XHR support abort, but shutdown may wait for local storage operations that cannot be cancelled immediately. See lifecycle guarantees.
- Startup bootstrap from the
filestable is conditional. It runs only when the stored cursor is the root cursor orlocalFileStateis empty. - Mid-session restarts (
syncNow(), heartbeat recovery) restarteventsStreamfrom the stored cursor and do not rescan every file row. - Startup and each leadership acquisition rebuild executor queues from persisted queued and
interrupted rows, even with an advanced cursor. Errors receive one bounded transfer retry
cycle per leadership acquisition or explicit
retryErrors()call. - Heartbeat and
syncNow()restore missing queued work without resetting active attempts or repeatedly retrying terminal errors. A new file event can retry failed work for that file. - Failed file inspections persist targeted repair IDs and attempt counts alongside the cursor
before it advances. Startup and heartbeat retry those IDs, up to
max(2, maxRetries + 1)inspections including the first failure.retryErrors()resets exhausted inspection attempts. Disabling heartbeat requires startup orretryErrors()to resume these repairs. - Reconciliation batches changed local rows and compares metadata and local state again before committing. Concurrent changes defer that file to durable repair instead of overwriting it. Unchanged done files are neither inspected on warm startup nor reuploaded.
FileSync supports preprocessing files before they are saved. This is useful for:
- Resizing images to reduce storage and bandwidth
- Converting images to more efficient formats (WebP, JPEG)
- Applying transformations based on file type
import { initFileSync, type PreprocessorMap } from '@livestore-filesync/core'
import { layer as opfsLayer } from '@livestore-filesync/opfs'
const preprocessors: PreprocessorMap = {
// Transform all images
'image/*': async (file) => {
// Your transformation logic
return transformedFile
},
// Or return metadata with the final file
'image/webp': async (file) => ({
file,
metadata: {
mimeType: file.type,
sizeBytes: file.size,
image: { width: 1200, height: 800 }
}
}),
// Or specific types
'image/png': async (file) => convertPngToJpeg(file)
}
initFileSync(store, {
fileSystem: opfsLayer(),
remote: { signerBaseUrl: '/api' },
options: { preprocessors }
})Preprocessor patterns support:
- Exact match:
'image/png'matches only PNG files - Wildcard subtype:
'image/*'matches all image types - Universal wildcard:
'*'or'*/*'matches any file
Priority order: exact match > wildcard subtype > universal wildcard.
Preprocessors can return { file, metadata } to persist metadata on the synced files row. Metadata is stored with the current contentHash, so updateFile() replaces it when file content changes.
import { getFileMetadata } from '@livestore-filesync/core'
const metadata = getFileMetadata(fileRecord)
const ratio = metadata?.image
? `${metadata.image.width} / ${metadata.image.height}`
: undefinedSupported metadata fields are:
mimeType?: stringsizeBytes?: numberimage?: { width: number; height: number }custom?: Record<string, unknown>
Existing preprocessors that return only File continue to work. Existing rows without metadata read as null through getFileMetadata().
For image preprocessing, use the optional @livestore-filesync/image package:
pnpm add @livestore-filesync/image wasm-vipsimport { createImagePreprocessor } from '@livestore-filesync/image/preprocessor'
initFileSync(store, {
fileSystem: opfsLayer(),
remote: { signerBaseUrl: '/api' },
options: {
preprocessors: {
'image/*': createImagePreprocessor({
maxDimension: 1500, // Max width/height in pixels
quality: 90, // JPEG/WebP quality (1-100)
format: 'jpeg' // Output format: 'jpeg', 'webp', or 'png'
})
}
}
})Image preprocessors return metadata for the final output image, including MIME type, byte size, and pixel dimensions. When an image is already in the target format and is returned unchanged, dimensions are still extracted once so UIs can reserve layout before resolveFileUrl() returns.
Lightweight Canvas Alternative: If you don't need the full power of wasm-vips (ICC profile preservation, lossless compression), you can use the canvas-based processor:
createImagePreprocessor({
processor: 'canvas', // No WASM required
maxDimension: 1500,
format: 'webp'
})See the image package README and image processing docs for setup instructions and full documentation.
The @livestore-filesync/image package provides client-side thumbnail generation in a dedicated web worker. Two processing backends are available:
- Canvas: Lightweight, zero additional bundle size, works everywhere
- Vips (wasm-vips): Higher quality, ICC profile preservation, ~5 MB WASM download
- Client-side generation: Thumbnails are generated in the browser
- Multiple sizes: Configure named sizes (e.g., small: 128, medium: 256, large: 512)
- Local storage: Thumbnails stored in OPFS (not synced between clients)
- Automatic generation: Watches for new image files and generates thumbnails automatically
- Leader-only: Only the leader tab generates thumbnails to avoid duplicated work
- Batched scan state commits: Startup/poll scans commit changed thumbnail state updates in one transaction before enqueueing generation work, reducing sync-queue pressure during large cold starts without re-emitting unchanged state
import { createFileSyncSchema } from '@livestore-filesync/core/schema'
import { createThumbnailSchema } from '@livestore-filesync/image/thumbnails/schema'
import { initThumbnails, resolveThumbnailUrl } from '@livestore-filesync/image/thumbnails'
import { State } from '@livestore/livestore'
import { layer as opfsLayer } from '@livestore-filesync/opfs'
// 1. Merge schemas
const fileSyncSchema = createFileSyncSchema()
const thumbnailSchema = createThumbnailSchema()
const tables = { ...fileSyncSchema.tables, ...thumbnailSchema.tables }
const events = { ...fileSyncSchema.events, ...thumbnailSchema.events }
const materializers = State.SQLite.materializers(events, {
...fileSyncSchema.createMaterializers(tables),
...thumbnailSchema.createMaterializers(tables)
})
// 2. Initialize (after FileSync is initialized)
const dispose = initThumbnails(store, {
sizes: { small: 128, medium: 256, large: 512 },
format: 'webp',
fileSystem: opfsLayer(),
workerUrl: new URL('./thumbnail.worker.ts', import.meta.url),
schema: { tables }
})
// 3. Create your worker file (thumbnail.worker.ts)
// import '@livestore-filesync/image/thumbnails/worker'
// 4. Get thumbnail URLs
const url = await resolveThumbnailUrl(fileId, 'small')See examples/react-thumbnail for the maintained thumbnail example. Vue thumbnail examples are historical and are not actively maintained.
- Browser: OPFS support (Chrome 86+, Edge 86+, Firefox 111+, Safari 15.2+)
- Effect
4.0.0-rc.111and the matching Effect platform packages - For image processing with Vips: wasm-vips ^0.0.16 (~5 MB WASM), SharedArrayBuffer support (requires COOP/COEP headers). Alternatively, use the Canvas processor which has no additional requirements.
MIT