Files
scarf/scarf/standards/02-swiftdata.md
T
Alan Wizemann 18278a3357 Initial release: Scarf — macOS GUI for the Hermes AI agent
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>
2026-03-31 02:30:04 -04:00

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))