mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat(sync): implement vector clock checking
This commit is contained in:
parent
2767503799
commit
a08ad5ae9d
19 changed files with 2279 additions and 87 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -54,6 +54,12 @@ perf-metrics-*.json
|
|||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# iOS build artifacts
|
||||
ios/App/App/public/
|
||||
ios/App/App/capacitor.config.json
|
||||
ios/App/App/config.xml
|
||||
ios/App/App/Plugins/
|
||||
|
||||
# sometimes idea does compile ts files...
|
||||
src/app/**/*.js
|
||||
src/app/**/*.js.map
|
||||
|
|
|
|||
250
docs/sync/vector-clocks.md
Normal file
250
docs/sync/vector-clocks.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# Vector Clocks in Super Productivity Sync
|
||||
|
||||
## Overview
|
||||
|
||||
Super Productivity uses vector clocks to provide accurate conflict detection and resolution in its synchronization system. This document explains how vector clocks work, why they're used, and how they integrate with the existing sync infrastructure.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [What are Vector Clocks?](#what-are-vector-clocks)
|
||||
2. [Why Vector Clocks?](#why-vector-clocks)
|
||||
3. [Implementation Details](#implementation-details)
|
||||
4. [Migration from Lamport Timestamps](#migration-from-lamport-timestamps)
|
||||
5. [API Reference](#api-reference)
|
||||
6. [Examples](#examples)
|
||||
|
||||
## What are Vector Clocks?
|
||||
|
||||
A vector clock is a data structure used in distributed systems to determine the partial ordering of events and detect causality violations. Each client/device maintains its own component in the vector, incrementing it on local updates.
|
||||
|
||||
### Structure
|
||||
|
||||
```typescript
|
||||
interface VectorClock {
|
||||
[clientId: string]: number;
|
||||
}
|
||||
|
||||
// Example:
|
||||
{
|
||||
"desktop_1234": 5,
|
||||
"mobile_5678": 3,
|
||||
"web_9012": 7
|
||||
}
|
||||
```
|
||||
|
||||
### Comparison Results
|
||||
|
||||
Vector clocks can have four relationships:
|
||||
|
||||
1. **EQUAL**: Same values for all components
|
||||
2. **LESS_THAN**: A happened before B (all components of A ≤ B)
|
||||
3. **GREATER_THAN**: B happened before A (all components of B ≤ A)
|
||||
4. **CONCURRENT**: Neither happened before the other (true conflict)
|
||||
|
||||
## Why Vector Clocks?
|
||||
|
||||
### Problem with Lamport Timestamps
|
||||
|
||||
Lamport timestamps provide a total ordering but can't distinguish between:
|
||||
|
||||
- Changes made after syncing (sequential)
|
||||
- Changes made independently (concurrent)
|
||||
|
||||
This leads to false conflicts where user intervention is required even though one device is clearly ahead.
|
||||
|
||||
### Benefits of Vector Clocks
|
||||
|
||||
1. **Accurate Conflict Detection**: Only reports conflicts for truly concurrent changes
|
||||
2. **Automatic Resolution**: Can auto-merge when one vector dominates another
|
||||
3. **Device Tracking**: Maintains history of which device made which changes
|
||||
4. **Reduced User Interruptions**: Fewer false conflicts mean better UX
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
src/app/pfapi/api/
|
||||
├── util/
|
||||
│ ├── vector-clock.ts # Core vector clock operations
|
||||
│ ├── backwards-compat.ts # Migration helpers
|
||||
│ └── get-sync-status-from-meta-files.ts # Sync status detection
|
||||
├── model-ctrl/
|
||||
│ └── meta-model-ctrl.ts # Updates vector clocks on changes
|
||||
└── sync/
|
||||
└── sync.service.ts # Integrates vector clocks in sync flow
|
||||
```
|
||||
|
||||
### Core Operations
|
||||
|
||||
#### 1. Increment on Local Change
|
||||
|
||||
```typescript
|
||||
// When user modifies data
|
||||
const newVectorClock = incrementVectorClock(currentVectorClock, clientId);
|
||||
```
|
||||
|
||||
#### 2. Merge on Sync
|
||||
|
||||
```typescript
|
||||
// When downloading remote changes
|
||||
const mergedClock = mergeVectorClocks(localVector, remoteVector);
|
||||
```
|
||||
|
||||
#### 3. Compare for Conflicts
|
||||
|
||||
```typescript
|
||||
const comparison = compareVectorClocks(localVector, remoteVector);
|
||||
if (comparison === VectorClockComparison.CONCURRENT) {
|
||||
// True conflict - user must resolve
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. **MetaModelCtrl**: Increments vector clock on every local change
|
||||
2. **SyncService**: Merges vector clocks during download, includes in upload
|
||||
3. **getSyncStatusFromMetaFiles**: Uses vector clocks for conflict detection
|
||||
|
||||
## Migration from Lamport Timestamps
|
||||
|
||||
The system maintains backwards compatibility with existing Lamport timestamps:
|
||||
|
||||
### Automatic Migration
|
||||
|
||||
```typescript
|
||||
// If no vector clock exists, create from Lamport timestamp
|
||||
if (!meta.vectorClock && meta.localLamport > 0) {
|
||||
meta.vectorClock = { [clientId]: meta.localLamport };
|
||||
}
|
||||
```
|
||||
|
||||
### Dual Support
|
||||
|
||||
- New installations use vector clocks immediately
|
||||
- Existing installations migrate transparently
|
||||
- Both systems coexist during transition period
|
||||
|
||||
### Field Mapping
|
||||
|
||||
| Old Field | New Field | Purpose |
|
||||
| ------------------- | ----------------------- | ------------------- |
|
||||
| `localLamport` | `vectorClock[clientId]` | Track local changes |
|
||||
| `lastSyncedLamport` | `lastSyncedVectorClock` | Track sync state |
|
||||
|
||||
## API Reference
|
||||
|
||||
### Core Functions
|
||||
|
||||
#### `initializeVectorClock(clientId: string, initialValue?: number): VectorClock`
|
||||
|
||||
Creates a new vector clock for a client.
|
||||
|
||||
#### `compareVectorClocks(a: VectorClock, b: VectorClock): VectorClockComparison`
|
||||
|
||||
Determines the relationship between two vector clocks.
|
||||
|
||||
#### `incrementVectorClock(clock: VectorClock, clientId: string): VectorClock`
|
||||
|
||||
Increments the client's component in the vector clock.
|
||||
|
||||
#### `mergeVectorClocks(a: VectorClock, b: VectorClock): VectorClock`
|
||||
|
||||
Merges two vector clocks by taking the maximum of each component.
|
||||
|
||||
#### `hasVectorClockChanges(current: VectorClock, reference: VectorClock): boolean`
|
||||
|
||||
Checks if current has any changes compared to reference.
|
||||
|
||||
### Helper Functions
|
||||
|
||||
#### `vectorClockToString(clock: VectorClock): string`
|
||||
|
||||
Returns human-readable representation for debugging.
|
||||
|
||||
#### `lamportToVectorClock(lamport: number, clientId: string): VectorClock`
|
||||
|
||||
Converts Lamport timestamp to vector clock for migration.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Simple Sequential Updates
|
||||
|
||||
```typescript
|
||||
// Device A makes a change
|
||||
deviceA.vectorClock = { A: 1 };
|
||||
|
||||
// Device A syncs to cloud
|
||||
cloud.vectorClock = { A: 1 };
|
||||
|
||||
// Device B downloads
|
||||
deviceB.vectorClock = { A: 1 };
|
||||
|
||||
// Device B makes a change
|
||||
deviceB.vectorClock = { A: 1, B: 1 };
|
||||
|
||||
// When A tries to sync, vector clock shows B is ahead
|
||||
// Result: A downloads B's changes (no conflict)
|
||||
```
|
||||
|
||||
### Example 2: Concurrent Updates (True Conflict)
|
||||
|
||||
```typescript
|
||||
// Both devices start synced
|
||||
deviceA.vectorClock = { A: 1, B: 1 };
|
||||
deviceB.vectorClock = { A: 1, B: 1 };
|
||||
|
||||
// Both make changes before syncing
|
||||
deviceA.vectorClock = { A: 2, B: 1 }; // A incremented
|
||||
deviceB.vectorClock = { A: 1, B: 2 }; // B incremented
|
||||
|
||||
// Comparison shows CONCURRENT - neither dominates
|
||||
// Result: User must resolve conflict
|
||||
```
|
||||
|
||||
### Example 3: Complex Multi-Device Scenario
|
||||
|
||||
```typescript
|
||||
// Three devices with different states
|
||||
desktop.vectorClock = { desktop: 5, mobile: 3, web: 2 };
|
||||
mobile.vectorClock = { desktop: 4, mobile: 3, web: 2 };
|
||||
web.vectorClock = { desktop: 4, mobile: 3, web: 7 };
|
||||
|
||||
// Desktop vs Mobile: Desktop is ahead (5 > 4)
|
||||
// Desktop vs Web: Concurrent (desktop has 5 vs 4, but web has 7 vs 2)
|
||||
// Mobile vs Web: Web is ahead (7 > 2, everything else equal)
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Verbose Logging
|
||||
|
||||
```typescript
|
||||
// In pfapi/api/util/log.ts, set log level to 2 or higher
|
||||
pfLog(2, 'Vector clock comparison', {
|
||||
localVector: vectorClockToString(localVector),
|
||||
remoteVector: vectorClockToString(remoteVector),
|
||||
result: comparison,
|
||||
});
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Clock Drift**: Ensure client IDs are stable and unique
|
||||
2. **Migration Issues**: Check both vector clock and Lamport fields during transition
|
||||
3. **Overflow Protection**: Clocks reset to 1 when approaching MAX_SAFE_INTEGER
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always increment** on local changes
|
||||
2. **Always merge** when receiving remote data
|
||||
3. **Never modify** vector clocks directly
|
||||
4. **Use backwards-compat** helpers during migration period
|
||||
5. **Log vector states** when debugging sync issues
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Compression**: Prune old client entries after inactivity
|
||||
2. **Conflict Resolution**: Add automatic resolution strategies
|
||||
3. **Visualization**: Add UI to show vector clock states
|
||||
4. **Performance**: Optimize comparison for many clients
|
||||
|
|
@ -38,6 +38,43 @@
|
|||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Vector Clock Debug Information -->
|
||||
@if (localVectorClock || remoteVectorClock) {
|
||||
<h3>Vector Clock Debug Information</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Vector Clock</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td>{{ T.F.SYNC.D_CONFLICT.REMOTE | translate }}</td>
|
||||
<td [matTooltip]="getVectorClockString(remoteVectorClock)">
|
||||
{{ getVectorClockString(remoteVectorClock) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ T.F.SYNC.D_CONFLICT.LOCAL | translate }}</td>
|
||||
<td [matTooltip]="getVectorClockString(localVectorClock)">
|
||||
{{ getVectorClockString(localVectorClock) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Synced</td>
|
||||
<td [matTooltip]="getVectorClockString(lastSyncedVectorClock)">
|
||||
{{ getVectorClockString(lastSyncedVectorClock) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Comparison Result</td>
|
||||
<td>
|
||||
<strong>{{ getVectorClockComparisonLabel() }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
.isHighlight {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.1em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
table + table {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,17 @@ import {
|
|||
} from '@angular/material/dialog';
|
||||
import { T } from 'src/app/t.const';
|
||||
import { DialogConflictResolutionResult } from '../sync.model';
|
||||
import { ConflictData } from '../../../pfapi/api';
|
||||
import { ConflictData, VectorClock } from '../../../pfapi/api';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import {
|
||||
vectorClockToString,
|
||||
compareVectorClocks,
|
||||
VectorClockComparison,
|
||||
} from '../../../pfapi/api/util/vector-clock';
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-sync-conflict',
|
||||
|
|
@ -49,6 +54,15 @@ export class DialogSyncConflictComponent {
|
|||
localLamport: number = this.data.local.localLamport;
|
||||
lastSyncedLamport: number | null = this.data.local.lastSyncedLamport;
|
||||
|
||||
// Vector clock data
|
||||
remoteVectorClock: VectorClock | undefined = this.data.remote.vectorClock;
|
||||
localVectorClock: VectorClock | undefined = this.data.local.vectorClock;
|
||||
lastSyncedVectorClock: VectorClock | null | undefined =
|
||||
this.data.local.lastSyncedVectorClock;
|
||||
|
||||
// Vector clock comparison
|
||||
vectorClockComparison: VectorClockComparison | null = this.getVectorClockComparison();
|
||||
|
||||
remoteLastUpdateAction: string | undefined = this.data.remote.lastUpdateAction;
|
||||
localLastUpdateAction: string | undefined = this.data.local.lastUpdateAction;
|
||||
lastSyncedAction: string | undefined = this.data.local.lastSyncedAction;
|
||||
|
|
@ -72,4 +86,32 @@ export class DialogSyncConflictComponent {
|
|||
if (!lamport) return '-';
|
||||
return lamport.toString().slice(-4);
|
||||
}
|
||||
|
||||
getVectorClockComparison(): VectorClockComparison | null {
|
||||
if (!this.localVectorClock || !this.remoteVectorClock) {
|
||||
return null;
|
||||
}
|
||||
return compareVectorClocks(this.localVectorClock, this.remoteVectorClock);
|
||||
}
|
||||
|
||||
getVectorClockString(clock?: VectorClock | null): string {
|
||||
if (!clock) return '-';
|
||||
return vectorClockToString(clock);
|
||||
}
|
||||
|
||||
getVectorClockComparisonLabel(): string {
|
||||
if (!this.vectorClockComparison) return '-';
|
||||
switch (this.vectorClockComparison) {
|
||||
case VectorClockComparison.EQUAL:
|
||||
return 'Equal';
|
||||
case VectorClockComparison.LESS_THAN:
|
||||
return 'Local < Remote';
|
||||
case VectorClockComparison.GREATER_THAN:
|
||||
return 'Local > Remote';
|
||||
case VectorClockComparison.CONCURRENT:
|
||||
return 'Concurrent (True Conflict)';
|
||||
default:
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,17 @@ export class SyncWrapperService {
|
|||
lastSync: r.conflictData?.local.lastSyncedUpdate,
|
||||
conflictData: r.conflictData,
|
||||
});
|
||||
|
||||
// Enhanced debugging for vector clock issues
|
||||
console.log('CONFLICT DEBUG - Vector Clock Analysis:', {
|
||||
localVectorClock: r.conflictData?.local.vectorClock,
|
||||
remoteVectorClock: r.conflictData?.remote.vectorClock,
|
||||
localLastSyncedVectorClock: r.conflictData?.local.lastSyncedVectorClock,
|
||||
localLamport: r.conflictData?.local.localLamport,
|
||||
remoteLamport: r.conflictData?.remote.localLamport,
|
||||
conflictReason: r.conflictData?.reason,
|
||||
additional: r.conflictData?.additional,
|
||||
});
|
||||
const res = await this._openConflictDialog$(
|
||||
r.conflictData as ConflictData,
|
||||
).toPromise();
|
||||
|
|
|
|||
|
|
@ -15,8 +15,13 @@ describe('MetaModelCtrl', () => {
|
|||
mockDb = jasmine.createSpyObj('Database', ['save', 'load']);
|
||||
mockEventEmitter = jasmine.createSpyObj('PFEventEmitter', ['emit']);
|
||||
|
||||
// Default behavior for load - return null initially
|
||||
mockDb.load.and.returnValue(Promise.resolve(null));
|
||||
// Default behavior for load - return null initially for meta, but return client ID
|
||||
mockDb.load.and.callFake(<T = unknown>(key: string): Promise<T | void> => {
|
||||
if (key === MetaModelCtrl.CLIENT_ID) {
|
||||
return Promise.resolve('test-client-id' as any);
|
||||
}
|
||||
return Promise.resolve(null as any);
|
||||
});
|
||||
mockDb.save.and.returnValue(Promise.resolve());
|
||||
|
||||
metaModelCtrl = new MetaModelCtrl(mockDb, mockEventEmitter, crossModelVersion);
|
||||
|
|
@ -45,7 +50,12 @@ describe('MetaModelCtrl', () => {
|
|||
lastSyncedLamport: 5,
|
||||
};
|
||||
|
||||
mockDb.load.and.returnValue(Promise.resolve(existingMeta));
|
||||
mockDb.load.and.callFake(<T = unknown>(key: string): Promise<T | void> => {
|
||||
if (key === MetaModelCtrl.CLIENT_ID) {
|
||||
return Promise.resolve('test-client-id' as any);
|
||||
}
|
||||
return Promise.resolve(existingMeta as any);
|
||||
});
|
||||
|
||||
const newCtrl = new MetaModelCtrl(mockDb, mockEventEmitter, crossModelVersion);
|
||||
const meta = await newCtrl.load();
|
||||
|
|
@ -68,7 +78,12 @@ describe('MetaModelCtrl', () => {
|
|||
localLamport: 5,
|
||||
lastSyncedLamport: null,
|
||||
};
|
||||
mockDb.load.and.returnValue(Promise.resolve(initialMeta));
|
||||
mockDb.load.and.callFake(<T = unknown>(key: string): Promise<T | void> => {
|
||||
if (key === MetaModelCtrl.CLIENT_ID) {
|
||||
return Promise.resolve('test-client-id' as any);
|
||||
}
|
||||
return Promise.resolve(initialMeta as any);
|
||||
});
|
||||
|
||||
// Create a new controller and wait for it to load
|
||||
testCtrl = new MetaModelCtrl(mockDb, mockEventEmitter, crossModelVersion);
|
||||
|
|
@ -78,14 +93,14 @@ describe('MetaModelCtrl', () => {
|
|||
mockDb.save.calls.reset();
|
||||
});
|
||||
|
||||
it('should increment localLamport when updating a model', () => {
|
||||
it('should increment localLamport when updating a model', async () => {
|
||||
const modelCfg: ModelCfg<any> = {
|
||||
defaultData: {},
|
||||
isLocalOnly: false,
|
||||
isMainFileModel: false,
|
||||
};
|
||||
|
||||
testCtrl.updateRevForModel('testModel', modelCfg);
|
||||
await testCtrl.updateRevForModel('testModel', modelCfg);
|
||||
|
||||
expect(mockDb.save).toHaveBeenCalledWith(
|
||||
MetaModelCtrl.META_MODEL_ID,
|
||||
|
|
@ -96,14 +111,14 @@ describe('MetaModelCtrl', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should set lastUpdateAction when updating a model', () => {
|
||||
it('should set lastUpdateAction when updating a model', async () => {
|
||||
const modelCfg: ModelCfg<any> = {
|
||||
defaultData: {},
|
||||
isLocalOnly: false,
|
||||
isMainFileModel: false,
|
||||
};
|
||||
|
||||
testCtrl.updateRevForModel('testModel', modelCfg);
|
||||
await testCtrl.updateRevForModel('testModel', modelCfg);
|
||||
|
||||
expect(mockDb.save).toHaveBeenCalledWith(
|
||||
MetaModelCtrl.META_MODEL_ID,
|
||||
|
|
@ -114,26 +129,26 @@ describe('MetaModelCtrl', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should not update for local-only models', () => {
|
||||
it('should not update for local-only models', async () => {
|
||||
const modelCfg: ModelCfg<any> = {
|
||||
defaultData: {},
|
||||
isLocalOnly: true,
|
||||
isMainFileModel: false,
|
||||
};
|
||||
|
||||
testCtrl.updateRevForModel('testModel', modelCfg);
|
||||
await testCtrl.updateRevForModel('testModel', modelCfg);
|
||||
|
||||
expect(mockDb.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update revMap for non-main file models', () => {
|
||||
it('should update revMap for non-main file models', async () => {
|
||||
const modelCfg: ModelCfg<any> = {
|
||||
defaultData: {},
|
||||
isLocalOnly: false,
|
||||
isMainFileModel: false,
|
||||
};
|
||||
|
||||
testCtrl.updateRevForModel('testModel', modelCfg);
|
||||
await testCtrl.updateRevForModel('testModel', modelCfg);
|
||||
|
||||
expect(mockDb.save).toHaveBeenCalledWith(
|
||||
MetaModelCtrl.META_MODEL_ID,
|
||||
|
|
@ -146,27 +161,27 @@ describe('MetaModelCtrl', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should not update revMap for main file models', () => {
|
||||
it('should not update revMap for main file models', async () => {
|
||||
const modelCfg: ModelCfg<any> = {
|
||||
defaultData: {},
|
||||
isLocalOnly: false,
|
||||
isMainFileModel: true,
|
||||
};
|
||||
|
||||
metaModelCtrl.updateRevForModel('mainModel', modelCfg);
|
||||
await testCtrl.updateRevForModel('mainModel', modelCfg);
|
||||
|
||||
const savedMeta = mockDb.save.calls.mostRecent().args[1] as LocalMeta;
|
||||
expect(savedMeta.revMap).toEqual({});
|
||||
});
|
||||
|
||||
it('should emit events after update', () => {
|
||||
it('should emit events after update', async () => {
|
||||
const modelCfg: ModelCfg<any> = {
|
||||
defaultData: {},
|
||||
isLocalOnly: false,
|
||||
isMainFileModel: false,
|
||||
};
|
||||
|
||||
testCtrl.updateRevForModel('testModel', modelCfg);
|
||||
await testCtrl.updateRevForModel('testModel', modelCfg);
|
||||
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
|
||||
'metaModelChange',
|
||||
|
|
@ -189,7 +204,12 @@ describe('MetaModelCtrl', () => {
|
|||
localLamport: undefined as any,
|
||||
lastSyncedLamport: undefined as any,
|
||||
};
|
||||
mockDb.load.and.returnValue(Promise.resolve(oldMeta));
|
||||
mockDb.load.and.callFake(<T = unknown>(key: string): Promise<T | void> => {
|
||||
if (key === MetaModelCtrl.CLIENT_ID) {
|
||||
return Promise.resolve('test-client-id' as any);
|
||||
}
|
||||
return Promise.resolve(oldMeta as any);
|
||||
});
|
||||
|
||||
const newCtrl = new MetaModelCtrl(mockDb, mockEventEmitter, crossModelVersion);
|
||||
await newCtrl.load();
|
||||
|
|
@ -203,7 +223,7 @@ describe('MetaModelCtrl', () => {
|
|||
isMainFileModel: false,
|
||||
};
|
||||
|
||||
newCtrl.updateRevForModel('testModel', modelCfg);
|
||||
await newCtrl.updateRevForModel('testModel', modelCfg);
|
||||
|
||||
expect(mockDb.save).toHaveBeenCalledWith(
|
||||
MetaModelCtrl.META_MODEL_ID,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
import { validateLocalMeta } from '../util/validate-local-meta';
|
||||
import { PFEventEmitter } from '../util/events';
|
||||
import { devError } from '../../../util/dev-error';
|
||||
import { incrementVectorClock } from '../util/vector-clock';
|
||||
import { getVectorClock, setVectorClock } from '../util/backwards-compat';
|
||||
|
||||
export const DEFAULT_META_MODEL: LocalMeta = {
|
||||
crossModelVersion: 1,
|
||||
|
|
@ -20,6 +22,8 @@ export const DEFAULT_META_MODEL: LocalMeta = {
|
|||
lastSyncedUpdate: null,
|
||||
localLamport: 0,
|
||||
lastSyncedLamport: null,
|
||||
vectorClock: {},
|
||||
lastSyncedVectorClock: null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -64,11 +68,11 @@ export class MetaModelCtrl {
|
|||
* @param isIgnoreDBLock Whether to ignore database locks
|
||||
* @throws {MetaNotReadyError} When metamodel is not loaded yet
|
||||
*/
|
||||
updateRevForModel<MT extends ModelBase>(
|
||||
async updateRevForModel<MT extends ModelBase>(
|
||||
modelId: string,
|
||||
modelCfg: ModelCfg<MT>,
|
||||
isIgnoreDBLock = false,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
pfLog(2, `${MetaModelCtrl.L}.${this.updateRevForModel.name}()`, modelId, {
|
||||
modelCfg,
|
||||
inMemory: this._metaModelInMemory,
|
||||
|
|
@ -80,31 +84,54 @@ export class MetaModelCtrl {
|
|||
const metaModel = this._getMetaModelOrThrow(modelId, modelCfg);
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Get client ID for vector clock
|
||||
const clientId = await this.loadClientId();
|
||||
|
||||
// Create action string (max 100 chars)
|
||||
const actionStr = `${modelId} => ${new Date(timestamp).toISOString()}`;
|
||||
const lastUpdateAction =
|
||||
actionStr.length > 100 ? actionStr.substring(0, 97) + '...' : actionStr;
|
||||
|
||||
this.save(
|
||||
{
|
||||
...metaModel,
|
||||
lastUpdate: timestamp,
|
||||
lastUpdateAction,
|
||||
localLamport: this._incrementLamport(metaModel.localLamport || 0),
|
||||
// Update vector clock - migrate from Lamport if needed
|
||||
let currentVectorClock = getVectorClock(metaModel, clientId);
|
||||
if (!currentVectorClock && metaModel.localLamport > 0) {
|
||||
// First time creating vector clock - migrate from Lamport timestamp
|
||||
currentVectorClock = { [clientId]: metaModel.localLamport };
|
||||
pfLog(
|
||||
2,
|
||||
`${MetaModelCtrl.L}.${this.updateRevForModel.name}() migrating Lamport to vector clock`,
|
||||
{
|
||||
clientId,
|
||||
localLamport: metaModel.localLamport,
|
||||
newVectorClock: currentVectorClock,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
...(modelCfg.isMainFileModel
|
||||
? {}
|
||||
: {
|
||||
revMap: {
|
||||
...metaModel.revMap,
|
||||
[modelId]: timestamp.toString(),
|
||||
},
|
||||
}),
|
||||
// as soon as we save a related model, we are using the local crossModelVersion (while other updates might be from importing remote data)
|
||||
crossModelVersion: this.crossModelVersion,
|
||||
},
|
||||
isIgnoreDBLock,
|
||||
);
|
||||
const newVectorClock = incrementVectorClock(currentVectorClock || {}, clientId);
|
||||
|
||||
const updatedMeta = {
|
||||
...metaModel,
|
||||
lastUpdate: timestamp,
|
||||
lastUpdateAction,
|
||||
localLamport: this._incrementLamport(metaModel.localLamport || 0),
|
||||
|
||||
...(modelCfg.isMainFileModel
|
||||
? {}
|
||||
: {
|
||||
revMap: {
|
||||
...metaModel.revMap,
|
||||
[modelId]: timestamp.toString(),
|
||||
},
|
||||
}),
|
||||
// as soon as we save a related model, we are using the local crossModelVersion (while other updates might be from importing remote data)
|
||||
crossModelVersion: this.crossModelVersion,
|
||||
};
|
||||
|
||||
// Set vector clock using backwards compatibility helper
|
||||
setVectorClock(updatedMeta, newVectorClock, clientId);
|
||||
|
||||
await this.save(updatedMeta, isIgnoreDBLock);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export class ModelCtrl<MT extends ModelBase> {
|
|||
* @param p
|
||||
* @returns Promise resolving after save operation
|
||||
*/
|
||||
save(
|
||||
async save(
|
||||
data: MT,
|
||||
p?: { isUpdateRevAndLastUpdate: boolean; isIgnoreDBLock?: boolean },
|
||||
): Promise<unknown> {
|
||||
|
|
@ -70,7 +70,11 @@ export class ModelCtrl<MT extends ModelBase> {
|
|||
// Update revision if requested
|
||||
const isIgnoreDBLock = !!p?.isIgnoreDBLock;
|
||||
if (p?.isUpdateRevAndLastUpdate) {
|
||||
this._metaModel.updateRevForModel(this.modelId, this.modelCfg, isIgnoreDBLock);
|
||||
await this._metaModel.updateRevForModel(
|
||||
this.modelId,
|
||||
this.modelCfg,
|
||||
isIgnoreDBLock,
|
||||
);
|
||||
}
|
||||
|
||||
// Save data to database
|
||||
|
|
|
|||
|
|
@ -98,6 +98,13 @@ export interface MetaFileBase {
|
|||
localChangeCounter?: number;
|
||||
lastSyncedChangeCounter?: number | null;
|
||||
lastSyncedAction?: string;
|
||||
// Vector clock fields for improved conflict detection
|
||||
vectorClock?: VectorClock;
|
||||
lastSyncedVectorClock?: VectorClock | null;
|
||||
}
|
||||
|
||||
export interface VectorClock {
|
||||
[clientId: string]: number;
|
||||
}
|
||||
|
||||
export interface RemoteMeta extends MetaFileBase {
|
||||
|
|
@ -105,6 +112,15 @@ export interface RemoteMeta extends MetaFileBase {
|
|||
isFullData?: boolean;
|
||||
}
|
||||
|
||||
export interface UploadMeta
|
||||
extends Omit<
|
||||
RemoteMeta,
|
||||
'lastSyncedLamport' | 'lastSyncedChangeCounter' | 'lastSyncedVectorClock'
|
||||
> {
|
||||
lastSyncedLamport: null;
|
||||
lastSyncedChangeCounter?: null;
|
||||
}
|
||||
|
||||
export interface LocalMeta extends MetaFileBase {
|
||||
lastSyncedUpdate: number | null;
|
||||
metaRev: string | null;
|
||||
|
|
|
|||
132
src/app/pfapi/api/sync/README.md
Normal file
132
src/app/pfapi/api/sync/README.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Sync System Overview
|
||||
|
||||
This directory contains the synchronization implementation for Super Productivity, enabling data sync across devices through various providers (Dropbox, WebDAV, Local File).
|
||||
|
||||
## Key Components
|
||||
|
||||
### Core Services
|
||||
|
||||
- **`sync.service.ts`** - Main orchestrator for sync operations
|
||||
- **`meta-sync.service.ts`** - Handles sync metadata file operations
|
||||
- **`model-sync.service.ts`** - Manages individual model synchronization
|
||||
- **`conflict-handler.service.ts`** - User interface for conflict resolution
|
||||
|
||||
### Sync Providers
|
||||
|
||||
Located in `sync-providers/`:
|
||||
|
||||
- Dropbox
|
||||
- WebDAV
|
||||
- Local File System
|
||||
|
||||
### Sync Algorithm
|
||||
|
||||
The sync system uses a hybrid approach combining:
|
||||
|
||||
1. **Physical Timestamps** (`lastUpdate`) - For ordering events
|
||||
2. **Change Counters** (`localLamport`) - For detecting local modifications
|
||||
3. **Vector Clocks** (`vectorClock`) - For accurate causality tracking
|
||||
4. **Sync State** (`lastSyncedUpdate`, `lastSyncedLamport`) - To track last successful sync
|
||||
|
||||
## How Sync Works
|
||||
|
||||
### 1. Change Detection
|
||||
|
||||
When a user modifies data:
|
||||
|
||||
```typescript
|
||||
// In meta-model-ctrl.ts
|
||||
lastUpdate = Date.now();
|
||||
localLamport = localLamport + 1;
|
||||
vectorClock[clientId] = vectorClock[clientId] + 1;
|
||||
```
|
||||
|
||||
### 2. Sync Status Determination
|
||||
|
||||
The system compares local and remote metadata to determine:
|
||||
|
||||
- **InSync**: No changes needed
|
||||
- **UpdateLocal**: Download remote changes
|
||||
- **UpdateRemote**: Upload local changes
|
||||
- **Conflict**: Both have changes (requires user resolution)
|
||||
|
||||
### 3. Conflict Detection
|
||||
|
||||
Uses vector clocks for accurate detection:
|
||||
|
||||
```typescript
|
||||
const comparison = compareVectorClocks(localVector, remoteVector);
|
||||
if (comparison === VectorClockComparison.CONCURRENT) {
|
||||
// True conflict - changes were made independently
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Data Transfer
|
||||
|
||||
- **Upload**: Sends changed models and updated metadata
|
||||
- **Download**: Retrieves and merges remote changes
|
||||
- **Conflict Resolution**: User chooses which version to keep
|
||||
|
||||
## Key Files
|
||||
|
||||
### Metadata Structure
|
||||
|
||||
```typescript
|
||||
interface LocalMeta {
|
||||
lastUpdate: number; // Physical timestamp
|
||||
lastSyncedUpdate: number; // Last synced timestamp
|
||||
localLamport: number; // Change counter
|
||||
lastSyncedLamport: number; // Last synced counter
|
||||
vectorClock?: VectorClock; // Causality tracking
|
||||
revMap: RevMap; // Model revision map
|
||||
crossModelVersion: number; // Schema version
|
||||
}
|
||||
```
|
||||
|
||||
### Important Considerations
|
||||
|
||||
1. **NOT Pure Lamport Clocks**: We don't increment on receive to prevent sync loops
|
||||
2. **Backwards Compatibility**: Supports migration from older versions
|
||||
3. **Conflict Minimization**: Vector clocks reduce false conflicts
|
||||
4. **Atomic Operations**: Meta file serves as transaction coordinator
|
||||
|
||||
## Common Sync Scenarios
|
||||
|
||||
### Scenario 1: Simple Update
|
||||
|
||||
1. Device A makes changes
|
||||
2. Device A uploads to cloud
|
||||
3. Device B downloads changes
|
||||
4. Both devices now in sync
|
||||
|
||||
### Scenario 2: Conflict Resolution
|
||||
|
||||
1. Device A and B both make changes
|
||||
2. Device A syncs first
|
||||
3. Device B detects conflict
|
||||
4. User chooses which version to keep
|
||||
5. Chosen version propagates to all devices
|
||||
|
||||
### Scenario 3: Multiple Devices
|
||||
|
||||
1. Devices A, B, C all synced
|
||||
2. Device A makes changes while offline
|
||||
3. Device B makes different changes
|
||||
4. Device C acts as intermediary
|
||||
5. Vector clocks ensure proper ordering
|
||||
|
||||
## Debugging Sync Issues
|
||||
|
||||
1. Enable verbose logging in `pfapi/api/util/log.ts`
|
||||
2. Check vector clock states in sync status
|
||||
3. Verify client IDs are stable
|
||||
4. Ensure metadata files are properly updated
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- [ ] Automatic conflict resolution strategies
|
||||
- [ ] Partial sync for large datasets
|
||||
- [ ] Real-time sync notifications
|
||||
- [ ] Sync history visualization
|
||||
|
||||
For detailed vector clock documentation, see [vector-clocks.md](../../../docs/sync/vector-clocks.md).
|
||||
|
|
@ -157,8 +157,13 @@ describe('SyncService', () => {
|
|||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type,prefer-arrow/prefer-arrow-functions
|
||||
function setupMetaModelCtrl() {
|
||||
const ctrl = jasmine.createSpyObj<MetaModelCtrl>('MetaModelCtrl', ['load', 'save']);
|
||||
const ctrl = jasmine.createSpyObj<MetaModelCtrl>('MetaModelCtrl', [
|
||||
'load',
|
||||
'save',
|
||||
'loadClientId',
|
||||
]);
|
||||
ctrl.load.and.returnValue(Promise.resolve(createDefaultLocalMeta()));
|
||||
ctrl.loadClientId.and.returnValue(Promise.resolve('test-client-id'));
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
ModelCfgToModelCtrl,
|
||||
RemoteMeta,
|
||||
RevMap,
|
||||
UploadMeta,
|
||||
} from '../pfapi.model';
|
||||
import { SyncProviderServiceInterface } from './sync-provider.interface';
|
||||
import { MiniObservable } from '../util/mini-observable';
|
||||
|
|
@ -28,6 +29,12 @@ import { Pfapi } from '../pfapi';
|
|||
import { modelVersionCheck, ModelVersionCheckResult } from '../util/model-version-check';
|
||||
import { MetaSyncService } from './meta-sync.service';
|
||||
import { ModelSyncService } from './model-sync.service';
|
||||
import { mergeVectorClocks, incrementVectorClock } from '../util/vector-clock';
|
||||
import {
|
||||
getVectorClock,
|
||||
setVectorClock,
|
||||
setLastSyncedVectorClock,
|
||||
} from '../util/backwards-compat';
|
||||
|
||||
/**
|
||||
* Sync Service for Super Productivity
|
||||
|
|
@ -176,12 +183,24 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
lastSyncedLamport: localMeta.lastSyncedLamport,
|
||||
localLamport: localMeta.localLamport,
|
||||
});
|
||||
await this._metaFileSyncService.saveLocal({
|
||||
|
||||
// Get client ID for vector clock operations
|
||||
const clientId = await this._metaModelCtrl.loadClientId();
|
||||
const localVector = getVectorClock(localMeta, clientId);
|
||||
|
||||
const updatedMeta = {
|
||||
...localMeta,
|
||||
lastSyncedUpdate: localMeta.lastUpdate,
|
||||
lastSyncedLamport: localMeta.localLamport || 0,
|
||||
metaRev: remoteMetaRev,
|
||||
});
|
||||
};
|
||||
|
||||
// Update vector clock if available
|
||||
if (localVector) {
|
||||
setLastSyncedVectorClock(updatedMeta, localVector, clientId);
|
||||
}
|
||||
|
||||
await this._metaFileSyncService.saveLocal(updatedMeta);
|
||||
}
|
||||
return { status };
|
||||
case SyncStatus.Conflict:
|
||||
|
|
@ -252,14 +271,52 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
{ localLamport: local.localLamport, remoteLamport, currentLamport, nextLamport },
|
||||
);
|
||||
|
||||
// Get client ID for vector clock operations
|
||||
const clientId = await this._metaModelCtrl.loadClientId();
|
||||
let localVector = getVectorClock(local, clientId) || {};
|
||||
|
||||
// For force uploads, try to merge with remote vector clock to preserve all components
|
||||
if (isForceUpload) {
|
||||
try {
|
||||
const { remoteMeta } = await this._metaFileSyncService.download();
|
||||
const remoteVector = getVectorClock(remoteMeta, clientId);
|
||||
if (remoteVector) {
|
||||
localVector = mergeVectorClocks(localVector, remoteVector);
|
||||
pfLog(
|
||||
2,
|
||||
`${SyncService.L}.${this.uploadAll.name}(): Merged remote vector clock for force upload`,
|
||||
{
|
||||
localOriginal: getVectorClock(local, clientId),
|
||||
remote: remoteVector,
|
||||
merged: localVector,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
pfLog(
|
||||
1,
|
||||
`${SyncService.L}.${this.uploadAll.name}(): Could not get remote vector clock for merge`,
|
||||
e,
|
||||
);
|
||||
// Continue with local vector clock only
|
||||
}
|
||||
}
|
||||
|
||||
const newVector = incrementVectorClock(localVector, clientId);
|
||||
|
||||
const updatedMeta = {
|
||||
...local,
|
||||
lastUpdate: Date.now(),
|
||||
localLamport: nextLamport,
|
||||
// Important: Don't update lastSyncedLamport yet
|
||||
// It will be updated after successful upload
|
||||
};
|
||||
|
||||
// Update vector clock
|
||||
setVectorClock(updatedMeta, newVector, clientId);
|
||||
|
||||
await this._metaModelCtrl.save(
|
||||
{
|
||||
...local,
|
||||
lastUpdate: Date.now(),
|
||||
localLamport: nextLamport,
|
||||
// Important: Don't update lastSyncedLamport yet
|
||||
// It will be updated after successful upload
|
||||
},
|
||||
updatedMeta,
|
||||
// NOTE we always ignore db lock while syncing
|
||||
true,
|
||||
);
|
||||
|
|
@ -267,6 +324,10 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
}
|
||||
|
||||
try {
|
||||
// Get client ID for vector clock operations
|
||||
const clientId = await this._metaModelCtrl.loadClientId();
|
||||
const localVector = getVectorClock(local, clientId);
|
||||
|
||||
return await this.uploadToRemote(
|
||||
{
|
||||
crossModelVersion: local.crossModelVersion,
|
||||
|
|
@ -276,6 +337,7 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
mainModelData: {},
|
||||
localLamport: local.localLamport || 0,
|
||||
lastSyncedLamport: null,
|
||||
vectorClock: localVector,
|
||||
},
|
||||
{
|
||||
...local,
|
||||
|
|
@ -316,6 +378,9 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
revMap: {},
|
||||
localLamport: 0,
|
||||
lastSyncedLamport: null,
|
||||
// Include vector clock fields to prevent comparison issues
|
||||
vectorClock: {},
|
||||
lastSyncedVectorClock: null,
|
||||
};
|
||||
|
||||
return await this.downloadToLocal(
|
||||
|
|
@ -369,7 +434,15 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
localRevMap: local.revMap,
|
||||
});
|
||||
|
||||
await this._metaFileSyncService.saveLocal({
|
||||
// Get client ID for vector clock operations
|
||||
const clientId = await this._metaModelCtrl.loadClientId();
|
||||
|
||||
// Merge vector clocks if available
|
||||
const localVector = getVectorClock(local, clientId);
|
||||
const remoteVector = getVectorClock(remote, clientId);
|
||||
const mergedVector = mergeVectorClocks(localVector, remoteVector);
|
||||
|
||||
const updatedMeta = {
|
||||
// shared
|
||||
lastUpdate: remote.lastUpdate,
|
||||
crossModelVersion: remote.crossModelVersion,
|
||||
|
|
@ -381,7 +454,15 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
lastSyncedUpdate: remote.lastUpdate,
|
||||
lastSyncedLamport: Math.max(local.localLamport || 0, remote.localLamport || 0),
|
||||
metaRev: remoteRev,
|
||||
});
|
||||
};
|
||||
|
||||
// Update vector clocks if we have them
|
||||
if (mergedVector) {
|
||||
setVectorClock(updatedMeta, mergedVector, clientId);
|
||||
setLastSyncedVectorClock(updatedMeta, mergedVector, clientId);
|
||||
}
|
||||
|
||||
await this._metaFileSyncService.saveLocal(updatedMeta);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -460,7 +541,15 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
await this._modelSyncService.updateLocalMainModelsFromRemoteMetaFile(remote);
|
||||
}
|
||||
|
||||
await this._metaFileSyncService.saveLocal({
|
||||
// Get client ID for vector clock operations
|
||||
const clientId = await this._metaModelCtrl.loadClientId();
|
||||
|
||||
// Merge vector clocks if available
|
||||
const localVector = getVectorClock(local, clientId);
|
||||
const remoteVector = getVectorClock(remote, clientId);
|
||||
const mergedVector = mergeVectorClocks(localVector, remoteVector);
|
||||
|
||||
const updatedMeta = {
|
||||
metaRev: remoteRev,
|
||||
lastSyncedUpdate: remote.lastUpdate,
|
||||
lastUpdate: remote.lastUpdate,
|
||||
|
|
@ -474,7 +563,15 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
...realRemoteRevMap,
|
||||
}),
|
||||
crossModelVersion: remote.crossModelVersion,
|
||||
});
|
||||
};
|
||||
|
||||
// Update vector clocks if we have them
|
||||
if (mergedVector) {
|
||||
setVectorClock(updatedMeta, mergedVector, clientId);
|
||||
setLastSyncedVectorClock(updatedMeta, mergedVector, clientId);
|
||||
}
|
||||
|
||||
await this._metaFileSyncService.saveLocal(updatedMeta);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -509,16 +606,23 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
? await this._pfapiMain.getAllSyncModelData()
|
||||
: await this._modelSyncService.getMainFileModelDataForUpload();
|
||||
|
||||
// Get client ID for vector clock operations
|
||||
const clientId = await this._metaModelCtrl.loadClientId();
|
||||
const localVector = getVectorClock(local, clientId);
|
||||
|
||||
const uploadMeta: UploadMeta = {
|
||||
revMap: local.revMap,
|
||||
lastUpdate: local.lastUpdate,
|
||||
crossModelVersion: local.crossModelVersion,
|
||||
mainModelData,
|
||||
localLamport: local.localLamport || 0,
|
||||
lastSyncedLamport: null,
|
||||
vectorClock: localVector,
|
||||
...(syncProvider.isLimitedToSingleFileSync ? { isFullData: true } : {}),
|
||||
};
|
||||
|
||||
const metaRevAfterUpdate = await this._metaFileSyncService.upload(
|
||||
{
|
||||
revMap: local.revMap,
|
||||
lastUpdate: local.lastUpdate,
|
||||
crossModelVersion: local.crossModelVersion,
|
||||
mainModelData,
|
||||
localLamport: local.localLamport || 0,
|
||||
lastSyncedLamport: null,
|
||||
...(syncProvider.isLimitedToSingleFileSync ? { isFullData: true } : {}),
|
||||
},
|
||||
uploadMeta,
|
||||
lastRemoteRev,
|
||||
);
|
||||
|
||||
|
|
@ -534,13 +638,20 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
},
|
||||
);
|
||||
|
||||
await this._metaFileSyncService.saveLocal({
|
||||
const updatedMeta = {
|
||||
...local,
|
||||
lastSyncedUpdate: local.lastUpdate,
|
||||
lastSyncedLamport: local.localLamport || 0,
|
||||
lastSyncedAction: `Uploaded single file at ${new Date().toISOString()}`,
|
||||
metaRev: metaRevAfterUpdate,
|
||||
});
|
||||
};
|
||||
|
||||
// Update vector clock if available
|
||||
if (localVector) {
|
||||
setLastSyncedVectorClock(updatedMeta, localVector, clientId);
|
||||
}
|
||||
|
||||
await this._metaFileSyncService.saveLocal(updatedMeta);
|
||||
|
||||
pfLog(
|
||||
2,
|
||||
|
|
@ -606,7 +717,12 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
|
||||
// Validate and upload the final revMap
|
||||
const validatedRevMap = validateRevMap(realRevMap);
|
||||
const metaRevAfterUpload = await this._metaFileSyncService.upload({
|
||||
|
||||
// Get client ID for vector clock operations
|
||||
const clientId = await this._metaModelCtrl.loadClientId();
|
||||
const localVector = getVectorClock(local, clientId);
|
||||
|
||||
const uploadMeta: UploadMeta = {
|
||||
revMap: validatedRevMap,
|
||||
lastUpdate: local.lastUpdate,
|
||||
crossModelVersion: local.crossModelVersion,
|
||||
|
|
@ -614,10 +730,13 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
await this._modelSyncService.getMainFileModelDataForUpload(completeData),
|
||||
localLamport: local.localLamport || 0,
|
||||
lastSyncedLamport: null,
|
||||
});
|
||||
vectorClock: localVector,
|
||||
};
|
||||
|
||||
const metaRevAfterUpload = await this._metaFileSyncService.upload(uploadMeta);
|
||||
|
||||
// Update local after successful upload
|
||||
await this._metaFileSyncService.saveLocal({
|
||||
const updatedMeta = {
|
||||
// leave as is basically
|
||||
lastUpdate: local.lastUpdate,
|
||||
crossModelVersion: local.crossModelVersion,
|
||||
|
|
@ -629,7 +748,14 @@ export class SyncService<const MD extends ModelCfgs> {
|
|||
lastSyncedAction: `Uploaded ${toUpdate.length} models at ${new Date().toISOString()}`,
|
||||
revMap: validatedRevMap,
|
||||
metaRev: metaRevAfterUpload,
|
||||
});
|
||||
};
|
||||
|
||||
// Update vector clock if available
|
||||
if (localVector) {
|
||||
setLastSyncedVectorClock(updatedMeta, localVector, clientId);
|
||||
}
|
||||
|
||||
await this._metaFileSyncService.saveLocal(updatedMeta);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import {
|
|||
setLocalChangeCounter,
|
||||
setLastSyncedChangeCounter,
|
||||
createBackwardsCompatibleMeta,
|
||||
getVectorClock,
|
||||
getLastSyncedVectorClock,
|
||||
setVectorClock,
|
||||
setLastSyncedVectorClock,
|
||||
hasVectorClocks,
|
||||
} from './backwards-compat';
|
||||
|
||||
describe('backwards-compat', () => {
|
||||
|
|
@ -182,4 +187,224 @@ describe('backwards-compat', () => {
|
|||
expect(result.lastSyncedAction).toBe('sync action');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vector Clock Functions', () => {
|
||||
const createBaseMeta = (): LocalMeta => ({
|
||||
localLamport: 0,
|
||||
lastUpdate: 0,
|
||||
lastSyncedUpdate: null,
|
||||
lastSyncedLamport: null,
|
||||
revMap: {},
|
||||
metaRev: null,
|
||||
crossModelVersion: 1,
|
||||
});
|
||||
|
||||
describe('getVectorClock', () => {
|
||||
it('should return existing vector clock', () => {
|
||||
const meta = createBaseMeta();
|
||||
meta.vectorClock = { client1: 5, client2: 3 };
|
||||
const result = getVectorClock(meta, 'client1');
|
||||
expect(result).toEqual({ client1: 5, client2: 3 });
|
||||
});
|
||||
|
||||
it('should migrate from Lamport timestamp', () => {
|
||||
const meta = createBaseMeta();
|
||||
meta.localLamport = 7;
|
||||
const result = getVectorClock(meta, 'client1');
|
||||
expect(result).toEqual({ client1: 7 });
|
||||
});
|
||||
|
||||
it('should migrate from localChangeCounter', () => {
|
||||
const meta = createBaseMeta();
|
||||
meta.localChangeCounter = 9;
|
||||
const result = getVectorClock(meta, 'client1');
|
||||
expect(result).toEqual({ client1: 9 });
|
||||
});
|
||||
|
||||
it('should return undefined for zero Lamport', () => {
|
||||
const meta = createBaseMeta();
|
||||
meta.localLamport = 0;
|
||||
const result = getVectorClock(meta, 'client1');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when no data available', () => {
|
||||
const meta = createBaseMeta();
|
||||
const result = getVectorClock(meta, 'client1');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return empty vector clock', () => {
|
||||
const meta = createBaseMeta();
|
||||
meta.vectorClock = {};
|
||||
const result = getVectorClock(meta, 'client1');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLastSyncedVectorClock', () => {
|
||||
it('should return existing last synced vector clock', () => {
|
||||
const meta = createBaseMeta();
|
||||
meta.lastSyncedVectorClock = { client1: 4, client2: 2 };
|
||||
const result = getLastSyncedVectorClock(meta, 'client1');
|
||||
expect(result).toEqual({ client1: 4, client2: 2 });
|
||||
});
|
||||
|
||||
it('should migrate from last synced Lamport', () => {
|
||||
const meta = createBaseMeta();
|
||||
meta.lastSyncedLamport = 6;
|
||||
const result = getLastSyncedVectorClock(meta, 'client1');
|
||||
expect(result).toEqual({ client1: 6 });
|
||||
});
|
||||
|
||||
it('should migrate from lastSyncedChangeCounter', () => {
|
||||
const meta = createBaseMeta();
|
||||
meta.lastSyncedChangeCounter = 8;
|
||||
const result = getLastSyncedVectorClock(meta, 'client1');
|
||||
expect(result).toEqual({ client1: 8 });
|
||||
});
|
||||
|
||||
it('should return null for null Lamport', () => {
|
||||
const meta = createBaseMeta();
|
||||
meta.lastSyncedLamport = null;
|
||||
const result = getLastSyncedVectorClock(meta, 'client1');
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for zero Lamport', () => {
|
||||
const meta = createBaseMeta();
|
||||
meta.lastSyncedLamport = 0;
|
||||
const result = getLastSyncedVectorClock(meta, 'client1');
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setVectorClock', () => {
|
||||
it('should set vector clock and update Lamport', () => {
|
||||
const meta = createBaseMeta();
|
||||
const vectorClock = { client1: 10, client2: 5 };
|
||||
setVectorClock(meta, vectorClock, 'client1');
|
||||
|
||||
expect(meta.vectorClock).toEqual(vectorClock);
|
||||
expect(meta.localLamport).toBe(10);
|
||||
expect(meta.localChangeCounter).toBe(10);
|
||||
});
|
||||
|
||||
it('should use 0 for missing client component', () => {
|
||||
const meta = createBaseMeta();
|
||||
const vectorClock = { client2: 5 };
|
||||
setVectorClock(meta, vectorClock, 'client1');
|
||||
|
||||
expect(meta.vectorClock).toEqual(vectorClock);
|
||||
expect(meta.localLamport).toBe(0);
|
||||
expect(meta.localChangeCounter).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty vector clock', () => {
|
||||
const meta = createBaseMeta();
|
||||
const vectorClock = {};
|
||||
setVectorClock(meta, vectorClock, 'client1');
|
||||
|
||||
expect(meta.vectorClock).toEqual({});
|
||||
expect(meta.localLamport).toBe(0);
|
||||
expect(meta.localChangeCounter).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLastSyncedVectorClock', () => {
|
||||
it('should set last synced vector clock and update Lamport', () => {
|
||||
const meta = createBaseMeta();
|
||||
const vectorClock = { client1: 8, client2: 4 };
|
||||
setLastSyncedVectorClock(meta, vectorClock, 'client1');
|
||||
|
||||
expect(meta.lastSyncedVectorClock).toEqual(vectorClock);
|
||||
expect(meta.lastSyncedLamport).toBe(8);
|
||||
expect(meta.lastSyncedChangeCounter).toBe(8);
|
||||
});
|
||||
|
||||
it('should handle null vector clock', () => {
|
||||
const meta = createBaseMeta();
|
||||
meta.lastSyncedLamport = 5;
|
||||
meta.lastSyncedChangeCounter = 5;
|
||||
setLastSyncedVectorClock(meta, null, 'client1');
|
||||
|
||||
expect(meta.lastSyncedVectorClock).toBe(null);
|
||||
expect(meta.lastSyncedLamport).toBeNull();
|
||||
expect(meta.lastSyncedChangeCounter).toBeNull();
|
||||
});
|
||||
|
||||
it('should use 0 for missing client component', () => {
|
||||
const meta = createBaseMeta();
|
||||
const vectorClock = { client2: 4 };
|
||||
setLastSyncedVectorClock(meta, vectorClock, 'client1');
|
||||
|
||||
expect(meta.lastSyncedVectorClock).toEqual(vectorClock);
|
||||
expect(meta.lastSyncedLamport).toBe(0);
|
||||
expect(meta.lastSyncedChangeCounter).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasVectorClocks', () => {
|
||||
it('should return true when both have non-empty vector clocks', () => {
|
||||
const local: LocalMeta = {
|
||||
...createBaseMeta(),
|
||||
vectorClock: { client1: 5 },
|
||||
};
|
||||
const remote: RemoteMeta = {
|
||||
...createBaseMeta(),
|
||||
vectorClock: { client2: 3 },
|
||||
mainModelData: {},
|
||||
};
|
||||
expect(hasVectorClocks(local, remote)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when local missing vector clock', () => {
|
||||
const local = createBaseMeta();
|
||||
const remote: RemoteMeta = {
|
||||
...createBaseMeta(),
|
||||
vectorClock: { client2: 3 },
|
||||
mainModelData: {},
|
||||
};
|
||||
expect(hasVectorClocks(local, remote)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when remote missing vector clock', () => {
|
||||
const local: LocalMeta = {
|
||||
...createBaseMeta(),
|
||||
vectorClock: { client1: 5 },
|
||||
};
|
||||
const remote: RemoteMeta = {
|
||||
...createBaseMeta(),
|
||||
mainModelData: {},
|
||||
};
|
||||
expect(hasVectorClocks(local, remote)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when both have empty vector clocks', () => {
|
||||
const local: LocalMeta = {
|
||||
...createBaseMeta(),
|
||||
vectorClock: {},
|
||||
};
|
||||
const remote: RemoteMeta = {
|
||||
...createBaseMeta(),
|
||||
vectorClock: {},
|
||||
mainModelData: {},
|
||||
};
|
||||
expect(hasVectorClocks(local, remote)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when one has empty vector clock', () => {
|
||||
const local: LocalMeta = {
|
||||
...createBaseMeta(),
|
||||
vectorClock: {},
|
||||
};
|
||||
const remote: RemoteMeta = {
|
||||
...createBaseMeta(),
|
||||
vectorClock: { client1: 5 },
|
||||
mainModelData: {},
|
||||
};
|
||||
expect(hasVectorClocks(local, remote)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { LocalMeta, RemoteMeta } from '../pfapi.model';
|
||||
import { LocalMeta, RemoteMeta, VectorClock } from '../pfapi.model';
|
||||
import { lamportToVectorClock, isVectorClockEmpty } from './vector-clock';
|
||||
import { pfLog } from './log';
|
||||
|
||||
/**
|
||||
* Utility functions for backwards compatibility with old field names.
|
||||
* This allows gradual migration from localLamport/lastSyncedLamport to
|
||||
* localChangeCounter/lastSyncedChangeCounter.
|
||||
* localChangeCounter/lastSyncedChangeCounter and to vector clocks.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -72,6 +74,18 @@ export const createBackwardsCompatibleMeta = <T extends LocalMeta | RemoteMeta>(
|
|||
result.localChangeCounter === undefined
|
||||
) {
|
||||
result.localChangeCounter = result.localLamport;
|
||||
} else if (
|
||||
result.localChangeCounter !== undefined &&
|
||||
result.localLamport !== undefined &&
|
||||
result.localChangeCounter !== result.localLamport
|
||||
) {
|
||||
// Warn about field mismatch but use the newer field
|
||||
pfLog(1, 'WARN: Mismatch between localChangeCounter and localLamport fields', {
|
||||
localChangeCounter: result.localChangeCounter,
|
||||
localLamport: result.localLamport,
|
||||
using: 'localChangeCounter',
|
||||
});
|
||||
result.localLamport = result.localChangeCounter;
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
@ -84,7 +98,124 @@ export const createBackwardsCompatibleMeta = <T extends LocalMeta | RemoteMeta>(
|
|||
result.lastSyncedChangeCounter === undefined
|
||||
) {
|
||||
result.lastSyncedChangeCounter = result.lastSyncedLamport;
|
||||
} else if (
|
||||
result.lastSyncedChangeCounter !== undefined &&
|
||||
result.lastSyncedLamport !== undefined &&
|
||||
result.lastSyncedChangeCounter !== result.lastSyncedLamport
|
||||
) {
|
||||
// Warn about field mismatch but use the newer field
|
||||
pfLog(
|
||||
1,
|
||||
'WARN: Mismatch between lastSyncedChangeCounter and lastSyncedLamport fields',
|
||||
{
|
||||
lastSyncedChangeCounter: result.lastSyncedChangeCounter,
|
||||
lastSyncedLamport: result.lastSyncedLamport,
|
||||
using: 'lastSyncedChangeCounter',
|
||||
},
|
||||
);
|
||||
result.lastSyncedLamport = result.lastSyncedChangeCounter;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the vector clock, creating it from Lamport timestamp if needed
|
||||
* @param meta The metadata object
|
||||
* @param clientId The client ID to use for migration
|
||||
* @returns The vector clock
|
||||
*/
|
||||
export const getVectorClock = (
|
||||
meta: LocalMeta | RemoteMeta,
|
||||
clientId: string,
|
||||
): VectorClock | undefined => {
|
||||
// Return existing vector clock if available
|
||||
if (meta.vectorClock && !isVectorClockEmpty(meta.vectorClock)) {
|
||||
return meta.vectorClock;
|
||||
}
|
||||
|
||||
// Migrate from Lamport timestamp if available
|
||||
const changeCounter = getLocalChangeCounter(meta);
|
||||
if (changeCounter > 0) {
|
||||
return lamportToVectorClock(changeCounter, clientId);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the last synced vector clock, creating it from Lamport timestamp if needed
|
||||
* @param meta The metadata object
|
||||
* @param clientId The client ID to use for migration
|
||||
* @returns The last synced vector clock
|
||||
*/
|
||||
export const getLastSyncedVectorClock = (
|
||||
meta: LocalMeta | RemoteMeta,
|
||||
clientId: string,
|
||||
): VectorClock | null => {
|
||||
// Return existing vector clock if available
|
||||
if (meta.lastSyncedVectorClock && !isVectorClockEmpty(meta.lastSyncedVectorClock)) {
|
||||
return meta.lastSyncedVectorClock;
|
||||
}
|
||||
|
||||
// Migrate from Lamport timestamp if available
|
||||
const lastSyncedCounter = getLastSyncedChangeCounter(meta);
|
||||
if (lastSyncedCounter != null && lastSyncedCounter > 0) {
|
||||
return lamportToVectorClock(lastSyncedCounter, clientId);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the vector clock and update Lamport timestamps for compatibility
|
||||
* @param meta The metadata object
|
||||
* @param vectorClock The vector clock to set
|
||||
* @param clientId The client ID for this instance
|
||||
*/
|
||||
export const setVectorClock = (
|
||||
meta: LocalMeta | RemoteMeta,
|
||||
vectorClock: VectorClock,
|
||||
clientId: string,
|
||||
): void => {
|
||||
meta.vectorClock = vectorClock;
|
||||
|
||||
// Update Lamport timestamps for backwards compatibility
|
||||
// Use this client's component value
|
||||
const clientValue = vectorClock[clientId] || 0;
|
||||
setLocalChangeCounter(meta, clientValue);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the last synced vector clock and update Lamport timestamps for compatibility
|
||||
* @param meta The metadata object
|
||||
* @param vectorClock The vector clock to set (can be null)
|
||||
* @param clientId The client ID for this instance
|
||||
*/
|
||||
export const setLastSyncedVectorClock = (
|
||||
meta: LocalMeta | RemoteMeta,
|
||||
vectorClock: VectorClock | null,
|
||||
clientId: string,
|
||||
): void => {
|
||||
meta.lastSyncedVectorClock = vectorClock;
|
||||
|
||||
// Update Lamport timestamps for backwards compatibility
|
||||
if (vectorClock) {
|
||||
const clientValue = vectorClock[clientId] || 0;
|
||||
setLastSyncedChangeCounter(meta, clientValue);
|
||||
} else {
|
||||
setLastSyncedChangeCounter(meta, null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if both metadata objects have vector clocks
|
||||
* @param local Local metadata
|
||||
* @param remote Remote metadata
|
||||
* @returns True if both have non-empty vector clocks
|
||||
*/
|
||||
export const hasVectorClocks = (local: LocalMeta, remote: RemoteMeta): boolean => {
|
||||
return (
|
||||
!isVectorClockEmpty(local.vectorClock) && !isVectorClockEmpty(remote.vectorClock)
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
import { getSyncStatusFromMetaFiles } from './get-sync-status-from-meta-files';
|
||||
import { ConflictReason, SyncStatus } from '../pfapi.const';
|
||||
import { LocalMeta, RemoteMeta, VectorClock } from '../pfapi.model';
|
||||
import { VectorClockComparison } from './vector-clock';
|
||||
|
||||
describe('getSyncStatusFromMetaFiles with Vector Clocks', () => {
|
||||
// Helper to create test data with vector clocks
|
||||
const createMetaWithVectorClock = (
|
||||
localLastUpdate: number,
|
||||
remoteLastUpdate: number,
|
||||
lastSyncedUpdate: number | null = null,
|
||||
vectorClockData?: {
|
||||
localVector?: VectorClock;
|
||||
remoteVector?: VectorClock;
|
||||
lastSyncedVector?: VectorClock | null;
|
||||
},
|
||||
): { local: LocalMeta; remote: RemoteMeta } => {
|
||||
const local: LocalMeta = {
|
||||
lastUpdate: localLastUpdate,
|
||||
lastSyncedUpdate: lastSyncedUpdate,
|
||||
crossModelVersion: 1,
|
||||
metaRev: 'test-rev',
|
||||
revMap: {},
|
||||
localLamport: 0,
|
||||
lastSyncedLamport: null,
|
||||
vectorClock: vectorClockData?.localVector,
|
||||
lastSyncedVectorClock: vectorClockData?.lastSyncedVector,
|
||||
};
|
||||
|
||||
const remote: RemoteMeta = {
|
||||
lastUpdate: remoteLastUpdate,
|
||||
crossModelVersion: 1,
|
||||
revMap: {},
|
||||
mainModelData: {},
|
||||
localLamport: 0,
|
||||
lastSyncedLamport: null,
|
||||
vectorClock: vectorClockData?.remoteVector,
|
||||
};
|
||||
|
||||
return { local, remote };
|
||||
};
|
||||
|
||||
describe('Vector Clock Sync Status Detection', () => {
|
||||
it('should detect InSync when vector clocks are equal', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000, {
|
||||
localVector: { clientA: 5, clientB: 3 },
|
||||
remoteVector: { clientA: 5, clientB: 3 },
|
||||
lastSyncedVector: { clientA: 5, clientB: 3 },
|
||||
});
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
expect(result.status).toBe(SyncStatus.InSync);
|
||||
});
|
||||
|
||||
it('should detect UpdateLocal when remote is ahead', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(1500, 2000, 1000, {
|
||||
localVector: { clientA: 3, clientB: 2 },
|
||||
remoteVector: { clientA: 5, clientB: 3 },
|
||||
lastSyncedVector: { clientA: 3, clientB: 2 },
|
||||
});
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
expect(result.status).toBe(SyncStatus.UpdateLocal);
|
||||
});
|
||||
|
||||
it('should detect UpdateRemote when local is ahead', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000, {
|
||||
localVector: { clientA: 5, clientB: 3 },
|
||||
remoteVector: { clientA: 3, clientB: 2 },
|
||||
lastSyncedVector: { clientA: 3, clientB: 2 },
|
||||
});
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
expect(result.status).toBe(SyncStatus.UpdateRemote);
|
||||
});
|
||||
|
||||
it('should detect Conflict when vector clocks are concurrent', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(2000, 2000, 1000, {
|
||||
localVector: { clientA: 5, clientB: 2 },
|
||||
remoteVector: { clientA: 3, clientB: 4 },
|
||||
lastSyncedVector: { clientA: 3, clientB: 2 },
|
||||
});
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
expect(result.status).toBe(SyncStatus.Conflict);
|
||||
expect(result.conflictData).toBeDefined();
|
||||
expect(result.conflictData?.reason).toBe(ConflictReason.BothNewerLastSync);
|
||||
expect(result.conflictData?.additional).toEqual({
|
||||
vectorClockComparison: VectorClockComparison.CONCURRENT,
|
||||
localVector: '{clientA:5, clientB:2}',
|
||||
remoteVector: '{clientA:3, clientB:4}',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing components in vector clocks', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000, {
|
||||
localVector: { clientA: 5, clientB: 3, clientC: 1 },
|
||||
remoteVector: { clientA: 5, clientB: 3 },
|
||||
lastSyncedVector: { clientA: 5, clientB: 3 },
|
||||
});
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
expect(result.status).toBe(SyncStatus.UpdateRemote);
|
||||
});
|
||||
|
||||
it('should handle empty lastSyncedVector', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(2000, 1500, null, {
|
||||
localVector: { clientA: 5 },
|
||||
remoteVector: { clientA: 3 },
|
||||
lastSyncedVector: null,
|
||||
});
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
expect(result.status).toBe(SyncStatus.UpdateRemote);
|
||||
});
|
||||
|
||||
it('should fall back to Lamport when vector clocks are not available', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000);
|
||||
// Add Lamport data
|
||||
local.localLamport = 5;
|
||||
local.lastSyncedLamport = 3;
|
||||
remote.localLamport = 3;
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
expect(result.status).toBe(SyncStatus.UpdateRemote);
|
||||
});
|
||||
|
||||
it('should prefer vector clocks over Lamport when both are available', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(2000, 2000, 1000, {
|
||||
localVector: { clientA: 5, clientB: 3 },
|
||||
remoteVector: { clientA: 5, clientB: 3 },
|
||||
lastSyncedVector: { clientA: 5, clientB: 3 },
|
||||
});
|
||||
// Add conflicting Lamport data that would indicate UpdateRemote
|
||||
local.localLamport = 10;
|
||||
local.lastSyncedLamport = 5;
|
||||
remote.localLamport = 5;
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
// Should use vector clock result (InSync) not Lamport result
|
||||
expect(result.status).toBe(SyncStatus.InSync);
|
||||
});
|
||||
|
||||
it('should handle when only one side has vector clocks', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000, {
|
||||
localVector: { clientA: 5 },
|
||||
// remote has no vector clock
|
||||
});
|
||||
// Add Lamport data as fallback
|
||||
local.localLamport = 5;
|
||||
local.lastSyncedLamport = 3;
|
||||
remote.localLamport = 3;
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
// Should fall back to Lamport
|
||||
expect(result.status).toBe(SyncStatus.UpdateRemote);
|
||||
});
|
||||
|
||||
it('should detect diverged changes with partial vector clock overlap', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(2000, 2000, 1000, {
|
||||
localVector: { clientA: 5, clientB: 2, clientC: 7 },
|
||||
remoteVector: { clientA: 3, clientB: 4, clientD: 2 },
|
||||
lastSyncedVector: { clientA: 3, clientB: 2 },
|
||||
});
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
expect(result.status).toBe(SyncStatus.Conflict);
|
||||
});
|
||||
|
||||
it('should handle vector clock with resolved conflict scenario', () => {
|
||||
// Scenario: A and B had concurrent changes, then A merged B's changes
|
||||
const { local, remote } = createMetaWithVectorClock(3000, 2000, 2000, {
|
||||
localVector: { clientA: 6, clientB: 4 }, // A incremented after merge
|
||||
remoteVector: { clientA: 5, clientB: 4 }, // B's state before A's merge
|
||||
lastSyncedVector: { clientA: 5, clientB: 4 },
|
||||
});
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
expect(result.status).toBe(SyncStatus.UpdateRemote);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle when timestamps match but vector clocks differ', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(2000, 2000, 1500, {
|
||||
localVector: { clientA: 5 },
|
||||
remoteVector: { clientA: 5 },
|
||||
lastSyncedVector: { clientA: 4 },
|
||||
});
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
// Timestamps match exactly, so should be InSync
|
||||
expect(result.status).toBe(SyncStatus.InSync);
|
||||
});
|
||||
|
||||
it('should handle empty vector clocks', () => {
|
||||
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000, {
|
||||
localVector: {},
|
||||
remoteVector: {},
|
||||
lastSyncedVector: {},
|
||||
});
|
||||
// Add Lamport as fallback
|
||||
local.localLamport = 5;
|
||||
local.lastSyncedLamport = 3;
|
||||
remote.localLamport = 3;
|
||||
|
||||
const result = getSyncStatusFromMetaFiles(remote, local);
|
||||
// Empty vector clocks should fall back to Lamport
|
||||
expect(result.status).toBe(SyncStatus.UpdateRemote);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ConflictData, LocalMeta, RemoteMeta } from '../pfapi.model';
|
||||
import { ConflictData, LocalMeta, RemoteMeta, VectorClock } from '../pfapi.model';
|
||||
import { ConflictReason, SyncStatus } from '../pfapi.const';
|
||||
import {
|
||||
ImpossibleError,
|
||||
|
|
@ -7,7 +7,17 @@ import {
|
|||
SyncInvalidTimeValuesError,
|
||||
} from '../errors/errors';
|
||||
import { pfLog } from './log';
|
||||
import { getLocalChangeCounter, getLastSyncedChangeCounter } from './backwards-compat';
|
||||
import {
|
||||
getLocalChangeCounter,
|
||||
getLastSyncedChangeCounter,
|
||||
hasVectorClocks,
|
||||
} from './backwards-compat';
|
||||
import {
|
||||
compareVectorClocks,
|
||||
VectorClockComparison,
|
||||
hasVectorClockChanges,
|
||||
vectorClockToString,
|
||||
} from './vector-clock';
|
||||
|
||||
// TODO unit test the hell out of this
|
||||
export const getSyncStatusFromMetaFiles = (
|
||||
|
|
@ -33,23 +43,139 @@ export const getSyncStatusFromMetaFiles = (
|
|||
status: SyncStatus.UpdateRemote,
|
||||
};
|
||||
} else {
|
||||
// Special case: When timestamps match exactly, we're in sync
|
||||
// This overrides any Lamport timestamp mismatches which only indicate metadata is out of sync
|
||||
if (local.lastUpdate === remote.lastUpdate) {
|
||||
pfLog(2, 'Timestamps match exactly - treating as InSync', {
|
||||
local,
|
||||
remote,
|
||||
// Check if we can use vector clocks for more accurate conflict detection
|
||||
const localHasVectorClock =
|
||||
local.vectorClock && Object.keys(local.vectorClock).length > 0;
|
||||
const remoteHasVectorClock =
|
||||
remote.vectorClock && Object.keys(remote.vectorClock).length > 0;
|
||||
|
||||
// Use console.log for critical debugging to ensure visibility
|
||||
console.log('SYNC DEBUG - Vector clock availability check', {
|
||||
localHasVectorClock,
|
||||
remoteHasVectorClock,
|
||||
localVectorClock: local.vectorClock,
|
||||
remoteVectorClock: remote.vectorClock,
|
||||
hasVectorClocksResult: hasVectorClocks(local, remote),
|
||||
localLastUpdate: local.lastUpdate,
|
||||
remoteLastUpdate: remote.lastUpdate,
|
||||
localLastSyncedUpdate: local.lastSyncedUpdate,
|
||||
});
|
||||
|
||||
// Try to use vector clocks first if both sides have them
|
||||
if (hasVectorClocks(local, remote)) {
|
||||
// Extract vector clocks directly since we're comparing full clocks
|
||||
// Don't use backwards compatibility functions here as they require client ID for migration
|
||||
const localVector = local.vectorClock!;
|
||||
const remoteVector = remote.vectorClock!;
|
||||
const lastSyncedVector = local.lastSyncedVectorClock;
|
||||
|
||||
console.log('SYNC DEBUG - Using vector clocks for sync status', {
|
||||
localVector: vectorClockToString(localVector),
|
||||
remoteVector: vectorClockToString(remoteVector),
|
||||
lastSyncedVector: vectorClockToString(lastSyncedVector),
|
||||
localVectorRaw: localVector,
|
||||
remoteVectorRaw: remoteVector,
|
||||
lastSyncedVectorRaw: lastSyncedVector,
|
||||
});
|
||||
return {
|
||||
status: SyncStatus.InSync,
|
||||
};
|
||||
|
||||
const vectorResult = _checkForUpdateVectorClock({
|
||||
localVector,
|
||||
remoteVector,
|
||||
lastSyncedVector: lastSyncedVector || null,
|
||||
});
|
||||
|
||||
switch (vectorResult) {
|
||||
case UpdateCheckResult.InSync:
|
||||
return {
|
||||
status: SyncStatus.InSync,
|
||||
};
|
||||
case UpdateCheckResult.LocalUpdateRequired:
|
||||
return {
|
||||
status: SyncStatus.UpdateLocal,
|
||||
};
|
||||
case UpdateCheckResult.RemoteUpdateRequired:
|
||||
return {
|
||||
status: SyncStatus.UpdateRemote,
|
||||
};
|
||||
case UpdateCheckResult.DataDiverged:
|
||||
return {
|
||||
status: SyncStatus.Conflict,
|
||||
conflictData: {
|
||||
reason: ConflictReason.BothNewerLastSync,
|
||||
remote,
|
||||
local,
|
||||
additional: {
|
||||
vectorClockComparison: VectorClockComparison.CONCURRENT,
|
||||
localVector: vectorClockToString(localVector),
|
||||
remoteVector: vectorClockToString(remoteVector),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try to use change counters (Lamport timestamps) first if available
|
||||
// Enhanced fallback: Try to create hybrid comparison using available data
|
||||
const localChangeCounter = getLocalChangeCounter(local);
|
||||
const remoteChangeCounter = getLocalChangeCounter(remote);
|
||||
const lastSyncedChangeCounter = getLastSyncedChangeCounter(local);
|
||||
|
||||
// Special case: If one side has vector clock and other has Lamport, we can still compare intelligently
|
||||
if (
|
||||
localHasVectorClock &&
|
||||
!remoteHasVectorClock &&
|
||||
typeof remoteChangeCounter === 'number'
|
||||
) {
|
||||
// Extract the maximum value from local vector clock to compare with remote Lamport
|
||||
const localMaxClock = Math.max(...Object.values(local.vectorClock!));
|
||||
const hasLocalChanges = localMaxClock > (lastSyncedChangeCounter || 0);
|
||||
const hasRemoteChanges = remoteChangeCounter > (lastSyncedChangeCounter || 0);
|
||||
|
||||
if (!hasLocalChanges && !hasRemoteChanges) {
|
||||
return { status: SyncStatus.InSync };
|
||||
} else if (hasLocalChanges && !hasRemoteChanges) {
|
||||
return { status: SyncStatus.UpdateRemote };
|
||||
} else if (!hasLocalChanges && hasRemoteChanges) {
|
||||
return { status: SyncStatus.UpdateLocal };
|
||||
} else {
|
||||
// Both have changes - need to compare magnitudes
|
||||
if (localMaxClock > remoteChangeCounter) {
|
||||
return { status: SyncStatus.UpdateRemote };
|
||||
} else if (remoteChangeCounter > localMaxClock) {
|
||||
return { status: SyncStatus.UpdateLocal };
|
||||
} else {
|
||||
// Equal - fall through to timestamp comparison
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
!localHasVectorClock &&
|
||||
remoteHasVectorClock &&
|
||||
typeof localChangeCounter === 'number'
|
||||
) {
|
||||
// Extract the maximum value from remote vector clock to compare with local Lamport
|
||||
const remoteMaxClock = Math.max(...Object.values(remote.vectorClock!));
|
||||
const hasLocalChanges = localChangeCounter > (lastSyncedChangeCounter || 0);
|
||||
const hasRemoteChanges = remoteMaxClock > (lastSyncedChangeCounter || 0);
|
||||
|
||||
if (!hasLocalChanges && !hasRemoteChanges) {
|
||||
return { status: SyncStatus.InSync };
|
||||
} else if (hasLocalChanges && !hasRemoteChanges) {
|
||||
return { status: SyncStatus.UpdateRemote };
|
||||
} else if (!hasLocalChanges && hasRemoteChanges) {
|
||||
return { status: SyncStatus.UpdateLocal };
|
||||
} else {
|
||||
// Both have changes - need to compare magnitudes
|
||||
if (localChangeCounter > remoteMaxClock) {
|
||||
return { status: SyncStatus.UpdateRemote };
|
||||
} else if (remoteMaxClock > localChangeCounter) {
|
||||
return { status: SyncStatus.UpdateLocal };
|
||||
} else {
|
||||
// Equal - fall through to timestamp comparison
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standard Lamport fallback when both sides lack vector clocks
|
||||
|
||||
if (
|
||||
typeof localChangeCounter === 'number' &&
|
||||
typeof remoteChangeCounter === 'number' &&
|
||||
|
|
@ -96,7 +222,8 @@ export const getSyncStatusFromMetaFiles = (
|
|||
}
|
||||
|
||||
// TODO remove later once it is likely that all running apps have lamport clocks
|
||||
// Fall back to timestamp-based checking
|
||||
// Final fallback to timestamp-based checking
|
||||
|
||||
if (typeof local.lastSyncedUpdate === 'number') {
|
||||
const r = _checkForUpdate({
|
||||
remote: remote.lastUpdate,
|
||||
|
|
@ -193,6 +320,52 @@ enum UpdateCheckResult {
|
|||
LastSyncNotUpToDate = 'LastSyncNotUpToDate',
|
||||
}
|
||||
|
||||
const _checkForUpdateVectorClock = (params: {
|
||||
localVector: VectorClock;
|
||||
remoteVector: VectorClock;
|
||||
lastSyncedVector: VectorClock | null;
|
||||
}): UpdateCheckResult => {
|
||||
const { localVector, remoteVector, lastSyncedVector } = params;
|
||||
|
||||
pfLog(2, 'Vector clock check', {
|
||||
localVector: vectorClockToString(localVector),
|
||||
remoteVector: vectorClockToString(remoteVector),
|
||||
lastSyncedVector: vectorClockToString(lastSyncedVector),
|
||||
});
|
||||
|
||||
// Check if there have been changes since last sync
|
||||
const hasLocalChanges = hasVectorClockChanges(localVector, lastSyncedVector);
|
||||
const hasRemoteChanges = hasVectorClockChanges(remoteVector, lastSyncedVector);
|
||||
|
||||
if (!hasLocalChanges && !hasRemoteChanges) {
|
||||
return UpdateCheckResult.InSync;
|
||||
} else if (hasLocalChanges && !hasRemoteChanges) {
|
||||
return UpdateCheckResult.RemoteUpdateRequired;
|
||||
} else if (!hasLocalChanges && hasRemoteChanges) {
|
||||
return UpdateCheckResult.LocalUpdateRequired;
|
||||
} else {
|
||||
// Both have changes - need to check if they're truly concurrent
|
||||
const comparison = compareVectorClocks(localVector, remoteVector);
|
||||
|
||||
pfLog(2, 'Both sides have changes, vector comparison result:', comparison);
|
||||
|
||||
// If one vector clock dominates the other, we can still sync
|
||||
if (comparison === VectorClockComparison.LESS_THAN) {
|
||||
// Remote is strictly ahead, update local
|
||||
return UpdateCheckResult.LocalUpdateRequired;
|
||||
} else if (comparison === VectorClockComparison.GREATER_THAN) {
|
||||
// Local is strictly ahead, update remote
|
||||
return UpdateCheckResult.RemoteUpdateRequired;
|
||||
} else if (comparison === VectorClockComparison.EQUAL) {
|
||||
// Both have the same vector clock - they're in sync
|
||||
return UpdateCheckResult.InSync;
|
||||
} else {
|
||||
// Vectors are concurrent - true conflict
|
||||
return UpdateCheckResult.DataDiverged;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const _checkForUpdateLamport = (params: {
|
||||
remoteLocalLamport: number;
|
||||
localLamport: number;
|
||||
|
|
@ -219,7 +392,12 @@ const _checkForUpdateLamport = (params: {
|
|||
} else if (!hasLocalChanges && hasRemoteChanges) {
|
||||
return UpdateCheckResult.LocalUpdateRequired;
|
||||
} else {
|
||||
// Both have changes - conflict
|
||||
// Both have changes - check if they're the same
|
||||
if (localLamport === remoteLocalLamport) {
|
||||
// Both made the same changes - they're in sync
|
||||
return UpdateCheckResult.InSync;
|
||||
}
|
||||
// Different changes - conflict
|
||||
return UpdateCheckResult.DataDiverged;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
521
src/app/pfapi/api/util/vector-clock.spec.ts
Normal file
521
src/app/pfapi/api/util/vector-clock.spec.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import {
|
||||
VectorClock,
|
||||
VectorClockComparison,
|
||||
initializeVectorClock,
|
||||
isVectorClockEmpty,
|
||||
compareVectorClocks,
|
||||
incrementVectorClock,
|
||||
mergeVectorClocks,
|
||||
lamportToVectorClock,
|
||||
vectorClockToString,
|
||||
hasVectorClockChanges,
|
||||
} from './vector-clock';
|
||||
|
||||
describe('Vector Clock', () => {
|
||||
describe('initializeVectorClock', () => {
|
||||
it('should create a vector clock with initial value 0', () => {
|
||||
const clock = initializeVectorClock('client1');
|
||||
expect(clock).toEqual({ client1: 0 });
|
||||
});
|
||||
|
||||
it('should create a vector clock with custom initial value', () => {
|
||||
const clock = initializeVectorClock('client1', 5);
|
||||
expect(clock).toEqual({ client1: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVectorClockEmpty', () => {
|
||||
it('should return true for null', () => {
|
||||
expect(isVectorClockEmpty(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for undefined', () => {
|
||||
expect(isVectorClockEmpty(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for empty object', () => {
|
||||
expect(isVectorClockEmpty({})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-empty clock', () => {
|
||||
expect(isVectorClockEmpty({ client1: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareVectorClocks', () => {
|
||||
it('should return EQUAL for two empty clocks', () => {
|
||||
expect(compareVectorClocks({}, {})).toBe(VectorClockComparison.EQUAL);
|
||||
expect(compareVectorClocks(null, null)).toBe(VectorClockComparison.EQUAL);
|
||||
});
|
||||
|
||||
it('should return EQUAL for identical clocks', () => {
|
||||
const clock1 = { client1: 5, client2: 3 };
|
||||
const clock2 = { client1: 5, client2: 3 };
|
||||
expect(compareVectorClocks(clock1, clock2)).toBe(VectorClockComparison.EQUAL);
|
||||
});
|
||||
|
||||
it('should return LESS_THAN when first clock is behind', () => {
|
||||
const clock1 = { client1: 3, client2: 2 };
|
||||
const clock2 = { client1: 5, client2: 3 };
|
||||
expect(compareVectorClocks(clock1, clock2)).toBe(VectorClockComparison.LESS_THAN);
|
||||
});
|
||||
|
||||
it('should return GREATER_THAN when first clock is ahead', () => {
|
||||
const clock1 = { client1: 5, client2: 3 };
|
||||
const clock2 = { client1: 3, client2: 2 };
|
||||
expect(compareVectorClocks(clock1, clock2)).toBe(
|
||||
VectorClockComparison.GREATER_THAN,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return CONCURRENT for concurrent clocks', () => {
|
||||
const clock1 = { client1: 5, client2: 2 };
|
||||
const clock2 = { client1: 3, client2: 4 };
|
||||
expect(compareVectorClocks(clock1, clock2)).toBe(VectorClockComparison.CONCURRENT);
|
||||
});
|
||||
|
||||
it('should handle missing components as 0', () => {
|
||||
const clock1 = { client1: 5 };
|
||||
const clock2 = { client1: 5, client2: 0 };
|
||||
expect(compareVectorClocks(clock1, clock2)).toBe(VectorClockComparison.EQUAL);
|
||||
});
|
||||
|
||||
it('should handle comparison with empty clock', () => {
|
||||
const clock1 = { client1: 1 };
|
||||
expect(compareVectorClocks(clock1, {})).toBe(VectorClockComparison.GREATER_THAN);
|
||||
expect(compareVectorClocks({}, clock1)).toBe(VectorClockComparison.LESS_THAN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementVectorClock', () => {
|
||||
it('should increment existing component', () => {
|
||||
const clock = { client1: 5, client2: 3 };
|
||||
const result = incrementVectorClock(clock, 'client1');
|
||||
expect(result).toEqual({ client1: 6, client2: 3 });
|
||||
// Should not modify original
|
||||
expect(clock).toEqual({ client1: 5, client2: 3 });
|
||||
});
|
||||
|
||||
it('should add new component if not exists', () => {
|
||||
const clock = { client1: 5 };
|
||||
const result = incrementVectorClock(clock, 'client2');
|
||||
expect(result).toEqual({ client1: 5, client2: 1 });
|
||||
});
|
||||
|
||||
it('should handle empty clock', () => {
|
||||
const result = incrementVectorClock({}, 'client1');
|
||||
expect(result).toEqual({ client1: 1 });
|
||||
});
|
||||
|
||||
it('should handle null clock', () => {
|
||||
const result = incrementVectorClock(null, 'client1');
|
||||
expect(result).toEqual({ client1: 1 });
|
||||
});
|
||||
|
||||
it('should handle overflow protection', () => {
|
||||
const clock = { client1: Number.MAX_SAFE_INTEGER - 500 };
|
||||
const result = incrementVectorClock(clock, 'client1');
|
||||
expect(result).toEqual({ client1: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeVectorClocks', () => {
|
||||
it('should take maximum of each component', () => {
|
||||
const clock1 = { client1: 5, client2: 3, client3: 7 };
|
||||
const clock2 = { client1: 3, client2: 6, client3: 2 };
|
||||
const result = mergeVectorClocks(clock1, clock2);
|
||||
expect(result).toEqual({ client1: 5, client2: 6, client3: 7 });
|
||||
});
|
||||
|
||||
it('should include components from both clocks', () => {
|
||||
const clock1 = { client1: 5, client2: 3 };
|
||||
const clock2 = { client2: 2, client3: 4 };
|
||||
const result = mergeVectorClocks(clock1, clock2);
|
||||
expect(result).toEqual({ client1: 5, client2: 3, client3: 4 });
|
||||
});
|
||||
|
||||
it('should handle empty clocks', () => {
|
||||
const clock1 = { client1: 5 };
|
||||
expect(mergeVectorClocks(clock1, {})).toEqual({ client1: 5 });
|
||||
expect(mergeVectorClocks({}, clock1)).toEqual({ client1: 5 });
|
||||
expect(mergeVectorClocks({}, {})).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle null clocks', () => {
|
||||
const clock1 = { client1: 5 };
|
||||
expect(mergeVectorClocks(clock1, null)).toEqual({ client1: 5 });
|
||||
expect(mergeVectorClocks(null, clock1)).toEqual({ client1: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('lamportToVectorClock', () => {
|
||||
it('should convert positive Lamport timestamp', () => {
|
||||
const result = lamportToVectorClock(5, 'client1');
|
||||
expect(result).toEqual({ client1: 5 });
|
||||
});
|
||||
|
||||
it('should return empty clock for null', () => {
|
||||
const result = lamportToVectorClock(null, 'client1');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty clock for 0', () => {
|
||||
const result = lamportToVectorClock(0, 'client1');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty clock for undefined', () => {
|
||||
const result = lamportToVectorClock(undefined, 'client1');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('vectorClockToString', () => {
|
||||
it('should format vector clock as string', () => {
|
||||
const clock = { client2: 3, client1: 5 };
|
||||
expect(vectorClockToString(clock)).toBe('{client1:5, client2:3}');
|
||||
});
|
||||
|
||||
it('should handle empty clock', () => {
|
||||
expect(vectorClockToString({})).toBe('{}');
|
||||
});
|
||||
|
||||
it('should handle null clock', () => {
|
||||
expect(vectorClockToString(null)).toBe('{}');
|
||||
});
|
||||
|
||||
it('should sort client IDs alphabetically', () => {
|
||||
const clock = { z: 1, a: 2, m: 3 };
|
||||
expect(vectorClockToString(clock)).toBe('{a:2, m:3, z:1}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasVectorClockChanges', () => {
|
||||
it('should detect changes when current is ahead', () => {
|
||||
const current = { client1: 5, client2: 3 };
|
||||
const reference = { client1: 3, client2: 3 };
|
||||
expect(hasVectorClockChanges(current, reference)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect no changes when equal', () => {
|
||||
const current = { client1: 5, client2: 3 };
|
||||
const reference = { client1: 5, client2: 3 };
|
||||
expect(hasVectorClockChanges(current, reference)).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect no changes when current is behind', () => {
|
||||
const current = { client1: 3, client2: 3 };
|
||||
const reference = { client1: 5, client2: 3 };
|
||||
expect(hasVectorClockChanges(current, reference)).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect changes in new components', () => {
|
||||
const current = { client1: 5, client2: 1 };
|
||||
const reference = { client1: 5 };
|
||||
expect(hasVectorClockChanges(current, reference)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty current clock', () => {
|
||||
const reference = { client1: 5 };
|
||||
expect(hasVectorClockChanges({}, reference)).toBe(false);
|
||||
expect(hasVectorClockChanges(null, reference)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty reference clock', () => {
|
||||
const current = { client1: 5 };
|
||||
expect(hasVectorClockChanges(current, {})).toBe(true);
|
||||
expect(hasVectorClockChanges(current, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle both empty', () => {
|
||||
expect(hasVectorClockChanges({}, {})).toBe(false);
|
||||
expect(hasVectorClockChanges(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
it('should handle typical sync scenario', () => {
|
||||
// Initial state - both clients start at 0
|
||||
const clientA = initializeVectorClock('A');
|
||||
const clientB = initializeVectorClock('B');
|
||||
|
||||
// Client A makes changes
|
||||
const clockA1 = incrementVectorClock(clientA, 'A');
|
||||
const clockA2 = incrementVectorClock(clockA1, 'A');
|
||||
|
||||
// Client B makes changes
|
||||
const clockB1 = incrementVectorClock(clientB, 'B');
|
||||
|
||||
// Check they are concurrent
|
||||
expect(compareVectorClocks(clockA2, clockB1)).toBe(
|
||||
VectorClockComparison.CONCURRENT,
|
||||
);
|
||||
|
||||
// Client B syncs with A's changes
|
||||
const clockBSynced = mergeVectorClocks(clockB1, clockA2);
|
||||
expect(clockBSynced).toEqual({ A: 2, B: 1 });
|
||||
|
||||
// Client B makes more changes
|
||||
const clockB2 = incrementVectorClock(clockBSynced, 'B');
|
||||
expect(clockB2).toEqual({ A: 2, B: 2 });
|
||||
|
||||
// Now B is strictly ahead of A
|
||||
expect(compareVectorClocks(clockB2, clockA2)).toBe(
|
||||
VectorClockComparison.GREATER_THAN,
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect true conflicts', () => {
|
||||
// Start with synced state
|
||||
const syncedClock: VectorClock = { A: 5, B: 3, C: 2 };
|
||||
|
||||
// A and B both make changes independently
|
||||
const clockA = incrementVectorClock(syncedClock, 'A');
|
||||
const clockB = incrementVectorClock(syncedClock, 'B');
|
||||
|
||||
// They should be concurrent (true conflict)
|
||||
expect(compareVectorClocks(clockA, clockB)).toBe(VectorClockComparison.CONCURRENT);
|
||||
|
||||
// Both have changes since synced state
|
||||
expect(hasVectorClockChanges(clockA, syncedClock)).toBe(true);
|
||||
expect(hasVectorClockChanges(clockB, syncedClock)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle three-way sync scenario', () => {
|
||||
// Three devices starting fresh
|
||||
const deviceA: VectorClock = {};
|
||||
const deviceB: VectorClock = {};
|
||||
const deviceC: VectorClock = {};
|
||||
|
||||
// Device A makes initial changes
|
||||
const clockA1 = incrementVectorClock(deviceA, 'A');
|
||||
const clockA2 = incrementVectorClock(clockA1, 'A');
|
||||
|
||||
// Device B syncs with A
|
||||
const clockB1 = mergeVectorClocks(deviceB, clockA2);
|
||||
const clockB2 = incrementVectorClock(clockB1, 'B');
|
||||
|
||||
// Device C syncs with B (gets both A and B changes)
|
||||
const clockC1 = mergeVectorClocks(deviceC, clockB2);
|
||||
expect(clockC1).toEqual({ A: 2, B: 1 });
|
||||
|
||||
// Device C makes changes
|
||||
const clockC2 = incrementVectorClock(clockC1, 'C');
|
||||
|
||||
// Now C is ahead of both A and B
|
||||
expect(compareVectorClocks(clockC2, clockA2)).toBe(
|
||||
VectorClockComparison.GREATER_THAN,
|
||||
);
|
||||
expect(compareVectorClocks(clockC2, clockB2)).toBe(
|
||||
VectorClockComparison.GREATER_THAN,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex conflict resolution', () => {
|
||||
// Start with all devices synced
|
||||
const baseClock: VectorClock = { A: 10, B: 8, C: 5 };
|
||||
|
||||
// Each device makes independent changes
|
||||
const clockA = incrementVectorClock(incrementVectorClock(baseClock, 'A'), 'A');
|
||||
const clockB = incrementVectorClock(baseClock, 'B');
|
||||
const clockC = incrementVectorClock(
|
||||
incrementVectorClock(incrementVectorClock(baseClock, 'C'), 'C'),
|
||||
'C',
|
||||
);
|
||||
|
||||
// All should be concurrent with each other
|
||||
expect(compareVectorClocks(clockA, clockB)).toBe(VectorClockComparison.CONCURRENT);
|
||||
expect(compareVectorClocks(clockB, clockC)).toBe(VectorClockComparison.CONCURRENT);
|
||||
expect(compareVectorClocks(clockA, clockC)).toBe(VectorClockComparison.CONCURRENT);
|
||||
|
||||
// Resolve by merging all clocks
|
||||
const resolved = mergeVectorClocks(mergeVectorClocks(clockA, clockB), clockC);
|
||||
expect(resolved).toEqual({ A: 12, B: 9, C: 8 });
|
||||
|
||||
// Resolved clock should be greater than all individual clocks
|
||||
expect(compareVectorClocks(resolved, clockA)).toBe(
|
||||
VectorClockComparison.GREATER_THAN,
|
||||
);
|
||||
expect(compareVectorClocks(resolved, clockB)).toBe(
|
||||
VectorClockComparison.GREATER_THAN,
|
||||
);
|
||||
expect(compareVectorClocks(resolved, clockC)).toBe(
|
||||
VectorClockComparison.GREATER_THAN,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle lost update scenario', () => {
|
||||
// Device A and B start synced
|
||||
const syncedState: VectorClock = { A: 5, B: 5 };
|
||||
|
||||
// Device A makes changes
|
||||
const clockA1 = incrementVectorClock(syncedState, 'A');
|
||||
|
||||
// Device B makes changes but doesn\'t sync
|
||||
const clockB1 = incrementVectorClock(syncedState, 'B');
|
||||
const clockB2 = incrementVectorClock(clockB1, 'B');
|
||||
|
||||
// Device A syncs and overwrites B\'s first change
|
||||
const clockA2 = mergeVectorClocks(clockA1, { A: 5, B: 5 }); // B hasn't synced yet
|
||||
|
||||
// Now when B tries to sync, conflict is detected
|
||||
expect(compareVectorClocks(clockA2, clockB2)).toBe(
|
||||
VectorClockComparison.CONCURRENT,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle clock drift recovery', () => {
|
||||
// Simulate a device with drifted clock values
|
||||
const driftedClock: VectorClock = { A: 1000000, B: 999999, C: 1000001 };
|
||||
const normalClock: VectorClock = { A: 10, B: 8, C: 12 };
|
||||
|
||||
// Merge should take maximum values
|
||||
const merged = mergeVectorClocks(driftedClock, normalClock);
|
||||
expect(merged).toEqual({ A: 1000000, B: 999999, C: 1000001 });
|
||||
|
||||
// Comparison should still work correctly
|
||||
expect(compareVectorClocks(driftedClock, normalClock)).toBe(
|
||||
VectorClockComparison.GREATER_THAN,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and error handling', () => {
|
||||
it('should handle very large vector clocks', () => {
|
||||
const largeClock: VectorClock = {};
|
||||
// Create a clock with many clients
|
||||
for (let i = 0; i < 100; i++) {
|
||||
largeClock[`client${i}`] = i;
|
||||
}
|
||||
|
||||
// Operations should still work
|
||||
const incremented = incrementVectorClock(largeClock, 'client50');
|
||||
expect(incremented.client50).toBe(51);
|
||||
|
||||
const str = vectorClockToString(largeClock);
|
||||
expect(str).toContain('client0:0');
|
||||
expect(str).toContain('client99:99');
|
||||
});
|
||||
|
||||
it('should handle special characters in client IDs', () => {
|
||||
const clock = initializeVectorClock('client-with-dash');
|
||||
const clock2 = incrementVectorClock(clock, 'client.with.dots');
|
||||
const clock3 = incrementVectorClock(clock2, 'client@email.com');
|
||||
|
||||
expect(clock3).toEqual({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'client-with-dash': 0,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'client.with.dots': 1,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'client@email.com': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle comparison with mixed empty/zero components', () => {
|
||||
const clock1 = { A: 0, B: 5, C: 0 };
|
||||
const clock2 = { B: 5 };
|
||||
|
||||
expect(compareVectorClocks(clock1, clock2)).toBe(VectorClockComparison.EQUAL);
|
||||
});
|
||||
|
||||
it('should handle increments near overflow gracefully', () => {
|
||||
const nearMax = Number.MAX_SAFE_INTEGER - 100;
|
||||
const clock = { A: nearMax };
|
||||
|
||||
// First increment should trigger overflow protection
|
||||
const clock1 = incrementVectorClock(clock, 'A');
|
||||
expect(clock1.A).toBe(1);
|
||||
|
||||
// Subsequent increments should work normally
|
||||
const clock2 = incrementVectorClock(clock1, 'A');
|
||||
expect(clock2.A).toBe(2);
|
||||
});
|
||||
|
||||
it('should maintain consistency when merging with self', () => {
|
||||
const clock: VectorClock = { A: 5, B: 3 };
|
||||
const merged = mergeVectorClocks(clock, clock);
|
||||
|
||||
expect(merged).toEqual(clock);
|
||||
expect(merged).not.toBe(clock); // Should be a new object
|
||||
});
|
||||
|
||||
it('should handle comparison of single-client clocks', () => {
|
||||
const clock1 = { A: 5 };
|
||||
const clock2 = { A: 3 };
|
||||
|
||||
expect(compareVectorClocks(clock1, clock2)).toBe(
|
||||
VectorClockComparison.GREATER_THAN,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backwards compatibility scenarios', () => {
|
||||
it('should handle migration from Lamport timestamps', () => {
|
||||
// Simulate device with only Lamport timestamp
|
||||
const lamportValue = 42;
|
||||
const migratedClock = lamportToVectorClock(lamportValue, 'deviceA');
|
||||
|
||||
expect(migratedClock).toEqual({ deviceA: 42 });
|
||||
|
||||
// Should be able to increment normally after migration
|
||||
const incremented = incrementVectorClock(migratedClock, 'deviceA');
|
||||
expect(incremented).toEqual({ deviceA: 43 });
|
||||
});
|
||||
|
||||
it('should handle mixed Lamport/vector clock comparison', () => {
|
||||
// Device A has vector clock, Device B has Lamport
|
||||
const vectorClock: VectorClock = { A: 10, B: 5 };
|
||||
const lamportClock = lamportToVectorClock(8, 'B');
|
||||
|
||||
// B's Lamport 8 is higher than its component in A's vector (5)
|
||||
const merged = mergeVectorClocks(vectorClock, lamportClock);
|
||||
expect(merged).toEqual({ A: 10, B: 8 });
|
||||
});
|
||||
|
||||
it('should handle empty to non-empty migration', () => {
|
||||
// Device starts with no clock
|
||||
let deviceClock: VectorClock | undefined = undefined;
|
||||
|
||||
// First update creates clock
|
||||
deviceClock = incrementVectorClock(deviceClock, 'device1');
|
||||
expect(deviceClock).toEqual({ device1: 1 });
|
||||
|
||||
// Can continue incrementing
|
||||
deviceClock = incrementVectorClock(deviceClock, 'device1');
|
||||
expect(deviceClock).toEqual({ device1: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance considerations', () => {
|
||||
it('should handle rapid increments efficiently', () => {
|
||||
let clock: VectorClock = {};
|
||||
const clientId = 'rapidClient';
|
||||
|
||||
// Perform many increments
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
clock = incrementVectorClock(clock, clientId);
|
||||
}
|
||||
|
||||
expect(clock[clientId]).toBe(1000);
|
||||
});
|
||||
|
||||
it('should handle large merges efficiently', () => {
|
||||
const clock1: VectorClock = {};
|
||||
const clock2: VectorClock = {};
|
||||
|
||||
// Create two large clocks with overlapping clients
|
||||
for (let i = 0; i < 50; i++) {
|
||||
clock1[`client${i}`] = i * 2;
|
||||
clock2[`client${i}`] = i * 3;
|
||||
}
|
||||
|
||||
const merged = mergeVectorClocks(clock1, clock2);
|
||||
|
||||
// Should have taken max of each
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(merged[`client${i}`]).toBe(i * 3);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
238
src/app/pfapi/api/util/vector-clock.ts
Normal file
238
src/app/pfapi/api/util/vector-clock.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { pfLog } from './log';
|
||||
|
||||
/**
|
||||
* Vector Clock implementation for distributed synchronization
|
||||
*
|
||||
* A vector clock is a data structure used to determine the partial ordering of events
|
||||
* in a distributed system and detect causality violations.
|
||||
*
|
||||
* Each process/device maintains its own component in the vector, incrementing it
|
||||
* on local updates. This allows us to determine if two states are:
|
||||
* - EQUAL: Same vector values
|
||||
* - LESS_THAN: A happened before B
|
||||
* - GREATER_THAN: B happened before A
|
||||
* - CONCURRENT: Neither happened before the other (true conflict)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Vector clock data structure
|
||||
* Maps client IDs to their respective clock values
|
||||
*/
|
||||
export interface VectorClock {
|
||||
[clientId: string]: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of comparing two vector clocks
|
||||
*/
|
||||
export enum VectorClockComparison {
|
||||
EQUAL = 'EQUAL',
|
||||
LESS_THAN = 'LESS_THAN',
|
||||
GREATER_THAN = 'GREATER_THAN',
|
||||
CONCURRENT = 'CONCURRENT',
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a new vector clock for a client
|
||||
* @param clientId The client's unique identifier
|
||||
* @param initialValue Optional initial value (defaults to 0)
|
||||
* @returns A new vector clock
|
||||
*/
|
||||
export const initializeVectorClock = (
|
||||
clientId: string,
|
||||
initialValue: number = 0,
|
||||
): VectorClock => {
|
||||
return { [clientId]: initialValue };
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a vector clock is empty or uninitialized
|
||||
* @param clock The vector clock to check
|
||||
* @returns True if the clock is null, undefined, or has no entries
|
||||
*/
|
||||
export const isVectorClockEmpty = (clock: VectorClock | null | undefined): boolean => {
|
||||
return !clock || Object.keys(clock).length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two vector clocks to determine their relationship
|
||||
*
|
||||
* @param a First vector clock
|
||||
* @param b Second vector clock
|
||||
* @returns The comparison result
|
||||
*/
|
||||
export const compareVectorClocks = (
|
||||
a: VectorClock | null | undefined,
|
||||
b: VectorClock | null | undefined,
|
||||
): VectorClockComparison => {
|
||||
// Handle null/undefined cases
|
||||
if (isVectorClockEmpty(a) && isVectorClockEmpty(b)) {
|
||||
return VectorClockComparison.EQUAL;
|
||||
}
|
||||
if (isVectorClockEmpty(a)) {
|
||||
return VectorClockComparison.LESS_THAN;
|
||||
}
|
||||
if (isVectorClockEmpty(b)) {
|
||||
return VectorClockComparison.GREATER_THAN;
|
||||
}
|
||||
|
||||
// Safe type assertion after null checks
|
||||
const clockA = a!;
|
||||
const clockB = b!;
|
||||
|
||||
// Get all client IDs from both clocks
|
||||
const allClientIds = new Set([...Object.keys(clockA), ...Object.keys(clockB)]);
|
||||
|
||||
let aHasGreater = false;
|
||||
let bHasGreater = false;
|
||||
|
||||
// Compare each component
|
||||
for (const clientId of allClientIds) {
|
||||
const aVal = clockA[clientId] || 0;
|
||||
const bVal = clockB[clientId] || 0;
|
||||
|
||||
if (aVal > bVal) {
|
||||
aHasGreater = true;
|
||||
}
|
||||
if (bVal > aVal) {
|
||||
bHasGreater = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine relationship
|
||||
if (!aHasGreater && !bHasGreater) {
|
||||
return VectorClockComparison.EQUAL;
|
||||
} else if (!aHasGreater) {
|
||||
// B has some greater components, A has none -> A < B
|
||||
return VectorClockComparison.LESS_THAN;
|
||||
} else if (!bHasGreater) {
|
||||
// A has some greater components, B has none -> A > B
|
||||
return VectorClockComparison.GREATER_THAN;
|
||||
} else {
|
||||
// Both have some greater components -> concurrent
|
||||
return VectorClockComparison.CONCURRENT;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Increment a client's component in the vector clock
|
||||
* Creates a new vector clock with the incremented value
|
||||
*
|
||||
* @param clock The current vector clock
|
||||
* @param clientId The client ID to increment
|
||||
* @returns A new vector clock with the incremented value
|
||||
*/
|
||||
export const incrementVectorClock = (
|
||||
clock: VectorClock | null | undefined,
|
||||
clientId: string,
|
||||
): VectorClock => {
|
||||
const newClock = { ...(clock || {}) };
|
||||
const currentValue = newClock[clientId] || 0;
|
||||
|
||||
// Handle overflow - reset to 1 if approaching max safe integer
|
||||
if (currentValue >= Number.MAX_SAFE_INTEGER - 1000) {
|
||||
pfLog(1, 'Vector clock component overflow protection triggered', {
|
||||
clientId,
|
||||
currentValue,
|
||||
});
|
||||
newClock[clientId] = 1;
|
||||
} else {
|
||||
newClock[clientId] = currentValue + 1;
|
||||
}
|
||||
|
||||
return newClock;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge two vector clocks by taking the maximum value for each component
|
||||
* This is used when receiving updates to ensure we have the most recent view
|
||||
*
|
||||
* @param a First vector clock
|
||||
* @param b Second vector clock
|
||||
* @returns A new merged vector clock
|
||||
*/
|
||||
export const mergeVectorClocks = (
|
||||
a: VectorClock | null | undefined,
|
||||
b: VectorClock | null | undefined,
|
||||
): VectorClock => {
|
||||
if (isVectorClockEmpty(a)) return { ...(b || {}) };
|
||||
if (isVectorClockEmpty(b)) return { ...(a || {}) };
|
||||
|
||||
const merged: VectorClock = {};
|
||||
const allClientIds = new Set([...Object.keys(a!), ...Object.keys(b!)]);
|
||||
|
||||
for (const clientId of allClientIds) {
|
||||
const aVal = a![clientId] || 0;
|
||||
const bVal = b![clientId] || 0;
|
||||
merged[clientId] = Math.max(aVal, bVal);
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a Lamport timestamp to a vector clock
|
||||
* Used for backwards compatibility during migration
|
||||
*
|
||||
* @param lamport The Lamport timestamp value
|
||||
* @param clientId The client ID to use
|
||||
* @returns A vector clock with a single component
|
||||
*/
|
||||
export const lamportToVectorClock = (
|
||||
lamport: number | null | undefined,
|
||||
clientId: string,
|
||||
): VectorClock => {
|
||||
if (lamport == null || lamport === 0) {
|
||||
return {};
|
||||
}
|
||||
return { [clientId]: lamport };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a human-readable string representation of a vector clock
|
||||
* Useful for debugging and logging
|
||||
*
|
||||
* @param clock The vector clock
|
||||
* @returns A string representation
|
||||
*/
|
||||
export const vectorClockToString = (clock: VectorClock | null | undefined): string => {
|
||||
if (isVectorClockEmpty(clock)) {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
const entries = Object.entries(clock!)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([id, val]) => `${id}:${val}`);
|
||||
|
||||
return `{${entries.join(', ')}}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a vector clock has changes compared to a reference clock
|
||||
* Used to determine if local changes exist
|
||||
*
|
||||
* @param current The current vector clock
|
||||
* @param reference The reference vector clock (e.g., last synced)
|
||||
* @returns True if current has any components greater than reference
|
||||
*/
|
||||
export const hasVectorClockChanges = (
|
||||
current: VectorClock | null | undefined,
|
||||
reference: VectorClock | null | undefined,
|
||||
): boolean => {
|
||||
if (isVectorClockEmpty(current)) {
|
||||
return false;
|
||||
}
|
||||
if (isVectorClockEmpty(reference)) {
|
||||
return !isVectorClockEmpty(current);
|
||||
}
|
||||
|
||||
// Check if any component in current is greater than in reference
|
||||
for (const [clientId, currentVal] of Object.entries(current!)) {
|
||||
const refVal = reference![clientId] || 0;
|
||||
if (currentVal > refVal) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue