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 '@xnetjs/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, presence } = 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 presence for collaborator 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 a hybrid signature (Ed25519 + ML-DSA-65 by default)
- 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 hybrid signature before applying it. This means:
- You can prove who made every change
- Tampered data is rejected
- No server is needed to enforce trust
- Changes are protected against quantum forgery attacks
The default Level 1 (Hybrid) signature carries both an Ed25519 component (64 bytes) and an ML-DSA-65 component (~3.3 KB). Both must verify — if either is invalid, the change is rejected. Level 0 (Ed25519 only) is available for high-frequency paths where the smaller signature size matters more than quantum resistance.
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).
Authorization is encryption
Section titled “Authorization is encryption”xNet enforces read access cryptographically. Every private node is encrypted with a per-node content key. Only users in the recipients list can unwrap the key and read the node — the hub never decrypts anything.
You define access control in the schema itself:
const TaskSchema = defineSchema({ // ... authorization: { roles: { owner: role.creator(), editor: role.property('editors') }, actions: { read: allow('owner', 'editor'), write: allow('owner', 'editor'), share: allow('owner') } }})Then check permissions in components:
const { canWrite, canShare } = useCan(taskId)const { grants, grant, revoke } = useGrants(taskId)Grants are stored as ordinary nodes — they sync, replicate, and inherit the same CRDT conflict resolution as all your data.
See the Authorization Guide for the full model including delegation, key recovery, and offline policy.