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:
Mohamed Abdeltawab 2026-06-17 17:21:26 +03:00 committed by GitHub
parent 898436e3da
commit c2bf7b26a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 54 additions and 624 deletions

View file

@ -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

View file

@ -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
View file

@ -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",

View file

@ -18,7 +18,6 @@
}
},
"dependencies": {
"@sp/sync-core": "*",
"zod": "^4.4.3"
},
"scripts": {

View file

@ -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';

View file

@ -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';

View file

@ -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({});
});
});

View file

@ -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",

View file

@ -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;

View file

@ -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';

View file

@ -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.

View file

@ -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)

View file

@ -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.

View file

@ -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;

View file

@ -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';

View file

@ -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 /

View file

@ -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);

View file

@ -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);