Property Mapping Contract
June 24, 2026 · View on GitHub
Read this when you need to know exactly how SwiftSync interprets payload keys, null, relationships, and export keys.
If you only need the practical rule set, start here:
- convention-first mapping is the default
- missing key means ignore
- explicit
nullmeans clear @RemoteKeyand@PrimaryKey(remote:)override conventions
This document is the source of truth for the detailed behavior behind those rules.
Scope
This contract defines current behavior for:
- inbound key resolution
- null/missing semantics
- relationship key conventions
- container-level input key style
- export key emission contract
- scalar type coercion policy
Core Rules
- Convention-first mapping is expected.
absent = ignorefor payload fields.null = clearfor optional fields/relationships when delete semantics apply.- Explicit overrides (
@PrimaryKey(remote:),@RemoteKey) take precedence over conventions.
Practical defaults:
- Prefer convention mapping first; add
@RemoteKeyonly when local naming intentionally differs. - Prefer
projectID,remoteURL,uuidValuestyle names; SwiftSync normalizes acronyms for snake/camel mapping. - Configure inbound key style once at
SyncContainer(.snakeCasedefault,.camelCaseoptional).
Container Input Key Style
Inbound key style is configured at SyncContainer:
.snakeCase(default).camelCase
The configured style is applied to all payload lookups performed during sync.
Inbound Key Resolution (Attributes)
For generated model input keys:
@PrimaryKey(remote:)if present@RemoteKeyif present- property name by convention
Lookup behavior in SyncPayload:
- style-transformed candidate (based on container
keyStyle) - literal requested key
- identity aliases (
id/remote_id/remoteIDas applicable)
Example:
- Property
projectID .snakeCasemode acceptsproject_id.camelCasemode acceptsprojectId
Inbound Key Resolution (Relationships)
Relationship lookup uses the same SyncPayload candidate pipeline.
Foreign-key conventions:
- to-one:
<relationship>_id - to-many IDs:
<relationship>_idsand singularized fallback (watcher_idsforwatchers) @RemoteKeyoverrides FK convention
Nested-object conventions:
- relationship name by convention
@RemoteKeymay target nested key paths using dotted notation
Dispatch order for relationships:
- if FK key is present, process FK path
- else if nested key is present, process nested object path
Deep-path behavior:
- dotted keys (for example
relationships.owner) are resolved against nested dictionaries on import. - deep-path lookup uses the same key-style candidate pipeline (
snakeCase/camelCase) as flat keys. - nested relationship payload processing keeps the parent sync key-style configuration.
Null and Missing Semantics
- Missing key: no change.
- Explicit
NSNull: - optional scalar: clears to
nil - non-optional scalar: resets to default fallback in required reads
- optional relationship: clears when delete operation is enabled
- non-optional relationship: cannot be cleared
Relationship operation flags still gate insert/update/delete behavior.
FK Typing and Unknown References
- FK parsing is strict for relationship IDs.
- Unknown FK references are soft no-ops (no placeholder creation, no forced clear).
Reserved Name Diagnostics
For @Syncable models, SwiftSync emits compile-time diagnostics for blocked names:
description(suggested replacement:descriptionText)hashValue(suggested replacement:hashValueRaw)
When payload keys still use blocked names, map them with @RemoteKey.
Export Key Contract
Export key precedence:
@RemoteKey@PrimaryKey(remote:)- convention transform from export
keyStyle
Defaults:
- export key style defaults to snake_case
- relationship export mode defaults to array mode
Round-trip expectation:
- if import/export use matching conventions and no divergent overrides, exported keys match expected API keys.
Scalar Coercion Matrix
Supported scalar coercions on inbound attribute reads include:
- string -> numeric (
Int,Double,Float,Decimal) when parseable - integer/number ->
Double,Float,Decimal - number ->
Int(truncating numeric conversion) - string ->
Bool(true/false,1/0,yes/no) - numeric
0/1->Bool - string ->
UUID - string ->
URL UUID/URL/numeric/bool/decimal ->String- string/number ->
Datevia parser and unix timestamp handling
Strict reads (strictValue, relationship FK linking) remain non-coercive by design.
Uniqueness Constraints (identity only)
Declare uniqueness only on the sync identity (the id / @PrimaryKey property). Do not
put @Attribute(.unique) or #Unique (single or compound) on any other property of a synced model.
SwiftSync identifies rows by syncIdentity and upserts by fetching the existing row, then
updating or inserting — one row per identity is the core invariant. SwiftData enforces unique
constraints with its own constraint-based upsert: inserting a row that collides on a unique
property silently overwrites the existing row. When those two disagree — e.g. two
identity-distinct records that happen to share a email you marked unique — sync silently
destroys one of them, and the local store ends up with fewer rows than the backend sent, with
no error.
Guardrail: SyncContainer validates this at init and throws SchemaValidationError if a
@Syncable model declares a uniqueness constraint on a non-identity property (alongside the
existing many-to-many inverse check). #Index on any property is unaffected — it only changes
query planning and is safe to use freely. Hand-written SyncUpdatableModel conformances are not
checked (they leave syncIdentityPropertyName empty); the same rule applies to them by
convention.
SwiftData query features (#Index, #Expression)
Both are transparent to SwiftSync — use them freely. For consumer-model queries SwiftSync builds only identity- and parent-scope predicates internally (its own history-author and token-record predicates are a separate, internal concern), and never inspects or restricts how a consumer constructs a query:
#Indexaffects only query planning, never sync semantics. (The uniqueness guardrail above rejects a non-identity unique constraint, but leaves indexes untouched.)#Expressioncomposes into a consumer's#Predicate.@SyncQuery/SyncQueryPublisheraccept anyPredicate<Model>and pass it straight toFetchDescriptor, so an#Expression-built predicate works unchanged. SwiftSync neither requires nor exposes#Expressionin its own API — it is out of scope for the library surface and fully available to consumers.