Skip to content

Data Model

xNet’s data model has three layers:

  1. Schemas — Define the shape of your data (property types, defaults, validation)
  2. Nodes — Individual data records conforming to a schema
  3. Changes — Signed, hash-chained mutations that form an audit trail

A schema declares the properties a node can have, using typed builder functions:

import { defineSchema, text, number, checkbox, select, relation } from '@xnetjs/data'
const TaskSchema = defineSchema({
name: 'Task',
namespace: 'xnet://my-app/',
properties: {
title: text({ required: true }),
priority: number({ default: 0 }),
done: checkbox({ default: false }),
status: select({ options: [{ id: 'todo', name: 'To Do' }] as const }),
project: relation({ target: 'xnet://my-app/Project@1.0.0' })
}
})

Each builder returns a typed descriptor that encodes the value type, default, and cardinality. TypeScript infers the full type from the schema definition — no manual type annotations needed.

xNet provides 15 property types:

TypeValueNotes
text()stringPlain text
number()numberNumeric value
checkbox()booleanTrue/false
date()numberUnix timestamp
select()stringSingle choice from options
multiSelect()string[]Multiple choices
url()stringURL string
email()stringEmail address
phone()stringPhone number
relation()stringReference to another node
relation({ multiple: true })string[]Multiple references
person()stringReference to a DID
person({ multiple: true })string[]Multiple DID references
file()objectFile attachment

See Property Types for full details on each type.

A node is an instance of a schema. Every node has base fields that are managed automatically by the store — you never define or set these yourself:

interface NodeBase {
id: string // Unique node ID (auto-generated)
schemaId: string // Schema identifier
createdAt: number // Set at creation time
updatedAt: number // Updated on every change
createdBy: string // Author DID
updatedBy: string // Last modifier's DID
}

When you read a node via useQuery or useNode, you get a FlatNode — the base fields merged with the schema properties:

// FlatNode<TaskSchema>
{
id: 'abc123',
schemaId: 'xnet://app/task@1.0.0',
createdAt: 1706000000,
updatedAt: 1706000100,
createdBy: 'did:key:z6Mk...',
title: 'Ship the feature',
priority: 1,
done: false,
status: 'doing',
project: 'project-456'
}

The NodeStore is the central data layer that manages CRUD operations and change tracking:

  • Createstore.create(schema, properties) → new node with signed change
  • Readstore.get(id) or store.query(schema, filter) → node(s)
  • Updatestore.update(id, changes) → signed change with Lamport timestamp
  • Deletestore.delete(id) → soft delete with signed change

Every mutation creates a Change<T> record that is signed, hash-chained, and propagated to peers.

The Change<T> type is the fundamental unit of sync:

interface Change<T> {
id: string // Unique change ID
type: string // e.g., 'create-task', 'update-task'
payload: T // The actual data
hash: ContentId // cid:blake3:... content-addressed
parentHash: ContentId | null // Previous change in the chain
authorDID: DID // Who made this change
signature: Uint8Array // Ed25519 over the hash
wallTime: number // Wall clock (display only)
lamport: LamportTimestamp // Causal ordering
}

Changes form a hash chain — each change references its parent’s hash. This creates a verifiable audit trail where:

  • Tampering is detectable — altering a change invalidates its hash and breaks the chain
  • Authorship is provable — every change is signed with Ed25519
  • Ordering is deterministic — Lamport timestamps provide causal ordering across peers

For multi-node operations, use useMutate().mutate(...):

await mutate([
{ type: 'create', schema: ProjectSchema, id: 'project-acme', data: { name: 'Acme' } },
{ type: 'create', schema: TaskSchema, data: { title: 'Setup', project: 'project-acme' } },
{ type: 'create', schema: TaskSchema, data: { title: 'Launch', project: 'project-acme' } }
])

The current mutate() API returns per-operation results and runs through the active data bridge.