diff --git a/engine-kmp-app/build.gradle.kts b/engine-kmp-app/build.gradle.kts
new file mode 100644
index 0000000000..ee9284b4c8
--- /dev/null
+++ b/engine-kmp-app/build.gradle.kts
@@ -0,0 +1,99 @@
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ id("org.jetbrains.kotlin.multiplatform")
+ id("com.android.application")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("org.jetbrains.compose")
+}
+
+android {
+ namespace = "com.example.enginekmpapp"
+ compileSdk = Sdk.COMPILE_SDK
+
+ defaultConfig {
+ applicationId = "com.example.enginekmpapp"
+ minSdk = Sdk.MIN_SDK
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+ }
+ packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
+ buildTypes { getByName("release") { isMinifyEnabled = false } }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+}
+
+kotlin {
+ jvmToolchain(21)
+
+ androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
+
+ jvm("desktop")
+
+ listOf(
+ iosX64(),
+ iosArm64(),
+ iosSimulatorArm64(),
+ )
+ .forEach { iosTarget ->
+ iosTarget.binaries.framework {
+ baseName = "engineKmpAppKit"
+ isStatic = true
+ }
+ }
+
+ targets.configureEach {
+ compilations.configureEach {
+ compilerOptions.configure {
+ freeCompilerArgs.add("-Xexpect-actual-classes")
+ optIn.addAll(
+ "kotlin.time.ExperimentalTime",
+ "kotlin.uuid.ExperimentalUuidApi",
+ )
+ }
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material3)
+ implementation(compose.ui)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlin.fhir)
+ implementation(project(":engine-kmp"))
+ }
+ androidMain.dependencies {
+ implementation(compose.preview)
+ implementation(libs.androidx.activity.compose)
+ }
+ val desktopMain by getting {
+ dependencies {
+ implementation(compose.desktop.currentOs)
+ }
+ }
+ val iosMain by creating {
+ dependsOn(commonMain.get())
+ }
+ val iosX64Main by getting { dependsOn(iosMain) }
+ val iosArm64Main by getting { dependsOn(iosMain) }
+ val iosSimulatorArm64Main by getting { dependsOn(iosMain) }
+ }
+}
+
+compose.desktop {
+ application {
+ mainClass = "com.example.enginekmpapp.MainKt"
+
+ nativeDistributions {
+ targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
+ packageName = "com.example.enginekmpapp"
+ packageVersion = "1.0.0"
+ }
+ }
+}
diff --git a/engine-kmp-app/src/androidMain/AndroidManifest.xml b/engine-kmp-app/src/androidMain/AndroidManifest.xml
new file mode 100644
index 0000000000..e12506faed
--- /dev/null
+++ b/engine-kmp-app/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/engine-kmp-app/src/androidMain/kotlin/com/example/enginekmpapp/EngineKmpApplication.kt b/engine-kmp-app/src/androidMain/kotlin/com/example/enginekmpapp/EngineKmpApplication.kt
new file mode 100644
index 0000000000..ff7b80f748
--- /dev/null
+++ b/engine-kmp-app/src/androidMain/kotlin/com/example/enginekmpapp/EngineKmpApplication.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.enginekmpapp
+
+import android.app.Application
+
+class EngineKmpApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ appContext = this
+ }
+
+ companion object {
+ lateinit var appContext: Application
+ private set
+ }
+}
diff --git a/engine-kmp-app/src/androidMain/kotlin/com/example/enginekmpapp/MainActivity.kt b/engine-kmp-app/src/androidMain/kotlin/com/example/enginekmpapp/MainActivity.kt
new file mode 100644
index 0000000000..880f79ac8f
--- /dev/null
+++ b/engine-kmp-app/src/androidMain/kotlin/com/example/enginekmpapp/MainActivity.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.enginekmpapp
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent { App(platformContext = applicationContext) }
+ }
+}
diff --git a/engine-kmp-app/src/commonMain/kotlin/com/example/enginekmpapp/App.kt b/engine-kmp-app/src/commonMain/kotlin/com/example/enginekmpapp/App.kt
new file mode 100644
index 0000000000..a05ad26a7d
--- /dev/null
+++ b/engine-kmp-app/src/commonMain/kotlin/com/example/enginekmpapp/App.kt
@@ -0,0 +1,320 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.enginekmpapp
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.FhirEngineConfiguration
+import com.google.android.fhir.FhirEngineProvider
+import com.google.android.fhir.get
+import com.google.android.fhir.delete
+import com.google.android.fhir.index.SearchParamDefinition
+import com.google.android.fhir.index.SearchParamType
+import com.google.android.fhir.registerResourceType
+import com.google.android.fhir.search.StringClientParam
+import com.google.android.fhir.search.search
+import com.google.android.fhir.search.count
+import com.google.fhir.model.r4.Patient
+import com.google.fhir.model.r4.HumanName
+import com.google.fhir.model.r4.terminologies.ResourceType
+import kotlinx.coroutines.launch
+
+/** Initializes FhirEngineProvider with resource types and search params for the demo. */
+fun initFhirEngine(platformContext: Any = Unit) {
+ registerResourceType(Patient::class, ResourceType.Patient)
+
+ FhirEngineProvider.init(
+ FhirEngineConfiguration(
+ customSearchParameters =
+ listOf(
+ SearchParamDefinition(
+ name = "name",
+ type = SearchParamType.STRING,
+ path = "Patient.name",
+ ),
+ SearchParamDefinition(
+ name = "family",
+ type = SearchParamType.STRING,
+ path = "Patient.name.family",
+ ),
+ SearchParamDefinition(
+ name = "given",
+ type = SearchParamType.STRING,
+ path = "Patient.name.given",
+ ),
+ ),
+ ),
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun App(platformContext: Any = Unit) {
+ val scope = rememberCoroutineScope()
+ var log by remember { mutableStateOf("Ready. Tap a button to start.\n") }
+ var initialized by remember { mutableStateOf(false) }
+
+ fun appendLog(msg: String) {
+ log += "$msg\n"
+ }
+
+ fun getEngine(): FhirEngine {
+ if (!initialized) {
+ initFhirEngine(platformContext)
+ initialized = true
+ }
+ return FhirEngineProvider.getInstance(platformContext)
+ }
+
+ MaterialTheme {
+ Scaffold(
+ topBar = {
+ TopAppBar(title = { Text("Engine KMP Demo") })
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
+ ) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Button(onClick = {
+ scope.launch {
+ try {
+ val engine = getEngine()
+ val patient =
+ Patient(
+ id = "patient-1",
+ name = listOf(
+ HumanName(
+ family = com.google.fhir.model.r4.String(value = "Doe"),
+ given = listOf(com.google.fhir.model.r4.String(value = "John")),
+ ),
+ ),
+ )
+ val ids = engine.create(patient)
+ appendLog("[Create] Patient created with id: ${ids.first()}")
+ } catch (e: Exception) {
+ appendLog("[Create] ERROR: ${e.message}")
+ }
+ }
+ }) { Text("Create Patient") }
+
+ Button(onClick = {
+ scope.launch {
+ try {
+ val engine = getEngine()
+ val patient = engine.get("patient-1")
+ val name = patient.name?.firstOrNull()
+ val given = name?.given?.firstOrNull()?.value ?: "?"
+ val family = name?.family?.value ?: "?"
+ appendLog("[Get] Patient: $given $family (id=${patient.id})")
+ } catch (e: Exception) {
+ appendLog("[Get] ERROR: ${e.message}")
+ }
+ }
+ }) { Text("Get Patient") }
+
+ Button(onClick = {
+ scope.launch {
+ try {
+ val engine = getEngine()
+ val results = engine.search {
+ filter(StringClientParam("name"), { value = "Doe" })
+ }
+ appendLog("[Search] Found ${results.size} patient(s)")
+ results.forEach { result ->
+ val name = result.resource.name?.firstOrNull()
+ val given = name?.given?.firstOrNull()?.value ?: "?"
+ val family = name?.family?.value ?: "?"
+ appendLog(" - $given $family (id=${result.resource.id})")
+ }
+ } catch (e: Exception) {
+ appendLog("[Search] ERROR: ${e.message}")
+ }
+ }
+ }) { Text("Search") }
+
+ Button(onClick = {
+ scope.launch {
+ try {
+ val engine = getEngine()
+ val patient = engine.get("patient-1")
+ val updated =
+ patient.copy(
+ name = listOf(
+ HumanName(
+ family = com.google.fhir.model.r4.String(value = "Smith"),
+ given = listOf(com.google.fhir.model.r4.String(value = "Jane")),
+ ),
+ ),
+ )
+ engine.update(updated)
+ appendLog("[Update] Patient updated to Jane Smith")
+ } catch (e: Exception) {
+ appendLog("[Update] ERROR: ${e.message}")
+ }
+ }
+ }) { Text("Update") }
+
+ Button(onClick = {
+ scope.launch {
+ try {
+ val engine = getEngine()
+ val c = engine.count {}
+ appendLog("[Count] Total patients: $c")
+ } catch (e: Exception) {
+ appendLog("[Count] ERROR: ${e.message}")
+ }
+ }
+ }) { Text("Count") }
+
+ Button(onClick = {
+ scope.launch {
+ try {
+ val engine = getEngine()
+ engine.delete("patient-1")
+ appendLog("[Delete] Patient patient-1 deleted")
+ } catch (e: Exception) {
+ appendLog("[Delete] ERROR: ${e.message}")
+ }
+ }
+ }) { Text("Delete") }
+
+ Button(onClick = {
+ scope.launch {
+ try {
+ val engine = getEngine()
+ engine.clearDatabase()
+ appendLog("[Clear] Database cleared")
+ } catch (e: Exception) {
+ appendLog("[Clear] ERROR: ${e.message}")
+ }
+ }
+ }) { Text("Clear DB") }
+
+ Button(onClick = {
+ scope.launch {
+ try {
+ val engine = getEngine()
+ // Full flow: create 3 patients, search, count, update, search again, delete, count
+ appendLog("--- Full Flow Demo ---")
+
+ engine.clearDatabase()
+ appendLog("1. Cleared database")
+
+ val p1 = Patient(
+ id = "demo-1",
+ name = listOf(HumanName(
+ family = com.google.fhir.model.r4.String(value = "Garcia"),
+ given = listOf(com.google.fhir.model.r4.String(value = "Maria")),
+ )),
+ )
+ val p2 = Patient(
+ id = "demo-2",
+ name = listOf(HumanName(
+ family = com.google.fhir.model.r4.String(value = "Garcia"),
+ given = listOf(com.google.fhir.model.r4.String(value = "Carlos")),
+ )),
+ )
+ val p3 = Patient(
+ id = "demo-3",
+ name = listOf(HumanName(
+ family = com.google.fhir.model.r4.String(value = "Chen"),
+ given = listOf(com.google.fhir.model.r4.String(value = "Wei")),
+ )),
+ )
+ engine.create(p1, p2, p3)
+ appendLog("2. Created 3 patients")
+
+ val count1 = engine.count {}
+ appendLog("3. Count: $count1")
+
+ val garcias = engine.search {
+ filter(StringClientParam("family"), { value = "Garcia" })
+ }
+ appendLog("4. Search 'Garcia': found ${garcias.size}")
+
+ val maria = engine.get("demo-1")
+ engine.update(
+ maria.copy(
+ name = listOf(HumanName(
+ family = com.google.fhir.model.r4.String(value = "Garcia-Lopez"),
+ given = listOf(com.google.fhir.model.r4.String(value = "Maria")),
+ )),
+ ),
+ )
+ appendLog("5. Updated demo-1 family to Garcia-Lopez")
+
+ val updated = engine.get("demo-1")
+ appendLog("6. Verified: ${updated.name?.first()?.family?.value}")
+
+ engine.delete("demo-2")
+ appendLog("7. Deleted demo-2")
+
+ val count2 = engine.count {}
+ appendLog("8. Final count: $count2")
+
+ appendLog("--- Flow Complete ---")
+ } catch (e: Exception) {
+ appendLog("[Flow] ERROR: ${e.message}")
+ }
+ }
+ }) { Text("Full Flow") }
+ }
+
+ Text(
+ text = log,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .padding(top = 16.dp)
+ .verticalScroll(rememberScrollState()),
+ fontFamily = FontFamily.Monospace,
+ fontSize = 13.sp,
+ )
+ }
+ }
+ }
+}
diff --git a/engine-kmp-app/src/desktopMain/kotlin/com/example/enginekmpapp/Main.kt b/engine-kmp-app/src/desktopMain/kotlin/com/example/enginekmpapp/Main.kt
new file mode 100644
index 0000000000..9b6be817fb
--- /dev/null
+++ b/engine-kmp-app/src/desktopMain/kotlin/com/example/enginekmpapp/Main.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.enginekmpapp
+
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.WindowPlacement
+import androidx.compose.ui.window.WindowState
+import androidx.compose.ui.window.application
+
+fun main() = application {
+ Window(
+ onCloseRequest = ::exitApplication,
+ title = "Engine KMP Demo",
+ state = WindowState(placement = WindowPlacement.Maximized),
+ ) {
+ App()
+ }
+}
diff --git a/engine-kmp-app/src/iosMain/kotlin/com/example/enginekmpapp/MainViewController.kt b/engine-kmp-app/src/iosMain/kotlin/com/example/enginekmpapp/MainViewController.kt
new file mode 100644
index 0000000000..be9e6456f2
--- /dev/null
+++ b/engine-kmp-app/src/iosMain/kotlin/com/example/enginekmpapp/MainViewController.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.enginekmpapp
+
+import androidx.compose.ui.window.ComposeUIViewController
+
+fun MainViewController() = ComposeUIViewController { App() }
diff --git a/engine-kmp/build.gradle.kts b/engine-kmp/build.gradle.kts
new file mode 100644
index 0000000000..550c35113c
--- /dev/null
+++ b/engine-kmp/build.gradle.kts
@@ -0,0 +1,102 @@
+plugins {
+ id("org.jetbrains.kotlin.multiplatform")
+ id("com.android.kotlin.multiplatform.library")
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+kotlin {
+ jvmToolchain(21)
+
+ androidLibrary {
+ namespace = "com.google.android.fhir.engine"
+ compileSdk = Sdk.COMPILE_SDK
+ minSdk = Sdk.MIN_SDK
+ withHostTestBuilder {}
+ }
+
+ jvm("desktop")
+
+ iosX64()
+ iosArm64()
+ iosSimulatorArm64()
+
+ targets.configureEach {
+ compilations.configureEach {
+ compilerOptions.configure {
+ freeCompilerArgs.add("-Xexpect-actual-classes")
+ optIn.addAll(
+ "kotlin.time.ExperimentalTime",
+ "kotlin.uuid.ExperimentalUuidApi",
+ )
+ }
+ }
+ }
+
+ sourceSets {
+ commonMain {
+ dependencies {
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.datetime)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.kotlin.fhir)
+ implementation(libs.fhir.path)
+ implementation(libs.kermit)
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.sqlite.bundled)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.client.logging)
+ implementation(libs.ktor.client.encoding)
+ implementation(libs.ktor.client.auth)
+ implementation(libs.ktor.serialization.kotlinx.json)
+ }
+ }
+ commonTest {
+ dependencies {
+ implementation(libs.kotlin.test)
+ implementation(libs.kotlinx.coroutines.test)
+ }
+ }
+ val androidMain by getting {
+ dependencies {
+ implementation(libs.androidx.work.runtime)
+ implementation(libs.androidx.lifecycle.livedata)
+ implementation(libs.ktor.client.okhttp)
+ }
+ }
+ val desktopMain by getting {
+ dependencies {
+ implementation(libs.ktor.client.java)
+ }
+ }
+ iosMain {
+ dependencies {
+ implementation(libs.ktor.client.darwin)
+ }
+ }
+ getByName("androidHostTest") {
+ dependencies {
+ implementation(libs.junit)
+ implementation(libs.robolectric)
+ implementation(libs.androidx.test.core)
+ implementation(libs.androidx.work.testing)
+ implementation(libs.kotlin.test.junit)
+ implementation(libs.kotlinx.coroutines.test)
+ }
+ }
+ }
+}
+
+dependencies {
+ listOf(
+ "kspAndroid",
+ "kspDesktop",
+ "kspIosX64",
+ "kspIosArm64",
+ "kspIosSimulatorArm64",
+ ).forEach {
+ add(it, libs.androidx.room.compiler)
+ }
+}
diff --git a/engine-kmp/src/androidHostTest/kotlin/com/google/android/fhir/sync/AndroidSyncSchedulerTest.kt b/engine-kmp/src/androidHostTest/kotlin/com/google/android/fhir/sync/AndroidSyncSchedulerTest.kt
new file mode 100644
index 0000000000..a0f00eca9a
--- /dev/null
+++ b/engine-kmp/src/androidHostTest/kotlin/com/google/android/fhir/sync/AndroidSyncSchedulerTest.kt
@@ -0,0 +1,368 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.work.Configuration
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import androidx.work.testing.WorkManagerTestInitHelper
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.sync.upload.HttpCreateMethod
+import com.google.android.fhir.sync.upload.HttpUpdateMethod
+import com.google.android.fhir.sync.upload.UploadStrategy
+import android.os.Build
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Tests the AndroidSyncScheduler integration with WorkManager to verify that:
+ * - One-time sync jobs are correctly enqueued and can be cancelled
+ * - Periodic sync jobs are correctly enqueued and can be cancelled
+ * - Unique work name management (store/fetch/remove) in DataStore works correctly
+ * - Sync constraints and retry configuration are correctly applied
+ * - State flows emit correct initial states after enqueue
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.P])
+internal class AndroidSyncSchedulerTest {
+
+ private lateinit var context: Context
+ private lateinit var dataStore: FhirDataStore
+ private lateinit var scheduler: AndroidSyncScheduler
+
+ class TestSyncWorker(appContext: Context, workerParams: WorkerParameters) :
+ FhirSyncWorker(appContext, workerParams) {
+
+ override fun getFhirEngine(): FhirEngine = TestFhirEngineImpl
+
+ override fun getDataSource(): DataSource = TestDataSourceImpl
+
+ override fun getDownloadWorkManager(): DownloadWorkManager = TestDownloadManagerImpl()
+
+ override fun getConflictResolver() = AcceptRemoteConflictResolver
+
+ override fun getUploadStrategy(): UploadStrategy =
+ UploadStrategy.forBundleRequest(
+ methodForCreate = HttpCreateMethod.PUT,
+ methodForUpdate = HttpUpdateMethod.PATCH,
+ squash = true,
+ bundleSize = 500,
+ )
+
+ override fun getFhirDataStore(): FhirDataStore =
+ FhirDataStore(createDataStore(applicationContext))
+ }
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ val config = Configuration.Builder().setMinimumLoggingLevel(android.util.Log.DEBUG).build()
+ WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
+ dataStore = FhirDataStore(createDataStore(context))
+ scheduler = AndroidSyncScheduler(context, TestSyncWorker::class.java, dataStore)
+ }
+
+ @Test
+ fun createSyncUniqueName_oneTimeSync_shouldContainWorkerClassNameAndSyncType() {
+ val name = scheduler.createSyncUniqueName("oneTimeSync")
+ assertTrue(name.contains("TestSyncWorker"))
+ assertTrue(name.contains("oneTimeSync"))
+ assertEquals("${TestSyncWorker::class.java.name}-oneTimeSync", name)
+ }
+
+ @Test
+ fun createSyncUniqueName_periodicSync_shouldContainWorkerClassNameAndSyncType() {
+ val name = scheduler.createSyncUniqueName("periodicSync")
+ assertTrue(name.contains("TestSyncWorker"))
+ assertTrue(name.contains("periodicSync"))
+ assertEquals("${TestSyncWorker::class.java.name}-periodicSync", name)
+ }
+
+ @Test
+ fun storeUniqueWorkNameInDataStore_shouldPersistWorkName() {
+ runBlocking {
+ val workName = scheduler.createSyncUniqueName("oneTimeSync")
+ scheduler.storeUniqueWorkNameInDataStore(workName)
+ val fetched = dataStore.fetchUniqueWorkName(workName)
+ assertNotNull(fetched)
+ assertEquals(workName, fetched)
+ }
+ }
+
+ @Test
+ fun storeUniqueWorkNameInDataStore_calledTwice_shouldNotOverwrite() {
+ runBlocking {
+ val workName = scheduler.createSyncUniqueName("oneTimeSync")
+ scheduler.storeUniqueWorkNameInDataStore(workName)
+ // Call again — should be idempotent (only stores if null)
+ scheduler.storeUniqueWorkNameInDataStore("wrong-name")
+ val fetched = dataStore.fetchUniqueWorkName(workName)
+ assertNotNull(fetched)
+ assertEquals(workName, fetched)
+ }
+ }
+
+ @Test
+ fun removeUniqueWorkNameInDataStore_shouldRemoveStoredWorkName() {
+ runBlocking {
+ val workName = scheduler.createSyncUniqueName("oneTimeSync")
+ scheduler.storeUniqueWorkNameInDataStore(workName)
+ assertNotNull(dataStore.fetchUniqueWorkName(workName))
+
+ scheduler.removeUniqueWorkNameInDataStore(workName)
+ assertNull(dataStore.fetchUniqueWorkName(workName))
+ }
+ }
+
+ @Test
+ fun removeUniqueWorkNameInDataStore_whenNotStored_shouldNotThrow() {
+ runBlocking {
+ val workName = scheduler.createSyncUniqueName("oneTimeSync")
+ // Should not throw even when nothing is stored
+ scheduler.removeUniqueWorkNameInDataStore(workName)
+ assertNull(dataStore.fetchUniqueWorkName(workName))
+ }
+ }
+
+ @Test
+ fun runOneTimeSync_shouldRunWorkInWorkManager() {
+ runBlocking {
+ val retryConfig = RetryConfiguration(
+ backoffCriteria = BackoffCriteria(BackoffPolicy.LINEAR, 30.seconds),
+ maxRetries = 3,
+ )
+ scheduler.runOneTimeSync(retryConfig)
+
+ // Recreate the same unique work name to check if any work is registered under this name
+ val uniqueWorkName = scheduler.createSyncUniqueName("oneTimeSync")
+ val workInfos = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(uniqueWorkName)
+ .get()
+
+ // Work is registered
+ assertTrue(workInfos.isNotEmpty())
+
+ // Work should be enqueued or running
+ val workInfo = workInfos.first()
+ assertEquals(workInfo.state, WorkInfo.State.RUNNING)
+ }
+ }
+
+ @Test
+ fun runOneTimeSync_shouldStoreWorkNameInDataStore() {
+ runBlocking {
+ val retryConfig = RetryConfiguration(
+ backoffCriteria = BackoffCriteria(BackoffPolicy.LINEAR, 30.seconds),
+ maxRetries = 3,
+ )
+ scheduler.runOneTimeSync(retryConfig)
+
+ val uniqueWorkName = scheduler.createSyncUniqueName("oneTimeSync")
+ val storedName = dataStore.fetchUniqueWorkName(uniqueWorkName)
+ assertNotNull(storedName)
+ assertEquals(uniqueWorkName, storedName)
+ }
+ }
+
+ @Test
+ fun schedulePeriodicSync_shouldEnqueuePeriodicWorkInWorkManager() {
+ runBlocking {
+ val config = PeriodicSyncConfiguration(
+ syncConstraints = SyncConstraints(requiredNetworkType = NetworkType.CONNECTED),
+ repeat = RepeatInterval(interval = 15.minutes),
+ retryConfiguration = RetryConfiguration(
+ backoffCriteria = BackoffCriteria(BackoffPolicy.LINEAR, 30.seconds),
+ maxRetries = 3,
+ ),
+ )
+ scheduler.schedulePeriodicSync(config)
+
+ val uniqueWorkName = scheduler.createSyncUniqueName("periodicSync")
+ val workInfos = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(uniqueWorkName)
+ .get()
+
+ assertTrue(workInfos.isNotEmpty())
+ val workInfo = workInfos.first()
+ assertEquals(workInfo.state, WorkInfo.State.ENQUEUED)
+ }
+ }
+
+ @Test
+ fun schedulePeriodicSync_shouldStoreWorkNameInDataStore() {
+ runBlocking {
+ val config = PeriodicSyncConfiguration(
+ repeat = RepeatInterval(interval = 15.minutes),
+ retryConfiguration = RetryConfiguration(
+ backoffCriteria = BackoffCriteria(BackoffPolicy.LINEAR, 30.seconds),
+ maxRetries = 3,
+ ),
+ )
+ scheduler.schedulePeriodicSync(config)
+
+ val uniqueWorkName = scheduler.createSyncUniqueName("periodicSync")
+ val storedName = dataStore.fetchUniqueWorkName(uniqueWorkName)
+ assertNotNull(storedName)
+ assertEquals(uniqueWorkName, storedName)
+ }
+ }
+
+ @Test
+ fun cancelOneTimeSync_afterEnqueue_shouldCancelWork() {
+ runBlocking {
+ val retryConfig = RetryConfiguration(
+ backoffCriteria = BackoffCriteria(BackoffPolicy.LINEAR, 30.seconds),
+ maxRetries = 3,
+ )
+ scheduler.runOneTimeSync(retryConfig)
+
+ val uniqueWorkName = scheduler.createSyncUniqueName("oneTimeSync")
+ // Verify work is enqueued
+ val workInfosBefore = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(uniqueWorkName)
+ .get()
+ assertTrue(workInfosBefore.isNotEmpty())
+
+ // Cancel the sync
+ scheduler.cancelOneTimeSync()
+
+ // Verify work is cancelled
+ val workInfosAfter = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(uniqueWorkName)
+ .get()
+ assertEquals(workInfosAfter.first().state, WorkInfo.State.CANCELLED)
+ }
+ }
+
+ @Test
+ fun cancelPeriodicSync_afterSchedule_shouldCancelWork() {
+ runBlocking {
+ val config = PeriodicSyncConfiguration(
+ repeat = RepeatInterval(interval = 15.minutes),
+ retryConfiguration = defaultRetryConfiguration,
+ )
+ scheduler.schedulePeriodicSync(config)
+
+ val uniqueWorkName = scheduler.createSyncUniqueName("periodicSync")
+ // Verify work is enqueued
+ val workInfosBefore = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(uniqueWorkName)
+ .get()
+ assertTrue(workInfosBefore.isNotEmpty())
+
+ // Cancel the sync
+ scheduler.cancelPeriodicSync()
+
+ // Verify work is cancelled
+ val workInfosAfter = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(uniqueWorkName)
+ .get()
+ assertEquals(workInfosAfter.first().state, WorkInfo.State.CANCELLED)
+ }
+ }
+
+ @Test
+ fun cancelOneTimeSync_whenNothingEnqueued_shouldNotThrow() {
+ runBlocking {
+ // Should not throw even when there's no work enqueued
+ scheduler.cancelOneTimeSync()
+ }
+ }
+
+ @Test
+ fun cancelPeriodicSync_whenNothingScheduled_shouldNotThrow() {
+ runBlocking {
+ // Should not throw even when there's no work scheduled
+ scheduler.cancelPeriodicSync()
+ }
+ }
+
+ @Test
+ fun runOneTimeSync_withNullRetryConfiguration_shouldEnqueueWork() {
+ runBlocking {
+ scheduler.runOneTimeSync(retryConfiguration = null)
+
+ val uniqueWorkName = scheduler.createSyncUniqueName("oneTimeSync")
+ val workInfos = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(uniqueWorkName)
+ .get()
+
+ assertTrue(workInfos.isNotEmpty())
+ }
+ }
+
+ @Test
+ fun schedulePeriodicSync_withCustomConstraints_shouldEnqueueWork() {
+ runBlocking {
+ val config = PeriodicSyncConfiguration(
+ syncConstraints = SyncConstraints(
+ requiredNetworkType = NetworkType.UNMETERED,
+ requiresBatteryNotLow = true,
+ requiresCharging = true,
+ requiresStorageNotLow = true,
+ ),
+ repeat = RepeatInterval(interval = 30.minutes),
+ retryConfiguration = RetryConfiguration(
+ backoffCriteria = BackoffCriteria(BackoffPolicy.EXPONENTIAL, 60.seconds),
+ maxRetries = 5,
+ ),
+ )
+ scheduler.schedulePeriodicSync(config)
+
+ val uniqueWorkName = scheduler.createSyncUniqueName("periodicSync")
+ val workInfos = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(uniqueWorkName)
+ .get()
+
+ assertTrue(workInfos.isNotEmpty())
+ }
+ }
+
+ @Test
+ fun runOneTimeSync_withExistingWorkPolicy_shouldKeepExisting() {
+ runBlocking {
+ val retryConfig = RetryConfiguration(
+ backoffCriteria = BackoffCriteria(BackoffPolicy.LINEAR, 30.seconds),
+ maxRetries = 3,
+ )
+ // Enqueue twice — ExistingWorkPolicy.KEEP should keep the first one
+ scheduler.runOneTimeSync(retryConfig)
+ scheduler.runOneTimeSync(retryConfig)
+
+ val uniqueWorkName = scheduler.createSyncUniqueName("oneTimeSync")
+ val workInfos = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(uniqueWorkName)
+ .get()
+
+ // Should still have exactly one work item (KEEP policy)
+ assertEquals(1, workInfos.size)
+ }
+ }
+}
diff --git a/engine-kmp/src/androidHostTest/kotlin/com/google/android/fhir/sync/FhirSyncWorkerTest.kt b/engine-kmp/src/androidHostTest/kotlin/com/google/android/fhir/sync/FhirSyncWorkerTest.kt
new file mode 100644
index 0000000000..d7780d4cbd
--- /dev/null
+++ b/engine-kmp/src/androidHostTest/kotlin/com/google/android/fhir/sync/FhirSyncWorkerTest.kt
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.work.Data
+import androidx.work.ListenableWorker
+import androidx.work.WorkerParameters
+import androidx.work.testing.TestListenableWorkerBuilder
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.sync.upload.HttpCreateMethod
+import com.google.android.fhir.sync.upload.HttpUpdateMethod
+import com.google.android.fhir.sync.upload.UploadStrategy
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Adapted from engine/src/test/java/com/google/android/fhir/sync/FhirSyncWorkerTest.kt
+ *
+ * Tests the FhirSyncWorker integration with WorkManager to verify that sync operations
+ * correctly return success, failure, or retry results based on the configuration.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+internal class FhirSyncWorkerTest {
+ private lateinit var context: Context
+
+ class PassingPeriodicSyncWorker(appContext: Context, workerParams: WorkerParameters) :
+ FhirSyncWorker(appContext, workerParams) {
+
+ override fun getFhirEngine(): FhirEngine = TestFhirEngineImpl
+
+ override fun getDataSource(): DataSource = TestDataSourceImpl
+
+ override fun getDownloadWorkManager(): DownloadWorkManager = TestDownloadManagerImpl()
+
+ override fun getConflictResolver() = AcceptRemoteConflictResolver
+
+ override fun getUploadStrategy(): UploadStrategy =
+ UploadStrategy.forBundleRequest(
+ methodForCreate = HttpCreateMethod.PUT,
+ methodForUpdate = HttpUpdateMethod.PATCH,
+ squash = true,
+ bundleSize = 500,
+ )
+
+ override fun getFhirDataStore(): FhirDataStore =
+ FhirDataStore(createDataStore(applicationContext))
+ }
+
+ class FailingPeriodicSyncWorker(appContext: Context, workerParams: WorkerParameters) :
+ FhirSyncWorker(appContext, workerParams) {
+
+ override fun getFhirEngine(): FhirEngine = TestFhirEngineImpl
+
+ override fun getDataSource(): DataSource = TestFailingDatasource
+
+ override fun getDownloadWorkManager(): DownloadWorkManager = TestDownloadManagerImpl()
+
+ override fun getConflictResolver() = AcceptRemoteConflictResolver
+
+ override fun getUploadStrategy(): UploadStrategy =
+ UploadStrategy.forBundleRequest(
+ methodForCreate = HttpCreateMethod.PUT,
+ methodForUpdate = HttpUpdateMethod.PATCH,
+ squash = true,
+ bundleSize = 500,
+ )
+
+ override fun getFhirDataStore(): FhirDataStore =
+ FhirDataStore(createDataStore(applicationContext))
+ }
+
+ class FailingPeriodicSyncWorkerWithoutDataSource(
+ appContext: Context,
+ workerParams: WorkerParameters,
+ ) : FhirSyncWorker(appContext, workerParams) {
+
+ override fun getFhirEngine(): FhirEngine = TestFhirEngineImpl
+
+ override fun getDownloadWorkManager() = TestDownloadManagerImpl()
+
+ override fun getDataSource(): DataSource? = null
+
+ override fun getConflictResolver() = AcceptRemoteConflictResolver
+
+ override fun getUploadStrategy(): UploadStrategy =
+ UploadStrategy.forBundleRequest(
+ methodForCreate = HttpCreateMethod.PUT,
+ methodForUpdate = HttpUpdateMethod.PATCH,
+ squash = true,
+ bundleSize = 500,
+ )
+
+ override fun getFhirDataStore(): FhirDataStore =
+ FhirDataStore(createDataStore(applicationContext))
+ }
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ }
+
+ @Test
+ fun fhirSyncWorker_successfulTask_resultSuccess() {
+ val worker =
+ TestListenableWorkerBuilder(
+ context,
+ inputData = Data.Builder().putInt(MAX_RETRIES_ALLOWED, 1).build(),
+ runAttemptCount = 0,
+ )
+ .build()
+ val result = runBlocking { worker.doWork() }
+ assertIs(result)
+ assertEquals(ListenableWorker.Result.success()::class, result::class)
+ }
+
+ @Test
+ fun fhirSyncWorker_failedTaskWithZeroRetries_resultShouldBeFail() {
+ val worker =
+ TestListenableWorkerBuilder(
+ context,
+ inputData = Data.Builder().putInt(MAX_RETRIES_ALLOWED, 0).build(),
+ runAttemptCount = 0,
+ )
+ .build()
+ val result = runBlocking { worker.doWork() }
+ assertIs(result)
+ assertEquals(ListenableWorker.Result.failure()::class, result::class)
+ }
+
+ @Test
+ fun fhirSyncWorker_failedTaskWithCurrentRunAttemptSameAsTheRetries_resultShouldBeFail() {
+ val worker =
+ TestListenableWorkerBuilder(
+ context,
+ inputData = Data.Builder().putInt(MAX_RETRIES_ALLOWED, 2).build(),
+ runAttemptCount = 2,
+ )
+ .build()
+ val result = runBlocking { worker.doWork() }
+ assertIs(result)
+ assertEquals(ListenableWorker.Result.failure()::class, result::class)
+ }
+
+ @Test
+ fun fhirSyncWorker_failedTaskWithCurrentRunAttemptSmallerThanTheRetries_resultShouldBeRetry() {
+ val worker =
+ TestListenableWorkerBuilder(
+ context,
+ inputData = Data.Builder().putInt(MAX_RETRIES_ALLOWED, 1).build(),
+ runAttemptCount = 0,
+ )
+ .build()
+ val result = runBlocking { worker.doWork() }
+ assertEquals(ListenableWorker.Result.retry(), result)
+ }
+
+ @Test
+ fun fhirSyncWorker_nullDataSource_resultShouldBeFail() {
+ val worker =
+ TestListenableWorkerBuilder(
+ context,
+ inputData = Data.Builder().putInt(MAX_RETRIES_ALLOWED, 1).build(),
+ runAttemptCount = 2,
+ )
+ .build()
+ val result = runBlocking { worker.doWork() }
+ assertEquals(ListenableWorker.Result.failure()::class, result::class)
+ assertIs(result)
+ val outputData = (result as ListenableWorker.Result.Failure).outputData
+ assertNotNull(outputData)
+ assertTrue(outputData.keyValueMap.containsKey("error"))
+ assertEquals("java.lang.IllegalStateException", outputData.getString("error"))
+ }
+}
diff --git a/engine-kmp/src/androidHostTest/kotlin/com/google/android/fhir/sync/TestFakes.kt b/engine-kmp/src/androidHostTest/kotlin/com/google/android/fhir/sync/TestFakes.kt
new file mode 100644
index 0000000000..7a5649c40b
--- /dev/null
+++ b/engine-kmp/src/androidHostTest/kotlin/com/google/android/fhir/sync/TestFakes.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.LocalChange
+import com.google.android.fhir.LocalChangeToken
+import com.google.android.fhir.SearchResult
+import com.google.android.fhir.db.LocalChangeResourceReference
+import com.google.android.fhir.search.Search
+import com.google.android.fhir.sync.download.DownloadRequest
+import com.google.android.fhir.sync.download.UrlDownloadRequest
+import com.google.android.fhir.sync.upload.SyncUploadProgress
+import com.google.android.fhir.sync.upload.UploadRequestResult
+import com.google.android.fhir.sync.upload.UploadStrategy
+import com.google.fhir.model.r4.Bundle
+import com.google.fhir.model.r4.Enumeration
+import com.google.fhir.model.r4.Meta
+import com.google.fhir.model.r4.Patient
+import com.google.fhir.model.r4.Resource
+import com.google.fhir.model.r4.terminologies.ResourceType
+import com.google.fhir.model.r4.FhirDateTime
+import kotlin.time.Clock
+import kotlin.time.Instant
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.flow
+
+internal object TestDataSourceImpl : DataSource {
+
+ override suspend fun download(downloadRequest: DownloadRequest): Resource =
+ when (downloadRequest) {
+ is UrlDownloadRequest ->
+ Bundle(type = Enumeration(value = Bundle.BundleType.Searchset))
+ else ->
+ Bundle(type = Enumeration(value = Bundle.BundleType.Batch_Response))
+ }
+
+ override suspend fun upload(request: com.google.android.fhir.sync.upload.request.UploadRequest): Resource {
+ return Bundle(
+ type = Enumeration(value = Bundle.BundleType.Transaction_Response),
+ entry = listOf(Bundle.Entry(resource = Patient(id = "123"))),
+ )
+ }
+}
+
+internal open class TestDownloadManagerImpl(
+ private val queries: List = listOf("Patient?address-city=NAIROBI"),
+) : DownloadWorkManager {
+ private val urls = ArrayDeque(queries)
+
+ override suspend fun getNextRequest(): DownloadRequest? =
+ urls.removeFirstOrNull()?.let { DownloadRequest.of(it) }
+
+ override suspend fun getSummaryRequestUrls(): Map =
+ queries
+ .map { ResourceType.valueOf(it.substringBefore("?")) to it.plus("?_summary=count") }
+ .toMap()
+
+ override suspend fun processResponse(response: Resource): Collection {
+ val patient = Patient(
+ id = "test-patient",
+ meta = Meta(lastUpdated = com.google.fhir.model.r4.Instant(value = FhirDateTime.fromString(Clock.System.now().toString()))),
+ )
+ return listOf(patient)
+ }
+}
+
+internal object TestFhirEngineImpl : FhirEngine {
+ override suspend fun create(vararg resource: Resource) = emptyList()
+
+ override suspend fun update(vararg resource: Resource) {}
+
+ override suspend fun get(type: ResourceType, id: String): Resource {
+ return Patient()
+ }
+
+ override suspend fun delete(type: ResourceType, id: String) {}
+
+ override suspend fun search(search: Search): List> {
+ return emptyList()
+ }
+
+ override suspend fun syncUpload(
+ uploadStrategy: UploadStrategy,
+ upload:
+ (suspend (List, List) -> Flow),
+ ): Flow = flow {
+ emit(SyncUploadProgress(1, 1))
+ upload(getLocalChanges(ResourceType.Patient, "123"), emptyList()).collect {
+ when (it) {
+ is UploadRequestResult.Success -> emit(SyncUploadProgress(0, 1))
+ is UploadRequestResult.Failure -> emit(SyncUploadProgress(1, 1, it.uploadError))
+ }
+ }
+ }
+
+ override suspend fun syncDownload(
+ conflictResolver: ConflictResolver,
+ download: suspend () -> Flow>,
+ ) {
+ download().collect()
+ }
+
+ override suspend fun withTransaction(block: suspend FhirEngine.() -> Unit) {}
+
+ override suspend fun count(search: Search): Long {
+ return 0
+ }
+
+ override suspend fun getLastSyncTimeStamp(): Instant? {
+ return Clock.System.now()
+ }
+
+ override suspend fun clearDatabase() {}
+
+ override suspend fun getLocalChanges(type: ResourceType, id: String): List {
+ return listOf(
+ LocalChange(
+ resourceType = type.name,
+ resourceId = id,
+ payload = """{ "resourceType" : "$type", "id" : "$id" }""",
+ token = LocalChangeToken(listOf(1)),
+ type = LocalChange.Type.INSERT,
+ timestamp = Clock.System.now(),
+ ),
+ )
+ }
+
+ override suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean) {}
+
+ override suspend fun purge(type: ResourceType, ids: Set, forcePurge: Boolean) {}
+}
+
+internal object TestFailingDatasource : DataSource {
+
+ override suspend fun download(downloadRequest: DownloadRequest): Resource =
+ when (downloadRequest) {
+ is UrlDownloadRequest -> {
+ throw Exception("Download failed with a large error message for testing purposes")
+ }
+ else -> throw Exception("Posting Download Bundle failed...")
+ }
+
+ override suspend fun upload(request: com.google.android.fhir.sync.upload.request.UploadRequest): Resource {
+ throw Exception("Posting Upload Bundle failed...")
+ }
+}
diff --git a/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/db/impl/DatabaseBuilder.kt b/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/db/impl/DatabaseBuilder.kt
new file mode 100644
index 0000000000..8c80021951
--- /dev/null
+++ b/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/db/impl/DatabaseBuilder.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl
+
+import android.content.Context
+import androidx.room.Room
+import androidx.room.RoomDatabase
+
+internal actual fun getDatabaseBuilder(
+ platformContext: Any,
+): RoomDatabase.Builder {
+ val context = platformContext as Context
+ val dbFile = context.getDatabasePath(DATABASE_NAME)
+ return Room.databaseBuilder(context, dbFile.absolutePath)
+}
+
+private const val DATABASE_NAME = "resources.db"
diff --git a/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/AndroidSyncScheduler.kt b/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/AndroidSyncScheduler.kt
new file mode 100644
index 0000000000..e0b0f5c459
--- /dev/null
+++ b/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/AndroidSyncScheduler.kt
@@ -0,0 +1,383 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import android.content.Context
+import androidx.lifecycle.asFlow
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.Data
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.PeriodicWorkRequest
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.hasKeyWithValueOfType
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapConcat
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.serialization.json.Json
+
+/**
+ * Android implementation of [SyncScheduler] that uses [WorkManager] to schedule sync jobs.
+ *
+ * @param context The application context.
+ * @param workerClass The class of the [FhirSyncWorker] to be used for sync jobs.
+ * @param dataStore The [FhirDataStore] instance for persisting sync state and metadata.
+ */
+@PublishedApi
+internal class AndroidSyncScheduler(
+ private val context: Context,
+ private val workerClass: Class,
+ private val dataStore: FhirDataStore,
+) : SyncScheduler {
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ /**
+ * Starts a one time sync job based on [FhirSyncWorker].
+ *
+ * Use the returned [Flow] to get updates of the sync job. Alternatively, use [getWorkerInfo] with
+ * the same 'workName' to retrieve the status of the job.
+ *
+ * @param retryConfiguration configuration to guide the retry mechanism, or `null` to stop retry.
+ * @return a [Flow] of [CurrentSyncJobStatus]
+ */
+ override suspend fun runOneTimeSync(
+ retryConfiguration: RetryConfiguration?,
+ ): Flow {
+ val uniqueWorkName = createSyncUniqueName("oneTimeSync")
+ val flow = getWorkerInfo(uniqueWorkName)
+ val oneTimeWorkRequest = createOneTimeWorkRequest(retryConfiguration, uniqueWorkName)
+ WorkManager.getInstance(context)
+ .enqueueUniqueWork(
+ uniqueWorkName,
+ ExistingWorkPolicy.KEEP,
+ oneTimeWorkRequest,
+ )
+ storeUniqueWorkNameInDataStore(uniqueWorkName)
+ return combineSyncStateForOneTimeSync(uniqueWorkName, flow)
+ }
+
+ /**
+ * Starts a periodic sync job based on [FhirSyncWorker].
+ *
+ * Use the returned [Flow] to get updates of the sync job. Alternatively, use [getWorkerInfo] with
+ * the same 'workName' to retrieve the status of the job.
+ *
+ * @param config configuration to determine the sync frequency and retry mechanism
+ * @return a [Flow] of [PeriodicSyncJobStatus]
+ */
+ override suspend fun schedulePeriodicSync(
+ config: PeriodicSyncConfiguration,
+ ): Flow {
+ val uniqueWorkName = createSyncUniqueName("periodicSync")
+ val flow = getWorkerInfo(uniqueWorkName)
+ val periodicWorkRequest = createPeriodicWorkRequest(config, uniqueWorkName)
+ WorkManager.getInstance(context)
+ .enqueueUniquePeriodicWork(
+ uniqueWorkName,
+ ExistingPeriodicWorkPolicy.KEEP,
+ periodicWorkRequest,
+ )
+ storeUniqueWorkNameInDataStore(uniqueWorkName)
+ return combineSyncStateForPeriodicSync(uniqueWorkName, flow)
+ }
+
+ override suspend fun cancelOneTimeSync() {
+ cancelSync("oneTimeSync")
+ }
+
+ override suspend fun cancelPeriodicSync() {
+ cancelSync("periodicSync")
+ }
+
+ @PublishedApi
+ internal suspend fun cancelSync(syncType: String) {
+ val uniqueWorkNameAsKey = createSyncUniqueName(syncType)
+ val uniqueWorkNameValueFromDataStore = dataStore.fetchUniqueWorkName(uniqueWorkNameAsKey)
+ if (uniqueWorkNameValueFromDataStore != null) {
+ WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkNameValueFromDataStore)
+ }
+ }
+
+ /**
+ * Retrieves the work information for a specific unique work name as a flow of pairs containing
+ * the work state and the corresponding progress data if available.
+ *
+ * @param workName The unique name of the work to retrieve information for.
+ * @return A flow emitting pairs of [WorkInfo.State] and [SyncJobStatus].
+ */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun getWorkerInfo(workName: String): Flow> =
+ WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWorkLiveData(workName)
+ .asFlow()
+ .flatMapConcat { list: List -> list.asFlow() }
+ .mapNotNull { workInfo: WorkInfo ->
+ val syncStatus =
+ workInfo.progress
+ .takeIf { it.keyValueMap.isNotEmpty() && it.hasKeyWithValueOfType("State") }
+ ?.let {
+ val stateType = it.getString("StateType")
+ val stateData = it.getString("State")
+ stateData?.let { data ->
+ if (stateType != null) {
+ // Use Polymorphic serialization via SyncJobStatus
+ json.decodeFromString(data)
+ } else {
+ json.decodeFromString(data)
+ }
+ }
+ }
+ workInfo.state to syncStatus
+ }
+
+ /**
+ * Combines the sync state for a one-time sync operation, including work state, progress, and
+ * terminal states.
+ *
+ * @param workName The name of the one-time sync work.
+ * @param workerInfoFlow A flow representing the progress of the sync job.
+ * @return A flow of [CurrentSyncJobStatus] combining the sync job states.
+ */
+ private fun combineSyncStateForOneTimeSync(
+ workName: String,
+ workerInfoFlow: Flow>,
+ ): Flow {
+ val syncJobStatusInDataStoreFlow: Flow =
+ dataStore.observeLastSyncJobStatus(workName)
+
+ return combine(workerInfoFlow, syncJobStatusInDataStoreFlow) {
+ workerInfoSyncJobStatusPairFromWorkManager,
+ syncJobStatusFromDataStore,
+ ->
+ createSyncStateForOneTimeSync(
+ workName,
+ workerInfoSyncJobStatusPairFromWorkManager.first,
+ workerInfoSyncJobStatusPairFromWorkManager.second,
+ syncJobStatusFromDataStore,
+ )
+ }
+ }
+
+ /**
+ * Combines the sync state for a periodic sync operation, including work state, progress, and
+ * terminal states.
+ *
+ * @param workName The name of the periodic sync work.
+ * @param workerInfoFlow A flow representing the progress of the sync job.
+ * @return A flow of [PeriodicSyncJobStatus] combining the sync job states.
+ */
+ private fun combineSyncStateForPeriodicSync(
+ workName: String,
+ workerInfoFlow: Flow>,
+ ): Flow {
+ val syncJobStatusInDataStoreFlow: Flow =
+ dataStore.observeLastSyncJobStatus(workName)
+ return combine(workerInfoFlow, syncJobStatusInDataStoreFlow) {
+ workerInfoSyncJobStatusPairFromWorkManager,
+ syncJobStatusFromDataStore,
+ ->
+ PeriodicSyncJobStatus(
+ lastSyncJobStatus = syncJobStatusFromDataStore,
+ currentSyncJobStatus =
+ createSyncStateForPeriodicSync(
+ workName,
+ workerInfoSyncJobStatusPairFromWorkManager.first,
+ workerInfoSyncJobStatusPairFromWorkManager.second,
+ ),
+ )
+ }
+ }
+
+ private suspend fun createSyncStateForOneTimeSync(
+ uniqueWorkName: String,
+ workInfoState: WorkInfo.State,
+ syncJobStatusFromWorkManager: SyncJobStatus?,
+ syncJobStatusFromDataStore: LastSyncJobStatus?,
+ ): CurrentSyncJobStatus {
+ return when (workInfoState) {
+ WorkInfo.State.ENQUEUED -> CurrentSyncJobStatus.Enqueued
+ WorkInfo.State.RUNNING -> {
+ when (syncJobStatusFromWorkManager) {
+ is SyncJobStatus.Started,
+ is SyncJobStatus.InProgress, -> CurrentSyncJobStatus.Running(syncJobStatusFromWorkManager)
+ is SyncJobStatus.Succeeded ->
+ CurrentSyncJobStatus.Succeeded(syncJobStatusFromWorkManager.timestamp)
+ is SyncJobStatus.Failed ->
+ CurrentSyncJobStatus.Failed(syncJobStatusFromWorkManager.timestamp)
+ null -> CurrentSyncJobStatus.Running(SyncJobStatus.Started())
+ }
+ }
+ WorkInfo.State.SUCCEEDED -> {
+ removeUniqueWorkNameInDataStore(uniqueWorkName)
+ syncJobStatusFromDataStore?.let {
+ when (it) {
+ is LastSyncJobStatus.Succeeded -> CurrentSyncJobStatus.Succeeded(it.timestamp)
+ else -> error("Inconsistent terminal syncJobStatus : $syncJobStatusFromDataStore")
+ }
+ }
+ ?: error("Inconsistent terminal syncJobStatus.")
+ }
+ WorkInfo.State.FAILED -> {
+ removeUniqueWorkNameInDataStore(uniqueWorkName)
+ syncJobStatusFromDataStore?.let {
+ when (it) {
+ is LastSyncJobStatus.Failed -> CurrentSyncJobStatus.Failed(it.timestamp)
+ else -> error("Inconsistent terminal syncJobStatus : $syncJobStatusFromDataStore")
+ }
+ }
+ ?: error("Inconsistent terminal syncJobStatus.")
+ }
+ WorkInfo.State.CANCELLED -> {
+ removeUniqueWorkNameInDataStore(uniqueWorkName)
+ CurrentSyncJobStatus.Cancelled
+ }
+ WorkInfo.State.BLOCKED -> CurrentSyncJobStatus.Blocked
+ }
+ }
+
+ private suspend fun createSyncStateForPeriodicSync(
+ uniqueWorkName: String,
+ workInfoState: WorkInfo.State,
+ syncJobStatusFromWorkManager: SyncJobStatus?,
+ ): CurrentSyncJobStatus {
+ return when (workInfoState) {
+ WorkInfo.State.ENQUEUED -> CurrentSyncJobStatus.Enqueued
+ WorkInfo.State.RUNNING -> {
+ return when (syncJobStatusFromWorkManager) {
+ is SyncJobStatus.Started,
+ is SyncJobStatus.InProgress, -> CurrentSyncJobStatus.Running(syncJobStatusFromWorkManager)
+ is SyncJobStatus.Succeeded ->
+ CurrentSyncJobStatus.Succeeded(syncJobStatusFromWorkManager.timestamp)
+ is SyncJobStatus.Failed ->
+ CurrentSyncJobStatus.Failed(syncJobStatusFromWorkManager.timestamp)
+ null -> CurrentSyncJobStatus.Running(SyncJobStatus.Started())
+ }
+ }
+ WorkInfo.State.CANCELLED -> {
+ removeUniqueWorkNameInDataStore(uniqueWorkName)
+ CurrentSyncJobStatus.Cancelled
+ }
+ WorkInfo.State.BLOCKED -> CurrentSyncJobStatus.Blocked
+ else -> error("Inconsistent WorkInfo.State in periodic sync : $workInfoState.")
+ }
+ }
+
+ @PublishedApi
+ internal fun createSyncUniqueName(syncType: String): String {
+ return "${workerClass.name}-$syncType"
+ }
+
+ @PublishedApi
+ internal suspend fun storeUniqueWorkNameInDataStore(
+ uniqueWorkName: String,
+ ) {
+ if (dataStore.fetchUniqueWorkName(uniqueWorkName) == null) {
+ dataStore.storeUniqueWorkName(key = uniqueWorkName, value = uniqueWorkName)
+ }
+ }
+
+ @PublishedApi
+ internal suspend fun removeUniqueWorkNameInDataStore(
+ uniqueWorkName: String,
+ ) {
+ if (dataStore.fetchUniqueWorkName(uniqueWorkName) != null) {
+ dataStore.removeUniqueWorkName(key = uniqueWorkName)
+ }
+ }
+
+ private fun createOneTimeWorkRequest(
+ retryConfiguration: RetryConfiguration?,
+ uniqueWorkName: String,
+ ): OneTimeWorkRequest {
+ val builder = OneTimeWorkRequest.Builder(workerClass)
+ retryConfiguration?.let {
+ builder.setBackoffCriteria(
+ it.backoffCriteria.backoffPolicy.toWorkManagerBackoffPolicy(),
+ it.backoffCriteria.backoffDelay.inWholeMilliseconds,
+ TimeUnit.MILLISECONDS,
+ )
+ builder.setInputData(
+ Data.Builder()
+ .putInt(MAX_RETRIES_ALLOWED, it.maxRetries)
+ .putString(UNIQUE_WORK_NAME, uniqueWorkName)
+ .build(),
+ )
+ }
+ return builder.build()
+ }
+
+ private fun createPeriodicWorkRequest(
+ periodicSyncConfiguration: PeriodicSyncConfiguration,
+ uniqueWorkName: String,
+ ): PeriodicWorkRequest {
+ val builder =
+ PeriodicWorkRequest.Builder(
+ workerClass,
+ periodicSyncConfiguration.repeat.interval.inWholeMilliseconds,
+ TimeUnit.MILLISECONDS,
+ )
+ .setConstraints(periodicSyncConfiguration.syncConstraints.toWorkManagerConstraints())
+
+ periodicSyncConfiguration.retryConfiguration?.let {
+ builder.setBackoffCriteria(
+ it.backoffCriteria.backoffPolicy.toWorkManagerBackoffPolicy(),
+ it.backoffCriteria.backoffDelay.inWholeMilliseconds,
+ TimeUnit.MILLISECONDS,
+ )
+ builder.setInputData(
+ Data.Builder()
+ .putInt(MAX_RETRIES_ALLOWED, it.maxRetries)
+ .putString(UNIQUE_WORK_NAME, uniqueWorkName)
+ .build(),
+ )
+ }
+ return builder.build()
+ }
+}
+
+private fun com.google.android.fhir.sync.BackoffPolicy.toWorkManagerBackoffPolicy() =
+ when (this) {
+ com.google.android.fhir.sync.BackoffPolicy.EXPONENTIAL -> BackoffPolicy.EXPONENTIAL
+ com.google.android.fhir.sync.BackoffPolicy.LINEAR -> BackoffPolicy.LINEAR
+ }
+
+private fun SyncConstraints.toWorkManagerConstraints() =
+ Constraints.Builder()
+ .apply {
+ setRequiredNetworkType(
+ when (requiredNetworkType) {
+ NetworkType.NOT_REQUIRED -> androidx.work.NetworkType.NOT_REQUIRED
+ NetworkType.CONNECTED -> androidx.work.NetworkType.CONNECTED
+ NetworkType.UNMETERED -> androidx.work.NetworkType.UNMETERED
+ NetworkType.NOT_ROAMING -> androidx.work.NetworkType.NOT_ROAMING
+ NetworkType.METERED -> androidx.work.NetworkType.METERED
+ },
+ )
+ setRequiresBatteryNotLow(requiresBatteryNotLow)
+ setRequiresCharging(requiresCharging)
+ setRequiresDeviceIdle(requiresDeviceIdle)
+ setRequiresStorageNotLow(requiresStorageNotLow)
+ }
+ .build()
diff --git a/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/DataStoreFactory.android.kt b/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/DataStoreFactory.android.kt
new file mode 100644
index 0000000000..9ce19462ae
--- /dev/null
+++ b/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/DataStoreFactory.android.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+
+private var dataStoreInstance: DataStore? = null
+
+@PublishedApi
+internal fun createDataStore(context: Context): DataStore =
+ dataStoreInstance ?: createDataStore {
+ context.filesDir.resolve(fhirDataStoreFileName).absolutePath
+ }.also { dataStoreInstance = it }
diff --git a/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/FhirSyncWorker.kt b/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/FhirSyncWorker.kt
new file mode 100644
index 0000000000..a726687731
--- /dev/null
+++ b/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/FhirSyncWorker.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.Data
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import co.touchlab.kermit.Logger
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.FhirEngineProvider
+import com.google.android.fhir.sync.download.DownloaderImpl
+import com.google.android.fhir.sync.upload.UploadStrategy
+import com.google.android.fhir.sync.upload.Uploader
+import com.google.android.fhir.sync.upload.patch.PatchGeneratorFactory
+import com.google.android.fhir.sync.upload.request.UploadRequestGeneratorFactory
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+/**
+ * Handles FHIR data synchronization between local database and remote server.
+ *
+ * Extend this abstract [CoroutineWorker] and implement the abstract methods to define your specific
+ * synchronization behavior. The custom worker class can then be used to schedule periodic
+ * synchronization jobs using [Sync].
+ */
+abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameters) :
+ CoroutineWorker(appContext, workerParams) {
+
+ /** Returns the [FhirEngine] instance used for interacting with the local FHIR data store. */
+ abstract fun getFhirEngine(): FhirEngine
+
+ /** Returns the [DownloadWorkManager] instance that manages the download process. */
+ abstract fun getDownloadWorkManager(): DownloadWorkManager
+
+ /**
+ * Returns the [ConflictResolver] instance that defines how to handle conflicts between local and
+ * remote data during synchronization.
+ */
+ abstract fun getConflictResolver(): ConflictResolver
+
+ /**
+ * Returns the [UploadStrategy] instance that defines how local changes are uploaded to the
+ * server.
+ */
+ abstract fun getUploadStrategy(): UploadStrategy
+
+ /** Returns the [DataSource] instance from [FhirEngineProvider]. */
+ internal open fun getDataSource(): DataSource? = FhirEngineProvider.getDataSource()
+
+ /** Returns the [FhirDataStore] instance for persisting sync state and metadata. */
+ internal open fun getFhirDataStore(): FhirDataStore =
+ FhirDataStore(createDataStore(applicationContext))
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ override suspend fun doWork(): Result {
+ val dataSource =
+ getDataSource()
+ ?: return Result.failure(
+ buildErrorData(
+ IllegalStateException(
+ "FhirEngineConfiguration.ServerConfiguration is not set. Call FhirEngineProvider.init to initialize with appropriate configuration.",
+ ),
+ ),
+ )
+
+ val fhirDataStore = getFhirDataStore()
+
+ val synchronizer =
+ FhirSynchronizer(
+ getFhirEngine(),
+ UploadConfiguration(
+ uploader =
+ Uploader(
+ dataSource = dataSource,
+ patchGenerator = PatchGeneratorFactory.byMode(getUploadStrategy().patchGeneratorMode),
+ requestGenerator =
+ UploadRequestGeneratorFactory.byMode(getUploadStrategy().requestGeneratorMode),
+ ),
+ uploadStrategy = getUploadStrategy(),
+ ),
+ DownloadConfiguration(
+ DownloaderImpl(dataSource, getDownloadWorkManager()),
+ getConflictResolver(),
+ ),
+ fhirDataStore,
+ )
+
+ val job =
+ CoroutineScope(Dispatchers.IO).launch {
+ synchronizer.syncState.collect { syncJobStatus ->
+ val uniqueWorkerName = inputData.getString(UNIQUE_WORK_NAME)
+ when (syncJobStatus) {
+ is SyncJobStatus.Succeeded,
+ is SyncJobStatus.Failed, -> {
+ if (uniqueWorkerName != null) {
+ fhirDataStore.writeTerminalSyncJobStatus(uniqueWorkerName, syncJobStatus)
+ }
+ cancel()
+ }
+ else -> {
+ setProgress(buildWorkData(syncJobStatus))
+ }
+ }
+ }
+ }
+
+ val result = synchronizer.synchronize()
+ val output = buildWorkData(result)
+
+ kotlin.runCatching { job.join() }.onFailure { Logger.w(it) { "Failed to join sync job" } }
+
+ Logger.d { "Received result from worker $result and sending output $output" }
+
+ /**
+ * In case of failure, we can check if its worth retrying and do retry based on
+ * [RetryConfiguration.maxRetries] set by user.
+ */
+ val retries = inputData.getInt(MAX_RETRIES_ALLOWED, 0)
+ return when (result) {
+ is SyncJobStatus.Succeeded -> Result.success(output)
+ else -> {
+ if (retries > runAttemptCount) Result.retry() else Result.failure(output)
+ }
+ }
+ }
+
+ private fun buildWorkData(state: SyncJobStatus): Data {
+ return workDataOf(
+ "StateType" to state::class.java.name,
+ "State" to json.encodeToString(state),
+ )
+ }
+
+ private fun buildErrorData(exception: Exception): Data {
+ return workDataOf("error" to exception::class.java.name, "reason" to exception.message)
+ }
+}
diff --git a/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/SyncExtensions.kt b/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/SyncExtensions.kt
new file mode 100644
index 0000000000..e7f2836622
--- /dev/null
+++ b/engine-kmp/src/androidMain/kotlin/com/google/android/fhir/sync/SyncExtensions.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import android.content.Context
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Starts a one-time sync job using the specified [FhirSyncWorker] subclass.
+ *
+ * @param W The concrete [FhirSyncWorker] subclass to use for the sync job.
+ * @param context The application [Context].
+ * @param retryConfiguration Configuration to guide the retry mechanism, or `null` to disable retry.
+ * @return A [Flow] of [CurrentSyncJobStatus] representing the sync job progress.
+ */
+suspend inline fun Sync.oneTimeSync(
+ context: Context,
+ retryConfiguration: RetryConfiguration? = defaultRetryConfiguration,
+): Flow {
+ val dataStore = FhirDataStore(createDataStore(context))
+ val scheduler = AndroidSyncScheduler(context, W::class.java, dataStore)
+ return oneTimeSync(scheduler, retryConfiguration)
+}
+
+/**
+ * Starts a periodic sync job using the specified [FhirSyncWorker] subclass.
+ *
+ * @param W The concrete [FhirSyncWorker] subclass to use for the sync job.
+ * @param context The application [Context].
+ * @param periodicSyncConfiguration Configuration to determine the sync frequency and retry
+ * mechanism.
+ * @return A [Flow] of [PeriodicSyncJobStatus] representing the sync job progress.
+ */
+suspend inline fun Sync.periodicSync(
+ context: Context,
+ periodicSyncConfiguration: PeriodicSyncConfiguration,
+): Flow {
+ val dataStore = FhirDataStore(createDataStore(context))
+ val scheduler = AndroidSyncScheduler(context, W::class.java, dataStore)
+ return periodicSync(scheduler, periodicSyncConfiguration)
+}
+
+/**
+ * Cancels a previously started one-time sync job for the specified [FhirSyncWorker] subclass.
+ *
+ * @param W The concrete [FhirSyncWorker] subclass whose sync job should be cancelled.
+ * @param context The application [Context].
+ */
+suspend inline fun Sync.cancelOneTimeSync(context: Context) {
+ val dataStore = FhirDataStore(createDataStore(context))
+ val scheduler = AndroidSyncScheduler(context, W::class.java, dataStore)
+ cancelOneTimeSync(scheduler)
+}
+
+/**
+ * Cancels a previously started periodic sync job for the specified [FhirSyncWorker] subclass.
+ *
+ * @param W The concrete [FhirSyncWorker] subclass whose sync job should be cancelled.
+ * @param context The application [Context].
+ */
+suspend inline fun Sync.cancelPeriodicSync(context: Context) {
+ val dataStore = FhirDataStore(createDataStore(context))
+ val scheduler = AndroidSyncScheduler(context, W::class.java, dataStore)
+ cancelPeriodicSync(scheduler)
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/ContentTypes.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/ContentTypes.kt
new file mode 100644
index 0000000000..ee4e4ab2bd
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/ContentTypes.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir
+
+internal object ContentTypes {
+ const val APPLICATION_JSON_PATCH = "application/json-patch+json"
+ const val APPLICATION_FHIR_JSON = "application/fhir+json"
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/DateProvider.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/DateProvider.kt
new file mode 100644
index 0000000000..63447f10da
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/DateProvider.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir
+
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+/** The DateProvider instance [FhirEngine] uses for date/time related operations. */
+internal object DateProvider {
+ private var fixedInstant: Instant? = null
+
+ /**
+ * Returns the current [Instant]. If a fixed instant has been set via [setFixed], returns that
+ * instead.
+ */
+ fun now(): Instant = fixedInstant ?: Clock.System.now()
+
+ /** Fixes the clock to always return the given [instant]. */
+ fun setFixed(instant: Instant) {
+ fixedInstant = instant
+ }
+
+ /** Resets the clock to use the system clock. */
+ fun resetClock() {
+ fixedInstant = null
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngine.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngine.kt
new file mode 100644
index 0000000000..0e6b7196c8
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngine.kt
@@ -0,0 +1,253 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir
+
+import com.google.android.fhir.db.LocalChangeResourceReference
+import com.google.android.fhir.db.ResourceNotFoundException
+import com.google.android.fhir.search.Search
+import com.google.android.fhir.sync.ConflictResolver
+import com.google.android.fhir.sync.upload.SyncUploadProgress
+import com.google.android.fhir.sync.upload.UploadRequestResult
+import com.google.android.fhir.sync.upload.UploadStrategy
+import com.google.fhir.model.r4.Resource
+import com.google.fhir.model.r4.terminologies.ResourceType
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.time.Instant
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Provides an interface for managing FHIR resources in local storage.
+ *
+ * The FHIR Engine allows you to create, read, update, and delete (CRUD) FHIR resources, as well as
+ * perform searches and synchronize data with a remote FHIR server. The FHIR resources are
+ * represented using Kotlin FHIR Structures [Resource] and [ResourceType].
+ *
+ * To use a FHIR Engine instance, first call [FhirEngineProvider.init] with a
+ * [FhirEngineConfiguration]. This must be done only once; we recommend doing this in the
+ * `onCreate()` function of your `Application` class on Android, or during app initialization on
+ * other platforms.
+ *
+ * ```
+ * FhirEngineProvider.init(
+ * FhirEngineConfiguration(
+ * enableEncryptionIfSupported = true,
+ * RECREATE_AT_OPEN
+ * )
+ * )
+ * ```
+ *
+ * To get a `FhirEngine` to interact with, use [FhirEngineProvider.getInstance]:
+ * ```
+ * val fhirEngine = FhirEngineProvider.getInstance()
+ * ```
+ */
+interface FhirEngine {
+ /**
+ * Creates one or more FHIR [Resource]s in the local storage. FHIR Engine requires all stored
+ * resources to have a logical [Resource.id]. If the `id` is specified in the resource passed to
+ * [create], the resource created in `FhirEngine` will have the same `id`. If no `id` is
+ * specified, `FhirEngine` will generate a UUID as that resource's `id` and include it in the
+ * returned list of IDs.
+ *
+ * @param resource The FHIR resources to create.
+ * @return A list of logical IDs of the newly created resources.
+ */
+ suspend fun create(vararg resource: Resource): List
+
+ /**
+ * Loads a FHIR resource given its [ResourceType] and logical ID.
+ *
+ * @param type The type of the resource to load.
+ * @param id The logical ID of the resource.
+ * @return The requested FHIR resource.
+ * @throws ResourceNotFoundException if the resource is not found.
+ */
+ @Throws(ResourceNotFoundException::class, CancellationException::class)
+ suspend fun get(type: ResourceType, id: String): Resource
+
+ /**
+ * Updates one or more FHIR [Resource]s in the local storage.
+ *
+ * @param resource The FHIR resources to update.
+ */
+ suspend fun update(vararg resource: Resource)
+
+ /**
+ * Removes a FHIR resource given its [ResourceType] and logical ID.
+ *
+ * @param type The type of the resource to delete.
+ * @param id The logical ID of the resource.
+ */
+ suspend fun delete(type: ResourceType, id: String)
+
+ /**
+ * Searches the database and returns a list of resources matching the [Search] specifications.
+ *
+ * @param search The search criteria to apply.
+ * @return A list of [SearchResult] objects containing the matching resources and any included
+ * references.
+ */
+ suspend fun search(search: Search): List>
+
+ /**
+ * Synchronizes upload results with the database.
+ *
+ * This function initiates multiple server calls to upload local changes. The results of each call
+ * are emitted as [UploadRequestResult] objects, which can be collected using a [Flow].
+ *
+ * @param uploadStrategy Defines strategies for uploading FHIR resource.
+ * @param upload A suspending function that takes a list of [LocalChange] objects and returns a
+ * [Flow] of [UploadRequestResult] objects.
+ * @return A [Flow] that emits the progress of the synchronization process as [SyncUploadProgress]
+ * objects.
+ */
+ @Deprecated("To be deprecated.")
+ suspend fun syncUpload(
+ uploadStrategy: UploadStrategy,
+ upload:
+ (suspend (List, List) -> Flow<
+ UploadRequestResult,
+ >),
+ ): Flow
+
+ /**
+ * Synchronizes the download results with the database.
+ *
+ * This function updates the local database to reflect the results of the download operation,
+ * resolving any conflicts using the provided [ConflictResolver].
+ *
+ * @param conflictResolver The [ConflictResolver] to use for resolving conflicts between local and
+ * remote data.
+ * @param download A suspending function that returns a [Flow] of lists of [Resource] objects
+ * representing the downloaded data.
+ */
+ @Deprecated("To be deprecated.")
+ suspend fun syncDownload(
+ conflictResolver: ConflictResolver,
+ download: suspend () -> Flow>,
+ )
+
+ /**
+ * Returns the total count of entities available for the given [Search].
+ *
+ * @param search The search criteria to apply.
+ * @return The total number of matching resources.
+ */
+ suspend fun count(search: Search): Long
+
+ /**
+ * Returns the timestamp when data was last synchronized, or `null` if no synchronization has
+ * occurred yet.
+ */
+ suspend fun getLastSyncTimeStamp(): Instant?
+
+ /**
+ * Clears all database tables without resetting the auto-increment value generated by
+ * PrimaryKey.autoGenerate.
+ *
+ * WARNING: This will permanently delete all data in the database.
+ */
+ suspend fun clearDatabase()
+
+ /**
+ * Retrieves a list of [LocalChange]s for the [Resource] with the given type and ID. This can be
+ * used to select resources to purge from the database.
+ *
+ * @param type The [ResourceType] of the resource.
+ * @param id The resource ID.
+ * @return A list of [LocalChange] objects representing the local changes made to the resource, or
+ * an empty list if no changes.
+ */
+ suspend fun getLocalChanges(type: ResourceType, id: String): List
+
+ /**
+ * Purges a resource from the database without deleting data from the server.
+ *
+ * @param type The [ResourceType] of the resource.
+ * @param id The resource ID.
+ * @param forcePurge If `true`, the resource will be purged even if it has local changes.
+ * Otherwise, an [IllegalStateException] will be thrown if local changes exist. Defaults to
+ * `false`.
+ *
+ * If you need to purge resources in bulk use the method
+ * [FhirEngine.purge(type: ResourceType, ids: Set, forcePurge: Boolean = false)]
+ */
+ suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean = false)
+
+ /**
+ * Purges resources of the specified type from the database identified by their IDs without any
+ * deletion of data from the server.
+ *
+ * @param type The [ResourceType]
+ * @param ids The resource ids [Set]<[Resource.id]>
+ * @param forcePurge If `true`, the resource will be purged even if it has local changes.
+ * Otherwise, an [IllegalStateException] will be thrown if local changes exist. Defaults to
+ * `false`.
+ *
+ * In the case an exception is thrown by any entry in the list the whole transaction is rolled
+ * back and no record is purged.
+ */
+ suspend fun purge(type: ResourceType, ids: Set, forcePurge: Boolean = false)
+
+ /**
+ * Adds support for performing actions on `FhirEngine` as a single atomic transaction where the
+ * entire set of changes succeed or fail as a single entity
+ */
+ suspend fun withTransaction(block: suspend FhirEngine.() -> Unit)
+}
+
+/**
+ * Retrieves a FHIR resource of type [R] with the given [id] from the local storage.
+ *
+ * @param R The type of the FHIR resource to retrieve.
+ * @param id The logical ID of the resource to retrieve.
+ * @return The requested FHIR resource.
+ * @throws ResourceNotFoundException if the resource is not found.
+ */
+@Throws(ResourceNotFoundException::class, CancellationException::class)
+suspend inline fun FhirEngine.get(id: String): R {
+ return get(getResourceType(R::class), id) as R
+}
+
+/**
+ * Deletes a FHIR resource of type [R] with the given [id] from the local storage.
+ *
+ * @param R The type of the FHIR resource to delete.
+ * @param id The logical ID of the resource to delete.
+ */
+suspend inline fun FhirEngine.delete(id: String) {
+ delete(getResourceType(R::class), id)
+}
+
+typealias SearchParamName = String
+
+/**
+ * Represents the result of a FHIR search query, containing a matching resource and any referenced
+ * resources as specified in the query.
+ *
+ * @param R The type of the main FHIR resource in the search result.
+ * @property resource The FHIR resource that matches the search criteria.
+ * @property included A map of included resources, keyed by the search parameter name used for
+ * inclusion, as per the [Search.forwardIncludes] criteria in the query.
+ * @property revIncluded A map of reverse included resources, keyed by the resource type and search
+ * parameter name used for inclusion, as per the [Search.revIncludes] criteria in the query.
+ */
+data class SearchResult(
+ val resource: R,
+ val included: Map>?,
+ val revIncluded: Map, List>?,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngineConfiguration.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngineConfiguration.kt
new file mode 100644
index 0000000000..3ed29a6339
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngineConfiguration.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir
+
+import com.google.android.fhir.index.SearchParamDefinition
+import com.google.android.fhir.sync.HttpAuthenticator
+import com.google.android.fhir.sync.remote.HttpLogger
+
+/**
+ * Configuration for the FHIR Engine, including database setup, error recovery, server connection,
+ * and custom search parameters.
+ *
+ * @property enableEncryptionIfSupported Enables database encryption if supported by the platform.
+ * Defaults to false.
+ * @property databaseErrorStrategy The strategy to handle database errors. Defaults to
+ * [DatabaseErrorStrategy.UNSPECIFIED].
+ * @property serverConfiguration Optional configuration for connecting to a remote FHIR server.
+ * @property testMode Whether to run the engine in test mode (using an in-memory database). Defaults
+ * to false.
+ * @property customSearchParameters Additional search parameters to be used for querying the FHIR
+ * engine with the Search API. These are in addition to the default search parameters defined in
+ * the FHIR specification. Custom search parameters must be unique and not change existing or
+ * default search parameters.
+ */
+data class FhirEngineConfiguration(
+ val enableEncryptionIfSupported: Boolean = false,
+ val databaseErrorStrategy: DatabaseErrorStrategy = DatabaseErrorStrategy.UNSPECIFIED,
+ val serverConfiguration: ServerConfiguration? = null,
+ val testMode: Boolean = false,
+ val customSearchParameters: List? = null,
+)
+
+/** How database errors should be handled. */
+enum class DatabaseErrorStrategy {
+ /**
+ * If unspecified, all database errors will be propagated to the call site. The caller shall
+ * handle the database error on a case-by-case basis.
+ */
+ UNSPECIFIED,
+
+ /**
+ * If a database error occurs at open, automatically recreate the database.
+ *
+ * This strategy is NOT respected when opening a previously unencrypted database with an encrypted
+ * configuration or vice versa. An [IllegalStateException] is thrown instead.
+ */
+ RECREATE_AT_OPEN,
+}
+
+/**
+ * Configuration for connecting to a remote FHIR server.
+ *
+ * @property baseUrl The base URL of the remote FHIR server.
+ * @property networkConfiguration Configuration for network connection parameters. Defaults to
+ * [NetworkConfiguration].
+ * @property authenticator An optional [HttpAuthenticator] for providing HTTP authorization headers.
+ * @property httpLogger Logs the communication between the engine and the remote server. Defaults to
+ * [HttpLogger.NONE].
+ */
+data class ServerConfiguration(
+ val baseUrl: String,
+ val networkConfiguration: NetworkConfiguration = NetworkConfiguration(),
+ val authenticator: HttpAuthenticator? = null,
+ val httpLogger: HttpLogger = HttpLogger.NONE,
+)
+
+/**
+ * Configuration for network connection parameters used when communicating with a remote FHIR
+ * server.
+ *
+ * @property connectionTimeOut Connection timeout in seconds. Defaults to 10 seconds.
+ * @property readTimeOut Read timeout in seconds for network connections. Defaults to 10 seconds.
+ * @property writeTimeOut Write timeout in seconds for network connections. Defaults to 10 seconds.
+ * @property uploadWithGzip Enables compression of requests when uploading to a server that supports
+ * gzip. Defaults to false.
+ * @property httpCache Optional [CacheConfiguration] to enable Cache-Control headers for network
+ * requests.
+ */
+data class NetworkConfiguration(
+ val connectionTimeOut: Long = 10,
+ val readTimeOut: Long = 10,
+ val writeTimeOut: Long = 10,
+ val uploadWithGzip: Boolean = false,
+ val httpCache: CacheConfiguration? = null,
+)
+
+/**
+ * Configuration for HTTP caching of network requests.
+ *
+ * @property cacheDir The directory path used for caching (platform-agnostic string path).
+ * @property maxSize The maximum size of the cache in bits, e.g., `50L * 1024L * 1024L` for 50 MiB.
+ */
+data class CacheConfiguration(
+ val cacheDir: String,
+ val maxSize: Long,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngineProvider.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngineProvider.kt
new file mode 100644
index 0000000000..2504b4e551
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngineProvider.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir
+
+import com.google.android.fhir.db.impl.DatabaseImpl
+import com.google.android.fhir.impl.FhirEngineImpl
+import com.google.android.fhir.index.ResourceIndexer
+import com.google.android.fhir.index.SearchParamDefinition
+import com.google.android.fhir.index.SearchParamDefinitionsProviderImpl
+import com.google.android.fhir.sync.DataSource
+import com.google.android.fhir.sync.remote.FhirHttpDataSource
+import com.google.android.fhir.sync.remote.KtorHttpService
+
+/**
+ * Provides singleton access to the [FhirEngine] instance.
+ *
+ * Initialize with [init] before calling [getInstance]. On Android, pass the application `Context`
+ * as `platformContext`. On Desktop and iOS, the parameter is ignored.
+ *
+ * ```
+ * // Initialize (once, e.g. in Application.onCreate on Android)
+ * FhirEngineProvider.init(FhirEngineConfiguration())
+ *
+ * // Get the engine
+ * val fhirEngine = FhirEngineProvider.getInstance(context)
+ * ```
+ */
+object FhirEngineProvider {
+ private var configuration: FhirEngineConfiguration? = null
+ private var fhirEngine: FhirEngine? = null
+ private var dataSource: DataSource? = null
+ private var platformContext: Any = Unit
+
+ /**
+ * Initializes the [FhirEngineProvider] with the given [configuration].
+ *
+ * This must be called before [getInstance]. Calling it again after initialization will throw an
+ * [IllegalStateException].
+ */
+ fun init(configuration: FhirEngineConfiguration, platformContext: Any = Unit) {
+ check(this.configuration == null) {
+ "FhirEngineProvider has already been initialized."
+ }
+ this.configuration = configuration
+ this.platformContext = platformContext
+ }
+
+ /**
+ * Returns the [FhirEngine] instance, creating it if necessary.
+ *
+ * @param platformContext Platform-specific context. On Android, this should be the application
+ * `Context`. On Desktop and iOS, pass `Unit` or omit.
+ */
+ fun getInstance(platformContext: Any = Unit): FhirEngine {
+ val config =
+ checkNotNull(configuration) {
+ "FhirEngineProvider not initialized. Call FhirEngineProvider.init() first."
+ }
+ val context = if (platformContext == Unit) this.platformContext else platformContext
+ if (fhirEngine == null) {
+ fhirEngine = buildFhirEngine(context, config)
+ }
+ return fhirEngine!!
+ }
+
+ /**
+ * Returns the [DataSource] instance, or `null` if no [ServerConfiguration] was provided.
+ *
+ * Only available after [init] has been called.
+ */
+ @PublishedApi
+ internal fun getDataSource(): DataSource? {
+ checkNotNull(configuration) {
+ "FhirEngineProvider not initialized. Call FhirEngineProvider.init() first."
+ }
+ return dataSource
+ }
+
+ /** Clears the singleton instance. Intended for testing only. */
+ internal fun clearInstance() {
+ fhirEngine = null
+ dataSource = null
+ configuration = null
+ platformContext = Unit
+ }
+
+ private fun buildFhirEngine(
+ platformContext: Any,
+ config: FhirEngineConfiguration,
+ ): FhirEngine {
+ val searchParamDefinitionsProvider =
+ SearchParamDefinitionsProviderImpl(customParams = buildCustomParamsMap(config))
+ val resourceIndexer = ResourceIndexer(searchParamDefinitionsProvider)
+ val database = DatabaseImpl(platformContext, resourceIndexer)
+
+ config.serverConfiguration?.let { serverConfig ->
+ dataSource =
+ FhirHttpDataSource(
+ KtorHttpService.builder(serverConfig.baseUrl, serverConfig.networkConfiguration)
+ .setAuthenticator(serverConfig.authenticator)
+ .setHttpLogger(serverConfig.httpLogger)
+ .build(),
+ )
+ }
+
+ return FhirEngineImpl(database)
+ }
+
+ private fun buildCustomParamsMap(
+ config: FhirEngineConfiguration,
+ ): Map> {
+ val params = config.customSearchParameters ?: return emptyMap()
+ return params.groupBy { it.path.substringBefore(".") }
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/LocalChange.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/LocalChange.kt
new file mode 100644
index 0000000000..a6144a788b
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/LocalChange.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir
+
+import kotlin.time.Instant
+
+/** Data class for squashed local changes for resource. */
+data class LocalChange(
+ /** The resource type name (e.g. "Patient"). */
+ val resourceType: String,
+ /** The resource id. */
+ val resourceId: String,
+ /** The id of the version of the resource that this local change is based on. */
+ val versionId: String? = null,
+ /** The time instant the app user performed a CUD operation on the resource. */
+ val timestamp: Instant,
+ /** Type of local change like insert, delete, etc. */
+ val type: Type,
+ /** JSON string with local changes. */
+ val payload: String,
+ /**
+ * This token value must be explicitly applied when list of local changes are squashed and
+ * [LocalChange] class instance is created.
+ */
+ var token: LocalChangeToken,
+) {
+ enum class Type(val value: Int) {
+ INSERT(1), // create a new resource. payload is the entire resource json.
+ UPDATE(2), // patch. payload is the json patch.
+ DELETE(3), // delete. payload is empty string.
+ ;
+
+ companion object {
+ fun from(input: Int): Type = entries.first { it.value == input }
+ }
+ }
+}
+
+data class LocalChangeToken(val ids: List)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Logging.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Logging.kt
new file mode 100644
index 0000000000..64a6628857
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Logging.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir
+
+import co.touchlab.kermit.Logger
+
+/** Kermit-based logging for engine-kmp, replacing Timber (Android-only). */
+internal object FhirEngineLog {
+ private val logger = Logger.withTag("FhirEngine")
+
+ fun w(message: String) {
+ logger.w { message }
+ }
+
+ fun d(message: String) {
+ logger.d { message }
+ }
+
+ fun e(message: String, throwable: Throwable? = null) {
+ if (throwable != null) {
+ logger.e(throwable) { message }
+ } else {
+ logger.e { message }
+ }
+ }
+
+ fun i(message: String) {
+ logger.i { message }
+ }
+
+ fun withTag(tag: String): TaggedLog = TaggedLog(tag)
+}
+
+internal class TaggedLog(tag: String) {
+ private val logger = Logger.withTag(tag)
+
+ fun w(message: String) {
+ logger.w { message }
+ }
+
+ fun d(message: String) {
+ logger.d { message }
+ }
+
+ fun e(message: String, throwable: Throwable? = null) {
+ if (throwable != null) {
+ logger.e(throwable) { message }
+ } else {
+ logger.e { message }
+ }
+ }
+
+ fun i(message: String) {
+ logger.i { message }
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/MoreResources.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/MoreResources.kt
new file mode 100644
index 0000000000..0fd9ba2070
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/MoreResources.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir
+
+import com.google.fhir.model.r4.Resource
+import com.google.fhir.model.r4.terminologies.ResourceType
+import kotlin.reflect.KClass
+import kotlinx.datetime.Instant
+
+/**
+ * Resource type registry mapping KClass to ResourceType. This replaces JVM reflection
+ * (Class.forName) used in the original engine module with a KMP-compatible lookup.
+ *
+ * Note: This registry is populated with common resource types. Additional types can be registered
+ * via [registerResourceType].
+ */
+private val resourceTypeRegistry = mutableMapOf, ResourceType>()
+
+private val resourceTypeNameRegistry = mutableMapOf>()
+
+/** Registers a resource KClass with its corresponding [ResourceType]. */
+fun registerResourceType(kClass: KClass, type: ResourceType) {
+ resourceTypeRegistry[kClass] = type
+ resourceTypeNameRegistry[type.name] = kClass
+}
+
+/**
+ * Returns the FHIR [ResourceType] for the given resource [KClass].
+ *
+ * @throws IllegalArgumentException if the class is not registered in the resource type registry
+ */
+fun getResourceType(kClass: KClass): ResourceType =
+ resourceTypeRegistry[kClass]
+ ?: throw IllegalArgumentException(
+ "Cannot resolve resource type for ${kClass.simpleName}. " +
+ "Register it with registerResourceType() first.",
+ )
+
+/**
+ * Returns the [KClass] for the given resource type name.
+ *
+ * @throws IllegalArgumentException if the resource type name is not registered
+ */
+fun getResourceClass(resourceType: String): KClass {
+ val className = resourceType.replace(Regex("\\{[^}]*\\}"), "")
+ return resourceTypeNameRegistry[className]
+ ?: throw IllegalArgumentException(
+ "Cannot resolve resource class for $className. " +
+ "Register it with registerResourceType() first.",
+ )
+}
+
+/** Returns the [KClass] for the given [ResourceType]. */
+fun getResourceClass(resourceType: ResourceType): KClass =
+ getResourceClass(resourceType.name)
+
+/**
+ * Returns the FHIR resource type name string for this [Resource] (e.g., "Patient", "Observation").
+ *
+ * This uses the Kotlin class simple name which matches the FHIR resource type name for all
+ * kotlin-fhir model classes.
+ */
+internal val Resource.resourceType: String
+ get() = this::class.simpleName ?: error("Cannot determine resource type for $this")
+
+internal val Resource.versionId: String?
+ get() = meta?.versionId?.value
+
+internal val Resource.lastUpdated: Instant?
+ get() = meta?.lastUpdated?.value?.toString()?.let { Instant.parse(it) }
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/UnitConverter.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/UnitConverter.kt
new file mode 100644
index 0000000000..d930f562ba
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/UnitConverter.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir
+
+import com.ionspin.kotlin.bignum.decimal.BigDecimal
+
+/**
+ * Canonicalizes unit values to UCUM base units.
+ *
+ * For details of UCUM, see http://unitsofmeasure.org/
+ *
+ * For using UCUM with FHIR, see https://www.hl7.org/fhir/ucum.html
+ *
+ * TODO: Provide a full UCUM conversion implementation for KMP. Currently returns the original value
+ * unchanged.
+ */
+internal object UnitConverter {
+
+ /**
+ * Returns the canonical form of a UCUM Value. Currently returns the original value as UCUM
+ * conversion is not yet available for KMP.
+ */
+ fun getCanonicalFormOrOriginal(value: UcumValue): UcumValue = value
+}
+
+internal class ConverterException(message: String, cause: Throwable? = null) :
+ Exception(message, cause)
+
+internal data class UcumValue(val code: String, val value: BigDecimal)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Util.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Util.kt
new file mode 100644
index 0000000000..60adb9b61e
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Util.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir
+
+import kotlin.time.Instant
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+
+/** Formats an [Instant] as an ISO 8601 date-time string with timezone offset. */
+internal fun Instant.toTimeZoneString(): String {
+ val localDateTime = this.toLocalDateTime(TimeZone.currentSystemDefault())
+ return localDateTime.toString()
+}
+
+/** Returns true if given string matches ISO date format i.e. "yyyy-MM-dd", false otherwise. */
+internal fun isValidDateOnly(date: String): Boolean = Regex("^\\d{4}-\\d{2}-\\d{2}$").matches(date)
+
+/** Implementation of a parallelized map. */
+suspend fun Iterable.pmap(
+ dispatcher: CoroutineDispatcher,
+ f: suspend (A) -> B,
+): List = coroutineScope { map { async(dispatcher) { f(it) } }.awaitAll() }
+
+/** Url for the UCUM system of measures. */
+internal const val ucumUrl = "http://unitsofmeasure.org"
+
+internal fun percentOf(value: Number, total: Number) =
+ if (total == 0) 0.0 else value.toDouble() / total.toDouble()
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/Database.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/Database.kt
new file mode 100644
index 0000000000..bcfad54928
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/Database.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db
+
+import com.google.android.fhir.LocalChange
+import com.google.android.fhir.LocalChangeToken
+import com.google.android.fhir.search.ReferencedResourceResult
+import com.google.android.fhir.search.SearchQuery
+import com.google.fhir.model.r4.Resource
+import com.google.fhir.model.r4.terminologies.ResourceType
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.time.Instant
+import kotlin.uuid.Uuid
+
+/** The interface for the FHIR resource database. */
+internal interface Database {
+ /**
+ * Inserts a list of local `resources` into the FHIR resource database. If any of the resources
+ * already exists, it will be overwritten.
+ *
+ * @return the logical IDs of the newly created resources.
+ */
+ suspend fun insert(vararg resource: R): List
+
+ /**
+ * Inserts a list of remote `resources` into the FHIR resource database. If any of the resources
+ * already exists, it will be overwritten.
+ */
+ suspend fun insertRemote(vararg resource: R)
+
+ /**
+ * Updates the `resource` in the FHIR resource database. If the resource does not already exist,
+ * then it will not be created.
+ */
+ suspend fun update(vararg resources: Resource)
+
+ /** Updates the `resource` meta in the FHIR resource database. */
+ suspend fun updateVersionIdAndLastUpdated(
+ resourceId: String,
+ resourceType: ResourceType,
+ versionId: String?,
+ lastUpdated: Instant?,
+ )
+
+ /**
+ * Updates the existing [oldResourceId] with the new [newResourceId]. Even if [oldResourceId] and
+ * [newResourceId] are the same, it is still necessary to update the resource meta.
+ */
+ suspend fun updateResourcePostSync(
+ oldResourceId: String,
+ newResourceId: String,
+ resourceType: ResourceType,
+ versionId: String?,
+ lastUpdated: Instant?,
+ )
+
+ /**
+ * Selects the FHIR resource of type with `id`.
+ *
+ * @throws ResourceNotFoundException if the resource is not found in the database
+ */
+ @Throws(ResourceNotFoundException::class, CancellationException::class)
+ suspend fun select(type: ResourceType, id: String): Resource
+
+ /** Insert resources that were synchronized. */
+ suspend fun insertSyncedResources(resources: List)
+
+ /** Deletes the FHIR resource of type with `id`. */
+ suspend fun delete(type: ResourceType, id: String)
+
+ suspend fun search(query: SearchQuery): List>
+
+ suspend fun count(query: SearchQuery): Long
+
+ suspend fun searchReferencedResources(query: SearchQuery): List
+
+ /**
+ * Retrieves all [LocalChange]s for all [Resource]s, which can be used to update the remote FHIR
+ * server.
+ */
+ suspend fun getAllLocalChanges(): List
+
+ /**
+ * Retrieves all [LocalChange]s for the [Resource] which has the [LocalChange] with the oldest
+ * [LocalChange.timestamp].
+ */
+ suspend fun getAllChangesForEarliestChangedResource(): List
+
+ /** Retrieves the count of [LocalChange]s stored in the database. */
+ suspend fun getLocalChangesCount(): Int
+
+ /** Remove the [LocalChange]s with given ids. Call this after a successful sync. */
+ suspend fun deleteUpdates(token: LocalChangeToken)
+
+ /** Remove the [LocalChange]s with matching resource ids. */
+ suspend fun deleteUpdates(resources: List)
+
+ /**
+ * Updates the existing resource identified by [currentResourceId] with the [updatedResource],
+ * ensuring all associated references in the database are also updated accordingly.
+ */
+ suspend fun updateResourceAndReferences(
+ currentResourceId: String,
+ updatedResource: Resource,
+ )
+
+ /** Runs the block as a database transaction. */
+ suspend fun withTransaction(block: suspend () -> Unit)
+
+ /** Closes the database connection. */
+ fun close()
+
+ /** Clears all database tables. WARNING: This will clear the database and it's not recoverable. */
+ suspend fun clearDatabase()
+
+ /**
+ * Retrieve a list of [LocalChange] for [Resource] with given type and id.
+ *
+ * @return A list of local changes, or an empty list if none exist.
+ */
+ suspend fun getLocalChanges(type: ResourceType, id: String): List
+
+ /**
+ * Retrieve a list of [LocalChange] for a resource with the given UUID.
+ *
+ * @return A list of local changes, or an empty list if none exist.
+ */
+ suspend fun getLocalChanges(resourceUuid: Uuid): List
+
+ /**
+ * Purges resources of the specified type from the database identified by their IDs without any
+ * deletion of data from the server.
+ */
+ suspend fun purge(type: ResourceType, ids: Set, forcePurge: Boolean = false)
+
+ /**
+ * @return List of [LocalChangeResourceReference] associated with the local change IDs. A single
+ * local change may have one or more [LocalChangeResourceReference] associated with it.
+ */
+ suspend fun getLocalChangeResourceReferences(
+ localChangeIds: List,
+ ): List
+}
+
+internal data class ResourceWithUUID(
+ val uuid: Uuid,
+ val resource: R,
+)
+
+data class LocalChangeResourceReference(
+ val localChangeId: Long,
+ val resourceReferenceValue: String,
+ val resourceReferencePath: String?,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/DatabaseEncryptionException.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/DatabaseEncryptionException.kt
new file mode 100644
index 0000000000..b3720e044c
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/DatabaseEncryptionException.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db
+
+/**
+ * A database encryption exception wrapper which maps comprehensive keystore errors to a limited set
+ * of actionable errors.
+ */
+class DatabaseEncryptionException(cause: Exception, val errorCode: DatabaseEncryptionErrorCode) :
+ Exception(cause) {
+
+ enum class DatabaseEncryptionErrorCode {
+ /** Unclassified error. The error could potentially be mitigated by recreating the database. */
+ UNKNOWN,
+
+ /** Required encryption algorithm is not available. */
+ UNSUPPORTED,
+
+ /** Timeout when accessing encrypted database. */
+ TIMEOUT,
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundException.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundException.kt
new file mode 100644
index 0000000000..5476f3abea
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundException.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db
+
+import kotlin.uuid.Uuid
+
+/** Thrown to indicate that the requested resource is not found. */
+class ResourceNotFoundException : Exception {
+ lateinit var type: String
+ lateinit var id: String
+ lateinit var uuid: Uuid
+
+ constructor(
+ type: String,
+ id: String,
+ cause: Throwable,
+ ) : super("Resource not found with type $type and id $id!", cause) {
+ this.type = type
+ this.id = id
+ }
+
+ constructor(
+ type: String,
+ id: String,
+ ) : super("Resource not found with type $type and id $id!") {
+ this.type = type
+ this.id = id
+ }
+
+ constructor(
+ uuid: Uuid,
+ ) : super("Resource not found with UUID $uuid!") {
+ this.uuid = uuid
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundInDbException.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundInDbException.kt
new file mode 100644
index 0000000000..e78cc580fb
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundInDbException.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db
+
+/** Exception thrown to indicate that the requested resource is not found in the database. */
+class ResourceNotFoundInDbException(val type: String, val id: String) :
+ Exception("Resource not found with type $type and id $id!")
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/DatabaseBuilder.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/DatabaseBuilder.kt
new file mode 100644
index 0000000000..05c038c0d3
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/DatabaseBuilder.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl
+
+import androidx.room.RoomDatabase
+
+/**
+ * Returns a platform-specific [RoomDatabase.Builder] for [ResourceDatabase].
+ *
+ * @param platformContext Platform-specific context. On Android, this should be the application
+ * `Context`. On Desktop and iOS, this parameter is ignored.
+ */
+internal expect fun getDatabaseBuilder(
+ platformContext: Any,
+): RoomDatabase.Builder
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/DatabaseImpl.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/DatabaseImpl.kt
new file mode 100644
index 0000000000..253288cb89
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/DatabaseImpl.kt
@@ -0,0 +1,568 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl
+
+import androidx.room.useReaderConnection
+import androidx.sqlite.SQLiteStatement
+import androidx.sqlite.driver.bundled.BundledSQLiteDriver
+import com.google.android.fhir.LocalChange
+import com.google.android.fhir.LocalChangeToken
+import com.google.android.fhir.db.Database
+import com.google.android.fhir.db.LocalChangeResourceReference
+import com.google.android.fhir.db.ResourceNotFoundException
+import com.google.android.fhir.db.ResourceWithUUID
+import com.google.android.fhir.search.ReferencedResourceResult
+import com.google.android.fhir.db.impl.entities.LocalChangeEntity
+import com.google.android.fhir.db.impl.entities.LocalChangeResourceReferenceEntity
+import com.google.android.fhir.db.impl.entities.DateIndexEntity
+import com.google.android.fhir.db.impl.entities.DateTimeIndexEntity
+import com.google.android.fhir.db.impl.entities.NumberIndexEntity
+import com.google.android.fhir.db.impl.entities.PositionIndexEntity
+import com.google.android.fhir.db.impl.entities.QuantityIndexEntity
+import com.google.android.fhir.db.impl.entities.ReferenceIndexEntity
+import com.google.android.fhir.db.impl.entities.ResourceEntity
+import com.google.android.fhir.db.impl.entities.StringIndexEntity
+import com.google.android.fhir.db.impl.entities.TokenIndexEntity
+import com.google.android.fhir.db.impl.entities.UriIndexEntity
+import com.google.android.fhir.index.ResourceIndexer
+import com.google.android.fhir.index.ResourceIndices
+import com.google.android.fhir.resourceType
+import com.google.android.fhir.search.SearchQuery
+import com.google.fhir.model.r4.Resource
+import com.google.fhir.model.r4.terminologies.ResourceType
+import kotlin.time.Clock
+import kotlin.time.Instant
+import kotlin.uuid.Uuid
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+
+/**
+ * The implementation for the persistence layer using Room KMP. Provides the minimum operations
+ * needed for the vertical slice: insert, select, insertRemote, and insertSyncedResources.
+ *
+ * Note: Room KMP does not provide `withTransaction` as an extension function. For the vertical
+ * slice, DAO calls are made sequentially without explicit transaction wrapping. Full transaction
+ * support will be added when the database layer is widened.
+ */
+internal class DatabaseImpl(
+ platformContext: Any,
+ private val resourceIndexer: ResourceIndexer,
+) : Database {
+
+ private val db: ResourceDatabase =
+ getDatabaseBuilder(platformContext)
+ .setDriver(BundledSQLiteDriver())
+ .setQueryCoroutineContext(Dispatchers.IO)
+ .fallbackToDestructiveMigration(dropAllTables = true)
+ .build()
+
+ private val resourceDao by lazy { db.resourceDao() }
+ private val localChangeDao by lazy { db.localChangeDao() }
+
+ override suspend fun insert(vararg resource: R): List {
+ return resource.map { res ->
+ val resourceId = res.id ?: Uuid.random().toString()
+ val resourceUuid = Uuid.random().toString()
+ val resourceTypeName = res.resourceType
+ val now = Clock.System.now().toEpochMilliseconds()
+
+ // If the resource has no id, create a copy with the generated id via JSON round-trip
+ val resourceWithId =
+ if (res.id == null) {
+ val json = serializeResource(res)
+ val jsonWithId = ensureIdInJson(json, resourceId)
+ deserializeResource(jsonWithId)
+ } else {
+ res
+ }
+
+ val serialized = serializeResource(resourceWithId)
+ val entity =
+ ResourceEntity(
+ id = 0,
+ resourceUuid = resourceUuid,
+ resourceType = resourceTypeName,
+ resourceId = resourceId,
+ serializedResource = serialized,
+ versionId = null,
+ lastUpdatedRemote = null,
+ lastUpdatedLocal = now,
+ )
+ resourceDao.insertResource(entity)
+
+ val indices = resourceIndexer.index(resourceWithId)
+ insertIndices(resourceUuid, resourceTypeName, indices)
+
+ // Track local change
+ createLocalChange(
+ resourceType = resourceTypeName,
+ resourceId = resourceId,
+ resourceUuid = resourceUuid,
+ timestamp = now,
+ type = LocalChange.Type.INSERT.value,
+ payload = serialized,
+ versionId = null,
+ )
+
+ resourceId
+ }
+ }
+
+ override suspend fun insertRemote(vararg resource: R) {
+ resource.forEach { res ->
+ val resourceId = res.id ?: error("Remote resource must have an id")
+ val resourceUuid = Uuid.random().toString()
+ val resourceTypeName = res.resourceType
+ val now = Clock.System.now().toEpochMilliseconds()
+
+ val entity =
+ ResourceEntity(
+ id = 0,
+ resourceUuid = resourceUuid,
+ resourceType = resourceTypeName,
+ resourceId = resourceId,
+ serializedResource = serializeResource(res),
+ versionId = null,
+ lastUpdatedRemote = now,
+ lastUpdatedLocal = now,
+ )
+ resourceDao.insertResource(entity)
+
+ val indices = resourceIndexer.index(res)
+ insertIndices(resourceUuid, resourceTypeName, indices)
+ }
+ }
+
+ override suspend fun select(type: ResourceType, id: String): Resource {
+ val json = resourceDao.getResource(resourceId = id, resourceType = type.name)
+ return if (json != null) {
+ deserializeResource(json)
+ } else {
+ throw ResourceNotFoundException(type.name, id)
+ }
+ }
+
+ override suspend fun insertSyncedResources(resources: List) {
+ insertRemote(*resources.toTypedArray())
+ }
+
+ override suspend fun withTransaction(block: suspend () -> Unit) {
+ // TODO: Implement proper transaction support with Room KMP useWriterConnection
+ block()
+ }
+
+ override fun close() {
+ db.close()
+ }
+
+ override suspend fun clearDatabase() {
+ resourceDao.deleteAllResources()
+ }
+
+ // --- Methods not needed for the vertical slice ---
+
+ override suspend fun update(vararg resources: Resource) {
+ resources.forEach { res ->
+ val resourceId = res.id ?: error("Resource must have an id to be updated")
+ val resourceTypeName = res.resourceType
+ val existing =
+ resourceDao.getResourceEntity(resourceId = resourceId, resourceType = resourceTypeName)
+ ?: throw ResourceNotFoundException(resourceTypeName, resourceId)
+
+ val now = Clock.System.now().toEpochMilliseconds()
+ val newSerialized = serializeResource(res)
+
+ // Compute JSON diff for the local change
+ val jsonDiff = JsonDiff.diff(existing.serializedResource, newSerialized)
+ if (jsonDiff != "[]") {
+ createLocalChange(
+ resourceType = resourceTypeName,
+ resourceId = resourceId,
+ resourceUuid = existing.resourceUuid,
+ timestamp = now,
+ type = LocalChange.Type.UPDATE.value,
+ payload = jsonDiff,
+ versionId = existing.versionId,
+ )
+ }
+
+ val updatedEntity =
+ existing.copy(
+ serializedResource = newSerialized,
+ lastUpdatedLocal = now,
+ )
+ resourceDao.insertResource(updatedEntity)
+
+ val indices = resourceIndexer.index(res)
+ insertIndices(existing.resourceUuid, resourceTypeName, indices)
+ }
+ }
+
+ override suspend fun updateVersionIdAndLastUpdated(
+ resourceId: String,
+ resourceType: ResourceType,
+ versionId: String?,
+ lastUpdated: Instant?,
+ ) {
+ resourceDao.updateVersionIdAndLastUpdated(
+ resourceId = resourceId,
+ resourceType = resourceType.name,
+ versionId = versionId,
+ lastUpdated = lastUpdated?.toEpochMilliseconds(),
+ )
+ }
+
+ override suspend fun updateResourcePostSync(
+ oldResourceId: String,
+ newResourceId: String,
+ resourceType: ResourceType,
+ versionId: String?,
+ lastUpdated: Instant?,
+ ) {
+ resourceDao.updateResourceIdAndMeta(
+ oldResourceId = oldResourceId,
+ newResourceId = newResourceId,
+ resourceType = resourceType.name,
+ versionId = versionId,
+ lastUpdated = lastUpdated?.toEpochMilliseconds(),
+ )
+ }
+
+ override suspend fun delete(type: ResourceType, id: String) {
+ val existing = resourceDao.getResourceEntity(resourceId = id, resourceType = type.name)
+ val rowsDeleted = resourceDao.deleteResource(resourceId = id, resourceType = type.name)
+ if (rowsDeleted > 0 && existing != null) {
+ createLocalChange(
+ resourceType = type.name,
+ resourceId = id,
+ resourceUuid = existing.resourceUuid,
+ timestamp = Clock.System.now().toEpochMilliseconds(),
+ type = LocalChange.Type.DELETE.value,
+ payload = "",
+ versionId = existing.versionId,
+ )
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ override suspend fun search(query: SearchQuery): List> {
+ return db.useReaderConnection { transactor ->
+ transactor.usePrepared(query.query) { statement ->
+ bindArgs(statement, query.args)
+ val results = mutableListOf>()
+ while (statement.step()) {
+ val uuid = statement.getText(0)
+ val json = statement.getText(1)
+ val resource = deserializeResource(json) as R
+ results.add(ResourceWithUUID(Uuid.parse(uuid), resource))
+ }
+ results
+ }
+ }
+ }
+
+ override suspend fun count(query: SearchQuery): Long {
+ return db.useReaderConnection { transactor ->
+ transactor.usePrepared(query.query) { statement ->
+ bindArgs(statement, query.args)
+ if (statement.step()) {
+ statement.getLong(0)
+ } else {
+ 0L
+ }
+ }
+ }
+ }
+
+ override suspend fun searchReferencedResources(
+ query: SearchQuery,
+ ): List {
+ return db.useReaderConnection { transactor ->
+ transactor.usePrepared(query.query) { statement ->
+ bindArgs(statement, query.args)
+ val results = mutableListOf()
+ while (statement.step()) {
+ val searchIndex = statement.getText(0)
+ val baseId = statement.getText(1)
+ val json = statement.getText(2)
+ val resource = deserializeResource(json)
+ results.add(ReferencedResourceResult(searchIndex, baseId, resource))
+ }
+ results
+ }
+ }
+ }
+
+ override suspend fun getAllLocalChanges(): List =
+ localChangeDao.getAllLocalChanges().map { it.toLocalChange() }
+
+ override suspend fun getAllChangesForEarliestChangedResource(): List =
+ localChangeDao.getAllChangesForEarliestChangedResource().map { it.toLocalChange() }
+
+ override suspend fun getLocalChangesCount(): Int = localChangeDao.getLocalChangesCount()
+
+ override suspend fun deleteUpdates(token: LocalChangeToken) {
+ token.ids.forEach { localChangeDao.discardLocalChanges(it) }
+ }
+
+ override suspend fun deleteUpdates(resources: List) {
+ resources.forEach { res ->
+ val id = res.id ?: return@forEach
+ localChangeDao.discardLocalChanges(id, res.resourceType)
+ }
+ }
+
+ override suspend fun updateResourceAndReferences(
+ currentResourceId: String,
+ updatedResource: Resource,
+ ) {
+ // Simplified: update the resource only, skip cross-resource reference propagation
+ update(updatedResource)
+ }
+
+ override suspend fun getLocalChanges(type: ResourceType, id: String): List =
+ localChangeDao.getLocalChanges(type.name, id).map { it.toLocalChange() }
+
+ override suspend fun getLocalChanges(resourceUuid: Uuid): List =
+ localChangeDao.getLocalChangesByUuid(resourceUuid.toString()).map { it.toLocalChange() }
+
+ override suspend fun purge(type: ResourceType, ids: Set, forcePurge: Boolean) {
+ ids.forEach { id ->
+ localChangeDao.discardLocalChanges(id, type.name)
+ resourceDao.deleteResource(resourceId = id, resourceType = type.name)
+ }
+ }
+
+ override suspend fun getLocalChangeResourceReferences(
+ localChangeIds: List,
+ ): List {
+ if (localChangeIds.isEmpty()) return emptyList()
+ return localChangeDao.getReferencesForLocalChanges(localChangeIds).map {
+ LocalChangeResourceReference(
+ localChangeId = it.localChangeId,
+ resourceReferenceValue = it.resourceReferenceValue,
+ resourceReferencePath = it.resourceReferencePath,
+ )
+ }
+ }
+
+ // --- Local change helpers ---
+
+ private suspend fun createLocalChange(
+ resourceType: String,
+ resourceId: String,
+ resourceUuid: String,
+ timestamp: Long,
+ type: Int,
+ payload: String,
+ versionId: String?,
+ ) {
+ val localChangeId =
+ localChangeDao.addLocalChange(
+ LocalChangeEntity(
+ id = 0,
+ resourceType = resourceType,
+ resourceId = resourceId,
+ resourceUuid = resourceUuid,
+ timestamp = timestamp,
+ type = type,
+ payload = payload,
+ versionId = versionId,
+ ),
+ )
+ // Extract resource references from the payload for INSERT type
+ if (type == LocalChange.Type.INSERT.value && payload.isNotEmpty()) {
+ val refs = extractResourceReferences(payload)
+ if (refs.isNotEmpty()) {
+ localChangeDao.insertLocalChangeResourceReferences(
+ refs.map { (path, value) ->
+ LocalChangeResourceReferenceEntity(
+ id = 0,
+ localChangeId = localChangeId,
+ resourceReferencePath = path,
+ resourceReferenceValue = value,
+ )
+ },
+ )
+ }
+ }
+ }
+
+ /**
+ * Extracts resource references from a FHIR resource JSON string. Walks the JSON tree looking for
+ * objects with a "reference" key. Returns a list of (path, referenceValue) pairs.
+ */
+ private fun extractResourceReferences(json: String): List> {
+ val refs = mutableListOf>()
+ try {
+ val element = kotlinx.serialization.json.Json.parseToJsonElement(json)
+ collectReferences("", element, refs)
+ } catch (_: Exception) {
+ // If JSON parsing fails, return empty references
+ }
+ return refs
+ }
+
+ private fun collectReferences(
+ path: String,
+ element: kotlinx.serialization.json.JsonElement,
+ refs: MutableList>,
+ ) {
+ when (element) {
+ is kotlinx.serialization.json.JsonObject -> {
+ val refValue = element["reference"]
+ if (refValue is kotlinx.serialization.json.JsonPrimitive && refValue.isString) {
+ refs.add(path to refValue.content)
+ }
+ element.forEach { (key, value) ->
+ collectReferences("$path/$key", value, refs)
+ }
+ }
+ is kotlinx.serialization.json.JsonArray -> {
+ element.forEachIndexed { index, value ->
+ collectReferences("$path/$index", value, refs)
+ }
+ }
+ else -> {}
+ }
+ }
+
+ // --- Private helpers ---
+
+ private fun bindArgs(statement: SQLiteStatement, args: List) {
+ args.forEachIndexed { i, arg ->
+ when (arg) {
+ is String -> statement.bindText(i + 1, arg)
+ is Long -> statement.bindLong(i + 1, arg)
+ is Double -> statement.bindDouble(i + 1, arg)
+ is Int -> statement.bindLong(i + 1, arg.toLong())
+ else -> statement.bindText(i + 1, arg.toString())
+ }
+ }
+ }
+
+ /** Injects an `"id"` field into a JSON resource string if not already present. */
+ private fun ensureIdInJson(json: String, id: String): String {
+ val jsonObj = kotlinx.serialization.json.Json.parseToJsonElement(json)
+ if (jsonObj is kotlinx.serialization.json.JsonObject && "id" !in jsonObj) {
+ val mutable = jsonObj.toMutableMap()
+ mutable["id"] = kotlinx.serialization.json.JsonPrimitive(id)
+ return kotlinx.serialization.json.Json.encodeToString(
+ kotlinx.serialization.json.JsonObject.serializer(),
+ kotlinx.serialization.json.JsonObject(mutable),
+ )
+ }
+ return json
+ }
+
+ private suspend fun insertIndices(
+ resourceUuid: String,
+ resourceType: String,
+ indices: ResourceIndices,
+ ) {
+ indices.stringIndices.forEach {
+ resourceDao.insertStringIndex(
+ StringIndexEntity(
+ id = 0,
+ resourceUuid = resourceUuid,
+ resourceType = resourceType,
+ index = it,
+ ),
+ )
+ }
+ indices.referenceIndices.forEach {
+ resourceDao.insertReferenceIndex(
+ ReferenceIndexEntity(
+ id = 0,
+ resourceUuid = resourceUuid,
+ resourceType = resourceType,
+ index = it,
+ ),
+ )
+ }
+ indices.tokenIndices.forEach {
+ resourceDao.insertTokenIndex(
+ TokenIndexEntity(
+ id = 0,
+ resourceUuid = resourceUuid,
+ resourceType = resourceType,
+ index = it,
+ ),
+ )
+ }
+ indices.quantityIndices.forEach {
+ resourceDao.insertQuantityIndex(
+ QuantityIndexEntity(
+ id = 0,
+ resourceUuid = resourceUuid,
+ resourceType = resourceType,
+ index = it,
+ ),
+ )
+ }
+ indices.uriIndices.forEach {
+ resourceDao.insertUriIndex(
+ UriIndexEntity(
+ id = 0,
+ resourceUuid = resourceUuid,
+ resourceType = resourceType,
+ index = it,
+ ),
+ )
+ }
+ indices.dateIndices.forEach {
+ resourceDao.insertDateIndex(
+ DateIndexEntity(
+ id = 0,
+ resourceUuid = resourceUuid,
+ resourceType = resourceType,
+ index = it,
+ ),
+ )
+ }
+ indices.dateTimeIndices.forEach {
+ resourceDao.insertDateTimeIndex(
+ DateTimeIndexEntity(
+ id = 0,
+ resourceUuid = resourceUuid,
+ resourceType = resourceType,
+ index = it,
+ ),
+ )
+ }
+ indices.numberIndices.forEach {
+ resourceDao.insertNumberIndex(
+ NumberIndexEntity(
+ id = 0,
+ resourceUuid = resourceUuid,
+ resourceType = resourceType,
+ index = it,
+ ),
+ )
+ }
+ indices.positionIndices.forEach {
+ resourceDao.insertPositionIndex(
+ PositionIndexEntity(
+ id = 0,
+ resourceUuid = resourceUuid,
+ resourceType = resourceType,
+ index = it,
+ ),
+ )
+ }
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/DbTypeConverters.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/DbTypeConverters.kt
new file mode 100644
index 0000000000..cb84dc61e2
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/DbTypeConverters.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl
+
+import androidx.room.TypeConverter
+import com.ionspin.kotlin.bignum.decimal.BigDecimal
+
+internal object DbTypeConverters {
+ @TypeConverter fun bigDecimalToDouble(value: BigDecimal): Double = value.doubleValue(false)
+
+ @TypeConverter fun doubleToBigDecimal(value: Double): BigDecimal = BigDecimal.fromDouble(value)
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/JsonDiff.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/JsonDiff.kt
new file mode 100644
index 0000000000..c6b96c6100
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/JsonDiff.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+
+/**
+ * Generates RFC 6902 JSON patches by comparing two JSON strings. This is a KMP-compatible
+ * replacement for the JVM-only Jackson + jsonpatch library used in the original engine module.
+ *
+ * Filters out `/meta` and `/text` paths, matching the original engine behavior.
+ */
+internal object JsonDiff {
+
+ private val ignorePaths = setOf("/meta", "/text")
+
+ fun diff(source: String, target: String): String {
+ val sourceElement = Json.parseToJsonElement(source)
+ val targetElement = Json.parseToJsonElement(target)
+ val ops = mutableListOf()
+ generateDiff("", sourceElement, targetElement, ops)
+ val filtered = ops.filter { op ->
+ val path = (op["path"] as? JsonPrimitive)?.content ?: ""
+ ignorePaths.none { path.startsWith(it) }
+ }
+ return Json.encodeToString(JsonArray.serializer(), JsonArray(filtered))
+ }
+
+ private fun generateDiff(
+ path: String,
+ source: JsonElement,
+ target: JsonElement,
+ ops: MutableList,
+ ) {
+ if (source == target) return
+
+ when {
+ source is JsonObject && target is JsonObject -> diffObjects(path, source, target, ops)
+ source is JsonArray && target is JsonArray -> diffArrays(path, source, target, ops)
+ else -> ops.add(replaceOp(path, target))
+ }
+ }
+
+ private fun diffObjects(
+ path: String,
+ source: JsonObject,
+ target: JsonObject,
+ ops: MutableList,
+ ) {
+ // Removed keys
+ for (key in source.keys) {
+ if (key !in target) {
+ ops.add(removeOp("$path/${escapeJsonPointer(key)}"))
+ }
+ }
+ // Added keys
+ for (key in target.keys) {
+ if (key !in source) {
+ ops.add(addOp("$path/${escapeJsonPointer(key)}", target[key]!!))
+ }
+ }
+ // Changed keys
+ for (key in source.keys) {
+ if (key in target) {
+ generateDiff("$path/${escapeJsonPointer(key)}", source[key]!!, target[key]!!, ops)
+ }
+ }
+ }
+
+ private fun diffArrays(
+ path: String,
+ source: JsonArray,
+ target: JsonArray,
+ ops: MutableList,
+ ) {
+ // Simple approach: replace entire array if different rather than computing element-level diffs.
+ // This matches practical behavior for FHIR resources where array elements don't have stable IDs.
+ ops.add(replaceOp(path, target))
+ }
+
+ private fun replaceOp(path: String, value: JsonElement) = buildJsonObject {
+ put("op", "replace")
+ put("path", path)
+ put("value", value)
+ }
+
+ private fun addOp(path: String, value: JsonElement) = buildJsonObject {
+ put("op", "add")
+ put("path", path)
+ put("value", value)
+ }
+
+ private fun removeOp(path: String) = buildJsonObject {
+ put("op", "remove")
+ put("path", path)
+ }
+
+ private fun escapeJsonPointer(key: String) = key.replace("~", "~0").replace("/", "~1")
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/ResourceDatabase.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/ResourceDatabase.kt
new file mode 100644
index 0000000000..f7d5ab8cb4
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/ResourceDatabase.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl
+
+import androidx.room.ConstructedBy
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.RoomDatabaseConstructor
+import androidx.room.TypeConverters
+import com.google.android.fhir.db.impl.dao.LocalChangeDao
+import com.google.android.fhir.db.impl.dao.ResourceDao
+import com.google.android.fhir.db.impl.entities.DateIndexEntity
+import com.google.android.fhir.db.impl.entities.LocalChangeEntity
+import com.google.android.fhir.db.impl.entities.LocalChangeResourceReferenceEntity
+import com.google.android.fhir.db.impl.entities.DateTimeIndexEntity
+import com.google.android.fhir.db.impl.entities.NumberIndexEntity
+import com.google.android.fhir.db.impl.entities.PositionIndexEntity
+import com.google.android.fhir.db.impl.entities.QuantityIndexEntity
+import com.google.android.fhir.db.impl.entities.ReferenceIndexEntity
+import com.google.android.fhir.db.impl.entities.ResourceEntity
+import com.google.android.fhir.db.impl.entities.StringIndexEntity
+import com.google.android.fhir.db.impl.entities.TokenIndexEntity
+import com.google.android.fhir.db.impl.entities.UriIndexEntity
+
+@Database(
+ entities =
+ [
+ ResourceEntity::class,
+ StringIndexEntity::class,
+ ReferenceIndexEntity::class,
+ TokenIndexEntity::class,
+ QuantityIndexEntity::class,
+ UriIndexEntity::class,
+ DateIndexEntity::class,
+ DateTimeIndexEntity::class,
+ NumberIndexEntity::class,
+ PositionIndexEntity::class,
+ LocalChangeEntity::class,
+ LocalChangeResourceReferenceEntity::class,
+ ],
+ version = 2,
+ exportSchema = true,
+)
+@TypeConverters(DbTypeConverters::class)
+@ConstructedBy(ResourceDatabaseConstructor::class)
+internal abstract class ResourceDatabase : RoomDatabase() {
+ abstract fun resourceDao(): ResourceDao
+
+ abstract fun localChangeDao(): LocalChangeDao
+}
+
+@Suppress("NO_ACTUAL_FOR_EXPECT")
+internal expect object ResourceDatabaseConstructor : RoomDatabaseConstructor
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/ResourceSerializer.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/ResourceSerializer.kt
new file mode 100644
index 0000000000..65987e947a
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/ResourceSerializer.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl
+
+import com.google.fhir.model.r4.FhirR4Json
+import com.google.fhir.model.r4.Resource
+
+/**
+ * Singleton FHIR JSON parser for serializing/deserializing resources. Replaces HAPI's
+ * `FhirContext.forR4Cached().newJsonParser()`. Thread-safe and reusable.
+ */
+internal val fhirJsonParser = FhirR4Json()
+
+/** Serializes a FHIR [Resource] to a JSON string for database storage. */
+internal fun serializeResource(resource: Resource): String = fhirJsonParser.encodeToString(resource)
+
+/**
+ * Deserializes a JSON string back to a FHIR [Resource]. Polymorphic deserialization is handled
+ * automatically via the `"resourceType"` JSON field.
+ */
+internal fun deserializeResource(json: String): Resource = fhirJsonParser.decodeFromString(json)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt
new file mode 100644
index 0000000000..a4e421a238
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.google.android.fhir.db.impl.entities.LocalChangeEntity
+import com.google.android.fhir.db.impl.entities.LocalChangeResourceReferenceEntity
+
+@Dao
+internal interface LocalChangeDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun addLocalChange(localChangeEntity: LocalChangeEntity): Long
+
+ @Query(
+ """
+ SELECT *
+ FROM LocalChangeEntity
+ ORDER BY timestamp ASC
+ """,
+ )
+ suspend fun getAllLocalChanges(): List
+
+ @Query(
+ """
+ SELECT *
+ FROM LocalChangeEntity
+ WHERE resourceUuid = (
+ SELECT resourceUuid
+ FROM LocalChangeEntity
+ ORDER BY timestamp ASC
+ LIMIT 1)
+ ORDER BY timestamp ASC
+ """,
+ )
+ suspend fun getAllChangesForEarliestChangedResource(): List
+
+ @Query(
+ """
+ SELECT COUNT(*)
+ FROM LocalChangeEntity
+ """,
+ )
+ suspend fun getLocalChangesCount(): Int
+
+ @Query(
+ """
+ DELETE FROM LocalChangeEntity
+ WHERE id = :id
+ """,
+ )
+ suspend fun discardLocalChanges(id: Long)
+
+ @Query(
+ """
+ DELETE FROM LocalChangeEntity
+ WHERE resourceId = :resourceId AND resourceType = :resourceType
+ """,
+ )
+ suspend fun discardLocalChanges(resourceId: String, resourceType: String)
+
+ @Query(
+ """
+ SELECT *
+ FROM LocalChangeEntity
+ WHERE resourceType = :resourceType AND resourceId = :resourceId
+ ORDER BY timestamp ASC
+ """,
+ )
+ suspend fun getLocalChanges(resourceType: String, resourceId: String): List
+
+ @Query(
+ """
+ SELECT *
+ FROM LocalChangeEntity
+ WHERE resourceUuid = :resourceUuid
+ ORDER BY timestamp ASC
+ """,
+ )
+ suspend fun getLocalChangesByUuid(resourceUuid: String): List
+
+ @Query(
+ """
+ SELECT type
+ FROM LocalChangeEntity
+ WHERE resourceId = :resourceId AND resourceType = :resourceType
+ ORDER BY id ASC
+ LIMIT 1
+ """,
+ )
+ suspend fun lastChangeType(resourceId: String, resourceType: String): Int?
+
+ @Query(
+ """
+ SELECT COUNT(type)
+ FROM LocalChangeEntity
+ WHERE resourceId = :resourceId AND resourceType = :resourceType
+ LIMIT 1
+ """,
+ )
+ suspend fun countLastChange(resourceId: String, resourceType: String): Int
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertLocalChangeResourceReferences(
+ refs: List,
+ )
+
+ @Query(
+ """
+ SELECT *
+ FROM LocalChangeResourceReferenceEntity
+ WHERE localChangeId IN (:localChangeIds)
+ """,
+ )
+ suspend fun getReferencesForLocalChanges(
+ localChangeIds: List,
+ ): List
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/dao/ResourceDao.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/dao/ResourceDao.kt
new file mode 100644
index 0000000000..40c994ad40
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/dao/ResourceDao.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.google.android.fhir.db.impl.entities.DateIndexEntity
+import com.google.android.fhir.db.impl.entities.DateTimeIndexEntity
+import com.google.android.fhir.db.impl.entities.NumberIndexEntity
+import com.google.android.fhir.db.impl.entities.PositionIndexEntity
+import com.google.android.fhir.db.impl.entities.QuantityIndexEntity
+import com.google.android.fhir.db.impl.entities.ReferenceIndexEntity
+import com.google.android.fhir.db.impl.entities.ResourceEntity
+import com.google.android.fhir.db.impl.entities.StringIndexEntity
+import com.google.android.fhir.db.impl.entities.TokenIndexEntity
+import com.google.android.fhir.db.impl.entities.UriIndexEntity
+
+@Dao
+internal interface ResourceDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertResource(resource: ResourceEntity)
+
+ @Query(
+ """
+ SELECT serializedResource
+ FROM ResourceEntity
+ WHERE resourceId = :resourceId AND resourceType = :resourceType
+ """,
+ )
+ suspend fun getResource(resourceId: String, resourceType: String): String?
+
+ @Query(
+ """
+ SELECT *
+ FROM ResourceEntity
+ WHERE resourceId = :resourceId AND resourceType = :resourceType
+ """,
+ )
+ suspend fun getResourceEntity(resourceId: String, resourceType: String): ResourceEntity?
+
+ @Query(
+ """
+ DELETE FROM ResourceEntity
+ WHERE resourceId = :resourceId AND resourceType = :resourceType
+ """,
+ )
+ suspend fun deleteResource(resourceId: String, resourceType: String): Int
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertStringIndex(entity: StringIndexEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertReferenceIndex(entity: ReferenceIndexEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertTokenIndex(entity: TokenIndexEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertQuantityIndex(entity: QuantityIndexEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertUriIndex(entity: UriIndexEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertDateIndex(entity: DateIndexEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertDateTimeIndex(entity: DateTimeIndexEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertNumberIndex(entity: NumberIndexEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertPositionIndex(entity: PositionIndexEntity)
+
+ @Query("DELETE FROM ResourceEntity")
+ suspend fun deleteAllResources()
+
+ @Query(
+ """
+ UPDATE ResourceEntity
+ SET versionId = :versionId, lastUpdatedRemote = :lastUpdated
+ WHERE resourceId = :resourceId AND resourceType = :resourceType
+ """,
+ )
+ suspend fun updateVersionIdAndLastUpdated(
+ resourceId: String,
+ resourceType: String,
+ versionId: String?,
+ lastUpdated: Long?,
+ )
+
+ @Query(
+ """
+ UPDATE ResourceEntity
+ SET resourceId = :newResourceId, versionId = :versionId, lastUpdatedRemote = :lastUpdated
+ WHERE resourceId = :oldResourceId AND resourceType = :resourceType
+ """,
+ )
+ suspend fun updateResourceIdAndMeta(
+ oldResourceId: String,
+ newResourceId: String,
+ resourceType: String,
+ versionId: String?,
+ lastUpdated: Long?,
+ )
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/DateIndexEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/DateIndexEntity.kt
new file mode 100644
index 0000000000..79cd62f17d
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/DateIndexEntity.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.google.android.fhir.index.entities.DateIndex
+
+@Entity(
+ indices =
+ [
+ Index(value = ["resourceType", "index_name", "index_from", "index_to"]),
+ Index(value = ["resourceUuid", "index_name", "index_from", "index_to"]),
+ ],
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = ResourceEntity::class,
+ parentColumns = ["resourceUuid"],
+ childColumns = ["resourceUuid"],
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.NO_ACTION,
+ deferred = true,
+ ),
+ ],
+)
+internal data class DateIndexEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val resourceUuid: String,
+ val resourceType: String,
+ @Embedded(prefix = "index_") val index: DateIndex,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/DateTimeIndexEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/DateTimeIndexEntity.kt
new file mode 100644
index 0000000000..a2dc86ab55
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/DateTimeIndexEntity.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.google.android.fhir.index.entities.DateTimeIndex
+
+@Entity(
+ indices =
+ [
+ Index(value = ["resourceType", "index_name", "index_from", "index_to"]),
+ Index(value = ["resourceUuid", "index_name", "index_from", "index_to"]),
+ ],
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = ResourceEntity::class,
+ parentColumns = ["resourceUuid"],
+ childColumns = ["resourceUuid"],
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.NO_ACTION,
+ deferred = true,
+ ),
+ ],
+)
+internal data class DateTimeIndexEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val resourceUuid: String,
+ val resourceType: String,
+ @Embedded(prefix = "index_") val index: DateTimeIndex,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt
new file mode 100644
index 0000000000..2c0a97bd39
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.google.android.fhir.LocalChange
+import com.google.android.fhir.LocalChangeToken
+import kotlin.time.Instant
+
+@Entity(
+ indices =
+ [
+ Index(value = ["resourceType", "resourceId"]),
+ Index(value = ["resourceUuid"]),
+ ],
+)
+internal data class LocalChangeEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val resourceType: String,
+ val resourceId: String,
+ val resourceUuid: String,
+ val timestamp: Long,
+ val type: Int,
+ val payload: String,
+ val versionId: String? = null,
+) {
+ fun toLocalChange() =
+ LocalChange(
+ resourceType = resourceType,
+ resourceId = resourceId,
+ versionId = versionId,
+ timestamp = Instant.fromEpochMilliseconds(timestamp),
+ type = LocalChange.Type.from(type),
+ payload = payload,
+ token = LocalChangeToken(listOf(id)),
+ )
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt
new file mode 100644
index 0000000000..20c2152927
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ indices =
+ [
+ Index(value = ["resourceReferenceValue"]),
+ Index(value = ["localChangeId"]),
+ ],
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = LocalChangeEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["localChangeId"],
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.NO_ACTION,
+ deferred = true,
+ ),
+ ],
+)
+internal data class LocalChangeResourceReferenceEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val localChangeId: Long,
+ val resourceReferenceValue: String,
+ val resourceReferencePath: String?,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/NumberIndexEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/NumberIndexEntity.kt
new file mode 100644
index 0000000000..b859659d4d
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/NumberIndexEntity.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.google.android.fhir.index.entities.NumberIndex
+
+@Entity(
+ indices =
+ [
+ Index(value = ["resourceType", "index_name", "index_value"]),
+ Index(value = ["resourceUuid", "index_name", "index_value"]),
+ ],
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = ResourceEntity::class,
+ parentColumns = ["resourceUuid"],
+ childColumns = ["resourceUuid"],
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.NO_ACTION,
+ deferred = true,
+ ),
+ ],
+)
+internal data class NumberIndexEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val resourceUuid: String,
+ val resourceType: String,
+ @Embedded(prefix = "index_") val index: NumberIndex,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/PositionIndexEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/PositionIndexEntity.kt
new file mode 100644
index 0000000000..0f54a68039
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/PositionIndexEntity.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.google.android.fhir.index.entities.PositionIndex
+
+@Entity(
+ indices = [Index(value = ["resourceUuid"])],
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = ResourceEntity::class,
+ parentColumns = ["resourceUuid"],
+ childColumns = ["resourceUuid"],
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.NO_ACTION,
+ deferred = true,
+ ),
+ ],
+)
+internal data class PositionIndexEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val resourceUuid: String,
+ val resourceType: String,
+ @Embedded(prefix = "index_") val index: PositionIndex,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/QuantityIndexEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/QuantityIndexEntity.kt
new file mode 100644
index 0000000000..be0fb664ed
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/QuantityIndexEntity.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.google.android.fhir.index.entities.QuantityIndex
+
+@Entity(
+ indices =
+ [
+ Index(
+ value =
+ [
+ "resourceType",
+ "index_name",
+ "index_system",
+ "index_code",
+ "index_value",
+ ],
+ ),
+ Index(
+ value =
+ [
+ "resourceUuid",
+ "index_name",
+ "index_system",
+ "index_code",
+ "index_value",
+ ],
+ ),
+ ],
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = ResourceEntity::class,
+ parentColumns = ["resourceUuid"],
+ childColumns = ["resourceUuid"],
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.NO_ACTION,
+ deferred = true,
+ ),
+ ],
+)
+internal data class QuantityIndexEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val resourceUuid: String,
+ val resourceType: String,
+ @Embedded(prefix = "index_") val index: QuantityIndex,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/ReferenceIndexEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/ReferenceIndexEntity.kt
new file mode 100644
index 0000000000..c1a4917298
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/ReferenceIndexEntity.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.google.android.fhir.index.entities.ReferenceIndex
+
+@Entity(
+ indices =
+ [
+ Index(value = ["resourceType", "index_name", "index_value"]),
+ Index(value = ["resourceUuid", "index_name", "index_value"]),
+ ],
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = ResourceEntity::class,
+ parentColumns = ["resourceUuid"],
+ childColumns = ["resourceUuid"],
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.NO_ACTION,
+ deferred = true,
+ ),
+ ],
+)
+internal data class ReferenceIndexEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val resourceUuid: String,
+ val resourceType: String,
+ @Embedded(prefix = "index_") val index: ReferenceIndex,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/ResourceEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/ResourceEntity.kt
new file mode 100644
index 0000000000..4bf294750d
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/ResourceEntity.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ indices =
+ [
+ Index(value = ["resourceUuid"], unique = true),
+ Index(value = ["resourceType", "resourceId"], unique = true),
+ ],
+)
+internal data class ResourceEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val resourceUuid: String,
+ val resourceType: String,
+ val resourceId: String,
+ val serializedResource: String,
+ val versionId: String?,
+ val lastUpdatedRemote: Long?,
+ val lastUpdatedLocal: Long?,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/StringIndexEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/StringIndexEntity.kt
new file mode 100644
index 0000000000..d1fdb2060f
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/StringIndexEntity.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.google.android.fhir.index.entities.StringIndex
+
+@Entity(
+ indices =
+ [
+ Index(value = ["resourceType", "index_name", "index_value"]),
+ Index(value = ["resourceUuid", "index_name", "index_value"]),
+ ],
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = ResourceEntity::class,
+ parentColumns = ["resourceUuid"],
+ childColumns = ["resourceUuid"],
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.NO_ACTION,
+ deferred = true,
+ ),
+ ],
+)
+internal data class StringIndexEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val resourceUuid: String,
+ val resourceType: String,
+ @Embedded(prefix = "index_") val index: StringIndex,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/TokenIndexEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/TokenIndexEntity.kt
new file mode 100644
index 0000000000..68ce439beb
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/TokenIndexEntity.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.google.android.fhir.index.entities.TokenIndex
+
+@Entity(
+ indices =
+ [
+ Index(value = ["resourceType", "index_name", "index_system", "index_value"]),
+ Index(value = ["resourceUuid", "index_name", "index_system", "index_value"]),
+ ],
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = ResourceEntity::class,
+ parentColumns = ["resourceUuid"],
+ childColumns = ["resourceUuid"],
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.NO_ACTION,
+ deferred = true,
+ ),
+ ],
+)
+internal data class TokenIndexEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val resourceUuid: String,
+ val resourceType: String,
+ @Embedded(prefix = "index_") val index: TokenIndex,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/UriIndexEntity.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/UriIndexEntity.kt
new file mode 100644
index 0000000000..9afd532b63
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/impl/entities/UriIndexEntity.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.db.impl.entities
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.google.android.fhir.index.entities.UriIndex
+
+@Entity(
+ indices =
+ [
+ Index(value = ["resourceType", "index_value"]),
+ Index(value = ["resourceUuid", "index_value"]),
+ ],
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = ResourceEntity::class,
+ parentColumns = ["resourceUuid"],
+ childColumns = ["resourceUuid"],
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.NO_ACTION,
+ deferred = true,
+ ),
+ ],
+)
+internal data class UriIndexEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ val resourceUuid: String,
+ val resourceType: String,
+ @Embedded(prefix = "index_") val index: UriIndex,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/engine/EngineKmp.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/engine/EngineKmp.kt
new file mode 100644
index 0000000000..082d9edde6
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/engine/EngineKmp.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.engine
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/impl/FhirEngineImpl.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/impl/FhirEngineImpl.kt
new file mode 100644
index 0000000000..2a07193809
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/impl/FhirEngineImpl.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.impl
+
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.LocalChange
+import com.google.android.fhir.SearchResult
+import com.google.android.fhir.db.Database
+import com.google.android.fhir.db.LocalChangeResourceReference
+import com.google.android.fhir.search.Search
+import com.google.android.fhir.search.count
+import com.google.android.fhir.search.execute
+import com.google.android.fhir.sync.ConflictResolver
+import com.google.android.fhir.sync.upload.LocalChangeFetcherFactory
+import com.google.android.fhir.sync.upload.ResourceConsolidatorFactory
+import com.google.android.fhir.sync.upload.SyncUploadProgress
+import com.google.android.fhir.sync.upload.UploadRequestResult
+import com.google.android.fhir.sync.upload.UploadStrategy
+import com.google.fhir.model.r4.Resource
+import com.google.fhir.model.r4.terminologies.ResourceType
+import kotlin.time.Instant
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.onEach
+
+/**
+ * Implementation of [FhirEngine] backed by a [Database]. Provides the minimum operations needed for
+ * the vertical slice: create, get, syncDownload, and clearDatabase.
+ */
+internal class FhirEngineImpl(private val database: Database) : FhirEngine {
+
+ override suspend fun create(vararg resource: Resource): List {
+ return database.insert(*resource)
+ }
+
+ override suspend fun get(type: ResourceType, id: String): Resource {
+ return database.select(type, id)
+ }
+
+ override suspend fun update(vararg resource: Resource) {
+ database.update(*resource)
+ }
+
+ override suspend fun delete(type: ResourceType, id: String) {
+ database.delete(type, id)
+ }
+
+ override suspend fun search(search: Search): List> {
+ return search.execute(database)
+ }
+
+ override suspend fun syncUpload(
+ uploadStrategy: UploadStrategy,
+ upload:
+ (suspend (List, List) -> Flow<
+ UploadRequestResult,
+ >),
+ ): Flow = flow {
+ val resourceConsolidator =
+ ResourceConsolidatorFactory.byHttpVerb(uploadStrategy.requestGeneratorMode, database)
+ val localChangeFetcher =
+ LocalChangeFetcherFactory.byMode(uploadStrategy.localChangesFetchMode, database)
+
+ emit(
+ SyncUploadProgress(
+ remaining = localChangeFetcher.total,
+ initialTotal = localChangeFetcher.total,
+ ),
+ )
+
+ while (localChangeFetcher.hasNext()) {
+ val localChanges = localChangeFetcher.next()
+ val localChangeReferences =
+ database.getLocalChangeResourceReferences(localChanges.flatMap { it.token.ids })
+ val uploadRequestResult =
+ upload(localChanges, localChangeReferences)
+ .onEach { result ->
+ resourceConsolidator.consolidate(result)
+ val newProgress =
+ when (result) {
+ is UploadRequestResult.Success -> localChangeFetcher.getProgress()
+ is UploadRequestResult.Failure ->
+ localChangeFetcher.getProgress().copy(uploadError = result.uploadError)
+ }
+ emit(newProgress)
+ }
+ .firstOrNull { it is UploadRequestResult.Failure }
+
+ if (uploadRequestResult is UploadRequestResult.Failure) break
+ }
+ }
+
+ override suspend fun syncDownload(
+ conflictResolver: ConflictResolver,
+ download: suspend () -> Flow>,
+ ) {
+ download().collect { resources ->
+ database.withTransaction { database.insertSyncedResources(resources) }
+ }
+ }
+
+ override suspend fun count(search: Search): Long {
+ return search.count(database)
+ }
+
+ override suspend fun getLastSyncTimeStamp(): Instant? {
+ // TODO: implement with FhirDataStore
+ return null
+ }
+
+ override suspend fun clearDatabase() {
+ database.clearDatabase()
+ }
+
+ override suspend fun getLocalChanges(type: ResourceType, id: String): List {
+ return database.getLocalChanges(type, id)
+ }
+
+ override suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean) {
+ database.purge(type, setOf(id), forcePurge)
+ }
+
+ override suspend fun purge(type: ResourceType, ids: Set, forcePurge: Boolean) {
+ database.purge(type, ids, forcePurge)
+ }
+
+ override suspend fun withTransaction(block: suspend FhirEngine.() -> Unit) {
+ database.withTransaction { block() }
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndexer.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndexer.kt
new file mode 100644
index 0000000000..b38400075a
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndexer.kt
@@ -0,0 +1,486 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index
+
+import com.google.android.fhir.UcumValue
+import com.google.android.fhir.UnitConverter
+import com.google.android.fhir.index.entities.DateIndex
+import com.google.android.fhir.index.entities.DateTimeIndex
+import com.google.android.fhir.index.entities.NumberIndex
+import com.google.android.fhir.index.entities.PositionIndex
+import com.google.android.fhir.index.entities.QuantityIndex
+import com.google.android.fhir.index.entities.ReferenceIndex
+import com.google.android.fhir.index.entities.StringIndex
+import com.google.android.fhir.index.entities.TokenIndex
+import com.google.android.fhir.index.entities.UriIndex
+import com.google.android.fhir.resourceType
+import com.google.android.fhir.search.LAST_UPDATED
+import com.google.android.fhir.search.LOCAL_LAST_UPDATED
+import com.google.android.fhir.ucumUrl
+import com.google.fhir.fhirpath.FhirPathEngine
+import com.google.fhir.model.r4.Address
+import com.google.fhir.model.r4.Canonical
+import com.google.fhir.model.r4.CodeableConcept
+import com.google.fhir.model.r4.Coding
+import com.google.fhir.model.r4.DateTime
+import com.google.fhir.model.r4.FhirDate
+import com.google.fhir.model.r4.FhirDateTime
+import com.google.fhir.model.r4.HumanName
+import com.google.fhir.model.r4.Id
+import com.google.fhir.model.r4.Identifier
+import com.google.fhir.model.r4.Location
+import com.google.fhir.model.r4.Money
+import com.google.fhir.model.r4.Period
+import com.google.fhir.model.r4.Quantity
+import com.google.fhir.model.r4.Reference
+import com.google.fhir.model.r4.Resource
+import com.google.fhir.model.r4.Timing
+import com.google.fhir.model.r4.Uri
+import com.google.fhir.model.r4.terminologies.ResourceType
+import com.ionspin.kotlin.bignum.decimal.BigDecimal
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.plus
+import kotlinx.datetime.toInstant
+
+/**
+ * Indexes a FHIR resource according to the
+ * [search parameters](https://www.hl7.org/fhir/searchparameter-registry.html).
+ */
+internal class ResourceIndexer(
+ private val searchParamDefinitionsProvider: SearchParamDefinitionsProvider,
+) {
+ private val fhirPathEngine = FhirPathEngine.forR4()
+
+ fun index(resource: R) = extractIndexValues(resource)
+
+ private fun extractIndexValues(resource: R): ResourceIndices {
+ val resourceTypeName = resource.resourceType
+ val resourceTypeEnum = ResourceType.fromCode(resourceTypeName)
+ val indexBuilder =
+ ResourceIndices.Builder(resourceTypeEnum, resource.id ?: error("Resource must have an id"))
+ searchParamDefinitionsProvider
+ .get(resource)
+ .map { it to fhirPathEngine.evaluateExpression(it.path, resource).toList() }
+ .flatMap { pair -> pair.second.map { pair.first to it } }
+ .forEach { pair ->
+ val (searchParam, value) = pair
+ when (pair.first.type) {
+ SearchParamType.NUMBER ->
+ numberIndex(searchParam, value)?.also { indexBuilder.addNumberIndex(it) }
+ SearchParamType.DATE ->
+ when (value) {
+ is com.google.fhir.model.r4.Date ->
+ dateIndex(searchParam, value)?.also { indexBuilder.addDateIndex(it) }
+ else -> dateTimeIndex(searchParam, value)?.also { indexBuilder.addDateTimeIndex(it) }
+ }
+ SearchParamType.STRING ->
+ stringIndex(searchParam, value)?.also { indexBuilder.addStringIndex(it) }
+ SearchParamType.TOKEN ->
+ tokenIndex(searchParam, value).forEach { indexBuilder.addTokenIndex(it) }
+ SearchParamType.REFERENCE ->
+ referenceIndex(searchParam, value)?.also { indexBuilder.addReferenceIndex(it) }
+ SearchParamType.QUANTITY ->
+ quantityIndex(searchParam, value).forEach { indexBuilder.addQuantityIndex(it) }
+ SearchParamType.URI -> uriIndex(searchParam, value)?.also { indexBuilder.addUriIndex(it) }
+ SearchParamType.SPECIAL -> specialIndex(value)?.also { indexBuilder.addPositionIndex(it) }
+ // TODO: Handle composite type https://github.com/google/android-fhir/issues/292.
+ else -> Unit
+ }
+ }
+
+ return indexBuilder.build()
+ }
+
+ companion object {
+
+ private fun numberIndex(searchParam: SearchParamDefinition, value: Any): NumberIndex? =
+ when (value) {
+ is com.google.fhir.model.r4.Integer ->
+ value.value?.let {
+ NumberIndex(searchParam.name, searchParam.path, BigDecimal.fromInt(it))
+ }
+ is com.google.fhir.model.r4.Decimal ->
+ value.value?.let { NumberIndex(searchParam.name, searchParam.path, it) }
+ else -> null
+ }
+
+ private fun dateIndex(
+ searchParam: SearchParamDefinition,
+ value: com.google.fhir.model.r4.Date,
+ ): DateIndex? {
+ val fhirDate = value.value ?: return null
+ val (fromEpochDay, toEpochDay) = fhirDateToEpochDayRange(fhirDate)
+ return DateIndex(searchParam.name, searchParam.path, fromEpochDay, toEpochDay)
+ }
+
+ private fun dateTimeIndex(searchParam: SearchParamDefinition, value: Any): DateTimeIndex? {
+ return when (value) {
+ is DateTime -> {
+ val fhirDateTime = value.value ?: return null
+ val (fromMs, toMs) = fhirDateTimeToEpochMillisRange(fhirDateTime)
+ DateTimeIndex(searchParam.name, searchParam.path, fromMs, toMs)
+ }
+ // No need to add precision because an instant is meant to have zero width
+ is com.google.fhir.model.r4.Instant -> {
+ val fhirDateTime = value.value ?: return null
+ val ms = fhirDateTimeToEpochMillis(fhirDateTime)
+ DateTimeIndex(searchParam.name, searchParam.path, ms, ms)
+ }
+ is Period -> {
+ val startMs = value.start?.value?.let { fhirDateTimeToEpochMillis(it) } ?: 0L
+ val endMs = value.end?.value?.let { fhirDateTimeToEndEpochMillis(it) } ?: Long.MAX_VALUE
+ DateTimeIndex(searchParam.name, searchParam.path, startMs, endMs)
+ }
+ is Timing -> {
+ if (value.event.isNotEmpty()) {
+ val events = value.event.mapNotNull { it.value }
+ if (events.isEmpty()) return null
+ DateTimeIndex(
+ searchParam.name,
+ searchParam.path,
+ events.minOf { fhirDateTimeToEpochMillis(it) },
+ events.maxOf { fhirDateTimeToEndEpochMillis(it) },
+ )
+ } else {
+ null
+ }
+ }
+ is com.google.fhir.model.r4.String -> {
+ // e.g. CarePlan may have schedule as a string value
+ try {
+ val fhirDateTime = FhirDateTime.fromString(value.value)
+ if (fhirDateTime != null) {
+ val (fromMs, toMs) = fhirDateTimeToEpochMillisRange(fhirDateTime)
+ DateTimeIndex(searchParam.name, searchParam.path, fromMs, toMs)
+ } else {
+ null
+ }
+ } catch (_: IllegalStateException) {
+ null
+ }
+ }
+ else -> null
+ }
+ }
+
+ /**
+ * Extension to express [HumanName] as a separated string using [separator]. See
+ * https://www.hl7.org/fhir/patient.html#search
+ */
+ private fun HumanName.asString(separator: CharSequence = " "): kotlin.String {
+ return (prefix.mapNotNull { it.value } +
+ given.mapNotNull { it.value } +
+ listOfNotNull(family?.value) +
+ suffix.mapNotNull { it.value } +
+ listOfNotNull(text?.value))
+ .filter { it.isNotBlank() }
+ .joinToString(separator)
+ }
+
+ /**
+ * Extension to express [Address] as a string using [separator]. See
+ * https://www.hl7.org/fhir/patient.html#search
+ */
+ private fun Address.asString(separator: CharSequence = ", "): kotlin.String {
+ return (line.mapNotNull { it.value } +
+ listOfNotNull(
+ city?.value,
+ district?.value,
+ state?.value,
+ country?.value,
+ postalCode?.value,
+ text?.value,
+ ))
+ .filter { it.isNotBlank() }
+ .joinToString(separator)
+ }
+
+ private fun stringIndex(searchParam: SearchParamDefinition, value: Any): StringIndex? {
+ val stringValue =
+ when (value) {
+ is HumanName -> value.asString()
+ is Address -> value.asString()
+ is com.google.fhir.model.r4.String -> value.value
+ else -> value.toString()
+ }
+ return if (!stringValue.isNullOrEmpty()) {
+ StringIndex(searchParam.name, searchParam.path, stringValue)
+ } else {
+ null
+ }
+ }
+
+ private fun tokenIndex(searchParam: SearchParamDefinition, value: Any): List =
+ when (value) {
+ is com.google.fhir.model.r4.Boolean ->
+ value.value?.let {
+ listOf(
+ TokenIndex(searchParam.name, searchParam.path, system = null, it.toString()),
+ )
+ }
+ ?: emptyList()
+ is Identifier -> {
+ val identifierValue = value.value?.value
+ if (identifierValue != null) {
+ listOf(
+ TokenIndex(
+ searchParam.name,
+ searchParam.path,
+ value.system?.value,
+ identifierValue,
+ ),
+ )
+ } else {
+ emptyList()
+ }
+ }
+ is CodeableConcept -> {
+ value.coding.mapNotNull { coding ->
+ val codeValue = coding.code?.value
+ if (codeValue != null && codeValue.isNotEmpty()) {
+ TokenIndex(
+ searchParam.name,
+ searchParam.path,
+ coding.system?.value ?: "",
+ codeValue,
+ )
+ } else {
+ null
+ }
+ }
+ }
+ is Coding -> {
+ val code = value.code?.value
+ if (code != null) {
+ listOf(
+ TokenIndex(searchParam.name, searchParam.path, value.system?.value ?: "", code),
+ )
+ } else {
+ emptyList()
+ }
+ }
+ is com.google.fhir.model.r4.Code -> {
+ val code = value.value
+ if (code != null) {
+ listOf(TokenIndex(searchParam.name, searchParam.path, null, code))
+ } else {
+ emptyList()
+ }
+ }
+ is Id -> {
+ val idValue = value.value
+ if (idValue != null) {
+ listOf(TokenIndex(searchParam.name, searchParam.path, null, idValue))
+ } else {
+ emptyList()
+ }
+ }
+ else -> emptyList()
+ }
+
+ private fun referenceIndex(searchParam: SearchParamDefinition, value: Any): ReferenceIndex? {
+ val refValue =
+ when (value) {
+ is Reference -> value.reference?.value
+ is Canonical -> value.value
+ is Uri -> value.value
+ else -> null
+ }
+ return refValue?.let { ReferenceIndex(searchParam.name, searchParam.path, it) }
+ }
+
+ private fun quantityIndex(
+ searchParam: SearchParamDefinition,
+ value: Any,
+ ): List {
+ return when (value) {
+ is Money -> {
+ val moneyValue = value.value?.value ?: return emptyList()
+ // Currencies enum name is the code with first letter capitalized (e.g., "Usd" for "USD")
+ val currencyCode = value.currency?.value?.name?.uppercase() ?: return emptyList()
+ listOf(
+ QuantityIndex(
+ searchParam.name,
+ searchParam.path,
+ FHIR_CURRENCY_CODE_SYSTEM,
+ currencyCode,
+ moneyValue,
+ ),
+ )
+ }
+ is Quantity -> {
+ val quantityValue = value.value?.value ?: return emptyList()
+ val quantityIndices = mutableListOf()
+
+ // Add quantity indexing record for the human readable unit
+ val unit = value.unit?.value
+ if (unit != null) {
+ quantityIndices.add(
+ QuantityIndex(searchParam.name, searchParam.path, "", unit, quantityValue),
+ )
+ }
+
+ // Add quantity indexing record for the coded unit
+ val system = value.system?.value
+ val code = value.code?.value
+ var canonicalCode = code
+ var canonicalValue = quantityValue
+ if (system == ucumUrl && code != null) {
+ try {
+ val ucumUnit =
+ UnitConverter.getCanonicalFormOrOriginal(UcumValue(code, quantityValue))
+ canonicalCode = ucumUnit.code
+ canonicalValue = ucumUnit.value
+ } catch (_: Exception) {
+ // Fall through with original values
+ }
+ }
+ quantityIndices.add(
+ QuantityIndex(
+ searchParam.name,
+ searchParam.path,
+ system ?: "",
+ canonicalCode ?: "",
+ canonicalValue,
+ ),
+ )
+ quantityIndices
+ }
+ else -> emptyList()
+ }
+ }
+
+ private fun uriIndex(searchParam: SearchParamDefinition, value: Any): UriIndex? {
+ val uri =
+ when (value) {
+ is Uri -> value.value
+ is com.google.fhir.model.r4.String -> value.value
+ else -> null
+ }
+ return if (!uri.isNullOrEmpty()) {
+ UriIndex(searchParam.name, searchParam.path, uri)
+ } else {
+ null
+ }
+ }
+
+ private fun specialIndex(value: Any?): PositionIndex? {
+ return when (value) {
+ is Location.Position -> {
+ val lat = value.latitude.value?.doubleValue(false) ?: return null
+ val lon = value.longitude.value?.doubleValue(false) ?: return null
+ PositionIndex(lat, lon)
+ }
+ else -> null
+ }
+ }
+
+ /**
+ * The FHIR currency code system. See: https://bit.ly/30YB3ML. See:
+ * https://www.hl7.org/fhir/valueset-currencies.html.
+ */
+ private const val FHIR_CURRENCY_CODE_SYSTEM = "urn:iso:std:iso:4217"
+
+ fun createLastUpdatedIndex(resourceType: ResourceType, epochMillis: Long) =
+ DateTimeIndex(
+ name = LAST_UPDATED,
+ path = arrayOf(resourceType.name, "meta", "lastUpdated").joinToString(separator = "."),
+ from = epochMillis,
+ to = epochMillis,
+ )
+
+ fun createLocalLastUpdatedIndex(resourceType: ResourceType, epochMillis: Long) =
+ DateTimeIndex(
+ name = LOCAL_LAST_UPDATED,
+ path = arrayOf(resourceType.name, "meta", "localLastUpdated").joinToString(separator = "."),
+ from = epochMillis,
+ to = epochMillis,
+ )
+
+ // --- Date utility functions ---
+
+ /**
+ * Converts a [FhirDate] to an epoch day range (from, to) accounting for the date's precision.
+ */
+ private fun fhirDateToEpochDayRange(date: FhirDate): Pair =
+ when (date) {
+ is FhirDate.Date -> {
+ val epochDay = date.date.toEpochDays().toLong()
+ epochDay to epochDay
+ }
+ is FhirDate.YearMonth -> {
+ val firstDay = LocalDate(date.value.year, date.value.month, 1)
+ val nextMonth = firstDay.plus(1, DateTimeUnit.MONTH)
+ firstDay.toEpochDays().toLong() to (nextMonth.toEpochDays().toLong() - 1)
+ }
+ is FhirDate.Year -> {
+ val firstDay = LocalDate(date.value, 1, 1)
+ val nextYear = LocalDate(date.value + 1, 1, 1)
+ firstDay.toEpochDays().toLong() to (nextYear.toEpochDays().toLong() - 1)
+ }
+ }
+
+ /** Converts a [FhirDateTime] to epoch milliseconds (start of the range). */
+ private fun fhirDateTimeToEpochMillis(dateTime: FhirDateTime): Long =
+ when (dateTime) {
+ is FhirDateTime.DateTime -> {
+ dateTime.dateTime.toInstant(dateTime.utcOffset).toEpochMilliseconds()
+ }
+ is FhirDateTime.Date -> {
+ dateTime.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
+ }
+ is FhirDateTime.YearMonth -> {
+ val firstDay = LocalDate(dateTime.value.year, dateTime.value.month, 1)
+ firstDay.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
+ }
+ is FhirDateTime.Year -> {
+ val firstDay = LocalDate(dateTime.value, 1, 1)
+ firstDay.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
+ }
+ }
+
+ /** Converts a [FhirDateTime] to epoch milliseconds (end of the range, precision-aware). */
+ private fun fhirDateTimeToEndEpochMillis(dateTime: FhirDateTime): Long =
+ when (dateTime) {
+ is FhirDateTime.DateTime -> {
+ dateTime.dateTime.toInstant(dateTime.utcOffset).toEpochMilliseconds()
+ }
+ is FhirDateTime.Date -> {
+ val nextDay = dateTime.date.plus(1, DateTimeUnit.DAY)
+ nextDay.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - 1
+ }
+ is FhirDateTime.YearMonth -> {
+ val firstDay = LocalDate(dateTime.value.year, dateTime.value.month, 1)
+ val nextMonth = firstDay.plus(1, DateTimeUnit.MONTH)
+ nextMonth.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - 1
+ }
+ is FhirDateTime.Year -> {
+ val nextYear = LocalDate(dateTime.value + 1, 1, 1)
+ nextYear.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - 1
+ }
+ }
+
+ /**
+ * Converts a [FhirDateTime] to an epoch millisecond range (from, to) accounting for precision.
+ */
+ private fun fhirDateTimeToEpochMillisRange(dateTime: FhirDateTime): Pair =
+ fhirDateTimeToEpochMillis(dateTime) to fhirDateTimeToEndEpochMillis(dateTime)
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndices.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndices.kt
new file mode 100644
index 0000000000..0447a197f7
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndices.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index
+
+import com.google.android.fhir.index.entities.DateIndex
+import com.google.android.fhir.index.entities.DateTimeIndex
+import com.google.android.fhir.index.entities.NumberIndex
+import com.google.android.fhir.index.entities.PositionIndex
+import com.google.android.fhir.index.entities.QuantityIndex
+import com.google.android.fhir.index.entities.ReferenceIndex
+import com.google.android.fhir.index.entities.StringIndex
+import com.google.android.fhir.index.entities.TokenIndex
+import com.google.android.fhir.index.entities.UriIndex
+import com.google.fhir.model.r4.terminologies.ResourceType
+
+/**
+ * Indices extracted from the resource of [resourceType] and [resourceId]. Used to create index
+ * records in the database to support search.
+ *
+ * See https://www.hl7.org/fhir/search.html.
+ */
+internal data class ResourceIndices(
+ val resourceType: ResourceType,
+ val resourceId: String,
+ val numberIndices: List,
+ val dateIndices: List,
+ val dateTimeIndices: List,
+ val stringIndices: List,
+ val uriIndices: List,
+ val tokenIndices: List,
+ val quantityIndices: List,
+ val referenceIndices: List,
+ val positionIndices: List,
+) {
+ class Builder(private val resourceType: ResourceType, private val resourceId: String) {
+ private val stringIndices = mutableListOf()
+ private val referenceIndices = mutableListOf()
+ private val tokenIndices = mutableListOf()
+ private val quantityIndices = mutableListOf()
+ private val uriIndices = mutableListOf()
+ private val dateIndices = mutableListOf()
+ private val dateTimeIndices = mutableListOf()
+ private val numberIndices = mutableListOf()
+ private val positionIndices = mutableListOf()
+
+ constructor(indices: ResourceIndices) : this(indices.resourceType, indices.resourceId) {
+ stringIndices += indices.stringIndices
+ referenceIndices += indices.referenceIndices
+ tokenIndices += indices.tokenIndices
+ quantityIndices += indices.quantityIndices
+ uriIndices += indices.uriIndices
+ dateIndices += indices.dateIndices
+ dateTimeIndices += indices.dateTimeIndices
+ numberIndices += indices.numberIndices
+ positionIndices += indices.positionIndices
+ }
+
+ fun addNumberIndex(numberIndex: NumberIndex) {
+ if (numberIndices.contains(numberIndex)) {
+ return
+ }
+ numberIndices.add(numberIndex)
+ }
+
+ fun addDateIndex(dateIndex: DateIndex) {
+ if (dateIndices.contains(dateIndex)) {
+ return
+ }
+ dateIndices.add(dateIndex)
+ }
+
+ fun addDateTimeIndex(dateTimeIndex: DateTimeIndex) {
+ if (dateTimeIndices.contains(dateTimeIndex)) {
+ return
+ }
+ dateTimeIndices.add(dateTimeIndex)
+ }
+
+ fun addStringIndex(stringIndex: StringIndex) {
+ if (stringIndices.contains(stringIndex)) {
+ return
+ }
+ stringIndices.add(stringIndex)
+ }
+
+ fun addUriIndex(uriIndex: UriIndex) {
+ if (uriIndices.contains(uriIndex)) {
+ return
+ }
+ uriIndices.add(uriIndex)
+ }
+
+ fun addTokenIndex(tokenIndex: TokenIndex) {
+ if (tokenIndices.contains(tokenIndex)) {
+ return
+ }
+ tokenIndices.add(tokenIndex)
+ }
+
+ fun addQuantityIndex(quantityIndex: QuantityIndex) {
+ if (quantityIndices.contains(quantityIndex)) {
+ return
+ }
+ quantityIndices.add(quantityIndex)
+ }
+
+ fun addReferenceIndex(referenceIndex: ReferenceIndex) {
+ if (referenceIndices.contains(referenceIndex)) {
+ return
+ }
+ referenceIndices.add(referenceIndex)
+ }
+
+ fun addPositionIndex(positionIndex: PositionIndex) {
+ if (positionIndices.contains(positionIndex)) {
+ return
+ }
+ positionIndices.add(positionIndex)
+ }
+
+ fun build() =
+ ResourceIndices(
+ resourceType = resourceType,
+ resourceId = resourceId,
+ numberIndices = numberIndices.toList(),
+ dateIndices = dateIndices.toList(),
+ dateTimeIndices = dateTimeIndices.toList(),
+ stringIndices = stringIndices.toList(),
+ uriIndices = uriIndices.toList(),
+ tokenIndices = tokenIndices.toList(),
+ quantityIndices = quantityIndices.toList(),
+ referenceIndices = referenceIndices.toList(),
+ positionIndices = positionIndices.toList(),
+ )
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinition.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinition.kt
new file mode 100644
index 0000000000..7999ac3004
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinition.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index
+
+/** Definition of a search parameter for indexing and querying FHIR resources. */
+data class SearchParamDefinition(
+ val name: String,
+ val type: SearchParamType,
+ val path: String,
+)
+
+/** The type of a search parameter. */
+enum class SearchParamType {
+ NUMBER,
+ DATE,
+ STRING,
+ TOKEN,
+ REFERENCE,
+ COMPOSITE,
+ QUANTITY,
+ URI,
+ SPECIAL,
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinitionsProvider.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinitionsProvider.kt
new file mode 100644
index 0000000000..5d261e4212
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinitionsProvider.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index
+
+import com.google.fhir.model.r4.Resource
+
+/** Provides a list of [SearchParamDefinition]s for a [Resource]. */
+internal fun interface SearchParamDefinitionsProvider {
+
+ /** @return [SearchParamDefinition]s based on the resource type name. */
+ fun get(resource: Resource): List
+}
+
+/**
+ * An implementation of [SearchParamDefinitionsProvider] that provides the [List]<
+ * [SearchParamDefinition]> from the default params and custom params(if any).
+ */
+internal class SearchParamDefinitionsProviderImpl(
+ private val defaultParams: Map> = emptyMap(),
+ private val customParams: Map> = emptyMap(),
+) : SearchParamDefinitionsProvider {
+
+ override fun get(resource: Resource): List {
+ val resourceTypeName = resource::class.simpleName ?: return emptyList()
+ return defaultParams.getOrElse(resourceTypeName) { emptyList() } +
+ customParams.getOrElse(resourceTypeName) { emptyList() }
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateIndex.kt
new file mode 100644
index 0000000000..71472469dc
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateIndex.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index.entities
+
+/**
+ * An index record for a `Date` value in a FHIR resource.
+ *
+ * Note one fundamental difference between `Date` and `DateTime` data types in FHIR in that `Date`
+ * does not contain timezone info where `DateTime` does.
+ *
+ * See https://hl7.org/FHIR/search.html#date.
+ */
+internal data class DateIndex(
+ /** The name of the date index, e.g. "birthdate". */
+ val name: String,
+ /** The path of the date index, e.g. "Patient.birthdate". */
+ val path: String,
+ /** The epoch day of the first date. */
+ val from: Long,
+ /** The epoch day of the last date. */
+ val to: Long,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateTimeIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateTimeIndex.kt
new file mode 100644
index 0000000000..0611ca8ea6
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateTimeIndex.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index.entities
+
+/**
+ * An index record for a `DateTime` value in a FHIR resource.
+ *
+ * This could be used to index resources by `DateTime`, `Instant`, `Period` and `Timing` data types.
+ *
+ * Note one fundamental difference between `DateTime` and `Date` data types in FHIR in that
+ * `DateTime` contains timezone info where `Date` does not.
+ *
+ * See https://hl7.org/FHIR/search.html#date.
+ */
+internal data class DateTimeIndex(
+ /** The name of the date index, e.g. "birthdate". */
+ val name: String,
+ /** The path of the date index, e.g. "Patient.birthdate". */
+ val path: String,
+ /** The epoch time of the first millisecond. */
+ val from: Long,
+ /** The epoch time of the last millisecond. */
+ val to: Long,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/NumberIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/NumberIndex.kt
new file mode 100644
index 0000000000..6a81bb172d
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/NumberIndex.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index.entities
+
+import com.ionspin.kotlin.bignum.decimal.BigDecimal
+
+/**
+ * An index record for a number value in a resource.
+ *
+ * See https://hl7.org/FHIR/search.html#number.
+ */
+internal data class NumberIndex(
+ /** The name of the number index, e.g. "probability". */
+ val name: String,
+ /** The path of the number index, e.g. "RiskAssessment.prediction.probability". */
+ val path: String,
+ /** The value of the number index, e.g. "0.1". */
+ val value: BigDecimal,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/PositionIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/PositionIndex.kt
new file mode 100644
index 0000000000..96c5fca8f1
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/PositionIndex.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index.entities
+
+/**
+ * An index record for a position value in a location resource.
+ *
+ * See https://www.hl7.org/fhir/search.html#special.
+ */
+internal data class PositionIndex(val latitude: Double, val longitude: Double)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/QuantityIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/QuantityIndex.kt
new file mode 100644
index 0000000000..f7ad8f5e8b
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/QuantityIndex.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index.entities
+
+import com.ionspin.kotlin.bignum.decimal.BigDecimal
+
+/**
+ * An index record for a quantity value in a resource.
+ *
+ * See https://hl7.org/FHIR/search.html#quantity.
+ */
+internal data class QuantityIndex(
+ val name: String,
+ val path: String,
+ val system: String,
+ val code: String,
+ val value: BigDecimal,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/ReferenceIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/ReferenceIndex.kt
new file mode 100644
index 0000000000..d3378fd757
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/ReferenceIndex.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index.entities
+
+/**
+ * An index record for a reference value in a resource.
+ *
+ * See https://hl7.org/FHIR/search.html#reference.
+ */
+internal data class ReferenceIndex(
+ /** The name of the reference index, e.g. "subject". */
+ val name: String,
+ /** The path of the reference index, e.g. "Observation.subject". */
+ val path: String,
+ /** The value of the reference index, e.g. "Patient/123". */
+ val value: String,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/StringIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/StringIndex.kt
new file mode 100644
index 0000000000..66c73d59d0
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/StringIndex.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index.entities
+
+/**
+ * An index record for a string value in a resource.
+ *
+ * See https://hl7.org/FHIR/search.html#string.
+ */
+internal data class StringIndex(
+ /** The name of the string index, e.g. "given". */
+ val name: String,
+ /** The path of the string index, e.g. "Patient.name.given". */
+ val path: String,
+ /** The value of the string index, e.g. "Tom". */
+ val value: String,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/TokenIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/TokenIndex.kt
new file mode 100644
index 0000000000..3a626f1cdb
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/TokenIndex.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index.entities
+
+/**
+ * An index record for a token value in a resource.
+ *
+ * See https://hl7.org/FHIR/search.html#token.
+ */
+internal data class TokenIndex(
+ /** The name of the code index, e.g. "code". */
+ val name: String,
+ /** The path of the code index, e.g. "Observation.code". */
+ val path: String,
+ /** The system of the code index, e.g. "http://openmrs.org/concepts". */
+ val system: String?,
+ /** The value of the code index, e.g. "1427AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA". */
+ val value: String,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/UriIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/UriIndex.kt
new file mode 100644
index 0000000000..f6542b2444
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/UriIndex.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.index.entities
+
+/**
+ * An index record for a URI value in a resource.
+ *
+ * See https://hl7.org/FHIR/search.html#uri.
+ */
+internal data class UriIndex(val name: String, val path: String, val value: String)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/BaseSearch.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/BaseSearch.kt
new file mode 100644
index 0000000000..5cb63f7683
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/BaseSearch.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search
+
+import com.google.android.fhir.search.filter.DateParamFilterCriterion
+import com.google.android.fhir.search.filter.NumberParamFilterCriterion
+import com.google.android.fhir.search.filter.QuantityParamFilterCriterion
+import com.google.android.fhir.search.filter.ReferenceParamFilterCriterion
+import com.google.android.fhir.search.filter.StringParamFilterCriterion
+import com.google.android.fhir.search.filter.TokenParamFilterCriterion
+import com.google.android.fhir.search.filter.UriParamFilterCriterion
+
+/** Core search contract declaring filter and sort operations for all parameter types. */
+interface BaseSearch {
+
+ var operation: Operation
+ var count: Int?
+ var from: Int?
+
+ fun filter(
+ stringParameter: StringClientParam,
+ vararg init: StringParamFilterCriterion.() -> Unit,
+ operation: Operation = Operation.OR,
+ )
+
+ fun filter(
+ referenceParameter: ReferenceClientParam,
+ vararg init: ReferenceParamFilterCriterion.() -> Unit,
+ operation: Operation = Operation.OR,
+ )
+
+ fun filter(
+ dateParameter: DateClientParam,
+ vararg init: DateParamFilterCriterion.() -> Unit,
+ operation: Operation = Operation.OR,
+ )
+
+ fun filter(
+ quantityParameter: QuantityClientParam,
+ vararg init: QuantityParamFilterCriterion.() -> Unit,
+ operation: Operation = Operation.OR,
+ )
+
+ fun filter(
+ tokenParameter: TokenClientParam,
+ vararg init: TokenParamFilterCriterion.() -> Unit,
+ operation: Operation = Operation.OR,
+ )
+
+ fun filter(
+ numberParameter: NumberClientParam,
+ vararg init: NumberParamFilterCriterion.() -> Unit,
+ operation: Operation = Operation.OR,
+ )
+
+ fun filter(
+ uriParam: UriClientParam,
+ vararg init: UriParamFilterCriterion.() -> Unit,
+ operation: Operation = Operation.OR,
+ )
+
+ fun sort(parameter: StringClientParam, order: Order)
+
+ fun sort(parameter: NumberClientParam, order: Order)
+
+ fun sort(parameter: DateClientParam, order: Order)
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/ClientParam.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/ClientParam.kt
new file mode 100644
index 0000000000..6f8ee364d6
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/ClientParam.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search
+
+/**
+ * KMP-compatible replacement for HAPI FHIR's `IParam` / `*ClientParam` hierarchy. Provides
+ * compile-time safety for search parameter types.
+ */
+sealed class ClientParam(val paramName: String)
+
+class StringClientParam(paramName: String) : ClientParam(paramName)
+
+class DateClientParam(paramName: String) : ClientParam(paramName)
+
+class NumberClientParam(paramName: String) : ClientParam(paramName)
+
+class TokenClientParam(paramName: String) : ClientParam(paramName)
+
+class ReferenceClientParam(paramName: String) : ClientParam(paramName)
+
+class QuantityClientParam(paramName: String) : ClientParam(paramName)
+
+class UriClientParam(paramName: String) : ClientParam(paramName)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/MoreClientParams.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/MoreClientParams.kt
new file mode 100644
index 0000000000..02a894ca86
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/MoreClientParams.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search
+
+val LOCAL_LAST_UPDATED_PARAM = DateClientParam(LOCAL_LAST_UPDATED)
+val LAST_UPDATED_PARAM = DateClientParam(LAST_UPDATED)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/MoreSearch.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/MoreSearch.kt
new file mode 100644
index 0000000000..2010cd2d5b
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/MoreSearch.kt
@@ -0,0 +1,706 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search
+
+import com.google.android.fhir.ConverterException
+import com.google.android.fhir.SearchResult
+import com.google.android.fhir.UcumValue
+import com.google.android.fhir.UnitConverter
+import com.google.android.fhir.db.Database
+import com.google.android.fhir.db.ResourceWithUUID
+import com.google.android.fhir.resourceType
+import com.google.android.fhir.ucumUrl
+import com.google.fhir.model.r4.FhirDate
+import com.google.fhir.model.r4.FhirDateTime
+import com.google.fhir.model.r4.Resource
+import com.ionspin.kotlin.bignum.decimal.BigDecimal
+import kotlin.math.absoluteValue
+import kotlin.math.roundToLong
+import kotlin.time.Clock
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.plus
+import kotlinx.datetime.toInstant
+
+/**
+ * The multiplier used to determine the range for the `ap` search prefix. See
+ * https://www.hl7.org/fhir/search.html#prefix for more details.
+ */
+private const val APPROXIMATION_COEFFICIENT = 0.1
+
+private const val MIN_VALUE = "-9223372036854775808"
+private const val MAX_VALUE = "9223372036854775808"
+
+internal suspend fun Search.execute(database: Database): List> {
+ val baseResources = database.search(getQuery())
+
+ val includedResources =
+ if (forwardIncludes.isEmpty() || baseResources.isEmpty()) {
+ null
+ } else {
+ val uuids = baseResources.map { it.uuid.toString() }
+ database.searchReferencedResources(getIncludeQuery(uuids))
+ }
+
+ val revIncludedResources =
+ if (revIncludes.isEmpty() || baseResources.isEmpty()) {
+ null
+ } else {
+ val typeIdPairs =
+ baseResources.map { "${it.resource.resourceType}/${(it.resource as Resource).id}" }
+ database.searchReferencedResources(getRevIncludeQuery(typeIdPairs))
+ }
+
+ return baseResources.map { (uuid, baseResource) ->
+ SearchResult(
+ baseResource,
+ included =
+ includedResources
+ ?.asSequence()
+ ?.filter { it.baseId == uuid.toString() }
+ ?.groupBy({ it.searchIndex }, { it.resource }),
+ revIncluded =
+ revIncludedResources
+ ?.asSequence()
+ ?.filter {
+ it.baseId == "${(baseResource as Resource).resourceType}/${baseResource.id}"
+ }
+ ?.groupBy(
+ { com.google.fhir.model.r4.terminologies.ResourceType.fromCode(it.resource.resourceType) to it.searchIndex },
+ { it.resource },
+ ),
+ )
+ }
+}
+
+internal suspend fun Search.count(database: Database): Long {
+ return database.count(getQuery(true))
+}
+
+fun Search.getQuery(isCount: Boolean = false): SearchQuery {
+ return getQuery(isCount, null)
+}
+
+internal fun Search.getRevIncludeQuery(includeIds: List): SearchQuery {
+ val args = mutableListOf()
+ val uuidsString = CharArray(includeIds.size) { '?' }.joinToString()
+
+ fun generateFilterQuery(nestedSearch: NestedSearch): String {
+ val (param, search) = nestedSearch
+ val resourceToInclude = search.type
+ args.add(resourceToInclude.name)
+ args.add(param.paramName)
+ args.addAll(includeIds)
+
+ var filterQuery = ""
+ val filters = search.getFilterQueries()
+ val iterator = filters.listIterator()
+ while (iterator.hasNext()) {
+ iterator.next().let {
+ filterQuery += it.query
+ args.addAll(it.args)
+ }
+ if (iterator.hasNext()) {
+ filterQuery +=
+ if (search.operation == Operation.OR) "\n UNION \n" else "\n INTERSECT \n"
+ }
+ }
+ if (filters.isEmpty()) args.add(resourceToInclude.name)
+ return filterQuery
+ }
+
+ return revIncludes
+ .map {
+ val (join, order) =
+ it.search.getSortOrder(otherTable = "re", groupByColumn = "rie.index_value")
+ args.addAll(join.args)
+ val filterQuery = generateFilterQuery(it)
+ """
+ SELECT rie.index_name, rie.index_value, re.serializedResource
+ FROM ResourceEntity re
+ JOIN ReferenceIndexEntity rie
+ ON re.resourceUuid = rie.resourceUuid
+ ${join.query}
+ WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.index_value IN ($uuidsString)
+ ${if (filterQuery.isNotBlank()) "AND re.resourceUuid IN ($filterQuery)" else "AND re.resourceType = ?"}
+ $order
+ """
+ .trimIndent()
+ }
+ .joinToString("\nUNION ALL\n") {
+ StringBuilder("SELECT * FROM (\n").append(it.trim()).append("\n)")
+ }
+ .split("\n")
+ .filter { it.isNotBlank() }
+ .joinToString("\n") { it.trim() }
+ .let { SearchQuery(it, args) }
+}
+
+internal fun Search.getIncludeQuery(includeIds: List): SearchQuery {
+ val args = mutableListOf()
+ val baseResourceType = type
+ val uuidsString = CharArray(includeIds.size) { '?' }.joinToString()
+
+ fun generateFilterQuery(nestedSearch: NestedSearch): String {
+ val (param, search) = nestedSearch
+ val resourceToInclude = search.type
+ args.add(baseResourceType.name)
+ args.add(param.paramName)
+ args.addAll(includeIds)
+
+ var filterQuery = ""
+ val filters = search.getFilterQueries()
+ val iterator = filters.listIterator()
+ while (iterator.hasNext()) {
+ iterator.next().let {
+ filterQuery += it.query
+ args.addAll(it.args)
+ }
+ if (iterator.hasNext()) {
+ filterQuery +=
+ if (search.operation == Operation.OR) "\nUNION\n" else "\nINTERSECT\n"
+ }
+ }
+ if (filters.isEmpty()) args.add(resourceToInclude.name)
+ return filterQuery
+ }
+
+ return forwardIncludes
+ .map {
+ val (join, order) =
+ it.search.getSortOrder(otherTable = "re", groupByColumn = "rie.resourceUuid")
+ args.addAll(join.args)
+ val filterQuery = generateFilterQuery(it)
+ """
+ SELECT rie.index_name, rie.resourceUuid, re.serializedResource
+ FROM ResourceEntity re
+ JOIN ReferenceIndexEntity rie
+ ON re.resourceType||"/"||re.resourceId = rie.index_value
+ ${join.query}
+ WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.resourceUuid IN ($uuidsString)
+ ${if (filterQuery.isNotBlank()) "AND re.resourceUuid IN ($filterQuery)" else "AND re.resourceType = ?"}
+ $order
+ """
+ .trimIndent()
+ }
+ .joinToString("\nUNION ALL\n") {
+ StringBuilder("SELECT * FROM (\n").append(it.trim()).append("\n)")
+ }
+ .split("\n")
+ .filter { it.isNotBlank() }
+ .joinToString("\n") { it.trim() }
+ .let { SearchQuery(it, args) }
+}
+
+private fun Search.getSortOrder(
+ otherTable: String,
+ isReferencedSearch: Boolean = false,
+ groupByColumn: String = "",
+): Pair {
+ var sortJoinStatement = ""
+ var sortOrderStatement = ""
+ val args = mutableListOf()
+
+ sort?.let { sort ->
+ val sortTableNames =
+ when (sort) {
+ is StringClientParam -> listOf(SortTableInfo.STRING_SORT_TABLE_INFO)
+ is NumberClientParam -> listOf(SortTableInfo.NUMBER_SORT_TABLE_INFO)
+ is DateClientParam ->
+ listOf(SortTableInfo.DATE_SORT_TABLE_INFO, SortTableInfo.DATE_TIME_SORT_TABLE_INFO)
+ else -> throw NotImplementedError("Unhandled sort parameter of type ${sort::class}: $sort")
+ }
+
+ sortJoinStatement =
+ sortTableNames
+ .mapIndexed { index, sortTableName ->
+ val tableAlias = 'b' + index
+ """
+ LEFT JOIN ${sortTableName.tableName} $tableAlias
+ ON $otherTable.resourceUuid = $tableAlias.resourceUuid AND $tableAlias.index_name = ?
+ """
+ }
+ .joinToString(separator = "\n")
+ sortTableNames.forEach { _ -> args.add(sort.paramName) }
+
+ sortOrderStatement +=
+ generateGroupAndOrderQuery(sort, order!!, otherTable, groupByColumn, sortTableNames)
+ }
+ return Pair(SearchQuery(sortJoinStatement, args), sortOrderStatement)
+}
+
+private fun generateGroupAndOrderQuery(
+ sort: ClientParam,
+ order: Order,
+ otherTable: String,
+ groupByColumn: String,
+ sortTableNames: List,
+): String {
+ var sortOrderStatement = ""
+ val havingColumn =
+ when (sort) {
+ is StringClientParam,
+ is NumberClientParam, -> "IFNULL(b.index_value,0)"
+ is DateClientParam -> "IFNULL(b.index_from,0) + IFNULL(c.index_from,0)"
+ else -> throw NotImplementedError("Unhandled sort parameter of type ${sort::class}: $sort")
+ }
+
+ sortOrderStatement +=
+ """
+ GROUP BY $otherTable.resourceUuid ${if (groupByColumn.isNotEmpty()) ", $groupByColumn" else ""}
+ HAVING ${if (order == Order.ASCENDING) "MIN($havingColumn) >= $MIN_VALUE" else "MAX($havingColumn) >= $MIN_VALUE"}
+
+ """
+ .trimIndent()
+ val defaultValue = if (order == Order.ASCENDING) MAX_VALUE else MIN_VALUE
+ sortTableNames.forEachIndexed { index, sortTableName ->
+ val tableAlias = 'b' + index
+ sortOrderStatement +=
+ if (index == 0) {
+ """
+ ORDER BY IFNULL($tableAlias.${sortTableName.columnName}, $defaultValue) ${order.sqlString}
+ """
+ .trimIndent()
+ } else {
+ ", IFNULL($tableAlias.${SortTableInfo.DATE_TIME_SORT_TABLE_INFO.columnName}, $defaultValue) ${order.sqlString}"
+ }
+ }
+ return sortOrderStatement
+}
+
+private fun Search.getFilterQueries() =
+ (stringFilterCriteria +
+ quantityFilterCriteria +
+ numberFilterCriteria +
+ referenceFilterCriteria +
+ dateTimeFilterCriteria +
+ tokenFilterCriteria +
+ uriFilterCriteria)
+ .map { it.query(type) }
+
+internal fun Search.getQuery(
+ isCount: Boolean = false,
+ nestedContext: NestedContext? = null,
+): SearchQuery {
+ val (join, order) = getSortOrder(otherTable = "a")
+ val sortJoinStatement = join.query
+ val sortOrderStatement = order
+ val sortArgs = join.args
+
+ val filterQuery = getFilterQueries()
+ val filterQueryStatement =
+ filterQuery.joinToString(separator = "${operation.logicalOperator} ") {
+ """
+ a.resourceUuid IN (
+ ${it.query}
+ )
+
+ """.trimIndent()
+ }
+ val filterQueryArgs = filterQuery.flatMap { it.args }
+
+ var limitStatement = ""
+ val limitArgs = mutableListOf()
+ if (count != null) {
+ limitStatement = "LIMIT ?"
+ limitArgs += count!!
+ if (from != null) {
+ limitStatement += " OFFSET ?"
+ limitArgs += from!!
+ }
+ }
+
+ val nestedFilterQuery = nestedSearches.nestedQuery(type, operation)
+ val nestedQueryFilterStatement = nestedFilterQuery?.query ?: ""
+ val nestedQueryFilterArgs = nestedFilterQuery?.args ?: emptyList()
+
+ val filterStatement =
+ listOf(filterQueryStatement, nestedQueryFilterStatement)
+ .filter { it.isNotBlank() }
+ .joinToString(separator = " AND ")
+ .ifBlank { "a.resourceType = ?" }
+ val filterArgs = (filterQueryArgs + nestedQueryFilterArgs).ifEmpty { listOf(type.name) }
+
+ val whereArgs = mutableListOf()
+ val nestedArgs = mutableListOf()
+ val query =
+ when {
+ isCount -> {
+ """
+ SELECT COUNT(*)
+ FROM ResourceEntity a
+ $sortJoinStatement
+ WHERE $filterStatement
+ $sortOrderStatement
+ $limitStatement
+ """
+ }
+ nestedContext != null -> {
+ whereArgs.add(nestedContext.param.paramName)
+ val start = "${nestedContext.parentType.name}/".length + 1
+ nestedArgs.add(nestedContext.parentType.name)
+ """
+ SELECT resourceUuid
+ FROM ResourceEntity a
+ WHERE a.resourceType = ? AND a.resourceId IN (
+ SELECT substr(a.index_value, $start)
+ FROM ReferenceIndexEntity a
+ $sortJoinStatement
+ WHERE a.index_name = ? AND $filterStatement
+ $sortOrderStatement
+ $limitStatement)
+ """
+ }
+ else ->
+ """
+ SELECT a.resourceUuid, a.serializedResource
+ FROM ResourceEntity a
+ $sortJoinStatement
+ WHERE $filterStatement
+ $sortOrderStatement
+ $limitStatement
+ """
+ }
+ .split("\n")
+ .filter { it.isNotBlank() }
+ .joinToString("\n") { it.trim() }
+
+ return SearchQuery(
+ query,
+ nestedArgs + sortArgs + whereArgs + filterArgs + limitArgs,
+ )
+}
+
+private val Order?.sqlString: String
+ get() =
+ when (this) {
+ Order.ASCENDING -> "ASC"
+ Order.DESCENDING -> "DESC"
+ null -> ""
+ }
+
+// --- Condition param pair helpers ---
+
+internal fun getConditionParamPairForDate(
+ prefix: ParamPrefixEnum,
+ value: FhirDate,
+): ConditionParam {
+ val (start, end) = fhirDateToEpochDayRange(value)
+ return when (prefix) {
+ ParamPrefixEnum.APPROXIMATE -> {
+ val now = Clock.System.now().toEpochMilliseconds()
+ val nowDay = now / 86400000L
+ val currentRange = nowDay to nowDay
+ val (diffStart, diffEnd) =
+ getApproximateDateRange(start..end, currentRange.first..currentRange.second)
+ ConditionParam(
+ "index_from BETWEEN ? AND ? AND index_to BETWEEN ? AND ?",
+ diffStart,
+ diffEnd,
+ diffStart,
+ diffEnd,
+ )
+ }
+ ParamPrefixEnum.STARTS_AFTER -> ConditionParam("index_from > ?", end)
+ ParamPrefixEnum.ENDS_BEFORE -> ConditionParam("index_to < ?", start)
+ ParamPrefixEnum.NOT_EQUAL ->
+ ConditionParam(
+ "index_from NOT BETWEEN ? AND ? OR index_to NOT BETWEEN ? AND ?",
+ start,
+ end,
+ start,
+ end,
+ )
+ ParamPrefixEnum.EQUAL ->
+ ConditionParam(
+ "index_from BETWEEN ? AND ? AND index_to BETWEEN ? AND ?",
+ start,
+ end,
+ start,
+ end,
+ )
+ ParamPrefixEnum.GREATERTHAN -> ConditionParam("index_to > ?", end)
+ ParamPrefixEnum.GREATERTHAN_OR_EQUALS -> ConditionParam("index_to >= ?", start)
+ ParamPrefixEnum.LESSTHAN -> ConditionParam("index_from < ?", start)
+ ParamPrefixEnum.LESSTHAN_OR_EQUALS -> ConditionParam("index_from <= ?", end)
+ }
+}
+
+internal fun getConditionParamPairForDateTime(
+ prefix: ParamPrefixEnum,
+ value: FhirDateTime,
+): ConditionParam {
+ val (start, end) = fhirDateTimeToEpochMillisRange(value)
+ return when (prefix) {
+ ParamPrefixEnum.APPROXIMATE -> {
+ val nowMs = Clock.System.now().toEpochMilliseconds()
+ val (diffStart, diffEnd) =
+ getApproximateDateRange(start..end, nowMs..nowMs)
+ ConditionParam(
+ "index_from BETWEEN ? AND ? AND index_to BETWEEN ? AND ?",
+ diffStart,
+ diffEnd,
+ diffStart,
+ diffEnd,
+ )
+ }
+ ParamPrefixEnum.STARTS_AFTER -> ConditionParam("index_from > ?", end)
+ ParamPrefixEnum.ENDS_BEFORE -> ConditionParam("index_to < ?", start)
+ ParamPrefixEnum.NOT_EQUAL ->
+ ConditionParam(
+ "index_from NOT BETWEEN ? AND ? OR index_to NOT BETWEEN ? AND ?",
+ start,
+ end,
+ start,
+ end,
+ )
+ ParamPrefixEnum.EQUAL ->
+ ConditionParam(
+ "index_from BETWEEN ? AND ? AND index_to BETWEEN ? AND ?",
+ start,
+ end,
+ start,
+ end,
+ )
+ ParamPrefixEnum.GREATERTHAN -> ConditionParam("index_to > ?", end)
+ ParamPrefixEnum.GREATERTHAN_OR_EQUALS -> ConditionParam("index_to >= ?", start)
+ ParamPrefixEnum.LESSTHAN -> ConditionParam("index_from < ?", start)
+ ParamPrefixEnum.LESSTHAN_OR_EQUALS -> ConditionParam("index_from <= ?", end)
+ }
+}
+
+/**
+ * Returns the condition and list of params required in NumberFilter.query see
+ * https://www.hl7.org/fhir/search.html#number.
+ */
+internal fun getConditionParamPair(
+ prefix: ParamPrefixEnum?,
+ value: BigDecimal,
+): ConditionParam {
+ require(
+ (value.precision - 1 - value.exponent) > 0 ||
+ (prefix != ParamPrefixEnum.STARTS_AFTER && prefix != ParamPrefixEnum.ENDS_BEFORE),
+ ) {
+ "Prefix $prefix not allowed for Integer type"
+ }
+ return when (prefix) {
+ ParamPrefixEnum.EQUAL,
+ null, -> {
+ val precision = value.getRange()
+ ConditionParam(
+ "index_value >= ? AND index_value < ?",
+ (value - precision).doubleValue(false),
+ (value + precision).doubleValue(false),
+ )
+ }
+ ParamPrefixEnum.GREATERTHAN ->
+ ConditionParam("index_value > ?", value.doubleValue(false))
+ ParamPrefixEnum.GREATERTHAN_OR_EQUALS ->
+ ConditionParam("index_value >= ?", value.doubleValue(false))
+ ParamPrefixEnum.LESSTHAN ->
+ ConditionParam("index_value < ?", value.doubleValue(false))
+ ParamPrefixEnum.LESSTHAN_OR_EQUALS ->
+ ConditionParam("index_value <= ?", value.doubleValue(false))
+ ParamPrefixEnum.NOT_EQUAL -> {
+ val precision = value.getRange()
+ ConditionParam(
+ "index_value < ? OR index_value >= ?",
+ (value - precision).doubleValue(false),
+ (value + precision).doubleValue(false),
+ )
+ }
+ ParamPrefixEnum.ENDS_BEFORE ->
+ ConditionParam("index_value < ?", value.doubleValue(false))
+ ParamPrefixEnum.STARTS_AFTER ->
+ ConditionParam("index_value > ?", value.doubleValue(false))
+ ParamPrefixEnum.APPROXIMATE -> {
+ val range = value.multiply(BigDecimal.fromDouble(APPROXIMATION_COEFFICIENT))
+ ConditionParam(
+ "index_value >= ? AND index_value <= ?",
+ (value - range).doubleValue(false),
+ (value + range).doubleValue(false),
+ )
+ }
+ }
+}
+
+/**
+ * Returns the condition and list of params required in Quantity.query see
+ * https://www.hl7.org/fhir/search.html#quantity.
+ */
+internal fun getConditionParamPair(
+ prefix: ParamPrefixEnum?,
+ value: BigDecimal,
+ system: String?,
+ unit: String?,
+): ConditionParam {
+ var canonicalizedUnit = unit
+ var canonicalizedValue = value
+
+ if (system == ucumUrl && unit != null) {
+ try {
+ val ucumValue = UnitConverter.getCanonicalFormOrOriginal(UcumValue(unit, value))
+ canonicalizedUnit = ucumValue.code
+ canonicalizedValue = ucumValue.value
+ } catch (_: ConverterException) {
+ // Fall through with original values
+ }
+ }
+
+ val queryBuilder = StringBuilder()
+ val argList = mutableListOf()
+
+ if (system != null) {
+ queryBuilder.append("index_system = ? AND ")
+ argList.add(system)
+ }
+
+ if (canonicalizedUnit != null) {
+ queryBuilder.append("index_code = ? AND ")
+ argList.add(canonicalizedUnit)
+ }
+
+ val valueConditionParam = getConditionParamPair(prefix, canonicalizedValue)
+ queryBuilder.append(valueConditionParam.condition)
+ argList.addAll(valueConditionParam.params)
+
+ return ConditionParam(queryBuilder.toString(), argList)
+}
+
+/**
+ * Returns the range for an implicit precision search (see
+ * https://www.hl7.org/fhir/search.html#number). The value is directly related to the number of
+ * decimal digits.
+ *
+ * For example, a search with a value 100.00 (has 2 decimal places) would match any value in
+ * [99.995, 100.005) and the function returns 0.005.
+ *
+ * For integers which have no decimal places the function returns 5. For example a search with a
+ * value 1000 would match any value in [995, 1005) and the function returns 5.
+ *
+ * Note: ionspin BigDecimal's `scale` property comes from DecimalMode and is -1 when unset. We
+ * compute Java-style scale (number of decimal places) from the exponent instead.
+ */
+private fun BigDecimal.getRange(): BigDecimal {
+ // In ionspin BigDecimal, value = significand * 10^(exponent - precision + 1).
+ // Java-style scale (number of decimal places) = precision - 1 - exponent.
+ // For example: 5.403 → significand=5403, exponent=0, precision=4 → javaScale=3
+ // 1000 → significand=1, exponent=3, precision=1 → javaScale=-3 (integer)
+ val javaScale = precision - 1 - exponent
+ return if (javaScale > 0) {
+ BigDecimal.fromDouble(0.5).divide(BigDecimal.fromInt(10).pow(javaScale))
+ } else {
+ BigDecimal.fromInt(5)
+ }
+}
+
+data class ConditionParam(val condition: String, val params: List) {
+ constructor(condition: String, vararg params: T) : this(condition, params.asList())
+
+ val queryString = if (params.size > 1) "($condition)" else condition
+}
+
+private enum class SortTableInfo(val tableName: String, val columnName: String) {
+ STRING_SORT_TABLE_INFO("StringIndexEntity", "index_value"),
+ NUMBER_SORT_TABLE_INFO("NumberIndexEntity", "index_value"),
+ DATE_SORT_TABLE_INFO("DateIndexEntity", "index_from"),
+ DATE_TIME_SORT_TABLE_INFO("DateTimeIndexEntity", "index_from"),
+}
+
+private fun getApproximateDateRange(
+ valueRange: LongRange,
+ currentRange: LongRange,
+ approximationCoefficient: Double = APPROXIMATION_COEFFICIENT,
+): ApproximateDateRange {
+ return ApproximateDateRange(
+ (valueRange.first -
+ approximationCoefficient * (valueRange.first - currentRange.first).absoluteValue)
+ .roundToLong(),
+ (valueRange.last +
+ approximationCoefficient * (valueRange.last - currentRange.last).absoluteValue)
+ .roundToLong(),
+ )
+}
+
+private data class ApproximateDateRange(val start: Long, val end: Long)
+
+// --- Date utility functions (reused from ResourceIndexer patterns) ---
+
+internal fun fhirDateToEpochDayRange(date: FhirDate): Pair =
+ when (date) {
+ is FhirDate.Date -> {
+ val epochDay = date.date.toEpochDays().toLong()
+ epochDay to epochDay
+ }
+ is FhirDate.YearMonth -> {
+ val firstDay = LocalDate(date.value.year, date.value.month, 1)
+ val nextMonth = firstDay.plus(1, DateTimeUnit.MONTH)
+ firstDay.toEpochDays().toLong() to (nextMonth.toEpochDays().toLong() - 1)
+ }
+ is FhirDate.Year -> {
+ val firstDay = LocalDate(date.value, 1, 1)
+ val nextYear = LocalDate(date.value + 1, 1, 1)
+ firstDay.toEpochDays().toLong() to (nextYear.toEpochDays().toLong() - 1)
+ }
+ }
+
+internal fun fhirDateTimeToEpochMillis(dateTime: FhirDateTime): Long =
+ when (dateTime) {
+ is FhirDateTime.DateTime ->
+ dateTime.dateTime.toInstant(dateTime.utcOffset).toEpochMilliseconds()
+ is FhirDateTime.Date ->
+ dateTime.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
+ is FhirDateTime.YearMonth -> {
+ val firstDay = LocalDate(dateTime.value.year, dateTime.value.month, 1)
+ firstDay.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
+ }
+ is FhirDateTime.Year -> {
+ val firstDay = LocalDate(dateTime.value, 1, 1)
+ firstDay.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
+ }
+ }
+
+internal fun fhirDateTimeToEndEpochMillis(dateTime: FhirDateTime): Long =
+ when (dateTime) {
+ is FhirDateTime.DateTime ->
+ dateTime.dateTime.toInstant(dateTime.utcOffset).toEpochMilliseconds()
+ is FhirDateTime.Date -> {
+ val nextDay = dateTime.date.plus(1, DateTimeUnit.DAY)
+ nextDay.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - 1
+ }
+ is FhirDateTime.YearMonth -> {
+ val firstDay = LocalDate(dateTime.value.year, dateTime.value.month, 1)
+ val nextMonth = firstDay.plus(1, DateTimeUnit.MONTH)
+ nextMonth.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - 1
+ }
+ is FhirDateTime.Year -> {
+ val nextYear = LocalDate(dateTime.value + 1, 1, 1)
+ nextYear.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - 1
+ }
+ }
+
+internal fun fhirDateTimeToEpochMillisRange(dateTime: FhirDateTime): Pair =
+ fhirDateTimeToEpochMillis(dateTime) to fhirDateTimeToEndEpochMillis(dateTime)
+
+/** Result of a referenced resource search (used for include/revInclude). */
+internal data class ReferencedResourceResult(
+ val searchIndex: String,
+ val baseId: String,
+ val resource: Resource,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/NestedSearch.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/NestedSearch.kt
new file mode 100644
index 0000000000..9791a33a07
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/NestedSearch.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search
+
+import com.google.android.fhir.getResourceType
+import com.google.fhir.model.r4.Resource
+import com.google.fhir.model.r4.terminologies.ResourceType
+
+/** Lets users perform a nested search using [Search.has] api. */
+@PublishedApi internal data class NestedSearch(val param: ReferenceClientParam, val search: Search)
+
+/** Keeps the parent context for a nested query loop. */
+internal data class NestedContext(val parentType: ResourceType, val param: ClientParam)
+
+/**
+ * Provides limited support for the reverse chaining on [Search]. For example: search all Patient
+ * that have Condition - Diabetes.
+ */
+inline fun Search.has(
+ referenceParam: ReferenceClientParam,
+ init: BaseSearch.() -> Unit,
+) {
+ nestedSearches.add(
+ NestedSearch(referenceParam, Search(type = getResourceType(R::class))).apply { search.init() },
+ )
+}
+
+fun Search.has(
+ resourceType: ResourceType,
+ referenceParam: ReferenceClientParam,
+ init: BaseSearch.() -> Unit,
+) {
+ nestedSearches.add(
+ NestedSearch(referenceParam, Search(type = resourceType)).apply { search.init() },
+ )
+}
+
+/**
+ * Includes additional resources in the search results that are referenced by the base resource via
+ * the given [referenceParam].
+ */
+inline fun Search.include(
+ referenceParam: ReferenceClientParam,
+ init: BaseSearch.() -> Unit = {},
+) {
+ forwardIncludes.add(
+ NestedSearch(referenceParam, Search(type = getResourceType(R::class))).apply { search.init() },
+ )
+}
+
+fun Search.include(
+ resourceType: ResourceType,
+ referenceParam: ReferenceClientParam,
+ init: BaseSearch.() -> Unit = {},
+) {
+ forwardIncludes.add(
+ NestedSearch(referenceParam, Search(type = resourceType)).apply { search.init() },
+ )
+}
+
+/**
+ * Includes additional resources in the search results that reference the base resource via the
+ * given [referenceParam].
+ */
+inline fun Search.revInclude(
+ referenceParam: ReferenceClientParam,
+ init: BaseSearch.() -> Unit = {},
+) {
+ revIncludes.add(
+ NestedSearch(referenceParam, Search(type = getResourceType(R::class))).apply { search.init() },
+ )
+}
+
+fun Search.revInclude(
+ resourceType: ResourceType,
+ referenceParam: ReferenceClientParam,
+ init: BaseSearch.() -> Unit = {},
+) {
+ revIncludes.add(
+ NestedSearch(referenceParam, Search(type = resourceType)).apply { search.init() },
+ )
+}
+
+/**
+ * Generates the complete nested query going to several depths depending on the [Search] dsl
+ * specified by the user.
+ */
+internal fun List.nestedQuery(
+ type: ResourceType,
+ operation: Operation,
+): SearchQuery? {
+ return if (isEmpty()) {
+ null
+ } else {
+ map { it.nestedQuery(type) }
+ .let { searchQueries ->
+ SearchQuery(
+ query =
+ searchQueries.joinToString(
+ prefix = "a.resourceUuid IN ",
+ separator = " ${operation.logicalOperator} a.resourceUuid IN",
+ ) { searchQuery ->
+ "(\n${searchQuery.query}\n) "
+ },
+ args = searchQueries.flatMap { it.args },
+ )
+ }
+ }
+}
+
+private fun NestedSearch.nestedQuery(type: ResourceType): SearchQuery {
+ return search.getQuery(nestedContext = NestedContext(type, param))
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/ParamPrefixEnum.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/ParamPrefixEnum.kt
new file mode 100644
index 0000000000..9616cf9054
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/ParamPrefixEnum.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search
+
+/** KMP-compatible replacement for HAPI's `ca.uhn.fhir.rest.param.ParamPrefixEnum`. */
+enum class ParamPrefixEnum {
+ EQUAL,
+ GREATERTHAN,
+ GREATERTHAN_OR_EQUALS,
+ LESSTHAN,
+ LESSTHAN_OR_EQUALS,
+ NOT_EQUAL,
+ STARTS_AFTER,
+ ENDS_BEFORE,
+ APPROXIMATE,
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/Search.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/Search.kt
new file mode 100644
index 0000000000..71a6700406
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/Search.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search
+
+import com.google.android.fhir.search.filter.DateClientParamFilterCriteria
+import com.google.android.fhir.search.filter.DateParamFilterCriterion
+import com.google.android.fhir.search.filter.NumberParamFilterCriteria
+import com.google.android.fhir.search.filter.NumberParamFilterCriterion
+import com.google.android.fhir.search.filter.QuantityParamFilterCriteria
+import com.google.android.fhir.search.filter.QuantityParamFilterCriterion
+import com.google.android.fhir.search.filter.ReferenceParamFilterCriteria
+import com.google.android.fhir.search.filter.ReferenceParamFilterCriterion
+import com.google.android.fhir.search.filter.StringParamFilterCriteria
+import com.google.android.fhir.search.filter.StringParamFilterCriterion
+import com.google.android.fhir.search.filter.TokenParamFilterCriteria
+import com.google.android.fhir.search.filter.TokenParamFilterCriterion
+import com.google.android.fhir.search.filter.UriFilterCriteria
+import com.google.android.fhir.search.filter.UriParamFilterCriterion
+import com.google.fhir.model.r4.terminologies.ResourceType
+
+internal const val LOCAL_LAST_UPDATED = "local_lastUpdated"
+internal const val LAST_UPDATED = "_lastUpdated"
+
+/** Specifies search criteria for querying the FHIR database. */
+@SearchDslMarker
+class Search(
+ val type: ResourceType,
+ override var count: Int? = null,
+ override var from: Int? = null,
+) : BaseSearch {
+ internal val stringFilterCriteria = mutableListOf()
+ internal val dateTimeFilterCriteria = mutableListOf()
+ internal val numberFilterCriteria = mutableListOf()
+ internal val referenceFilterCriteria = mutableListOf()
+ internal val tokenFilterCriteria = mutableListOf()
+ internal val quantityFilterCriteria = mutableListOf()
+ internal val uriFilterCriteria = mutableListOf()
+ internal var sort: ClientParam? = null
+ internal var order: Order? = null
+
+ @PublishedApi internal var nestedSearches = mutableListOf()
+ @PublishedApi internal var revIncludes = mutableListOf()
+ @PublishedApi internal var forwardIncludes = mutableListOf()
+
+ override var operation = Operation.AND
+
+ override fun filter(
+ stringParameter: StringClientParam,
+ vararg init: StringParamFilterCriterion.() -> Unit,
+ operation: Operation,
+ ) {
+ val filters = mutableListOf()
+ init.forEach { StringParamFilterCriterion(stringParameter).apply(it).also(filters::add) }
+ stringFilterCriteria.add(StringParamFilterCriteria(stringParameter, filters, operation))
+ }
+
+ override fun filter(
+ referenceParameter: ReferenceClientParam,
+ vararg init: ReferenceParamFilterCriterion.() -> Unit,
+ operation: Operation,
+ ) {
+ val filters = mutableListOf()
+ init.forEach { ReferenceParamFilterCriterion(referenceParameter).apply(it).also(filters::add) }
+ referenceFilterCriteria.add(
+ ReferenceParamFilterCriteria(referenceParameter, filters, operation),
+ )
+ }
+
+ override fun filter(
+ dateParameter: DateClientParam,
+ vararg init: DateParamFilterCriterion.() -> Unit,
+ operation: Operation,
+ ) {
+ val filters = mutableListOf()
+ init.forEach { DateParamFilterCriterion(dateParameter).apply(it).also(filters::add) }
+ dateTimeFilterCriteria.add(DateClientParamFilterCriteria(dateParameter, filters, operation))
+ }
+
+ override fun filter(
+ quantityParameter: QuantityClientParam,
+ vararg init: QuantityParamFilterCriterion.() -> Unit,
+ operation: Operation,
+ ) {
+ val filters = mutableListOf()
+ init.forEach { QuantityParamFilterCriterion(quantityParameter).apply(it).also(filters::add) }
+ quantityFilterCriteria.add(QuantityParamFilterCriteria(quantityParameter, filters, operation))
+ }
+
+ override fun filter(
+ tokenParameter: TokenClientParam,
+ vararg init: TokenParamFilterCriterion.() -> Unit,
+ operation: Operation,
+ ) {
+ val filters = mutableListOf()
+ init.forEach { TokenParamFilterCriterion(tokenParameter).apply(it).also(filters::add) }
+ tokenFilterCriteria.add(TokenParamFilterCriteria(tokenParameter, filters, operation))
+ }
+
+ override fun filter(
+ numberParameter: NumberClientParam,
+ vararg init: NumberParamFilterCriterion.() -> Unit,
+ operation: Operation,
+ ) {
+ val filters = mutableListOf()
+ init.forEach { NumberParamFilterCriterion(numberParameter).apply(it).also(filters::add) }
+ numberFilterCriteria.add(NumberParamFilterCriteria(numberParameter, filters, operation))
+ }
+
+ override fun filter(
+ uriParam: UriClientParam,
+ vararg init: UriParamFilterCriterion.() -> Unit,
+ operation: Operation,
+ ) {
+ val filters = mutableListOf()
+ init.forEach { UriParamFilterCriterion(uriParam).apply(it).also(filters::add) }
+ uriFilterCriteria.add(UriFilterCriteria(uriParam, filters, operation))
+ }
+
+ override fun sort(parameter: StringClientParam, order: Order) {
+ sort = parameter
+ this.order = order
+ }
+
+ override fun sort(parameter: NumberClientParam, order: Order) {
+ sort = parameter
+ this.order = order
+ }
+
+ override fun sort(parameter: DateClientParam, order: Order) {
+ sort = parameter
+ this.order = order
+ }
+}
+
+enum class Order {
+ ASCENDING,
+ DESCENDING,
+}
+
+enum class StringFilterModifier {
+ STARTS_WITH,
+ MATCHES_EXACTLY,
+ CONTAINS,
+}
+
+/** Logical operator between the filter values or the filters themselves. */
+enum class Operation(val logicalOperator: String) {
+ OR("OR"),
+ AND("AND"),
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchDslMarker.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchDslMarker.kt
new file mode 100644
index 0000000000..65846a950f
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchDslMarker.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search
+
+@DslMarker annotation class SearchDslMarker
+
+@DslMarker annotation class BaseSearchDsl
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchExtensions.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchExtensions.kt
new file mode 100644
index 0000000000..46c64f0cf4
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchExtensions.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search
+
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.SearchResult
+import com.google.android.fhir.getResourceType
+import com.google.fhir.model.r4.Resource
+
+/**
+ * Searches the database and returns a list of resources matching the given [Search] criteria.
+ *
+ * Example usage:
+ * ```
+ * val patients = fhirEngine.search {
+ * filter(StringClientParam("name"), { value = "John" })
+ * count = 10
+ * }
+ * ```
+ */
+suspend inline fun FhirEngine.search(
+ init: Search.() -> Unit,
+): List> {
+ val search = Search(getResourceType(R::class))
+ search.init()
+ return search(search)
+}
+
+/**
+ * Returns the total count of entities available for the given [Search] criteria.
+ *
+ * Example usage:
+ * ```
+ * val count = fhirEngine.count {
+ * filter(StringClientParam("name"), { value = "John" })
+ * }
+ * ```
+ */
+suspend inline fun FhirEngine.count(
+ init: Search.() -> Unit,
+): Long {
+ val search = Search(getResourceType(R::class))
+ search.init()
+ return count(search)
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchQuery.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchQuery.kt
new file mode 100644
index 0000000000..5e79435061
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchQuery.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search
+
+// TODO: Phase 3 — Full search query implementation
+/** Represents a compiled search query ready for database execution. */
+data class SearchQuery(
+ val query: String,
+ val args: List,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/DateParamFilterCriterion.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/DateParamFilterCriterion.kt
new file mode 100644
index 0000000000..2747867728
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/DateParamFilterCriterion.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search.filter
+
+import com.google.android.fhir.search.ConditionParam
+import com.google.android.fhir.search.DateClientParam
+import com.google.android.fhir.search.Operation
+import com.google.android.fhir.search.ParamPrefixEnum
+import com.google.android.fhir.search.SearchQuery
+import com.google.android.fhir.search.getConditionParamPairForDate
+import com.google.android.fhir.search.getConditionParamPairForDateTime
+import com.google.fhir.model.r4.FhirDate
+import com.google.fhir.model.r4.FhirDateTime
+import com.google.fhir.model.r4.terminologies.ResourceType
+
+data class DateParamFilterCriterion(
+ val parameter: DateClientParam,
+ var prefix: ParamPrefixEnum = ParamPrefixEnum.EQUAL,
+ var value: DateFilterValues? = null,
+) : FilterCriterion {
+
+ override fun getConditionalParams(): List> {
+ val filterValues = value ?: error("DateFilterValues must not be null")
+ return when {
+ filterValues.date != null -> listOf(getConditionParamPairForDate(prefix, filterValues.date!!))
+ filterValues.dateTime != null ->
+ listOf(getConditionParamPairForDateTime(prefix, filterValues.dateTime!!))
+ else -> error("Either date or dateTime must be set in DateFilterValues")
+ }
+ }
+}
+
+class DateFilterValues internal constructor() {
+ var date: FhirDate? = null
+ var dateTime: FhirDateTime? = null
+
+ companion object {
+ fun of(date: FhirDate) = DateFilterValues().apply { this.date = date }
+
+ fun of(dateTime: FhirDateTime) = DateFilterValues().apply { this.dateTime = dateTime }
+ }
+}
+
+/**
+ * Splits date filter criteria into separate queries for DateIndexEntity and DateTimeIndexEntity,
+ * then combines them with UNION.
+ */
+internal data class DateClientParamFilterCriteria(
+ val parameter: DateClientParam,
+ override val filters: List,
+ override val operation: Operation,
+) : FilterCriteria(filters, operation, parameter, "DateIndexEntity") {
+
+ override fun query(type: ResourceType): SearchQuery {
+ val dateFilters = filters.filter { it.value?.date != null }
+ val dateTimeFilters = filters.filter { it.value?.dateTime != null }
+
+ val queries = mutableListOf()
+
+ if (dateFilters.isNotEmpty()) {
+ queries.add(
+ DateFilterCriteria(parameter, dateFilters, operation).query(type),
+ )
+ }
+
+ if (dateTimeFilters.isNotEmpty()) {
+ queries.add(
+ DateTimeFilterCriteria(parameter, dateTimeFilters, operation).query(type),
+ )
+ }
+
+ if (queries.isEmpty()) {
+ return super.query(type)
+ }
+
+ if (queries.size == 1) {
+ return queries.first()
+ }
+
+ val unionOperator =
+ if (operation == Operation.OR) "\n UNION \n" else "\n INTERSECT \n"
+ return SearchQuery(
+ queries.joinToString(unionOperator) { it.query },
+ queries.flatMap { it.args },
+ )
+ }
+}
+
+private data class DateFilterCriteria(
+ val parameter: DateClientParam,
+ override val filters: List,
+ override val operation: Operation,
+) : FilterCriteria(filters, operation, parameter, "DateIndexEntity")
+
+private data class DateTimeFilterCriteria(
+ val parameter: DateClientParam,
+ override val filters: List,
+ override val operation: Operation,
+) : FilterCriteria(filters, operation, parameter, "DateTimeIndexEntity")
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/FilterCriterion.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/FilterCriterion.kt
new file mode 100644
index 0000000000..464c885a27
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/FilterCriterion.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search.filter
+
+import com.google.android.fhir.search.ClientParam
+import com.google.android.fhir.search.ConditionParam
+import com.google.android.fhir.search.Operation
+import com.google.android.fhir.search.SearchQuery
+import com.google.fhir.model.r4.terminologies.ResourceType
+
+/** Represents filter for a [ClientParam]. */
+internal interface FilterCriterion {
+
+ /** Returns [ConditionParam]s for the particular [FilterCriterion]. */
+ fun getConditionalParams(): List>
+}
+
+/**
+ * Contains a set of filter criteria sharing the same search parameter. e.g A
+ * [StringParamFilterCriteria] may contain a list of [StringParamFilterCriterion] each with different
+ * [StringParamFilterCriterion.value] and [StringParamFilterCriterion.modifier].
+ */
+internal sealed class FilterCriteria(
+ open val filters: List,
+ open val operation: Operation,
+ val param: ClientParam,
+ private val entityTableName: String,
+) {
+
+ /**
+ * Returns a [SearchQuery] for the [FilterCriteria] based on all the [FilterCriterion]. Subclasses
+ * may override to provide custom query generation — see [DateClientParamFilterCriteria].
+ */
+ open fun query(type: ResourceType): SearchQuery {
+ val conditionParams = filters.flatMap { it.getConditionalParams() }
+ return SearchQuery(
+ """
+ SELECT resourceUuid FROM $entityTableName
+ WHERE resourceType = ? AND index_name = ?${if (conditionParams.isNotEmpty()) " AND ${conditionParams.toQueryString(operation)}" else ""}
+ """,
+ listOf(type.name, param.paramName) + conditionParams.flatMap { it.params },
+ )
+ }
+
+ /**
+ * Joins [ConditionParam]s to generate condition string for the SearchQuery. Uses recursive
+ * divide-and-conquer to properly bracket conditions with the operation.
+ */
+ private fun List>.toQueryString(operation: Operation): String {
+ if (this.size == 1) {
+ return first().queryString
+ }
+
+ val mid = this.size / 2
+ val left = this.subList(0, mid).toQueryString(operation)
+ val right = this.subList(mid, this.size).toQueryString(operation)
+
+ return "($left ${operation.logicalOperator} $right)"
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/NumberParamFilterCriterion.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/NumberParamFilterCriterion.kt
new file mode 100644
index 0000000000..0968f593e0
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/NumberParamFilterCriterion.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search.filter
+
+import com.google.android.fhir.search.ConditionParam
+import com.google.android.fhir.search.NumberClientParam
+import com.google.android.fhir.search.Operation
+import com.google.android.fhir.search.ParamPrefixEnum
+import com.google.android.fhir.search.getConditionParamPair
+import com.ionspin.kotlin.bignum.decimal.BigDecimal
+
+data class NumberParamFilterCriterion(
+ val parameter: NumberClientParam,
+ var prefix: ParamPrefixEnum? = null,
+ var value: BigDecimal? = null,
+) : FilterCriterion {
+
+ override fun getConditionalParams(): List> {
+ return listOf(getConditionParamPair(prefix, value!!))
+ }
+}
+
+internal data class NumberParamFilterCriteria(
+ val parameter: NumberClientParam,
+ override val filters: List,
+ override val operation: Operation,
+) : FilterCriteria(filters, operation, parameter, "NumberIndexEntity")
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/QuantityParamFilterCriterion.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/QuantityParamFilterCriterion.kt
new file mode 100644
index 0000000000..278332999d
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/QuantityParamFilterCriterion.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search.filter
+
+import com.google.android.fhir.search.ConditionParam
+import com.google.android.fhir.search.Operation
+import com.google.android.fhir.search.ParamPrefixEnum
+import com.google.android.fhir.search.QuantityClientParam
+import com.google.android.fhir.search.getConditionParamPair
+import com.ionspin.kotlin.bignum.decimal.BigDecimal
+
+data class QuantityParamFilterCriterion(
+ val parameter: QuantityClientParam,
+ var prefix: ParamPrefixEnum? = null,
+ var value: BigDecimal? = null,
+ var system: String? = null,
+ var unit: String? = null,
+) : FilterCriterion {
+
+ override fun getConditionalParams(): List> {
+ return listOf(getConditionParamPair(prefix, value!!, system, unit))
+ }
+}
+
+internal data class QuantityParamFilterCriteria(
+ val parameter: QuantityClientParam,
+ override val filters: List,
+ override val operation: Operation,
+) : FilterCriteria(filters, operation, parameter, "QuantityIndexEntity")
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/ReferenceParamFilterCriterion.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/ReferenceParamFilterCriterion.kt
new file mode 100644
index 0000000000..f4b9a771e4
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/ReferenceParamFilterCriterion.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search.filter
+
+import com.google.android.fhir.search.ConditionParam
+import com.google.android.fhir.search.Operation
+import com.google.android.fhir.search.ReferenceClientParam
+
+data class ReferenceParamFilterCriterion(
+ val parameter: ReferenceClientParam,
+ var value: String? = null,
+) : FilterCriterion {
+
+ override fun getConditionalParams(): List> {
+ return listOf(ConditionParam("index_value = ?", value!!))
+ }
+}
+
+internal data class ReferenceParamFilterCriteria(
+ val parameter: ReferenceClientParam,
+ override val filters: List,
+ override val operation: Operation,
+) : FilterCriteria(filters, operation, parameter, "ReferenceIndexEntity")
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/StringParamFilterCriterion.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/StringParamFilterCriterion.kt
new file mode 100644
index 0000000000..3cca1a0ee6
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/StringParamFilterCriterion.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search.filter
+
+import com.google.android.fhir.search.ConditionParam
+import com.google.android.fhir.search.Operation
+import com.google.android.fhir.search.StringClientParam
+import com.google.android.fhir.search.StringFilterModifier
+
+data class StringParamFilterCriterion(
+ val parameter: StringClientParam,
+ var modifier: StringFilterModifier = StringFilterModifier.STARTS_WITH,
+ var value: String? = null,
+) : FilterCriterion {
+
+ override fun getConditionalParams(): List> {
+ return listOf(
+ when (modifier) {
+ StringFilterModifier.STARTS_WITH ->
+ ConditionParam("index_value LIKE ? || '%' COLLATE NOCASE", value!!)
+ StringFilterModifier.MATCHES_EXACTLY -> ConditionParam("index_value = ?", value!!)
+ StringFilterModifier.CONTAINS ->
+ ConditionParam("index_value LIKE '%' || ? || '%' COLLATE NOCASE", value!!)
+ },
+ )
+ }
+}
+
+internal data class StringParamFilterCriteria(
+ val parameter: StringClientParam,
+ override val filters: List,
+ override val operation: Operation,
+) : FilterCriteria(filters, operation, parameter, "StringIndexEntity")
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/TokenParamFilterCriterion.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/TokenParamFilterCriterion.kt
new file mode 100644
index 0000000000..6aeac10cd0
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/TokenParamFilterCriterion.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search.filter
+
+import com.google.android.fhir.search.ConditionParam
+import com.google.android.fhir.search.Operation
+import com.google.android.fhir.search.TokenClientParam
+
+data class TokenParamFilterCriterion(var parameter: TokenClientParam) : FilterCriterion {
+
+ var value: TokenFilterValue? = null
+
+ override fun getConditionalParams(): List> {
+ return value!!.tokenFilters.map {
+ ConditionParam(
+ "index_value = ?${if (it.uri.isNullOrBlank()) "" else " AND IFNULL(index_system,'') = ?"}",
+ listOfNotNull(it.code, it.uri),
+ )
+ }
+ }
+}
+
+class TokenFilterValue internal constructor() {
+ internal val tokenFilters = mutableListOf()
+
+ companion object {
+ fun string(code: String) =
+ TokenFilterValue().apply { tokenFilters.add(TokenParamFilterValueInstance(code = code)) }
+
+ fun coding(system: String?, code: String) =
+ TokenFilterValue().apply {
+ tokenFilters.add(TokenParamFilterValueInstance(uri = system, code = code))
+ }
+
+ fun boolean(value: Boolean) =
+ TokenFilterValue().apply {
+ tokenFilters.add(TokenParamFilterValueInstance(code = value.toString()))
+ }
+ }
+}
+
+internal data class TokenParamFilterValueInstance(
+ var uri: String? = null,
+ var code: String,
+)
+
+internal data class TokenParamFilterCriteria(
+ var parameter: TokenClientParam,
+ override val filters: List,
+ override val operation: Operation,
+) : FilterCriteria(filters, operation, parameter, "TokenIndexEntity")
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/UriParamFilterCriterion.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/UriParamFilterCriterion.kt
new file mode 100644
index 0000000000..3ec19c0d9e
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/filter/UriParamFilterCriterion.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.search.filter
+
+import com.google.android.fhir.search.ConditionParam
+import com.google.android.fhir.search.Operation
+import com.google.android.fhir.search.UriClientParam
+
+data class UriParamFilterCriterion(
+ val parameter: UriClientParam,
+ var value: String? = null,
+) : FilterCriterion {
+
+ override fun getConditionalParams(): List> {
+ return listOf(ConditionParam("index_value = ?", value!!))
+ }
+}
+
+internal data class UriFilterCriteria(
+ val parameter: UriClientParam,
+ override val filters: List,
+ override val operation: Operation,
+) : FilterCriteria(filters, operation, parameter, "UriIndexEntity")
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/Config.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/Config.kt
new file mode 100644
index 0000000000..50bfe60d11
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/Config.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2023-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import io.ktor.http.encodeURLQueryComponent
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Class that holds what type of resources we need to synchronise and what are the parameters of
+ * that type. e.g. we only want to synchronise patients that live in United States
+ * `ResourceSyncParams(ResourceType.Patient, mapOf("address-country" to "United States")`
+ */
+typealias ParamMap = Map
+
+/** Constant for the max number of retries in case of sync failure */
+@PublishedApi internal const val MAX_RETRIES_ALLOWED = "max_retries"
+
+/** Constant for the Greater Than Search Prefix */
+@PublishedApi internal const val GREATER_THAN_PREFIX = "gt"
+
+@PublishedApi internal const val UNIQUE_WORK_NAME = "unique_work_name"
+
+val defaultRetryConfiguration =
+ RetryConfiguration(BackoffCriteria(BackoffPolicy.LINEAR, 30.seconds), 3)
+
+object SyncDataParams {
+ const val SORT_KEY = "_sort"
+ const val LAST_UPDATED_KEY = "_lastUpdated"
+ const val SUMMARY_KEY = "_summary"
+ const val SUMMARY_COUNT_VALUE = "count"
+}
+
+enum class NetworkType {
+ NOT_REQUIRED,
+ CONNECTED,
+ UNMETERED,
+ NOT_ROAMING,
+ METERED,
+}
+
+data class SyncConstraints(
+ val requiredNetworkType: NetworkType = NetworkType.CONNECTED,
+ val requiresBatteryNotLow: Boolean = false,
+ val requiresCharging: Boolean = false,
+ val requiresDeviceIdle: Boolean = false,
+ val requiresStorageNotLow: Boolean = false,
+)
+
+/** Configuration for period synchronisation */
+class PeriodicSyncConfiguration(
+ /**
+ * Constraints that specify the requirements needed before the synchronization is triggered. E.g.
+ * network type (Wi-Fi, 3G etc.), the device should be charging etc.
+ */
+ val syncConstraints: SyncConstraints = SyncConstraints(),
+
+ /** The interval at which the sync should be triggered in. */
+ val repeat: RepeatInterval,
+
+ /** Configuration for synchronization retry */
+ val retryConfiguration: RetryConfiguration? = defaultRetryConfiguration,
+)
+
+data class RepeatInterval(
+ /** The interval at which the sync should be triggered in */
+ val interval: Duration,
+)
+
+fun ParamMap.concatParams(): String {
+ return this.entries.joinToString("&") { (key, value) ->
+ "$key=${value.encodeURLQueryComponent()}"
+ }
+}
+
+/** Configuration for synchronization retry */
+data class RetryConfiguration(
+ /** The criteria to retry failed synchronization work. */
+ val backoffCriteria: BackoffCriteria,
+
+ /** Maximum retries for a failing sync worker */
+ val maxRetries: Int,
+)
+
+enum class BackoffPolicy {
+ EXPONENTIAL,
+ LINEAR,
+}
+
+/** The criteria for sync worker failure retry. */
+data class BackoffCriteria(
+ /** Backoff policy */
+ val backoffPolicy: BackoffPolicy,
+
+ /** Backoff delay for each retry attempt. */
+ val backoffDelay: Duration,
+)
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/ConflictResolver.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/ConflictResolver.kt
new file mode 100644
index 0000000000..149a164358
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/ConflictResolver.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2022-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import com.google.fhir.model.r4.Resource
+
+/** Resolves conflicts between the client and remote changes in a Resource. */
+fun interface ConflictResolver {
+ /**
+ * @param local The modified resource on the client.
+ * @param remote The latest version of the resource downloaded from the remote server.
+ */
+ fun resolve(local: Resource, remote: Resource): ConflictResolutionResult
+}
+
+/**
+ * Contains the result of the conflict resolution. For now, [Resolved] is the only acceptable result
+ * and the expectation is that the client will resolve each and every conflict in-flight that may
+ * arise during the sync process. There is no way for the client application to abort or defer the
+ * conflict resolution to a later time.
+ */
+sealed class ConflictResolutionResult
+
+data class Resolved(val resolved: Resource) : ConflictResolutionResult()
+
+/** Accepts the local change and rejects the remote change. */
+val AcceptLocalConflictResolver = ConflictResolver { local, _ -> Resolved(local) }
+
+/** Accepts the remote change and rejects the local change. */
+val AcceptRemoteConflictResolver = ConflictResolver { _, remote -> Resolved(remote) }
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/DataSource.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/DataSource.kt
new file mode 100644
index 0000000000..589118bb96
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/DataSource.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2023-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import com.google.android.fhir.sync.download.DownloadRequest
+import com.google.android.fhir.sync.upload.request.UploadRequest
+import com.google.fhir.model.r4.Bundle
+import com.google.fhir.model.r4.OperationOutcome
+import com.google.fhir.model.r4.Resource
+
+/** Interface for an abstraction of retrieving FHIR data from a network source. */
+internal interface DataSource {
+ /** @return [Bundle] on a successful operation, [OperationOutcome] otherwise. */
+ suspend fun download(downloadRequest: DownloadRequest): Resource
+
+ /**
+ * @return [Bundle] of type [Bundle.BundleType.Transaction_Response] for a successful operation,
+ * [OperationOutcome] otherwise. Call this api with the [Bundle] that needs to be uploaded to
+ * the server.
+ */
+ suspend fun upload(request: UploadRequest): Resource
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/DataStoreFactory.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/DataStoreFactory.kt
new file mode 100644
index 0000000000..bc752f752f
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/DataStoreFactory.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import okio.Path.Companion.toPath
+
+internal const val fhirDataStoreFileName = "fhir.engine.preferences_pb"
+
+fun createDataStore(producePath: () -> String): DataStore =
+ PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/DownloadWorkManager.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/DownloadWorkManager.kt
new file mode 100644
index 0000000000..21a1b7b9b5
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/DownloadWorkManager.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2023-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import com.google.android.fhir.sync.download.DownloadRequest
+import com.google.fhir.model.r4.Bundle
+import com.google.fhir.model.r4.List
+import com.google.fhir.model.r4.Resource
+import com.google.fhir.model.r4.terminologies.ResourceType
+
+/**
+ * Manages the process of downloading FHIR resources from a remote server.
+ *
+ * Implementations of this interface define how download requests are generated and how responses
+ * are processed to update the local database.
+ */
+
+/* TODO(jingtang10): What happens after the end of a download job. Should a new download work
+ * manager be created or should there be an API to restart a new download job.
+ */
+interface DownloadWorkManager {
+ /** Returns the next [DownloadRequest] to be executed, or `null` if there are no more requests. */
+ suspend fun getNextRequest(): DownloadRequest?
+
+ /* TODO: Generalize the DownloadWorkManager API to not sequentially download resource by type (https://github.com/google/android-fhir/issues/1884) */
+ /**
+ * Returns a map of [ResourceType] to URLs that can be used to retrieve the total count of
+ * resources to be downloaded for each type. This information is used for displaying download
+ * progress.
+ */
+ suspend fun getSummaryRequestUrls(): Map
+
+ /**
+ * Processes the [response] received from the FHIR server.
+ *
+ * This method is responsible for:
+ * * Extracting resources from the response.
+ * * Identifying additional resource URLs to download, for example to handle pagination.
+ * * Returning the resources to be saved to the local database.
+ *
+ * @param response The FHIR resource received from the server, often a [List] or [Bundle].
+ * @return A collection of [Resource]s extracted from the response.
+ */
+ suspend fun processResponse(response: Resource): Collection
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/FhirDataStore.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/FhirDataStore.kt
new file mode 100644
index 0000000000..d747bf2680
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/FhirDataStore.kt
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2025-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.stringPreferencesKey
+import co.touchlab.kermit.Logger
+import kotlin.time.Instant
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.serialization.json.Json
+
+@PublishedApi
+internal class FhirDataStore(private val dataStore: DataStore) {
+
+ private val json = Json { ignoreUnknownKeys = true }
+ private val mutexCacheMutex = Mutex()
+ private val mutexCache = mutableMapOf()
+
+ /**
+ * Observes the sync job terminal state for a given key and provides it as a Flow.
+ *
+ * @param key The key associated with the sync job.
+ * @return A Flow of [LastSyncJobStatus] representing the terminal state of the sync job, or null
+ * if the state is not allowed.
+ */
+ internal fun observeLastSyncJobStatus(key: String): Flow =
+ dataStore.data
+ .catch { e ->
+ Logger.e(e) { "Error reading FhirDataStore" }
+ emit(emptyPreferences())
+ }
+ .map { prefs ->
+ prefs[stringPreferencesKey(key)]
+ ?.let { json.decodeFromString(it) }
+ ?.let {
+ when (it) {
+ is SyncJobStatus.Succeeded -> LastSyncJobStatus.Succeeded(it.timestamp)
+ is SyncJobStatus.Failed -> LastSyncJobStatus.Failed(it.timestamp)
+ else -> null
+ }
+ }
+ }
+
+ /**
+ * Edits the DataStore to store synchronization job status. It creates a data object containing
+ * the state type and serialized state of the synchronization job status. The edited preferences
+ * are updated with the serialized data.
+ *
+ * @param key The key associated with the data to edit.
+ * @param syncJobStatus The synchronization job status to be stored.
+ */
+ internal suspend fun writeTerminalSyncJobStatus(key: String, syncJobStatus: SyncJobStatus) {
+ when (syncJobStatus) {
+ is SyncJobStatus.Succeeded,
+ is SyncJobStatus.Failed, -> writeStatus(key, syncJobStatus)
+ else -> error("Cannot persist non-terminal sync status")
+ }
+ }
+
+ internal suspend fun readLastSyncTimestamp(): Instant? =
+ dataStore.data.first()[stringPreferencesKey(LAST_SYNC_TIMESTAMP_KEY)]?.let { Instant.parse(it) }
+
+ internal suspend fun writeLastSyncTimestamp(timestamp: Instant) =
+ dataStore.edit { it[stringPreferencesKey(LAST_SYNC_TIMESTAMP_KEY)] = timestamp.toString() }
+
+ /** Stores the given unique-work-name in DataStore. */
+ @PublishedApi
+ internal suspend fun storeUniqueWorkName(key: String, value: String) =
+ mutexFor(key).withLock { dataStore.edit { it[stringPreferencesKey("$key-name")] = value } }
+
+ @PublishedApi
+ internal suspend fun removeUniqueWorkName(key: String) =
+ mutexFor(key).withLock {
+ dataStore.edit {
+ val value = it.remove(stringPreferencesKey("$key-name"))
+ Logger.d("Removed value: $value")
+ }
+ }
+
+ /** Fetches the stored unique-work-name from DataStore. */
+ @PublishedApi
+ internal suspend fun fetchUniqueWorkName(key: String): String? =
+ mutexFor(key).withLock { dataStore.data.first()[stringPreferencesKey("$key-name")] }
+
+ private suspend fun writeStatus(key: String, syncJobStatus: SyncJobStatus) {
+ dataStore.edit { prefs ->
+ prefs[stringPreferencesKey(key)] = json.encodeToString(syncJobStatus)
+ }
+ }
+
+ private fun readLastStatus(prefs: Preferences, key: String): LastSyncJobStatus? {
+ // This method is now replaced by logic in observeLastSyncJobStatus map.
+ // Keeping it for internal use if needed, but updated to use json.
+ return prefs[stringPreferencesKey(key)]
+ ?.let { json.decodeFromString(it) }
+ ?.let {
+ when (it) {
+ is SyncJobStatus.Succeeded -> LastSyncJobStatus.Succeeded(it.timestamp)
+ is SyncJobStatus.Failed -> LastSyncJobStatus.Failed(it.timestamp)
+ else -> null
+ }
+ }
+ }
+
+ private suspend fun mutexFor(key: String): Mutex =
+ mutexCacheMutex.withLock { mutexCache.getOrPut(key) { Mutex() } }
+
+ companion object {
+ private const val LAST_SYNC_TIMESTAMP_KEY = "LAST_SYNC_TIMESTAMP"
+ }
+}
diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/FhirSynchronizer.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/FhirSynchronizer.kt
new file mode 100644
index 0000000000..f2981d4fd1
--- /dev/null
+++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/FhirSynchronizer.kt
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2023-2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.sync
+
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.sync.download.DownloadState
+import com.google.android.fhir.sync.download.Downloader
+import com.google.android.fhir.sync.upload.UploadStrategy
+import com.google.android.fhir.sync.upload.Uploader
+import com.google.fhir.model.r4.terminologies.ResourceType
+import kotlin.time.Clock
+import kotlin.time.Instant
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.serialization.Serializable
+
+@Serializable
+enum class SyncOperation {
+ DOWNLOAD,
+ UPLOAD,
+}
+
+private sealed class SyncResult {
+ val timestamp: Instant = Clock.System.now()
+
+ class Success : SyncResult()
+
+ data class Error(val exceptions: List) : SyncResult()
+}
+
+@Serializable
+data class ResourceSyncException(
+ val resourceType: ResourceType,
+ val exceptionMessage: String? = null,
+ val exceptionStacktrace: String? = null,
+)
+
+internal data class UploadConfiguration(
+ val uploader: Uploader,
+ val uploadStrategy: UploadStrategy,
+)
+
+internal class DownloadConfiguration(
+ val downloader: Downloader,
+ val conflictResolver: ConflictResolver,
+)
+
+/** Class that helps synchronize the data source and save it in the local database */
+internal class FhirSynchronizer(
+ private val fhirEngine: FhirEngine,
+ private val uploadConfiguration: UploadConfiguration,
+ private val downloadConfiguration: DownloadConfiguration,
+ private val datastoreUtil: FhirDataStore,
+) {
+
+ private val _syncState = MutableSharedFlow()
+ val syncState: SharedFlow = _syncState
+
+ private suspend fun setSyncState(state: SyncJobStatus) = _syncState.emit(state)
+
+ private suspend fun setSyncState(result: SyncResult): SyncJobStatus {
+ // todo: emit this properly instead of using datastore?
+ datastoreUtil.writeLastSyncTimestamp(result.timestamp)
+
+ val state =
+ when (result) {
+ is SyncResult.Success -> SyncJobStatus.Succeeded()
+ is SyncResult.Error -> SyncJobStatus.Failed(result.exceptions)
+ }
+
+ setSyncState(state)
+ return state
+ }
+
+ /**
+ * Manages the sequential execution of downloading and uploading for coordinated operation. This
+ * function is coroutine-safe, ensuring that multiple invocations will not interfere with each
+ * other.
+ */
+ suspend fun synchronize(): SyncJobStatus {
+ mutex.withLock {
+ setSyncState(SyncJobStatus.Started())
+
+ return listOf(download(), upload())
+ .filterIsInstance