Skip to content

Version Compatibility

xNet uses a translate-on-read approach to version compatibility:

flowchart LR
  subgraph Storage
    V1["v1 data"]
    V2["v2 data"]
    V3["v3 data"]
  end

  subgraph "Read Path"
    L1["Lens v1→v2"]
    L2["Lens v2→v3"]
  end

  subgraph App
    Current["Current Schema (v3)"]
  end

  V1 --> L1 --> L2 --> Current
  V2 --> L2
  V3 --> Current
  1. Data is stored in its original format
  2. Migrations are applied when reading, not when writing
  3. Old clients can still read new data (graceful degradation)
  4. No batch migrations required

This means you can ship schema changes incrementally without breaking existing clients.

The sync protocol is versioned separately from schemas. Every Change<T> includes a protocolVersion:

interface Change<T> {
id: string
type: string
payload: T
protocolVersion: number // Currently: 1
hash: ContentId
parentHash: ContentId | null
authorDID: DID
signature: Uint8Array
lamport: LamportTimestamp
}
Your VersionPeer VersionBehavior
v1v1Full compatibility
v1v0 (legacy)Full backward compatibility
v1v2+Partial — unknown fields preserved, warning logged

When peers connect, they negotiate a common feature set:

sequenceDiagram
  participant A as Client A (v1.2)
  participant B as Client B (v1.0)

  A->>B: Hello {features: [yjs, change_v2, compression]}
  B->>A: Hello {features: [yjs, change_v1]}
  Note over A,B: Negotiate intersection
  A->>B: Using {features: [yjs, change_v1]}
  Note over A,B: A downgrades to v1 format
// Handshake exchange
{
clientVersion: '1.2.0',
protocolVersion: 1,
features: ['yjs_sync', 'change_v2', 'signatures', 'compression']
}

The negotiator finds the intersection of features both peers support. If a required feature is missing, the connection is downgraded or rejected.

Every schema should have a semantic version:

import { defineSchema } from '@xnetjs/data'
const TaskSchema = defineSchema({
name: 'Task',
version: '2.0.0', // Major.Minor.Patch
properties: {
title: { type: 'text' },
status: { type: 'select', options: ['todo', 'doing', 'done'] },
priority: { type: 'select', options: ['low', 'medium', 'high'] }
}
})
Change TypeVersion BumpExample
Add optional fieldMinor1.0.01.1.0
Add required field with defaultMinor1.0.01.1.0
Remove fieldMajor1.0.02.0.0
Rename fieldMajor1.0.02.0.0
Change field typeMajor1.0.02.0.0
Fix bug in migrationPatch1.0.11.0.2

Every schema change falls into one of three risk categories:

flowchart TB
    subgraph "SAFE - Ship Anytime"
        SAFE1["Add optional properties"]
        SAFE2["Add new schemas"]
        SAFE3["Add new change types"]
        SAFE4["Add new select options"]
        SAFE5["Make required → optional"]
    end

    subgraph "CAUTION - Plan Ahead"
        CAUTION1["Add required properties"]
        CAUTION2["Remove properties"]
        CAUTION3["Make optional → required"]
        CAUTION4["Change property defaults"]
    end

    subgraph "BREAKING - Requires Migration"
        DANGER1["Change property type"]
        DANGER2["Rename properties"]
        DANGER3["Change schema namespace"]
        DANGER4["Remove select options"]
    end

    style SAFE1 fill:#c8e6c9
    style SAFE2 fill:#c8e6c9
    style SAFE3 fill:#c8e6c9
    style SAFE4 fill:#c8e6c9
    style SAFE5 fill:#c8e6c9
    style CAUTION1 fill:#fff3e0
    style CAUTION2 fill:#fff3e0
    style CAUTION3 fill:#fff3e0
    style CAUTION4 fill:#fff3e0
    style DANGER1 fill:#ffcdd2
    style DANGER2 fill:#ffcdd2
    style DANGER3 fill:#ffcdd2
    style DANGER4 fill:#ffcdd2
Change TypeForward SafeBackward SafeAuto-MigrateManual Action
Add optional propertyYesYesN/ANone
Add required propertyNoYesYesDefine default
Remove propertyYesNoYesNone
Change property typeNoNoMaybeDefine transform
Rename propertyNoNoYesDefine mapping
Add select optionYesYesN/ANone
Remove select optionNoNoMaybeDefine fallback
Make optional→requiredNoYesYesDefine default
Make required→optionalYesYesN/ANone

Legend:

  • Forward Safe: Old clients can read data created by new clients
  • Backward Safe: New clients can read data created by old clients
  • Auto-Migrate: The system can generate migration code automatically

Use the Migrate panel in DevTools to:

  1. Analyze - Scan your store for schema changes
  2. Review - See changes grouped by risk level
  3. Generate - Auto-generate lens code for migrations
  4. Test - Validate migrations on sample data
  5. Apply - Apply migrations to your store
flowchart TB
    START["Schema Change Detected"]
    ANALYZE["Analyze Changes"]
    CLASSIFY["Classify Risk"]

    START --> ANALYZE
    ANALYZE --> CLASSIFY

    CLASSIFY -->|"Safe"| AUTO["Auto-apply"]
    CLASSIFY -->|"Caution"| REVIEW["Review & Confirm"]
    CLASSIFY -->|"Breaking"| WIZARD["Migration Wizard"]

    WIZARD --> LENS["Generate Lens Code"]
    LENS --> TEST["Test Migration"]
    TEST -->|"Pass"| APPLY["Apply to Store"]
    TEST -->|"Fail"| EDIT["Edit Lens"]
    EDIT --> TEST

    AUTO --> DONE["Done"]
    REVIEW --> APPLY
    APPLY --> DONE

    style AUTO fill:#c8e6c9
    style REVIEW fill:#fff3e0
    style WIZARD fill:#ffcdd2

Lenses transform data between schema versions. They’re bidirectional — you can upgrade and downgrade.

flowchart LR
  subgraph "Lens"
    UP["up()"]
    DOWN["down()"]
  end

  V1["v1.0.0"] -->|upgrade| UP --> V2["v2.0.0"]
  V2 -->|downgrade| DOWN --> V1
import { createLens, type SchemaLens } from '@xnetjs/data'
const taskV1ToV2: SchemaLens = createLens({
from: '1.0.0',
to: '2.0.0',
// Transform v1 → v2
up(node) {
return {
...node,
priority: node.priority ?? 'medium' // Add default
}
},
// Transform v2 → v1
down(node) {
const { priority, ...rest } = node
return rest // Remove new field
}
})
import { LensRegistry } from '@xnetjs/data'
const registry = new LensRegistry()
// Register all lenses for a schema
registry.register('Task', taskV1ToV2)
registry.register('Task', taskV2ToV3)
// The registry automatically chains lenses
// v1 → v2 → v3 happens automatically
flowchart LR
  V1["v1.0.0"] --> L1["Lens 1"] --> V2["v2.0.0"] --> L2["Lens 2"] --> V3["v3.0.0"]

  style V1 fill:#fee
  style V2 fill:#ffe
  style V3 fill:#efe

The registry finds the shortest path between any two versions and chains the lenses automatically.

import { NodeStore } from '@xnetjs/data'
const store = new NodeStore({ lensRegistry: registry })
// Read with automatic migration
const task = await store.getWithMigration(taskId, {
targetVersion: '3.0.0'
})
// Data is transformed to v3 format
// Original data unchanged in storage
const original = await store.get(taskId)
// Still in its original version
const addPriority = createLens({
from: '1.0.0',
to: '1.1.0',
up: (node) => ({
...node,
priority: 'medium' // Sensible default
}),
down: (node) => {
const { priority, ...rest } = node
return rest
}
})
const renameDueDate = createLens({
from: '1.0.0',
to: '2.0.0',
up: (node) => {
const { due_date, ...rest } = node
return { ...rest, dueDate: due_date }
},
down: (node) => {
const { dueDate, ...rest } = node
return { ...rest, due_date: dueDate }
}
})
const splitName = createLens({
from: '1.0.0',
to: '2.0.0',
up: (node) => {
const [firstName, ...rest] = (node.name || '').split(' ')
return {
...node,
firstName,
lastName: rest.join(' ')
}
},
down: (node) => ({
...node,
name: `${node.firstName} ${node.lastName}`.trim()
})
})
import { useQuery } from '@xnetjs/react'
function TaskList() {
const { data: tasks } = useQuery({
type: 'Task',
targetVersion: '3.0.0' // Migrate to this version
})
// All tasks are v3 format, regardless of storage version
return tasks.map((task) => <TaskCard key={task.id} task={task} />)
}
Terminal window
# Extract current schema to JSON
xnet schema extract --output schemas/
# Creates schemas/Task.v2.0.0.json
Terminal window
# Compare two versions
xnet schema diff Task 1.0.0 2.0.0
# Output:
# + priority: select (added)
# ~ status: text → select (type changed)
Terminal window
# Check that lenses are registered for all version pairs
xnet migrate validate
# Output:
# ✓ Task: 1.0.0 → 2.0.0 (lens registered)
# ✓ Task: 2.0.0 → 3.0.0 (lens registered)
# ✗ Contact: 1.0.0 → 2.0.0 (no lens!)
Terminal window
# Full diagnostic
xnet doctor
# Output:
# Checking nodes...
# ✓ 1,234 nodes checked
# ✓ All hashes valid
# ⚠ 3 nodes have unknown schema types
#
# Summary:
# Errors: 0
# Warnings: 3
# Status: HEALTHY (with warnings)
Terminal window
# Preview what would be fixed
xnet repair --dry-run
# Apply fixes
xnet repair
Terminal window
# Export all data to JSON
xnet export --output backup.json
# Import with migration
xnet import backup.json --migrate-to-current

When encountering unknown data, xNet preserves it rather than discarding it:

flowchart TD
  subgraph "New Client (v2)"
    N1["Create node with\nnew 'priority' field"]
  end

  subgraph "Old Client (v1)"
    O1["Receive node"]
    O2["Unknown field\n'priority'"]
    O3["Store as-is\n(preserve field)"]
    O4["Edit 'title'"]
    O5["Save node\n(priority intact)"]
  end

  N1 --> O1 --> O2 --> O3 --> O4 --> O5
// Node with unknown schema type
const node = {
'@type': 'xnet://example.com/FutureType',
title: 'Something new',
unknownField: { complex: 'data' }
}
// Old client behavior:
// - Stores node as-is
// - Can display raw data
// - Preserves unknown fields on save
// Property with future type
const property = {
name: 'location',
type: 'geo-polygon', // Unknown type
value: { points: [...] }
}
// Old client behavior:
// - Preserves value
// - Displays as JSON in UI
// - Round-trips correctly

Features follow a deprecation lifecycle:

flowchart LR
  A["Active"] --> D["Deprecated\n(warning)"]
  D --> X["Discouraged\n(error)"]
  X --> R["Removed"]

  D -.- |"6+ months"| X
  X -.- |"3+ months"| R
StageBehaviorDuration
ActiveWorks normallyIndefinite
DeprecatedWorks, warning loggedMin 6 months
DiscouragedWorks, error loggedMin 3 months
RemovedFeature does not existPermanent

Check for deprecations programmatically:

import { checkDeprecations } from '@xnetjs/sync'
const warnings = checkDeprecations({
protocolVersion: 0,
features: ['change_v1']
})
warnings.forEach((w) => {
console.warn(`${w.feature}: ${w.message}`)
console.warn(` Deadline: ${w.deadline}`)
})

The Version DevTools panel shows:

  • Current protocol version
  • Connected peer versions
  • Feature negotiation results
  • Deprecation warnings
  • Schema version distribution

Enable it in the DevTools:

import { XNetProvider } from '@xnetjs/react'
import { XNetDevToolsProvider } from '@xnetjs/devtools'
function App() {
return (
<XNetProvider config={{ authorDID, signingKey }}>
<XNetDevToolsProvider>
<YourApp />
</XNetDevToolsProvider>
</XNetProvider>
)
}
  1. Always provide defaults when adding fields:

    up: (node) => ({ ...node, priority: node.priority ?? 'medium' })
  2. Test both directions — migrations should round-trip:

    const roundTripped = lens.down(lens.up(original))
    expect(roundTripped.title).toBe(original.title)
  3. Keep lenses simple — one concern per lens, chain for complex migrations

  4. Version bumps are cheap — don’t be afraid to bump minor versions for additive changes

  5. Run xnet doctor in CI to catch issues early