package com.timesafari.dailynotification import androidx.room.* import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase /** * SQLite schema for Daily Notification Plugin * Implements TTL-at-fire invariant and rolling window armed design * * @author Matthew Raymer * @version 1.1.0 */ @Entity(tableName = "content_cache") data class ContentCache( @PrimaryKey val id: String, val fetchedAt: Long, // epoch ms val ttlSeconds: Int, val payload: ByteArray, // BLOB val meta: String? = null ) @Entity(tableName = "schedules") data class Schedule( @PrimaryKey val id: String, val kind: String, // 'fetch' or 'notify' val cron: String? = null, // optional cron expression val clockTime: String? = null, // optional HH:mm val enabled: Boolean = true, val lastRunAt: Long? = null, val nextRunAt: Long? = null, val jitterMs: Int = 0, val backoffPolicy: String = "exp", val stateJson: String? = null ) @Entity(tableName = "callbacks") data class Callback( @PrimaryKey val id: String, val kind: String, // 'http', 'local', 'queue' val target: String, // url_or_local val headersJson: String? = null, val enabled: Boolean = true, val createdAt: Long ) @Entity(tableName = "history") data class History( @PrimaryKey(autoGenerate = true) val id: Int = 0, val refId: String, // content or schedule id val kind: String, // fetch/notify/callback val occurredAt: Long, val durationMs: Long? = null, val outcome: String, // success|failure|skipped_ttl|circuit_open val diagJson: String? = null ) @Database( entities = [ContentCache::class, Schedule::class, Callback::class, History::class], version = 1, exportSchema = false ) @TypeConverters(Converters::class) abstract class DailyNotificationDatabase : RoomDatabase() { abstract fun contentCacheDao(): ContentCacheDao abstract fun scheduleDao(): ScheduleDao abstract fun callbackDao(): CallbackDao abstract fun historyDao(): HistoryDao } @Dao interface ContentCacheDao { @Query("SELECT * FROM content_cache WHERE id = :id") suspend fun getById(id: String): ContentCache? @Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1") suspend fun getLatest(): ContentCache? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(contentCache: ContentCache) @Query("DELETE FROM content_cache WHERE fetchedAt < :cutoffTime") suspend fun deleteOlderThan(cutoffTime: Long) @Query("SELECT COUNT(*) FROM content_cache") suspend fun getCount(): Int } @Dao interface ScheduleDao { @Query("SELECT * FROM schedules WHERE enabled = 1") suspend fun getEnabled(): List @Query("SELECT * FROM schedules WHERE id = :id") suspend fun getById(id: String): Schedule? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(schedule: Schedule) @Query("UPDATE schedules SET enabled = :enabled WHERE id = :id") suspend fun setEnabled(id: String, enabled: Boolean) @Query("UPDATE schedules SET lastRunAt = :lastRunAt, nextRunAt = :nextRunAt WHERE id = :id") suspend fun updateRunTimes(id: String, lastRunAt: Long?, nextRunAt: Long?) } @Dao interface CallbackDao { @Query("SELECT * FROM callbacks WHERE enabled = 1") suspend fun getEnabled(): List @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(callback: Callback) @Query("DELETE FROM callbacks WHERE id = :id") suspend fun deleteById(id: String) } @Dao interface HistoryDao { @Insert suspend fun insert(history: History) @Query("SELECT * FROM history WHERE occurredAt >= :since ORDER BY occurredAt DESC") suspend fun getSince(since: Long): List @Query("DELETE FROM history WHERE occurredAt < :cutoffTime") suspend fun deleteOlderThan(cutoffTime: Long) @Query("SELECT COUNT(*) FROM history") suspend fun getCount(): Int } class Converters { @TypeConverter fun fromByteArray(value: ByteArray?): String? { return value?.let { String(it) } } @TypeConverter fun toByteArray(value: String?): ByteArray? { return value?.toByteArray() } }