Reactive application data
Push database changes into live collections and framework bindings without polling or manually naming every invalidation topic.
A reactive data layer for your own database. Push row-level diffs over a WebSocket, run server-authored mutations with optimistic client edits, declare CRDT fields for conflict-free collaboration, and run scheduled functions — all without adopting a hosted backend.
For the operator surface (point-in-time replay, tenant migration primitives, hash-chain audit, OTel across the substrate), see Substrate complete (G1–G7).
bun add @absolutejs/sync elysiaDefine a collection on your engine, expose it over syncSocket, and let the engine push diffs. Reads and writes both flow through one WebSocket.
import { Elysia } from 'elysia';
import { syncSocket } from '@absolutejs/sync';
import {
createSyncEngine,
defineMutation,
defineReactiveQuery,
} from '@absolutejs/sync/engine';
type Task = { id: string; title: string };
const store = new Map<string, Task>();
const engine = createSyncEngine();
// Teach the engine your table once — it powers read-set-tracked queries.
engine.registerReader('tasks', { all: () => [...store.values()] });
engine.registerWriter('tasks', {
insert: (data) => { store.set(data.id, data); return data; },
update: (data) => { store.set(data.id, data); return data; },
delete: (row) => { store.delete(row.id); },
});
// A live collection: re-runs and re-pushes whenever the table changes.
engine.registerReactive(defineReactiveQuery({
name: 'tasks',
key: (task) => task.id,
run: ({ db }) => db.all('tasks'),
}));
engine.registerMutation(defineMutation({
name: 'addTask',
handler: (args, _ctx, actions) =>
actions.insert('tasks', { id: crypto.randomUUID(), title: args.title }),
}));
new Elysia().use(syncSocket({ engine })).listen(3000);Proof of success: open two clients, add a task in one, and confirm the second receives the row without polling.
On the client, one hook gives you the live data + an optimistic mutate:
import { useSyncCollection } from '@absolutejs/sync/react';
const Tasks = () => {
const { data, mutate } = useSyncCollection({
url: 'ws://localhost:3000/sync/ws',
collection: 'tasks',
});
return (
<ul>
{data.map((task) => <li key={task.id}>{task.title}</li>)}
<button onClick={() => mutate({
name: 'addTask',
args: { title: 'New task' },
optimistic: (draft) => draft.set({
id: crypto.randomUUID(),
title: 'New task',
}),
})}>Add</button>
</ul>
);
};Sync is a library, not a hosted backend. It runs inside your Elysia server, talks to the database you already have (Postgres, MySQL, SQLite via Drizzle or Prisma), and ships its own transport:
{ added, removed, changed } diffs over a WebSocket, optimistic mutations, an offline queue, and a local-first IndexedDB cache.Declare any row field as a CRDT and the engine merges concurrent writes server-side instead of overwriting. A client hook reads/writes the field with no per-keystroke server round-trip — the local replica holds the live text and uploads only the delta ops (O(edit), not O(doc)).
import { rgaText } from '@absolutejs/sync/crdt';
// Server — declare a field as a CRDT; the engine merges on write instead of
// overwriting, and auto-registers a "doc:merge" mutation for the client.
engine.registerCrdt('doc', { body: rgaText });
// Client — useCollaborativeText reads/writes that field. Open the same row
// in two tabs and type at once: edits merge with no clobbering.
import { useCollaborativeText } from '@absolutejs/sync/react';
const Editor = () => {
const doc = useCollaborativeText({
url: 'ws://localhost:3000/sync/ws',
collection: 'doc',
field: 'body',
id: 'shared',
});
return (
<textarea
value={doc.text}
onChange={(event) => doc.setText(event.target.value)}
/>
);
};The first-party CRDT kit at @absolutejs/sync/crdt is zero-dependency and isomorphic — a PN-counter, an LWW register, an OR-Set, a key→value map, an ordered list, and the RGA text type. Caret positions can be anchored to CRDT element ids so they survive concurrent edits (collaborative cursors).
Row-level reads and writes are gated declaratively: the read rule filters every diff the engine emits, and the write rule runs against the committed row before the mutation touches your store (a deny rolls the transaction back).
import { definePermissions } from '@absolutejs/sync/engine';
const engine = createSyncEngine({
permissions: definePermissions({
tasks: {
// Row-level read filter applied to every diff the engine emits.
read: (row, ctx) => row.userId === ctx.userId,
// Write gate runs before insert/update/delete; deny rolls the txn back.
write: (ctx) => ctx.role !== 'viewer',
},
}),
});The companion defineSchema + the field kit validate every write (a bad write throws SchemaError), and migrate lazily upcasts rows on read — no database migration step required.
A search collection keeps a server-side BM25 (or vector) index current from the same change feed. The subscription's params are the query — the ranked top-K streams back as a normal collection, re-ranked as rows change.
import { createTextIndex, defineSearchCollection } from '@absolutejs/sync/engine';
// Live full-text search (BM25) kept current from the same change feed.
engine.registerSearch(defineSearchCollection({
name: 'taskSearch',
table: 'tasks',
source: () => [...store.values()],
key: (task) => task.id,
index: () => createTextIndex({
fields: ['title', 'body'],
key: (task) => task.id,
}),
}));
// Client subscribes; the params ARE the query string. Top-K rows stream back
// re-ranked as rows change, each tagged with _score.
const hits = useSyncCollection({
collection: 'taskSearch',
params: 'urgent',
url,
});Cron-pattern server jobs whose writes go live through the change feed — no polling, no separate scheduler service. Wired through the scheduled Elysia plugin (an optional subpath so consumers without it pull no cron dependency).
import { defineSchedule } from '@absolutejs/sync/engine';
import { scheduled } from '@absolutejs/sync/scheduled';
engine.registerSchedule(defineSchedule({
name: 'nightly-summary',
pattern: '0 0 3 * * *', // 6-field cron, seconds first
run: ({ actions }) => actions.insert('reports', { /* ... */ }),
}));
new Elysia().use(syncSocket({ engine })).use(scheduled({ engine }));The in-package CRDT is great for offline-merge and moderate collaboration. For production-scale collaborative text, swap in a battle-tested backend from the sync-adapters repo — @absolutejs/sync-yjs, @absolutejs/sync-automerge, or @absolutejs/sync-loro — all behind the same TextCrdtAdapter contract:
// Swap the in-package RGA for a production CRDT — same call sites:
import { yjsText } from '@absolutejs/sync-yjs';
// or
import { automergeText } from '@absolutejs/sync-automerge';
import { loroText } from '@absolutejs/sync-loro';
engine.registerCrdt('doc', { body: yjsText });
const doc = useCollaborativeText({
url, collection: 'doc', field: 'body', id: 'shared',
create: createYjsText, // <- the only client change
});Idiomatic bindings live at @absolutejs/sync/{react, vue, svelte, angular}. Each ships useSyncCollection / createSyncCollectionStore / SyncCollectionService.connect for live collections, plus a matching useCollaborativeText / createCollaborativeTextStore / SyncCollectionService.collaborativeText for CRDT fields.
All four hook surfaces are SSR-safe and return the same shape — so swapping frameworks in a multi-stack app doesn't change your data layer.
These playbooks show where this package fits, how to verify the combined system, and what changes before production.
Current package surface
Import surface · click to copy
Outcomes
Push database changes into live collections and framework bindings without polling or manually naming every invalidation topic.
Compose CRDT collaboration, write-behind caching, PostgreSQL/MySQL/SQLite stores, ORM integrations, and one brokered upstream pool for many tenants.
Hardening checklist
Follow in order