Implement 4 of 8 Phase 2 iOS enhancements from TODO review. Changes: - DailyNotificationStateActor: Remove TODOs, implement TTL validation - maintainRollingWindow(): Already implemented, removed TODO - validateContentFreshness(): Now calls ttlEnforcer.validateBeforeArming() - DailyNotificationDatabase: Add queryInt() method for PRAGMA queries - Enables database statistics collection (page_count, page_size, cache_size) - DailyNotificationPerformanceOptimizer: Implement database stats and metrics - analyzeDatabasePerformance(): Queries PRAGMA values and records metrics - Removed 2 TODOs (database statistics, metrics recording) Verification: - TypeScript typecheck: PASS - All TODOs removed from fixed files Remaining Phase 2 items (4): - DailyNotificationBackgroundTasks: CoreData history - DailyNotificationReactivationManager: Fetcher instance - DailyNotificationPlugin: Fetcher instance - Additional items to verify
348 lines
11 KiB
Swift
348 lines
11 KiB
Swift
/**
|
|
* DailyNotificationDatabase.swift
|
|
*
|
|
* iOS SQLite database management for daily notifications
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.0.0
|
|
*/
|
|
|
|
import Foundation
|
|
import SQLite3
|
|
|
|
/**
|
|
* SQLite database manager for daily notifications on iOS
|
|
*
|
|
* This class manages the SQLite database with the three-table schema:
|
|
* - notif_contents: keep history, fast newest-first reads
|
|
* - notif_deliveries: track many deliveries per slot/time
|
|
* - notif_config: generic configuration KV
|
|
*/
|
|
class DailyNotificationDatabase {
|
|
|
|
// MARK: - Constants
|
|
|
|
private static let TAG = "DailyNotificationDatabase"
|
|
|
|
// Table names
|
|
static let TABLE_NOTIF_CONTENTS = "notif_contents"
|
|
static let TABLE_NOTIF_DELIVERIES = "notif_deliveries"
|
|
static let TABLE_NOTIF_CONFIG = "notif_config"
|
|
|
|
// Column names
|
|
static let COL_CONTENTS_ID = "id"
|
|
static let COL_CONTENTS_SLOT_ID = "slot_id"
|
|
static let COL_CONTENTS_PAYLOAD_JSON = "payload_json"
|
|
static let COL_CONTENTS_FETCHED_AT = "fetched_at"
|
|
static let COL_CONTENTS_ETAG = "etag"
|
|
|
|
static let COL_DELIVERIES_ID = "id"
|
|
static let COL_DELIVERIES_SLOT_ID = "slot_id"
|
|
static let COL_DELIVERIES_FIRE_AT = "fire_at"
|
|
static let COL_DELIVERIES_DELIVERED_AT = "delivered_at"
|
|
static let COL_DELIVERIES_STATUS = "status"
|
|
static let COL_DELIVERIES_ERROR_CODE = "error_code"
|
|
static let COL_DELIVERIES_ERROR_MESSAGE = "error_message"
|
|
|
|
static let COL_CONFIG_K = "k"
|
|
static let COL_CONFIG_V = "v"
|
|
|
|
// Status values
|
|
static let STATUS_SCHEDULED = "scheduled"
|
|
static let STATUS_SHOWN = "shown"
|
|
static let STATUS_ERROR = "error"
|
|
static let STATUS_CANCELED = "canceled"
|
|
|
|
// MARK: - Properties
|
|
|
|
private var db: OpaquePointer?
|
|
private let path: String
|
|
|
|
// MARK: - Initialization
|
|
|
|
/**
|
|
* Initialize database with path
|
|
*
|
|
* @param path Database file path
|
|
*/
|
|
init(path: String) {
|
|
self.path = path
|
|
openDatabase()
|
|
}
|
|
|
|
/**
|
|
* Open database connection
|
|
*/
|
|
private func openDatabase() {
|
|
if sqlite3_open(path, &db) == SQLITE_OK {
|
|
print("\(Self.TAG): Database opened successfully at \(path)")
|
|
createTables()
|
|
configureDatabase()
|
|
} else {
|
|
print("\(Self.TAG): Error opening database: \(String(cString: sqlite3_errmsg(db)))")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create database tables
|
|
*/
|
|
private func createTables() {
|
|
// Create notif_contents table
|
|
let createContentsTable = """
|
|
CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_CONTENTS)(
|
|
\(Self.COL_CONTENTS_ID) INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
\(Self.COL_CONTENTS_SLOT_ID) TEXT NOT NULL,
|
|
\(Self.COL_CONTENTS_PAYLOAD_JSON) TEXT NOT NULL,
|
|
\(Self.COL_CONTENTS_FETCHED_AT) INTEGER NOT NULL,
|
|
\(Self.COL_CONTENTS_ETAG) TEXT,
|
|
UNIQUE(\(Self.COL_CONTENTS_SLOT_ID), \(Self.COL_CONTENTS_FETCHED_AT))
|
|
);
|
|
"""
|
|
|
|
// Create notif_deliveries table
|
|
let createDeliveriesTable = """
|
|
CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_DELIVERIES)(
|
|
\(Self.COL_DELIVERIES_ID) INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
\(Self.COL_DELIVERIES_SLOT_ID) TEXT NOT NULL,
|
|
\(Self.COL_DELIVERIES_FIRE_AT) INTEGER NOT NULL,
|
|
\(Self.COL_DELIVERIES_DELIVERED_AT) INTEGER,
|
|
\(Self.COL_DELIVERIES_STATUS) TEXT NOT NULL DEFAULT '\(Self.STATUS_SCHEDULED)',
|
|
\(Self.COL_DELIVERIES_ERROR_CODE) TEXT,
|
|
\(Self.COL_DELIVERIES_ERROR_MESSAGE) TEXT
|
|
);
|
|
"""
|
|
|
|
// Create notif_config table
|
|
let createConfigTable = """
|
|
CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_CONFIG)(
|
|
\(Self.COL_CONFIG_K) TEXT PRIMARY KEY,
|
|
\(Self.COL_CONFIG_V) TEXT NOT NULL
|
|
);
|
|
"""
|
|
|
|
// Create indexes
|
|
let createContentsIndex = """
|
|
CREATE INDEX IF NOT EXISTS notif_idx_contents_slot_time
|
|
ON \(Self.TABLE_NOTIF_CONTENTS)(\(Self.COL_CONTENTS_SLOT_ID), \(Self.COL_CONTENTS_FETCHED_AT) DESC);
|
|
"""
|
|
|
|
// Execute table creation
|
|
executeSQL(createContentsTable)
|
|
executeSQL(createDeliveriesTable)
|
|
executeSQL(createConfigTable)
|
|
executeSQL(createContentsIndex)
|
|
|
|
print("\(Self.TAG): Database tables created successfully")
|
|
}
|
|
|
|
/**
|
|
* Configure database settings
|
|
*/
|
|
private func configureDatabase() {
|
|
// Enable WAL mode
|
|
executeSQL("PRAGMA journal_mode=WAL")
|
|
|
|
// Set synchronous mode
|
|
executeSQL("PRAGMA synchronous=NORMAL")
|
|
|
|
// Set busy timeout
|
|
executeSQL("PRAGMA busy_timeout=5000")
|
|
|
|
// Enable foreign keys
|
|
executeSQL("PRAGMA foreign_keys=ON")
|
|
|
|
// Set user version
|
|
executeSQL("PRAGMA user_version=1")
|
|
|
|
print("\(Self.TAG): Database configured successfully")
|
|
}
|
|
|
|
/**
|
|
* Execute SQL statement
|
|
*
|
|
* @param sql SQL statement to execute
|
|
*/
|
|
func executeSQL(_ sql: String) {
|
|
var statement: OpaquePointer?
|
|
|
|
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK {
|
|
if sqlite3_step(statement) == SQLITE_DONE {
|
|
print("\(Self.TAG): SQL executed successfully: \(sql)")
|
|
} else {
|
|
print("\(Self.TAG): SQL execution failed: \(String(cString: sqlite3_errmsg(db)))")
|
|
}
|
|
} else {
|
|
print("\(Self.TAG): SQL preparation failed: \(String(cString: sqlite3_errmsg(db)))")
|
|
}
|
|
|
|
sqlite3_finalize(statement)
|
|
}
|
|
|
|
/**
|
|
* Query SQL and return integer result
|
|
*
|
|
* @param sql SQL query statement
|
|
* @return Integer result or nil if query fails
|
|
*/
|
|
func queryInt(_ sql: String) -> Int? {
|
|
var statement: OpaquePointer?
|
|
var result: Int? = nil
|
|
|
|
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK {
|
|
if sqlite3_step(statement) == SQLITE_ROW {
|
|
result = Int(sqlite3_column_int(statement, 0))
|
|
}
|
|
} else {
|
|
print("\(Self.TAG): Query preparation failed: \(String(cString: sqlite3_errmsg(db)))")
|
|
}
|
|
|
|
sqlite3_finalize(statement)
|
|
return result
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/**
|
|
* Close database connection
|
|
*/
|
|
func close() {
|
|
if sqlite3_close(db) == SQLITE_OK {
|
|
print("\(Self.TAG): Database closed successfully")
|
|
} else {
|
|
print("\(Self.TAG): Error closing database: \(String(cString: sqlite3_errmsg(db)))")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get database path
|
|
*
|
|
* @return Database file path
|
|
*/
|
|
func getPath() -> String {
|
|
return path
|
|
}
|
|
|
|
/**
|
|
* Check if database is open
|
|
*
|
|
* @return true if database is open
|
|
*/
|
|
func isOpen() -> Bool {
|
|
return db != nil
|
|
}
|
|
|
|
/**
|
|
* Save notification content to database
|
|
*
|
|
* @param content Notification content to save
|
|
*/
|
|
func saveNotificationContent(_ content: NotificationContent) {
|
|
do {
|
|
guard db != nil else {
|
|
print("\(Self.TAG): DB not open; cannot saveNotificationContent for \(content.id)")
|
|
return
|
|
}
|
|
|
|
let encoder = JSONEncoder()
|
|
let data = try encoder.encode(content)
|
|
guard let json = String(data: data, encoding: .utf8) else {
|
|
print("\(Self.TAG): Failed to encode NotificationContent to UTF-8 JSON for \(content.id)")
|
|
return
|
|
}
|
|
|
|
let sql = """
|
|
INSERT OR REPLACE INTO \(Self.TABLE_NOTIF_CONTENTS)
|
|
(\(Self.COL_CONTENTS_SLOT_ID), \(Self.COL_CONTENTS_PAYLOAD_JSON), \(Self.COL_CONTENTS_FETCHED_AT), \(Self.COL_CONTENTS_ETAG))
|
|
VALUES (?, ?, ?, ?);
|
|
"""
|
|
|
|
var stmt: OpaquePointer?
|
|
if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK {
|
|
print("\(Self.TAG): saveNotificationContent prepare failed: \(String(cString: sqlite3_errmsg(db)))")
|
|
sqlite3_finalize(stmt)
|
|
return
|
|
}
|
|
|
|
sqlite3_bind_text(stmt, 1, (content.id as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
|
sqlite3_bind_text(stmt, 2, (json as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
|
sqlite3_bind_int64(stmt, 3, sqlite3_int64(content.fetchedAt))
|
|
|
|
if let etag = content.etag {
|
|
sqlite3_bind_text(stmt, 4, (etag as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
|
} else {
|
|
sqlite3_bind_null(stmt, 4)
|
|
}
|
|
|
|
if sqlite3_step(stmt) != SQLITE_DONE {
|
|
print("\(Self.TAG): saveNotificationContent step failed: \(String(cString: sqlite3_errmsg(db)))")
|
|
} else {
|
|
print("\(Self.TAG): Saved notification content: slot=\(content.id) fetched_at=\(content.fetchedAt)")
|
|
}
|
|
|
|
sqlite3_finalize(stmt)
|
|
|
|
} catch {
|
|
print("\(Self.TAG): saveNotificationContent error for \(content.id): \(error)")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete notification content from database
|
|
*
|
|
* @param id Notification ID
|
|
*/
|
|
func deleteNotificationContent(id: String) {
|
|
do {
|
|
guard db != nil else {
|
|
print("\(Self.TAG): DB not open; cannot deleteNotificationContent for \(id)")
|
|
return
|
|
}
|
|
|
|
let sql = """
|
|
DELETE FROM \(Self.TABLE_NOTIF_CONTENTS)
|
|
WHERE \(Self.COL_CONTENTS_SLOT_ID) = ?;
|
|
"""
|
|
|
|
var stmt: OpaquePointer?
|
|
if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK {
|
|
print("\(Self.TAG): deleteNotificationContent prepare failed: \(String(cString: sqlite3_errmsg(db)))")
|
|
sqlite3_finalize(stmt)
|
|
return
|
|
}
|
|
|
|
sqlite3_bind_text(stmt, 1, (id as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
|
|
|
if sqlite3_step(stmt) != SQLITE_DONE {
|
|
print("\(Self.TAG): deleteNotificationContent step failed: \(String(cString: sqlite3_errmsg(db)))")
|
|
} else {
|
|
print("\(Self.TAG): Deleted notification content rows for slot=\(id)")
|
|
}
|
|
|
|
sqlite3_finalize(stmt)
|
|
|
|
} catch {
|
|
print("\(Self.TAG): deleteNotificationContent error for \(id): \(error)")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all notifications from database
|
|
*/
|
|
func clearAllNotifications() {
|
|
do {
|
|
guard db != nil else {
|
|
print("\(Self.TAG): DB not open; cannot clearAllNotifications")
|
|
return
|
|
}
|
|
|
|
executeSQL("DELETE FROM \(Self.TABLE_NOTIF_CONTENTS);")
|
|
executeSQL("DELETE FROM \(Self.TABLE_NOTIF_DELIVERIES);")
|
|
|
|
print("\(Self.TAG): Cleared all notifications (contents + deliveries)")
|
|
|
|
} catch {
|
|
print("\(Self.TAG): clearAllNotifications error: \(error)")
|
|
}
|
|
}
|
|
}
|