SKILL.md
SwiftData Models
Overview
Define the SwiftData persistence layer for the planner app. All models use the @Model macro targeting iOS 18+ with SwiftData. The schema covers meeting records, rich text notes, people/contacts, vector embeddings for RAG, and tags for organization.
Core Models
MeetingRecord
import SwiftData
import Foundation
@Model
final class MeetingRecord {
@Attribute(.unique) var eventIdentifier: String
var title: String
var startDate: Date
var endDate: Date
var location: String?
var organizerName: String?
var organizerEmail: String?
var purpose: String?
var outcomes: String?
var actionItems: String?
var summary: String?
var calendarTitle: String?
var isRecurring: Bool
var createdAt: Date
var updatedAt: Date
@Relationship(deleteRule: .cascade, inverse: \Note.meetingRecord)
var notes: [Note] = []
@Relationship(inverse: \Person.meetings)
var attendees: [Person] = []
@Relationship(deleteRule: .cascade, inverse: \EmbeddingRecord.meetingRecord)
var embeddings: [EmbeddingRecord] = []
@Relationship(inverse: \Tag.meetingRecords)
var tags: [Tag] = []
}
Key decisions:
- Use
eventIdentifier(notcalendarItemIdentifier) as the unique key.eventIdentifierpersists across calendar syncs;calendarItemIdentifiercan change. - Store
organizerName/organizerEmailas strings rather than linking to Person, because EKParticipant data is ephemeral and may not match local contacts. summaryis populated by the Apple Intelligence summarization service, stored to avoid re-computation.- Cascade delete on
notesandembeddings— when a meeting record is removed, its associated data goes too.
Note
@Model
final class Note {
var title: String
var richTextData: Data? // Archived NSAttributedString
var plainText: String // Plain text extraction for RAG indexing
var createdAt: Date
var updatedAt: Date
var meetingRecord: MeetingRecord?
@Relationship(deleteRule: .cascade, inverse: \EmbeddingRecord.note)
var embeddings: [EmbeddingRecord] = []
@Relationship(inverse: \Tag.notes)
var tags: [Tag] = []
}
Key decisions:
- Store rich text as
Data(archivedNSAttributedString). SwiftData cannot persistNSAttributedStringdirectly. - Maintain
plainTextalongsiderichTextData— extracted on save — for the RAG indexing pipeline. Avoids deserializing attributed strings during embedding generation. meetingRecordis optional: notes can exist standalone (not tied to a meeting).
Person
@Model
final class Person {
@Attribute(.unique) var email: String
var name: String
var company: String?
var role: String?
var relationshipSummary: String?
var lastInteractionDate: Date?
var meetingCount: Int
var meetings: [MeetingRecord] = []
}
Key decisions:
- Use
emailas unique identifier since EKParticipant provides email reliably. relationshipSummaryis generated by the summarization service (e.g., "Met 12 times since March. Discuss project X and budget reviews.").meetingCountis denormalized for quick display; update on meeting record creation.
EmbeddingRecord
@Model
final class EmbeddingRecord {
var vectorData: Data // [Double] encoded as Data
var sourceText: String // The text chunk this embedding represents
var sourceType: String // "meeting" or "note"
var chunkIndex: Int // Position within the source document
var createdAt: Date
var meetingRecord: MeetingRecord?
var note: Note?
}
Key decisions:
- Store vectors as
DatausingwithUnsafeBytes/withUnsafeBufferPointerconversion. SwiftData cannot store[Double]arrays directly. - Vector conversion helpers:
extension EmbeddingRecord {
var vector: [Double] {
get {
vectorData.withUnsafeBytes { Array($0.bindMemory(to: Double.self)) }
}
set {
vectorData = newValue.withUnsafeBytes { Data($0) }
}
}
}
sourceTypeis a string enum discriminator. Avoid Swift enums in SwiftData models — they cause issues with schema migration.- Either
meetingRecordornoteis set, not both. Enforce in application logic.
Tag
@Model
final class Tag {
@Attribute(.unique) var name: String
var color: String // Hex string, e.g. "#FF5733"
var meetingRecords: [MeetingRecord] = []
var notes: [Note] = []
}
Schema Versioning
Set up VersionedSchema and SchemaMigrationPlan from the start:
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[MeetingRecord.self, Note.self, Person.self, EmbeddingRecord.self, Tag.self]
}
}
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] }
static var stages: [MigrationStage] { [] }
}
Add new schema versions as the app evolves. Use .lightweight migration stages when only adding optional properties. Use .custom for data transformations.
Query Patterns
Fetch meetings for a date range
@Query(sort: \MeetingRecord.startDate)
private var meetings: [MeetingRecord]
// Dynamic predicate:
let start = Calendar.current.startOfDay(for: date)
let end = Calendar.current.date(byAdding: .day, value: 1, to: start)!
let predicate = #Predicate<MeetingRecord> { $0.startDate >= start && $0.startDate < end }
Search notes by plain text
let searchTerm = "budget"
let predicate = #Predicate<Note> {
$0.plainText.localizedStandardContains(searchTerm)
}
Fetch a person's meeting history
let predicate = #Predicate<MeetingRecord> {
$0.attendees.contains(where: { $0.email == targetEmail })
}
SwiftData Gotchas
- Never use Swift enums as stored properties unless they conform to
Codable. Prefer raw strings with constants. Enum cases cannot be renamed without a migration. @Attribute(.unique)triggers upsert behavior — inserting a model with an existing unique value updates the existing row silently. This is useful for calendar event sync but can cause unexpected overwrites.- Relationship arrays default to empty
[], notnil. Always initialize relationship arrays in the model declaration. #Predicatedoes not support all Swift expressions. Avoid optional chaining, complex closures, or computed properties inside predicates. Test predicates in isolation.ModelContext.save()is called automatically by SwiftUI at appropriate times. Call manually only in background tasks or service classes.- In-memory configuration for testing:
ModelConfiguration(isStoredInMemoryOnly: true). Always use this in unit tests to avoid polluting on-disk storage. @Queryonly works in SwiftUI views. In service classes, usemodelContext.fetch(FetchDescriptor<T>(...))instead.