From c2bf7b26a0e049f5f92ba3da8bcc5963bb2828ee Mon Sep 17 00:00:00 2001 From: Mohamed Abdeltawab <28455546+forge34@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:21:26 +0300 Subject: [PATCH] refactor(sync-core): drop shared-schema vector-clock compat re-export anda app enum (#8441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- docs/sync-and-op-log/package-boundaries.md | 17 +- docs/sync-and-op-log/vector-clocks.md | 8 +- package-lock.json | 2 +- packages/shared-schema/package.json | 1 - packages/shared-schema/src/index.ts | 9 - packages/shared-schema/src/vector-clock.ts | 9 - .../shared-schema/tests/vector-clock.spec.ts | 560 ------------------ packages/super-sync-server/package.json | 1 + .../super-sync-server/src/sync/sync.types.ts | 9 +- packages/sync-core/src/index.ts | 2 +- packages/sync-core/src/vector-clock.ts | 10 +- src/app/core/util/vector-clock.ts | 24 +- src/app/op-log/core/operation-log.const.ts | 2 +- .../operation-log-compaction.service.spec.ts | 2 +- .../operation-log-hydrator.service.spec.ts | 6 +- .../operation-log-snapshot.service.spec.ts | 2 +- .../operation-log-store.service.spec.ts | 5 +- ...tor-clock-import-reset.integration.spec.ts | 9 +- 18 files changed, 54 insertions(+), 624 deletions(-) delete mode 100644 packages/shared-schema/src/vector-clock.ts delete mode 100644 packages/shared-schema/tests/vector-clock.spec.ts diff --git a/docs/sync-and-op-log/package-boundaries.md b/docs/sync-and-op-log/package-boundaries.md index c733f79f95..8e89951be7 100644 --- a/docs/sync-and-op-log/package-boundaries.md +++ b/docs/sync-and-op-log/package-boundaries.md @@ -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 diff --git a/docs/sync-and-op-log/vector-clocks.md b/docs/sync-and-op-log/vector-clocks.md index 32ea655b7b..23b0599837 100644 --- a/docs/sync-and-op-log/vector-clocks.md +++ b/docs/sync-and-op-log/vector-clocks.md @@ -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` | diff --git a/package-lock.json b/package-lock.json index 43f2cea0f8..ad14ab6ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/shared-schema/package.json b/packages/shared-schema/package.json index 482db90938..47d71239c2 100644 --- a/packages/shared-schema/package.json +++ b/packages/shared-schema/package.json @@ -18,7 +18,6 @@ } }, "dependencies": { - "@sp/sync-core": "*", "zod": "^4.4.3" }, "scripts": { diff --git a/packages/shared-schema/src/index.ts b/packages/shared-schema/src/index.ts index abc0ddeee1..27a706441a 100644 --- a/packages/shared-schema/src/index.ts +++ b/packages/shared-schema/src/index.ts @@ -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'; diff --git a/packages/shared-schema/src/vector-clock.ts b/packages/shared-schema/src/vector-clock.ts deleted file mode 100644 index d1e5badd02..0000000000 --- a/packages/shared-schema/src/vector-clock.ts +++ /dev/null @@ -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'; diff --git a/packages/shared-schema/tests/vector-clock.spec.ts b/packages/shared-schema/tests/vector-clock.spec.ts deleted file mode 100644 index 6f832280ac..0000000000 --- a/packages/shared-schema/tests/vector-clock.spec.ts +++ /dev/null @@ -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 = {}; - const b: Record = {}; - 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 = {}; - const b: Record = {}; - 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 = {}; - const b: Record = {}; - 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 = {}; - const b: Record = {}; - 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 = {}; - const b: Record = {}; - // 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 = {}; - 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 = {}; - 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 = { 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 = {}; - 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 = {}; - 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 = {}; - 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 = {}; - 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 = {}; - 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 = { ...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 = {}; - const b: Record = {}; - - 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 = {}; - 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 = {}; - 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 = {}; - // 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 = {}; - // 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 = { - 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 = { 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 = { 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 = {}; - 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[] = []; - 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 = {}; - 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 = {}; - 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 = { ...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({}); - }); -}); diff --git a/packages/super-sync-server/package.json b/packages/super-sync-server/package.json index ab944cb17b..4a33e17673 100644 --- a/packages/super-sync-server/package.json +++ b/packages/super-sync-server/package.json @@ -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", diff --git a/packages/super-sync-server/src/sync/sync.types.ts b/packages/super-sync-server/src/sync/sync.types.ts index 87bcd6dc42..b743cfda23 100644 --- a/packages/super-sync-server/src/sync/sync.types.ts +++ b/packages/super-sync-server/src/sync/sync.types.ts @@ -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 = 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; diff --git a/packages/sync-core/src/index.ts b/packages/sync-core/src/index.ts index d9bfea85b7..3f6ed1ab7b 100644 --- a/packages/sync-core/src/index.ts +++ b/packages/sync-core/src/index.ts @@ -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'; diff --git a/packages/sync-core/src/vector-clock.ts b/packages/sync-core/src/vector-clock.ts index 41d1efbad6..909adf95f7 100644 --- a/packages/sync-core/src/vector-clock.ts +++ b/packages/sync-core/src/vector-clock.ts @@ -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. diff --git a/src/app/core/util/vector-clock.ts b/src/app/core/util/vector-clock.ts index a49cfabb9f..75c7097080 100644 --- a/src/app/core/util/vector-clock.ts +++ b/src/app/core/util/vector-clock.ts @@ -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) diff --git a/src/app/op-log/core/operation-log.const.ts b/src/app/op-log/core/operation-log.const.ts index ab46843f5c..46cf13f3ac 100644 --- a/src/app/op-log/core/operation-log.const.ts +++ b/src/app/op-log/core/operation-log.const.ts @@ -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. diff --git a/src/app/op-log/persistence/operation-log-compaction.service.spec.ts b/src/app/op-log/persistence/operation-log-compaction.service.spec.ts index 3a53e8ac23..0120037633 100644 --- a/src/app/op-log/persistence/operation-log-compaction.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-compaction.service.spec.ts @@ -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; diff --git a/src/app/op-log/persistence/operation-log-hydrator.service.spec.ts b/src/app/op-log/persistence/operation-log-hydrator.service.spec.ts index 25f6fa30a8..3ba60e317a 100644 --- a/src/app/op-log/persistence/operation-log-hydrator.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-hydrator.service.spec.ts @@ -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'; diff --git a/src/app/op-log/persistence/operation-log-snapshot.service.spec.ts b/src/app/op-log/persistence/operation-log-snapshot.service.spec.ts index c470a6421b..58514f53ed 100644 --- a/src/app/op-log/persistence/operation-log-snapshot.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-snapshot.service.spec.ts @@ -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 / diff --git a/src/app/op-log/persistence/operation-log-store.service.spec.ts b/src/app/op-log/persistence/operation-log-store.service.spec.ts index bb1bf1641d..3730045b6f 100644 --- a/src/app/op-log/persistence/operation-log-store.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-store.service.spec.ts @@ -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); diff --git a/src/app/op-log/testing/integration/vector-clock-import-reset.integration.spec.ts b/src/app/op-log/testing/integration/vector-clock-import-reset.integration.spec.ts index a167667fa4..adde32ba90 100644 --- a/src/app/op-log/testing/integration/vector-clock-import-reset.integration.spec.ts +++ b/src/app/op-log/testing/integration/vector-clock-import-reset.integration.spec.ts @@ -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);