Skip to content

KMP phase 6D: core:data:database becomes a KMP module, on AndroidSQLiteDriver - #239

Merged
stslex merged 10 commits into
devfrom
claude/database-kmp
Aug 21, 2026
Merged

KMP phase 6D: core:data:database becomes a KMP module, on AndroidSQLiteDriver#239
stslex merged 10 commits into
devfrom
claude/database-kmp

Conversation

@stslex

@stslex stslex commented Aug 20, 2026

Copy link
Copy Markdown
Owner

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.timekotlin.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 formRoom.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 nameswithDeviceTest 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 AtomicRollbackDeviceTestsrc/androidTest was never full-detekt-checked on classic modules (default sources are src/main + src/test), the same shape as KMP phase 6D step 1: the DB fixtures leave testFixtures, and the pre-commit hook stops ignoring moves #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.ai/code/session_011kNU4XxzXp6NpCnVUQA6yH

stslex and others added 5 commits August 20, 2026 20:13
… byte

epochToIso becomes kotlin.time.Instant.fromEpochMilliseconds(ms).toString() —
the zero-new-dependency replacement (stdlib, stable since Kotlin 2.3), taken
now so WorkoutExportMapper is commonMain-eligible when the module converts.

The rendering is pinned twice, in different currencies: a new literal-string
test (whole-second, zero, three-digit fractional, negative epoch) and the
existing DatabaseJsonExporterImplTest assertion, which keeps java.time on the
test side deliberately — it is now a cross-library oracle, not a leftover.
Mutation M-ISO1 (fromEpochSeconds) reds 4 tests including the cross-oracle;
the epoch-zero pin alone survives, as 0s and 0ms name the same instant.
Reverted, not committed. Module suite: 128 tests / 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kNU4XxzXp6NpCnVUQA6yH
Room.databaseBuilder(context, AppDatabase::class.java, name) becomes the
KMP-shared Room.databaseBuilder<AppDatabase>(context, name), across the one
production site, seven src/test, four androidTest, two database-test and one
app:app androidTest. The sixteenth site — MigrationTestHelper's KClass
argument — was already the Room 3 form and does not move. The module is
still Android-only; the reified overload resolves the constructor
reflectively until @ConstructedBy arrives with the conversion.

One inference consequence, worth knowing before phase 7 repeats it: with the
Class<T> parameter gone, a bare ApplicationProvider.getApplicationContext()
argument no longer anchors either type parameter — the builder's factory
overloads and the context accessor's own T deadlock inference. The three such
sites pin it as getApplicationContext<Application>().

Suites: database 128/0, exercise 212/0 (RepositoryTestEnv consumers
untouched); both androidTest APKs compile; detekt separate, 56/56.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kNU4XxzXp6NpCnVUQA6yH
…ve before the module that needs them

KmpLibraryConventionPlugin now calls withDeviceTest unconditionally —
mirroring classic AGP, where every library carries an androidTest component
whether or not sources exist — and registers two more aliases:
assembleDebugAndroidTest → assembleAndroidDeviceTest (the vanish
ConfigureInstrumentedSuiteGate pre-diagnosed) and connectedDebugAndroidTest →
connectedAndroidDeviceTest. The second is a finding, not a plan item:
ui_tests.yml invokes connectedDebugAndroidTest literally, so the assemble
alias alone would build a device-test APK the weekly suite never runs — the
same hole one workflow later.

Proven both directions at the convention level: on the parent commit both
repo-wide graphs contain 0 *AndroidDeviceTest tasks; with this commit,
assembleDebugAndroidTest pulls every KMP module's device-test build and
connectedDebugAndroidTest pulls connectedAndroidDeviceTest on all three.
An empty device-test APK runs green on-device under the Regression
annotation filter (the exact ui_tests.yml invocation), so componentizing
modules without instrumented sources costs the weekly suite nothing.

RoomLibraryConventionPlugin keeps one plugin id and branches: the KMP side
declares the room bundle + paging-common in commonMain (paging-runtime-ktx is
Android-only; PagingSource lives in paging-common), sqlite-framework in
androidMain (the driver artifact is per-target by construction), room-compiler
on kspAndroid + kspIosSimulatorArm64, room-testing on androidDeviceTest.
Dead until the first KMP Room module lands in the next commit.

The stale KmpLibraryConventionPlugin KDoc sentence pointing android.* needs
at the phase-3-deleted core:core-android sibling is corrected to the
androidMain + direct-Metro reality core:data:dataStore already proves.

KMP modules re-verified green with the new component: core, dataStore,
backup:api assemble + host tests, 140/140 executed. detekt separate, 56/56.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kNU4XxzXp6NpCnVUQA6yH
…ing a byte of schema

core:data:database converts to convention.kmpLibrary + the Room convention's
KMP branch: 51 files to commonMain, 4 to androidMain (buildAppDatabase and
the snapshot/ package — Context, framework SQLite, java.io.File), 26 test
classes to androidHostTest, 5 to src/androidDeviceTest/kotlin. AppDatabase
gains @ConstructedBy(AppDatabaseConstructor) — a bare expect object; Room KSP
generates the actual per target, and NoActualForExpectSuppressionRule is why
it carries no NO_ACTUAL_FOR_EXPECT suppression: the metadata compilation
never demands an actual, and if codegen stops, the platform compile reds
instead of green-over-nothing.

The schema did not move, proven the way §6 prescribes: schemaInput redirected
to an empty directory out-of-tree, the processor exported a fresh 6.json,
byte-identical, identityHash 9a848458f02ef4c4ace9841b9f2e3d42. The mechanism
that made the probe necessary is itself the finding — Room 3 exports
write-on-diff against schemaInput (= the committed schemas/), so a production
build cannot silently rewrite the directory.

What the first instrumented conversion surfaced, each fixed at the root:

- Room-KMP puts no schemas on the device-test APK; all 7 MigrationTestHelper
  tests failed loud (Missing file: …/5.json). The Room convention's KMP
  branch now wires schemas/ as a static device-test asset via the variant
  API — which requires androidResources.enable = true first, because with it
  off the component's sources.assets is null (measured): the asset pipeline
  does not exist.
- The first KMP device APK depending on an Android-convention AAR fails
  checkAndroidDeviceTestAarMetadata over desugaring; the KMP convention now
  mirrors configureKotlinAndroid's repo-wide core-library desugaring.
- Lint does not classify KSP output as generated on KMP: a consumer with
  checkDependencies analyzed the generated Room code and red 1087
  RestrictedApi + 6 SyntheticAccessor — all under build/generated, none in
  handwritten code. lint.xml gains per-issue **/build/generated/** ignores;
  mutation M-LINT1 (break the path) brings back exactly 1093, reverted.
- src/androidTest was never full-detekt-checked on classic modules (default
  sources are src/main + src/test); the KMP source-set model put the device
  tests under the full ruleset for the first time and surfaced two
  pre-existing Wrapping violations in AtomicRollbackDeviceTest — fixed.
- detekt.yml: every list carrying the androidHostTest glob gains
  **/androidDeviceTest/**, and the header note now points at the convention
  (configureDetektSources) instead of the pre-05b75a4c per-module wiring.

Gates, all --rerun-tasks --no-build-cache --no-configuration-cache:
full forced gate 3248/3248 tasks 0 failed; detekt separate 56/56; unit tests
2230/230 (testDebugUnitTest scope) + 165/33 (testAndroidHostTest scope) =
2395, accounting exactly as 2354 baseline + 4 new epochToIso pins + 37
KMP-module tests the old testDebugUnitTest-glob methodology never counted
(core:core 32, dataStore 5 — execution was never affected, only counting;
report both scopes from here on). Device: Starting 28 tests on
Pixel_6_API_34, 28/28 green via connectedDebugAndroidTest alias under the
exact ui_tests.yml Regression annotation filter. Suite gate prints
":core:data:database: 5 instrumented source files, 206 classpath entries
scanned, 0 missing" over androidDeviceTestRuntimeClasspath. iOS: klib
compiled + kspKotlinIosSimulatorArm64 in the same repo-wide graph. Goldens
456, 0 touched. schemas/ untouched by git.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kNU4XxzXp6NpCnVUQA6yH
… of its own claims

New §9 carries the conversion's findings for phase 7's ~30 modules: the
connectedDebugAndroidTest fifth vanish, the Room-KMP device-asset gap behind
androidResources.enable, write-on-diff schema export, KSP2 config spellings,
the forbidden-and-unneeded NO_ACTUAL_FOR_EXPECT suppression, the desugaring
AAR-metadata check, lint's blindness to generated code on KMP, and detekt
seeing device tests under the full ruleset for the first time. §3's
setQueryCoroutineContext and 5-file-androidMain claims are dated in place,
not deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: 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   2 066 ✅  9m 55s ⏱️
  237 suites      0 💤
  237 files        0 ❌

Results for 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 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 17s —— View job


Review

Went through the full diff (git diff origin/dev...HEAD) — convention plugins, the commonMain/androidMain/androidHostTest/androidDeviceTest split, the epochToIso port, and the docs. This is a large, well-instrumented port and the claims in the PR body check out against the code:

  • epochToIsokotlin.time.Instant.fromEpochMilliseconds(ms).toString() in WorkoutExportMapper.kt, pinned by literal-string tests, with DatabaseJsonExporterImplTest keeping java.time.Instant.ofEpochMilli(...).toString() as a cross-library oracle — confirmed both files.
  • AppDatabase/AppDatabaseConstructor/AppDatabaseFactory split matches the described commonMain/androidMain boundary; the expect object AppDatabaseConstructor has no hand-written actual, consistent with NoActualForExpectSuppressionRule existing in lint-rules/.
  • Room bundle (room3-runtime + room3-paging) and androidx-paging-common are genuinely multiplatform artifacts, so commonMainImplementation placement is correct; the Android-only androidx-paging-runtime stays out of commonMain.
  • RoomLibraryConventionPlugin.configureKmp()'s androidResources.enable = truedeviceTests…sources.assets.addStaticSourceDirectory(schemas) sequencing matches the "null until enabled" finding.
  • KmpLibraryConventionPlugin's assembleDebugAndroidTest/connectedDebugAndroidTest alias tasks are literal-named and depend on the AGP-KMP task names, matching the "5th silent vanish" finding and ui_tests.yml's literal connectedDebugAndroidTest invocation.
  • detekt.yml/lint.xml additions are scoped correctly (**/androidDeviceTest/** glob added everywhere androidHostTest already was; lint ignores are path-scoped to **/build/generated/**, not issue-wide).
  • Docs (architecture.md, testing.md, the phase-6 spec §9) accurately reflect the new source-set layout and are consistent with the code.

Two minor, non-blocking notes:

  1. RoomLibraryConventionPlugin.configureKmp() (lines 95–99) uses fully-qualified names inline (org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension, org.gradle.api.plugins.ExtensionAware, com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryExtension) instead of top-level imports used everywhere else in the file. Not a functional issue, just inconsistent with the file's own style — could be cleaned up with plain imports.
  2. Uncommitted local working-tree changes to .claude/skills/add-database-migration.md and .claude/skills/write-repository-test.md are present in this checkout (reverting the just-committed KMP path updates back to old src/main/src/androidTest paths) but are not part of this PR's commit history — confirmed the actual PR diff (origin/dev...HEAD) already has the correct KMP paths in both files. This looks like a leftover artifact of the CI/review environment, not something in the PR itself; flagging in case it's unexpected on your end, no action needed from this review.

No correctness issues found in the conversion itself. The gate table in the PR description (2395 unit tests, 28/28 device suite, byte-identical schema/identityHash, both alias directions verified) is consistent with what the diff implements. Nice work isolating this from the BundledSQLiteDriver flip (#240) — keeps this PR bisectable on its own.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 2ca9c924d6

ℹ️ 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

@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 30s —— View job


Review

  • Gather context (PR body, prior review comments, changed files)
  • Read diff (git diff origin/dev...HEAD)
  • Review convention plugin changes (Kmp/Room)
  • Review commonMain/androidMain/androidHostTest/androidDeviceTest split
  • Review epochToIso port
  • Review docs updates
  • Post final review feedback

Went through git diff origin/dev...HEAD in full (convention plugins, the source-set split, the expect/actual constructor seam, epochToIso, lint/detekt config, docs). Since the automated review at 2ca9c92, the only change is a 2-line docs trim in core/data/database-test/build.gradle.kts (fea9edd), so this mostly reconfirms that review against the current HEAD, plus a closer look at a few specific files.

Confirmed correct / no new issues found:

  • WorkoutExportMapper.epochToIsokotlin.time.Instant.fromEpochMilliseconds(ms).toString(), pinned by WorkoutExportMapperEpochToIsoTest against whole-second/zero/fractional/negative epochs — matches the claimed byte-identical behavior, and DatabaseJsonExporterImplTest correctly keeps java.time as the cross-library oracle.
  • AppDatabase.kt / AppDatabaseConstructor.kt / AppDatabaseFactory.kt split is exactly as described: @ConstructedBy(AppDatabaseConstructor::class) in commonMain, bare expect object with no hand-written actual, buildAppDatabase (Context + AndroidSQLiteDriver) in androidMain.
  • RoomLibraryConventionPlugin.configureKmp(): androidResources.enable = true is set before deviceTests…sources.assets.addStaticSourceDirectory(schemas) — the described ordering dependency (assets are null until resources are enabled) is respected.
  • KmpLibraryConventionPlugin's assembleDebugAndroidTest/connectedDebugAndroidTest alias tasks depend on the literal AGP-KMP task names (assembleAndroidDeviceTest/connectedAndroidDeviceTest), matching ui_tests.yml's literal invocation.
  • The 15+1 reflective builder-site conversions I sampled (RecoveryActivityDbFreeTest, RepositoryTestEnv, InMemoryDatabaseProvider, AppDatabaseFactory) are correct — Room.inMemoryDatabaseBuilder<AppDatabase>(context) / getApplicationContext<Application>(), no leftover AppDatabase::class.java forms.
  • lint.xml/detekt.yml additions are scoped as claimed: **/build/generated/** path-scoped lint ignores (not issue-wide disables), **/androidDeviceTest/** added consistently everywhere androidHostTest already appears.
  • documentation/architecture.md / testing.md accurately describe the new source-set layout and are consistent with the actual build scripts (core/data/database-test stays a classic Android module depending on the now-KMP core:data:database, testImplementation there is correct).
  • AtomicRollbackDeviceTest (device suite) is a well-constructed rollback oracle — three non-vacuous assertions per shape (intentional-throwable check, in-transaction visibility, post-rollback state), matches the two real production transaction shapes.

Still open from the prior automated review, both non-blocking:

  1. RoomLibraryConventionPlugin.configureKmp() (around lines 95–99) still uses fully-qualified names inline (org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension, org.gradle.api.plugins.ExtensionAware, com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryExtension) instead of top-level imports, inconsistent with the rest of the file. Purely stylistic.
  2. The checkout still has uncommitted local working-tree changes to .claude/skills/add-database-migration.md and .claude/skills/write-repository-test.md that revert the committed KMP paths (commonMain/androidHostTest/androidDeviceTest) back to old src/main/src/androidTest/src/test paths. Confirmed via git diff (no path arg) that these are dirty working-tree changes, not part of any commit in this PR — git diff origin/dev...HEAD already has the correct KMP paths in both files. This looks like a review-environment artifact, not something in the PR itself; no action needed on your end.

No correctness issues found in the KMP conversion itself. The port is well-isolated from the BundledSQLiteDriver flip (#240) as described, and the gate table claims in the PR body check out against the diff.

@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: fea9edd127

ℹ️ 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".

Comment thread build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt Outdated
Comment thread build-logic/convention/src/main/kotlin/RoomLibraryConventionPlugin.kt Outdated
Comment thread lint-rules/lint.xml Outdated
@stslex

stslex commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Review classification before the next push:

  • Codex CI-alias history — correct. The current task-name invariant is load-bearing, but “pre-diagnosed” / “found when the first instrumented module converted” is discovery history. The derivation already exists in documentation/feature-specs/kmp-phase-6-data-layer.md§9 What PR D measured, “The connected-run alias is the FIFTH silent vanish.” I will leave the invariant plus anchor in code.
  • Codex Room asset history — correct. “Measured on the first converted module” is history. The actual missing-schema failure mode remains a useful guard, and the measurement already exists under the same §9 registry, “Room-KMP does not put schemas on the device-test APK.”
  • Codex lint measurement detail — correct. The 1087 RestrictedApi + 6 SyntheticAccessor counts are derivation, already recorded under §9, “Lint does not classify KSP output as generated on KMP.” I will keep only the generated-path conclusion and that stable anchor in lint.xml.

@stslex

stslex commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Additional classification before the same push:

  • Claude fully-qualified type names — correct. The inline fully-qualified KotlinMultiplatformExtension, ExtensionAware, and KotlinMultiplatformAndroidLibraryExtension names are behaviorally harmless but inconsistent with this convention plugin. I will replace them with explicit imports.
  • Claude dirty-worktree note — wrong for the PR branch, and the reviewer already marked it as a review-environment artifact. git status --short is empty in the isolated KMP phase 6D: core:data:database becomes a KMP module, on AndroidSQLiteDriver #239 review worktree, while git diff origin/dev...HEAD contains the corrected KMP skill paths. There is no PR change to make for that item.

@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 53s —— View job


Review

  • Gather context (PR body, prior review comments, changed files)
  • Read diff (git diff origin/dev...HEAD)
  • Check the two P1 review-comment fixups already claimed as resolved in c857cb6d
  • Review convention plugin changes (Kmp/Room) for anything new since last automated pass
  • Post final review feedback

Re-reviewed at HEAD (c857cb6), the fixup commit for Codex's two P1 comments from the prior round.

Both P1 fixes verified correct:

  • RoomLibraryConventionPlugin.kt: the measured on the first converted module history is gone from the asset-wiring comment; the guard now cites kmp-phase-6-data-layer.md → §9 "Room-KMP does not put schemas on the device-test APK." — confirmed that heading exists verbatim at line 504 of the spec. The fully-qualified inline type names (org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension, etc., flagged in the earlier automated reviews) are also now replaced with top-level imports, consistent with the rest of the file.
  • KmpLibraryConventionPlugin.kt: the pre-diagnosed / found when the first instrumented module converted discovery narrative is gone; the comment keeps the literal task-name invariant and now cites §9 "The connected-run alias is the FIFTH silent vanish." — confirmed at line 497 of the spec.
  • lint-rules/lint.xml: the 1087 RestrictedApi + 6 SyntheticAccessor derivation is gone from the config comment, replaced with the conclusion plus a §9 "Lint does not classify KSP output as generated on KMP" anchor (line 524). Also picked up a missing trailing newline as a side effect.

All three anchors resolve to real, matching section headers in documentation/feature-specs/kmp-phase-6-data-layer.md §9 — not broken references.

Other changes in this commit (fea9eddc857cb6, i.e. the 2-line docs trim plus this fixup): only the three files above and the previously-reviewed .claude/skills/*.md path updates. No behavior change, no new files touched.

Housekeeping note: the working tree in this review checkout (git status --short) is clean — no dirty .claude/skills/*.md changes. This confirms stslex's classification in the PR comments: the earlier "dirty worktree" observation from the prior automated review was an artifact of that specific review environment, not something in the PR branch itself.

No correctness issues found. Both outstanding P1 comments from Codex are resolved as claimed, and the fix commit is scoped exactly to what those comments asked for — no unrelated changes riding along.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: c857cb6db2

ℹ️ 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 merged commit e4b9c8a into dev Aug 21, 2026
8 checks passed
stslex added a commit that referenced this pull request Aug 21, 2026
)

**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 #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 #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.com/claude-code)

https://claude.ai/code/session_011kNU4XxzXp6NpCnVUQA6yH
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