Skip to main content

Overview

CV Builder uses a three-way merge algorithm to intelligently resolve conflicts when the same resume is edited on multiple devices. The algorithm compares three versions:
  1. Local - Changes made in the current browser session
  2. Server - Changes made on other devices (synced to Firebase)
  3. Base - Common ancestor (last synced state)
All merge logic is implemented in lib/backend/cvThreeWayMerge.ts.

Three-Way Merge Concept

Why Three-Way Merge?

A simple two-way merge (comparing only local and server) cannot distinguish between:
  • “User A deleted this field” vs “User A never had this field”
  • “User B modified this” vs “This was already that way”
By comparing against a base version (the common ancestor), we can determine:
  • What changed locally since last sync
  • What changed on the server since last sync
  • Whether both sides made conflicting changes

Example Scenario

// Base (last synced state)
const base = {
  personalInfo: { fullName: "John Doe" }
};

// Local (current browser)
const local = {
  personalInfo: { fullName: "John Smith" } // Changed locally
};

// Server (from other device)
const server = {
  personalInfo: { fullName: "John Doe" } // Unchanged
};

// Result: Local change wins (server unchanged)
// Merged: { fullName: "John Smith" }

Main Merge Function

lib/backend/cvThreeWayMerge.ts
export function threeWayMerge(
  local: CVData,
  server: CVData,
  base: CVData | undefined
): MergeResult {
  // If no base, we can't do three-way merge
  if (!base) {
    const hasDifferences = !deepEqual(local, server);
    return {
      merged: server,
      conflicts: hasDifferences ? [{
        section: "sectionOrder",
        localValue: null,
        serverValue: null,
        baseValue: null,
        description: "No sync history available - cannot detect individual conflicts. Server version will be used.",
      }] : [],
      hasConflicts: hasDifferences,
    };
  }

  const allConflicts: FieldConflict[] = [];

  // Merge each section...
  const { merged: personalInfo, conflicts: personalConflicts } = mergePersonalInfo(
    local.personalInfo,
    server.personalInfo,
    base.personalInfo
  );
  allConflicts.push(...personalConflicts);

  // Merge arrays...
  const { merged: experience, conflicts: expConflicts } = mergeArrayById(
    local.experience,
    server.experience,
    base.experience,
    "experience"
  );
  allConflicts.push(...expConflicts);

  // ... (similar for other sections)

  return {
    merged: /* combined result */,
    conflicts: allConflicts,
    hasConflicts: allConflicts.length > 0,
  };
}
Returns:
merged
CVData
required
Merged resume data with conflicts resolved to default (usually server)
conflicts
FieldConflict[]
required
Array of detected conflicts for user review
hasConflicts
boolean
required
Whether any conflicts were detected

Conflict Detection Rules

For each field, the algorithm checks:
const localChanged = !deepEqual(localValue, baseValue);
const serverChanged = !deepEqual(serverValue, baseValue);

if (localChanged && serverChanged && !deepEqual(localValue, serverValue)) {
  // CONFLICT: Both changed to different values
  conflicts.push({ ... });
} else if (localChanged) {
  // Only local changed - use local value
  merged.field = localValue;
} else {
  // Only server changed or neither changed - use server value
  merged.field = serverValue;
}

Change Detection Outcomes

Local vs BaseServer vs BaseLocal vs ServerResult
SameSameSameNo conflict - use either
ChangedSameDifferentNo conflict - use local
SameChangedDifferentNo conflict - use server
ChangedChangedSameNo conflict - use either
ChangedChangedDifferentCONFLICT

Merging Personal Info

Personal info is merged field-by-field:
lib/backend/cvThreeWayMerge.ts
function mergePersonalInfo(
  local: PersonalInfo,
  server: PersonalInfo,
  base: PersonalInfo
): { merged: PersonalInfo; conflicts: FieldConflict[] } {
  const conflicts: FieldConflict[] = [];
  const merged: PersonalInfo = { ...server };

  const fields: (keyof PersonalInfo)[] = [
    "fullName",
    "email",
    "phone",
    "address",
    "jobTitle",
    "summary",
    "website",
    "linkedin",
    "github",
    "profileImageUrl",
  ];

  for (const field of fields) {
    const localVal = local[field];
    const serverVal = server[field];
    const baseVal = base[field];

    const localChanged = hasChanged(localVal, baseVal);
    const serverChanged = hasChanged(serverVal, baseVal);

    if (localChanged && serverChanged && !deepEqual(localVal, serverVal)) {
      // Conflict: both changed to different values
      conflicts.push({
        section: "personal",
        field,
        localValue: localVal,
        serverValue: serverVal,
        baseValue: baseVal,
        description: getFieldDisplayName("personal", field),
      });
      // Default: prefer server (safer - already in cloud)
    } else if (localChanged) {
      // Only local changed
      merged[field] = localVal;
    }
    // else: only server changed or neither changed, keep server value
  }

  return { merged, conflicts };
}
Example conflict:
// Base
base.personalInfo.email = "[email protected]"

// Local changed
local.personalInfo.email = "[email protected]"

// Server also changed
server.personalInfo.email = "[email protected]"

// Result: Conflict detected
conflict = {
  section: "personal",
  field: "email",
  localValue: "[email protected]",
  serverValue: "[email protected]",
  baseValue: "[email protected]",
  description: "Personal Info: Email"
}

Merging Arrays

Arrays (experience, education, projects, etc.) are merged by ID:
lib/backend/cvThreeWayMerge.ts
function mergeArrayById<T extends { id: string }>(
  local: T[],
  server: T[],
  base: T[],
  sectionName: SectionId
): { merged: T[]; conflicts: FieldConflict[] } {
  const conflicts: FieldConflict[] = [];
  const result = new Map<string, T>();

  // Create maps for quick lookup
  const localMap = new Map(local.map((item) => [item.id, item]));
  const serverMap = new Map(server.map((item) => [item.id, item]));
  const baseMap = new Map(base.map((item) => [item.id, item]));

  // All IDs across all three versions
  const allIds = new Set([...localMap.keys(), ...serverMap.keys(), ...baseMap.keys()]);

  for (const id of allIds) {
    const localItem = localMap.get(id);
    const serverItem = serverMap.get(id);
    const baseItem = baseMap.get(id);

    // Handle different cases...
  }

  // Preserve order from server, append new local items at end
  const orderedResult: T[] = [];
  for (const item of server) {
    if (result.has(item.id)) {
      orderedResult.push(result.get(item.id)!);
      result.delete(item.id);
    }
  }
  // Add remaining items (new from local)
  for (const item of result.values()) {
    orderedResult.push(item);
  }

  return { merged: orderedResult, conflicts };
}

Array Merge Cases

Case 1: New Item Added Locally

// Base: []
// Local: [{ id: "1", ... }]  <- New item
// Server: []
// Result: Include new local item

Case 2: New Item Added on Server

// Base: []
// Local: []
// Server: [{ id: "1", ... }]  <- New item
// Result: Include new server item

Case 3: Item Deleted Locally

// Base: [{ id: "1", company: "A" }]
// Local: []  <- Deleted
// Server: [{ id: "1", company: "A" }]  <- Unchanged
// Result: Keep deleted (local intent)

// BUT if server modified:
// Server: [{ id: "1", company: "B" }]  <- Modified
// Result: CONFLICT (local deleted, server modified)

Case 4: Item Deleted on Server

// Base: [{ id: "1", company: "A" }]
// Local: [{ id: "1", company: "A" }]  <- Unchanged
// Server: []  <- Deleted
// Result: Keep deleted (server intent)

// BUT if local modified:
// Local: [{ id: "1", company: "B" }]  <- Modified
// Result: CONFLICT (server deleted, local modified)

Case 5: Item Modified on Both Sides

// Base: [{ id: "1", company: "A", role: "X" }]
// Local: [{ id: "1", company: "B", role: "X" }]  <- company changed
// Server: [{ id: "1", company: "C", role: "X" }]  <- company changed differently
// Result: CONFLICT (both modified same item)

Conflict Types

Field Conflict Structure

lib/types.ts
export interface FieldConflict {
  section: SectionId | 'sectionOrder' | 'hiddenSections' | 'template' | 'references';
  field?: string;  // For nested fields or item IDs
  localValue: unknown;
  serverValue: unknown;
  baseValue: unknown;
  description?: string;
}

Conflict Examples

Personal info field conflict:
{
  section: "personal",
  field: "email",
  localValue: "[email protected]",
  serverValue: "[email protected]",
  baseValue: "[email protected]",
  description: "Personal Info: Email"
}
Array item modified on both sides:
{
  section: "experience",
  field: "exp_123",  // Item ID
  localValue: { id: "exp_123", company: "Local Corp", ... },
  serverValue: { id: "exp_123", company: "Server Inc", ... },
  baseValue: { id: "exp_123", company: "Old Company", ... },
  description: "Experience item"
}
Array item deletion conflict:
{
  section: "education",
  field: "edu_456",
  localValue: null,  // Deleted locally
  serverValue: { id: "edu_456", school: "University", ... },  // Modified on server
  baseValue: { id: "edu_456", school: "College", ... },
  description: "Education item"
}

Applying Conflict Resolutions

After user reviews conflicts and makes decisions:
lib/backend/cvThreeWayMerge.ts
export function applyResolutions(
  merged: CVData,
  conflicts: FieldConflict[],
  resolutions: Record<string, "local" | "server">
): CVData {
  const result = { ...merged };

  for (const conflict of conflicts) {
    const key = conflict.field ? `${conflict.section}.${conflict.field}` : conflict.section;
    const resolution = resolutions[key];

    if (resolution === "local") {
      // Personal info field
      if (conflict.section === "personal" && conflict.field) {
        result.personalInfo[conflict.field] = conflict.localValue;
      }
      // Array item operations
      else if (isArraySection(conflict.section) && conflict.field) {
        const arr = result[conflict.section];
        const idx = arr.findIndex((item) => item.id === conflict.field);
        if (idx !== -1) {
          if (conflict.localValue === null) {
            // Local deleted the item
            arr.splice(idx, 1);
          } else {
            // Local modified the item
            arr[idx] = conflict.localValue;
          }
        }
      }
      // Simple fields
      else if (conflict.section === "references") {
        result.references = conflict.localValue;
      }
      // ... other cases
    }
    // resolution === "server" - already in merged by default
  }

  return result;
}
Usage:
import { threeWayMerge, applyResolutions } from '@/lib/backend/cvThreeWayMerge';

// 1. Perform merge
const mergeResult = threeWayMerge(localData, serverData, baseData);

if (mergeResult.hasConflicts) {
  // 2. Show conflict UI to user
  const resolutions = await showConflictDialog(mergeResult.conflicts);
  
  // 3. Apply user's choices
  const finalData = applyResolutions(
    mergeResult.merged,
    mergeResult.conflicts,
    resolutions
  );
  
  // 4. Save final data
  await saveToFirebase(finalData);
} else {
  // No conflicts - save merged data
  await saveToFirebase(mergeResult.merged);
}

Complete Sync Flow

import { loadDraft, loadBase, updateBaseAfterSync, clearDraft } from '@/lib/backend/cvDraftStorage';
import { threeWayMerge, applyResolutions } from '@/lib/backend/cvThreeWayMerge';

async function syncToCloud() {
  // 1. Load local data
  const draft = loadDraft();
  if (!draft) {
    console.log('No draft to sync');
    return;
  }
  const localData = draft.data;

  // 2. Load base (last synced state)
  const base = loadBase();
  const baseData = base?.data;

  // 3. Load server data
  const serverDoc = await getDoc(doc(db, 'resumes', userId));
  const serverData = serverDoc.exists() ? serverDoc.data().data : null;

  if (!serverData) {
    // No server data - first sync
    await setDoc(doc(db, 'resumes', userId), {
      data: localData,
      updatedAt: new Date(),
      createdAt: new Date(),
    });
    updateBaseAfterSync(localData);
    clearDraft();
    return;
  }

  // 4. Perform three-way merge
  const mergeResult = threeWayMerge(localData, serverData, baseData);

  // 5. Handle conflicts
  let finalData = mergeResult.merged;
  
  if (mergeResult.hasConflicts) {
    // Show conflict resolution UI
    const resolutions = await showConflictDialog(mergeResult.conflicts);
    finalData = applyResolutions(mergeResult.merged, mergeResult.conflicts, resolutions);
  }

  // 6. Save merged data to server
  await setDoc(doc(db, 'resumes', userId), {
    data: finalData,
    updatedAt: new Date(),
  });

  // 7. Update base and clear draft
  updateBaseAfterSync(finalData);
  clearDraft();
}

Conflict Resolution UI

Example UI component for resolving conflicts:
interface ConflictDialogProps {
  conflicts: FieldConflict[];
  onResolve: (resolutions: Record<string, "local" | "server">) => void;
}

function ConflictDialog({ conflicts, onResolve }: ConflictDialogProps) {
  const [resolutions, setResolutions] = useState<Record<string, "local" | "server">>({});

  const handleResolve = (conflict: FieldConflict, choice: "local" | "server") => {
    const key = conflict.field ? `${conflict.section}.${conflict.field}` : conflict.section;
    setResolutions(prev => ({ ...prev, [key]: choice }));
  };

  return (
    <Dialog>
      <DialogTitle>Resolve Conflicts</DialogTitle>
      <DialogContent>
        {conflicts.map((conflict, index) => (
          <ConflictItem
            key={index}
            conflict={conflict}
            onResolve={(choice) => handleResolve(conflict, choice)}
          />
        ))}
      </DialogContent>
      <DialogActions>
        <Button onClick={() => onResolve(resolutions)}>
          Apply Resolutions
        </Button>
      </DialogActions>
    </Dialog>
  );
}

Best Practices

Update base after every successful sync:
await saveToFirebase(data);
updateBaseAfterSync(data); // Critical!
When in doubt, prefer server values (they’re already backed up):
const merged: PersonalInfo = { ...server }; // Start with server
// Then selectively apply local changes
Make conflicts understandable to users:
description: getFieldDisplayName("personal", "email")
// "Personal Info: Email" (user-friendly)
First sync won’t have base version:
if (!base) {
  // Can't do three-way merge - prefer server
  return { merged: server, conflicts: [], hasConflicts: false };
}

Testing Merge Logic

import { threeWayMerge } from '@/lib/backend/cvThreeWayMerge';
import { initialCVData } from '@/lib/types';

// Test: Local change only
const base = { ...initialCVData, personalInfo: { ...initialCVData.personalInfo, fullName: "John" } };
const local = { ...base, personalInfo: { ...base.personalInfo, fullName: "Jane" } };
const server = base;

const result = threeWayMerge(local, server, base);
console.log(result.merged.personalInfo.fullName); // "Jane" (local change)
console.log(result.hasConflicts); // false

// Test: Conflicting changes
const local2 = { ...base, personalInfo: { ...base.personalInfo, fullName: "Jane" } };
const server2 = { ...base, personalInfo: { ...base.personalInfo, fullName: "Bob" } };

const result2 = threeWayMerge(local2, server2, base);
console.log(result2.hasConflicts); // true
console.log(result2.conflicts[0].localValue); // "Jane"
console.log(result2.conflicts[0].serverValue); // "Bob"

Next Steps

Local Storage

Learn about draft and base storage

Data Structure

Review complete type definitions

Build docs developers (and LLMs) love