Skip to content

Commit bd07a56

Browse files
Rajan MauryaRajan Maurya
authored andcommitted
feat(core/store): complete Phase B seam — Theme + Koin + DB v2 + RoomSubmitOutbox
Phase B chunks 1-4 complete. Builds on the scaffold from prior commit: Chunk 1 — Theme wiring (core/designsystem/.../Theme.kt) + import com.mifos.core.store.appScreenStateDefaults + import template.core.base.ui.screen.LocalScreenStateDefaults + KptMaterialTheme now wraps content in CompositionLocalProvider(LocalScreenStateDefaults provides ...) — every screen under MifosTheme inherits branded ScreenState defaults. + core/designsystem/build.gradle.kts: + implementation(projects.core.store) Chunk 2 — Koin DI wiring + cmp-navigation/.../di/KoinModules.kt registers appStoreModule in 'allModules' list. Feature waves (Phase C) bind their Store<K,V> factories qualifier-bound via AppStoreRegistry. + cmp-navigation/build.gradle.kts: + implementation(projects.core.store) Chunk 3 — Database v1 -> v2 migration (Store5 submit-draft outbox) + core/database/src/commonMain/.../entities/framework/DraftEntity.kt — @entity(tableName = 'framework_submit_drafts'); 7 columns matching template's reference impl. + core/database/src/commonMain/.../dao/DraftDao.kt — @dao interface with insert / getById / getPendingByFormKey / observePendingByFormKey / getAllPending / markRetrying / markSubmitted / markFailed / updatePayload / deleteByFormKey / deleteAll / deleteOlderThan. + commonMain MifosDatabase + 3 platform actuals (android, desktop, native): import DraftEntity / DraftDao, add to entities[], add 'abstract val draftDao: DraftDao', bump VERSION 1 -> 2 with KDoc. + DatabaseModule.android.kt / desktop.kt / PlatformSpecificModule.kt: add MIGRATION_1_2 (raw SQL CREATE TABLE) and chain .addMigrations() onto the AppDatabaseFactory builder. SQL is platform-portable (androidx.room3.migration.Migration + androidx.sqlite.execSQL). + DaoModule.kt: + single { get<MifosDatabase>().draftDao } + core/data/.../infra/impl/RoomSubmitOutbox.kt — Room-backed SubmitOutbox<P>. Serializes payload via kotlinx.serialization, stores in framework_submit_drafts. Idempotent save (upserts on formKey collision). Wires markRetrying / markSubmitted / markFailed to DraftDao status transitions. + core/data/build.gradle.kts: + api(projects.coreBase.store) Chunk 4 — OfflineSubmitSyncer pattern documented in core/store/README.md Syncer is parameterized by payload type P, so it is instantiated per-form in feature DI modules (not globally in MainActivity). Pattern documented with full Koin DI example under 'Phase C syncer pattern' heading. This makes core/store / core/database / core/data ready for Phase C feature waves to start migrating to Store5 with offline-resilient mutations.
1 parent e73331c commit bd07a56

21 files changed

Lines changed: 394 additions & 122 deletions

File tree

cmp-navigation/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ kotlin {
2929
implementation(projects.core.datastore)
3030
implementation(projects.core.database)
3131
implementation(projects.core.network)
32+
implementation(projects.core.store)
3233
implementation(projects.coreBase.common)
3334

3435
implementation(projects.feature.about)

cmp-navigation/src/commonMain/kotlin/cmp/navigation/di/KoinModules.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.mifos.core.datastore.di.PreferencesModule
1818
import com.mifos.core.domain.di.UseCaseModule
1919
import com.mifos.core.network.di.DataManagerModule
2020
import com.mifos.core.network.di.NetworkModule
21+
import com.mifos.core.store.di.appStoreModule
2122
import com.mifos.feature.activate.di.ActivateModule
2223
import com.mifos.feature.auth.di.AuthModule
2324
import com.mifos.feature.center.di.CenterModule
@@ -107,5 +108,6 @@ object KoinModules {
107108
networkModules,
108109
coreDataStoreModules,
109110
coreBaseCommonModules,
111+
appStoreModule,
110112
)
111113
}

core/data/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ kotlin {
4040
api(projects.core.network)
4141
api(projects.core.database)
4242
api(projects.coreBase.common)
43+
api(projects.coreBase.store)
4344

4445

4546
implementation(libs.mifos.authenticator.passcode)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md
9+
*/
10+
package com.mifos.core.data.infra.impl
11+
12+
import com.mifos.room.dao.DraftDao
13+
import com.mifos.room.entities.framework.DraftEntity
14+
import kotlinx.coroutines.flow.Flow
15+
import kotlinx.coroutines.flow.map
16+
import kotlinx.serialization.KSerializer
17+
import kotlinx.serialization.json.Json
18+
import template.core.base.store.submit.SubmitOutbox
19+
import template.core.base.store.submit.SubmitOutboxEntry
20+
import template.core.base.store.submit.SubmitOutboxStatus
21+
22+
/**
23+
* Room-backed [SubmitOutbox] that persists form payloads across process death.
24+
*
25+
* Serializes payloads to JSON via [kotlinx.serialization] and stores them in the
26+
* `framework_submit_drafts` table inside [com.mifos.room.MifosDatabase].
27+
*
28+
* Wire one instance per form type in your DI module:
29+
* ```kotlin
30+
* single<SubmitOutbox<LoanApplicationPayload>> {
31+
* RoomSubmitOutbox(
32+
* dao = get<MifosDatabase>().draftDao,
33+
* serializer = LoanApplicationPayload.serializer(),
34+
* )
35+
* }
36+
* ```
37+
*
38+
* @param P Payload type. Must be annotated with `@Serializable`.
39+
* @param dao The Room DAO for the `framework_submit_drafts` table.
40+
* @param serializer Kotlinx serializer for [P]. Obtain via `P.serializer()`.
41+
*/
42+
class RoomSubmitOutbox<P>(
43+
private val dao: DraftDao,
44+
private val serializer: KSerializer<P>,
45+
) : SubmitOutbox<P> {
46+
47+
private val json = Json { ignoreUnknownKeys = true }
48+
49+
override suspend fun save(formKey: String, payload: P): Long {
50+
val nowMs = currentTimeMillis()
51+
val existing = dao.getPendingByFormKey(formKey)
52+
if (existing != null) {
53+
dao.updatePayload(existing.id, json.encodeToString(serializer, payload), nowMs)
54+
return existing.id
55+
}
56+
val entity = DraftEntity(
57+
formKey = formKey,
58+
payloadJson = json.encodeToString(serializer, payload),
59+
status = SubmitOutboxStatus.PENDING.name,
60+
createdAtMs = nowMs,
61+
updatedAtMs = nowMs,
62+
)
63+
return dao.insert(entity)
64+
}
65+
66+
override suspend fun getPending(formKey: String): SubmitOutboxEntry<P>? =
67+
dao.getPendingByFormKey(formKey)?.toEntry()
68+
69+
override fun observePending(formKey: String): Flow<SubmitOutboxEntry<P>?> =
70+
dao.observePendingByFormKey(formKey).map { it?.toEntry() }
71+
72+
override suspend fun getAllPending(): List<SubmitOutboxEntry<P>> =
73+
dao.getAllPending().mapNotNull { it.toEntry() }
74+
75+
override suspend fun markRetrying(id: Long) =
76+
dao.markRetrying(id, currentTimeMillis())
77+
78+
override suspend fun markSubmitted(id: Long) =
79+
dao.markSubmitted(id, currentTimeMillis())
80+
81+
override suspend fun markFailed(id: Long, error: String?) =
82+
dao.markFailed(id, currentTimeMillis(), error)
83+
84+
override suspend fun deleteByFormKey(formKey: String) =
85+
dao.deleteByFormKey(formKey)
86+
87+
override suspend fun deleteAll() =
88+
dao.deleteAll()
89+
90+
private fun DraftEntity.toEntry(): SubmitOutboxEntry<P>? = runCatching {
91+
SubmitOutboxEntry(
92+
id = id,
93+
formKey = formKey,
94+
payload = json.decodeFromString(serializer, payloadJson),
95+
status = SubmitOutboxStatus.valueOf(status),
96+
createdAtMs = createdAtMs,
97+
errorMessage = errorMessage,
98+
)
99+
}.getOrNull()
100+
}
101+
102+
private fun currentTimeMillis(): Long = kotlin.time.Clock.System.now().toEpochMilliseconds()

core/database/src/androidMain/kotlin/com/mifos/room/MifosDatabase.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ import com.mifos.room.dao.CenterDao
1616
import com.mifos.room.dao.ChargeDao
1717
import com.mifos.room.dao.ClientDao
1818
import com.mifos.room.dao.ColumnValueDao
19+
import com.mifos.room.dao.DraftDao
1920
import com.mifos.room.dao.GroupsDao
2021
import com.mifos.room.dao.LoanDao
2122
import com.mifos.room.dao.OfficeDao
2223
import com.mifos.room.dao.SavingsDao
2324
import com.mifos.room.dao.StaffDao
2425
import com.mifos.room.dao.SurveyDao
2526
import com.mifos.room.entities.PaymentTypeOptionEntity
27+
import com.mifos.room.entities.framework.DraftEntity
2628
import com.mifos.room.entities.accounts.loans.ActualDisbursementDateEntity
2729
import com.mifos.room.entities.accounts.loans.LoanAccountEntity
2830
import com.mifos.room.entities.accounts.loans.LoanRepaymentRequestEntity
@@ -148,6 +150,8 @@ import com.mifos.room.typeconverters.CustomTypeConverters
148150
LoanRepaymentTemplateEntity::class,
149151
// zip models package
150152
PaymentTypeOptionEntity::class,
153+
// framework: submit-draft outbox
154+
DraftEntity::class,
151155
],
152156
version = MifosDatabase.VERSION,
153157
exportSchema = false,
@@ -161,6 +165,7 @@ actual abstract class MifosDatabase : RoomDatabase() {
161165
actual abstract val chargeDao: ChargeDao
162166
actual abstract val clientDao: ClientDao
163167
actual abstract val columnValueDao: ColumnValueDao
168+
actual abstract val draftDao: DraftDao
164169
actual abstract val groupsDao: GroupsDao
165170
actual abstract val loanDao: LoanDao
166171
actual abstract val officeDao: OfficeDao
@@ -169,6 +174,6 @@ actual abstract class MifosDatabase : RoomDatabase() {
169174
actual abstract val surveyDao: SurveyDao
170175

171176
companion object {
172-
const val VERSION = 1
177+
const val VERSION = 2
173178
}
174179
}

core/database/src/androidMain/kotlin/com/mifos/room/di/DatabaseModule.android.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
*/
1010
package com.mifos.room.di
1111

12+
import androidx.room3.migration.Migration
13+
import androidx.sqlite.SQLiteConnection
1214
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
15+
import androidx.sqlite.execSQL
1316
import com.mifos.core.common.network.MifosDispatchers
1417
import com.mifos.core.common.utils.Constants
1518
import com.mifos.room.MifosDatabase
@@ -20,13 +23,36 @@ import org.koin.dsl.module
2023
import template.core.base.database.AppDatabaseFactory
2124
import kotlin.coroutines.CoroutineContext
2225

26+
/**
27+
* Adds the `framework_submit_drafts` table — see
28+
* [com.mifos.room.entities.framework.DraftEntity] for the schema.
29+
*/
30+
internal val MIGRATION_1_2 = object : Migration(1, 2) {
31+
override fun migrate(connection: SQLiteConnection) {
32+
connection.execSQL(
33+
"""
34+
CREATE TABLE IF NOT EXISTS `framework_submit_drafts` (
35+
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
36+
`formKey` TEXT NOT NULL,
37+
`payloadJson` TEXT NOT NULL,
38+
`status` TEXT NOT NULL,
39+
`createdAtMs` INTEGER NOT NULL,
40+
`updatedAtMs` INTEGER NOT NULL,
41+
`errorMessage` TEXT
42+
)
43+
""".trimIndent(),
44+
)
45+
}
46+
}
47+
2348
actual val PlatformSpecificDatabaseModule: Module = module {
2449
single<MifosDatabase> {
2550
val ioContext: CoroutineContext = getKoin().get(named(MifosDispatchers.IO.name))
2651

2752
AppDatabaseFactory(androidApplication())
2853
.createDatabase(MifosDatabase::class.java, Constants.DATABASE_NAME)
2954
.fallbackToDestructiveMigrationOnDowngrade(false)
55+
.addMigrations(MIGRATION_1_2)
3056
.setDriver(BundledSQLiteDriver())
3157
.setQueryCoroutineContext(ioContext)
3258
.build()

core/database/src/commonMain/kotlin/com/mifos/room/MifosDatabase.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.mifos.room.dao.CenterDao
1313
import com.mifos.room.dao.ChargeDao
1414
import com.mifos.room.dao.ClientDao
1515
import com.mifos.room.dao.ColumnValueDao
16+
import com.mifos.room.dao.DraftDao
1617
import com.mifos.room.dao.GroupsDao
1718
import com.mifos.room.dao.LoanDao
1819
import com.mifos.room.dao.OfficeDao
@@ -26,6 +27,7 @@ expect abstract class MifosDatabase {
2627
abstract val chargeDao: ChargeDao
2728
abstract val clientDao: ClientDao
2829
abstract val columnValueDao: ColumnValueDao
30+
abstract val draftDao: DraftDao
2931
abstract val groupsDao: GroupsDao
3032
abstract val loanDao: LoanDao
3133
abstract val officeDao: OfficeDao
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md
9+
*/
10+
package com.mifos.room.dao
11+
12+
import com.mifos.room.entities.framework.DraftEntity
13+
import kotlinx.coroutines.flow.Flow
14+
import template.core.base.database.Dao
15+
import template.core.base.database.Insert
16+
import template.core.base.database.OnConflictStrategy
17+
import template.core.base.database.Query
18+
19+
/**
20+
* DAO for the framework-owned `framework_submit_drafts` table.
21+
*
22+
* Consumed by [com.mifos.core.data.infra.impl.RoomSubmitOutbox]. The framework uses
23+
* this table to persist form payloads that failed to reach the server so users can
24+
* resume or retry later.
25+
*/
26+
@Dao
27+
interface DraftDao {
28+
29+
@Insert(onConflict = OnConflictStrategy.REPLACE)
30+
suspend fun insert(entity: DraftEntity): Long
31+
32+
@Query("SELECT * FROM framework_submit_drafts WHERE id = :id")
33+
suspend fun getById(id: Long): DraftEntity?
34+
35+
@Query("SELECT * FROM framework_submit_drafts WHERE formKey = :formKey AND status = 'PENDING' LIMIT 1")
36+
suspend fun getPendingByFormKey(formKey: String): DraftEntity?
37+
38+
@Query("SELECT * FROM framework_submit_drafts WHERE formKey = :formKey AND status = 'PENDING' LIMIT 1")
39+
fun observePendingByFormKey(formKey: String): Flow<DraftEntity?>
40+
41+
@Query("SELECT * FROM framework_submit_drafts WHERE status = 'PENDING'")
42+
suspend fun getAllPending(): List<DraftEntity>
43+
44+
@Query("UPDATE framework_submit_drafts SET status = 'RETRYING', updatedAtMs = :nowMs WHERE id = :id")
45+
suspend fun markRetrying(id: Long, nowMs: Long)
46+
47+
@Query("UPDATE framework_submit_drafts SET status = 'SUBMITTED', updatedAtMs = :nowMs WHERE id = :id")
48+
suspend fun markSubmitted(id: Long, nowMs: Long)
49+
50+
@Query(
51+
"UPDATE framework_submit_drafts SET status = 'FAILED', " +
52+
"updatedAtMs = :nowMs, errorMessage = :error WHERE id = :id",
53+
)
54+
suspend fun markFailed(id: Long, nowMs: Long, error: String?)
55+
56+
@Query("UPDATE framework_submit_drafts SET payloadJson = :payloadJson, updatedAtMs = :nowMs WHERE id = :id")
57+
suspend fun updatePayload(id: Long, payloadJson: String, nowMs: Long)
58+
59+
@Query("DELETE FROM framework_submit_drafts WHERE formKey = :formKey")
60+
suspend fun deleteByFormKey(formKey: String)
61+
62+
@Query("DELETE FROM framework_submit_drafts")
63+
suspend fun deleteAll()
64+
65+
/**
66+
* Deletes SUBMITTED and FAILED rows older than [thresholdMs] (epoch millis).
67+
* PENDING drafts are never pruned here — the user may still want to resume them.
68+
* Call on app start via the framework's StoreCacheManager.pruneExpiredDrafts hook.
69+
*/
70+
@Query(
71+
"DELETE FROM framework_submit_drafts " +
72+
"WHERE createdAtMs < :thresholdMs AND status IN ('SUBMITTED', 'FAILED')",
73+
)
74+
suspend fun deleteOlderThan(thresholdMs: Long)
75+
}

core/database/src/commonMain/kotlin/com/mifos/room/di/DaoModule.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ val DaoModule = module {
1818
single { get<MifosDatabase>().chargeDao }
1919
single { get<MifosDatabase>().clientDao }
2020
single { get<MifosDatabase>().columnValueDao }
21+
single { get<MifosDatabase>().draftDao }
2122
single { get<MifosDatabase>().groupsDao }
2223
single { get<MifosDatabase>().loanDao }
2324
single { get<MifosDatabase>().officeDao }
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* See https://github.com/openMF/mifos-x-field-officer-app/blob/master/LICENSE.md
9+
*/
10+
package com.mifos.room.entities.framework
11+
12+
import template.core.base.database.Entity
13+
import template.core.base.database.PrimaryKey
14+
15+
/**
16+
* Durable row for a form payload that failed to reach the server.
17+
*
18+
* Written by [com.mifos.core.data.infra.impl.RoomSubmitOutbox] when a network error
19+
* occurs during form submission. The row survives process death so the user can
20+
* resume or retry later.
21+
*
22+
* @param id Auto-generated surrogate key.
23+
* @param formKey Consumer-defined identifier that groups drafts by screen/form type
24+
* (e.g. `"loan_application"`, `"client_registration"`). One pending
25+
* draft per formKey is the intended invariant — consumers enforce
26+
* uniqueness via [com.mifos.room.dao.DraftDao.deleteByFormKey] before
27+
* inserting.
28+
* @param payloadJson Serialized form payload (JSON). The concrete type is opaque to the
29+
* framework; consumers encode/decode via kotlinx.serialization.
30+
* @param status Lifecycle state — one of
31+
* [template.core.base.store.submit.SubmitOutboxStatus] values
32+
* serialized as `.name`.
33+
* @param createdAtMs Epoch millis when the draft was first saved.
34+
* @param updatedAtMs Epoch millis of the most recent status transition.
35+
* @param errorMessage Last failure reason for display in the resume UI (nullable).
36+
*/
37+
@Entity(
38+
indices = [],
39+
inheritSuperIndices = false,
40+
primaryKeys = [],
41+
foreignKeys = [],
42+
ignoredColumns = [],
43+
tableName = "framework_submit_drafts",
44+
)
45+
data class DraftEntity(
46+
@PrimaryKey(autoGenerate = true) val id: Long = 0,
47+
val formKey: String,
48+
val payloadJson: String,
49+
val status: String,
50+
val createdAtMs: Long,
51+
val updatedAtMs: Long,
52+
val errorMessage: String? = null,
53+
)

0 commit comments

Comments
 (0)