08 - Storage
Concept
1. Why
Local storage fails in ways the network never does:
- a value that can't be decrypted after a restore;
- a device that's full;
- a Keychain item that survives an uninstall and hands the previous user's session to the next one.
Apps also mix up where things belong. A refresh token ends up in plain preferences; a list of orders ends up in secure storage.
sinew_storage gives each kind of data its store, a guard that turns storage failures into the same typed exceptions as the network, and a paging helper for local queries. It doesn't ship a database. The app picks its own (Room, SQLite, drift…), and Sinew's guard and paging helper work with any of them.
2. Shape
Which store for what
| Store | Backed by | For | Never for |
|---|---|---|---|
SecureStore | the platform's secure storage: a key-encrypted store on Android, the Keychain (this-device-only) on iOS | small secrets: the refresh token, a data key, a device id | large data, lists, anything queried |
KeyValueStore | the platform's preferences store | non-sensitive preferences and flags: onboarding seen, theme, the last filter | tokens, personal data |
| the app's database | the app's choice, with personal columns encrypted through FieldCipher | cached and offline data sets, anything queried | secrets |
| Rule | Why |
|---|---|
Only small secrets go in SecureStore. Personal data goes in the database, encrypted per field. | Each store does what it's built for |
| A value that can't be read or decrypted is absent, which for a session means signed out | Fail closed: never crash, never half-trust a broken value |
| The app wraps each store in typed, named stores with their own keys | String keys never spread through the app, and the full list of secrets is visible in one place |
clearUserData() runs on sign-out, and every repository with user-scoped local data offers its own clear function that sign-out calls | The next user never sees the previous user's data |
// the app's typed store over SecureStore (pseudocode)
class SessionSecrets(store: SecureStore) {
refreshToken(): String? = store.read("session.refreshToken")
saveRefreshToken(token) = store.write("session.refreshToken", token)
}
class OrderPreferences(store: KeyValueStore) {
lastStatusFilter(): String? = store.getString("orders.lastStatusFilter")
saveLastStatusFilter(value) = store.setString("orders.lastStatusFilter", value)
}
The reinstall rule
On iOS, Keychain items survive an uninstall, and preferences don't. Without care, a reinstalled app silently restores the previous user's refresh token. SecureStore handles it: on the first use after an install (detected by a marker in KeyValueStore, which an uninstall deletes), it clears every item it owns. On Android, secure storage is deleted with the app, so the rule is a no-op there.
The biometric vault's item is a Keychain item too, outside SecureStore's namespace. BiometricVault.create(clearOnReinstall = true) applies the same marker rule to it (see Security).
The storage guard
processStorageCall(module, function, access: Read | Write, block): Result<T> =
processCall(module, function, Repository, storageHandlers(module, function, access), block)
| # | Matches | Produces |
|---|---|---|
| 1 | DecryptionFailed, or stored bytes that don't decode | LocalStorageCorruptionException |
| 2 | the device is out of space (the platform's "disk full" errors, a database's "full" error) | StorageFullException |
| 3 | any other storage error, on a Read | StorageReadException |
| 4 | any other storage error, on a Write | StorageWriteException |
| 5 | EnvelopeDataMissing (shared) | ParseException |
access is a parameter because a storage library's errors rarely say whether a read or a write failed, and the two need different retry decisions.
Local sources
A local source is the storage counterpart of an API client: a small class over a store or database, private to its data module, working with entities:
class OrderLocalSource(dao) {
find(id): OrderEntity? = dao.findById(id)
put(entity) = dao.upsert(entity)
clear() = dao.deleteAll()
}
// in the repository
getCachedOrder(id) = processStorageCall("ORD", "GCO", Read) { local.find(id)?.toDomain() }
| Rule | Why |
|---|---|
| Stored data uses entities, never response DTOs | The storage schema and the API schema change for different reasons |
An entity records when it was stored (cachedAt) | Whoever decides freshness (a use case) needs it |
| A local source has no logic: find, put, delete, query | Like an API client, it's a contract. Mapping and errors go through the storage guard. |
| Where to cache, for how long, and what to do offline is decided in a use case, never in a local source or a repository | The data layer stores; the domain layer decides |
Paging local queries
pagedQuery(page: Int, limit: Int, firstPage = 1, fetch: (offset: Int, count: Int) -> List<T>): PagingEntity<T>
offset = (page - firstPage) * limit
rows = fetch(offset, limit + 1) // one extra row tells whether a next page exists
PagingEntity(items = rows.take(limit), meta = PagingMetaEntity(page, limit, hasNextPage = rows.size > limit))
It works with any database: the app's DAO supplies fetch as an OFFSET/LIMIT query. Asking for one extra row avoids a COUNT(*) query. total and totalPage stay 0 unless the app counts them, and the paging strategy for local data is Pager.offset.
Devtools inspection
interface InspectableStore { name: String, keys(): List<String>, read(key): String? }
The devtools browse local data through it. KeyValueStore and SecureStore implement it (secure values are masked by the devtools, see DevTools). An app's DAO can implement it to appear in the database browser. Nothing in production calls it.
3. API
| Name | Signature (pseudocode) | For |
|---|---|---|
SecureStore | read(key): String?, write(key, value), delete(key), clearUserData() | small secrets |
SecureStore.create | SecureStore.create(namespace = "sinew", clearOnReinstall = true, …platform options) | the platform implementation |
KeyValueStore | getString/getInt/getBool/getDouble(key), setString/…(key, value), remove(key), clear() | non-sensitive preferences |
KeyValueStore.create | KeyValueStore.create(name = "sinew_prefs") | the platform implementation |
processStorageCall | processStorageCall(module, function, access, block): Result<T>, the same name on both platforms | the source guard for storage |
StorageAccess | Read | Write | which kind of storage call failed |
pagedQuery | as above, → PagingEntity<T> | paging a local query |
InspectableStore | name, keys(), read(key) | devtools browsing |
4. Build steps
KeyValueStoreper platform. Test a round trip for each type,removeandclear.SecureStoreper platform (Android: values encrypted with a Keystore-protected key, then stored; iOS: Keychain, this-device-only). Tests:- a round trip;
clearUserData;- a value that can't be decrypted reads as absent, not a crash;
- the reinstall rule (iOS simulator: remove the marker, the next read finds nothing).
- The storage guard, with one test per handler row, including a simulated disk-full error on each platform and
DecryptionFailed. pagedQueryagainst an in-memory database. Tests:- first page;
- a middle page;
- the last page (
hasNextPage = false); - an empty table;
firstPage = 0.
InspectableStoreon both stores.
Platform setup (the app's part)
- Android: exclude the secure store's files from cloud backup and device transfer (data-extraction rules). Restored ciphertext can't be decrypted on a new device.
- iOS: if app extensions share secrets, configure a Keychain access group. The default is no sharing.
Done when
- No storage failure, on any platform, crashes the app. Each becomes a typed exception through the guard.
- A reinstall on iOS starts with an empty
SecureStoreand noBiometricVaultitem. - A local list pages with
Pager.offsetexactly like a remote one.
5. Edge cases
| Case | Decided behavior |
|---|---|
| The secure store's key is lost (restore to a new device, OS update) | Reads return absent (LocalStorageCorruptionException through the guard). With no refresh token, the app shows sign-in. Encrypted data is cleared. |
| iOS reinstall | The first use clears every SecureStore item, and BiometricVault clears its own item the same way. No previous session is restored, by token or by biometrics. |
| The device is full | Writes fail with StorageFullException ("Your device is out of space"). Reads keep working. |
Two writes to SecureStore at once | Writes are serialized inside the store, so the last one wins and no value is half-written. |
| Migrating existing unencrypted data | A one-time, idempotent migration: read the plain value, write it encrypted, delete the plain one, and record that it's done in KeyValueStore. If it's interrupted, it runs again next launch. |
| A secret too large for secure storage | Not supported: store the data encrypted in the database with FieldCipher, and keep only its key in SecureStore. |
| A local query beyond the last page | pagedQuery returns an empty page with hasNextPage = false. |
| Devtools reading a secure value | Shown masked. See DevTools. |
Implementation
1. Why
The base note defines the stores, the guard and the paging helper. On Kotlin, each store has a natural backing per target. DataStore is multiplatform except on web, and the Keychain is the right place for iOS secrets. This note maps them, and gives processStorageCall its storage handlers.
2. Shape
Backing per target
| Target | KeyValueStore | SecureStore |
|---|---|---|
| Android | DataStore Preferences (sinew_prefs.preferences_pb) | DataStore Preferences (sinew_secure.preferences_pb), every value encrypted with Tink AEAD, the key name as associated data |
| iOS | DataStore Preferences in the app's documents folder | Keychain generic-password items, service = namespace, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly |
| JVM | DataStore Preferences in the user's app-data folder | DataStore + Tink, as on Android, with the desktop key file |
| wasmJs | localStorage, keys prefixed with the store's name | localStorage with values encrypted by FieldCipher (WebCrypto) |
public interface KeyValueStore : InspectableStore {
public suspend fun getString(key: String): String?
public suspend fun setString(key: String, value: String)
// getInt / getBool / getDouble and their setters
public suspend fun remove(key: String)
public suspend fun clear()
public companion object { public fun create(platform: PlatformContext, name: String = "sinew_prefs"): KeyValueStore }
}
public interface SecureStore : InspectableStore {
public suspend fun read(key: String): String?
public suspend fun write(key: String, value: String)
public suspend fun delete(key: String)
public suspend fun clearUserData()
public companion object {
public fun create(platform: PlatformContext, namespace: String = "sinew", clearOnReinstall: Boolean = true): SecureStore
}
}
The reinstall rule. On iOS, SecureStore.create and BiometricVault.create both check a marker (sinew.secure.installed) in a DataStore file, which is deleted with the app. With no marker, each deletes its own Keychain items (the store's service, the vault's item), then writes the marker. On the other targets the rule does nothing, because their storage goes with the app.
Concurrent writes. Each SecureStore serializes its writes with a Mutex. DataStore already serializes its own.
The storage guard
public suspend fun <T> processStorageCall(module: String, function: String, access: StorageAccess, block: suspend () -> T): Result<T> =
processCall(module, function, ExceptionLayer.Repository, storageHandlers(module, function, access), block)
| Matches | Produces |
|---|---|
DecryptionFailed, DataStore's CorruptionException, SerializationException on stored data | LocalStorageCorruptionException |
Android/JVM IOException with ENOSPC, SQLiteFullException; iOS NSError NSFileWriteOutOfSpaceError (Cocoa 640) or SQLITE_FULL (13); web QuotaExceededError | StorageFullException |
any other IOException or storage error on Read / Write | StorageReadException / StorageWriteException |
EnvelopeDataMissing | ParseException |
Paging a Room query
pagedQuery works with any database. With Room (multiplatform):
@Dao
internal interface OrderDao {
@Query("SELECT * FROM orders ORDER BY placedAt DESC LIMIT :count OFFSET :offset")
suspend fun page(offset: Int, count: Int): List<OrderEntity>
}
// local source
suspend fun page(page: Int, limit: Int): PagingEntity<OrderEntity> = pagedQuery(page, limit) { offset, count -> dao.page(offset, count) }
3. API
| Name | Kotlin |
|---|---|
KeyValueStore, SecureStore | as above |
StorageAccess | public enum class StorageAccess { Read, Write } |
processStorageCall | as above |
pagedQuery | public suspend fun <T> pagedQuery(page: Int, limit: Int, firstPage: Int = 1, fetch: suspend (offset: Int, count: Int) -> List<T>): PagingEntity<T> |
InspectableStore | public interface InspectableStore { public val name: String; public suspend fun keys(): List<String>; public suspend fun read(key: String): String? } |
4. Build steps
KeyValueStoreon DataStore (android, ios, jvm) andlocalStorage(wasm), with one sharedcommonTestsuite run on every target.SecureStoreper target, with the shared suite plus platform tests:- Android: a value written, then the Tink keyset deleted, reads as absent.
- iOS: the reinstall rule (delete the marker file, recreate the store, find no items).
processStorageCallwith one test per handler row. The disk-full rows use fakes that throw each platform's error.pagedQueryagainst an in-memory Room database on Android/JVM, and a list-backed fetch incommonTest.
Platform setup (the app's part)
- Android: exclude
datastore/sinew_secure.preferences_pband the Tink keyset preferences from backup and device transfer. - iOS: to share secrets with an app extension, set a Keychain access group (an option on
create). The default is no sharing.
Done when
- The shared store suites pass on all four targets.
- An iOS reinstall starts with an empty
SecureStore(simulator test). - Every storage error a platform can throw maps to a typed exception.
5. Edge cases
| Case | Decided behavior |
|---|---|
| Web storage cleared by the browser (private mode, storage pressure) | Every value reads as absent. A session in it ends, which is the same "fail closed" rule. |
| A large value in the iOS Keychain | Keychain items are meant to be small. Values over 4 KB are rejected by write with StorageWriteException, per the base rule "secrets only". |
| DataStore file corrupted | CorruptionException → LocalStorageCorruptionException. The store is replaced with an empty file (DataStore's corruption handler), so the next write works. |
- The iOS secure store uses the Keychain directly, not DataStore + encryption, so secrets get the platform's own protection classes.
- A 4 KB limit on
SecureStorevalues, enforced on every target, so the rule "small secrets only" holds everywhere, not just where the Keychain would complain. localStorageon web for both stores, with secure values encrypted, at the documented weaker level.