Use when a new feature needs to persist data locally in Alkaa — triggers on tasks like "add database support", "create a new table", "store this data in SQLDelight", or when write-feature Phase 2 requires a new entity in the local database.
The local data layer spans eight phases per entity: DataSource interface, SQLDelight schema, DB migrations, DAO interface, DAO implementation, local mapper, LocalDataSource implementation, and DI registration. All phases must follow strict conventions.
references/DATASOURCE_INTERFACE.md.sq schema file with named queries and field prefixes → see references/SCHEMA.md.sqm file required for any structural change to an existing table → see references/MIGRATIONS.mdreferences/DAO.mdasFlow().mapToList() and executeAsOneOrNull() patterns → see references/DAO.mdtoRepofromReporeferences/MAPPER.md*Queries directly → see references/LOCAL_DATASOURCE.mdsingleOf for DAOs/DataSources, factoryOf for mappers → see references/DI.md| Rule | Details |
|---|---|
| Flow vs. suspend in DAO | Flow<List<T>> for reactive reads; suspend for mutations and point-in-time reads |
| No direct Queries access | LocalDataSource injects the DAO — never *Queries directly |
| executeAsOneOrNull | Always nullable for single reads — never executeAsOne() |
| cleanTable required | Every table needs a cleanTable: query for E2E test teardown |
| Field prefix | Column names prefixed with table name in snake_case (e.g., category_id) |
| DI scope | singleOf for DataSources and DAOs; factoryOf for mappers |
| Migration required | Any structural change to an existing table needs a .sqm file |
| Mistake | Fix |
|---|---|
suspend fun findAll() for a Flow return | Use fun findAll(): Flow<List<T>> — no suspend for reactive reads |
Calling .first() in DaoImpl for a Flow return | Return Flow directly; callers decide when to collect |
Using executeAsOne() for single reads | Always executeAsOneOrNull() — assume nullable |
Omitting cleanTable: in the .sq file | Required for E2E tests to reset state between runs |
Local mapper in data/repository/mapper/ | Belongs in data/local/mapper/ with toRepo/fromRepo |
Registering DataSource or DAO as factoryOf | Always singleOf — they share database state |
Adding DatabaseProvider registration again | Already registered once; duplicate causes a Koin conflict |
Accessing *Queries in LocalDataSource | Injects the DAO — never the queries object |
Changing CREATE TABLE without a .sqm file | Existing users never see the change; always add a migration |
Adding a NOT NULL column without DEFAULT | SQLite rejects the statement on existing rows |
Editing an existing .sqm file | .sqm files are immutable; create a new file for each change |
Wrong .sqm file number | Number must equal the count of existing .sqm files |
Wrapping migration SQL in BEGIN/END TRANSACTION | The driver manages the transaction; wrapping can cause crashes |
After completing the local data layer, use the write-unit-tests skill to test data sources and use cases. Use the write-e2e-tests skill for full-flow coverage — E2E tests use DAOs directly (by inject()) to seed and clean data.
.claude/skills/write-local-datasource/scripts/verify_migrations.sh