Skip to content

tanmaycooks/MiniSync

Repository files navigation

MiniSync

A minimal, deterministic, offline-first synchronization library for Android applications using Room.

License

Overview

MiniSync solves a narrow but recurring problem: keeping local Room data in sync with a remote system without hiding conflicts, enforcing backend assumptions, or introducing opaque magic.

Design Philosophy

  1. Local-first correctness - Local writes always succeed, regardless of network state
  2. Explicit conflict handling - Conflicts are surfaced, persisted, and owned by the application
  3. Deterministic behavior - Given the same inputs, sync always produces the same results
  4. Minimal public surface - Only essential APIs are exposed
  5. Predictability over convenience - No hidden background behavior, retries, or auto-resolution

What MiniSync Does

  • Version-based change detection
  • Ordered, resumable sync execution
  • Explicit conflict detection and signaling
  • Transactional application of sync results

What MiniSync Does NOT Do

  • Networking implementations
  • Authentication
  • Retry or backoff strategies
  • Background scheduling (optional adapter only)
  • Automatic conflict resolution

Installation

Add the dependencies to your build.gradle.kts:

dependencies {
    // Core sync engine (pure Kotlin)
    implementation("com.minisync:minisync-core:0.1.0")
    
    // Room integration
    implementation("com.minisync:minisync-room:0.1.0")
    
    // Optional: Test utilities
    testImplementation("com.minisync:minisync-test-utils:0.1.0")
}

Quick Start

1. Define Your Syncable Entity

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey override val id: String,
    override val version: Long,
    override val syncState: SyncState,
    override val lastModifiedAt: Long? = null,
    val title: String,
    val content: String
) : SyncEntity<String>

2. Implement the Remote Source

class NotesRemoteSource(
    private val api: NotesApi
) : RemoteSource<String, Note> {
    
    override suspend fun push(entities: List<Note>): PushResult<String, Note> {
        val succeeded = mutableListOf<Note>()
        val failed = mutableListOf<PushFailure<String>>()
        
        for (note in entities) {
            try {
                val updated = api.upsertNote(note)
                succeeded.add(updated)
            } catch (e: Exception) {
                failed.add(PushFailure(note.id, e.message ?: "Unknown error", e))
            }
        }
        
        return PushResult(succeeded, failed)
    }
    
    override suspend fun pull(cursor: String?, limit: Int): PullResult<String, Note> {
        val response = api.getNotes(since = cursor, limit = limit)
        return PullResult(
            entities = response.notes,
            nextCursor = response.nextCursor,
            hasMore = response.hasMore
        )
    }
}

3. Implement the Local Source

class NotesLocalSource(
    private val notesDao: NotesDao
) : LocalSource<String, Note> {
    
    override suspend fun getDirtyEntities(): List<Note> {
        return notesDao.getDirtyNotes()
    }
    
    override suspend fun getById(id: String): Note? {
        return notesDao.getById(id)
    }
    
    // ... implement other methods
}

4. Create the Sync Engine

val syncEngine = SyncEngine(
    entityType = "notes",
    localSource = notesLocalSource,
    remoteSource = notesRemoteSource,
    cursorStorage = RoomCursorStorage(cursorDao),
    transactionManager = RoomTransactionManager(database),
    conflictDetector = ConflictDetector(
        payloadComparator = { a, b -> 
            a.title == b.title && a.content == b.content 
        }
    ),
    entityUpdater = { entity, state, version ->
        entity.copy(
            syncState = state,
            version = version ?: entity.version
        )
    }
)

5. Trigger Sync

// Full sync (push then pull)
val result = syncEngine.sync()

// Or separately
val pushResult = syncEngine.push()
val pullResult = syncEngine.pull()

// Handle results
if (result.hasConflicts) {
    // Show conflicts to user
    result.conflicts.forEach { conflict ->
        showConflictDialog(conflict)
    }
}

if (result.hasErrors) {
    // Log errors
    result.errors.forEach { error ->
        Log.e("Sync", "Error: ${error.message}", error.cause)
    }
}

6. Resolve Conflicts

// User chose to keep local version
syncEngine.resolveConflict(
    conflict,
    ConflictResolution.AcceptLocal(conflict.entityId)
)

// Or accept remote
syncEngine.resolveConflict(
    conflict,
    ConflictResolution.AcceptRemote(conflict.entityId)
)

// Or merge
syncEngine.resolveConflict(
    conflict,
    ConflictResolution.Merge(
        entityId = conflict.entityId,
        mergedEntity = createMergedNote(conflict.localEntity, conflict.remoteEntity)
    )
)

Sync State Machine

[Creation] --> LOCAL_ONLY
LOCAL_ONLY --> SYNCED (after successful push)
LOCAL_ONLY --> DIRTY (on modification)
DIRTY --> SYNCED (after successful push)
DIRTY --> CONFLICT (when remote has conflicting changes)
SYNCED --> DIRTY (on local modification)
SYNCED --> CONFLICT (when pull detects conflict)
CONFLICT --> SYNCED (after explicit resolution to accept remote)
CONFLICT --> DIRTY (after resolution to accept local or merge)

Module Structure

Module Purpose
minisync-core Pure Kotlin sync engine, versioning, and conflict logic
minisync-room Room-specific integration helpers
minisync-test-utils Deterministic fakes, clocks, and simulators

Testing

The minisync-test-utils module provides fakes for deterministic testing:

val localSource = FakeLocalSource<String, Note> { entity, state ->
    entity.copy(syncState = state)
}
val remoteSource = FakeRemoteSource<String, Note>()
val cursorStorage = FakeCursorStorage()
val transactionManager = FakeTransactionManager()
val clock = TestClock()

// Configure remote behavior
remoteSource.addRemoteEntity(remoteNote)
remoteSource.configurePushFailure { note ->
    if (note.id == "fail-id") PushFailure(note.id, "Simulated error")
    else null
}

License

Copyright 2024 Tanmay Yadav

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

About

MiniSync is an offline-first synchronization library for Android apps using Room.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages