P2.1: iOS Schema Versioning Strategy
- Added SCHEMA_VERSION constant and checkSchemaVersion() method in PersistenceController
- Version stored in NSPersistentStore metadata (observability contract, not migration gate)
- CoreData auto-migration remains authoritative; version mismatches logged, not blocked
- Documentation added to ios/Plugin/README.md with migration contract
P2.2: Combined Edge Case Tests
- Added 3 resilience test scenarios to DailyNotificationRecoveryTests.swift:
- test_combined_dst_boundary_duplicate_delivery_cold_start()
- test_combined_rollover_duplicate_delivery_cold_start()
- test_combined_schema_version_cold_start_recovery()
- All tests labeled with @resilience @combined-scenarios comments
- Tests verify idempotency and correctness under combined stressors
P2.3: Android Combined Tests Design
- Created P2.3-DESIGN.md with scope, invariants, and acceptance criteria
- Created P2.3-IMPLEMENTATION-CHECKLIST.md with step-by-step execution plan
- Design ready for implementation to achieve parity with iOS P2.2
Documentation Updates
- Fixed parity matrix: iOS invalid data handling now correctly shows "✅ Recovery tested" with test references
- Updated progress docs (00-STATUS.md, 01-CHANGELOG-WORK.md, 03-TEST-RUNS.md, 04-PARITY-MATRIX.md)
- Updated P2-DESIGN.md to reflect P2.3 scope (Android combined tests)
- Updated SYSTEM_INVARIANTS.md baseline tag references
Baseline Tag
- Created and pushed v1.0.11-p2-complete tag
- Tag represents P2.x completion (schema versioning + combined resilience tests)
All invariants preserved. CI passes. Tests runnable via xcodebuild on macOS.
428 lines
14 KiB
Swift
428 lines
14 KiB
Swift
//
|
|
// DailyNotificationModel.xcdatamodeld
|
|
// DailyNotificationPlugin
|
|
//
|
|
// Created by Matthew Raymer on 2025-09-22
|
|
// Copyright © 2025 TimeSafari. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import CoreData
|
|
|
|
/**
|
|
* Core Data model for Daily Notification Plugin
|
|
* Mirrors Android SQLite schema for cross-platform consistency
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.1.0
|
|
* @created 2025-09-22 09:22:32 UTC
|
|
*/
|
|
|
|
// MARK: - ContentCache Entity
|
|
@objc(ContentCache)
|
|
public class ContentCache: NSManagedObject {
|
|
|
|
}
|
|
|
|
extension ContentCache {
|
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<ContentCache> {
|
|
return NSFetchRequest<ContentCache>(entityName: "ContentCache")
|
|
}
|
|
|
|
@NSManaged public var id: String?
|
|
@NSManaged public var fetchedAt: Date?
|
|
@NSManaged public var ttlSeconds: Int32
|
|
@NSManaged public var payload: Data?
|
|
@NSManaged public var meta: String?
|
|
}
|
|
|
|
extension ContentCache: Identifiable {
|
|
|
|
}
|
|
|
|
// MARK: - Schedule Entity
|
|
@objc(Schedule)
|
|
public class Schedule: NSManagedObject {
|
|
|
|
}
|
|
|
|
extension Schedule {
|
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Schedule> {
|
|
return NSFetchRequest<Schedule>(entityName: "Schedule")
|
|
}
|
|
|
|
@NSManaged public var id: String?
|
|
@NSManaged public var kind: String?
|
|
@NSManaged public var cron: String?
|
|
@NSManaged public var clockTime: String?
|
|
@NSManaged public var enabled: Bool
|
|
@NSManaged public var lastRunAt: Date?
|
|
@NSManaged public var nextRunAt: Date?
|
|
@NSManaged public var jitterMs: Int32
|
|
@NSManaged public var backoffPolicy: String?
|
|
@NSManaged public var stateJson: String?
|
|
}
|
|
|
|
extension Schedule: Identifiable {
|
|
|
|
}
|
|
|
|
// MARK: - Callback Entity
|
|
@objc(Callback)
|
|
public class Callback: NSManagedObject {
|
|
|
|
}
|
|
|
|
extension Callback {
|
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Callback> {
|
|
return NSFetchRequest<Callback>(entityName: "Callback")
|
|
}
|
|
|
|
@NSManaged public var id: String?
|
|
@NSManaged public var kind: String?
|
|
@NSManaged public var target: String?
|
|
@NSManaged public var headersJson: String?
|
|
@NSManaged public var enabled: Bool
|
|
@NSManaged public var createdAt: Date?
|
|
}
|
|
|
|
extension Callback: Identifiable {
|
|
|
|
}
|
|
|
|
// MARK: - History Entity
|
|
@objc(History)
|
|
public class History: NSManagedObject {
|
|
|
|
}
|
|
|
|
extension History {
|
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<History> {
|
|
return NSFetchRequest<History>(entityName: "History")
|
|
}
|
|
|
|
@NSManaged public var id: String?
|
|
@NSManaged public var refId: String?
|
|
@NSManaged public var kind: String?
|
|
@NSManaged public var occurredAt: Date?
|
|
@NSManaged public var durationMs: Int32
|
|
@NSManaged public var outcome: String?
|
|
@NSManaged public var diagJson: String?
|
|
}
|
|
|
|
extension History: Identifiable {
|
|
|
|
}
|
|
|
|
// MARK: - NotificationContent Entity
|
|
@objc(NotificationContentEntity)
|
|
public class NotificationContentEntity: NSManagedObject {
|
|
|
|
}
|
|
|
|
extension NotificationContentEntity {
|
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<NotificationContentEntity> {
|
|
return NSFetchRequest<NotificationContentEntity>(entityName: "NotificationContent")
|
|
}
|
|
|
|
@NSManaged public var id: String?
|
|
@NSManaged public var pluginVersion: String?
|
|
@NSManaged public var timesafariDid: String?
|
|
@NSManaged public var notificationType: String?
|
|
@NSManaged public var title: String?
|
|
@NSManaged public var body: String?
|
|
@NSManaged public var scheduledTime: Date?
|
|
@NSManaged public var timezone: String?
|
|
@NSManaged public var priority: Int32
|
|
@NSManaged public var vibrationEnabled: Bool
|
|
@NSManaged public var soundEnabled: Bool
|
|
@NSManaged public var mediaUrl: String?
|
|
@NSManaged public var encryptedContent: String?
|
|
@NSManaged public var encryptionKeyId: String?
|
|
@NSManaged public var createdAt: Date?
|
|
@NSManaged public var updatedAt: Date?
|
|
@NSManaged public var ttlSeconds: Int64
|
|
@NSManaged public var deliveryStatus: String?
|
|
@NSManaged public var deliveryAttempts: Int32
|
|
@NSManaged public var lastDeliveryAttempt: Date?
|
|
@NSManaged public var userInteractionCount: Int32
|
|
@NSManaged public var lastUserInteraction: Date?
|
|
@NSManaged public var metadata: String?
|
|
}
|
|
|
|
extension NotificationContentEntity: Identifiable {
|
|
|
|
}
|
|
|
|
// MARK: - NotificationDelivery Entity
|
|
@objc(NotificationDelivery)
|
|
public class NotificationDelivery: NSManagedObject {
|
|
|
|
}
|
|
|
|
extension NotificationDelivery {
|
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<NotificationDelivery> {
|
|
return NSFetchRequest<NotificationDelivery>(entityName: "NotificationDelivery")
|
|
}
|
|
|
|
@NSManaged public var id: String?
|
|
@NSManaged public var notificationId: String?
|
|
@NSManaged public var notificationContent: NotificationContentEntity?
|
|
@NSManaged public var timesafariDid: String?
|
|
@NSManaged public var deliveryTimestamp: Date?
|
|
@NSManaged public var deliveryStatus: String?
|
|
@NSManaged public var deliveryMethod: String?
|
|
@NSManaged public var deliveryAttemptNumber: Int32
|
|
@NSManaged public var deliveryDurationMs: Int64
|
|
@NSManaged public var userInteractionType: String?
|
|
@NSManaged public var userInteractionTimestamp: Date?
|
|
@NSManaged public var userInteractionDurationMs: Int64
|
|
@NSManaged public var errorCode: String?
|
|
@NSManaged public var errorMessage: String?
|
|
@NSManaged public var deviceInfo: String?
|
|
@NSManaged public var networkInfo: String?
|
|
@NSManaged public var batteryLevel: Int32
|
|
@NSManaged public var dozeModeActive: Bool
|
|
@NSManaged public var exactAlarmPermission: Bool
|
|
@NSManaged public var notificationPermission: Bool
|
|
@NSManaged public var metadata: String?
|
|
}
|
|
|
|
extension NotificationDelivery: Identifiable {
|
|
|
|
}
|
|
|
|
// MARK: - NotificationConfig Entity
|
|
@objc(NotificationConfig)
|
|
public class NotificationConfig: NSManagedObject {
|
|
|
|
}
|
|
|
|
extension NotificationConfig {
|
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<NotificationConfig> {
|
|
return NSFetchRequest<NotificationConfig>(entityName: "NotificationConfig")
|
|
}
|
|
|
|
@NSManaged public var id: String?
|
|
@NSManaged public var timesafariDid: String?
|
|
@NSManaged public var configType: String?
|
|
@NSManaged public var configKey: String?
|
|
@NSManaged public var configValue: String?
|
|
@NSManaged public var configDataType: String?
|
|
@NSManaged public var isEncrypted: Bool
|
|
@NSManaged public var encryptionKeyId: String?
|
|
@NSManaged public var createdAt: Date?
|
|
@NSManaged public var updatedAt: Date?
|
|
@NSManaged public var ttlSeconds: Int64
|
|
@NSManaged public var isActive: Bool
|
|
@NSManaged public var metadata: String?
|
|
}
|
|
|
|
extension NotificationConfig: Identifiable {
|
|
|
|
}
|
|
|
|
// MARK: - Persistence Controller
|
|
// Phase 2: CoreData integration for advanced features
|
|
// All entities now available: ContentCache, Schedule, Callback, History,
|
|
// NotificationContent, NotificationDelivery, NotificationConfig
|
|
class PersistenceController {
|
|
// MARK: - Schema Versioning
|
|
|
|
/// Current schema version (incremented when schema changes)
|
|
/// This is a logical contract for observability, not a migration gate.
|
|
/// CoreData auto-migration remains authoritative.
|
|
private static let SCHEMA_VERSION = 1
|
|
|
|
// Lazy initialization
|
|
private static var _shared: PersistenceController?
|
|
static var shared: PersistenceController {
|
|
if _shared == nil {
|
|
_shared = PersistenceController()
|
|
}
|
|
return _shared!
|
|
}
|
|
|
|
let container: NSPersistentContainer?
|
|
private var initializationError: Error?
|
|
|
|
init(inMemory: Bool = false) {
|
|
var tempContainer: NSPersistentContainer? = nil
|
|
|
|
do {
|
|
tempContainer = NSPersistentContainer(name: "DailyNotificationModel")
|
|
|
|
if inMemory {
|
|
tempContainer?.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
|
|
}
|
|
|
|
// Configure persistent store options
|
|
let description = tempContainer?.persistentStoreDescriptions.first
|
|
description?.shouldMigrateStoreAutomatically = true
|
|
description?.shouldInferMappingModelAutomatically = true
|
|
|
|
// Set initial schema version metadata (for new stores)
|
|
if !inMemory {
|
|
var metadata = description?.metadata ?? [:]
|
|
if metadata["schema_version"] == nil {
|
|
metadata["schema_version"] = PersistenceController.SCHEMA_VERSION
|
|
description?.metadata = metadata
|
|
}
|
|
}
|
|
|
|
var loadError: Error? = nil
|
|
tempContainer?.loadPersistentStores { description, error in
|
|
if let error = error as NSError? {
|
|
loadError = error
|
|
print("DNP-PLUGIN: CoreData store load error: \(error.localizedDescription)")
|
|
print("DNP-PLUGIN: Error domain: \(error.domain), code: \(error.code)")
|
|
if let failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String {
|
|
print("DNP-PLUGIN: Failure reason: \(failureReason)")
|
|
}
|
|
} else {
|
|
print("DNP-PLUGIN: CoreData store loaded successfully")
|
|
print("DNP-PLUGIN: Store URL: \(description.url?.absoluteString ?? "unknown")")
|
|
}
|
|
}
|
|
|
|
if let error = loadError {
|
|
self.initializationError = error
|
|
self.container = nil
|
|
} else {
|
|
// Configure view context
|
|
if let context = tempContainer?.viewContext {
|
|
context.automaticallyMergesChangesFromParent = true
|
|
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
|
}
|
|
self.container = tempContainer
|
|
|
|
// Check schema version (after container is initialized)
|
|
checkSchemaVersion()
|
|
|
|
// Verify all entities are available (after container is initialized)
|
|
if let context = tempContainer?.viewContext {
|
|
verifyEntities(in: context)
|
|
}
|
|
}
|
|
} catch {
|
|
print("DNP-PLUGIN: Failed to initialize CoreData container: \(error.localizedDescription)")
|
|
self.initializationError = error
|
|
self.container = nil
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if CoreData is available
|
|
*/
|
|
var isAvailable: Bool {
|
|
return container != nil && initializationError == nil
|
|
}
|
|
|
|
/**
|
|
* Get the main view context
|
|
*
|
|
* @return NSManagedObjectContext or nil if not available
|
|
*/
|
|
var viewContext: NSManagedObjectContext? {
|
|
return container?.viewContext
|
|
}
|
|
|
|
/**
|
|
* Create a new background context for async operations
|
|
*
|
|
* @return NSManagedObjectContext or nil if not available
|
|
*/
|
|
func newBackgroundContext() -> NSManagedObjectContext? {
|
|
return container?.newBackgroundContext()
|
|
}
|
|
|
|
/**
|
|
* Save the view context
|
|
*
|
|
* @return true if saved successfully, false otherwise
|
|
*/
|
|
func save() -> Bool {
|
|
guard let context = viewContext else {
|
|
print("DNP-PLUGIN: Cannot save - CoreData not available")
|
|
return false
|
|
}
|
|
|
|
if context.hasChanges {
|
|
do {
|
|
try context.save()
|
|
print("DNP-PLUGIN: CoreData context saved successfully")
|
|
return true
|
|
} catch {
|
|
print("DNP-PLUGIN: Error saving CoreData context: \(error.localizedDescription)")
|
|
context.rollback()
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Check and log schema version
|
|
*
|
|
* Schema version is a logical contract, not a forced migration trigger.
|
|
* CoreData auto-migration remains authoritative; version mismatches are
|
|
* logged, not blocked.
|
|
*/
|
|
private func checkSchemaVersion() {
|
|
guard let store = container?.persistentStoreCoordinator.persistentStores.first else {
|
|
return
|
|
}
|
|
|
|
let currentVersion = store.metadata["schema_version"] as? Int ?? 1
|
|
let expectedVersion = PersistenceController.SCHEMA_VERSION
|
|
|
|
if currentVersion != expectedVersion {
|
|
print("DNP-PLUGIN: Schema version mismatch - current: \(currentVersion), expected: \(expectedVersion)")
|
|
print("DNP-PLUGIN: CoreData auto-migration will handle schema changes")
|
|
|
|
// Update metadata for future reference (does not trigger migration)
|
|
var metadata = store.metadata
|
|
metadata["schema_version"] = expectedVersion
|
|
// Note: Metadata persists on next store save
|
|
} else {
|
|
print("DNP-PLUGIN: Schema version verified: \(currentVersion)")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify all entities are available in the model
|
|
*
|
|
* @param context Managed object context
|
|
*/
|
|
private func verifyEntities(in context: NSManagedObjectContext) {
|
|
guard let model = context.persistentStoreCoordinator?.managedObjectModel else {
|
|
print("DNP-PLUGIN: Cannot verify entities - no managed object model")
|
|
return
|
|
}
|
|
|
|
let entityNames = [
|
|
"ContentCache",
|
|
"Schedule",
|
|
"Callback",
|
|
"History",
|
|
"NotificationContent",
|
|
"NotificationDelivery",
|
|
"NotificationConfig"
|
|
]
|
|
|
|
var missingEntities: [String] = []
|
|
for entityName in entityNames {
|
|
if model.entitiesByName[entityName] == nil {
|
|
missingEntities.append(entityName)
|
|
}
|
|
}
|
|
|
|
if missingEntities.isEmpty {
|
|
print("DNP-PLUGIN: All \(entityNames.count) entities verified in CoreData model")
|
|
} else {
|
|
print("DNP-PLUGIN: WARNING - Missing entities: \(missingEntities.joined(separator: ", "))")
|
|
}
|
|
}
|
|
}
|
|
|