Add a repository mutation
Every change a user makes to their data goes through one class,
CadenceRepository.
A new mutation has four obligations: it must announce itself to sync, commit as one unit
when it touches several stores, reach the UI through the ViewModel, and, if it deletes
anything, be undoable and cancel reminders. This guide goes through each one.
The running example is a hypothetical renameTask(task, title) that refuses a blank title.
Prerequisites
Section titled “Prerequisites”- You know where the data flows: the tour of the code.
- You can run the tests.
1. Route the write through write { }
Section titled “1. Route the write through write { }”write { } is the private wrapper that runs a block and then ticks localWrites. The
ViewModel’s two-second sync debounce collects that tick. A mutation that skips it saves locally
and never syncs: the other devices don’t hear about it until some unrelated write happens to
push the row.
For a mutation that can’t fail, wrap the whole body, as setDueDate does:
suspend fun setDueDate(task: Task, dueDate: LocalDate?): Unit = write { taskStore.update(task.copy(dueDate = dueDate, updatedAt = now()))}Two details in that one line matter everywhere:
- Stamp
updatedAt = now(). Last-writer-wins comparesupdatedAt, so an edit without a new stamp loses to the server’s copy and gets overwritten on the next pull.now()reads the injectedClockand truncates to milliseconds. Never callInstant.now()directly. - Write through a store port. Never write SQL from the repository.
2. Tick only on success when the result is a RepositoryResult
Section titled “2. Tick only on success when the result is a RepositoryResult”A mutation that validates returns RepositoryResult (Success, Error, ValidationError) and
wraps only the store call on the success path. A refused write must not wake sync:
suspend fun renameTask(task: Task, title: String): RepositoryResult<Unit> { val trimmed = title.trim() if (trimmed.isEmpty()) return RepositoryResult.ValidationError return try { write { taskStore.update(task.copy(title = trimmed, updatedAt = now())) } RepositoryResult.Success(Unit) } catch (e: CancellationException) { throw e } catch (e: Exception) { RepositoryResult.Error("Failed to rename task", e) }}Rethrowing CancellationException keeps coroutine cancellation working. upsertProject,
upsertSection and upsertTag have the same shape.
3. Use one StoreTransaction when the write spans stores or steps
Section titled “3. Use one StoreTransaction when the write spans stores or steps”If the mutation is a chain of writes that only makes sense as a unit (close a row and insert
its successor, delete attachments and tombstone the task), run the chain inside
storeTransaction.run { … }. Otherwise a process killed between two steps leaves a half-done
state. deleteTask is the model:
suspend fun deleteTask(id: String): Unit = write { val hashes = storeTransaction.run { val ids = listOf(id) + subtasksOf(id).map { it.id } val hashes = hashesForTasks(ids) deleteAttachmentsForTasks(ids) tombstoneTaskWithSubtasks(id, now()) hashes } reclaim(hashes)}The block runs against a
TransactionScope whose
members are plain, non-suspending functions. That’s deliberate: SQLDelight’s transaction
callback isn’t suspend, and a store call that really suspended would land its write outside the
transaction. If your chain needs an operation TransactionScope doesn’t have, add it there and
implement it in SqlDelightTransactionScope in
SqlDelightStores.kt.
Keep work that SQL can’t roll back (filesystem cleanup like reclaim) outside the
transaction, and make it idempotent.
Two more storage rules apply to any new query you add:
- Deleting is a tombstone. Stamp
deletedAtandupdatedAt, and neverDELETE, or the other device puts the row back. Guard withdeletedAt IS NULLso a second delete doesn’t restamp. - Chunk
IN :listqueries atSQL_VARIABLE_LIMIT(900). Older Android SQLite allows 999 bind parameters, and no test reproduces the crash.
4. Expose it on the ViewModel
Section titled “4. Expose it on the ViewModel”Screens never see the repository. Add a callback to
CadenceViewModel
that launches on its scope and turns failures into a snackbar. The text comes from a string
resource, never a Kotlin literal:
fun renameTask(task: Task, title: String) = scope.launch { when (val result = repository.renameTask(task, title)) { is RepositoryResult.Success -> Unit // the repository already ticked localWrites is RepositoryResult.Error -> showSnackbar(Res.string.snackbar_error, listOf(result.message)) RepositoryResult.ValidationError -> showSnackbar(Res.string.snackbar_task_title_empty) }}snackbar_task_title_empty doesn’t exist. Add it to both strings.xml files
(localise a string). Don’t call armSync() or anything like it. The ViewModel
used to arm sync from each mutation, and that job now belongs to write { } alone.
5. If it destroys work, defer it behind the undo window
Section titled “5. If it destroys work, defer it behind the undo window”Deletes that make work disappear (a task, a project, completed Inbox tasks, the danger-zone wipe)
don’t write immediately. The ViewModel hands
UndoSlot an
UndoAction. The slot hides those ids from every list at once (CadenceUiState.pendingDeleteIds),
shows the snackbar, and only calls commitPendingDelete after UNDO_WINDOW (five seconds). An
undo just cancels the job, so it costs no transaction. For a new destructive mutation:
- Add an
UndoActionsubclass whoseidsare every row it will hide, subtasks included. - Call
undoSlot.offer(action, count)from the ViewModel method instead of the repository. - Add a branch to
commitPendingDeletethat performs the repository write.
A write that moves or relabels work and makes nothing disappear writes straight through, like
deleteSection and deleteTag: nothing vanishes, so there’s nothing for an undo to give back.
6. Cancel the alarms of anything it deletes
Section titled “6. Cancel the alarms of anything it deletes”ReminderScheduler.sync only ever sees the tasks that still exist, so it can’t cancel an
alarm for a task that’s gone. A mutation that deletes tasks returns the ids it deleted, and the
ViewModel cancels each one:
repository.deleteProject(action.project.id, action.deleteTasks) .forEach { reminderScheduler.cancel(it) }Return the ids the repository actually tombstoned, not a list the UI filtered. The repository is the only one that knows the whole set, including subtasks and anything a pull merged in meanwhile.
Tests to add
Section titled “Tests to add”All in :core’s
CadenceRepositoryTest,
against the real stores (TestStores, never a fake):
-
The rule itself: what the row looks like afterwards, read back with
taskStore.byId. PinupdatedAtagainst the test’s fixedclock. -
The
localWritescontract, in the── localWrites ──section at the end of the file. Use itscollectTicks()helper, which subscribes onbackgroundScopebefore the call:@Testfun `renameTask ticks localWrites once`() = runTest {val task = store(Task(title = "Buy milk"))val ticks = collectTicks()repository.renameTask(task, "Buy oat milk")assertEquals(1, ticks.size)}@Testfun `a blank rename does not tick localWrites`() = runTest {val task = store(Task(title = "Buy milk"))val ticks = collectTicks()assertEquals(RepositoryResult.ValidationError, repository.renameTask(task, " "))assertTrue(ticks.isEmpty())} -
Atomicity, if you used
storeTransaction:SqlDelightStoreTransactionTestshows how a failure insiderunrolls back every write in it. -
The ViewModel side, in
:ui’sCadenceViewModelCrudTest(orCadenceViewModelUndoTestfor a deferred delete). The ViewModel runs onbackgroundScope, and a deferred delete is tested by advancing virtual time pastUNDO_WINDOW. AssertRecordingReminderScheduler.cancelledfor the alarm rule.
Verify
Section titled “Verify”./gradlew :core:jvmTest --tests "de.andi1984.cadence.CadenceRepositoryTest"./gradlew :core:testDebugUnitTest :ui:testDebugUnitTest \ :core:jvmTest :ui:jvmTest :app-desktop:test :ui:compileKotlinJvmIf you’re signed in to your own Neon project, make the edit on one device and watch it arrive on the other within about a minute (the foreground poll runs every 60 seconds). If it doesn’t, debug sync.
Related
Section titled “Related”- Undo and deletes: tombstones, the undo window, and #114’s two rules.
- Sync: why
localWritesand not the task flow arms the debounce. - Testing philosophy: why the stores are never faked.