mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
refactor(sync-core): drop shared-schema vector-clock compat re-export anda app enum (#8441)
* feat(sync-core): drop shared-schema vector-clock compat re-export and app enum - Remove the shared-schema → sync-core compatibility re-export of vector-clock types/functions; retarget app files (vector-clock.ts,operation-log.const.ts) and the server (sync.types.ts) to import from @sp/sync-core directly - Add @sp/sync-core to super-sync-server/package.json deps (it was load-bearing through the re-export) - Convert VectorClockComparison from a bare type to an as const object + derived type in sync-core,drop the app-side enum copy and the as cast - Update spec imports and pa ckage-boundaries.md * docs: remove stale shared-schema vector-clock references - Update comments in vector-clocks.md, client vector-clock.ts, and server sync.types.ts to reference @sp/sync-core directly - Remove @sp/sync-core dependency from shared-schema/package.json - Regenerate package-lock.json to reflect the removed edge * docs(sync): fix orphaned VectorClockComparison comment The comment block describing VectorClockComparison was left dangling above no declaration after the app-side enum was removed, and still claimed 'Uses enum for client-side ergonomics'. Move it above the re-export it documents and correct the wording. --------- Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
This commit is contained in:
parent
898436e3da
commit
c2bf7b26a0
18 changed files with 54 additions and 624 deletions
|
|
@ -19,8 +19,9 @@ src/app
|
|||
src/app
|
||||
-> @sp/sync-core
|
||||
|
||||
packages/shared-schema
|
||||
packages/super-sync-server
|
||||
-> @sp/sync-core
|
||||
-> @sp/shared-schema
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
|
@ -33,16 +34,10 @@ Rules:
|
|||
- The app may import both packages and is responsible for Angular dependency
|
||||
injection, NgRx, Electron/Capacitor bridges, config UI, OAuth routing, and
|
||||
Super Productivity-specific model wiring.
|
||||
- `packages/shared-schema` depends on `@sp/sync-core` only for compatibility
|
||||
re-exports of generic vector-clock algorithms.
|
||||
|
||||
The `packages/shared-schema -> @sp/sync-core` edge is deliberate compatibility
|
||||
coupling. Vector-clock compare/merge/prune algorithms moved to `@sp/sync-core`
|
||||
so sync-core, client wrappers, and server/shared consumers use one
|
||||
implementation. `packages/shared-schema` re-exports those algorithms to preserve
|
||||
legacy import paths while consumers migrate; do not add new sync-engine logic to
|
||||
`@sp/shared-schema`, and remove the compatibility edge once no consumers need
|
||||
it.
|
||||
- `packages/shared-schema` owns schema contracts and validators shared between
|
||||
app and server. It has no dependency on `@sp/sync-core`.
|
||||
- `packages/super-sync-server` depends on both `@sp/shared-schema` (HTTP contract
|
||||
types and validation schemas) and `@sp/sync-core` (vector-clock algorithms).
|
||||
|
||||
## Ownership
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ At 6-char client IDs, a 20-entry clock is ~333 bytes — negligible bandwidth. A
|
|||
|
||||
## 2. Core Operations
|
||||
|
||||
Three operations — compare, merge, and prune (`limitVectorClockSize`) — are implemented in the generic sync-core package (`packages/sync-core/src/vector-clock.ts`), used by both client and server. `packages/shared-schema/src/vector-clock.ts` re-exports those algorithms for existing shared-schema import paths. Two operations — initialize and increment — are client-only (`src/app/core/util/vector-clock.ts`), which also wraps the shared operations with null-handling and logging.
|
||||
Three operations — compare, merge, and prune (`limitVectorClockSize`) — are implemented in the generic sync-core package (`packages/sync-core/src/vector-clock.ts`), used by both client and server. Two operations — initialize and increment — are client-only (`src/app/core/util/vector-clock.ts`), which also wraps the shared operations with null-handling and logging.
|
||||
|
||||
### Create
|
||||
|
||||
|
|
@ -142,7 +142,7 @@ Otherwise:
|
|||
3. Return clock with exactly MAX entries
|
||||
```
|
||||
|
||||
Implemented in `packages/sync-core/src/vector-clock.ts` and re-exported from `packages/shared-schema/src/vector-clock.ts`. The client wrapper in `src/app/core/util/vector-clock.ts` adds logging and passes `[currentClientId]` as the preserve list.
|
||||
Implemented in `packages/sync-core/src/vector-clock.ts`. The client wrapper in `src/app/core/util/vector-clock.ts` adds logging and passes `[currentClientId]` as the preserve list.
|
||||
|
||||
### When Pruning Happens (Exhaustive List)
|
||||
|
||||
|
|
@ -345,7 +345,7 @@ Rules that must hold for the system to be correct. Use these to verify implement
|
|||
|
||||
3. **Client does NOT prune during conflict resolution.** `SupersededOperationResolverService` sends full merged clocks; the server prunes after accepting.
|
||||
|
||||
4. **`compareVectorClocks` produces identical results on client and server.** Both import from `@sp/shared-schema`. The client wrapper only adds null handling.
|
||||
4. **`compareVectorClocks` produces identical results on client and server.** Both import from `@sp/sync-core`. The client wrapper only adds null handling.
|
||||
|
||||
5. **Full-state ops skip conflict detection on server.** `detectConflict()` returns `{ hasConflict: false }` for SYNC_IMPORT, BACKUP_IMPORT, and REPAIR.
|
||||
|
||||
|
|
@ -362,7 +362,7 @@ Rules that must hold for the system to be correct. Use these to verify implement
|
|||
| Concept | File(s) |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Core algorithms (compare, merge, prune) | `packages/sync-core/src/vector-clock.ts` |
|
||||
| Compatibility re-export for existing shared-schema imports | `packages/shared-schema/src/vector-clock.ts` |
|
||||
| Compatibility re-export for existing shared-schema imports | (removed — imports now target `@sp/sync-core` directly) |
|
||||
| Client wrappers (null handling, logging, validation) | `src/app/core/util/vector-clock.ts` |
|
||||
| Global clock management, entity frontier | `src/app/op-log/sync/vector-clock.service.ts` |
|
||||
| Operation capture (no pruning, atomic clock update) | `src/app/op-log/capture/operation-log.effects.ts` |
|
||||
|
|
|
|||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -28011,7 +28011,6 @@
|
|||
"name": "@sp/shared-schema",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@sp/sync-core": "*",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -28033,6 +28032,7 @@
|
|||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@simplewebauthn/types": "^12.0.0",
|
||||
"@sp/shared-schema": "*",
|
||||
"@sp/sync-core": "*",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"fastify": "^5.8.5",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@sp/sync-core": "*",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -27,15 +27,6 @@ export {
|
|||
// Migration registry (for inspection/debugging)
|
||||
export { MIGRATIONS } from './migrations/index';
|
||||
|
||||
// Vector clock types and comparison (shared between client and server)
|
||||
export type { VectorClock, VectorClockComparison } from './vector-clock';
|
||||
export {
|
||||
compareVectorClocks,
|
||||
mergeVectorClocks,
|
||||
limitVectorClockSize,
|
||||
MAX_VECTOR_CLOCK_SIZE,
|
||||
} from './vector-clock';
|
||||
|
||||
// Entity types (shared between client and server)
|
||||
export type { EntityType } from './entity-types';
|
||||
export { ENTITY_TYPES } from './entity-types';
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
// Compatibility re-export. The single source of truth for generic vector-clock
|
||||
// algorithms lives in @sp/sync-core.
|
||||
export type { VectorClock, VectorClockComparison } from '@sp/sync-core';
|
||||
export {
|
||||
compareVectorClocks,
|
||||
mergeVectorClocks,
|
||||
limitVectorClockSize,
|
||||
MAX_VECTOR_CLOCK_SIZE,
|
||||
} from '@sp/sync-core';
|
||||
|
|
@ -1,560 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
compareVectorClocks,
|
||||
mergeVectorClocks,
|
||||
limitVectorClockSize,
|
||||
MAX_VECTOR_CLOCK_SIZE,
|
||||
} from '../src/vector-clock';
|
||||
|
||||
describe('compareVectorClocks', () => {
|
||||
describe('basic cases', () => {
|
||||
it('should return EQUAL for identical clocks', () => {
|
||||
const clock = { a: 1, b: 2 };
|
||||
expect(compareVectorClocks(clock, clock)).toBe('EQUAL');
|
||||
});
|
||||
|
||||
it('should return EQUAL for two empty clocks', () => {
|
||||
expect(compareVectorClocks({}, {})).toBe('EQUAL');
|
||||
});
|
||||
|
||||
it('should return LESS_THAN when a is strictly behind b', () => {
|
||||
const a = { x: 1, y: 2 };
|
||||
const b = { x: 2, y: 3 };
|
||||
expect(compareVectorClocks(a, b)).toBe('LESS_THAN');
|
||||
});
|
||||
|
||||
it('should return GREATER_THAN when a is strictly ahead of b', () => {
|
||||
const a = { x: 5, y: 10 };
|
||||
const b = { x: 3, y: 7 };
|
||||
expect(compareVectorClocks(a, b)).toBe('GREATER_THAN');
|
||||
});
|
||||
|
||||
it('should return CONCURRENT when neither dominates', () => {
|
||||
const a = { x: 3, y: 1 };
|
||||
const b = { x: 1, y: 3 };
|
||||
expect(compareVectorClocks(a, b)).toBe('CONCURRENT');
|
||||
});
|
||||
|
||||
it('should return LESS_THAN when a has subset of keys (missing key = 0)', () => {
|
||||
const a = { x: 1 };
|
||||
const b = { x: 1, y: 2 };
|
||||
expect(compareVectorClocks(a, b)).toBe('LESS_THAN');
|
||||
});
|
||||
|
||||
it('should return GREATER_THAN when a has superset of keys', () => {
|
||||
const a = { x: 1, y: 2 };
|
||||
const b = { x: 1 };
|
||||
expect(compareVectorClocks(a, b)).toBe('GREATER_THAN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing keys treated as zero', () => {
|
||||
it('should return CONCURRENT when each clock has unique keys', () => {
|
||||
const a = { x: 5 };
|
||||
const b = { y: 5 };
|
||||
// a: x=5, y=0 vs b: x=0, y=5 => CONCURRENT
|
||||
expect(compareVectorClocks(a, b)).toBe('CONCURRENT');
|
||||
});
|
||||
|
||||
it('should return GREATER_THAN when a has all of b keys at equal values plus extras', () => {
|
||||
const a = { x: 1, y: 2, z: 3 };
|
||||
const b = { x: 1, y: 2 };
|
||||
expect(compareVectorClocks(a, b)).toBe('GREATER_THAN');
|
||||
});
|
||||
|
||||
it('should return LESS_THAN when b has all of a keys at equal values plus extras', () => {
|
||||
const a = { x: 1, y: 2 };
|
||||
const b = { x: 1, y: 2, z: 3 };
|
||||
expect(compareVectorClocks(a, b)).toBe('LESS_THAN');
|
||||
});
|
||||
|
||||
it('should return EQUAL for equivalent clocks with different key ordering', () => {
|
||||
const a = { x: 1, y: 2, z: 3 };
|
||||
const b = { z: 3, x: 1, y: 2 };
|
||||
expect(compareVectorClocks(a, b)).toBe('EQUAL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('large clocks', () => {
|
||||
it('should return EQUAL when two large identical clocks are compared', () => {
|
||||
const a: Record<string, number> = {};
|
||||
const b: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) {
|
||||
a[`client_${i}`] = 10;
|
||||
b[`client_${i}`] = 10;
|
||||
}
|
||||
expect(compareVectorClocks(a, b)).toBe('EQUAL');
|
||||
});
|
||||
|
||||
it('should return GREATER_THAN when a has extra keys beyond a large shared set', () => {
|
||||
const a: Record<string, number> = {};
|
||||
const b: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) {
|
||||
a[`client_${i}`] = 10;
|
||||
b[`client_${i}`] = 10;
|
||||
}
|
||||
a['extra_client'] = 5;
|
||||
expect(compareVectorClocks(a, b)).toBe('GREATER_THAN');
|
||||
});
|
||||
|
||||
it('should return LESS_THAN when b has extra keys beyond a large shared set', () => {
|
||||
const a: Record<string, number> = {};
|
||||
const b: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) {
|
||||
a[`client_${i}`] = 10;
|
||||
b[`client_${i}`] = 10;
|
||||
}
|
||||
b['extra_client'] = 5;
|
||||
expect(compareVectorClocks(a, b)).toBe('LESS_THAN');
|
||||
});
|
||||
|
||||
it('should return CONCURRENT when both large clocks have disjoint unique keys', () => {
|
||||
const a: Record<string, number> = {};
|
||||
const b: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) {
|
||||
a[`a_client_${i}`] = i + 1;
|
||||
b[`b_client_${i}`] = i + 1;
|
||||
}
|
||||
expect(compareVectorClocks(a, b)).toBe('CONCURRENT');
|
||||
});
|
||||
|
||||
it('should return CONCURRENT when large clocks have overlapping but divergent keys', () => {
|
||||
const a: Record<string, number> = {};
|
||||
const b: Record<string, number> = {};
|
||||
// shared keys where a wins
|
||||
for (let i = 0; i < 5; i++) {
|
||||
a[`shared_${i}`] = 10;
|
||||
b[`shared_${i}`] = 5;
|
||||
}
|
||||
// unique keys on each side
|
||||
for (let i = 0; i < 5; i++) {
|
||||
a[`a_only_${i}`] = 100;
|
||||
b[`b_only_${i}`] = 100;
|
||||
}
|
||||
expect(compareVectorClocks(a, b)).toBe('CONCURRENT');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('limitVectorClockSize', () => {
|
||||
it('should return unchanged when within limit', () => {
|
||||
const clock = { a: 1, b: 2, c: 3 };
|
||||
const result = limitVectorClockSize(clock);
|
||||
expect(result).toBe(clock); // Same reference
|
||||
});
|
||||
|
||||
it('should return unchanged when exactly at limit', () => {
|
||||
const clock: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) {
|
||||
clock[`client_${i}`] = i + 1;
|
||||
}
|
||||
const result = limitVectorClockSize(clock);
|
||||
expect(result).toBe(clock);
|
||||
});
|
||||
|
||||
it('should prune to MAX keeping highest-counter clients', () => {
|
||||
const clock: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 5; i++) {
|
||||
clock[`client_${i}`] = i + 1;
|
||||
}
|
||||
|
||||
const result = limitVectorClockSize(clock);
|
||||
expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
|
||||
// The lowest-counter clients should be pruned
|
||||
// client_0 (1), client_1 (2), ..., client_4 (5) should be pruned
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(result[`client_${i}`]).toBeUndefined();
|
||||
}
|
||||
// Higher-counter clients should be kept
|
||||
for (let i = 5; i < MAX_VECTOR_CLOCK_SIZE + 5; i++) {
|
||||
expect(result[`client_${i}`]).toBe(i + 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve specified client IDs even with low counters', () => {
|
||||
const clock: Record<string, number> = { lowClient: 1 };
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 5; i++) {
|
||||
clock[`high_${i}`] = 100 + i;
|
||||
}
|
||||
|
||||
const result = limitVectorClockSize(clock, ['lowClient']);
|
||||
expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
expect(result['lowClient']).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle empty preserveClientIds', () => {
|
||||
const clock: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 3; i++) {
|
||||
clock[`client_${i}`] = i + 1;
|
||||
}
|
||||
|
||||
const result = limitVectorClockSize(clock, []);
|
||||
expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
});
|
||||
|
||||
it('should handle preserveClientIds not present in clock', () => {
|
||||
const clock: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 3; i++) {
|
||||
clock[`client_${i}`] = i + 1;
|
||||
}
|
||||
|
||||
const result = limitVectorClockSize(clock, ['nonexistent']);
|
||||
expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
expect(result['nonexistent']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should cap at MAX_VECTOR_CLOCK_SIZE even when preserveClientIds exceeds MAX', () => {
|
||||
// Build a clock with many entries including 35 "preserved" clients
|
||||
const clock: Record<string, number> = {};
|
||||
const preserveIds: string[] = [];
|
||||
for (let i = 0; i < 35; i++) {
|
||||
const id = `preserved_${i}`;
|
||||
clock[id] = i + 1;
|
||||
preserveIds.push(id);
|
||||
}
|
||||
// Add more clients with higher counters
|
||||
for (let i = 0; i < 20; i++) {
|
||||
clock[`high_${i}`] = 200 + i;
|
||||
}
|
||||
|
||||
const result = limitVectorClockSize(clock, preserveIds);
|
||||
expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
|
||||
// Only the first MAX_VECTOR_CLOCK_SIZE preserved IDs should be kept
|
||||
// (Set insertion order determines which ones)
|
||||
let preservedCount = 0;
|
||||
for (const id of preserveIds) {
|
||||
if (result[id] !== undefined) {
|
||||
preservedCount++;
|
||||
}
|
||||
}
|
||||
expect(preservedCount).toBeLessThanOrEqual(MAX_VECTOR_CLOCK_SIZE);
|
||||
});
|
||||
|
||||
it('should break ties deterministically by client ID (lexicographic order)', () => {
|
||||
// All entries have the same counter value — tie-breaking by client ID determines which are kept.
|
||||
const clock: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 3; i++) {
|
||||
clock[`client_${String.fromCharCode(65 + i)}`] = 10; // client_A, client_B, ...
|
||||
}
|
||||
|
||||
const result1 = limitVectorClockSize(clock);
|
||||
const result2 = limitVectorClockSize(clock);
|
||||
|
||||
// Same input always produces same output
|
||||
expect(result1).toEqual(result2);
|
||||
expect(Object.keys(result1).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
|
||||
// Lexicographically earliest IDs should be kept (ascending sort as secondary)
|
||||
// client_A, client_B, ... should be kept; last 3 alphabetically should be pruned
|
||||
const sortedIds = Object.keys(clock).sort();
|
||||
const prunedIds = sortedIds.slice(MAX_VECTOR_CLOCK_SIZE);
|
||||
for (const id of prunedIds) {
|
||||
expect(result1[id]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('limitVectorClockSize then compareVectorClocks integration', () => {
|
||||
it('new client after MAX-entry entity clock — server accepts with >MAX entries', () => {
|
||||
// Scenario: Entity clock has exactly MAX entries.
|
||||
// New client K merges all + own = MAX+1 entries.
|
||||
const entityClock: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) {
|
||||
entityClock[`client_${i}`] = 10 + i;
|
||||
}
|
||||
expect(Object.keys(entityClock).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
|
||||
// Client K merges entity clock + increments own counter
|
||||
const kClock: Record<string, number> = { ...entityClock, clientK: 1 };
|
||||
expect(Object.keys(kClock).length).toBe(MAX_VECTOR_CLOCK_SIZE + 1);
|
||||
|
||||
// kClock has all of entity's keys at same values PLUS clientK → GREATER_THAN
|
||||
expect(compareVectorClocks(kClock, entityClock)).toBe('GREATER_THAN');
|
||||
|
||||
// Server then prunes kClock before storage
|
||||
const pruned = limitVectorClockSize(kClock, ['clientK']);
|
||||
expect(Object.keys(pruned).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
// clientK is preserved despite having lowest counter (1)
|
||||
expect(pruned['clientK']).toBe(1);
|
||||
});
|
||||
|
||||
it('unpruned clock (>MAX) vs pruned clock (MAX) — standard dominance', () => {
|
||||
// A has MAX+2 entries (never pruned). B has MAX entries.
|
||||
// A clearly dominates B on all shared keys.
|
||||
const a: Record<string, number> = {};
|
||||
const b: Record<string, number> = {};
|
||||
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) {
|
||||
a[`shared_${i}`] = 20;
|
||||
b[`shared_${i}`] = 10;
|
||||
}
|
||||
// A has extra keys beyond MAX
|
||||
a['extra_0'] = 50;
|
||||
a['extra_1'] = 60;
|
||||
|
||||
expect(Object.keys(a).length).toBe(MAX_VECTOR_CLOCK_SIZE + 2);
|
||||
expect(Object.keys(b).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
|
||||
// A has all of B's keys at higher values PLUS extras → GREATER_THAN
|
||||
expect(compareVectorClocks(a, b)).toBe('GREATER_THAN');
|
||||
});
|
||||
|
||||
it('limitVectorClockSize then compareVectorClocks preserves GREATER_THAN when pruned keys are not in other clock', () => {
|
||||
// Start with a MAX+5-entry clock that dominates a MAX-entry import clock.
|
||||
// After pruning to MAX, verify comparison result is preserved.
|
||||
const importClock: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) {
|
||||
importClock[`client_${i}`] = 5;
|
||||
}
|
||||
|
||||
// Original clock: has all import keys at higher values + 5 extra clients
|
||||
const originalClock: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) {
|
||||
originalClock[`client_${i}`] = 10; // dominates import on all shared keys
|
||||
}
|
||||
for (let i = 0; i < 5; i++) {
|
||||
originalClock[`new_client_${i}`] = 1; // low-counter extras
|
||||
}
|
||||
expect(Object.keys(originalClock).length).toBe(MAX_VECTOR_CLOCK_SIZE + 5);
|
||||
|
||||
// Before pruning: GREATER_THAN
|
||||
expect(compareVectorClocks(originalClock, importClock)).toBe('GREATER_THAN');
|
||||
|
||||
// Prune to MAX, preserving client_0 as the "current" client
|
||||
const pruned = limitVectorClockSize(originalClock, ['client_0']);
|
||||
expect(Object.keys(pruned).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
|
||||
// After pruning: the 5 low-counter new_client_* entries (counter=1) are dropped.
|
||||
// All remaining entries are shared client_0..19 with pruned dominating (10 > 5).
|
||||
// Missing keys from pruned = 0, which is still ≤ import's 5 → GREATER_THAN preserved.
|
||||
expect(compareVectorClocks(pruned, importClock)).toBe('GREATER_THAN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('limitVectorClockSize locale-independent sort edge cases', () => {
|
||||
it('should sort case-sensitively by byte order (uppercase before lowercase)', () => {
|
||||
// JavaScript's < and > operators compare by UTF-16 code unit values.
|
||||
// Uppercase letters (A=65) come before lowercase (a=97) in byte order.
|
||||
// This differs from localeCompare which may sort case-insensitively.
|
||||
const clock: Record<string, number> = {};
|
||||
// Mix uppercase and lowercase IDs, all with same counter
|
||||
const ids = ['Alpha', 'alpha', 'Beta', 'beta', 'Gamma', 'gamma'];
|
||||
for (const id of ids) {
|
||||
clock[id] = 10;
|
||||
}
|
||||
// Add enough extra to trigger pruning
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) {
|
||||
clock[`zzz_${i}`] = 10; // These sort last alphabetically
|
||||
}
|
||||
|
||||
const result = limitVectorClockSize(clock);
|
||||
expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
|
||||
// Byte-order sort: uppercase letters come before lowercase
|
||||
// So 'Alpha' < 'Beta' < 'Gamma' < 'alpha' < 'beta' < 'gamma' < 'zzz_*'
|
||||
// The first MAX entries in sorted order should be kept, pruning the last ones
|
||||
// All mixed-case IDs should be kept since they sort before 'zzz_*'
|
||||
for (const id of ids) {
|
||||
expect(result[id]).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('should produce identical results across repeated invocations with mixed-case IDs', () => {
|
||||
const clock: Record<string, number> = {};
|
||||
// Create a mix of uppercase, lowercase, and numeric IDs all with same counter
|
||||
const mixedIds = [
|
||||
'clientA',
|
||||
'ClientA',
|
||||
'CLIENTA',
|
||||
'client_a',
|
||||
'clientB',
|
||||
'ClientB',
|
||||
'CLIENTB',
|
||||
'client_b',
|
||||
];
|
||||
for (const id of mixedIds) {
|
||||
clock[id] = 5;
|
||||
}
|
||||
// Fill to exceed MAX
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) {
|
||||
clock[`z_filler_${i.toString().padStart(3, '0')}`] = 5;
|
||||
}
|
||||
|
||||
// Run multiple times — must always produce the same result
|
||||
const results = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
results.push(limitVectorClockSize(clock));
|
||||
}
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i]).toEqual(results[0]);
|
||||
}
|
||||
expect(Object.keys(results[0]).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('limitVectorClockSize with multiple preserveClientIds', () => {
|
||||
it('should preserve two low-counter IDs when both are in preserveClientIds', () => {
|
||||
// Simulates the server's getOpsSinceWithSeq passing [requestingClient, snapshotAuthor]
|
||||
const clock: Record<string, number> = {
|
||||
requestingClient: 1, // Low counter
|
||||
snapshotAuthor: 2, // Low counter
|
||||
};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 3; i++) {
|
||||
clock[`high_${i}`] = 100 + i; // All higher counters
|
||||
}
|
||||
|
||||
const result = limitVectorClockSize(clock, ['requestingClient', 'snapshotAuthor']);
|
||||
expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
// Both low-counter preserved IDs must be kept
|
||||
expect(result['requestingClient']).toBe(1);
|
||||
expect(result['snapshotAuthor']).toBe(2);
|
||||
// Remaining slots filled with highest-counter entries
|
||||
const nonPreservedEntries = Object.entries(result).filter(
|
||||
([k]) => k !== 'requestingClient' && k !== 'snapshotAuthor',
|
||||
);
|
||||
expect(nonPreservedEntries.length).toBe(MAX_VECTOR_CLOCK_SIZE - 2);
|
||||
// The kept non-preserved entries should be the highest-counter ones
|
||||
for (const [, value] of nonPreservedEntries) {
|
||||
// Should be the top (MAX-2) entries from high_0..high_(MAX+2)
|
||||
expect(value).toBeGreaterThanOrEqual(100 + 5); // lowest 5 pruned
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle overlapping preserveClientIds (same ID listed twice)', () => {
|
||||
const clock: Record<string, number> = { clientA: 1 };
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 2; i++) {
|
||||
clock[`high_${i}`] = 100 + i;
|
||||
}
|
||||
|
||||
// clientA listed twice — Set deduplicates, so only one slot consumed
|
||||
const result = limitVectorClockSize(clock, ['clientA', 'clientA']);
|
||||
expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
expect(result['clientA']).toBe(1);
|
||||
// Should have MAX-1 non-preserved entries (not MAX-2)
|
||||
const nonPreserved = Object.keys(result).filter((k) => k !== 'clientA');
|
||||
expect(nonPreserved.length).toBe(MAX_VECTOR_CLOCK_SIZE - 1);
|
||||
});
|
||||
|
||||
it('should handle one preserveClientId present and one missing from clock', () => {
|
||||
// Simulates getOpsSinceWithSeq when excludeClient is not in the aggregated snapshot clock
|
||||
const clock: Record<string, number> = { snapshotAuthor: 5 };
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 2; i++) {
|
||||
clock[`client_${i}`] = 50 + i;
|
||||
}
|
||||
|
||||
const result = limitVectorClockSize(clock, ['missingClient', 'snapshotAuthor']);
|
||||
expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
// Missing client is silently ignored (no crash, no placeholder)
|
||||
expect(result['missingClient']).toBeUndefined();
|
||||
// Present preserved client is kept
|
||||
expect(result['snapshotAuthor']).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle all preserveClientIds missing from clock', () => {
|
||||
const clock: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 3; i++) {
|
||||
clock[`client_${i}`] = i + 1;
|
||||
}
|
||||
|
||||
const result = limitVectorClockSize(clock, ['ghost_a', 'ghost_b']);
|
||||
expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
expect(result['ghost_a']).toBeUndefined();
|
||||
expect(result['ghost_b']).toBeUndefined();
|
||||
// All MAX slots filled by highest-counter entries
|
||||
for (let i = 3; i < MAX_VECTOR_CLOCK_SIZE + 3; i++) {
|
||||
expect(result[`client_${i}`]).toBe(i + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshot vector clock aggregation scenario', () => {
|
||||
it('should correctly aggregate multiple op clocks and prune with preserved IDs', () => {
|
||||
// Simulates the server's getOpsSinceWithSeq aggregation logic:
|
||||
// 1. Multiple ops from different clients are aggregated (max per key)
|
||||
// 2. Result is pruned with requesting client + snapshot author preserved
|
||||
|
||||
// Simulate 25 operations from different clients
|
||||
const opClocks: Record<string, number>[] = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
opClocks.push({ [`client_${i}`]: i + 1, shared_client: i + 10 });
|
||||
}
|
||||
|
||||
// Aggregate: take max for each key (mimics the server loop)
|
||||
const aggregated: Record<string, number> = {};
|
||||
for (const clock of opClocks) {
|
||||
for (const [clientId, value] of Object.entries(clock)) {
|
||||
aggregated[clientId] = Math.max(aggregated[clientId] ?? 0, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have 26 entries: client_0..client_24 + shared_client
|
||||
expect(Object.keys(aggregated).length).toBe(26);
|
||||
expect(aggregated['shared_client']).toBe(34); // 24 + 10
|
||||
|
||||
// Prune with two preserved IDs (requesting client + snapshot author)
|
||||
const pruned = limitVectorClockSize(aggregated, ['client_0', 'client_24']);
|
||||
expect(Object.keys(pruned).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
|
||||
// Both preserved IDs must survive
|
||||
expect(pruned['client_0']).toBe(1); // Lowest counter, preserved
|
||||
expect(pruned['client_24']).toBe(25); // High counter, preserved
|
||||
|
||||
// shared_client has value 34 (highest), should be kept
|
||||
expect(pruned['shared_client']).toBe(34);
|
||||
});
|
||||
|
||||
it('pruned snapshot clock still produces correct comparison with post-snapshot ops', () => {
|
||||
// After the server prunes the snapshot clock, a fresh client receives it.
|
||||
// The client's new ops (merged with snapshot clock) must still be GREATER_THAN
|
||||
// the snapshot clock itself.
|
||||
|
||||
// Simulate aggregated snapshot clock from many clients
|
||||
const snapshotClock: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 5; i++) {
|
||||
snapshotClock[`old_client_${i}`] = 50 + i;
|
||||
}
|
||||
|
||||
// Server prunes, preserving the requesting client (freshClient)
|
||||
const prunedSnapshot = limitVectorClockSize(snapshotClock, ['old_client_0']);
|
||||
expect(Object.keys(prunedSnapshot).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
|
||||
// Fresh client merges pruned snapshot + increments own counter
|
||||
const freshClock: Record<string, number> = { ...prunedSnapshot, freshClient: 1 };
|
||||
|
||||
// freshClock has all of prunedSnapshot's keys at same values PLUS freshClient
|
||||
expect(compareVectorClocks(freshClock, prunedSnapshot)).toBe('GREATER_THAN');
|
||||
|
||||
// Even after the fresh client's clock is pruned for storage, the pruned entries
|
||||
// (old_client_0..4) were also not in prunedSnapshot, so comparison is still safe
|
||||
const freshPruned = limitVectorClockSize(freshClock, ['freshClient']);
|
||||
expect(Object.keys(freshPruned).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
expect(freshPruned['freshClient']).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeVectorClocks', () => {
|
||||
it('should merge by taking max of each key', () => {
|
||||
const a = { x: 3, y: 1 };
|
||||
const b = { x: 1, y: 5 };
|
||||
expect(mergeVectorClocks(a, b)).toEqual({ x: 3, y: 5 });
|
||||
});
|
||||
|
||||
it('should include keys only present in one clock', () => {
|
||||
const a = { x: 3 };
|
||||
const b = { y: 5 };
|
||||
expect(mergeVectorClocks(a, b)).toEqual({ x: 3, y: 5 });
|
||||
});
|
||||
|
||||
it('should handle one empty clock', () => {
|
||||
const a = { x: 3, y: 1 };
|
||||
expect(mergeVectorClocks(a, {})).toEqual({ x: 3, y: 1 });
|
||||
expect(mergeVectorClocks({}, a)).toEqual({ x: 3, y: 1 });
|
||||
});
|
||||
|
||||
it('should handle both empty clocks', () => {
|
||||
expect(mergeVectorClocks({}, {})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
@ -42,6 +42,7 @@
|
|||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@simplewebauthn/types": "^12.0.0",
|
||||
"@sp/shared-schema": "*",
|
||||
"@sp/sync-core": "*",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"fastify": "^5.8.5",
|
||||
|
|
|
|||
|
|
@ -5,12 +5,15 @@ import {
|
|||
SUPER_SYNC_OP_TYPES,
|
||||
SUPER_SYNC_SNAPSHOT_OP_TYPES,
|
||||
type SuperSyncOpType,
|
||||
} from '@sp/shared-schema';
|
||||
|
||||
import {
|
||||
VectorClock,
|
||||
VectorClockComparison,
|
||||
compareVectorClocks,
|
||||
limitVectorClockSize,
|
||||
MAX_VECTOR_CLOCK_SIZE,
|
||||
} from '@sp/shared-schema';
|
||||
} from '@sp/sync-core';
|
||||
|
||||
const FULL_STATE_OP_TYPES: ReadonlySet<string> = new Set(SUPER_SYNC_SNAPSHOT_OP_TYPES);
|
||||
|
||||
|
|
@ -86,7 +89,7 @@ export const OP_TYPES = SUPER_SYNC_OP_TYPES;
|
|||
|
||||
export type OpType = SuperSyncOpType;
|
||||
|
||||
// VectorClock, VectorClockComparison, and compareVectorClocks are imported from @sp/shared-schema
|
||||
// VectorClock, VectorClockComparison, and compareVectorClocks are imported from @sp/sync-core
|
||||
// and re-exported above. This ensures client and server use identical implementations.
|
||||
|
||||
/**
|
||||
|
|
@ -158,7 +161,7 @@ export const sanitizeVectorClock = (
|
|||
return { valid: true, clock: sanitized };
|
||||
};
|
||||
|
||||
// compareVectorClocks is imported from @sp/shared-schema (see imports at top of file)
|
||||
// compareVectorClocks is imported from @sp/sync-core (see imports at top of file)
|
||||
|
||||
export interface Operation {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export {
|
|||
limitVectorClockSize,
|
||||
MAX_VECTOR_CLOCK_SIZE,
|
||||
} from './vector-clock';
|
||||
export type { VectorClockComparison } from './vector-clock';
|
||||
export { VectorClockComparison } from './vector-clock';
|
||||
|
||||
// Full-state import clean-slate vector-clock decisions.
|
||||
export { classifyOpAgainstSyncImport } from './sync-import-filter';
|
||||
|
|
|
|||
|
|
@ -27,7 +27,15 @@ export interface VectorClock {
|
|||
/**
|
||||
* Result of comparing two vector clocks.
|
||||
*/
|
||||
export type VectorClockComparison = 'EQUAL' | 'LESS_THAN' | 'GREATER_THAN' | 'CONCURRENT';
|
||||
export const VectorClockComparison = {
|
||||
EQUAL: 'EQUAL',
|
||||
LESS_THAN: 'LESS_THAN',
|
||||
GREATER_THAN: 'GREATER_THAN',
|
||||
CONCURRENT: 'CONCURRENT',
|
||||
} as const;
|
||||
|
||||
export type VectorClockComparison =
|
||||
(typeof VectorClockComparison)[keyof typeof VectorClockComparison];
|
||||
|
||||
/**
|
||||
* Maximum number of entries in a vector clock.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import {
|
|||
mergeVectorClocks as sharedMergeVectorClocks,
|
||||
limitVectorClockSize as sharedLimitVectorClockSize,
|
||||
MAX_VECTOR_CLOCK_SIZE,
|
||||
} from '@sp/shared-schema';
|
||||
VectorClockComparison,
|
||||
} from '@sp/sync-core';
|
||||
import { MIN_CLIENT_ID_LENGTH } from '../../op-log/core/operation-log.const';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
|
|
@ -22,7 +23,7 @@ import { Subject } from 'rxjs';
|
|||
* - GREATER_THAN: B happened before A
|
||||
* - CONCURRENT: Neither happened before the other (true conflict)
|
||||
*
|
||||
* IMPORTANT: Core comparison logic is shared with the server via @sp/shared-schema.
|
||||
* IMPORTANT: Core comparison logic is shared with the server via @sp/sync-core.
|
||||
* This file wraps the shared logic with null handling for client-side use.
|
||||
*/
|
||||
|
||||
|
|
@ -33,15 +34,10 @@ import { Subject } from 'rxjs';
|
|||
export type VectorClock = SharedVectorClock;
|
||||
|
||||
/**
|
||||
* Result of comparing two vector clocks.
|
||||
* Uses enum for client-side ergonomics, values match shared string literals.
|
||||
* Result of comparing two vector clocks (EQUAL | LESS_THAN | GREATER_THAN | CONCURRENT).
|
||||
* Re-exported from @sp/sync-core so client and server share one definition.
|
||||
*/
|
||||
export enum VectorClockComparison {
|
||||
EQUAL = 'EQUAL',
|
||||
LESS_THAN = 'LESS_THAN',
|
||||
GREATER_THAN = 'GREATER_THAN',
|
||||
CONCURRENT = 'CONCURRENT',
|
||||
}
|
||||
export { VectorClockComparison } from '@sp/sync-core';
|
||||
|
||||
/**
|
||||
* Initialize a new vector clock for a client
|
||||
|
|
@ -125,7 +121,7 @@ export const sanitizeVectorClock = (clock: any): VectorClock => {
|
|||
/**
|
||||
* Compare two vector clocks to determine their relationship.
|
||||
*
|
||||
* Uses the shared implementation from @sp/shared-schema to ensure
|
||||
* Uses the shared implementation from @sp/sync-core to ensure
|
||||
* client and server produce identical results. This wrapper adds
|
||||
* null/undefined handling for client-side convenience.
|
||||
*
|
||||
|
|
@ -140,7 +136,7 @@ export const compareVectorClocks = (
|
|||
// Coerce null/undefined to {} and delegate to shared implementation.
|
||||
// This ensures parity: shared treats missing keys as 0, so {} and {a:0} are EQUAL.
|
||||
const result = sharedCompareVectorClocks(a ?? {}, b ?? {});
|
||||
return result as VectorClockComparison;
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -201,7 +197,7 @@ export const incrementVectorClock = (
|
|||
/**
|
||||
* Merge two vector clocks by taking the maximum value for each component.
|
||||
*
|
||||
* Uses the shared implementation from @sp/shared-schema to ensure
|
||||
* Uses the shared implementation from @sp/sync-core to ensure
|
||||
* client and server produce identical results. This wrapper adds
|
||||
* null/undefined handling for client-side convenience.
|
||||
*
|
||||
|
|
@ -296,7 +292,7 @@ export const vectorClockPruned$ = new Subject<{
|
|||
|
||||
/**
|
||||
* Limits the size of a vector clock by keeping only the most active clients.
|
||||
* Wraps the shared implementation from @sp/shared-schema with client-side logging.
|
||||
* Wraps the shared implementation from @sp/sync-core with client-side logging.
|
||||
*
|
||||
* @param clock The vector clock to limit
|
||||
* @param currentClientId The current client's ID (always preserved)
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ export const DOWNLOAD_PAGE_SIZE = 500;
|
|||
*
|
||||
* Re-exported from @sp/shared-schema to ensure client and server use the same value.
|
||||
*/
|
||||
export { MAX_VECTOR_CLOCK_SIZE } from '@sp/shared-schema';
|
||||
export { MAX_VECTOR_CLOCK_SIZE } from '@sp/sync-core';
|
||||
|
||||
/**
|
||||
* Minimum length for client IDs in vector clocks.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { StateSnapshotService } from '../backup/state-snapshot.service';
|
|||
import { VectorClockService } from '../sync/vector-clock.service';
|
||||
import {
|
||||
COMPACTION_RETENTION_MS,
|
||||
MAX_VECTOR_CLOCK_SIZE,
|
||||
SLOW_COMPACTION_THRESHOLD_MS,
|
||||
} from '../core/operation-log.const';
|
||||
import { CURRENT_SCHEMA_VERSION } from './schema-migration.service';
|
||||
|
|
@ -13,7 +14,6 @@ import { OperationLogEntry } from '../core/operation.types';
|
|||
import { OpLog } from '../../core/log';
|
||||
import { MODEL_CONFIGS } from '../model/model-config';
|
||||
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
|
||||
import { MAX_VECTOR_CLOCK_SIZE } from '@sp/shared-schema';
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,10 @@ import {
|
|||
import { loadAllData } from '../../root-store/meta/load-all-data.action';
|
||||
import { bulkApplyHydrationOperations } from '../apply/bulk-hydration.action';
|
||||
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
|
||||
import { MAX_CONFLICT_RETRY_ATTEMPTS } from '../core/operation-log.const';
|
||||
import { MAX_VECTOR_CLOCK_SIZE } from '@sp/shared-schema';
|
||||
import {
|
||||
MAX_CONFLICT_RETRY_ATTEMPTS,
|
||||
MAX_VECTOR_CLOCK_SIZE,
|
||||
} from '../core/operation-log.const';
|
||||
import { IndexedDBOpenError } from '../core/errors/indexed-db-open.error';
|
||||
import { IDB_OPEN_ERROR_RELOAD_KEY } from './operation-log-hydrator.service';
|
||||
import { SyncProviderId } from '../sync-providers/provider.const';
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import {
|
|||
import { VectorClockService } from '../sync/vector-clock.service';
|
||||
import { StateSnapshotService } from '../backup/state-snapshot.service';
|
||||
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
|
||||
import { MAX_VECTOR_CLOCK_SIZE } from '@sp/shared-schema';
|
||||
import { ValidateStateService } from '../validation/validate-state.service';
|
||||
import { MAX_VECTOR_CLOCK_SIZE } from '@sp/sync-core';
|
||||
|
||||
// Meaningful state (contains a task) so saveCurrentStateAsSnapshot proceeds past
|
||||
// the empty-state guard (#7892). Tests that care only about clock pruning /
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ import { uuidv7 } from '../../util/uuid-v7';
|
|||
import {
|
||||
compareVectorClocks,
|
||||
incrementVectorClock,
|
||||
limitVectorClockSize,
|
||||
VectorClockComparison,
|
||||
} from '../../core/util/vector-clock';
|
||||
import { limitVectorClockSize, MAX_VECTOR_CLOCK_SIZE } from '@sp/shared-schema';
|
||||
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
|
||||
import { OP_LOG_DB_ADAPTER_FACTORY } from './op-log-db-adapter.token';
|
||||
import { OpLogDbAdapter } from './op-log-db-adapter';
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
IDB_OPEN_RETRIES,
|
||||
IDB_OPEN_RETRIES_NON_LOCK,
|
||||
IDB_OPEN_RETRY_BASE_DELAY_MS,
|
||||
MAX_VECTOR_CLOCK_SIZE,
|
||||
} from '../core/operation-log.const';
|
||||
import { IndexedDBOpenError } from '../core/errors/indexed-db-open.error';
|
||||
import { FULL_STATE_OPS_META_KEY, SINGLETON_KEY, STORE_NAMES } from './db-keys.const';
|
||||
|
|
@ -2913,7 +2914,7 @@ describe('OperationLogStoreService', () => {
|
|||
const newOpClock = incrementVectorClock(storedClock, 'localClient');
|
||||
|
||||
// Simulate server-side pruning (server prunes to MAX_VECTOR_CLOCK_SIZE)
|
||||
const prunedClock = limitVectorClockSize(newOpClock, ['localClient']);
|
||||
const prunedClock = limitVectorClockSize(newOpClock, 'localClient');
|
||||
|
||||
// importClient must survive pruning
|
||||
expect(prunedClock['importClient']).toBe(1);
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ import { SimulatedClient } from './helpers/simulated-client.helper';
|
|||
import { createMinimalTaskPayload } from './helpers/operation-factory.helper';
|
||||
import {
|
||||
compareVectorClocks,
|
||||
limitVectorClockSize,
|
||||
VectorClockComparison,
|
||||
} from '../../../core/util/vector-clock';
|
||||
import { MAX_VECTOR_CLOCK_SIZE } from '../../core/operation-log.const';
|
||||
import { limitVectorClockSize } from '@sp/shared-schema';
|
||||
import { uuidv7 } from '../../../util/uuid-v7';
|
||||
import { SyncImportFilterService } from '../../sync/sync-import-filter.service';
|
||||
|
||||
|
|
@ -462,7 +462,10 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
);
|
||||
// With minimal clocks, B's op is CONCURRENT (missing old entries)
|
||||
// but should still be kept by the import-client-counter exception
|
||||
expect([VectorClockComparison.GREATER_THAN, VectorClockComparison.CONCURRENT])
|
||||
expect([
|
||||
VectorClockComparison.GREATER_THAN,
|
||||
VectorClockComparison.CONCURRENT,
|
||||
] as VectorClockComparison[])
|
||||
.withContext(
|
||||
`Post-import op from B should be GREATER_THAN or CONCURRENT, got ${comparisonB}`,
|
||||
)
|
||||
|
|
@ -515,7 +518,7 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
|
||||
// Server prunes using limitVectorClockSize, preserving the uploading
|
||||
// client's ID (this is what the real server does).
|
||||
const prunedClock = limitVectorClockSize(newClientClock, [newClientId]);
|
||||
const prunedClock = limitVectorClockSize(newClientClock, newClientId);
|
||||
|
||||
// The pruned clock keeps the new client but drops the lowest import entry
|
||||
expect(Object.keys(prunedClock).length).toBe(MAX_VECTOR_CLOCK_SIZE);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue