Core Concepts
Schemas define your data
Section titled “Schemas define your data”Every piece of data in xNet is a node. Every node conforms to a schema. A schema is defined once in TypeScript and describes the shape of your data:
import { defineSchema, text, number, select } from '@xnet/data'
export const ProjectSchema = defineSchema({ name: 'Project', namespace: 'xnet://my-app/', properties: { title: text({ required: true }), priority: number({ min: 1, max: 5 }), status: select({ options: [ { id: 'active', name: 'Active' }, { id: 'archived', name: 'Archived' } ] as const }) }})Schemas give you:
- Type inference —
project.statusis'active' | 'archived', notstring - Validation — invalid data is rejected at creation time
- Identity — each schema has a unique IRI like
xnet://my-app/Project
See defineSchema and Property Types for full reference.
Nodes are your data
Section titled “Nodes are your data”A node is an instance of a schema. When you create a task, you create a node:
const { create } = useMutate()const task = await create(TaskSchema, { title: 'Ship it', status: 'todo' })Every node has system fields managed automatically by the store — you never need to define or set these yourself:
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier (nanoid, 21 chars) |
schemaId | string | Schema IRI (e.g., xnet://my-app/Task) |
createdAt | number | Set automatically at creation time |
createdBy | string | Author’s DID (e.g., did:key:z6Mk...) |
updatedAt | number | Updated automatically on every change |
updatedBy | string | Last modifier’s DID |
deleted | boolean | Soft-delete flag |
Your schema properties are merged alongside these fields into a flat node — you access task.title directly, not task.properties.title. You can filter and sort by any system field (e.g., orderBy: { createdAt: 'desc' }) without defining them in your schema.
Three hooks for everything
Section titled “Three hooks for everything”useQuery — read data
Section titled “useQuery — read data”const { data: tasks } = useQuery(TaskSchema, { where: { status: 'todo' }, orderBy: { createdAt: 'desc' }, limit: 20})Returns reactive data that auto-updates when the underlying store changes — whether from your own mutations, a peer’s sync, or a background process.
useMutate — write data
Section titled “useMutate — write data”const { create, update, remove, restore, mutate } = useMutate()
await create(TaskSchema, { title: 'New' })await update(TaskSchema, id, { status: 'done' })await remove(id)await restore(id)await mutate([ { type: 'create', schema: TaskSchema, data: { title: 'A' } }, { type: 'create', schema: TaskSchema, data: { title: 'B' } }])All mutations are type-safe, validated against the schema, and automatically synced.
useNode — edit a single node with CRDT
Section titled “useNode — edit a single node with CRDT”const { data, doc, update, syncStatus, remoteUsers } = useNode(PageSchema, pageId, { createIfMissing: { title: 'Untitled' }, did: currentUserDID})useNode is the powerhouse hook for collaborative editing. It gives you:
- Structured data via
data(the node’s properties) - A Yjs document via
doc(for rich text, bind to TipTap) - Sync status and remote users for presence UI
- Automatic persistence with debounced saves
Two conflict resolution strategies
Section titled “Two conflict resolution strategies”xNet uses different strategies for different data types:
| Data Type | Strategy | How It Works |
|---|---|---|
| Structured data (properties) | Lamport LWW (Last Writer Wins) | Each property change carries a Lamport timestamp. Higher timestamp wins. |
| Rich text (documents) | Yjs CRDT | Character-level merge. Two users typing in the same paragraph are merged without data loss. |
This dual strategy means structured fields like title or status use simple, predictable conflict resolution, while collaborative text editing uses the industry-standard Yjs CRDT for seamless merging.
Every change is signed
Section titled “Every change is signed”When you create or update a node, xNet:
- Signs the change with your Ed25519 private key
- Hashes the change with BLAKE3
- Stores the signed change locally
- Syncs the signed change to peers
When a peer receives a change, they verify the signature before applying it. This means:
- You can prove who made every change
- Tampered data is rejected
- No server is needed to enforce trust
Identity is a key pair
Section titled “Identity is a key pair”Your identity in xNet is a DID:key — a decentralized identifier derived from an Ed25519 public key:
did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doKNo accounts. No passwords. No auth server. Your key pair is your identity. You can delegate permissions to other keys using UCAN tokens (capability-based authorization).