Skip to main content
The mobile package provides delta synchronization helpers for building offline-first mobile applications. Sync tokens enable efficient incremental updates without re-downloading entire datasets.

Quick Start

import "github.com/go-mizu/mizu/mobile"

app.Get("/api/sync", func(c *mizu.Ctx) error {
    req := mobile.ParseSyncRequest(c)

    var delta mobile.Delta[Item]
    if req.IsInitial() {
        // Full sync - first time or forced
        delta.Created = db.GetAllItems()
    } else {
        // Delta sync - only changes since last sync
        since := req.Since()
        delta.Created = db.GetCreatedSince(since)
        delta.Updated = db.GetUpdatedSince(since)
        delta.Deleted = db.GetDeletedSince(since)
    }

    token := mobile.NewSyncToken(time.Now())
    return c.JSON(200, mobile.NewSyncDelta(delta, token, false))
})

Sync Tokens

Sync tokens are opaque strings that encode a timestamp. Clients store the token and send it back on subsequent syncs to receive only changes since that point.

Creating Tokens

// Create from current time
token := mobile.NewSyncToken(time.Now())

// Create from specific time
token := mobile.NewSyncToken(lastModifiedAt)

Token Methods

token := mobile.NewSyncToken(time.Now())

// Convert to string for response
token.String()  // "YWJjMTIz..."

// Check if empty
token.IsEmpty() // false

// Extract timestamp
t := token.Time() // time.Time

Response Header

// Set sync token in response header
mobile.SetSyncToken(c, token)
// Sets: X-Sync-Token: abc123...

Sync Request

Parse sync parameters from the client:
type SyncRequest struct {
    Token     SyncToken // Last sync token (empty for initial)
    Resources []string  // Specific resources to sync
    FullSync  bool      // Force complete resync
    Limit     int       // Maximum items to return
}

Parsing Requests

req := mobile.ParseSyncRequest(c)

// Parsed from:
// Header: X-Sync-Token
// Query: ?sync_token=abc123
// Query: ?full_sync=true
// Query: ?resources=users,posts
// Query: ?limit=100

Request Methods

req := mobile.ParseSyncRequest(c)

// Check if initial sync
req.IsInitial() // true if no token or full_sync=true

// Get timestamp to sync from
since := req.Since() // time.Time (zero for initial)

// Check specific resources
for _, r := range req.Resources {
    // Sync only requested resources
}

Delta Response

The Delta type represents changes since the last sync:
type Delta[T any] struct {
    Created []T      `json:"created,omitempty"`
    Updated []T      `json:"updated,omitempty"`
    Deleted []string `json:"deleted,omitempty"` // IDs of deleted items
}

Creating Delta Responses

delta := mobile.Delta[Item]{
    Created: newItems,
    Updated: modifiedItems,
    Deleted: deletedIDs,
}

// Check if empty
delta.IsEmpty() // true if no changes

// Count total changes
delta.Count() // len(created) + len(updated) + len(deleted)

Wrapped Response

type SyncDelta[T any] struct {
    Delta[T]
    SyncToken SyncToken `json:"sync_token"`
    HasMore   bool      `json:"has_more"`
    FullSync  bool      `json:"full_sync,omitempty"`
}
// Create sync delta
response := mobile.NewSyncDelta(delta, token, hasMore)

// For full sync, mark it
response := mobile.NewFullSyncDelta(delta, token, hasMore)

Complete Example

Database Schema

CREATE TABLE items (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    data JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMPTZ  -- Soft delete
);

CREATE INDEX idx_items_updated ON items(updated_at);
CREATE INDEX idx_items_deleted ON items(deleted_at);

Model

type Item struct {
    ID        string    `json:"id" db:"id"`
    Name      string    `json:"name" db:"name"`
    Data      any       `json:"data" db:"data"`
    CreatedAt time.Time `json:"created_at" db:"created_at"`
    UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

Repository

type ItemRepo struct {
    db *sql.DB
}

func (r *ItemRepo) GetAll() ([]Item, error) {
    return r.query(`
        SELECT * FROM items
        WHERE deleted_at IS NULL
        ORDER BY created_at
    `)
}

func (r *ItemRepo) GetCreatedSince(since time.Time) ([]Item, error) {
    return r.query(`
        SELECT * FROM items
        WHERE deleted_at IS NULL
          AND created_at > $1
        ORDER BY created_at
    `, since)
}

func (r *ItemRepo) GetUpdatedSince(since time.Time) ([]Item, error) {
    return r.query(`
        SELECT * FROM items
        WHERE deleted_at IS NULL
          AND updated_at > $1
          AND created_at <= $1  -- Exclude newly created
        ORDER BY updated_at
    `, since)
}

func (r *ItemRepo) GetDeletedSince(since time.Time) ([]string, error) {
    rows, err := r.db.Query(`
        SELECT id FROM items
        WHERE deleted_at IS NOT NULL
          AND deleted_at > $1
    `, since)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var ids []string
    for rows.Next() {
        var id string
        rows.Scan(&id)
        ids = append(ids, id)
    }
    return ids, nil
}

Handler

func syncItems(c *mizu.Ctx) error {
    req := mobile.ParseSyncRequest(c)
    repo := &ItemRepo{db: db}

    var delta mobile.Delta[Item]
    var err error

    if req.IsInitial() {
        // Full sync
        delta.Created, err = repo.GetAll()
        if err != nil {
            return mobile.SendError(c, 500, mobile.NewError(
                mobile.ErrInternal, "Failed to fetch items"))
        }
    } else {
        since := req.Since()

        delta.Created, err = repo.GetCreatedSince(since)
        if err != nil {
            return mobile.SendError(c, 500, mobile.NewError(
                mobile.ErrInternal, "Failed to fetch created items"))
        }

        delta.Updated, err = repo.GetUpdatedSince(since)
        if err != nil {
            return mobile.SendError(c, 500, mobile.NewError(
                mobile.ErrInternal, "Failed to fetch updated items"))
        }

        delta.Deleted, err = repo.GetDeletedSince(since)
        if err != nil {
            return mobile.SendError(c, 500, mobile.NewError(
                mobile.ErrInternal, "Failed to fetch deleted items"))
        }
    }

    // Create new token with current time
    token := mobile.NewSyncToken(time.Now())

    // Check if there are more items (pagination)
    hasMore := delta.Count() >= req.Limit

    // Set token in header too
    mobile.SetSyncToken(c, token)

    if req.IsInitial() {
        return c.JSON(200, mobile.NewFullSyncDelta(delta, token, hasMore))
    }
    return c.JSON(200, mobile.NewSyncDelta(delta, token, hasMore))
}

Conflict Resolution

Handle conflicts when the same item is modified on client and server:
type Conflict[T any] struct {
    ID         string    `json:"id"`
    ClientData T         `json:"client_data"`
    ServerData T         `json:"server_data"`
    ClientTime time.Time `json:"client_time"`
    ServerTime time.Time `json:"server_time"`
}

type ConflictResolution string

const (
    ResolutionServerWins ConflictResolution = "server_wins"
    ResolutionClientWins ConflictResolution = "client_wins"
    ResolutionLatestWins ConflictResolution = "latest_wins"
    ResolutionManual     ConflictResolution = "manual"
)

Detecting Conflicts

type UpdateRequest struct {
    ID         string    `json:"id"`
    Data       Item      `json:"data"`
    ClientTime time.Time `json:"client_time"`
}

func updateItem(c *mizu.Ctx) error {
    var req UpdateRequest
    if err := c.BodyJSON(&req); err != nil {
        return mobile.SendError(c, 400, mobile.NewError(
            mobile.ErrInvalidRequest, "Invalid request"))
    }

    // Get current server version
    serverItem, err := repo.GetByID(req.ID)
    if err != nil {
        return mobile.SendError(c, 404, mobile.NewError(
            mobile.ErrNotFound, "Item not found"))
    }

    // Check for conflict
    if serverItem.UpdatedAt.After(req.ClientTime) {
        return c.JSON(409, mobile.Conflict[Item]{
            ID:         req.ID,
            ClientData: req.Data,
            ServerData: serverItem,
            ClientTime: req.ClientTime,
            ServerTime: serverItem.UpdatedAt,
        })
    }

    // No conflict, apply update
    updated, err := repo.Update(req.ID, req.Data)
    if err != nil {
        return mobile.SendError(c, 500, mobile.NewError(
            mobile.ErrInternal, "Failed to update"))
    }

    return c.JSON(200, updated)
}

Resolving Conflicts

type ResolveConflictRequest struct {
    ID         string             `json:"id"`
    Resolution ConflictResolution `json:"resolution"`
    MergedData *Item              `json:"merged_data,omitempty"`
}

func resolveConflict(c *mizu.Ctx) error {
    var req ResolveConflictRequest
    c.BodyJSON(&req)

    serverItem, _ := repo.GetByID(req.ID)
    clientItem := getClientItem(c) // From request or session

    var finalData Item

    switch req.Resolution {
    case mobile.ResolutionServerWins:
        finalData = serverItem
    case mobile.ResolutionClientWins:
        finalData = clientItem
    case mobile.ResolutionLatestWins:
        if clientItem.UpdatedAt.After(serverItem.UpdatedAt) {
            finalData = clientItem
        } else {
            finalData = serverItem
        }
    case mobile.ResolutionManual:
        if req.MergedData == nil {
            return mobile.SendError(c, 400, mobile.NewError(
                mobile.ErrInvalidRequest, "Merged data required"))
        }
        finalData = *req.MergedData
    }

    updated, _ := repo.Update(req.ID, finalData)
    return c.JSON(200, updated)
}

Multi-Resource Sync

Sync multiple resource types in a single request:
type SyncResponse struct {
    Users     mobile.SyncDelta[User]    `json:"users,omitempty"`
    Posts     mobile.SyncDelta[Post]    `json:"posts,omitempty"`
    Comments  mobile.SyncDelta[Comment] `json:"comments,omitempty"`
    SyncToken mobile.SyncToken          `json:"sync_token"`
}

func syncAll(c *mizu.Ctx) error {
    req := mobile.ParseSyncRequest(c)
    since := req.Since()

    response := SyncResponse{}

    // Check which resources to sync
    syncUsers := len(req.Resources) == 0 || contains(req.Resources, "users")
    syncPosts := len(req.Resources) == 0 || contains(req.Resources, "posts")
    syncComments := len(req.Resources) == 0 || contains(req.Resources, "comments")

    if syncUsers {
        response.Users = getUsersDelta(since, req.IsInitial())
    }
    if syncPosts {
        response.Posts = getPostsDelta(since, req.IsInitial())
    }
    if syncComments {
        response.Comments = getCommentsDelta(since, req.IsInitial())
    }

    response.SyncToken = mobile.NewSyncToken(time.Now())

    return c.JSON(200, response)
}

Client Implementation

iOS (Swift)

class SyncManager {
    private var syncToken: String? {
        get { UserDefaults.standard.string(forKey: "sync_token") }
        set { UserDefaults.standard.set(newValue, forKey: "sync_token") }
    }

    func sync() async throws {
        var endpoint = "/api/sync"
        if let token = syncToken {
            endpoint += "?sync_token=\(token)"
        }

        let response: SyncResponse<Item> = try await api.get(endpoint)

        // Apply changes to local database
        try await db.write { realm in
            // Add created items
            for item in response.created {
                realm.add(item, update: .modified)
            }

            // Update modified items
            for item in response.updated {
                realm.add(item, update: .modified)
            }

            // Delete removed items
            for id in response.deleted {
                if let item = realm.object(ofType: Item.self, forPrimaryKey: id) {
                    realm.delete(item)
                }
            }
        }

        // Store new sync token
        syncToken = response.syncToken
    }
}

Android (Kotlin)

class SyncManager(
    private val api: ApiService,
    private val dao: ItemDao,
    private val prefs: SharedPreferences
) {
    private var syncToken: String?
        get() = prefs.getString("sync_token", null)
        set(value) = prefs.edit().putString("sync_token", value).apply()

    suspend fun sync() {
        val response = api.sync(syncToken)

        dao.withTransaction {
            // Insert created items
            dao.insertAll(response.created)

            // Update modified items
            response.updated.forEach { dao.update(it) }

            // Delete removed items
            response.deleted.forEach { dao.deleteById(it) }
        }

        // Store new sync token
        syncToken = response.syncToken
    }
}

Flutter (Dart)

class SyncManager {
  String? _syncToken;

  Future<void> sync() async {
    final params = _syncToken != null
        ? {'sync_token': _syncToken}
        : <String, String>{};

    final response = await api.get('/api/sync', queryParameters: params);
    final syncResponse = SyncResponse.fromJson(response.data);

    // Apply changes to local database
    await db.transaction(() async {
      // Add created items
      for (final item in syncResponse.created) {
        await db.items.insertOnConflictUpdate(item);
      }

      // Update modified items
      for (final item in syncResponse.updated) {
        await db.items.update(item);
      }

      // Delete removed items
      for (final id in syncResponse.deleted) {
        await db.items.deleteWhere((t) => t.id.equals(id));
      }
    });

    // Store new sync token
    _syncToken = syncResponse.syncToken;
    await prefs.setString('sync_token', _syncToken!);
  }
}

Best Practices

Use Soft Deletes

Never hard-delete records. Use a deleted_at timestamp so clients can receive deletion events.

Index Updated Timestamps

Ensure updated_at and deleted_at columns are indexed for efficient delta queries.

Handle Large Initial Syncs

For large datasets, paginate initial syncs:
if req.IsInitial() && delta.Count() > maxInitialSync {
    hasMore = true
    delta.Created = delta.Created[:maxInitialSync]
}

Implement Retry Logic

Clients should retry failed syncs with exponential backoff.

Store Sync State Atomically

Store the sync token only after successfully applying all changes locally.

Next Steps

Push Notifications

Cross-platform push token management

Deep Links

Universal and App Links

API Reference

Complete API documentation