ios-swiftdata
Skill1 starsChanged 14 days agoMITView on GitHub
Design and evolve a SwiftData model layer — @Model schema design, relationships, migrations with VersionedSchema, and where SwiftData fits next to (or instead of) a hand-rolled cache/outbox layer. Use when asked to add local persistence with SwiftData, design a @Model, migrate a SwiftData schema, or evaluate SwiftData as a replacement for an existing CacheStore/Outbox implementation.
In this repository37 other files it gives its agents.
- AGENTS.mdAGENTS.md
- ios-accessibility-auditSkill
- ios-api-contract-migrationSkill
- ios-api-endpointSkill
- ios-app-intents-siriSkill
- ios-bug-triageSkill
- ios-build-configurationSkill
- ios-cloudkit-syncSkill
- ios-combine-patternsSkill
- ios-commit-and-prSkill
- ios-commitSkill
- ios-concurrency-auditSkill
- ios-coreml-apple-intelligenceSkill
- ios-deep-linkingSkill
- ios-dependency-assessmentSkill
- ios-design-fixSkill
- ios-feature-moduleSkill
- ios-knowledge-baseSkill
- ios-latest-os-adoptionSkill
- ios-localizationSkill
- ios-offline-writeSkill
- ios-performance-tuningSkill
- ios-port-from-androidSkill
- ios-port-to-androidSkill
- ios-precommitSkill
- ios-privacy-complianceSkill
- ios-push-and-background-tasksSkill
- ios-refactorSkill
- ios-release-prepSkill
- ios-sdk-upgradeSkill
- ios-secure-reviewSkill
- ios-storekit-subscriptionsSkill
- ios-swift6-concurrency-migrationSkill
- ios-swift-testing-migrationSkill
- ios-swiftui-screenSkill
- ios-unit-testsSkill
- ios-widgetkit-live-activitiesSkill
The file
We split this skill into the rules it gives an agent
2 rules. About 0 are common, 1 is distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 21
Don't introduce SwiftData for one screen's local cache when the existing CacheStore already does that job.
- Distinctive: line 85
Test migrations against a real populated store from the previous version, not an empty one — an empty store migrating cleanly proves …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Work with SwiftData
SwiftData is Apple's persistence framework (iOS 17+) built on top of Core Data's underlying storage — not a different thing from Core Data so much as a Swift-native modeling layer in front of it. Decide deliberately whether a feature belongs in SwiftData at all before modeling anything.
1. SwiftData vs. this codebase's existing persistence
This repo's other skills (ios-offline-write, ios-api-endpoint) assume a hand-rolled CacheStore/Outbox pair: a response cache keyed by endpoint, and a separate durable queue for offline writes. SwiftData is a different tool solving a related but distinct problem — a real local database with relationships and queries, not a cache of server responses.
| Need | Reach for |
| Cache the last-known server response, evictable, cleared at sign-out | The existing CacheStore — SwiftData is the wrong tool for something explicitly disposable |
| Queue a pending write until connectivity returns | The existing Outbox pattern — SwiftData has no built-in notion of "pending sync state," you'd build that yourself on top of it anyway |
| A genuinely local-first dataset with relationships, queries, and sorting the user interacts with directly (notes, a local project list, draft content) | SwiftData is a reasonable fit |
Replacing the whole CacheStore/Outbox layer with SwiftData everywhere | Treat as an architecture decision, not a per-feature one — see ios-dependency-assessment's framing: what does it buy, what does it cost, how would you back out if it's wrong |
Don't introduce SwiftData for one screen's local cache when the existing CacheStore already does that job; introduce it when the feature actually needs persistent, queryable, relational local state.
2. Model design
@Model
final class Widget {
@Attribute(.unique) var id: String
var title: String
var createdAt: Date
var status: WidgetStatus
@Relationship(deleteRule: .cascade, inverse: \WidgetNote.widget)
var notes: [WidgetNote] = []
init(id: String, title: String, createdAt: Date, status: WidgetStatus) {
self.id = id
self.title = title
self.createdAt = createdAt
self.status = status
}
}@Attribute(.unique)is how you get an upsert-safe primary key — without it, inserting a model with a duplicate logical ID creates a second row rather than raising a conflict.@Relationship(deleteRule:)matters for correctness, not just convenience:.cascadedeletes children with the parent,.nullify(the default) leaves orphaned children with a nil reference,.denyblocks the delete if children exist. Pick deliberately — the default silently orphaning rows is rarely what you actually want for an owned child collection.- Enums used as a
@Modelproperty need to beCodableand have a stable raw representation if you'll ever migrate the schema; aString-backed enum with explicit raw values survives a migration far more predictably than relying on case order. @Modelclasses are reference types with observation built in for SwiftUI (@Query,@Bindable) — there's no separate "draft" struct the way this codebase's other models use<Name>Draft; mutate the model directly inside a transaction, or hold a lightweight plain-struct draft in the view model until save if you want cancel-without-saving behavior.
3. Migrations
Any schema change — a new property, a changed relationship, a renamed field — needs a VersionedSchema plan once the app has shipped with a prior schema. Skipping this means an existing user's local store either fails to open or silently drops data on upgrade.
enum WidgetSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Widget.self] }
}
enum WidgetSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Widget.self] }
// Widget.self here is the *current* definition with the new property —
// the migration plan below is what tells SwiftData how to get existing
// rows from V1's shape to V2's.
}
enum WidgetMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [WidgetSchemaV1.self, WidgetSchemaV2.self] }
static var stages: [MigrationStage] {
[.custom(
fromVersion: WidgetSchemaV1.self,
toVersion: WidgetSchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// Backfill the new property for existing rows here, then
// try context.save() — this closure runs inside the migration.
}
)]
}
}- A lightweight migration (adding an optional property, widening a type) can often happen without a custom stage — SwiftData infers it. The moment a change needs data transformation (backfilling a new required field, splitting one property into two), it needs an explicit
.customstage withdidMigrate. - Test migrations against a real populated store from the previous version, not an empty one — an empty store migrating cleanly proves nothing about whether existing users' data survives.
ModelContainer(for: Widget.self, migrationPlan: WidgetMigrationPlan.self)is where the plan actually gets wired in; forgetting to pass it means the container falls back to default (often destructive) migration behavior.
4. Queries and performance
@Queryin a SwiftUI view re-runs its fetch on relevant changes automatically — but a@Querywith a large, unfiltered result set in a list view is the SwiftData equivalent of this codebase's "whole-list invalidation" problem (ios-performance-tuning): add apredicateandsortByinstead of fetching everything and filtering in the view.- Reading/writing from a background context (
ModelActor, or a secondaryModelContext) for anything heavier than a quick UI-driven edit — blocking the main context for a large import or batch update produces the same "janky scrolling" symptom as any other main-thread-blocking work. FetchDescriptorwith#Predicatemacros are type-checked at compile time against the model — a predicate referencing a property that doesn't exist is a compile error, not a runtime crash; still worth sanity-checking that the predicate expresses what you think it does, since the macro won't catch a logically wrong condition.
Verification
- The decision to use SwiftData (vs. the existing
CacheStore/Outbox) for this feature is stated explicitly, not assumed. - Every
@Relationshiphas a deliberately chosendeleteRule— not left at the default without checking what that means for this relationship. - A schema change that isn't purely additive has a
VersionedSchema+SchemaMigrationPlan, tested against a populated store from the prior version. - List-driving
@Querys have apredicate/sortByrather than fetching unbounded data and filtering client-side. - Heavy writes (import, batch update) run off the main context.
- No test or fixture uses real personal data to exercise the model.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

