https://github.com/richardcarls/idb-free-sync
idb-free-sync is a browser-focused TypeScript library for synchronizing IndexedDB object stores with cloud-backed JSON files.
https://github.com/richardcarls/idb-free-sync
indexeddb-wrapper local-first serverless synchronization
Last synced: about 3 hours ago
JSON representation
idb-free-sync is a browser-focused TypeScript library for synchronizing IndexedDB object stores with cloud-backed JSON files.
- Host: GitHub
- URL: https://github.com/richardcarls/idb-free-sync
- Owner: richardcarls
- License: mit
- Created: 2026-06-14T23:22:09.000Z (15 days ago)
- Default Branch: main
- Last Pushed: 2026-06-19T21:25:13.000Z (10 days ago)
- Last Synced: 2026-06-19T21:25:26.528Z (10 days ago)
- Topics: indexeddb-wrapper, local-first, serverless, synchronization
- Language: TypeScript
- Homepage:
- Size: 772 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Security: SECURITY.md
- Agents: AGENTS.md
Awesome Lists containing this project
README
# @rcarls/idb-free-sync
`@rcarls/idb-free-sync` is a browser-focused TypeScript library for synchronizing IndexedDB
object stores with cloud-backed JSON files.
It provides:
- A small `SyncTransport` interface for storage providers
- A `syncStore` orchestrator for syncing an `idb` object store
- Built-in transports for Google Drive, OneDrive, Dropbox, and WebDAV
- `NullTransport` for disabling sync without special-case application logic
- Custom conflict resolution and soft-delete support
- `blobFields` for syncing binary assets alongside records
- `OPFSBlobStore` for local OPFS-backed blob storage
## Installation
```sh
yarn add @rcarls/idb-free-sync idb
```
`idb` is a peer dependency. Cloud provider SDKs are bundled dependencies.
The package is ESM-only. Import from `@rcarls/idb-free-sync/core` when an application
provides its own transport, or import a provider directly to avoid loading
unrelated provider SDKs:
```ts
import { syncStore } from '@rcarls/idb-free-sync/core';
import { GoogleDriveTransport } from '@rcarls/idb-free-sync/google';
```
## Quick Start
Records must use string primary keys. By default, records should also have a
`modified` date so `syncStore` can decide whether the local or remote copy is
newer. If your schema stores the timestamp under a different name, set
`modifiedField` (see [Conflict Resolution](#conflict-resolution)).
```ts
import { openDB } from 'idb';
import { syncStore, type SyncTransport } from '@rcarls/idb-free-sync';
type Note = {
id: string;
title: string;
modified: Date;
};
const db = await openDB('notes-app', 1, {
upgrade(database) {
database.createObjectStore('notes', { keyPath: 'id' });
},
});
// Configure one of the built-in cloud transports for your provider.
declare const transport: SyncTransport;
await syncStore(db, transport, 'notes');
```
For a record with the primary key `abc`, the transport stores a file named
`abc.json` inside the `notes` store directory.
## Conflict Resolution
The default resolver compares `local.modified` with the remote file's modified
timestamp:
- Remote soft deletion deletes the local record.
- If only one side has a modified timestamp, that side wins.
- The newer side wins.
- Equal timestamps are ignored.
If your schema stores the modification timestamp under a different key, set
`modifiedField` instead of writing a full custom resolver:
```ts
await syncStore(db, transport, 'notes', {
modifiedField: 'updatedAt',
});
```
Provide a custom resolver when your data needs different behavior:
```ts
await syncStore(db, transport, 'notes', {
resolve(local, remote) {
if (remote.deleted) return 'delete';
return local.pinned ? 'keep-local' : 'keep-remote';
},
});
```
A resolver returns one of:
```ts
type ConflictResolution = 'keep-local' | 'keep-remote' | 'delete' | 'ignore';
```
`syncStore` uploads local-only records and downloads remote-only records. Queue
operations run in parallel. Individual queue failures are logged rather than
causing `syncStore` to reject.
## Soft Deletes
Cloud transports support soft deletion by rewriting a remote JSON object with
`deleted: true`. To avoid restoring a downloaded soft-deleted value locally,
identify the corresponding record field:
```ts
await syncStore(db, transport, 'notes', {
softDeleteField: 'deleted',
});
```
The default resolver recognizes soft deletion only when the transport exposes
it through `SyncFileInfo.deleted`. Currently, Google Drive exposes that metadata
during listing; other built-in cloud transports store the marker in file
content.
## Transports
### Google Drive
```ts
const transport = new GoogleDriveTransport(googleOAuthClientId);
```
The host page must load Google Identity Services and the Google API client so
the global `google` and `gapi` objects are available. Files are stored in the
Google Drive application data folder. An optional `syncUserId` value in
`localStorage` is used as the OAuth login hint.
Required scopes are available from `transport.scopes`.
### OneDrive
```ts
const transport = new OneDriveTransport(microsoftApplicationClientId);
```
Uses MSAL browser authentication and Microsoft Graph. Configure the application
redirect URI to match `window.location.origin`. Files are stored in the
application folder.
### Dropbox
```ts
localStorage.setItem('dropboxAccessToken', accessToken);
const transport = new DropboxTransport();
```
Uses the access token from `localStorage.dropboxAccessToken`. Files are stored
under `/Apps/RecipeTome`.
### WebDAV
```ts
const transport = new WebDAVTransport({
url: 'https://cloud.example.com/remote.php/dav/files/user',
username: 'user',
password: 'app-password',
});
```
Bearer token authentication is also supported:
```ts
const transport = new WebDAVTransport({ url, token });
```
Files are stored under `/RecipeTome`.
### No Sync
```ts
const transport = new NullTransport();
```
`NullTransport` implements the interface without persisting anything.
## Blob Fields
When records reference binary assets — images captured by the app, attachments,
audio clips — `blobFields` lets you sync those binaries alongside the JSON
records using the same transport.
### Local storage: OPFSBlobStore
`OPFSBlobStore` stores blobs in the Origin Private File System, keyed by a
stable identifier (typically a content hash). A Service Worker can intercept
URL requests and serve blobs from OPFS, making them usable as `
` or
`` values.
```ts
import { OPFSBlobStore } from '@rcarls/idb-free-sync';
const imageStore = new OPFSBlobStore('recipe-images');
// Store a captured blob
await imageStore.put('sha256-abc123', capturedBlob);
// Check existence
const exists = await imageStore.has('sha256-abc123');
```
### Service Worker integration (app-side)
```ts
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/_cache/')) {
const key = url.pathname.slice('/_cache/'.length);
event.respondWith(
navigator.storage
.getDirectory()
.then((root) => root.getDirectoryHandle('recipe-images'))
.then((dir) => dir.getFileHandle(key))
.then((fh) => fh.getFile())
.then((file) => new Response(file))
.catch(() => new Response('Not found', { status: 404 })),
);
}
});
```
### Syncing blobs with records
Configure `blobFields` in `syncStore` to sync blobs alongside JSON records. The
transport must implement `BlobSyncTransport` — all built-in transports do.
Remote JSON stores the raw blob key; `keyFromValue` and `valueFromKey` map
between that key and the local field value (such as a `/_cache/` URL). This
keeps remote files transport-agnostic.
Blobs are stored in a sibling directory named `-blobs/` so they do
not appear in the record listing.
```ts
import { OPFSBlobStore, syncStore } from '@rcarls/idb-free-sync';
type Recipe = {
id: string;
name: string;
imageUrl?: string; // '/_cache/' locally, '' in remote JSON
modified: Date;
};
const imageStore = new OPFSBlobStore('recipe-images');
await syncStore(db, transport, 'recipes', {
modifiedField: 'modified',
softDeleteField: 'deleted',
blobFields: {
imageUrl: {
blobStore: imageStore,
keyFromValue: (url) => url.replace('/_cache/', ''), // '/_cache/abc' → 'abc'
valueFromKey: (key) => `/_cache/${key}`, // 'abc' → '/_cache/abc'
contentType: 'image/jpeg',
},
},
});
```
**Upload path:** For each record being uploaded, any blob referenced by a
`blobFields` field is pushed to the transport (skipped if already present
remotely). The record's field value is replaced with the raw key in remote JSON.
**Download path:** For each record being downloaded, any blob key referenced in
a `blobFields` field is fetched from the transport and stored locally (skipped
if already present). The field value is rewritten to the local URL before the
record is written to IDB.
**Conflict resolution:** Blob conflict resolution is implicit — the same key
means the same content, so blobs are never merged. The record's conflict
resolution (keep-local, keep-remote, etc.) determines which blobs move.
### Implementing BlobSyncTransport
To add blob support to a custom transport, implement `BlobSyncTransport`:
```ts
import type { BlobSyncTransport, SyncFileInfo } from '@rcarls/idb-free-sync';
class CustomTransport implements BlobSyncTransport {
// ... SyncTransport methods ...
async putBlob(
storeName: string,
blobKey: string,
blob: Blob,
contentType?: string,
): Promise {
// Upload blob to -blobs/
}
async getBlob(storeName: string, blobKey: string): Promise {
// Download blob from -blobs/
}
async listBlobs(storeName: string): Promise {
// List all blobs in -blobs/
}
async deleteBlob(storeName: string, blobKey: string): Promise {
// Delete blob at -blobs/
}
}
```
Use `isBlobSyncTransport(transport)` to check whether a transport supports
blob sync at runtime.
## Roadmap
A user-visible device-folder transport could use the File System Access API,
but it would require the user to select a directory and grant permissions
through an interactive browser flow. It is intentionally treated as a possible
export or local-folder feature rather than transparent synchronization.
## Implementing a Transport
Implement `SyncTransport` to add another provider:
```ts
import type { SyncFileInfo, SyncTransport } from '@rcarls/idb-free-sync';
class CustomTransport implements SyncTransport {
readonly provider = 'custom';
readonly scopes: string[] = [];
list(storeName: string): Promise {
throw new Error('Not implemented');
}
get(storeName: string, syncKey: string): Promise {
throw new Error('Not implemented');
}
put(storeName: string, syncKey: string, value: T): Promise {
throw new Error('Not implemented');
}
delete(storeName: string, syncKey: string, soft?: boolean): Promise {
throw new Error('Not implemented');
}
deleteAll(storeName: string, soft?: boolean): Promise {
throw new Error('Not implemented');
}
count(storeName: string): Promise {
throw new Error('Not implemented');
}
}
```
Transport values must be JSON-serializable. `syncKey` values are file names,
normally `.json`.
## Development
This repository uses Yarn 4 with Plug'n'Play.
```sh
yarn install
yarn check
```
`yarn build` creates ESM, CommonJS, source map, and declaration outputs in
`dist/`. See [TESTING.md](./TESTING.md) for test commands, coverage policy, and
cloud-provider testing guidance.
## Contributing
Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup,
commit conventions, and pull request guidance. Please report security issues
according to [SECURITY.md](./SECURITY.md).
## License
[MIT](./LICENSE)