Note: Project migrated to Effect V4, Effect V3 commit:
682e964b
Screen.Recording.2026-07-12.at.12.47.05.AM.mov
An end-to-end demo of the LiveStore sync engine wired into TanStack DB collections.
To test locally: bunx prisma generate then bun run dev
To deploy, alchemy login to point to your cloudflare account then run bun run deploy
LiveStore already owns the "sync engine" role: its own local SQLite store, its own optimistic state, its own WebSocket transport. TanStack DB adds a second, much faster reactive layer on top, with a better API (imo) and useLiveQuery ergonomics.
Small Prisma to Effect Generator, which livestore consumes! (because I love the way prisma schemas work). Write your db schema in prisma, run bunx prisma generate which auto-generates the Effect schema, point livestore to it and you're good to go.
Prisma schema as the source of truth for both the Cloudflare D1 audit log and the LiveStore materialisers. The whole stack, D1, Durable Object, and Worker, is provisioned by Alchemy.
bun add -D prisma-effect-schema-generator// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
generator effect_client {
provider = "prisma-effect-schema-generator"
output = "./generated/client-schemas/index.ts"
}
generator effect_tables {
provider = "prisma-effect-schema-generator"
output = "./generated/client-schemas/tables.ts"
tables = "true"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Post {
id String @id @default(cuid())
title String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@map("posts")
}Generate schemas:
bunx prisma generate
# creates prisma/generated/client-schemas/index.ts (Effect Schemas)
# creates prisma/generated/client-schemas/tables.ts (LiveStore table descriptors)// src/livestore/schema.ts
import { createLiveStoreDb } from "livestore-prisma";
import { Schema } from "effect";
import { Events, State } from "@livestore/livestore";
// Generated tables (from prisma-effect-schema-generator)
import {
TABLES,
PRIMARY_KEY_COLUMNS,
SOFT_DELETE_COLUMNS,
} from "../../prisma/generated/client-schemas/tables.ts";
// Generated schemas (from prisma-effect-schema-generator)
import { PostSchema } from "../../prisma/generated/client-schemas/index.ts";
export const tables = createLiveStoreDb({
tables: TABLES,
primaryKeyColumns: PRIMARY_KEY_COLUMNS,
softDeleteColumns: SOFT_DELETE_COLUMNS,
});
export const events = {
postCreated: Events.synced({
name: "v1.PostCreated",
schema: Schema.Struct({ id: Schema.String, title: Schema.String, body: Schema.String }),
}),
postUpdated: Events.synced({
name: "v1.PostUpdated",
schema: Schema.Struct({ id: Schema.String, title: Schema.optional(Schema.String) }),
}),
postDeleted: Events.synced({
name: "v1.PostDeleted",
schema: Schema.Struct({ id: Schema.String, deletedAt: Schema.Date }),
}),
} as const;
const materializers = State.SQLite.materializers(events, {
"v1.PostCreated": ({ id, title, body }) =>
tables.Post.insert({
id,
title,
body,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
}),
"v1.PostUpdated": ({ id, ...rest }) =>
tables.Post.update({ ...rest, updatedAt: new Date() }).where({ id }),
"v1.PostDeleted": ({ id, deletedAt }) => tables.Post.update({ deletedAt }).where({ id }),
});
export const schema = State.makeSchema({ tables, materializers });// src/livestore/store.ts
import { makePersistedAdapter } from "@livestore/adapter-web";
import { StoreRegistry } from "@livestore/livestore";
import { StoreRegistryProvider, useStore } from "@livestore/react";
import { schema } from "./schema.ts";
const adapter = makePersistedAdapter({ storage: { type: "opfs" } });
export const storeRegistry = new StoreRegistry({
defaultOptions: { batchUpdates },
});
export const storeOptions = {
storeId: "my-app",
schema,
adapter,
} as const;
export const useAppStore = () => useStore(storeOptions);
export { StoreRegistryProvider };// src/db/postCollection.ts
import { createCollection } from "@tanstack/db";
import { liveStoreCollectionOptions } from "livestore-tanstack-db";
import { useAppStore } from "../livestore/store.ts";
import { events, tables } from "../livestore/schema.ts";
import type { Post } from "../db/postSchema.ts";
export const usePostCollection = () => {
const store = useAppStore();
return createCollection(
liveStoreCollectionOptions({
id: "posts",
store,
query: queryDb(tables.Post.where({ deletedAt: null })),
getKey: (item) => item.id,
onInsert: ({ transaction }) => {
for (const m of transaction.mutations) {
store.commit(events.postCreated(m.modified));
}
},
onUpdate: ({ transaction }) => {
for (const m of transaction.mutations) {
store.commit(events.postUpdated({ id: m.original.id, ...m.changes }));
}
},
onDelete: ({ transaction }) => {
for (const m of transaction.mutations) {
store.commit(events.postDeleted({ id: m.original.id, deletedAt: new Date() }));
}
},
}),
);
};The hand-written usePostCollection in step 4 is the lower-level
liveStoreCollectionOptions API. Most apps want useTable(name)
instead — it auto-derives getKey, the soft-delete predicate, and
the commit handlers from the createLiveStoreDb schema:
// src/db/postCollection.ts
import { useMemo } from "react";
import { useTable } from "livestore-tanstack-db";
import { useAppStore } from "../livestore/store.ts";
import { events, schema, tables } from "../livestore/schema.ts";
export const usePostCollection = () => {
const store = useAppStore();
// `liveStore` bundles the store + tables + events + schema in one
// object. Pass it explicitly to skip the <LiveStoreProvider> lookup.
const liveStore = useMemo(() => ({ store, tables, events, schema }), [store]);
const { collection } = useTable("Post", { liveStore });
return collection;
};What useTable auto-derives for you:
getKey— read from the schema's primary-key column. The schema walker looks for anisPrimaryKey: trueannotation on the property signatures (set by upstreamprisma-effect-schema-generatorwhenemitPrimaryKeyMarker: true), falls back to a field whose name ends inId, and finally to'id'.- Soft-delete predicate — walks the schema for a column matching
/(deleted|archived|removed)/ofNullOr(...)type, buildstables[name].where({ [col]: null }). No need to passwhere: { deletedAt: null }on every call site. commitInsert/Update/Delete— derived from the events emitted bycreateLiveStoreDb: insert →v1.<Model>Created, delete →v1.<Model>Deleted, update → auto-detected per-field boolean toggles (v1.<Model><Field>Completed/Uncompleted) orv1.<Model>Upserted.
Pass where to override or add to the auto-derived predicate:
// Active posts only (overrides the deletedAt filter)
const activePosts = useTable("Post", {
liveStore,
where: { deletedAt: null, draft: false },
});The where becomes both the LiveStore tables.Post.where(...) query
and a TanStack DB q.where(...) in one call.
Memoise many collections in one hook (Tier 1.4):
const { Post, Comment } = useTables({
Post: { liveStore },
Comment: { liveStore, where: { deletedAt: null } },
});Use outside a React tree — TanStack Router loaders, Cloudflare Worker
handlers, scripts. Returns a Collection directly (sync, no Suspense):
// In a TanStack Router loader
export const Route = createFileRoute("/posts")({
loader: ({ deps }) => {
const collection = preloadTable("Post", { liveStore });
// Optional: wait for first sync before returning
return collection.preload();
},
component: PostList,
});Tier 3.6 — wraps useTable and returns
[collection, { create, update, remove }] with auto-generated
ids and full type inference:
const [posts, { create, update, remove }] = useCrud<PostRow>("Post");
// `create` makes `id` optional — auto-generated via crypto.randomUUID()
create({ title: "hello", body: "world" });
create({ id: "fixed", title: "...", body: "..." }); // explicit id
// `update` accepts either a partial or a draft-mutation callback
update(post.id, { title: "new" });
update(post.id, (draft) => {
draft.title = "new";
});
// `remove` takes the id
remove(post.id);For apps that need to round-trip mutations through a server, useTable
accepts an RPC config. The adapter you choose depends on your RPC
library:
- oRPC and direct-call clients: use
createORPCAdapter. - tRPC: use
createTRPCAdapter(wrapsproc.mutate(input)).
Both return the same normalized RpcClient shape that useTable
consumes.
import { createORPCAdapter } from "livestore-tanstack-db";
const lessons = useTable("Lesson", {
liveStore,
rpc: {
client: createORPCAdapter(orpc, { namespaces: ["teacher"] }),
config: {
teacher: {
createLesson: {}, // auto-classified as `insert`
updateLesson: {}, // auto-classified as `update`
deleteLesson: {}, // auto-classified as `delete`
// upsert-style proc — fires on both insert AND update
upsertLesson: { event: "lessonUpserted" },
// explicit map: translate the row into the rpc input shape
updateOwnProfile: { map: (row) => ({ bio: row.bio }) },
},
},
},
});import { createTRPCAdapter } from "livestore-tanstack-db";
const lessons = useTable("Lesson", {
liveStore,
rpc: {
client: createTRPCAdapter(trpc, { namespaces: ["teacher"] }),
config: {
teacher: {
createLesson: {}, // auto-classified as `insert`
updateLesson: {}, // auto-classified as `update`
deleteLesson: {}, // auto-classified as `delete`
},
},
},
});Procedure-name heuristics auto-classify each proc into
commitInsert/Update/Delete:
| Procedure name pattern | Wired to |
|---|---|
createXxx, addXxx, upsertXxx |
commitInsert |
updateXxx, setXxx, markXxx |
commitUpdate |
xxxDelete, xxxRemove |
commitDelete |
| (fallback) | both insert + update (upsert-by-name) |
Override per-proc with { event: 'lessonUpserted' } to pin a specific
LiveStore event (the event name suffix Created / Deleted / anything
else disambiguates the mutation kind).
Both adapters accept the same options:
createORPCAdapter(client, {
namespaces: ["teacher"], // only include these top-level namespaces
skipValidation: true, // skip console.warn for missing/invalid procs
});
createTRPCAdapter(client, {
namespaces: ["teacher"],
skipValidation: true,
});Missing procedures become undefined in the output — the mutation layer
treats them as no-ops.
// src/components/PostList.tsx
import { useLiveQuery } from "@tanstack/react-db";
import { usePostCollection } from "../db/postCollection.ts";
export const PostList = () => {
const posts = usePostCollection();
const { data } = useLiveQuery((q) => q.from({ post: posts }));
return (
<ul>
{data.map(({ post }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
};
// Creating a post
posts.insert({
id: crypto.randomUUID(),
title: "Hello",
body: "World",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
});
// Updating a post
posts.update(post.id, (draft) => {
draft.title = "Updated";
});
// Deleting a post
posts.delete(post.id);
Add the LiveStore bridge + TanStack Devtools panel. The bridge
auto-registers every collection created by useTable(name) — no
manual collections={...} prop needed.
// src/Root.tsx
import { TanStackDevtools } from "@tanstack/react-devtools";
import { LiveStoreDevtoolsBridge, liveStoreDevtoolsPlugin } from "livestore-tanstack-db/devtools";
export const App = () => (
<StoreRegistryProvider storeRegistry={storeRegistry}>
{/* ... your app ... */}
<LiveStoreDevtoolsBridge store={storeRegistry.getStore()} />
<TanStackDevtools plugins={[liveStoreDevtoolsPlugin()]} />
</StoreRegistryProvider>
);<LiveStoreDevtoolsBridge /> is a side-effect-only component — it
returns null. Drop it in once and the per-table useTable calls
auto-register their collections, so the panel shows per-collection
status:change events without any consumer code.
Also add the vite plugin for source injection:
// vite.config.ts
import { devtools } from "@tanstack/devtools-vite";
export default defineConfig({
plugins: [devtools()],
});bun run gen from the workspace root runs the full regeneration
chain — prisma generate for both apps (the effect_client
generator that emits Schema.* + introspection maps, plus the
in-repo livestore generator that emits tables/events/materializers),
then rebuilds the integration packages so consumers see the latest
types.
bun run gen # full chain: prisma generate + bun run build
bun run gen:spa # prisma generate only (spa)
bun run gen:start-orpc # prisma generate only (tanstack-start-orpc)Both gen:* scripts run prisma generate --schema=... from the
workspace root (Prisma 7's example-dir config loader is broken on
macOS — see the prisma.config.ts files in each example). The
livestore generator block in each schema resolves the bin via the
livestore-prisma package's bin field (prisma-livestore-generator)
— same shape as the upstream prisma-effect-schema-generator, so
relative paths don't break under different invocation CWDs.
After a schema change in either example, run bun run gen (or the
narrower gen:spa / gen:start-orpc) before restarting the dev
server. The integration packages' dist/ is what the apps import, so
the rebuild step is mandatory for type changes to flow through.
| Path | What it does |
|---|---|
prisma/schema.prisma |
Single source of truth for tables |
prisma/livestore.annotations.json |
Per-table flags (e.g. serverOnly) consumed by the livestore gen |
prisma/generated/client-schemas/index.ts |
Generated Effect Schemas (gitignored) |
prisma/generated/livestore/*.ts |
Generated LiveStore tables/events/materializers (gitignored) |
prisma/migrations/0001_init/migration.sql |
Generated DDL for D1 |
src/livestore/schema.ts |
LiveStore tables + events + materialisers wrapper |
src/livestore/queries.ts |
Pre-built uiState$ query |
src/livestore/store.ts |
useAppStore() hook |
src/db/liveStoreCollection.ts |
TanStack DB collection options creator |
src/db/todoCollection.ts |
The todos collection wired with LiveStore events |
src/db/todoSchema.ts |
Client-facing Effect Schema for the row type |
src/components/*.tsx |
React components using useLiveQuery |
src/cf-worker/index.ts |
LiveStore sync DO with D1 write-through + SPA fallback |
alchemy.run.ts |
D1 + Durable Object + Worker |
packages/livestore-prisma/src/generator.ts |
The livestore Prisma generator source (built to dist/) |
packages/livestore-prisma/prisma-livestore-generator.cjs |
CJS shim that import()s the built ESM generator |
.gitignore |
Excludes node_modules, dist, generated, .alchemy, etc. |
- TODO app example pulled from
bunx @livestore/cli@dev create --example tutorial-starter livestore-todo-app
MIT