From e842355a6b8357f2023fa98608cb7a89f26d00f8 Mon Sep 17 00:00:00 2001 From: Avinal Kumar Date: Tue, 19 May 2026 17:08:18 +0530 Subject: [PATCH] Add Room database with multiplatform support - MemoEntity with JSON-serialized attachments and reactions columns - MemoDao: observe all/by-id, upsert, delete operations via Flow - Entity-domain mappers with JSON serialization for nested data - DatabaseFactory: expect/actual pattern for platform-specific builders - Android: Room.databaseBuilder with context - iOS: Room.databaseBuilder with NSHomeDirectory path - BundledSQLiteDriver for consistent cross-platform SQLite - Destructive migration fallback for schema changes Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Avinal Kumar --- .../memos/db/DatabaseFactory.android.kt | 14 +++ .../com/avinal/memos/db/DatabaseFactory.kt | 15 +++ .../com/avinal/memos/db/MemosDatabase.kt | 11 ++ .../kotlin/com/avinal/memos/db/dao/MemoDao.kt | 35 ++++++ .../avinal/memos/db/entity/EntityMappers.kt | 111 ++++++++++++++++++ .../com/avinal/memos/db/entity/MemoEntity.kt | 26 ++++ .../avinal/memos/db/DatabaseFactory.ios.kt | 13 ++ 7 files changed, 225 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/com/avinal/memos/db/DatabaseFactory.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/db/DatabaseFactory.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/db/MemosDatabase.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/db/dao/MemoDao.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/db/entity/EntityMappers.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/db/entity/MemoEntity.kt create mode 100644 composeApp/src/iosMain/kotlin/com/avinal/memos/db/DatabaseFactory.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/db/DatabaseFactory.android.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/db/DatabaseFactory.android.kt new file mode 100644 index 0000000..88a5962 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/db/DatabaseFactory.android.kt @@ -0,0 +1,14 @@ +package com.avinal.memos.db + +import android.content.Context +import androidx.room.Room + +actual fun createPlatformDatabase(context: Any): MemosDatabase { + val ctx = context as Context + return buildDatabase( + Room.databaseBuilder( + context = ctx, + name = ctx.getDatabasePath("memos.db").absolutePath, + ) + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/db/DatabaseFactory.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/DatabaseFactory.kt new file mode 100644 index 0000000..fb5d095 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/DatabaseFactory.kt @@ -0,0 +1,15 @@ +package com.avinal.memos.db + +import androidx.room.RoomDatabase +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO + +fun buildDatabase(builder: RoomDatabase.Builder): MemosDatabase = + builder + .fallbackToDestructiveMigration(true) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + +expect fun createPlatformDatabase(context: Any): MemosDatabase diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/db/MemosDatabase.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/MemosDatabase.kt new file mode 100644 index 0000000..e08bea9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/MemosDatabase.kt @@ -0,0 +1,11 @@ +package com.avinal.memos.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.avinal.memos.db.dao.MemoDao +import com.avinal.memos.db.entity.MemoEntity + +@Database(entities = [MemoEntity::class], version = 3) +abstract class MemosDatabase : RoomDatabase() { + abstract fun memoDao(): MemoDao +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/db/dao/MemoDao.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/dao/MemoDao.kt new file mode 100644 index 0000000..52ea08e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/dao/MemoDao.kt @@ -0,0 +1,35 @@ +package com.avinal.memos.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.avinal.memos.db.entity.MemoEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface MemoDao { + + @Query("SELECT * FROM memos ORDER BY pinned DESC, updateTime DESC") + fun observeAll(): Flow> + + @Query("SELECT * FROM memos ORDER BY pinned DESC, updateTime DESC") + suspend fun getAll(): List + + @Query("SELECT * FROM memos WHERE id = :id") + suspend fun getById(id: String): MemoEntity? + + @Query("SELECT * FROM memos WHERE id = :id") + fun observeById(id: String): Flow + + @Upsert + suspend fun upsert(memo: MemoEntity) + + @Upsert + suspend fun upsertAll(memos: List) + + @Query("DELETE FROM memos WHERE id = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM memos") + suspend fun deleteAll() +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/db/entity/EntityMappers.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/entity/EntityMappers.kt new file mode 100644 index 0000000..ee5acf4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/entity/EntityMappers.kt @@ -0,0 +1,111 @@ +package com.avinal.memos.db.entity + +import com.avinal.memos.domain.Attachment +import com.avinal.memos.domain.Memo +import com.avinal.memos.domain.MemoVisibility +import com.avinal.memos.domain.Reaction +import kotlin.time.Instant +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + +private val json = Json { ignoreUnknownKeys = true } + +@Serializable +private data class AttachmentJson( + val name: String, + val filename: String, + val mimeType: String, + val size: Long, + val externalLink: String, +) + +@Serializable +private data class ReactionJson( + val id: String, + val creator: String, + val reactionType: String, +) + +fun MemoEntity.toDomain(): Memo = Memo( + id = id, + uid = uid, + content = content, + visibility = MemoVisibility.fromApiString(visibility), + pinned = pinned, + state = state, + createTime = Instant.fromEpochMilliseconds(createTime), + updateTime = Instant.fromEpochMilliseconds(updateTime), + displayTime = Instant.fromEpochMilliseconds(displayTime), + creator = creator, + hasTaskList = hasTaskList, + hasIncompleteTasks = hasIncompleteTasks, + title = title, + tags = deserializeTags(tags), + snippet = snippet, + attachments = deserializeAttachments(attachmentsJson), + reactions = deserializeReactions(reactionsJson), +) + +fun Memo.toEntity(cachedAt: Long): MemoEntity = MemoEntity( + id = id, + uid = uid, + content = content, + visibility = visibility.toApiString(), + pinned = pinned, + state = state, + createTime = createTime.toEpochMilliseconds(), + updateTime = updateTime.toEpochMilliseconds(), + displayTime = displayTime.toEpochMilliseconds(), + creator = creator, + hasTaskList = hasTaskList, + hasIncompleteTasks = hasIncompleteTasks, + title = title, + tags = serializeTags(tags), + snippet = snippet, + attachmentsJson = serializeAttachments(attachments), + reactionsJson = serializeReactions(reactions), + cachedAt = cachedAt, +) + +private fun serializeTags(tags: List): String = + JsonArray(tags.map { JsonPrimitive(it) }).toString() + +private fun deserializeTags(value: String): List = + if (value.isEmpty() || value == "[]") emptyList() + else try { + json.parseToJsonElement(value).jsonArray.map { it.jsonPrimitive.content } + } catch (_: Exception) { + emptyList() + } + +private fun serializeAttachments(attachments: List): String = + json.encodeToString(kotlinx.serialization.builtins.ListSerializer(AttachmentJson.serializer()), + attachments.map { AttachmentJson(it.name, it.filename, it.mimeType, it.size, it.externalLink) } + ) + +private fun deserializeAttachments(value: String): List = + if (value.isEmpty() || value == "[]") emptyList() + else try { + json.decodeFromString(kotlinx.serialization.builtins.ListSerializer(AttachmentJson.serializer()), value) + .map { Attachment(it.name, it.filename, it.mimeType, it.size, it.externalLink) } + } catch (_: Exception) { + emptyList() + } + +private fun serializeReactions(reactions: List): String = + json.encodeToString(kotlinx.serialization.builtins.ListSerializer(ReactionJson.serializer()), + reactions.map { ReactionJson(it.id, it.creator, it.reactionType) } + ) + +private fun deserializeReactions(value: String): List = + if (value.isEmpty() || value == "[]") emptyList() + else try { + json.decodeFromString(kotlinx.serialization.builtins.ListSerializer(ReactionJson.serializer()), value) + .map { Reaction(it.id, it.creator, it.reactionType) } + } catch (_: Exception) { + emptyList() + } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/db/entity/MemoEntity.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/entity/MemoEntity.kt new file mode 100644 index 0000000..05f1d55 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/entity/MemoEntity.kt @@ -0,0 +1,26 @@ +package com.avinal.memos.db.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "memos") +data class MemoEntity( + @PrimaryKey val id: String, + val uid: String, + val content: String, + val visibility: String, + val pinned: Boolean, + val state: String, + val createTime: Long, + val updateTime: Long, + val displayTime: Long, + val creator: String, + val hasTaskList: Boolean, + val hasIncompleteTasks: Boolean, + val title: String, + val tags: String, + val snippet: String, + val attachmentsJson: String = "[]", + val reactionsJson: String = "[]", + val cachedAt: Long, +) diff --git a/composeApp/src/iosMain/kotlin/com/avinal/memos/db/DatabaseFactory.ios.kt b/composeApp/src/iosMain/kotlin/com/avinal/memos/db/DatabaseFactory.ios.kt new file mode 100644 index 0000000..a79ad0b --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/avinal/memos/db/DatabaseFactory.ios.kt @@ -0,0 +1,13 @@ +package com.avinal.memos.db + +import androidx.room.Room + +actual fun createPlatformDatabase(context: Any): MemosDatabase { + val dbPath = NSHomeDirectory() + "/Documents/memos.db" + return buildDatabase( + Room.databaseBuilder(name = dbPath) + ) +} + +private fun NSHomeDirectory(): String = + platform.Foundation.NSHomeDirectory()