Native SwiftUI app providing full visibility into the Hermes AI agent: - Dashboard with system health, token usage, and cost tracking - Sessions browser with conversation detail and FTS5 search - Activity feed with tool call inspector (read/edit/execute/fetch/browser) - Embedded terminal chat via SwiftTerm with full ANSI/Rich rendering - Memory viewer/editor with live file-watching refresh - Skills browser by category with file content viewer - Cron job viewer with output display - Real-time log tailing with level filtering - Settings display with raw config and Finder path links - Menu bar status icon with quick actions Architecture: MVVM-Feature, zero dependencies beyond SwiftTerm, read-only SQLite access, Swift 6 strict concurrency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8.9 KiB
SwiftData Standard
Applies to: InControl, ShabuBox, Threader, Modeler
1. Schema Versioning
Every SwiftData model change goes through VersionedSchema + SchemaMigrationPlan. No exceptions.
Rules
| Rule | Detail |
|---|---|
| Always version | Every model or stored-property addition, removal, or rename requires a new VersionedSchema enum and a corresponding MigrationStage in the migration plan. |
| Never modify existing versions | Once a VersionedSchema is shipped, treat it as immutable. Create a new version instead. |
| List ALL active models | Each VersionedSchema.models array must contain every model that should exist after that version. Omitting a model drops its table on migration. |
| No unversioned schemas | Never pass Schema([...]) directly to ModelContainer. Always use Schema(versionedSchema:) with migrationPlan:. |
ModelContainerFactory
// Correct
let schema = Schema(versionedSchema: AppSchemaV3.self)
let config = ModelConfiguration(schema: schema)
let container = try ModelContainer(
for: schema,
migrationPlan: AppMigrationPlan.self,
configurations: [config]
)
// Wrong -- unversioned, no migration plan
let container = try ModelContainer(for: MyModel.self)
Migration Stages
Use lightweight stages for structural changes (additions, removals, renames). These are the only stages compatible with CloudKit .automatic sync.
// Lightweight -- safe for CloudKit .automatic
.lightweight(fromVersion: AppSchemaV1.self, toVersion: AppSchemaV2.self)
Use custom stages only when data must be transformed (splitting a field, computing a derived value). Custom stages require CloudKit sync mode .none -- coordinate with the sync layer before adding one.
// Custom -- requires CloudKit .none
.custom(
fromVersion: AppSchemaV2.self,
toVersion: AppSchemaV3.self,
willMigrate: { context in
// transform data here
try context.save()
},
didMigrate: nil
)
Decision Table
| Change type | Stage | CloudKit compatible |
|---|---|---|
| Add model | lightweight | Yes |
| Add optional property | lightweight | Yes |
| Remove property | lightweight | Yes |
Rename property (@Attribute(originalName:)) |
lightweight | Yes |
| Split field into two | custom | No -- use .none sync |
| Backfill computed values | custom | No -- use .none sync |
2. Skeleton-First Pattern
Create the record with status = .processing immediately so the UI shows it right away. Fill in real data as background processing completes.
// 1. Insert skeleton record -- instant UI feedback
let record = MyRecord(name: placeholder, status: .processing)
modelContext.insert(record)
try modelContext.save()
// 2. Process in background
let result = await heavyWork()
// 3. Update the record
record.name = result.name
record.status = .ready
try modelContext.save()
The user sees the record appear in the list immediately. As processing finishes, the record fills in and the status indicator clears.
3. Indexing
Add #Index on fields that appear in predicates, sort descriptors, or frequent lookups.
@Model
final class Task {
var title: String
var dueDate: Date?
var status: String
var projectID: UUID?
var updatedAt: Date
// ...
}
// Declare indexes for query performance
extension Task {
static let indexes: [[IndexColumn<Task>]] = [
[\.dueDate],
[\.projectID],
[\.status],
[\.updatedAt]
]
}
Minimum Indexes by Domain
| Entity | Indexed fields |
|---|---|
| Task | dueDate, projectID, columnID, status, updatedAt |
| Expense | date, projectID, categoryID, status |
| Opportunity | stageID, targetCloseDate, updatedAt |
| ExternalLink | provider, externalID, (entityType + entityID) |
4. Query Patterns
Use DataStoreActor for Background Queries
All views should use background actor queries. Do not use @Query or synchronous DataStore calls for production data access.
// Proven pattern: background actor query + main-thread model resolution
let (ids, count) = try await dataStoreActor.fetchItemsWithCount(page: page, limit: limit)
await MainActor.run {
loadedItems = modelContext.items(from: ids)
}
Prefer Database-Level Filtering
Use #Predicate for filtering whenever possible. In-memory filtering fetches all matching rows into memory and becomes a scalability problem at 10k+ records.
// Good -- database-level filtering
let predicate = #Predicate<Task> { $0.status == "active" && $0.projectID == targetID }
let descriptor = FetchDescriptor(predicate: predicate)
let results = try modelContext.fetch(descriptor)
// Bad -- fetches everything, filters in Swift
let all = try modelContext.fetch(FetchDescriptor<Task>())
let filtered = all.filter { $0.status == "active" }
If in-memory filtering is unavoidable (e.g., complex path-prefix matching), add a fetchLimit cap and document why database-level filtering was not possible.
// Acceptable only with justification and cap
var descriptor = FetchDescriptor<Item>(predicate: basePredicate)
descriptor.fetchLimit = 500 // Cap to prevent unbounded memory
let items = try modelContext.fetch(descriptor)
// In-memory filter required: destinationPath prefix matching not supported by #Predicate
let filtered = items.filter { $0.destinationPath.hasPrefix(targetPath) }
5. Safe Fetch
Never use bare try? on modelContext.fetch(). Use the safe wrappers that log failures.
// Good -- logs the error, returns empty array on failure
let items = dataStore.safeFetch(descriptor, operation: "loading active tasks")
// Good -- logs the error, returns 0 on failure
let count = dataStore.safeFetchCount(descriptor, operation: "counting inbox items")
// Bad -- silently swallows fetch errors
let items = try? modelContext.fetch(descriptor)
safeFetch and safeFetchCount wrap the call in do/try/catch, log with logger.error() including the operation name, and return a safe default (empty array or zero).
6. Data Modeling Conventions
Primary Keys
All entities use UUID primary keys.
@Attribute(.unique) var id: UUID = UUID()
Timestamps
Every entity carries automatic timestamps.
var createdAt: Date = Date()
var updatedAt: Date = Date()
Update updatedAt on every mutation.
Soft Delete
Prefer archiving over hard deletion for auditability and sync safety.
var isArchived: Bool = false
var archivedAt: Date?
Money
Store monetary values as Int64 minor units (cents) plus an ISO 4217 currency code. Never use floating-point for money.
var amountMinor: Int64 // e.g., 1999 = $19.99
var currencyCode: String // e.g., "USD"
Kanban Ordering
Use sortIndex: Double for position within a column. Doubles allow cheap insertion between two items without rewriting the entire list.
var sortIndex: Double
// Insert between items at 1.0 and 2.0
newItem.sortIndex = 1.5
Many-to-Many Relationships
Use explicit join tables instead of native SwiftData many-to-many. This is CloudKit-friendly and supports auditing.
@Model
final class EntityTag {
@Attribute(.unique) var id: UUID = UUID()
var entityType: String
var entityID: UUID
var tagID: UUID
var createdAt: Date = Date()
}
7. Actor Safety
Isolate SwiftData models within the appropriate actor to prevent data races.
Logger in @Model Classes
@Model classes are actor-isolated, but Logger is not Sendable. Declare the logger at file scope with nonisolated(unsafe) to avoid concurrency warnings.
private nonisolated(unsafe) let logger = Logger(
subsystem: "com.yourapp",
category: "MyModel"
)
@Model
final class MyModel {
// Use `logger` freely inside the class
}
For structs (including SwiftUI views), use private static let logger instead.
8. Error Handling in Models
Encode / Decode
Never use try? on encode or decode operations. A silent failure here means corrupted or lost data.
// Good
do {
let data = try JSONEncoder().encode(value)
self.storedData = data
} catch {
logger.error("Failed to encode value: \(error)")
self.storedData = Data() // explicit fallback
}
// Bad -- silently drops data
self.storedData = try? JSONEncoder().encode(value)
modelContext.save()
Always wrap saves in do/try/catch. Save failures indicate data corruption or constraint violations -- they must never be silently ignored.
do {
try modelContext.save()
} catch {
logger.error("Save failed: \(error)")
}
When bare try? Is Acceptable
Only for truly ignorable, idempotent operations. Always include a comment.
// Ignorable: removing file before overwrite
try? FileManager.default.removeItem(at: tempURL)
// Ignorable: sleep cancellation
try? await Task.sleep(for: .seconds(1))