mirror of
https://github.com/zadam/trilium.git
synced 2025-11-08 23:49:00 +01:00
584 lines
14 KiB
Markdown
Vendored
584 lines
14 KiB
Markdown
Vendored
# Trilium Synchronization Architecture
|
|
|
|
> **Related:** [ARCHITECTURE.md](ARCHITECTURE.md) | [User Guide: Synchronization](https://triliumnext.github.io/Docs/Wiki/synchronization)
|
|
|
|
## Overview
|
|
|
|
Trilium implements a sophisticated **bidirectional synchronization system** that allows users to sync their note databases across multiple devices (desktop clients and server instances). The sync protocol is designed to handle:
|
|
|
|
- Concurrent modifications across devices
|
|
- Conflict resolution
|
|
- Partial sync (only changed entities)
|
|
- Protected note synchronization
|
|
- Efficient bandwidth usage
|
|
|
|
## Sync Architecture
|
|
|
|
```
|
|
┌─────────────┐ ┌─────────────┐
|
|
│ Desktop 1 │ │ Desktop 2 │
|
|
│ (Client) │ │ (Client) │
|
|
└──────┬──────┘ └──────┬──────┘
|
|
│ │
|
|
│ WebSocket/HTTP │
|
|
│ │
|
|
▼ ▼
|
|
┌────────────────────────────────────────────────┐
|
|
│ Sync Server │
|
|
│ ┌──────────────────────────────────────┐ │
|
|
│ │ Sync Service │ │
|
|
│ │ - Entity Change Management │ │
|
|
│ │ - Conflict Resolution │ │
|
|
│ │ - Version Tracking │ │
|
|
│ └──────────────────────────────────────┘ │
|
|
│ │ │
|
|
│ ┌──────┴───────┐ │
|
|
│ │ Database │ │
|
|
│ │ (entity_changes)│ │
|
|
│ └──────────────┘ │
|
|
└────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Core Concepts
|
|
|
|
### Entity Changes
|
|
|
|
Every modification to any entity (note, branch, attribute, etc.) creates an **entity change** record:
|
|
|
|
```sql
|
|
entity_changes (
|
|
id, -- Auto-increment ID
|
|
entityName, -- 'notes', 'branches', 'attributes', etc.
|
|
entityId, -- ID of the changed entity
|
|
hash, -- Content hash for integrity
|
|
isErased, -- If entity was erased (deleted permanently)
|
|
changeId, -- Unique change identifier
|
|
componentId, -- Installation identifier
|
|
instanceId, -- Process instance identifier
|
|
isSynced, -- Whether synced to server
|
|
utcDateChanged -- When change occurred
|
|
)
|
|
```
|
|
|
|
**Key Properties:**
|
|
- **changeId**: Globally unique identifier (UUID) for the change
|
|
- **componentId**: Unique per Trilium installation (persists across restarts)
|
|
- **instanceId**: Unique per process (changes on restart)
|
|
- **hash**: SHA-256 hash of entity data for integrity verification
|
|
|
|
### Sync Versions
|
|
|
|
Each Trilium installation tracks:
|
|
- **Local sync version**: Highest change ID seen locally
|
|
- **Server sync version**: Highest change ID on server
|
|
- **Entity versions**: Last sync version for each entity type
|
|
|
|
### Change Tracking
|
|
|
|
**When an entity is modified:**
|
|
|
|
```typescript
|
|
// apps/server/src/services/entity_changes.ts
|
|
function addEntityChange(entityName, entityId, entity) {
|
|
const hash = calculateHash(entity)
|
|
const changeId = generateUUID()
|
|
|
|
sql.insert('entity_changes', {
|
|
entityName,
|
|
entityId,
|
|
hash,
|
|
changeId,
|
|
componentId: config.componentId,
|
|
instanceId: config.instanceId,
|
|
isSynced: 0,
|
|
utcDateChanged: now()
|
|
})
|
|
}
|
|
```
|
|
|
|
**Entity modification triggers:**
|
|
- Note content update
|
|
- Note metadata change
|
|
- Branch creation/deletion/reorder
|
|
- Attribute addition/removal
|
|
- Options modification
|
|
|
|
## Sync Protocol
|
|
|
|
### Sync Handshake
|
|
|
|
**Step 1: Client Initiates Sync**
|
|
|
|
```typescript
|
|
// Client sends current sync version
|
|
POST /api/sync/check
|
|
{
|
|
"sourceId": "client-component-id",
|
|
"maxChangeId": 12345
|
|
}
|
|
```
|
|
|
|
**Step 2: Server Responds with Status**
|
|
|
|
```typescript
|
|
// Server checks for changes
|
|
Response:
|
|
{
|
|
"entityChanges": 567, // Changes on server
|
|
"maxChangeId": 12890, // Server's max change ID
|
|
"outstandingPushCount": 23 // Client changes not yet synced
|
|
}
|
|
```
|
|
|
|
**Step 3: Decision**
|
|
|
|
- If `entityChanges > 0`: Pull changes from server
|
|
- If `outstandingPushCount > 0`: Push changes to server
|
|
- Both can happen in sequence
|
|
|
|
### Pull Sync (Server → Client)
|
|
|
|
**Client Requests Changes:**
|
|
|
|
```typescript
|
|
POST /api/sync/pull
|
|
{
|
|
"sourceId": "client-component-id",
|
|
"lastSyncedChangeId": 12345
|
|
}
|
|
```
|
|
|
|
**Server Responds:**
|
|
|
|
```typescript
|
|
Response:
|
|
{
|
|
"notes": [
|
|
{ noteId: "abc", title: "New Note", ... }
|
|
],
|
|
"branches": [...],
|
|
"attributes": [...],
|
|
"revisions": [...],
|
|
"attachments": [...],
|
|
"entityChanges": [
|
|
{ entityName: "notes", entityId: "abc", changeId: "...", ... }
|
|
],
|
|
"maxChangeId": 12890
|
|
}
|
|
```
|
|
|
|
**Client Processing:**
|
|
|
|
1. Apply entity changes to local database
|
|
2. Update Froca cache
|
|
3. Update local sync version
|
|
4. Trigger UI refresh
|
|
|
|
### Push Sync (Client → Server)
|
|
|
|
**Client Sends Changes:**
|
|
|
|
```typescript
|
|
POST /api/sync/push
|
|
{
|
|
"sourceId": "client-component-id",
|
|
"entities": [
|
|
{
|
|
"entity": {
|
|
"noteId": "xyz",
|
|
"title": "Modified Note",
|
|
...
|
|
},
|
|
"entityChange": {
|
|
"changeId": "change-uuid",
|
|
"entityName": "notes",
|
|
...
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Server Processing:**
|
|
|
|
1. Validate changes
|
|
2. Check for conflicts
|
|
3. Apply changes to database
|
|
4. Update Becca cache
|
|
5. Mark as synced
|
|
6. Broadcast to other connected clients via WebSocket
|
|
|
|
**Conflict Detection:**
|
|
|
|
```typescript
|
|
// Check if entity was modified on server since client's last sync
|
|
const serverEntity = becca.getNote(noteId)
|
|
const serverLastModified = serverEntity.utcDateModified
|
|
|
|
if (serverLastModified > clientSyncVersion) {
|
|
// CONFLICT!
|
|
resolveConflict(serverEntity, clientEntity)
|
|
}
|
|
```
|
|
|
|
## Conflict Resolution
|
|
|
|
### Conflict Types
|
|
|
|
**1. Content Conflict**
|
|
- Both client and server modified same note content
|
|
- **Resolution**: Last-write-wins based on `utcDateModified`
|
|
|
|
**2. Structure Conflict**
|
|
- Branch moved/deleted on one side, modified on other
|
|
- **Resolution**: Tombstone records, reconciliation
|
|
|
|
**3. Attribute Conflict**
|
|
- Same attribute modified differently
|
|
- **Resolution**: Last-write-wins
|
|
|
|
### Conflict Resolution Strategy
|
|
|
|
**Last-Write-Wins:**
|
|
```typescript
|
|
if (clientEntity.utcDateModified > serverEntity.utcDateModified) {
|
|
// Client wins, apply client changes
|
|
applyClientChange(clientEntity)
|
|
} else {
|
|
// Server wins, reject client change
|
|
// Client will pull server version on next sync
|
|
}
|
|
```
|
|
|
|
**Tombstone Records:**
|
|
- Deleted entities leave tombstone in `entity_changes`
|
|
- Prevents re-sync of deleted items
|
|
- `isErased = 1` for permanent deletions
|
|
|
|
### Protected Notes Sync
|
|
|
|
**Challenge:** Encrypted content can't be synced without password
|
|
|
|
**Solution:**
|
|
|
|
1. **Protected session required**: User must unlock protected notes
|
|
2. **Encrypted sync**: Content synced in encrypted form
|
|
3. **Hash verification**: Integrity checked without decryption
|
|
4. **Lazy decryption**: Only decrypt when accessed
|
|
|
|
**Sync Flow:**
|
|
|
|
```typescript
|
|
// Client side
|
|
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
|
|
// Skip protected notes if session not active
|
|
continue
|
|
}
|
|
|
|
// Server side
|
|
if (note.isProtected) {
|
|
// Sync encrypted blob
|
|
// Don't decrypt for sync
|
|
syncEncryptedBlob(note.blobId)
|
|
}
|
|
```
|
|
|
|
## Sync States
|
|
|
|
### Connection States
|
|
|
|
- **Connected**: WebSocket connection active
|
|
- **Disconnected**: No connection to sync server
|
|
- **Syncing**: Actively transferring data
|
|
- **Conflict**: Sync paused due to conflict
|
|
|
|
### Entity Sync States
|
|
|
|
Each entity can be in:
|
|
- **Synced**: In sync with server
|
|
- **Pending**: Local changes not yet pushed
|
|
- **Conflict**: Conflicting changes detected
|
|
|
|
### UI Indicators
|
|
|
|
```typescript
|
|
// apps/client/src/widgets/sync_status.ts
|
|
class SyncStatusWidget {
|
|
showSyncStatus() {
|
|
if (isConnected && allSynced) {
|
|
showIcon('synced')
|
|
} else if (isSyncing) {
|
|
showIcon('syncing-spinner')
|
|
} else if (hasConflicts) {
|
|
showIcon('conflict-warning')
|
|
} else {
|
|
showIcon('not-synced')
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Performance Optimizations
|
|
|
|
### Incremental Sync
|
|
|
|
Only entities changed since last sync are transferred:
|
|
|
|
```sql
|
|
SELECT * FROM entity_changes
|
|
WHERE id > :lastSyncedChangeId
|
|
ORDER BY id ASC
|
|
LIMIT 1000
|
|
```
|
|
|
|
### Batch Processing
|
|
|
|
Changes sent in batches to reduce round trips:
|
|
|
|
```typescript
|
|
const BATCH_SIZE = 1000
|
|
const changes = getUnsyncedChanges(BATCH_SIZE)
|
|
await syncBatch(changes)
|
|
```
|
|
|
|
### Hash-Based Change Detection
|
|
|
|
```typescript
|
|
// Only sync if hash differs
|
|
const localHash = calculateHash(localEntity)
|
|
const serverHash = getServerHash(entityId)
|
|
|
|
if (localHash !== serverHash) {
|
|
syncEntity(localEntity)
|
|
}
|
|
```
|
|
|
|
### Compression
|
|
|
|
Large payloads compressed before transmission:
|
|
|
|
```typescript
|
|
// Server sends compressed response
|
|
res.setHeader('Content-Encoding', 'gzip')
|
|
res.send(gzip(syncData))
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### Network Errors
|
|
|
|
**Retry Strategy:**
|
|
```typescript
|
|
const RETRY_DELAYS = [1000, 2000, 5000, 10000, 30000]
|
|
|
|
async function syncWithRetry(attempt = 0) {
|
|
try {
|
|
await performSync()
|
|
} catch (error) {
|
|
if (attempt < RETRY_DELAYS.length) {
|
|
setTimeout(() => {
|
|
syncWithRetry(attempt + 1)
|
|
}, RETRY_DELAYS[attempt])
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Sync Integrity Checks
|
|
|
|
**Hash Verification:**
|
|
```typescript
|
|
// Verify entity hash matches
|
|
const calculatedHash = calculateHash(entity)
|
|
const receivedHash = entityChange.hash
|
|
|
|
if (calculatedHash !== receivedHash) {
|
|
throw new Error('Hash mismatch - data corruption detected')
|
|
}
|
|
```
|
|
|
|
**Consistency Checks:**
|
|
- Orphaned branches detection
|
|
- Missing parent notes
|
|
- Invalid entity references
|
|
- Circular dependencies
|
|
|
|
## Sync Server Configuration
|
|
|
|
### Server Setup
|
|
|
|
**Required Options:**
|
|
```javascript
|
|
{
|
|
"syncServerHost": "https://sync.example.com",
|
|
"syncServerTimeout": 60000,
|
|
"syncProxy": "" // Optional HTTP proxy
|
|
}
|
|
```
|
|
|
|
**Authentication:**
|
|
- Username/password or
|
|
- Sync token (generated on server)
|
|
|
|
### Client Setup
|
|
|
|
**Desktop Client:**
|
|
```javascript
|
|
// Settings → Sync
|
|
{
|
|
"syncServerHost": "https://sync.example.com",
|
|
"username": "user@example.com",
|
|
"password": "********"
|
|
}
|
|
```
|
|
|
|
**Test Connection:**
|
|
```typescript
|
|
POST /api/sync/test
|
|
Response: { "success": true }
|
|
```
|
|
|
|
## Sync API Endpoints
|
|
|
|
Located at: `apps/server/src/routes/api/sync.ts`
|
|
|
|
**Endpoints:**
|
|
|
|
- `POST /api/sync/check` - Check sync status
|
|
- `POST /api/sync/pull` - Pull changes from server
|
|
- `POST /api/sync/push` - Push changes to server
|
|
- `POST /api/sync/finished` - Mark sync complete
|
|
- `POST /api/sync/test` - Test connection
|
|
- `GET /api/sync/stats` - Sync statistics
|
|
|
|
## WebSocket Sync Updates
|
|
|
|
Real-time sync via WebSocket:
|
|
|
|
```typescript
|
|
// Server broadcasts change to all connected clients
|
|
ws.broadcast('entity-change', {
|
|
entityName: 'notes',
|
|
entityId: 'abc123',
|
|
changeId: 'change-uuid',
|
|
sourceId: 'originating-component-id'
|
|
})
|
|
|
|
// Client receives and applies
|
|
ws.on('entity-change', (data) => {
|
|
if (data.sourceId !== myComponentId) {
|
|
froca.processEntityChange(data)
|
|
}
|
|
})
|
|
```
|
|
|
|
## Sync Scheduling
|
|
|
|
### Automatic Sync
|
|
|
|
**Desktop:**
|
|
- Sync on startup
|
|
- Periodic sync (configurable interval, default: 60s)
|
|
- Sync before shutdown
|
|
|
|
**Server:**
|
|
- Sync on entity modification
|
|
- WebSocket push to connected clients
|
|
|
|
### Manual Sync
|
|
|
|
User can trigger:
|
|
- Full sync
|
|
- Sync now
|
|
- Sync specific subtree
|
|
|
|
## Troubleshooting
|
|
|
|
### Common Issues
|
|
|
|
**Sync stuck:**
|
|
```sql
|
|
-- Reset sync state
|
|
UPDATE entity_changes SET isSynced = 0;
|
|
DELETE FROM options WHERE name LIKE 'sync%';
|
|
```
|
|
|
|
**Hash mismatch:**
|
|
- Data corruption detected
|
|
- Re-sync from backup
|
|
- Check database integrity
|
|
|
|
**Conflict loop:**
|
|
- Manual intervention required
|
|
- Export conflicting notes
|
|
- Choose winning version
|
|
- Re-sync
|
|
|
|
### Sync Diagnostics
|
|
|
|
**Check sync status:**
|
|
```typescript
|
|
GET /api/sync/stats
|
|
Response: {
|
|
"unsyncedChanges": 0,
|
|
"lastSyncDate": "2025-11-02T12:00:00Z",
|
|
"syncVersion": 12890
|
|
}
|
|
```
|
|
|
|
**Entity change log:**
|
|
```sql
|
|
SELECT * FROM entity_changes
|
|
WHERE isSynced = 0
|
|
ORDER BY id DESC;
|
|
```
|
|
|
|
## Security Considerations
|
|
|
|
### Encrypted Sync
|
|
|
|
- Protected notes synced encrypted
|
|
- No plain text over network
|
|
- Server cannot read protected content
|
|
|
|
### Authentication
|
|
|
|
- Username/password over HTTPS only
|
|
- Sync tokens for token-based auth
|
|
- Session cookies with CSRF protection
|
|
|
|
### Authorization
|
|
|
|
- Users can only sync their own data
|
|
- No cross-user sync support
|
|
- Sync server validates ownership
|
|
|
|
## Performance Metrics
|
|
|
|
**Typical Sync Performance:**
|
|
- 1000 changes: ~2-5 seconds
|
|
- 10000 changes: ~20-50 seconds
|
|
- Initial full sync (100k notes): ~5-10 minutes
|
|
|
|
**Factors:**
|
|
- Network latency
|
|
- Database size
|
|
- Number of protected notes
|
|
- Attachment sizes
|
|
|
|
## Future Improvements
|
|
|
|
**Planned Enhancements:**
|
|
- Differential sync (binary diff)
|
|
- Peer-to-peer sync (no central server)
|
|
- Multi-server sync
|
|
- Partial sync (subtree only)
|
|
- Sync over Tor/I2P
|
|
|
|
---
|
|
|
|
**See Also:**
|
|
- [ARCHITECTURE.md](ARCHITECTURE.md) - Overall architecture
|
|
- [Sync User Guide](https://triliumnext.github.io/Docs/Wiki/synchronization)
|
|
- [Sync API Source](../apps/server/src/routes/api/sync.ts)
|