For coding agents
Paste this into your coding agent so it uses Vennbase and pulls the package docs instead of inventing a backend.
How it works
Every piece of data in Vennbase is a row. A row belongs to a collection defined in your schema, holds typed fields, and has its own identity.
Rows can be nested. A card lives inside a board; a recentBoard lives inside the built-in user collection. Parent links define query scope and visibility: gaining access to a parent automatically grants access to its children.
Access is explicit-grant only. To let someone into a row, generate a share link and send it to them. They accept it, they're in. There are no rule expressions to write and no policy surface to misconfigure.
Schema
Define your collections once. TypeScript infers field types throughout the SDK automatically.
import { collection, defineSchema, field } from "@vennbase/core";
export const schema = defineSchema({
boards: collection({
fields: {
title: field.string(),
},
}),
recentBoards: collection({
in: ["user"],
fields: {
boardRef: field.ref("boards").indexKey(),
openedAt: field.number().indexKey(),
},
}),
cards: collection({
in: ["boards"],
fields: {
text: field.string(),
done: field.boolean(),
createdAt: field.number().indexKey(),
},
}),
});
export type Schema = typeof schema;
collection({ in: [...] })—inlists the allowed parent collections.field.string()/.number()/.boolean()/.date()/.ref(collection)— typed fields; chain.indexKey(),.optional(), or.default(value)as needed
Fields are for metadata that you want to query. Mark fields with .indexKey() when they should be stored in the parent query index.
Only .indexKey() fields can be used in where and orderBy.
Important: select: "indexKeys" returns a projection of only .indexKey() fields. Before adding .indexKey(), assume submitters with index-key-query access may read that field.
The canonical CRDT pattern is: row fields hold metadata and row refs, while the CRDT document holds the collaborative value state for that row.
Querying
Vennbase queries always run within a known scope. For cards, that scope is a board, so you pass in: board. For collections declared as in: ["user"], pass in: CURRENT_USER.
Queries never mean "all accessible rows". in is always required, and collections not declared in another cannot be queried.
Imperative
import { CURRENT_USER } from "@vennbase/core";
const recentBoards = await db.query("recentBoards", {
in: CURRENT_USER,
orderBy: "openedAt",
order: "desc",
limit: 10,
});
// Multi-parent queries run in parallel, then merge and sort their results
const cards = await db.query("cards", {
in: [todoBoard, bugsBoard],
orderBy: "createdAt",
order: "asc",
limit: 20,
});
With React
@vennbase/react ships a useQuery hook that polls for changes and re-renders automatically:
import { useQuery } from "@vennbase/react";
const { rows: cards = [], isLoading } = useQuery(db, "cards", {
in: board,
orderBy: "createdAt",
order: "asc",
});
Full rows vs index-key projections
The default query result is a full row handle. Full rows are locatable and reusable: they expose ref, owner, fields, row membership APIs, parent-link APIs, and can be passed back into row workflows.
Index key queries are intentionally weaker:
const slots = await db.query("bookings", {
in: bookingRoot,
select: "indexKeys",
orderBy: "slotStartMs",
});
They return objects shaped like { kind: "index-key-projection", id, collection, fields }, where fields contains only values declared .indexKey(). These are index-key projections only. They are not row refs, cannot be reopened, and cannot be passed to row-handle APIs.
Sharing rows with share links
Access to a row is always explicit. There is no rule system to misconfigure. A user either holds a valid invite token or they do not.
const { shareLink } = useShareLink(db, board, "all-editor");
const incomingInvite = useAcceptInviteFromUrl<Schema, BoardHandle>(db, {
enabled: board === null,
onOpen: (nextBoard) => {
void rememberRecentBoard(nextBoard).catch(console.error);
setBoard(nextBoard);
},
});
const editorLink = db.createShareLink(board, "all-editor").value;
const sharedBoard = await db.acceptInvite(editorLink);
acceptInvite accepts either a full invite URL or a pre-parsed { ref, shareToken? } object from db.parseInvite(input). In React, useAcceptInviteFromUrl(db, ...) handles the common invite-landing flow for you.
Role names start with read scope within the row:
index-*can only work with child rows, and not access content.content-*can also access row content, parents, and sync, but not members.all-*can access content and inspect members.
and end with write capability:
*-viewercan only read that scope*-submittercan also add child rows*-editorcan edit anything the role can read.
For blind inbox workflows, create an index-submitter link instead:
const submissionLink = db.createShareLink(board, "index-submitter").value;
const joined = await db.joinInvite(submissionLink);
// joined.role === "index-submitter"
joinInvite is idempotent, so call it whenever you need it.
"index-submitter" members can create child rows under the shared parent and can run db.query(..., { select: "indexKeys" }) to see only index-key projections from sibling rows. If the app needs durable revisit/edit/cancel flows, the usual pattern is to also link the child into a user-specific join row that the submitter can query later.
Vennbase documentation
| Document | Description |
|---|---|
packages/todo-app | Small working board-and-cards app mirrored by this README. |
PATTERNS.md | Recipe-style app patterns for blind inboxes, index-key projections, resource claims, and other real-world Vennbase designs. |
Setup
Create one Vennbase instance for your app and pass it an appBaseUrl so that share links point back to your app:
import { Vennbase } from "@vennbase/core";
import { schema } from "./schema";
export const db = new Vennbase({ schema, appBaseUrl: window.location.origin });
Auth and startup
import { useSession } from "@vennbase/react";
function AppShell() {
const session = useSession(db);
const signedIn = session.status === "success" && session.data?.signedIn === true;
if (session.status === "loading") {
return <p>Checking session…</p>;
}
if (!signedIn) {
return <button onClick={() => void session.signIn()}>Log in with Puter</button>;
}
return <App />;
}
Creating rows
// Create a top-level row
const board = db.create("boards", { title: "Launch checklist" }).value;
// Create a child row — pass the parent row or row ref
db.create("cards", { text: "Write README", done: false, createdAt: Date.now() }, { in: board });
db.create("cards", { text: "Publish to npm", done: false, createdAt: Date.now() }, { in: board });
create and update are synchronous optimistic writes. In normal app code, use .value on the returned receipt when you want the row handle immediately. Only await .committed when another client must be able to rely on the write right away, or when you explicitly need remote confirmation.
To update fields on an existing row:
db.update("cards", card, { done: true });
Membership
Only all-* role members can inspect the member list, and only all-editor can revoke direct members:
// Flat list of usernames
const members = await db.listMembers(board);
// With roles
const detailed = await db.listDirectMembers(board);
// → [{ username: "alice", role: "all-editor" }, ...]
// Grant access by sending an invite link
const editorLink = await db.createShareLink(board, "all-editor").committed;
// Later, revoke an already-joined direct member
await db.removeMember(board, "eve").committed;
Membership inherited through a parent row is visible via listEffectiveMembers. For app-level "memberships" that need discovery, ordering, or extra metadata, prefer modeling them as rows and parent links instead of username-only grants. A common pattern is sharing a readable parent row with content-submitter, then letting each user create their own child join row beneath it.
Real-time sync (CRDT)
Vennbase includes a CRDT message bridge. Connect any CRDT library to a row and all members receive each other's updates in real time.
Sending CRDT updates requires "content-editor" or "all-editor" access. Any readable role (content-* or all-*) can poll and receive them.
In React, here is the recommended Yjs integration:
import * as Y from "yjs";
import { createYjsAdapter } from "@vennbase/yjs";
import { useCrdt } from "@vennbase/react";
const adapter = createYjsAdapter(Y);
const { value: doc, flush } = useCrdt(board, adapter);
// Write to doc normally, then push immediately when needed
await flush();
@vennbase/yjs uses your app's yjs instance instead of bundling its own runtime, which avoids the multi-runtime Yjs failure mode.
Example apps
packages/todo-app is the working version of this README — boards, recent boards, cards, and share links. Start with src/schema.ts, src/db.ts, and src/App.tsx. Try it live, or run it with:
pnpm --filter todo-app dev
For a fuller picture of how the pieces fit together in a real app, read packages/woof-app. It uses CRDT-backed live chat, user-scoped history rows for room restore, child rows with per-user metadata, and role-aware UI — the patterns you'll reach for once basic reads and writes are working. Try it live.
pnpm --filter woof-app dev
packages/appointment-app is the clearest example of the Vennbase access-control philosophy in a full app: explicit grants, a blind booking inbox, and minimal anonymous sibling visibility via select: "indexKeys". It demonstrates convergent client-side claim resolution, not hard capacity enforcement. Read PATTERNS.md for a recipe-style walkthrough of each pattern. Try it live.
pnpm --filter appointment-app dev