@byearlybird/starling
v0.18.0
Published
Conflict-free replicated state for JavaScript. Bring your own reactivity.
Readme
Starling
Conflict-free replicated state for JavaScript. Bring your own reactivity.
Starling is a CRDT (conflict-free replicated data type) library that provides automatic conflict resolution for distributed data. It manages state with Last-Write-Wins semantics using hybrid logical clocks, giving you a solid foundation for building local-first, collaborative applications.
Installation
npm install @byearlybird/starling
# or
pnpm add @byearlybird/starling
# or
bun add @byearlybird/starlingRequires TypeScript 5 or higher.
Quick Example
import { createStore, collection } from "@byearlybird/starling";
import { z } from "zod";
const userSchema = z.object({
id: z.string(),
name: z.string(),
});
const store = createStore({
users: collection(userSchema, (data) => data.id),
});
store.put("users", { id: "1", name: "Alice" });
const user = store.get("users", "1"); // { id: "1", name: "Alice" }Features
- CRDT-based: Automatic conflict resolution with Last-Write-Wins semantics
- Fast and synchronous: All operations are in-memory and synchronous
- Framework agnostic: Works with React, Vue, Svelte, or vanilla JS
- Type-safe: Full TypeScript support with type inference
- Schema validation: Works with Zod, Valibot, ArkType, and more
- Merge snapshots: Sync data between devices or users easily
- Change events: Listen to data changes and integrate with your reactive system
Basic Usage
Creating a Store
A store holds one or more collections. Each collection has a schema that defines what data it can store.
import { createStore, collection } from "@byearlybird/starling";
import { z } from "zod";
const store = createStore({
users: collection(
z.object({
id: z.string(),
name: z.string(),
email: z.string().optional(),
}),
(data) => data.id,
),
notes: collection(
z.object({
id: z.string(),
content: z.string(),
}),
(data) => data.id,
),
});Adding Documents
Add new items to a collection using put():
store.put("users", {
id: "1",
name: "Alice",
email: "[email protected]",
});Or use transact() for atomic multi-collection operations:
store.transact((tx) => {
tx.put("users", { id: "1", name: "Alice", email: "[email protected]" });
tx.put("notes", { id: "n1", content: "Hello" });
});Updating Documents
Update existing items using patch():
store.patch("users", "1", {
email: "[email protected]",
});Removing Documents
Remove items using remove():
store.remove("users", "1");Reading Data
Read data directly from the store:
// Get a single item
const user = store.get("users", "1");
// Get all items as an array
const allUsers = store.list("users");
// You can easily derive other operations:
const userIds = allUsers.map((u) => u.id);
const hasUser = allUsers.some((u) => u.id === "1");Listening to Changes
Subscribe to changes with subscribe():
// Subscribe to specific collections
const unsubscribe = store.subscribe(["users"], (event) => {
console.log("Users collection changed:", event);
const allUsers = store.list("users");
// Update UI, invalidate queries, etc.
});
// Or subscribe to all changes
store.subscribe((event) => {
console.log("Store changed:", event);
});
// Later, unsubscribe
unsubscribe();Merging Data
Starling's core feature is conflict-free merging. When data changes in multiple places, Starling automatically resolves conflicts using timestamps.
Merge snapshots directly:
// Get current state as a snapshot
const snapshot = store.getState();
// Send to server or save locally
await sendToServer(snapshot);
// Later, merge a snapshot from another source
const remoteSnapshot = await fetchFromServer();
store.merge(remoteSnapshot);Starling automatically resolves conflicts. If the same field was changed in both places, it keeps the change with the newer timestamp (Last-Write-Wins).
Reactivity Integration
Starling is framework-agnostic. Use subscribe() to integrate with your reactive system:
React with TanStack Query
import { useQuery, useQueryClient } from "@tanstack/react-query";
function useUsers() {
const queryClient = useQueryClient();
useEffect(() => {
return store.subscribe(["users"], () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
});
}, []);
return useQuery({
queryKey: ["users"],
queryFn: () => store.list("users"),
});
}React with useSyncExternalStore
import { useSyncExternalStore } from "react";
function useUsers() {
return useSyncExternalStore(
(callback) => store.subscribe(["users"], () => callback()),
() => store.list("users"),
);
}Svelte
import { writable } from "svelte/store";
const users = writable(store.list("users"));
store.subscribe(["users"], () => {
users.set(store.list("users"));
});Vue
import { ref } from "vue";
const users = ref(store.list("users"));
store.subscribe(["users"], () => {
users.value = store.list("users");
});Schema Support
Starling works with any library that follows the Standard Schema specification. This includes:
- Zod - Most popular schema library
- Valibot - Lightweight alternative
- ArkType - TypeScript-first schemas
You can use any of these to define your data shapes. Starling will validate your data and give you full TypeScript types.
import { z } from "zod";
// or
import * as v from "valibot";
// or
import { type } from "arktype";
// All of these work the same way
const schema = z.object({ id: z.string(), name: z.string() });
// or
const schema = v.object({ id: v.string(), name: v.string() });
// or
const schema = type({ id: "string", name: "string" });API Overview
Main Export
createStore(config)- Creates a new store with collections
Store Methods
get(collection, id)- Get a document by ID from a collectionlist(collection)- Get all documents as an array from a collectionput(collection, data)- Insert or replace a document (upsert, revives tombstoned IDs)patch(collection, id, data)- Partially update an existing document (throws if ID missing)remove(collection, id)- Remove a documenttransact(callback)- Execute operations atomically. Collections are cloned lazily on first access.subscribe(callback)- Subscribe to all collection changessubscribe(collections, callback)- Subscribe to changes in specific collectionsgetState()- Get current store state as a snapshotmerge(snapshot)- Merge a snapshot into the store
For full type definitions, see the TypeScript types exported from the package.
Package structure
lib/core/– CRDT primitives: hybrid logical clock, per-field atoms (LWW), tombstones, and document/collection merging.lib/store/– Store API with collections, batching, queries, and change subscriptions.lib/middleware/– Optional middleware (e.g. persistence).
Development
# Install dependencies
bun install
# Build the library
bun run build
# Run tests
bun test
# Watch mode for development
bun run dev