5 library targets, 1 compiler plugin, 1 ObjC helper:
Core (no dependencies)
↑
├─ MacrosImplementation (compiler plugin, + swift-syntax)
│ ↑
│ └─ Macros (public macro declarations)
│
├─ SwiftDataBridge (sync engine)
│
└─ TestingKit (test helpers)
SwiftSync (container + reactive queries)
depends on: Core, SwiftDataBridge, Macros, ObjCExceptionCatcher
ObjCExceptionCatcher (mixed Swift/ObjC, catches NSException from ModelContainer)
| Target | Key types |
|---|---|
| Core | SyncPayload, SyncDateParser, all protocols, KeyStyle, SyncError |
| SwiftDataBridge | SwiftSync.sync(), SyncLeaseRegistry |
| MacrosImplementation | SyncableMacro + three no-op peer macros |
| Macros | @Syncable, @PrimaryKey, @RemoteKey, @NotExport declarations |
| SwiftSync | SyncContainer, SyncQuery, SyncModel |
| ObjCExceptionCatcher | SwiftSyncObjCExceptionCatcher |
PersistentModel (SwiftData)
└─ SyncModelable syncIdentity, syncIdentityRemoteKeys, syncDefaultRefreshModelTypes,
│ syncRelationshipSchemaDescriptors
└─ SyncUpdatableModel make(from:), apply(_:) → Bool,
│ applyRelationships(_:in:operations:) → Bool (default no-op)
└─ ParentScopedModel parentRelationship keypath
@Syncable makes a class conform to all of:
SyncUpdatableModel
Raw [Any] payload (array of dicts)
│
▼ normalize()
[[String: Any]]
│
▼ per entry: SyncPayload(values:, keyStyle:)
Wraps dict with key resolution + coercion
│
▼ resolveIdentity()
SyncID (String/Int/UUID…)
│
▼ identityKey() / scopedIdentityKey()
String key for index lookup
│
├─ found in index → row.apply(payload) update scalars
└─ not found → Model.make(from: payload) create + insert
↓
applyRelationships(payload, context, operations)
↓
context.save()
↓
post SyncContainer.didSaveChangesNotification (internal)
candidateKeys(for: "assigneeId") on .snakeCase input generates:
"assignee_id"(snake-cased version)"assigneeId"(original)- Special cases for
"id"/"remoteID"
Result cached per SyncPayload instance in CandidateKeysCache.
Null semantics (strict):
- Key absent → ignore, no mutation
- Key present as
NSNull→ clear / delete - Key present as value → apply
Coercion in value(for:as:):
"42" → Int, 1 → Bool, "2025-01-01" → Date, "uuid-string" → UUID, etc.
required(for:) vs strictValue(for:):
requireduses coercion + Date fallback to epoch + null defaults; throws on unresolvablestrictValueuses direct cast only, returns nil silently
.global— identity unique across all rows (default).scopedByParent— identity unique within parent scope- Scoped key:
"TypeName|<PersistentIdentifier>|<identityValue>" - Default for
ParentScopedModel
- Scoped key:
Before processing entries, old rows with the same identity key are deleted. This cleans up any duplicates that crept in from previous partial syncs.
Given:
@Syncable
@Model
final class Task {
@Attribute(.unique) var id: String
@RemoteKey("state.id") var stateID: String
var title: String
var assignee: User?
var tags: [Tag]
@NotExport var internalFlag: Bool
init(...) { ... }
}The macro emits an extension Task: SyncUpdatableModel, ... containing:
typealias SyncID = String
static var syncIdentity: KeyPath<Task, String> { \.id }
static func make(from payload: SyncPayload) throws -> Task
id:try payload.required(String.self, for: "id")stateID:try payload.required(String.self, for: "state.id")title:try payload.required(String.self, for: "title")assignee:nil(relationship, skipped)tags:[](to-many, skipped)internalFlag:try payload.required(Bool.self, for: "internalFlag")
func apply(_ payload: SyncPayload) throws -> Bool
- Skips
id(primary key) - Skips
assignee,tags(relationships) - For each scalar: if
payload.contains(key), read + compare, set + mark changed
func applyRelationships(_:in:operations:) -> Bool
- For
assignee: ifpayload.contains("assignee_id")→syncApplyToOneForeignKey - For
tags: ifpayload.contains("tags_ids") || payload.contains("tag_ids")→syncApplyToManyForeignKeys- else if
payload.contains("tags")→syncApplyToManyNestedObjects
- else if
func exportObject(keyStyle:dateFormatter:) -> [String: Any]
internalFlagskipped (@NotExport)stateIDexported under key"state.id"(nested dict)assigneeexported as object or NSNulltagsexported as array of objects
static var syncRelationshipSchemaDescriptors
- Metadata for schema validation: each relationship's name, related type, isToMany, hasExplicitInverse
| Attribute | Effect on make/apply | Effect on export |
|---|---|---|
@PrimaryKey |
Sets syncIdentity; skipped in apply |
Exported normally |
@PrimaryKey(remote: "ext_id") |
Sets syncIdentityRemoteKeys: ["ext_id"] |
Exported under "ext_id" |
@RemoteKey("key") |
Read from "key" in payload |
Exported under "key" |
@RemoteKey("a.b") |
Read from nested payload["a"]["b"] |
Exported to nested dict |
@NotExport |
Normal sync | Excluded from export |
Four public overloaded functions, each in two variants (concrete PersistentModel = no-op stub, SyncModelable/SyncUpdatableModel = real logic):
syncApplyToOneForeignKey — Model? property, resolved by ID lookup
syncApplyToManyForeignKeys — [Model] property, resolved by ID array
syncApplyToOneNestedObject — Model? property, resolved by nested dict
syncApplyToManyNestedObjects — [Model] property, resolved by nested dicts
The stubs (Related: PersistentModel constraint) exist so the macro-generated code compiles cleanly even when the related type doesn't conform to SyncModelable — the stub silently returns false.
SyncRelationshipOperations bitmask
.insert— create new related rows.update— modify existing related rows.delete— remove relationships / delete children.all— default
mergeUnorderedRelationships: merges current + desired arrays respecting allow/delete flags, used for all to-many operations.
Problem: Multiple concurrent sync() calls on the same container would race on shared SwiftData state.
Solution in SyncLeaseRegistry (actor):
acquireSyncLease(for context)
scopeID = ObjectIdentifier(context.container)
if scopeID not active → mark active, return lease immediately
else → enqueue CheckedContinuation, suspend
releaseSyncLease(lease)
if waiters exist → resume first waiter (FIFO)
else → mark scope inactive
Lease always released in defer-equivalent pattern:
let lease = await acquireSyncLease(for: context)
do {
// ... sync work ...
await releaseSyncLease(lease)
} catch {
await releaseSyncLease(lease) // always release
if isCancellation(error) {
context.rollback()
throw SyncError.cancelled
}
throw error
}Thin orchestration layer over SwiftSync.* functions:
- Stores
ModelContainer,mainContext,keyStyle - Creates a fresh
ModelContextpersync()call (background context) - Observes
ModelContext.didSaveon all contexts → re-posts as an internaldidSaveChangesNotificationwith:changedIdentifiers: union of inserted + updated + deleted IDschangedModelTypeNames: type names derived from those IDs
Initialization pipeline:
_validateSchema(modelTypes) ← always runs; throws SchemaValidationError on unanchored many-to-many
↓
Schema(modelTypes)
↓
_recoverContainerInitialization(recoverOnFailure:configurations:makeContainer:resetPersistentStores:)
↓
_executeCatchingObjectiveCException {
ModelContainer(for: schema, configurations:)
} ← bridges NSException → ObjectiveCInitializationExceptionError
↓ (if throws + recoverOnFailure == true)
_resetPersistentStoreFiles(for:) ← deletes .store + WAL/SHM/support sidecars
↓
retry ModelContainer(...)
_resetPersistentStoreFiles: enumerates the directory of each configuration URL, deletes files whose names equal or start with the database filename. Catches SQLite WAL/SHM sidecars.
When to use recoverOnFailure: true: only when the local store is a pure client cache and remote is the source of truth. A wipe means all local data is lost and rebuilt on the next sync. For apps with user-owned data, provide a SchemaMigrationPlan instead — construct ModelContainer directly and pass it to SyncContainer(_ modelContainer:).
Two implementations, same contract:
@SyncQuery / @SyncModel |
SyncQueryPublisher |
|
|---|---|---|
| Integration | SwiftUI (primary) | UIKit / plain Swift |
| Observation | SyncQueryObserver |
SyncQueryPublisher |
| Output | @Published property on wrapper |
@Published var rows + rowsPublisher |
| Reload trigger | same internal didSaveChangesNotification |
same internal didSaveChangesNotification |
Both use identical reload heuristics:
SyncContainer.didSaveChangesNotification (internal)
│
▼
shouldReload(for notification)
1. changedModelTypeNames empty? → reload (no type info, be safe)
2. changedModelTypeNames ∩ observedModelTypeNames non-empty? → reload
3. changedIDs ∩ loadedRowIDs non-empty? → reload (a loaded row changed)
4. otherwise → skip
│
▼ (if reload)
FetchDescriptor<Model>(predicate:, sortBy:)
+ optional postFetchFilter (for relationship-scoped queries)
SwiftUI path applies animation: withAnimation(animation) { rows = resolved }
UIKit path assigns directly: rows = resolved
observedModelTypeNames built at init:
- Always includes
String(reflecting: Model.self) - Plus
syncDefaultRefreshModelTypeNames(declared by the model) - Plus
syncRefreshModelTypeNames(for: refreshOn)(fromrefreshOn:parameter, SwiftUI only)
postFetchFilter for relationship-scoped queries:
- Explicit
relationship: \Task.assignee→explicitToOneRelationshipIDFilter - Explicit
relationship: \Task.reviewers→explicitToManyRelationshipIDFilter
syncContainer.export(as: Task.self)- Fetch all rows, sort by identity key string
- For each row:
row.exportObject(keyStyle: syncContainer.keyStyle, dateFormatter: syncContainer.dateFormatter) - Each call to
exportObject:state.enter(self)— guard against cycles- For each non-
@NotExportproperty:- Scalar:
exportEncodeValue(value, dateFormatter: dateFormatter)→ encode - Optional scalar: encode or NSNull if nil
- Relationship: recurse via
exportObjecton children
- Scalar:
- Key from
@RemoteKeyorkeyStyle.transform(propertyName) exportSetValue(value, for: keyPath, into: &result)— supports nested dot-path keysstate.leave(self)
Relationship export:
- Relationships are included by default as inline arrays/objects.
- Apply
@NotExportto a relationship property to exclude it from all exports.
Runs unconditionally on every SyncContainer init. Detects many-to-many pairs where neither side has @Relationship(inverse: …), which would silently create two separate join tables in SwiftData.
for each isToMany relationship R:
find reciprocals = all isToMany on R.relatedType pointing back to R.ownerType
if reciprocals exist:
if neither R nor any reciprocal has hasExplicitInverseAnchor:
throw SchemaValidationError
hasExplicitInverseAnchor is detected by the macro scanning for @Relationship attributes with an inverse: argument.
ModelContainer(for:) can raise NSException (e.g., store migration failures) which Swift cannot catch with do/catch. The bridge:
- ObjC:
@try { block() } @catch (NSException *e) { wrap in NSError } - Swift: calls bridge, checks
swiftResult(set inside block), extracts name/reason from NSError userInfo
Error type: ObjectiveCInitializationExceptionError with name + reason from exception.
Areas with the most surface area relative to usage:
-
Four relationship application globals — each exists in two overloads (stub + real). The stubs return
falseunconditionally. If all related types were required to beSyncModelable, the stubs could disappear (8 functions → 4). -
KeyStyle.camelCase— if all your payloads are snake_case, the camelCase branch incandidateKeysis dead weight. -
Date parser breadth — handles 15+ ISO8601 variants + Unix timestamps. If your server only emits one format, most branches are never hit.