A single collaborative counter that persists to Postgres via Prisma.
Open two browser tabs, click +/− in either tab — the count updates in real time. Kill the server, restart it — the count is still there.
- Prisma persistence —
@kyneta/prisma-storewired into an Exchange with aPrismaClient - Prisma migrations —
npx prisma migrate devcreates thekyneta_doc_meta,kyneta_records, andkyneta_store_metatables - Loro Counter CRDT —
Schema.counter()withloro.bind()— concurrent increments merge additively - Server restart resilience — counter value survives process restart (persisted in Postgres)
- Multi-tab sync — WebSocket transport + Exchange synchronizes state across browser tabs
- Node 22+
- Postgres running locally (see Docker one-liner below)
If you don't have Postgres running:
docker run -d --name kyneta-pg \
-e POSTGRES_USER=kyneta \
-e POSTGRES_PASSWORD=kyneta \
-e POSTGRES_DB=kyneta \
-p 5432:5432 \
postgres:16cp .env.example .envThe default .env points at postgresql://kyneta:kyneta@localhost:5432/kyneta — matches the Docker command above.
cd examples/prisma-counter
pnpm installpnpm run generate
pnpm run migrateThis creates the kyneta_doc_meta, kyneta_records, and kyneta_store_meta tables in Postgres.
pnpm run devOpen http://localhost:5173 in two browser tabs. Click +/− in either tab — the count updates in both.
Kill the server (Ctrl+C) and restart it (pnpm run dev). The count survives.
Browser Tab A Browser Tab B
┌──────────────────┐ ┌──────────────────┐
│ React App │ │ React App │
│ useValue(count) │ │ useValue(count) │
│ count() → number│ │ count() → number│
│ .increment(n) │ │ .increment(n) │
│ ↕ │ │ ↕ │
│ Exchange │ │ Exchange │
│ ↕ WebSocket│ │ ↕ WebSocket│
└───────┬──────────┘ └───────┬──────────┘
│ │
└──────────┐ ┌─────────────────┘
↓ ↓
┌──────────────────┐
│ Server Exchange │
│ (sync hub) │
│ ↕ │
│ PrismaStore │
│ ↕ │
│ Postgres │
│ (kyneta DB) │
└──────────────────┘
prisma-counter/
├── prisma/
│ └── schema.prisma # KynetaDocMeta + KynetaRecord + KynetaStoreMeta models, Postgres datasource
├── src/
│ ├── schema.ts # Schema.struct({ count: Schema.counter() }) + loro.bind
│ ├── server.ts # Vite middleware + WebSocket + Exchange + PrismaStore
│ ├── app.tsx # Counter UI — useDocument, useValue, +/− buttons
│ └── main.tsx # Client bootstrap — ExchangeProvider + WebSocket
├── index.html
├── style.css
├── .env.example
├── package.json
├── tsconfig.json
└── vite.config.ts
import { Schema } from "@kyneta/schema"
import { loro } from "@kyneta/loro-schema"
export const CounterSchema = Schema.struct({
count: Schema.counter(),
})
export const CounterDoc = loro.bind(CounterSchema)import { useDocument, useValue } from "@kyneta/react"
function App() {
const doc = useDocument("counter", CounterDoc)
// useValue on a counter leaf ref returns the current count as a number.
// Re-renders on every increment (local or remote).
const count = useValue(doc.count) as number
return (
<>
<span>{count}</span>
<button onClick={() => doc.count.increment(1)}>+</button>
<button onClick={() => doc.count.increment(-1)}>−</button>
</>
)
}useValue(doc.count)returns anumber— the counter's current valuedoc.count.increment(n)mutates the counter directly — nobatch()wrapper needed- Counter refs auto-commit on each
.increment()call - The UI re-renders on every increment, local or remote
docker rm -f kyneta-pg