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, boolean, select, relation } from '@xnet/data'
const Task = defineSchema('task', {
title: text(),
priority: number({ default: 0 }),
done: boolean({ default: false }),
status: select({ options: ['todo', 'doing', 'done'] }),
project: relation()
})

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 16 property types:

TypeValueNotes
text()stringPlain text
number()numberNumeric value
boolean()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.many()string[]Multiple references
person()stringReference to a DID
person.many()string[]Multiple DID references
checkbox()booleanAlias for boolean
file()objectFile attachment
formula()computedComputed at read time
rollup()computedAggregation over relations

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)
schemaIRI: 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',
schemaIRI: 'xnet://app/task',
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 atomic multi-node operations, use transactions:

const result = mutate.transaction((tx) => {
const projectId = tx.create(ProjectSchema, { name: 'Acme' })
tx.create(TaskSchema, { title: 'Setup', project: projectId })
tx.create(TaskSchema, { title: 'Launch', project: projectId })
})

Transactions are batched — all changes share a batchId and are applied atomically. Temporary IDs used within a transaction are resolved to real IDs when committed.