Files
daily-notification-plugin/ios/Plugin/DailyNotificationDatabase.swift
Matthew Raymer c40bc8dab3 feat(ios): implement Phase 2 rolling window, TTL validation, and database stats
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
2025-12-24 07:30:43 +00:00

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