Version Compatibility
Overview
Section titled “Overview”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
- Data is stored in its original format
- Migrations are applied when reading, not when writing
- Old clients can still read new data (graceful degradation)
- No batch migrations required
This means you can ship schema changes incrementally without breaking existing clients.
Protocol versioning
Section titled “Protocol versioning”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}Version compatibility
Section titled “Version compatibility”| Your Version | Peer Version | Behavior |
|---|---|---|
| v1 | v1 | Full compatibility |
| v1 | v0 (legacy) | Full backward compatibility |
| v1 | v2+ | Partial — unknown fields preserved, warning logged |
Feature negotiation
Section titled “Feature negotiation”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.
Schema versioning
Section titled “Schema versioning”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'] } }})Version bump rules
Section titled “Version bump rules”| Change Type | Version Bump | Example |
|---|---|---|
| Add optional field | Minor | 1.0.0 → 1.1.0 |
| Add required field with default | Minor | 1.0.0 → 1.1.0 |
| Remove field | Major | 1.0.0 → 2.0.0 |
| Rename field | Major | 1.0.0 → 2.0.0 |
| Change field type | Major | 1.0.0 → 2.0.0 |
| Fix bug in migration | Patch | 1.0.1 → 1.0.2 |
Schema change classification
Section titled “Schema change classification”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
Classification matrix
Section titled “Classification matrix”| Change Type | Forward Safe | Backward Safe | Auto-Migrate | Manual Action |
|---|---|---|---|---|
| Add optional property | Yes | Yes | N/A | None |
| Add required property | No | Yes | Yes | Define default |
| Remove property | Yes | No | Yes | None |
| Change property type | No | No | Maybe | Define transform |
| Rename property | No | No | Yes | Define mapping |
| Add select option | Yes | Yes | N/A | None |
| Remove select option | No | No | Maybe | Define fallback |
| Make optional→required | No | Yes | Yes | Define default |
| Make required→optional | Yes | Yes | N/A | None |
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
Migration wizard
Section titled “Migration wizard”Use the Migrate panel in DevTools to:
- Analyze - Scan your store for schema changes
- Review - See changes grouped by risk level
- Generate - Auto-generate lens code for migrations
- Test - Validate migrations on sample data
- 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
Migrations with lenses
Section titled “Migrations with lenses”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
Creating a lens
Section titled “Creating a lens”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 }})Registering lenses
Section titled “Registering lenses”import { LensRegistry } from '@xnetjs/data'
const registry = new LensRegistry()
// Register all lenses for a schemaregistry.register('Task', taskV1ToV2)registry.register('Task', taskV2ToV3)
// The registry automatically chains lenses// v1 → v2 → v3 happens automaticallyflowchart 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.
Reading with migration
Section titled “Reading with migration”import { NodeStore } from '@xnetjs/data'
const store = new NodeStore({ lensRegistry: registry })
// Read with automatic migrationconst task = await store.getWithMigration(taskId, { targetVersion: '3.0.0'})// Data is transformed to v3 format
// Original data unchanged in storageconst original = await store.get(taskId)// Still in its original versionCommon migration patterns
Section titled “Common migration patterns”Adding a field
Section titled “Adding a field”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 }})Renaming a field
Section titled “Renaming a field”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 } }})Splitting a field
Section titled “Splitting a field”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() })})React integration
Section titled “React integration”useQuery with migrations
Section titled “useQuery with migrations”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} />)}CLI tools
Section titled “CLI tools”Extract schemas
Section titled “Extract schemas”# Extract current schema to JSONxnet schema extract --output schemas/
# Creates schemas/Task.v2.0.0.jsonCompare schemas
Section titled “Compare schemas”# Compare two versionsxnet schema diff Task 1.0.0 2.0.0
# Output:# + priority: select (added)# ~ status: text → select (type changed)Validate migrations
Section titled “Validate migrations”# Check that lenses are registered for all version pairsxnet 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!)Diagnose issues
Section titled “Diagnose issues”# Full diagnosticxnet 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)Repair data
Section titled “Repair data”# Preview what would be fixedxnet repair --dry-run
# Apply fixesxnet repairExport and import
Section titled “Export and import”# Export all data to JSONxnet export --output backup.json
# Import with migrationxnet import backup.json --migrate-to-currentGraceful degradation
Section titled “Graceful degradation”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
Unknown schema type
Section titled “Unknown schema type”// Node with unknown schema typeconst 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 saveUnknown property type
Section titled “Unknown property type”// Property with future typeconst property = { name: 'location', type: 'geo-polygon', // Unknown type value: { points: [...] }}
// Old client behavior:// - Preserves value// - Displays as JSON in UI// - Round-trips correctlyDeprecation policy
Section titled “Deprecation policy”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
| Stage | Behavior | Duration |
|---|---|---|
| Active | Works normally | Indefinite |
| Deprecated | Works, warning logged | Min 6 months |
| Discouraged | Works, error logged | Min 3 months |
| Removed | Feature does not exist | Permanent |
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}`)})DevTools panel
Section titled “DevTools panel”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> )}Best practices
Section titled “Best practices”-
Always provide defaults when adding fields:
up: (node) => ({ ...node, priority: node.priority ?? 'medium' }) -
Test both directions — migrations should round-trip:
const roundTripped = lens.down(lens.up(original))expect(roundTripped.title).toBe(original.title) -
Keep lenses simple — one concern per lens, chain for complex migrations
-
Version bumps are cheap — don’t be afraid to bump minor versions for additive changes
-
Run
xnet doctorin CI to catch issues early
Further reading
Section titled “Further reading”- Sync Guide — How sync works
- Schemas Overview — Defining schemas
- DevTools — Debugging tools