@claritylabs/cl-sync
v0.1.1
Published
Reusable local-first browser sync primitives for Convex and other backends
Readme
@claritylabs/cl-sync
Reusable local-first browser sync primitives for React apps.
cl-sync is intentionally app-agnostic. The core package owns IndexedDB persistence, a normalized in-memory store, selector subscriptions, schema metadata, scoped migrations, and a durable mutation outbox. Apps provide collection definitions, auth scopes, conflict behavior, and backend adapters. Convex is included as an optional adapter, but the core can be used with any API.
Packages
@claritylabs/cl-sync/core- store, collections, schema metadata, migrations, outbox, persistence.@claritylabs/cl-sync/react- provider and React hooks.@claritylabs/cl-sync/convex- Convex query/mutation adapter.
Use this when a UI should hydrate from a scoped local cache immediately, reconcile with a server in the background, and keep user actions responsive through optimistic local reducers.
Core Concepts
- Scope:
{ appId, environment, userId, orgId }isolates persisted data. Switching user or org creates a different IndexedDB scope. - Collections: named record sets with a stable ID resolver, optional persistence, redaction, sorting, and cache keys derived from query args.
- Schema metadata: app-owned version and collection metadata stored with the local scope.
- Migrations: app-owned functions that can rewrite scoped records, collection states, outbox items, or metadata during hydration.
- Outbox: queued mutations persist across reloads. Register mutation definitions after boot, then flush pending items when the app is hydrated and online.
Non-Convex Usage
import {
createSyncStore,
defineCollection,
defineMutation,
type SyncRecord,
} from "@claritylabs/cl-sync/core";
type Todo = SyncRecord & {
id: string;
title: string;
completed: boolean;
};
const todoCollection = defineCollection<Todo, { listId: string }>({
name: "todos",
getId: (todo) => todo.id,
deriveKey: (args) => args.listId,
sort: (a, b) => a.title.localeCompare(b.title),
});
const createTodo = defineMutation<{ id: string; title: string; listId: string }, { ok: true }>({
name: "todos.create",
reducer: (store, args) => {
const current = store.getCollection(todoCollection, { listId: args.listId }) ?? [];
void store.upsertCollection(todoCollection, { listId: args.listId }, [
...current,
{ id: args.id, title: args.title, completed: false },
]);
},
flush: async (args, clientMutationId) => {
const response = await fetch("/api/todos", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...args, clientMutationId }),
});
if (!response.ok) throw new Error(`Create failed: ${response.status}`);
return await response.json() as { ok: true };
},
});
const store = createSyncStore({
scope: {
appId: "todo-app",
environment: import.meta.env.MODE,
userId: currentUser.id,
},
schema: {
version: 2,
collections: [
{ name: "todos", version: 2, fields: ["id", "title", "completed"] },
],
},
migrations: [{
version: 2,
name: "rename text to title",
migrate: (context) => {
for (const record of context.getRecords("todos")) {
if (typeof record.text === "string" && typeof record.title !== "string") {
context.setRecord("todos", record.id as string, {
...record,
title: record.text,
});
}
}
},
}],
mutations: [createTodo],
});
await store.hydrate();
await store.flushPendingMutations();React Usage
import {
SyncProvider,
useSyncCollection,
useSyncMutation,
useSyncStatus,
} from "@claritylabs/cl-sync/react";
export function App() {
return (
<SyncProvider store={store} mutations={[createTodo]} flushOnHydrate>
<TodoList />
</SyncProvider>
);
}
function TodoList() {
const todos = useSyncCollection(todoCollection, { listId: "inbox" }) ?? [];
const create = useSyncMutation(createTodo);
const status = useSyncStatus();
return (
<button
disabled={!status.hydrated}
onClick={() => create({ id: crypto.randomUUID(), listId: "inbox", title: "Ship it" })}
>
Add todo
</button>
);
}flushOnHydrate registers the supplied mutations before hydration resolves, then calls store.flushPendingMutations(). You can call the core API yourself if you want to flush only after an auth token refresh, network check, or feature gate.
Convex Adapter Usage
import { ConvexReactClient } from "convex/react";
import { api } from "./convex/_generated/api";
import { createSyncStore } from "@claritylabs/cl-sync/core";
import {
defineConvexCollection,
defineConvexMutation,
subscribeConvexCollection,
} from "@claritylabs/cl-sync/convex";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
const todos = defineConvexCollection({
name: "todos",
query: api.todos.list,
getId: (todo) => todo._id,
});
const createTodo = defineConvexMutation(convex, {
name: "todos.create",
mutation: api.todos.create,
mapArgs: (args: { title: string }, clientMutationId) => ({
...args,
clientMutationId,
}),
});
const store = createSyncStore({
scope: { appId: "todo-app", environment: "prod", userId: currentUser.id },
schema: { version: 1, collections: [{ name: "todos", version: 1 }] },
mutations: [createTodo],
});
await store.hydrate();
const unsubscribe = subscribeConvexCollection(store, convex, todos, {});
await store.flushPendingMutations();The adapter only translates Convex query snapshots and mutation calls. Collection naming, persistence policy, reducers, migrations, and conflict handling stay in the app.
API Notes
store.getPersistedSchemaVersion()returns the scoped schema version loaded during hydration.store.getMeta(key)reads migration metadata or app metadata stored with the scope.store.registerMutation(definition)andstore.registerMutations(definitions)connect hydrated outbox rows back to executable mutation definitions.store.flushPendingMutations({ includeFailed, predicate })flushes registered pending mutations in creation order and reportsflushed,failed, andskippedIDs without dropping rows that cannot run.store.clearScope()clears records, collection states, outbox rows, and metadata for only the active scope.
