zerodrift
v1.0.4
Published
A TypeScript local-first sync engine: synchronous in-memory reads, optimistic writes, realtime SSE sync, offline IndexedDB persistence. Runs in the browser and in Node.
Maintainers
Readme
zerodrift
A TypeScript sync engine with an intuitive model API that hides the hard parts of local reads, optimistic writes, offline recovery, and realtime convergence.
zerodrift lets you work with synced data like normal application state. Components and headless workers read records synchronously, mutate model fields directly, call save(), and subscribe with typed React hooks or store APIs. Under that simple surface, the engine does the synchronization work that would otherwise spread across your codebase.
The backend stays yours. Implement bootstrap, transaction, and event-stream endpoints in any stack, or start from the included Go backend and Next.js demo. In the browser, zerodrift persists models and queued writes to IndexedDB; in Node, it can run against memory or a custom storage adapter.
The result is less sync code in every feature. Define models with decorators or schema-as-data, wire the three transport functions, and build against a small, predictable API while browser tabs, clients, and Node processes converge in the background.
The design is inspired by Linear's sync engine; see Acknowledgments for prior art and attribution.
What you get
- A small API for synced data: read records synchronously, mutate model fields directly, call
save(), or use typed store namespaces generated from a schema. - App logic without cache choreography: fetching, invalidation, optimistic updates, reconnects, offline replay, and conflict rebasing live in the engine instead of every screen.
- Optimistic writes with real recovery: local changes update immediately, batch into transaction POSTs, persist through reloads, and reconcile when matching server deltas arrive.
- Relationships that stay live: references, inverse collections, owned collections, and indexed lookups update as records hydrate, load lazily, or arrive over SSE.
- Schema or class models: use decorators (
@ClientModel,@Property,@Reference) or schema-as-data (defineSchema(...),entityFromZod(...)) withoutreflect-metadata. - Memory you can shape: choose per-model
LoadStrategyvalues for eager data, lazy tables, partial index-backed loading, local-only records, or ephemeral SSE-fed state. - Undo/redo built into the transaction layer: track field-level changes, group atomic multi-model edits, and include custom remote actions in the same undo stack.
- React, browser, or headless Node: use
<SyncProvider>and typed hooks in React, or runStoreManagerdirectly in agents, workers, CLIs, and tests. - Your backend, your stack: implement three HTTP endpoints in any language, with a reference Go backend and Next.js demo included.
Install
npm install zerodriftOptional packages depend on the surface you use:
npm install zod # for entityFromZod(...) schema authoring
npm install eventsource # for Node/headless SSE clientsDecorator path: enable experimentalDecorators in your tsconfig.json (or the SWC/Babel equivalent). Unlike most decorator libraries, reflect-metadata is not needed.
Import paths
| Import | Use it for |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| zerodrift | StoreManager, BaseModel, decorators, MemoryAdapter, relation field types (RefCollection/BackRef/OwnedRefs), and the config / error / sync types. The curated, stable surface. |
| zerodrift/schema | defineSchema, entityFromZod, field builders, links, extensions, and typed store.<entity>.* APIs. |
| zerodrift/react | <SyncProvider> and React hooks: useRecord, useRecords, useRecordsByIndex, useRelation, useBatch, useUndoRedo, useBootstrapStatus. |
| zerodrift/internal | Engine machinery (ObjectPool, TransactionQueue, SyncConnection, ModelRegistry, …) for tooling/tests. No stability promise — may change between releases. |
Define your models
Decorator models extend BaseModel and use decorators to declare fields and relationships.
import {
BaseModel,
ClientModel,
Property,
Reference,
LazyReferenceCollection,
LoadStrategy,
} from "zerodrift";
import type { RefCollection } from "zerodrift";
@ClientModel({ name: "Team", loadStrategy: LoadStrategy.Eager })
export class Team extends BaseModel {
@Property() public name = "";
@LazyReferenceCollection("Issue", { inverseOf: "teamId" })
public issues: RefCollection<Issue>;
}
@ClientModel({ name: "Issue", loadStrategy: LoadStrategy.Eager })
export class Issue extends BaseModel {
@Property() public title = "";
@Property() public priority = 0;
@Property({ indexed: true })
public teamId: string | null = null;
@Reference("Team", { onDelete: "cascade" })
public team: Team;
}@Property fields are persisted and observable. @Reference, @ReferenceCollection, @OwnedCollection, and @BackReference describe relationships; Lazy* variants load on demand. loadStrategy controls whether a model loads during bootstrap or only when requested. Pass an explicit @ClientModel({ name }) — it's the registry key and the useRecord(Model, …) handle; without it the class name is used, which minifiers mangle in production.
See agent-docs/01-models-and-decorators.md for the full decorator reference.
Schema-first with Zod
If your record shapes already live in Zod, use entityFromZod(...) as the schema authoring path. Zod owns the field types; fields overrides add zerodrift metadata such as foreign keys and indexes.
import { z } from "zod";
import {
createStore,
defineSchema,
entityFromZod,
fields as s,
link,
LoadStrategy,
} from "zerodrift/schema";
const TeamRecord = z.object({
id: z.string(),
name: z.string(),
});
const IssueRecord = z.object({
id: z.string(),
title: z.string().default(""),
priority: z.number().default(0),
teamId: z.string().nullable(),
});
export const schema = defineSchema({
entities: {
team: entityFromZod(TeamRecord, {
name: "Team",
loadStrategy: LoadStrategy.Eager,
}),
issue: entityFromZod(IssueRecord, {
name: "Issue",
loadStrategy: LoadStrategy.Eager,
fields: {
teamId: s.refId("team").nullable().indexed(),
},
}),
},
links: {
issueTeam: link({
from: { entity: "issue", field: "teamId", as: "team" },
to: { entity: "team", many: "issues", lazy: true },
onDelete: "cascade",
}),
},
});
const store = createStore({ schema, storeManager: sm });
const issue = await store.issue.get(issueId);
const teamIssues = await store.issue.getByIndex("teamId", teamId);
// create / patch commit at the current boundary — no separate save():
const newIssue = store.issue.create({ title: "Fix hydration", teamId });
store.issue.patch(issue.id, { priority: 1 });
// draft() is the staged path — mutate, then save() or discardUnsavedChanges():
const d = store.issue.draft({ title: "" });
d.title = "Write tests";
d.save();Both authoring paths compile to the same registry, so schema entities and decorator classes can coexist. See agent-docs/11-schema-first-authoring.md for extensions, typed subscriptions, Zod override forms, and coexistence details.
React quick start
Wrap your app in <SyncProvider> once. Import your model file as a side effect so decorators run before bootstrap.
import { SyncProvider } from "zerodrift/react";
import "./models";
export default function Providers({ children }) {
return (
<SyncProvider
config={{
workspaceId: "workspace-123",
transport: {
bootstrapFetcher: async (type, options) => {
const res = await fetch(
`/api/bootstrap?type=${type}&lastSyncId=${options?.sinceSyncId ?? 0}`,
);
return res.json();
},
transactionSender: async (batch) => {
const res = await fetch("/api/transactions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(batch),
});
return res.json();
},
syncUrl: "/api/events",
},
}}
fallback={<div>Loading...</div>}
>
{children}
</SyncProvider>
);
}Common reads and writes. The read hooks take a handle — a model class
(decorator path) or a store.<entity> namespace (schema path) — and infer
the record type from it. Every result has the same shape:
{ data, isLoading, isLoaded, error, reload }.
const { data: issues } = useRecords(Issue); // T[]
const { data: issue } = useRecord(Issue, issueId); // T | null
const { data: teamIssues } = useRecordsByIndex(Issue, "teamId", teamId);
const { data: comments } = useRelation(issue?.comments); // a relation
const { phase } = useBootstrapStatus();
issue.title = "New title";
issue.save();
const batch = useBatch();
batch(() => {
issue.priority = 1;
issue.save();
});
const { undo, redo, canUndo, canRedo } = useUndoRedo();Schema-authored stores pass the namespace as the handle — same hooks, typed
record + .indexed()-constrained index keys:
const { data: issue } = useRecord(store.issue, issueId);
const { data: teams } = useRecords(store.team);
const { data: teamIssues } = useRecordsByIndex(store.issue, "teamId", teamId);See agent-docs/08-react-integration.md for hook return shapes, context-driven id generation, Storybook seeding, and testing patterns.
Headless usage
The same StoreManager runs without React or a browser. Use MemoryAdapter for in-process agents and tests, or implement StorageAdapter for durable storage.
import EventSource from "eventsource";
import { MemoryAdapter, StoreManager } from "zerodrift";
import "./models";
const sm = new StoreManager({
workspaceId: "agent-1",
transport: {
bootstrapFetcher,
transactionSender,
syncUrl: "http://localhost:8081/api/events",
sseClientFactory: (url) => new EventSource(url),
},
persistence: { storageAdapter: new MemoryAdapter() },
});
await sm.bootstrap();See agent-docs/09-headless-and-agents.md for reactivity outside React, shared vs isolated agent state, refresh APIs, and observability.
Backend protocol
The client needs three endpoints:
| Endpoint | Purpose |
| ------------------------ | ------------------------------------ |
| GET /api/bootstrap | Fetch initial or partial model data. |
| POST /api/transactions | Accept queued client mutations. |
| GET /api/events | Stream delta packets over SSE. |
Bootstrap returns records grouped by model name:
{
"lastSyncId": 5205,
"subscribedSyncGroups": ["workspace-abc"],
"models": {
"Issue": [{ "id": "...", "title": "...", "teamId": "..." }],
"Team": [{ "id": "...", "name": "..." }]
},
"backendDatabaseVersion": 1
}Transactions send inserts, updates, deletes, and archives:
{
"transactions": [
{
"id": "uuid",
"action": "U",
"modelName": "Issue",
"modelId": "uuid",
"changes": {
"title": { "oldValue": "Old", "newValue": "New" }
}
}
]
}The response should include the latest committed sync id:
{ "success": true, "lastSyncId": 5206 }SSE messages are delta packets:
{
"syncId": 5206,
"syncActions": [
{
"modelName": "Issue",
"modelId": "uuid",
"action": "U",
"data": { "title": "New title", "priority": 1 }
}
],
"addedSyncGroups": [],
"removedSyncGroups": []
}The client reconnects with ?lastSyncId=<id> so the server can replay missed events. See agent-docs/07-realtime-sync.md for SSE details and agent-docs/05-sync-groups.md for scoped event delivery.
Run the demo
A reference Go backend + Next.js app that exercises the full sync loop locally live in examples/. See examples/README.md for the one-command-each setup:
cd examples && make start-backend && make run-webappDocumentation
Deeper material lives in agent-docs/:
- 00 - Architecture overview
- 01 - Models and decorators
- 02 - ObjectPool
- 03 - IndexedDB and persistence
- 04 - Lazy loading
- 05 - Sync groups
- 06 - Transactions and undo
- 07 - Realtime sync
- 08 - React integration
- 09 - Headless and agents
- 10 - Inverse links and reactivity
- 11 - Schema-first authoring
Project structure
. # the publishable zerodrift package
|-- src/
|-- agent-docs/ # architecture and API notes
`-- examples/ # self-contained runnable demo (own Makefile + compose)
|-- webapp/ # Next.js demo app
|-- go/ # reference Go backend
|-- docker-compose.yml
`-- MakefileTech stack
- Client: TypeScript, MobX, IndexedDB, EventSource (SSE)
- Reference server: Go, Gin, Bun ORM, Postgres (LISTEN/NOTIFY), pgx
- Protocol: append-only changelog, monotonic sync id, sync group filtering
Acknowledgments
zerodrift was informed by public writing and talks on local-first sync engines. Two especially helpful references were Wenzhao Hu's "Reverse Engineering Linear's Sync Engine: A Detailed Study" (wzhudev/reverse-linear-sync-engine) and Tuomas Artman's React Helsinki talk on Linear's realtime sync.
This project is an independent TypeScript implementation and is not affiliated with Linear.
License
MIT — see LICENSE. The MIT grant covers zerodrift's own code. See NOTICE for inspiration and attribution notes.
