Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions src/lib/sync/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,58 +220,62 @@
await initPersistence()
}

/** Apply user-chosen conflict resolutions to the Yjs sync document. */
export function applyConflictResolution(
resolutions: Map<string, 'local' | 'remote'>,
conflicts: import('./merge').FieldConflict[],
localEntities: Entity[],
localClaims: Claim[],
): void {
const sync = getSyncDoc()
const doc = getDoc()

const localEntityMap = new Map(localEntities.map((e) => [e.id, e]))
const localClaimMap = new Map(localClaims.map((c) => [c.id, c]))

const entityUpdates = collectUpdates<Entity>(
conflicts.filter((c) => c.entityType === 'entity'),
resolutions,
localEntities,
localEntityMap,
)
const claimUpdates = collectUpdates<Claim>(
conflicts.filter((c) => c.entityType === 'claim'),
resolutions,
localClaims,
localClaimMap,
)

doc.transact(() => {
for (const [id, updates] of entityUpdates) {
const local = localEntities.find((e) => e.id === id)
const local = localEntityMap.get(id)
if (local) {
sync.entities.set(id, entityToYMap({ ...local, ...updates, updatedAt: new Date().toISOString() }))
}
}
for (const [id, updates] of claimUpdates) {
const local = localClaims.find((c) => c.id === id)
const local = localClaimMap.get(id)
if (local) {
sync.claims.set(id, claimToYMap({ ...local, ...updates }))

Check notice on line 257 in src/lib/sync/bridge.ts

View check run for this annotation

nexus-check / GitNexus

Changed symbol: applyConflictResolution

`applyConflictResolution` (Function) is directly changed by this PR. PR-wide downstream impact: 1 direct dependent(s), 0 indirect. See the check summary for the impacted-file breakdown.
}
}
}, ORIGIN_OUTBOUND)
}

function collectUpdates<T extends Entity | Claim>(
/** Collect field-level conflict update overrides into a Map indexed by ID. */
const collectUpdates = <T extends Entity | Claim>(
conflicts: import('./merge').FieldConflict[],
resolutions: Map<string, 'local' | 'remote'>,
locals: T[],
): Map<string, T> {
localMap: Map<string, T>,
): Map<string, T> => {
const updates = new Map<string, T>()
for (const conflict of conflicts) {
const key = `${conflict.entityId}:${conflict.field}`
if ((resolutions.get(key) ?? conflict.winner) === 'local') continue
const local = locals.find((item) => item.id === conflict.entityId)
const local = localMap.get(conflict.entityId)
if (!local) continue
const existing = updates.get(conflict.entityId) ?? { ...local }
const field = conflict.field as keyof T
if (field in existing) {
Object.assign(existing, { [field]: conflict.remoteValue })

Check notice on line 278 in src/lib/sync/bridge.ts

View check run for this annotation

nexus-check / GitNexus

Changed symbol: collectUpdates

`collectUpdates` (Function) is directly changed by this PR. PR-wide downstream impact: 1 direct dependent(s), 0 indirect. See the check summary for the impacted-file breakdown.
updates.set(conflict.entityId, existing)
}
}
Expand Down
118 changes: 118 additions & 0 deletions src/lib/sync/merge.perf.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest'
import { mergeEntities, mergeClaims } from './merge'
import type { Entity, Claim } from '@/lib/studio/types'

function generateEntities(count: number): { local: Entity[]; remote: Entity[] } {
const local: Entity[] = []
const remote: Entity[] = []

for (let i = 0; i < count; i++) {
const id = `entity-${i}`
const timestamp = new Date(1700000000000 + i * 1000).toISOString()
const remoteTimestamp = new Date(1700000000000 + i * 1000 + 500).toISOString()

const links = [
{ targetId: `entity-${(i + 1) % count}`, relation: 'relates_to' },
{ targetId: `entity-${(i + 2) % count}`, relation: 'depends_on' },
]

local.push({
id,
name: `Entity ${i}`,
type: 'concept',
description: `Description ${i}`,
content: `Content for entity ${i}`,
sourceUrl: `https://example.com/${i}`,
tags: ['tag1', 'tag2', `tag-${i}`],
createdAt: timestamp,
updatedAt: timestamp,
links,
})

remote.push({
id,
name: `Entity Remote ${i}`,
type: 'concept',
description: `Description ${i}`,
content: `Content for entity ${i}`,
sourceUrl: `https://example.com/${i}`,
tags: ['tag1', 'tag2', `tag-remote-${i}`],
createdAt: timestamp,
updatedAt: remoteTimestamp,
links: [
...links,
{ targetId: `entity-${(i + 3) % count}`, relation: 'mentions' },
],
})
}

return { local, remote }
}

function generateClaims(count: number): { local: Claim[]; remote: Claim[] } {
const local: Claim[] = []
const remote: Claim[] = []

for (let i = 0; i < count; i++) {
const id = `claim-${i}`
const timestamp = new Date(1700000000000 + i * 1000).toISOString()
const remoteTimestamp = new Date(1700000000000 + i * 1000 + 500).toISOString()

local.push({
id,
entityId: `entity-${i % 100}`,
statement: `Statement ${i}`,
evidence: `Evidence ${i}`,
confidence: 0.8,
verification: 'verified',
source: `Source ${i}`,
createdAt: timestamp,
updatedAt: timestamp,
version: 1,
editHistory: [
{ statement: `Initial statement ${i}`, editedAt: timestamp },
],
})

remote.push({
id,
entityId: `entity-${i % 100}`,
statement: `Remote Statement ${i}`,
evidence: `Evidence ${i}`,
confidence: 0.85,
verification: 'verified',
source: `Source ${i}`,
createdAt: timestamp,
updatedAt: remoteTimestamp,
version: 2,
editHistory: [
{ statement: `Initial statement ${i}`, editedAt: timestamp },
{ statement: `Remote Statement ${i}`, editedAt: remoteTimestamp },
],
})
}

return { local, remote }
}

describe('merge performance', () => {
it('merges 1000 entities fast', () => {
const { local, remote } = generateEntities(1000)
const start = performance.now()
const result = mergeEntities(local, remote)
const duration = performance.now() - start

console.error(`mergeEntities (1000 entities): ${duration.toFixed(2)}ms`)
expect(result.merged).toHaveLength(1000)
})

it('merges 1000 claims fast', () => {
const { local, remote } = generateClaims(1000)
const start = performance.now()
const result = mergeClaims(local, remote)
const duration = performance.now() - start

console.error(`mergeClaims (1000 claims): ${duration.toFixed(2)}ms`)
Comment thread
d-oit marked this conversation as resolved.
expect(result.merged).toHaveLength(1000)
})
})

Check warning on line 118 in src/lib/sync/merge.perf.test.ts

View check run for this annotation

nexus-check / GitNexus

Performance tests never enforce a runtime bound

Both tests are named as fast-performance checks, but they only print the measured duration and assert the merged item count. Any arbitrarily slow regression still passes, so these tests do not detect the performance regressions their names imply.
Loading