Skip to content

SyncVault is an offline-first data synchronization layer for apps built with Swift. It automatically queues API requests when the device is offline and syncs them with exponential backoff when the connection returns.

License

Notifications You must be signed in to change notification settings

brownboycodes/SyncVault-Swift

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

25 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

SyncVault-Swift

Swift 5.9 Platforms SPM Compatible CocoaPods Compatible Zero Dependencies MIT License

SyncVault-Swift is a pure, native Swift offline-first "Store-and-Forward" sync engine for Apple platforms. Zero external dependencies. Drop it in.

✨ Features

  • πŸ”Œ Offline-First β€” Requests automatically queue when offline
  • πŸ”„ Auto-Sync β€” Queued requests sync when connectivity returns
  • ⏱️ Exponential Backoff β€” Smart retry logic with configurable delays
  • πŸ”’ Thread-Safe β€” Built with Swift Actors for safe concurrent access
  • πŸ“‘ Observable β€” AsyncStream events for SwiftUI integration
  • πŸ“± Multi-Platform β€” iOS, macOS, watchOS, tvOS support
  • 🚫 Zero Dependencies β€” Pure native Swift with URLSession & NWPathMonitor
  • πŸ”§ Auto Headers β€” Automatically sets Content-Type, Content-Length, and Accept headers

πŸ“¦ Installation

Swift Package Manager (Recommended)

Add SyncVault to your Package.swift:

dependencies: [
    .package(url: "https://github.com/brownboycodes/SyncVault-Swift.git", from: "1.0.0")
]

Or in Xcode:

  1. File β†’ Add Package Dependencies...
  2. Enter: https://github.com/brownboycodes/SyncVault-Swift.git
  3. Select version and add to your target

CocoaPods

Add to your Podfile:

pod 'SyncVault', '~> 1.0'

Then run:

pod install

πŸš€ Quick Start

import SyncVault

// Make a request (automatically queues if offline)
do {
    let response = try await SyncVault.shared.request(
        url: URL(string: "https://api.example.com/data")!,
        method: "POST",
        body: jsonData,
        headers: ["Authorization": "Bearer \(token)"]
    )
    // Handle response
} catch SyncVaultError.queued(let id) {
    // Request was queued for later sync
    print("Queued for sync: \(id)")
}

πŸ“– Usage

Basic Requests

// GET request
let (data, response) = try await SyncVault.shared.get(
    URL(string: "https://api.example.com/users")!
)

// POST request with body
// Use JSONSerialization for maximum server compatibility
let bodyDict: [String: Any] = ["name": "John", "email": "john@example.com"]
let bodyData = try JSONSerialization.data(withJSONObject: bodyDict)

let (data, response) = try await SyncVault.shared.post(
    URL(string: "https://api.example.com/users")!,
    body: bodyData
    // Headers are automatically set:
    // - Content-Type: application/json; charset=utf-8
    // - Content-Length: <body size>
    // - Accept: application/json
)

// PUT, PATCH, DELETE also available
try await SyncVault.shared.put(url, body: data)
try await SyncVault.shared.patch(url, body: data)
try await SyncVault.shared.delete(url)

Using Codable Models

struct User: Codable {
    let name: String
    let email: String
}

// Option 1: Using JSONSerialization (recommended for maximum server compatibility)
let userDict: [String: Any] = ["name": user.name, "email": user.email]
let bodyData = try JSONSerialization.data(withJSONObject: userDict)
let (data, response) = try await SyncVault.shared.post(
    URL(string: "https://api.example.com/users")!,
    body: bodyData
)

// Option 2: Using JSONEncoder to encode your model, then pass Data to post()
let bodyData = try JSONEncoder().encode(user)
let (data, response) = try await SyncVault.shared.post(
    URL(string: "https://api.example.com/users")!,
    body: bodyData
)

Observing Sync Events

// Listen for all sync events
Task {
    for await event in SyncVault.shared.events {
        switch event {
        case .queued(let action):
            print("Queued: \(action)")
        case .success(let action, _, let statusCode):
            print("Success (\(statusCode)): \(action)")
        case .retrying(let action, _, let attempt):
            print("Retrying (\(attempt)): \(action)")
        case .deadLetter(let action, _):
            print("Failed permanently: \(action)")
        case .connectivityChanged(let isOnline):
            print("Online: \(isOnline)")
        case .processingStarted(let count):
            print("Processing \(count) items...")
        case .processingCompleted(let success, let failed):
            print("Done: \(success) success, \(failed) failed")
        default:
            break
        }
    }
}

SwiftUI Integration

import SwiftUI
import SyncVault

struct ContentView: View {
    @State private var pendingCount = 0
    @State private var isOnline = true
    @State private var items: [Item] = []

    var body: some View {
        VStack {
            // Status indicator
            HStack {
                Circle()
                    .fill(isOnline ? .green : .red)
                    .frame(width: 10, height: 10)
                Text(isOnline ? "Online" : "Offline")

                Spacer()

                if pendingCount > 0 {
                    Text("\(pendingCount) pending")
                        .foregroundColor(.orange)
                }
            }
            .padding()

            // Action buttons
            Button("Create Item") {
                Task {
                    await createItem()
                }
            }

            Button("Sync Now") {
                Task { await SyncVault.shared.sync() }
            }
        }
        .task {
            // Observe events
            for await event in SyncVault.shared.events {
                if case .connectivityChanged(let online) = event {
                    isOnline = online
                }
                pendingCount = await SyncVault.shared.pendingCount()
            }
        }
    }

    func createItem() async {
        do {
            // Create JSON body using JSONSerialization
            let itemDict: [String: Any] = [
                "title": "New Item",
                "description": "Created from iOS"
            ]
            let bodyData = try JSONSerialization.data(withJSONObject: itemDict)

            let (data, _) = try await SyncVault.shared.post(
                URL(string: "https://api.example.com/items")!,
                body: bodyData
            )

            // Parse response
            if let data = data {
                let item = try JSONDecoder().decode(Item.self, from: data)
                items.append(item)
            }
        } catch SyncVaultError.queued(let id) {
            print("Queued for later: \(id)")
        } catch {
            print("Error: \(error)")
        }
    }
}

Manual Queue Management

// Get pending count
let count = await SyncVault.shared.pendingCount()

// Get all pending actions
let actions = await SyncVault.shared.pendingActions()

// Remove a specific action
await SyncVault.shared.removeAction(actionId)

// Clear entire queue
await SyncVault.shared.clearQueue()

// Manually trigger sync
let result = await SyncVault.shared.sync()
print("Processed: \(result.succeeded) success, \(result.failed) failed")

Auto-Sync Control

// Disable auto-sync when coming online
SyncVault.shared.disableAutoSync()

// Re-enable auto-sync
SyncVault.shared.enableAutoSync()

// Check current online status
if SyncVault.shared.isOnline {
    // Device is connected
}

Custom Configuration

let config = SyncClient.Configuration(
    workerConfiguration: QueueWorker.Configuration(
        baseRetryDelay: 2.0,      // Initial retry delay
        maxRetryDelay: 60.0,      // Maximum retry delay
        requestTimeout: 30.0,     // Request timeout
        stopOnError: true         // Stop queue processing on error
    ),
    autoSyncOnConnectivity: true, // Auto-sync when coming online
    logLevel: .debug              // Log level
)

let client = try SyncClient(configuration: config)

Custom Storage

// Implement StorageAdapter protocol for custom backends
class CoreDataStorageAdapter: StorageAdapter {
    func save(_ action: SyncAction) async throws { /* ... */ }
    func getAll() async throws -> [SyncAction] { /* ... */ }
    func remove(_ id: UUID) async throws { /* ... */ }
    func update(_ action: SyncAction) async throws { /* ... */ }
    func count() async throws -> Int { /* ... */ }
    func clear() async throws { /* ... */ }
}

let client = SyncClient(storage: CoreDataStorageAdapter())

Debugging Failed Requests

SyncVault automatically logs failed requests with a cURL command for easy debugging:

// When a request fails, check the console for output like:
// ============================================================
// πŸ”§ FAILED REQUEST - Copy this cURL command to debug:
// ------------------------------------------------------------
// curl -v \
//   -X POST \
//   -H 'Accept: application/json' \
//   -H 'Content-Length: 42' \
//   -H 'Content-Type: application/json; charset=utf-8' \
//   -d '{"title":"Test","body":"Hello"}' \
//   'https://api.example.com/posts'
// ============================================================

// You can also generate the cURL command manually:
let action = SyncAction(
    url: URL(string: "https://api.example.com/posts")!,
    httpMethod: "POST",
    body: jsonData
)
print(action.toCurlCommand())

🍎 watchOS Support

SyncVault is perfect for Apple Watch apps that frequently go offline:

// In your watchOS app
struct WorkoutView: View {
    var body: some View {
        Button("Save Workout") {
            Task {
                do {
                    // Create workout data
                    let workoutDict: [String: Any] = [
                        "type": "Running",
                        "duration": 1800,
                        "calories": 250
                    ]
                    let bodyData = try JSONSerialization.data(withJSONObject: workoutDict)

                    try await SyncVault.shared.post(
                        URL(string: "https://api.example.com/workouts")!,
                        body: bodyData
                    )
                } catch SyncVaultError.queued {
                    // Workout saved locally, will sync when watch reconnects
                }
            }
        }
    }
}

πŸ—οΈ Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     SyncClient                          β”‚
β”‚  (Public API - Singleton Entry Point)                   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                      β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β–Ό             β–Ό             β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Network   β”‚  β”‚  Queue     β”‚  β”‚   Storage    β”‚
β”‚ Monitor   β”‚  β”‚  Worker    β”‚  β”‚   Adapter    β”‚
β”‚           β”‚  β”‚  (Actor)   β”‚  β”‚  (Protocol)  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
     β”‚              β”‚                  β”‚
     β”‚              β”‚                  β–Ό
     β”‚              β”‚         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚              β”‚         β”‚    File      β”‚
     β”‚              β”‚         β”‚   Storage    β”‚
     β”‚              β”‚         β”‚  (Default)   β”‚
     β”‚              β”‚         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
     β”‚              β”‚
     β–Ό              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   URLSession                            β”‚
β”‚             (Native Apple API)                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Flow

  1. Capture: User calls SyncVault.request(...)
  2. Check: If online β†’ execute via URLSession
  3. Queue: If offline β†’ persist to local storage (JSON files)
  4. Sync: When connectivity returns β†’ process queue (FIFO) with exponential backoff

Automatic Headers

SyncVault automatically sets the following headers for requests with a body:

Header Value Purpose
Content-Type application/json; charset=utf-8 Tells server the body format
Content-Length <body size> Required by some servers
Accept application/json Requests JSON response

You can override these by passing custom headers.

πŸ“ Project Structure

Sources/SyncVault/
β”œβ”€β”€ Core/
β”‚   β”œβ”€β”€ SyncClient.swift       # Main entry point (singleton)
β”‚   β”œβ”€β”€ QueueWorker.swift      # Background processing (actor)
β”‚   └── NetworkMonitor.swift   # NWPathMonitor wrapper
β”œβ”€β”€ Storage/
β”‚   β”œβ”€β”€ StorageAdapter.swift   # Protocol for storage backends
β”‚   └── FileStorageAdapter.swift # Default JSON file storage
β”œβ”€β”€ Models/
β”‚   β”œβ”€β”€ SyncAction.swift       # Request model (Codable)
β”‚   β”œβ”€β”€ SyncEvent.swift        # Observable events
β”‚   └── SyncVaultError.swift   # Custom errors
β”œβ”€β”€ Utils/
β”‚   └── Logger.swift           # Logging utility
└── SyncVault.swift            # Public exports

πŸ”§ Requirements

Platform Minimum Version
iOS 15.0
macOS 12.0
watchOS 8.0
tvOS 15.0
Swift 5.9+

πŸ› Troubleshooting

500 Internal Server Error

If you're getting 500 errors, check:

  1. Use JSONSerialization for creating request bodies (most compatible):

    let dict: [String: Any] = ["key": "value"]
    let body = try JSONSerialization.data(withJSONObject: dict)
  2. Verify JSON is valid before sending:

    guard QueueWorker.isValidJSON(bodyData) else {
        print("Invalid JSON!")
        return
    }
  3. Check the cURL output in console logs to debug manually

Requests Not Syncing

  1. Ensure the device has network connectivity: SyncVault.shared.isOnline
  2. Check if auto-sync is enabled
  3. Manually trigger sync: await SyncVault.shared.sync()

πŸ‘₯ Authors

πŸ“„ License

SyncVault-Swift is available under the MIT License. See the LICENSE file for details.

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/AmazingFeature)
  3. Commit your changes (git commit -m 'Add some AmazingFeature')
  4. Push to the branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request

πŸ’‘ Why SyncVault?

"Don't bloat your app with Alamofire just for offline sync. SyncVault-Swift is pure, native Swift. Zero dependencies. Drop it in."

  • For iOS/macOS devs: Native API integration, no third-party risk
  • For watchOS devs: Critical for health/fitness apps that operate offline
  • For enterprise teams: Minimal dependencies = minimal audit surface
  • For everyone: Modern Swift Concurrency (async/await, Actors)

Made with ❀️ for the Apple Developer Community

About

SyncVault is an offline-first data synchronization layer for apps built with Swift. It automatically queues API requests when the device is offline and syncs them with exponential backoff when the connection returns.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •