SkillAgentSearch skills...

SwiftSync

JSON to SwiftData and back. SwiftData Sync.

Install / Use

/learn @3lvis/SwiftSync
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

SwiftSync

SwiftSync is a sync layer for SwiftData apps.

Define your models once, read from local SwiftData, and let SwiftSync handle the repetitive sync and export work in between.

Features

  • Convention-first JSON -> SwiftData mapping
  • Deterministic diffing for inserts, updates, and deletes
  • Automatic relationship syncing for nested objects and foreign keys
  • Export back into API-ready JSON
  • Reactive local reads for SwiftUI and UIKit

Quick Start

One-to-many

Relationship model example

import SwiftData
import SwiftSync

@Syncable
@Model
final class User {
  @Attribute(.unique) var id: Int
  var email: String?
  var createdAt: Date?
  var updatedAt: Date?
  var notes: [Note]

  init(id: Int, email: String? = nil, createdAt: Date? = nil, updatedAt: Date? = nil, notes: [Note] = []) {
    self.id = id
    self.email = email
    self.createdAt = createdAt
    self.updatedAt = updatedAt
    self.notes = notes
  }
}

@Syncable
@Model
final class Note {
  @Attribute(.unique) var id: Int
  var text: String
  var user: User?

  init(id: Int, text: String, user: User? = nil) {
    self.id = id
    self.text = text
    self.user = user
  }
}

JSON

[
  {
    "id": 6,
    "email": "shawn@ovium.com",
    "created_at": "2014-02-14T04:30:10+00:00",
    "updated_at": "2014-02-17T10:01:12+00:00",
    "notes": [
      {
        "id": 301,
        "text": "Call supplier before Friday"
      },
      {
        "id": 302,
        "text": "Prepare Q1 budget review"
      }
    ]
  }
]

Sync

Create the container:

let syncContainer = try SyncContainer(for: User.self, Note.self)

Then sync the payload:

let payload = try await getUsers()
try await syncContainer.sync(payload: payload, as: User.self)

SwiftUI reacts automatically using @SyncQuery

import SwiftUI
import SwiftSync

struct UsersView: View {
  let syncContainer: SyncContainer

  @SyncQuery(
    User.self,
    in: syncContainer,
    sortBy: [SortDescriptor(\User.id)]
  )
  private var users: [User]

  var body: some View {
    List {
      ForEach(users) { user in
        Section(user.email ?? "User \\(user.id)") {
          ForEach(user.notes) { note in
            Text(note.text)
          }
        }
      }
    }
  }
}

One-to-one

One-to-one relationship model example

Model

import SwiftData
import SwiftSync

@Syncable
@Model
final class User {
  @Attribute(.unique) var id: Int
  var name: String
  var company: Company?

  init(id: Int, name: String, company: Company? = nil) {
    self.id = id
    self.name = name
    self.company = company
  }
}

@Syncable
@Model
final class Company {
  @Attribute(.unique) var id: Int
  var name: String
  var user: User?

  init(id: Int, name: String, user: User? = nil) {
    self.id = id
    self.name = name
    self.user = user
  }
}

JSON

[
  {
    "id": 6,
    "name": "Shawn Merrill",
    "company": {
      "id": 0,
      "name": "Facebook"
    }
  }
]

Sync

let syncContainer = try SyncContainer(for: User.self, Company.self)

let payload = try await getUsers()
try await syncContainer.sync(payload: payload, as: User.self)

SwiftUI reacts automatically using @SyncQuery

import SwiftUI
import SwiftSync

struct UsersView: View {
  let syncContainer: SyncContainer

  @SyncQuery(
    User.self,
    in: syncContainer,
    sortBy: [SortDescriptor(\User.id)]
  )
  private var users: [User]

  var body: some View {
    List(users) { user in
      VStack(alignment: .leading, spacing: 4) {
        Text(user.name)
        Text(user.company?.name ?? "No company")
          .foregroundStyle(.secondary)
      }
    }
  }
}

Install

Add the package in Xcode:

  1. File -> Add Package Dependencies...
  2. Use this URL:
https://github.com/3lvis/SwiftSync.git
  1. Add the SwiftSync library product to your app target.

If you use Package.swift directly:

.package(url: "https://github.com/3lvis/SwiftSync.git", from: "1.0.0")

Then import:

import SwiftSync

Requirements: Xcode 17+, Swift 6.2, iOS 17+ / macOS 14+

Full Overview

Quick Start already covers the default root-collection path.

The sections below cover the next cases you are likely to hit in a real app:

  • One-to-many relationships linked by IDs instead of nested child objects
  • Parent-scoped sync for child collections returned under one parent
  • Single-item sync for detail payloads and mutation responses
  • Relationship payload shapes beyond the simple nested to-many example
  • Customization points for mapping, reads, export, and dates

Table of contents:

One-to-Many w/ child IDs

Use this shape when the parent JSON does not include full child objects and only sends their IDs.

Model

@Syncable
@Model
public final class Project {
  @Attribute(.unique) public var id: String
  public var name: String
  public var tasks: [Task]
}

@Syncable
@Model
public final class Task {
  @Attribute(.unique) public var id: String
  public var title: String

  @NotExport
  public var project: Project?
}

JSON

[
  {
    "id": "C3E7A1B2-1001-0000-0000-000000000001",
    "name": "Account Security Controls",
    "task_ids": [
      "C3E7A1B2-3001-0000-0000-000000000001",
      "C3E7A1B2-3001-0000-0000-000000000002"
    ]
  }
]

Sync

try await syncContainer.sync(payload: payload, as: Project.self)

This works when those tasks already exist in SwiftData, or are synced elsewhere. SwiftSync uses the *_ids list to connect the relationship without needing full task objects in the same JSON.

Read

@SyncModel(Project.self, id: projectID, in: syncContainer)
private var project: Project?

This pattern is useful for APIs that return lightweight parent objects and keep the full child records on a separate endpoint.

Parent-Scoped Sync

Use parent-scoped sync when an endpoint returns the children for a single parent, such as /projects/{id}/tasks.

Model

@Syncable
@Model
public final class Task {
  @Attribute(.unique) public var id: String
  public var title: String
  public var projectID: String

  @NotExport
  public var project: Project?
}

JSON

[
  {
    "id": "C3E7A1B2-3001-0000-0000-000000000001",
    "title": "Add session timeout controls to account settings",
    "project_id": "C3E7A1B2-1001-0000-0000-000000000001"
  }
]

Sync

try await syncContainer.sync(
  payload: payload,
  as: Task.self,
  parent: project,
  relationship: \Task.project
)

The explicit relationship: key path tells SwiftSync which parent these rows belong to. It compares changes only inside that parent’s set of rows, not across the whole table.

Read

let taskPublisher = SyncQueryPublisher(
  Task.self,
  relationship: \Task.project,
  relationshipID: projectID,
  in: syncContainer
)

See Parent Scope for the full rules.

Single-Item Sync

Use sync(item:) when an endpoint returns one model at a time, such as a detail screen or the response from saving an edit.

It also works well when that same response includes child data that belongs to that one model.

Model

@Syncable
@Model
public final class Item {
  @Attribute(.unique) public var id: String
  public var title: String
  public var taskID: String

  @NotExport
  public var task: Task?
}

JSON

{
  "id": "C3E7A1B2-3001-0000-0000-000000000001",
  "title": "Add session timeout controls to account settings",
  "items": [
    {
      "id": "C3E7A1B2-4001-0000-0000-000000000001",
      "task_id": "C3E7A1B2-3001-0000-0000-000000000001",
      "title": "Document requirements"
    }
  ]
}

Sync

try await syncContainer.sync(item: payload, as: Task.self)
try await syncContainer.sync(
  payload: itemPayload,
  as: Item.self,
  parent: task,
  relationship: \Item.task
)

Read

@SyncModel(Task.self, id: taskID, in: syncContainer)
private var task: Task?

This keeps the detail flow simple:

  • Update the task from the single-object response
  • Update that task's checklist items from the nested array
  • Let both list and detail screens keep reading from the same SwiftData data

Property Mapping

Convention-first mapping is the default. Reach for overrides only when local naming intentionally differs from the backend.

Let's say you have a Task model. The backend sends a top-level description field and a nested state object, but you want the local model to stay straightforward and Swift-friendly:

{
  "id": 42,
  "title": "Ship README rewrite",
  "description": "Tighten up the property mapping docs and examples.",
  "state": {
    "id": "in_progress",
    "label": "In Progress"
  }
}

You can keep title convention-based, rename description locally, and flatten the nested state object into plain properties on Task:

@Syncable
@Model
final class Task {
  @Attribute(.unique) var id: Int
  var title: String

  @RemoteKey("description")
  var details: String?

  @RemoteKey("state.id")
  var state: String

  @RemoteKey("state.label")
  var stateLabel: String

  init(
    id: Int,
    title: String,
    details: String? = nil,
    state: String,
    stateLabel: String
  ) {
    self.id = id
    self.title = title
    self.details = details
    self.state = state
    self.stateLabel = stateLabel
  }
}

In that example:

  • `ti

Related Skills

View on GitHub
GitHub Stars2.5k
CategoryDevelopment
Updated20h ago
Forks263

Languages

Swift

Security Score

100/100

Audited on Mar 30, 2026

No findings