@mmerterden/multi-agent-pipeline 20.1.0 → 20.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/docs/facts.json +5 -5
  3. package/manifest.json +52 -31
  4. package/package.json +1 -1
  5. package/pipeline/skills/.skill-manifest.json +36 -20
  6. package/pipeline/skills/.skills-index.json +75 -9
  7. package/pipeline/skills/shared/README.md +13 -7
  8. package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
  9. package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
  10. package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
  11. package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
  12. package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
  13. package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
  14. package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
  15. package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
  16. package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
  17. package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
  18. package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
  19. package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
  20. package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
  21. package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
  22. package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
  23. package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
  24. package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
  25. package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
  26. package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
  27. package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
  28. package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
  29. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
  30. package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
  31. package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
  32. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
  33. package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
  34. package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
  35. package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
  36. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
  37. package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
  38. package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
  39. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
  40. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
  41. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
  42. package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
  43. package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
  44. package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
  45. package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
  46. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
  47. package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
  48. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
  49. package/pipeline/skills/skills-index.md +8 -2
@@ -0,0 +1,614 @@
1
+ # Room Database Patterns
2
+
3
+ Full code examples backing the guidance in `SKILL.md`. Headings mirror the
4
+ SKILL.md sections.
5
+
6
+ ## Contents
7
+
8
+ - [Entity Definition](#entity-definition)
9
+ - [DAO Interface](#dao-interface)
10
+ - [Database Setup](#database-setup)
11
+ - [Reactive Queries with Flow](#reactive-queries-with-flow)
12
+ - [TypeConverters](#typeconverters)
13
+ - [Relations](#relations)
14
+ - [Migration Strategies](#migration-strategies)
15
+ - [Hilt Integration](#hilt-integration)
16
+ - [Testing with In-Memory Database](#testing-with-in-memory-database)
17
+ - [Encrypted Database (SQLCipher)](#encrypted-database-sqlcipher)
18
+ - [Async Database Provisioning](#async-database-provisioning)
19
+ - [Runtime Hygiene](#runtime-hygiene)
20
+
21
+ ## Entity Definition
22
+
23
+ ```kotlin
24
+ @Entity(
25
+ tableName = "flights",
26
+ indices = [
27
+ Index(value = ["origin_code", "destination_code"]),
28
+ Index(value = ["departure_time"]),
29
+ ],
30
+ )
31
+ data class FlightEntity(
32
+ @PrimaryKey
33
+ val id: String,
34
+ @ColumnInfo(name = "origin_code")
35
+ val originCode: String,
36
+ @ColumnInfo(name = "origin_name")
37
+ val originName: String,
38
+ @ColumnInfo(name = "destination_code")
39
+ val destinationCode: String,
40
+ @ColumnInfo(name = "destination_name")
41
+ val destinationName: String,
42
+ @ColumnInfo(name = "departure_time")
43
+ val departureTime: String,
44
+ @ColumnInfo(name = "arrival_time")
45
+ val arrivalTime: String,
46
+ @ColumnInfo(name = "price_amount")
47
+ val priceAmount: Double,
48
+ @ColumnInfo(name = "price_currency")
49
+ val priceCurrency: String,
50
+ val status: String,
51
+ @ColumnInfo(name = "updated_at", defaultValue = "0")
52
+ val updatedAt: Long = System.currentTimeMillis(),
53
+ )
54
+ ```
55
+
56
+ ### Composite Primary Key
57
+
58
+ ```kotlin
59
+ @Entity(
60
+ tableName = "booking_passengers",
61
+ primaryKeys = ["booking_id", "passenger_id"],
62
+ )
63
+ data class BookingPassengerEntity(
64
+ @ColumnInfo(name = "booking_id")
65
+ val bookingId: String,
66
+ @ColumnInfo(name = "passenger_id")
67
+ val passengerId: String,
68
+ @ColumnInfo(name = "seat_number")
69
+ val seatNumber: String?,
70
+ )
71
+ ```
72
+
73
+ ## DAO Interface
74
+
75
+ ```kotlin
76
+ @Dao
77
+ interface FlightDao {
78
+
79
+ @Query("SELECT * FROM flights WHERE origin_code = :origin AND destination_code = :destination ORDER BY departure_time ASC")
80
+ fun getFlights(origin: String, destination: String): Flow<List<FlightEntity>>
81
+
82
+ @Query("SELECT * FROM flights WHERE id = :id")
83
+ fun getFlightById(id: String): Flow<FlightEntity?>
84
+
85
+ @Query("SELECT * FROM flights WHERE id = :id")
86
+ suspend fun getFlightByIdOnce(id: String): FlightEntity?
87
+
88
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
89
+ suspend fun insertFlight(flight: FlightEntity)
90
+
91
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
92
+ suspend fun insertFlights(flights: List<FlightEntity>)
93
+
94
+ @Upsert
95
+ suspend fun upsertFlights(flights: List<FlightEntity>)
96
+
97
+ @Update
98
+ suspend fun updateFlight(flight: FlightEntity)
99
+
100
+ @Delete
101
+ suspend fun deleteFlight(flight: FlightEntity)
102
+
103
+ @Query("DELETE FROM flights WHERE id = :id")
104
+ suspend fun deleteFlightById(id: String)
105
+
106
+ @Query("DELETE FROM flights WHERE updated_at < :threshold")
107
+ suspend fun deleteStaleFlights(threshold: Long)
108
+
109
+ @Query("SELECT COUNT(*) FROM flights")
110
+ fun getFlightCount(): Flow<Int>
111
+
112
+ @Transaction
113
+ suspend fun replaceAllFlights(flights: List<FlightEntity>) {
114
+ deleteAllFlights()
115
+ insertFlights(flights)
116
+ }
117
+
118
+ @Query("DELETE FROM flights")
119
+ suspend fun deleteAllFlights()
120
+ }
121
+ ```
122
+
123
+ ### @Upsert vs @Insert(onConflict = REPLACE)
124
+
125
+ | Strategy | Behavior | Use When |
126
+ |----------|----------|----------|
127
+ | `@Upsert` | Insert if absent, update if present (by PK) | Syncing remote data; preserves non-conflicting columns |
128
+ | `@Insert(REPLACE)` | Delete + re-insert on conflict | Simpler; triggers delete cascade |
129
+ | `@Insert(IGNORE)` | Skip on conflict | Inserting if not exists, no update needed |
130
+
131
+ ## Database Setup
132
+
133
+ ```kotlin
134
+ @Database(
135
+ entities = [
136
+ FlightEntity::class,
137
+ BookingEntity::class,
138
+ BookingPassengerEntity::class,
139
+ ],
140
+ version = 2,
141
+ autoMigrations = [
142
+ AutoMigration(from = 1, to = 2),
143
+ ],
144
+ exportSchema = true,
145
+ )
146
+ @TypeConverters(Converters::class)
147
+ abstract class AppDatabase : RoomDatabase() {
148
+ abstract fun flightDao(): FlightDao
149
+ abstract fun bookingDao(): BookingDao
150
+ }
151
+ ```
152
+
153
+ Always set `exportSchema = true` and configure the schema export directory in
154
+ `build.gradle.kts`:
155
+
156
+ ```kotlin
157
+ ksp {
158
+ arg("room.schemaLocation", "$projectDir/schemas")
159
+ }
160
+ ```
161
+
162
+ ## Reactive Queries with Flow
163
+
164
+ DAO methods returning `Flow` automatically emit new values when the underlying
165
+ table changes.
166
+
167
+ ```kotlin
168
+ // DAO returns Flow
169
+ @Query("SELECT * FROM flights WHERE origin_code = :origin")
170
+ fun getFlightsByOrigin(origin: String): Flow<List<FlightEntity>>
171
+
172
+ // Repository maps to domain models
173
+ class FlightRepositoryImpl @Inject constructor(
174
+ private val dao: FlightDao,
175
+ private val mapper: FlightMapper,
176
+ ) : FlightRepository {
177
+
178
+ override fun getFlights(origin: String): Flow<List<Flight>> =
179
+ dao.getFlightsByOrigin(origin)
180
+ .map { entities -> entities.map(mapper::toDomain) }
181
+ .flowOn(Dispatchers.IO)
182
+ }
183
+
184
+ // ViewModel collects in viewModelScope
185
+ @HiltViewModel
186
+ class FlightListViewModel @Inject constructor(
187
+ private val repository: FlightRepository,
188
+ ) : ViewModel() {
189
+
190
+ val flights: StateFlow<List<Flight>> = repository.getFlights("IST")
191
+ .stateIn(
192
+ scope = viewModelScope,
193
+ started = SharingStarted.WhileSubscribed(5_000),
194
+ initialValue = emptyList(),
195
+ )
196
+ }
197
+
198
+ // Compose collects as state
199
+ @Composable
200
+ fun FlightListScreen(viewModel: FlightListViewModel = hiltViewModel()) {
201
+ val flights by viewModel.flights.collectAsStateWithLifecycle()
202
+ LazyColumn {
203
+ items(flights, key = { it.id }) { flight ->
204
+ FlightCard(flight = flight)
205
+ }
206
+ }
207
+ }
208
+ ```
209
+
210
+ ## TypeConverters
211
+
212
+ For types Room does not natively support (lists, enums, dates).
213
+
214
+ ```kotlin
215
+ class Converters {
216
+
217
+ private val json = Json { ignoreUnknownKeys = true }
218
+
219
+ @TypeConverter
220
+ fun fromStringList(value: List<String>): String =
221
+ json.encodeToString(value)
222
+
223
+ @TypeConverter
224
+ fun toStringList(value: String): List<String> =
225
+ json.decodeFromString(value)
226
+
227
+ @TypeConverter
228
+ fun fromInstant(value: Instant?): Long? = value?.toEpochMilliseconds()
229
+
230
+ @TypeConverter
231
+ fun toInstant(value: Long?): Instant? = value?.let { Instant.fromEpochMilliseconds(it) }
232
+
233
+ @TypeConverter
234
+ fun fromFlightStatus(value: FlightStatus): String = value.name
235
+
236
+ @TypeConverter
237
+ fun toFlightStatus(value: String): FlightStatus = FlightStatus.valueOf(value)
238
+ }
239
+ ```
240
+
241
+ Register in `@Database`:
242
+
243
+ ```kotlin
244
+ @Database(entities = [...], version = 1)
245
+ @TypeConverters(Converters::class)
246
+ abstract class AppDatabase : RoomDatabase()
247
+ ```
248
+
249
+ ## Relations
250
+
251
+ ### @Embedded
252
+
253
+ Flatten a nested object into the parent table columns:
254
+
255
+ ```kotlin
256
+ data class AddressEntity(
257
+ val street: String,
258
+ val city: String,
259
+ val country: String,
260
+ )
261
+
262
+ @Entity(tableName = "passengers")
263
+ data class PassengerEntity(
264
+ @PrimaryKey val id: String,
265
+ val name: String,
266
+ @Embedded(prefix = "address_")
267
+ val address: AddressEntity,
268
+ )
269
+ // Creates columns: id, name, address_street, address_city, address_country
270
+ ```
271
+
272
+ ### @Relation (One-to-Many)
273
+
274
+ ```kotlin
275
+ data class BookingWithPassengers(
276
+ @Embedded val booking: BookingEntity,
277
+ @Relation(
278
+ parentColumn = "id",
279
+ entityColumn = "booking_id",
280
+ )
281
+ val passengers: List<BookingPassengerEntity>,
282
+ )
283
+
284
+ @Dao
285
+ interface BookingDao {
286
+ @Transaction
287
+ @Query("SELECT * FROM bookings WHERE id = :bookingId")
288
+ fun getBookingWithPassengers(bookingId: String): Flow<BookingWithPassengers?>
289
+ }
290
+ ```
291
+
292
+ ### Junction Table (Many-to-Many)
293
+
294
+ ```kotlin
295
+ @Entity(
296
+ tableName = "flight_tags",
297
+ primaryKeys = ["flight_id", "tag_id"],
298
+ )
299
+ data class FlightTagCrossRef(
300
+ @ColumnInfo(name = "flight_id") val flightId: String,
301
+ @ColumnInfo(name = "tag_id") val tagId: String,
302
+ )
303
+
304
+ data class FlightWithTags(
305
+ @Embedded val flight: FlightEntity,
306
+ @Relation(
307
+ parentColumn = "id",
308
+ entityColumn = "id",
309
+ associateBy = Junction(
310
+ value = FlightTagCrossRef::class,
311
+ parentColumn = "flight_id",
312
+ entityColumn = "tag_id",
313
+ ),
314
+ )
315
+ val tags: List<TagEntity>,
316
+ )
317
+ ```
318
+
319
+ Always annotate relation queries with `@Transaction` to ensure consistent reads.
320
+
321
+ ## Migration Strategies
322
+
323
+ ### Auto Migration (Room 2.4+)
324
+
325
+ For simple schema changes (add column, add table, add index):
326
+
327
+ ```kotlin
328
+ @Database(
329
+ entities = [FlightEntity::class],
330
+ version = 3,
331
+ autoMigrations = [
332
+ AutoMigration(from = 1, to = 2),
333
+ AutoMigration(from = 2, to = 3, spec = Migration2To3::class),
334
+ ],
335
+ )
336
+ abstract class AppDatabase : RoomDatabase()
337
+
338
+ // Spec needed for column/table renames or deletes
339
+ @RenameColumn(tableName = "flights", fromColumnName = "price", toColumnName = "price_amount")
340
+ class Migration2To3 : AutoMigrationSpec
341
+ ```
342
+
343
+ ### Manual Migration
344
+
345
+ For complex changes (data transformation, merging tables):
346
+
347
+ ```kotlin
348
+ val MIGRATION_3_4 = object : Migration(3, 4) {
349
+ override fun migrate(db: SupportSQLiteDatabase) {
350
+ db.execSQL("""
351
+ CREATE TABLE IF NOT EXISTS bookings_new (
352
+ id TEXT NOT NULL PRIMARY KEY,
353
+ flight_id TEXT NOT NULL,
354
+ status TEXT NOT NULL DEFAULT 'PENDING',
355
+ created_at INTEGER NOT NULL DEFAULT 0,
356
+ FOREIGN KEY (flight_id) REFERENCES flights(id) ON DELETE CASCADE
357
+ )
358
+ """)
359
+ db.execSQL("""
360
+ INSERT INTO bookings_new (id, flight_id, status, created_at)
361
+ SELECT id, flight_id, status, 0 FROM bookings
362
+ """)
363
+ db.execSQL("DROP TABLE bookings")
364
+ db.execSQL("ALTER TABLE bookings_new RENAME TO bookings")
365
+ }
366
+ }
367
+
368
+ // Register in Hilt module
369
+ Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
370
+ .addMigrations(MIGRATION_3_4)
371
+ .build()
372
+ ```
373
+
374
+ ### Fallback to Destructive Migration
375
+
376
+ Only for development or non-critical data:
377
+
378
+ ```kotlin
379
+ Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
380
+ .fallbackToDestructiveMigration()
381
+ .build()
382
+ ```
383
+
384
+ ## Hilt Integration
385
+
386
+ ```kotlin
387
+ @Module
388
+ @InstallIn(SingletonComponent::class)
389
+ object DatabaseModule {
390
+
391
+ @Provides
392
+ @Singleton
393
+ fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
394
+ Room.databaseBuilder(
395
+ context,
396
+ AppDatabase::class.java,
397
+ "app.db",
398
+ )
399
+ .addMigrations(MIGRATION_3_4)
400
+ .build()
401
+
402
+ @Provides
403
+ fun provideFlightDao(database: AppDatabase): FlightDao =
404
+ database.flightDao()
405
+
406
+ @Provides
407
+ fun provideBookingDao(database: AppDatabase): BookingDao =
408
+ database.bookingDao()
409
+ }
410
+ ```
411
+
412
+ ## Testing with In-Memory Database
413
+
414
+ ```kotlin
415
+ class FlightDaoTest {
416
+
417
+ private lateinit var database: AppDatabase
418
+ private lateinit var dao: FlightDao
419
+
420
+ @Before
421
+ fun setup() {
422
+ database = Room.inMemoryDatabaseBuilder(
423
+ ApplicationProvider.getApplicationContext(),
424
+ AppDatabase::class.java,
425
+ )
426
+ .allowMainThreadQueries() // OK for tests only
427
+ .build()
428
+
429
+ dao = database.flightDao()
430
+ }
431
+
432
+ @After
433
+ fun tearDown() {
434
+ database.close()
435
+ }
436
+
437
+ @Test
438
+ fun insertAndQuery_returnsCorrectFlight() = runTest {
439
+ val flight = FlightEntity(
440
+ id = "TK1",
441
+ originCode = "IST",
442
+ originName = "Istanbul",
443
+ destinationCode = "JFK",
444
+ destinationName = "New York",
445
+ departureTime = "2025-01-15T10:00:00Z",
446
+ arrivalTime = "2025-01-15T18:00:00Z",
447
+ priceAmount = 799.0,
448
+ priceCurrency = "USD",
449
+ status = "SCHEDULED",
450
+ )
451
+
452
+ dao.insertFlight(flight)
453
+
454
+ val result = dao.getFlightByIdOnce("TK1")
455
+ assertThat(result).isNotNull()
456
+ assertThat(result?.originCode).isEqualTo("IST")
457
+ }
458
+
459
+ @Test
460
+ fun flowQuery_emitsUpdates() = runTest {
461
+ dao.getFlights("IST", "JFK").test {
462
+ assertThat(awaitItem()).isEmpty()
463
+
464
+ dao.insertFlight(testFlight)
465
+
466
+ val updated = awaitItem()
467
+ assertThat(updated).hasSize(1)
468
+
469
+ cancelAndIgnoreRemainingEvents()
470
+ }
471
+ }
472
+
473
+ @Test
474
+ fun upsert_updatesExistingFlight() = runTest {
475
+ dao.insertFlight(testFlight)
476
+ val updated = testFlight.copy(status = "DELAYED")
477
+ dao.upsertFlights(listOf(updated))
478
+
479
+ val result = dao.getFlightByIdOnce(testFlight.id)
480
+ assertThat(result?.status).isEqualTo("DELAYED")
481
+ }
482
+ }
483
+ ```
484
+
485
+ ### Testing Migrations
486
+
487
+ ```kotlin
488
+ @RunWith(AndroidJUnit4::class)
489
+ class MigrationTest {
490
+
491
+ @get:Rule
492
+ val helper = MigrationTestHelper(
493
+ InstrumentationRegistry.getInstrumentation(),
494
+ AppDatabase::class.java,
495
+ )
496
+
497
+ @Test
498
+ fun migrate3To4() {
499
+ // Create database at version 3
500
+ helper.createDatabase("test-db", 3).apply {
501
+ execSQL("INSERT INTO bookings (id, flight_id, status) VALUES ('B1', 'F1', 'CONFIRMED')")
502
+ close()
503
+ }
504
+
505
+ // Run migration and validate
506
+ val db = helper.runMigrationsAndValidate("test-db", 4, true, MIGRATION_3_4)
507
+ val cursor = db.query("SELECT * FROM bookings WHERE id = 'B1'")
508
+ assertThat(cursor.moveToFirst()).isTrue()
509
+ assertThat(cursor.getColumnIndex("created_at")).isAtLeast(0)
510
+ }
511
+ }
512
+ ```
513
+
514
+ ## Encrypted Database (SQLCipher)
515
+
516
+ Encrypt the database file by passing a SQLCipher `SupportOpenHelperFactory` to
517
+ `.openHelperFactory(...)`. This skill owns only the Room wiring. The passphrase
518
+ must come from a hardware-backed key hierarchy; the derivation and storage of
519
+ that key are the `android-security` skill's responsibility, not reproduced here.
520
+
521
+ ```kotlin
522
+ fun buildEncryptedDatabase(
523
+ context: Context,
524
+ passphrase: ByteArray,
525
+ ): AppDatabase {
526
+ val factory = SupportOpenHelperFactory(passphrase)
527
+ return Room.databaseBuilder(context, AppDatabase::class.java, "secure-app.db")
528
+ .openHelperFactory(factory)
529
+ .addCallback(WalHygieneCallback())
530
+ .build()
531
+ }
532
+ ```
533
+
534
+ `SupportOpenHelperFactory` is from the AndroidX-compatible SQLCipher artifact
535
+ (`net.zetetic.database.sqlcipher`); it zeroes the passphrase array after use.
536
+
537
+ ## Async Database Provisioning
538
+
539
+ When the passphrase must be derived or fetched before `Room.databaseBuilder`, a
540
+ synchronous `@Provides` cannot express the await. Expose the database through a
541
+ suspend, `Mutex`-guarded, double-checked lazy singleton provider.
542
+
543
+ ```kotlin
544
+ @Singleton
545
+ class DatabaseProvider @Inject constructor(
546
+ @ApplicationContext private val context: Context,
547
+ private val keyProvider: DatabaseKeyProvider,
548
+ ) {
549
+ private val mutex = Mutex()
550
+ @Volatile private var instance: AppDatabase? = null
551
+
552
+ suspend fun database(): AppDatabase {
553
+ instance?.let { return it }
554
+ return mutex.withLock {
555
+ instance ?: buildDatabase().also { instance = it }
556
+ }
557
+ }
558
+
559
+ private suspend fun buildDatabase(): AppDatabase {
560
+ val passphrase = keyProvider.passphrase()
561
+ return Room.databaseBuilder(context, AppDatabase::class.java, "secure-app.db")
562
+ .openHelperFactory(SupportOpenHelperFactory(passphrase))
563
+ .addCallback(WalHygieneCallback())
564
+ .build()
565
+ }
566
+ }
567
+ ```
568
+
569
+ The Hilt module provides `DatabaseProvider` (no I/O); repositories obtain DAOs
570
+ from a suspend accessor (`provider.database().recentSearchDao()`) at call time.
571
+
572
+ ## Runtime Hygiene
573
+
574
+ Cap WAL growth on a write-heavy database with an `onOpen` PRAGMA:
575
+
576
+ ```kotlin
577
+ class WalHygieneCallback : RoomDatabase.Callback() {
578
+ override fun onOpen(db: SupportSQLiteDatabase) {
579
+ db.query("PRAGMA wal_autocheckpoint=1000").close()
580
+ }
581
+ }
582
+ ```
583
+
584
+ Do multi-step read-then-write logic atomically in a `@Transaction` DAO default
585
+ method -- dedupe, insert, and trim-to-cap in one transaction:
586
+
587
+ ```kotlin
588
+ @Dao
589
+ interface RecentSearchDao {
590
+ @Query("SELECT id FROM recent_searches WHERE query = :query LIMIT 1")
591
+ suspend fun findId(query: String): Long?
592
+
593
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
594
+ suspend fun insert(entity: RecentSearchEntity): Long
595
+
596
+ @Query(
597
+ "DELETE FROM recent_searches WHERE id IN " +
598
+ "(SELECT id FROM recent_searches ORDER BY updated_at DESC LIMIT -1 OFFSET :cap)",
599
+ )
600
+ suspend fun trimToCap(cap: Int)
601
+
602
+ @Transaction
603
+ suspend fun record(entity: RecentSearchEntity, cap: Int) {
604
+ val existing = findId(entity.query)
605
+ insert(if (existing != null) entity.copy(id = existing) else entity)
606
+ trimToCap(cap)
607
+ }
608
+ }
609
+ ```
610
+
611
+ `exportSchema = false` is acceptable only for a cache or destructive-fallback
612
+ database, where the file is disposable. A migration-tested production database
613
+ must keep `exportSchema = true` and commit the schema JSON, since
614
+ `MigrationTestHelper` validates against the exported schema.