Skip to content

KMP phase 6, the driver flip: Android moves to BundledSQLiteDriver - #240

Merged
stslex merged 3 commits into
devfrom
claude/database-bundled-sqlite
Aug 21, 2026
Merged

KMP phase 6, the driver flip: Android moves to BundledSQLiteDriver#240
stslex merged 3 commits into
devfrom
claude/database-bundled-sqlite

Conversation

@stslex

@stslex stslex commented Aug 20, 2026

Copy link
Copy Markdown
Owner

KMP phase 6, the driver flip: Android moves to BundledSQLiteDriver. Stacked on #239 — review that first; this is one commit on top, deliberately separate so a SQLite-behaviour regression and a source-set regression stay distinguishable under bisect, and so this half reverts alone. #241 (core:data:exercise → KMP) stacks on top of this PR.

What flips, what doesn't, and why the line is where it is

Surface Driver after this PR Why
Production (buildAppDatabase) Bundled (SQLite 3.50.x) The point: one SQLite build on every device instead of the per-OEM system one
The 5 device-test classes (migration, rollback, invalidation, round-trip, recently-trained) Bundled The migration suite is the flip's gate, so it must run the flipped driver
InMemoryDatabaseProvider (app:app androidTest via MetroTestRule) Bundled Device-side; the whole instrumented app suite now exercises the production driver
RepositoryTestEnv + host tests (BaseDatabaseTest, snapshot test) Framework, pinned with a comment Measured, not assumed: the bundled android variant ships Android-ABI natives only — under Robolectric on a desktop JVM it dies with UnsatisfiedLinkError. Robolectric is not an admissible driver oracle in this repo anyway (AtomicRollbackDeviceTest KDoc: two false negatives)

The Room convention's KMP branch now declares sqlite-bundled for androidMain; the database module pins sqlite-framework on its host tests explicitly, next to the reason.

The gate — the migration suite on a real device, under the flipped driver

  • 28/28 device tests green under BundledSQLiteDriver on Pixel_6_API_34 (arm64), collected count printed (Starting 28 tests), via the connectedDebugAndroidTest alias under the exact ui_tests.yml Regression filter. That includes all 7 AppDatabaseMigrationTest cases — a real v5 file seeded on disk, Migration6 applied, schema validated against 6.json — plus the atomicity-rollback oracle (both production transaction shapes), invalidation across connections, and the on-disk round-trip.
  • The same 28 ran green under AndroidSQLiteDriver in KMP phase 6D: core:data:database becomes a KMP module, on AndroidSQLiteDriver #239 — the spec's "under both drivers" requirement, satisfied across the two PRs by construction.
  • 41/41 app:app device Regression tests green with the flipped in-memory provider (41 = the 35 pinned in tech-debt plus KMP phase 6A: the last three DataStore stragglers, and the phase spec #232's six DataStore singleton tests).

Risk register (from the pre-flip SQL audit of all 105 @Query sites + every raw-SQL site)

  • Zero double-quoted string literals repo-wide (the classic SQLITE_DQS=0 breakage): every SQL literal is single-quoted. Zero version-sensitive constructs in use — no window functions, CTEs, upserts (@Insert(REPLACE/IGNORE) compile to the pre-upsert forms, frozen since 3.6.19), date/time/json/math functions, RETURNING, or IIF.
  • One query with version-floating output, pre-existing: SessionDao.getRecentSessionsForExercise projects a bare column under GROUP BY with no aggregate — legal and arbitrary in both SQLite builds; a planner change may pick a different in-group row when one session holds the same exercise twice. Not a flip regression; noted for the record.
  • Cross-library file interop is deliberate and stays: the snapshot/ package opens the live DB via framework SQLite (peek + pre-migration checkpoint) while Room holds bundled connections. Main-db and WAL formats are frozen, so reads are safe by format; the concurrent-open path leans on POSIX locking shared by both libraries. This is the residual risk the device-restore pass (Ilya's gate, per the recovery spec) covers end-to-end; flagged here rather than silently carried.
  • Improvements that come free: host-variable limit 999 → 32766, and ROW_NUMBER() becomes available — SessionDao's PR-batch comment now records the rewrite as available but not taken (this PR is behaviour-neutral by design).

Gates

Gate Result
device suite under bundled ✅ 28/28, Starting 28 tests on Pixel_6_API_34(AVD)
app:app device Regression ✅ 41/41
host tests (framework pin) ✅ database 128/26, exercise 212/19, 0 failures
full forced gate (assembleDebug testDebugUnitTest verifyPaparazziDebug lintDebug assembleDebugAndroidTest) 3248 actionable tasks: 3248 executed, 0 failed
detekt (separate invocation) 56 actionable tasks: 56 executed
unit tests ✅ 2230/230 + 165/33 = 2395, 0 failures — identical to #239, as a behaviour-neutral flip must be
goldens ✅ 456, 0 touched

🤖 Generated with Claude Code

https://claude.ai/code/session_011kNU4XxzXp6NpCnVUQA6yH

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Unit Test Results

2 066 tests  ±0   2 066 ✅ ±0   8m 21s ⏱️ - 1m 34s
  237 suites ±0       0 💤 ±0 
  237 files   ±0       0 ❌ ±0 

Results for commit 5362cc7. ± Comparison against base commit c857cb6.

♻️ This comment has been updated with latest results.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@stslex
stslex force-pushed the claude/database-bundled-sqlite branch 2 times, most recently from 278b99e to 7459530 Compare August 21, 2026 07:43
@stslex

stslex commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@stslex

stslex commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Claude finished @stslex's task in 2m 21s —— View job


Review complete

  • Gather context (PR body, changed files, base branch)
  • Read diff against origin/claude/database-kmp
  • Review each changed file
  • Post review feedback

This is a clean, narrowly-scoped driver flip — every code-side setDriver() call site (production AppDatabaseFactory, the 5 device tests, InMemoryDatabaseProvider) moves to BundledSQLiteDriver consistently, while the two host-side fixtures (RepositoryTestEnv, BaseDatabaseTest/DatabaseSnapshotProviderImplTest) correctly stay pinned to AndroidSQLiteDriver with the measured UnsatisfiedLinkError rationale documented right where the pin lives. The SessionDao comment update (available-but-not-taken ROW_NUMBER() rewrite) is accurate and appropriately conservative for a "behaviour-neutral by design" PR.

One real issue: two now-stale comments this PR should have updated but didn't, both directly contradicted by this PR's own change:

  1. gradle/libs.versions.toml:51-54 and :183,185 — still say "sqlite-bundled (K/N BundledSQLiteDriver) stays declared but INERT — Android uses the framework driver; bundled lands when C.1 converts the DB module to K/N" and "K/N BundledSQLiteDriver — declared but inert (Android does not need it; lands with C.1)". That's exactly the state this PR ends. Post-flip, androidx-sqlite-bundled is the active production driver on Android via androidMainImplementation (RoomLibraryConventionPlugin.kt:78), not inert.
  2. core/data/database/build.gradle.kts:20"platform-typed stays in androidMain — buildAppDatabase (Context + AndroidSQLiteDriver)..." — contradicted by AppDatabaseFactory.kt:47, changed in this very diff to BundledSQLiteDriver(). Notably this stale line sits in the same file as the new, correct comment at line 44 explaining why host tests stay on AndroidSQLiteDriver — so the file now asserts both drivers for buildAppDatabase depending on which comment you read.

Neither is a functional bug (build correctness doesn't depend on comment text), but both are exactly the kind of drift this PR's own stated philosophy ("measured, not assumed"; a device-restore risk register that flags rather than silently carries) argues against leaving behind, and both are one-line fixes.

Fix this →

No other issues found — dependency wiring (database-test/build.gradle.kts declaring both drivers for its two fixtures, database/build.gradle.kts adding the explicit androidHostTestImplementation(libs.androidx.sqlite.framework) pin now that androidMain no longer implies framework), and all setDriver() call sites are consistent with the PR's stated design and its gate results.
· branch claude/database-bundled-sqlite

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 74595302d0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@stslex

stslex commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Review classification before the next push:

  • Claude stale driver comments — correct. The catalog and database build-script comments still describe BundledSQLiteDriver as inert/framework-only even though this PR activates it in androidMain. I will update those comments in this PR.
  • Codex constraint-exception finding — correct-but-already-decided. The failure reproduces: the bundled driver throws androidx.sqlite.SQLiteException and the framework-only catch does not match. The canonical phase ledger explicitly assigns the catch conversion and its red-first device test to PR E under documentation/feature-specs/kmp-phase-6-data-layer.md§5 core:data:exercise (“Decided: PR E converts this catch…”). That implementation is already present in the directly stacked KMP phase 6E: core:data:exercise becomes a KMP module — with zero androidMain #241 (ExerciseRepositoryImpl.isUniqueConstraintViolation plus ExerciseRepositoryDuplicateNameDeviceTest). I will keep the behavior fix at that ruled boundary rather than duplicate it in both PRs.

@stslex
stslex force-pushed the claude/database-bundled-sqlite branch from 7459530 to a00c91f Compare August 21, 2026 09:17
@stslex

stslex commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Implemented the correct stale-comment findings in a00c91fb: the catalog now describes BundledSQLiteDriver as the active KMP production driver, the database module names the bundled driver at buildAppDatabase, and the classic Room convention no longer claims that AppDatabaseFactory uses the framework driver. Verified with :build-logic:convention:check, database Detekt, and database lint: 65 actionable tasks: 65 executed.

@stslex

stslex commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@stslex

stslex commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Claude finished @stslex's task in 1m 22s —— View job


Review complete

  • Gather context (PR body, changed files, prior review + prior classification comments)
  • Read diff against origin/claude/database-kmp (both commits: the driver flip 9906301 and the follow-up a00c91f)
  • Review each changed file
  • Post review feedback

This is a re-review after a00c91f, which fixes the two stale-comment findings from my earlier pass. Verified both against the current code, not just the diff text:

  1. RoomLibraryConventionPlugin.kt:54-55 — now says classic Android modules use the framework artifact while KMP modules select their driver in configureKmp(). Checked configureKmp() (line 64-112): it adds androidx-sqlite-bundled on androidMainImplementation (line 78) and documents the Robolectric UnsatisfiedLinkError constraint that forces the host-test pin elsewhere. Accurate.
  2. core/data/database/build.gradle.kts:20 — now says buildAppDatabase (Context + BundledSQLiteDriver), matching AppDatabaseFactory.kt:47's .setDriver(BundledSQLiteDriver()). Accurate.
  3. gradle/libs.versions.toml:51-53,182,184 — catalog comments no longer describe sqlite-bundled as inert; they now state it's the production Android driver for KMP Room modules and sqlite-framework serves classic modules + host-test fixtures. Consistent with the dependency wiring in both build.gradle.kts files touched by this PR.

No new issues introduced by the fix commit — it's comment-only, and every comment now matches the code it sits next to.

On the outstanding Codex P1 finding (AppDatabaseFactory.kt:47, bundled driver throws androidx.sqlite.SQLiteException but ExerciseRepositoryImpl.saveItem still catches only android.database.sqlite.SQLiteConstraintException): I confirmed the classification of correct-but-already-decided holds up. ExerciseRepositoryImpl.kt:117 on this branch still catches the framework exception type, and documentation/feature-specs/kmp-phase-6-data-layer.md §5 (line 362) explicitly assigns the catch-type conversion to PR E (core:data:exercise → KMP, i.e. #241), gated by a duplicate-name device test. Since #241 is stacked directly on this PR and can't merge first, the gap never reaches master unfixed — the bisect-boundary rationale in the PR description is consistent with the code as it stands.

No other issues found in this pass.
· branch claude/database-bundled-sqlite

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a00c91fbe8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@stslex

stslex commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Review classification before the next push:

  • Codex SessionDao chronology — correct. The old system-driver chronology and “not taken in the driver flip” are history. The current guard is that a ROW_NUMBER() rewrite is available but must retain real-device coverage because Robolectric uses a different SQLite engine. The driver decision/history already lives in documentation/feature-specs/kmp-phase-6-data-layer.md§6 The driver decision — decided; I will reduce the KDoc to the current constraint plus that anchor.

stslex and others added 3 commits August 21, 2026 12:33
…d, behind its own bisect point

buildAppDatabase moves to BundledSQLiteDriver (SQLite 3.50.x on every device;
main-db and WAL formats frozen, so existing installations open unchanged),
and the flip's oracle moves with it: all 5 device-test classes and the
app:app in-memory provider (MetroTestRule) now run the production driver.

The Robolectric surfaces deliberately do NOT flip, and the reason is
measured, not assumed: the bundled android variant carries Android-ABI
natives only, and loading it on a desktop JVM dies with UnsatisfiedLinkError
(probed on ExerciseDaoGetAllTest before deciding). RepositoryTestEnv,
BaseDatabaseTest and the snapshot host test pin AndroidSQLiteDriver with the
reason in place — Robolectric was already inadmissible as a driver oracle
(two recorded false negatives on rollback).

Pre-flip audit of all 105 @query sites + every raw-SQL site found zero
version-sensitive constructs and zero double-quoted string literals (the
classic DQS breakage); the one pre-existing arbitrary-row query
(getRecentSessionsForExercise's bare column under GROUP BY) is recorded, not
regressed. SessionDao's PR-batch comment now states ROW_NUMBER() as
available-but-not-taken; the snapshot package's framework-SQLite peek and
checkpoint interop is documented at the builder and remains the
device-restore pass's territory.

Gate, under the flipped driver: 28/28 device tests green on Pixel_6_API_34
(the 7 migration cases apply Migration6 to a real v5 file and validate
against 6.json), the same 28 having run green under AndroidSQLiteDriver one
commit earlier — both drivers, as the spec's §6 requires. app:app device
Regression 41/41 (35 pinned + #232's six). Host tests unchanged: database
128, exercise 212. Full forced gate 3248/3248, detekt separate 56/56,
repo counts identical to the parent commit (2230+165, 0 failures), goldens
456/0 touched, schemas untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kNU4XxzXp6NpCnVUQA6yH
@stslex
stslex force-pushed the claude/database-bundled-sqlite branch from a00c91f to 5362cc7 Compare August 21, 2026 09:36
@stslex

stslex commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@stslex

stslex commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Claude finished @stslex's task in 1m 8s —— View job


Review complete

  • Gather context (PR body, changed files, prior review + classification comments)
  • Read diff against origin/claude/database-kmp (all 3 commits, through 5362cc7)
  • Review each changed file
  • Post review feedback

This is a re-review after 5362cc7, which trims the SessionDao KDoc per the last classification comment ("Codex SessionDao chronology — correct... I will reduce the KDoc to the current constraint plus that anchor").

SessionDao.kt:75-82 — verified. The KDoc for PR_BATCH_SQL now states only the current constraint (a ROW_NUMBER() rewrite is available under the bundled driver but must retain device coverage because Robolectric uses a different SQLite engine) plus a pointer to kmp-phase-6-data-layer.md → §6. Confirmed that anchor exists at line 372 of that spec (## §6 The driver decision — decided) and its content matches the pointer's claim. The system-driver chronology (SQLite 3.22/3.25, "declared but inert") is gone from the KDoc, consistent with the classification that decision history belongs in commit body / phase spec, not a query guard comment.

Both stale-comment fixes from the earlier passes hold up against the current code:

  1. RoomLibraryConventionPlugin.kt:56-57,78-84configureAndroid() correctly describes classic modules staying on the framework artifact; configureKmp() correctly describes BundledSQLiteDriver as the active KMP/Android production driver, with the per-target rationale and the Robolectric UnsatisfiedLinkError constraint that forces the host-test pin.
  2. core/data/database/build.gradle.kts:20 and gradle/libs.versions.toml:51-53,182,184 — all now describe BundledSQLiteDriver/sqlite-bundled as production, AndroidSQLiteDriver/sqlite-framework as classic-module + host-test only. Matches AppDatabaseFactory.kt:47's .setDriver(BundledSQLiteDriver()) and the androidHostTestImplementation(libs.androidx.sqlite.framework) pin added in database/build.gradle.kts.

On the outstanding Codex P1 (ExerciseRepositoryImpl.saveItem still catching android.database.sqlite.SQLiteConstraintException, not the bundled driver's androidx.sqlite.SQLiteException): re-checked ExerciseRepositoryImpl.kt:117-118 on this branch — the framework-only catch is still there, confirming the gap is real but unresolved in this PR by design. documentation/feature-specs/kmp-phase-6-data-layer.md §5 still assigns the catch-type conversion to PR E, and #241 (which already carries isUniqueConstraintViolation + a duplicate-name device test per your classification comment) stacks directly on top and cannot merge first. The bisect-boundary rationale holds.

No new issues found in this pass — the outstanding items from earlier reviews are all either fixed and verified, or correctly deferred with a live implementation already staged in the next stacked PR.
· branch claude/database-bundled-sqlite

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 5362cc7211

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

stslex added a commit that referenced this pull request Aug 21, 2026
…teDriver (#239)

**KMP phase 6, increment D: `core:data:database` becomes a KMP module,
on `AndroidSQLiteDriver`.**

The centrepiece of phase 6's second half (#232 carried the spec; #235
was this PR's step 1). The port only — the `BundledSQLiteDriver` flip is
deliberately NOT here; it follows as its own PR with its own gate, so a
SQLite regression and a source-set regression stay distinguishable under
bisect. **The schema did not move: the regenerated `6.json` is
byte-identical, `identityHash 9a848458f02ef4c4ace9841b9f2e3d42`.**

## The shape — five commits, bisect-green each

1. **`epochToIso` leaves `java.time`** —
`kotlin.time.Instant.fromEpochMilliseconds(ms).toString()`, the
zero-new-dependency stdlib replacement, measured byte-identical against
`java.time` for whole-second / zero / fractional / negative epochs.
Pinned twice: new literal-string tests, and
`DatabaseJsonExporterImplTest` keeps `java.time` on the test side
**deliberately** as a cross-library oracle. Mutation M-ISO1
(`fromEpochSeconds`) reds 4 tests — the epoch-zero pin alone survives,
since 0 s and 0 ms name the same instant; reverted. This makes
`WorkoutExportMapper` commonMain-eligible: androidMain ends at 4 files,
not the spec's 5.
2. **Fifteen reflective builder sites take the generic form** —
`Room.databaseBuilder<AppDatabase>(context, name)` etc., while the
module is still Android-only (the reified overload falls back to
reflection until `@ConstructedBy` exists — so this commit is pure
mechanics, bisectable on its own). The 16th site was already the Room 3
KClass form; the spec's total said 15, measured 16. Phase-7 note: with
`Class<T>` gone, a bare `ApplicationProvider.getApplicationContext()`
argument deadlocks inference against the builder's factory overloads —
pin as `getApplicationContext<Application>()`.
3. **The device-test component and its two CI names** — `withDeviceTest`
unconditional in the KMP convention (classic-AGP parity: every library
carries the component whether or not sources exist) plus
`assembleDebugAndroidTest → assembleAndroidDeviceTest` **and**
`connectedDebugAndroidTest → connectedAndroidDeviceTest`. **The second
alias is a finding, not a plan item**: `ui_tests.yml` invokes
`connectedDebugAndroidTest` literally, so the pre-diagnosed assemble
alias alone would build a device-test APK the weekly suite never runs —
the **fifth** silent-vanish instance of this arc. Proven both
directions: on the parent commit both repo-wide graphs contain **0**
`*AndroidDeviceTest` tasks; with the commit, both are fully wired for
every KMP module, and an **empty** KMP device-test APK runs green
on-device under the Regression annotation filter (the exact ui_tests.yml
invocation), so the three source-less KMP modules cost the weekly suite
nothing. `RoomLibraryConventionPlugin` keeps one plugin id and branches
(KMP: room bundle + `paging-common` in commonMain, `sqlite-framework` in
androidMain, `kspAndroid`/`kspIosSimulatorArm64`, room-testing on the
device suite).
4. **The conversion** — 51 files commonMain / 4 androidMain
(`buildAppDatabase`, `snapshot/`×3: Context, framework SQLite,
`java.io.File`), 26 test classes → `androidHostTest`, 5 →
`src/androidDeviceTest/kotlin`; `@ConstructedBy` + a **bare** `expect
object AppDatabaseConstructor` (see below); detekt.yml gains
`**/androidDeviceTest/**` beside every host-test glob, in this commit
because that is when the sources exist (spec §3.4).
5. **The spec records what PR D measured** — new §9 in
`kmp-phase-6-data-layer.md`; two of the spec's own §3 claims dated in
place.

## What the first instrumented conversion surfaced — each measured, each
fixed at the root

- **Room-KMP puts no schemas on the device-test APK.** All 7
`MigrationTestHelper` tests failed on device, loudly:
`FileNotFoundException … Missing file: …/5.json`. The classic
integration's androidTest-assets copy has no KMP counterpart, so the
Room convention's KMP branch wires `schemas/` as a static device-test
asset via the variant API — **which is null until
`androidResources.enable = true`**: AGP-KMP defaults the entire asset
pipeline off (measured: `sources.assets == null` before, six schemas
inside the APK after).
- **`checkAndroidDeviceTestAarMetadata` fails on the first KMP device
APK that depends on an Android-convention AAR** ("requires core library
desugaring"). The KMP convention now mirrors `configureKotlinAndroid`'s
repo-wide desugaring (flag + `desugar_jdk_libs`).
- **Lint does not classify KSP output as generated on KMP.**
`checkGeneratedSources = false` is already set and honored on classic
modules; here a consumer with `checkDependencies = true` red **1087
RestrictedApi + 6 SyntheticAccessor — 100% under `build/generated`, zero
in handwritten code**. Encoded in `lint.xml` as per-issue
`**/build/generated/**` ignores. Mutation M-LINT1 (break the path)
brings back exactly 1093; reverted.
- **The repo's own gates policed this PR, twice.**
`NoActualForExpectSuppressionRule` rejected the `NO_ACTUAL_FOR_EXPECT`
suppression I first wrote on the expect object — and it is right: the
metadata compilation never demands an actual, the platform compilations
get Room's generated ones, and without the suppression a codegen failure
reds the build instead of vanishing. And detekt, now seeing device tests
through the KMP source-set model, surfaced two pre-existing `Wrapping`
violations in `AtomicRollbackDeviceTest` — **`src/androidTest` was never
full-detekt-checked on classic modules** (default sources are `src/main`
+ `src/test`), the same shape as #235's testFixtures hole.
- **Room 3 schema export is write-on-diff** — the processor writes into
the plugin's `schemaOutput` only when the generated schema differs from
`schemaInput` (= the committed `schemas/`), so a production build can
never silently rewrite the schema directory. The §6 gate ran as
prescribed: schemaInput redirected to an empty dir via an out-of-tree
init script, fresh `6.json` exported, byte-identical.
- **`setQueryCoroutineContext` deliberately NOT added** (deviation from
spec §3, argued): the Android builder runs without it today, adding it
would change which context Room queries use — not "the port only" — and
nothing constructs a database off-Android until phase 7's iOS
composition root, which is where it belongs, next to iOS's driver
choice.
- One commonMain-ism: `String.toByteArray(Charsets.UTF_8)` is JVM-only →
`encodeToByteArray()` (one site).

## A counting correction, in the open

The repo-wide unit-test invariant number (2352 at #235) was produced by
a `test-results/testDebugUnitTest` glob — which **never saw KMP modules'
`testAndroidHostTest` results** (core:core 32, dataStore 5). Execution
was never affected (CI's alias runs them); only the counting was blind,
on both sides of every comparison, so past deltas stayed honest. From
this PR on the number is reported in both scopes.

## Gates

All `--rerun-tasks --no-build-cache --no-configuration-cache`; baseline
measured on dev@c81c4490 the same way.

| Gate | Result |
|---|---|
| full forced gate (assembleDebug testDebugUnitTest verifyPaparazziDebug
lintDebug assembleDebugAndroidTest) | ✅ `3248 actionable tasks: 3248
executed`, 0 failed (dev baseline: 3080/3080; the +168 is the KMP
module's task set + device-test components) |
| detekt (separate invocation) | ✅ `56 actionable tasks: 56 executed` |
| unit tests | ✅ **2230 tests / 230 classes** (`testDebugUnitTest`
scope) + **165 / 33** (`testAndroidHostTest` scope) = **2395**, 0
failures, 0 skipped, 0 stale XMLs. Accounting vs 2354 baseline: +4 new
epochToIso pins, +37 previously uncounted KMP host tests, −0 lost;
database's 128 moved scope, not existence |
| device suite | ✅ **`Starting 28 tests on Pixel_6_API_34(AVD)`, 28/28
green** — via the `connectedDebugAndroidTest` alias under the exact
ui_tests.yml Regression annotation filter; red direction observed live
(7 migration failures while schemas were missing from assets) |
| instrumented suite gate | ✅ `:core:data:database: 5 instrumented
source files, 206 classpath entries scanned, 0 missing` — resolved
through `androidDeviceTestRuntimeClasspath` |
| schema identityHash | ✅ regenerated-from-empty-input `6.json`
**byte-identical** to committed; `git status schemas/` empty |
| iOS | ✅ `compileKotlinIosSimulatorArm64` +
`kspKotlinIosSimulatorArm64` + klib in the same repo-wide graph (the
`assembleDebug` alias pulls them) |
| aliases both directions | ✅ parent commit: 0 `*AndroidDeviceTest`
tasks in either repo-wide graph; this branch: fully wired |
| goldens | ✅ **456 on disk, 0 touched** (446 in older docs is stale —
#236/#237 added 10 on dev before this branch) |

Stacked work: #240 (the `BundledSQLiteDriver` flip) sits on top of this
branch; PR E (`core:data:exercise` → KMP) will stack on #240. Waiting on
review here does not block that work.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_011kNU4XxzXp6NpCnVUQA6yH
@stslex
stslex changed the base branch from claude/database-kmp to dev August 21, 2026 10:21
@stslex
stslex merged commit 892835c into dev Aug 21, 2026
7 checks passed
stslex added a commit that referenced this pull request Aug 21, 2026
…roidMain (#241)

**KMP phase 6, increment E: `core:data:exercise` becomes a KMP module —
with zero androidMain. Stacked on #240** → #239. Review in that order.

The last code increment of phase 6. Four commits: the one genuine
behaviour change (red-first, on device), the wall-clock swap, the
pure-mechanics conversion, and the hook-deployment fix the conversion's
own near-miss surfaced.

## 1. The behaviour change the spec predicted, observed live before it
was fixed

Spec §5 called `catch (SQLiteConstraintException)` in
`ExerciseRepositoryImpl.saveItem` *control flow, not a type name*: under
a Room 3 driver the constraint violation arrives as a different type,
the catch silently stops matching, and the exception escapes — with no
compile error. That is exactly what happened. A new on-device
duplicate-name test (this module's first device test, `@Regression`,
in-memory DB under the flipped `BundledSQLiteDriver`) ran **red against
the unfixed repository**, escaping with:

```
android.database.SQLException: Error code: 2067, message: UNIQUE constraint failed: exercise_table.name
```

The fix is smaller than the spec's own sketch, because of a fact worth
keeping: **on Android, `androidx.sqlite.SQLiteException` is `actual
typealias … = android.database.SQLException`** (read from the 2.7.0
sources jar). So one common catch covers the framework driver's
`SQLiteConstraintException` (a subtype), the bundled driver's own throw
(measured above), and iOS's native actual — no expect/actual seam needed
at all. The constraint check gates on the one string both engines emit
(`UNIQUE constraint failed`), because the common type has no code field
and the framework subtype is not nameable from common code.

Proven in all four directions: device red on the old catch → green on
the new; the Robolectric twin (framework driver) green throughout;
mutation **M-DUP1** (never-matching string) reds **both** legs — 1 of 54
host tests, 1 of 1 device — reverted.

## 2. Mechanics

- **Eight `System.currentTimeMillis()` sites** →
`kotlin.time.Clock.System.now().toEpochMilliseconds()` (stdlib, same
wall clock; its own commit so the conversion stays pure moves).
- **The conversion**: all 42 production files → commonMain. **No
androidMain** — after the catch fix, the module had no platform type
left; the paging surface lives in `paging-common` (verified
`iosSimulatorArm64` published). 19 test classes + seed helper →
`androidHostTest`, the device test → `src/androidDeviceTest/kotlin`. The
dead `compose-bom`/`compose-runtime`/`room3-runtime` declarations (zero
usages, spec §5) do not return; `paging-runtime-ktx` → `paging-common`;
the new `androidx-sqlite` (base) alias supplies the exception type the
database module's `implementation` deps rightly don't leak.
- The whole PR-D convention machinery ran unchanged for the second
module: device-test component, both CI aliases, schema-asset wiring (n/a
here), desugaring, detekt device-test globs. **Nothing new was needed**
— the first conversion paid; the second rode.

## 3. A process finding, in the open

`setup-hooks.sh` **copies** `.githooks/pre-commit` into `.git/hooks/` —
it does not set `core.hooksPath`, though CLAUDE.md says it does.
Consequences, both real: a clone that never ran the script has **no hook
at all** (this session's clone — caught when a red detekt run and a
passing commit coexisted; the separate forced detekt invocations in this
arc's process were the actual gate all along), and a clone that ran it
**before #235 still carries the ACM filter** the fixture-move PR proved
blind to renames. Fixed here: the script now sets `core.hooksPath` to
the tracked `.githooks/`, so hook fixes deploy with `git pull` instead
of with a ritual — which is what CLAUDE.md already claimed. Reported
rather than silently corrected.

## 4. The §7 riders, re-priced with numbers (closing the phase brief's
ask)

- **`java.time`**: production `core/data` is now clean —
`WorkoutExportMapper`'s line was taken in #239. What remains: 1 test
file (`DatabaseJsonExporterImplTest`, deliberately kept as a
cross-library oracle) and 14 `feature:exercise-chart` files, which are
Phase 7's by the spec's own split. **This rider is discharged for phase
6.**
- **`java.io.File` → `okio.Path`**: the dependency half is now free
(okio rides the graph via `datastore-preferences-core` since #233; using
it in signatures needs only a toml alias). The remaining cost is
unchanged in kind: a signature ripple across 7 modules (backup:api,
google-drive, worker, database, recovery, settings, app:app). And its
urgency **dropped** with this phase: after #239 every `File`-typed
surface sits in an androidMain of a KMP module — nothing common is
blocked by it. Still correctly deferred until a real iOS backup
implementation makes the right shape observable; `android.net.Uri` still
has no common analogue (SAF).

## Gates

All `--rerun-tasks --no-build-cache --no-configuration-cache`.

| Gate | Result |
|---|---|
| device: duplicate-name under bundled | ✅ red on old catch (escape
above) → 1/1 green via the `connectedDebugAndroidTest` alias under the
ui_tests.yml Regression filter |
| host tests | ✅ exercise 212/19 (now `testAndroidHostTest` scope),
feature:exercise 113/9, exercise-chart 66/8, 0 failures |
| full forced gate (assembleDebug testDebugUnitTest verifyPaparazziDebug
lintDebug assembleDebugAndroidTest) | ✅ `3265 actionable tasks: 3265
executed`, 0 failed |
| detekt (separate invocation) | ✅ `57 actionable tasks: 57 executed` —
the 57th is the suite gate over this module's new device test,
previously NO-SOURCE |
| repo-wide unit tests | ✅ **2018 / 211** (`testDebugUnitTest` scope) +
**377 / 52** (`testAndroidHostTest` scope) = **2395**, 0 failures, 0
skipped — identical total to #239/#240; exercise's 212 moved scope, not
existence |
| iOS | ✅ exercise klib + `kspKotlinIosSimulatorArm64` in the repo-wide
`assembleDebug` graph |
| goldens | ✅ 456, 0 touched |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_011kNU4XxzXp6NpCnVUQA6yH
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant