From b4df5b4c6fadea726c311b772ab1e77d794ed84e Mon Sep 17 00:00:00 2001 From: Ranbir Singh Date: Mon, 22 Jun 2026 10:52:28 +0530 Subject: [PATCH] Codegen: add opt-in auto-sync (generated models track the live schema) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit supabaseCodegen { autoSync = true } wires generation into every Kotlin compile so models are regenerated from the live schema into build/generated/supabase (gitignored, never hand-edited) — the SQLDelight/RevenueCat 'generated code is build output' model. Missing SUPABASE_URL/KEY skips with a warning (offline/CI stay green); a reachable-but- failing fetch still fails the build. Default remains the on-demand task. Switch the chat-compose sample to auto-sync: drop the committed generated models and generate them into build/ at build time (config-cache compatible). --- CHANGELOG.md | 10 ++ samples/chat-compose/build.gradle.kts | 66 +++++++++++++ .../chat/generated/tables/ChatMessages.kt | 22 ----- .../sample/chat/generated/tables/ChatRooms.kt | 16 ---- .../chat/generated/tables/E2eMessages.kt | 16 ---- .../codegen/gradle/SupabaseCodegenPlugin.kt | 93 ++++++++++++++----- .../gradle/SupabaseCodegenFunctionalTest.kt | 33 +++++++ 7 files changed, 178 insertions(+), 78 deletions(-) delete mode 100644 samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatMessages.kt delete mode 100644 samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatRooms.kt delete mode 100644 samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/E2eMessages.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 6799eac4..5efcc58b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +### Added + +- **Codegen auto-sync** — `supabaseCodegen { autoSync = true }` wires model generation into every + Kotlin compile so the generated models always track the live schema (the SQLDelight/RevenueCat + "generated code is build output, never hand-edited" model). Output goes to `build/generated/supabase` + (gitignore it) and the generator owns it. When `SUPABASE_URL`/`SUPABASE_KEY` are unset, auto-sync + is skipped (with a warning) rather than failing, so offline/CI builds without credentials still + build; a configured-but-unreachable fetch still fails the build. The default stays the on-demand + `generateSupabaseModels` task (commit the output, like `supabase gen types`). + ### Changed - **Codegen now emits one file per model in typed sub-packages** instead of a single diff --git a/samples/chat-compose/build.gradle.kts b/samples/chat-compose/build.gradle.kts index 38b9fd49..a409f148 100644 --- a/samples/chat-compose/build.gradle.kts +++ b/samples/chat-compose/build.gradle.kts @@ -52,6 +52,72 @@ android { } } +// --- Supabase model auto-sync ------------------------------------------------------------- +// Regenerate @Serializable models from the LIVE schema into build/generated/supabase before +// every build, so they always track the database. Output lives under build/ (gitignored) and is +// never hand-edited — the SQLDelight "generated code is build output" model. Skips gracefully when +// SUPABASE_URL/SUPABASE_KEY are unset, so the sample still configures offline / without an instance. +// +// (A standalone consumer would instead apply the published plugin with `autoSync = true`; this +// sample drives the codegen CLI directly because a project can't apply a sibling module's plugin +// within the same Gradle build.) +// Resolve url/key via providers at configuration time (tracked as config-cache inputs) so we +// avoid storing script-capturing lambdas on the task, which the configuration cache rejects. +val supabaseGeneratedDir = + layout.buildDirectory + .dir("generated/supabase") + .get() + .asFile +val supabaseUrlForGen = + providers + .gradleProperty("SUPABASE_URL") + .orElse(providers.environmentVariable("SUPABASE_URL")) + .orElse("") + .get() +val supabaseKeyForGen = + providers + .gradleProperty("SUPABASE_KEY") + .orElse(providers.environmentVariable("SUPABASE_KEY")) + .orElse("") + .get() +val supabaseCodegenConfigured = supabaseUrlForGen.isNotBlank() && supabaseKeyForGen.isNotBlank() + +val codegenClasspath: Configuration by configurations.creating +dependencies { codegenClasspath(project(":supabase-codegen")) } + +val generateSupabaseModels by tasks.registering(JavaExec::class) { + group = "supabase" + description = "Auto-sync @Serializable models from the live Supabase schema" + classpath = codegenClasspath + mainClass.set("io.github.androidpoet.supabase.codegen.MainKt") + args( + "--url", + supabaseUrlForGen, + "--key", + supabaseKeyForGen, + "--package", + "io.github.androidpoet.supabase.sample.chat.generated", + "--out", + supabaseGeneratedDir.path, + ) + // No declared outputs → JavaExec always runs (the remote schema can change with no local + // change). Disabled (not failed) when unconfigured so the sample still builds offline. + enabled = supabaseCodegenConfigured +} + +if (!supabaseCodegenConfigured) { + logger.lifecycle("supabase codegen: SUPABASE_URL/SUPABASE_KEY not set — model auto-sync disabled for this build.") +} + +// Compile the generated models, and regenerate them first on every Kotlin compile. +android.sourceSets + .getByName("main") + .java + .srcDir(supabaseGeneratedDir) +tasks + .matching { it.name.startsWith("compile") && it.name.contains("Kotlin") } + .configureEach { dependsOn(generateSupabaseModels) } + dependencies { implementation(project(":supabase-client")) implementation(project(":supabase-auth")) diff --git a/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatMessages.kt b/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatMessages.kt deleted file mode 100644 index bd0dee97..00000000 --- a/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatMessages.kt +++ /dev/null @@ -1,22 +0,0 @@ -// Generated from the Supabase schema. Do not edit by hand. -// To customise a model, add extension functions/properties in your OWN file — -// this file is regenerated (overwritten) on every run. -package io.github.androidpoet.supabase.sample.chat.generated.tables - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlin.String - -@Serializable -public data class ChatMessages( - public val id: String, - @SerialName("room_id") - public val roomId: String, - @SerialName("sender_id") - public val senderId: String? = null, - @SerialName("sender_name") - public val senderName: String, - public val body: String, - @SerialName("created_at") - public val createdAt: String, -) diff --git a/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatRooms.kt b/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatRooms.kt deleted file mode 100644 index e425c008..00000000 --- a/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatRooms.kt +++ /dev/null @@ -1,16 +0,0 @@ -// Generated from the Supabase schema. Do not edit by hand. -// To customise a model, add extension functions/properties in your OWN file — -// this file is regenerated (overwritten) on every run. -package io.github.androidpoet.supabase.sample.chat.generated.tables - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlin.String - -@Serializable -public data class ChatRooms( - public val id: String, - public val name: String, - @SerialName("created_at") - public val createdAt: String, -) diff --git a/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/E2eMessages.kt b/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/E2eMessages.kt deleted file mode 100644 index 62099171..00000000 --- a/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/E2eMessages.kt +++ /dev/null @@ -1,16 +0,0 @@ -// Generated from the Supabase schema. Do not edit by hand. -// To customise a model, add extension functions/properties in your OWN file — -// this file is regenerated (overwritten) on every run. -package io.github.androidpoet.supabase.sample.chat.generated.tables - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlin.String - -@Serializable -public data class E2eMessages( - public val id: String, - public val body: String, - @SerialName("created_at") - public val createdAt: String, -) diff --git a/supabase-codegen-gradle/src/main/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPlugin.kt b/supabase-codegen-gradle/src/main/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPlugin.kt index 843e163f..b770d303 100644 --- a/supabase-codegen-gradle/src/main/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPlugin.kt +++ b/supabase-codegen-gradle/src/main/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPlugin.kt @@ -21,11 +21,17 @@ import org.gradle.work.DisableCachingByDefault * url.set(providers.environmentVariable("SUPABASE_URL")) * key.set(providers.environmentVariable("SUPABASE_KEY")) * packageName.set("com.example.db") - * outputDir.set(layout.projectDirectory.dir("src/commonMain/kotlin")) + * autoSync.set(true) // regenerate from the live schema on every build * } * ``` * * `url`/`key` fall back to the `SUPABASE_URL` / `SUPABASE_KEY` environment variables. + * + * With [autoSync] on, the models are regenerated into [outputDir] (defaults to + * `build/generated/supabase`, which you should gitignore) before every Kotlin compile, so they + * always track the database — the SQLDelight/RevenueCat "generated code is build output, never + * edited" model. Add the output as a source directory, e.g. + * `kotlin.sourceSets.commonMain { kotlin.srcDir(layout.buildDirectory.dir("generated/supabase")) }`. */ public abstract class SupabaseCodegenExtension { /** Project URL, e.g. `https://.supabase.co`. Falls back to `SUPABASE_URL`. */ @@ -39,13 +45,22 @@ public abstract class SupabaseCodegenExtension { /** Where to write the generated files. Defaults to `build/generated/supabase`. */ public abstract val outputDir: DirectoryProperty + + /** + * When `true`, regenerate the models from the live schema before every Kotlin compile so they + * stay in sync with the database (output goes to [outputDir]; gitignore it and never hand-edit). + * Requires network + a key on every build; if neither `url`/`key` nor the env vars are set, the + * build skips codegen (with a warning) rather than failing, so it stays buildable offline/in CI + * without credentials. A reachable-but-failing fetch still fails the build. Defaults to `false` + * (the on-demand `generateSupabaseModels` task, committed like `supabase gen types`). + */ + public abstract val autoSync: Property } /** - * Fetches the Supabase schema and writes one Kotlin file per table/enum of `@Serializable` models. This is - * an on-demand task — it is deliberately NOT wired into `compileKotlin`, because the schema - * lives behind the network and needs a key, and you don't want either on every build. Run it - * when your schema changes (and commit the result, the way `supabase gen types` is used). + * Fetches the Supabase schema and writes one Kotlin file per table/enum of `@Serializable` models. + * By default this is an on-demand task; with `supabaseCodegen.autoSync = true` it is wired to run + * before every Kotlin compile so the models always track the live schema. * * Everything is `@Internal`: this is a side-effecting command whose real input (the remote * schema) Gradle can't observe, so tracking inputs/outputs would only add footguns — notably @@ -67,21 +82,37 @@ public abstract class GenerateSupabaseModelsTask : DefaultTask() { @get:Internal public abstract val outputDir: DirectoryProperty + /** Set by the plugin from `supabaseCodegen.autoSync`; controls the missing-credentials behaviour. */ + @get:Internal + public abstract val autoSync: Property + @TaskAction public fun generate() { - val projectUrl = - url.orNull?.takeIf { it.isNotBlank() } - ?: throw GradleException("supabaseCodegen.url is not set (or export SUPABASE_URL).") - val apiKey = - key.orNull?.takeIf { it.isNotBlank() } - ?: throw GradleException( - "supabaseCodegen.key is not set (or export SUPABASE_KEY). Use a key that can read your tables.", + val auto = autoSync.getOrElse(false) + val projectUrl = url.orNull?.takeIf { it.isNotBlank() } + val apiKey = key.orNull?.takeIf { it.isNotBlank() } + + // In auto-sync mode an unconfigured build (no url/key) must not fail — that would break + // offline/CI builds and contributors who don't have a Supabase instance. Skip instead. + // A configured-but-unreachable fetch below still throws and fails the build. + if (projectUrl == null || apiKey == null) { + if (auto) { + logger.warn( + "supabaseCodegen: SUPABASE_URL/SUPABASE_KEY not set — skipping auto-sync codegen for this build.", ) - val pkg = packageName.get() + return + } + throw GradleException( + when (projectUrl) { + null -> "supabaseCodegen.url is not set (or export SUPABASE_URL)." + else -> "supabaseCodegen.key is not set (or export SUPABASE_KEY). Use a key that can read your tables." + }, + ) + } logger.lifecycle("Fetching Supabase schema from ${projectUrl.trimEnd('/')}/rest/v1/ …") val spec = SchemaFetcher.fetch(projectUrl, apiKey) - val files = SupabaseModelGenerator.generate(spec, pkg) + val files = SupabaseModelGenerator.generate(spec, packageName.get()) val root = outputDir.get().asFile for (file in files) { @@ -100,17 +131,31 @@ public class SupabaseCodegenPlugin : Plugin { val extension = project.extensions.create("supabaseCodegen", SupabaseCodegenExtension::class.java) extension.packageName.convention("supabase.generated") extension.outputDir.convention(project.layout.buildDirectory.dir("generated/supabase")) + extension.autoSync.convention(false) + + val generateTask = + project.tasks.register("generateSupabaseModels", GenerateSupabaseModelsTask::class.java) { task -> + task.group = "supabase" + task.description = "Generates Kotlin @Serializable models from your Supabase schema" + task.url.set(extension.url.orElse(project.providers.environmentVariable("SUPABASE_URL"))) + task.key.set(extension.key.orElse(project.providers.environmentVariable("SUPABASE_KEY"))) + task.packageName.set(extension.packageName) + task.outputDir.set(extension.outputDir) + task.autoSync.set(extension.autoSync) + // The remote schema can change without any local input changing, so never report + // up-to-date — when this runs, it means it. + task.outputs.upToDateWhen { false } + } - project.tasks.register("generateSupabaseModels", GenerateSupabaseModelsTask::class.java) { task -> - task.group = "supabase" - task.description = "Generates Kotlin @Serializable models from your Supabase schema" - task.url.set(extension.url.orElse(project.providers.environmentVariable("SUPABASE_URL"))) - task.key.set(extension.key.orElse(project.providers.environmentVariable("SUPABASE_KEY"))) - task.packageName.set(extension.packageName) - task.outputDir.set(extension.outputDir) - // The remote schema can change without any local input changing, so never report - // up-to-date — when you run the task, you mean it. - task.outputs.upToDateWhen { false } + // Auto-sync: regenerate before every Kotlin compile so the models track the live schema. + // Done in afterEvaluate so the Kotlin/Android compile tasks (and the user's autoSync choice) + // are registered. The generated dir still has to be added as a source set by the consumer. + project.afterEvaluate { + if (extension.autoSync.getOrElse(false)) { + project.tasks + .matching { it.name.startsWith("compile") && it.name.contains("Kotlin") } + .configureEach { it.dependsOn(generateTask) } + } } } } diff --git a/supabase-codegen-gradle/src/test/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenFunctionalTest.kt b/supabase-codegen-gradle/src/test/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenFunctionalTest.kt index 2279e79c..2567ad3c 100644 --- a/supabase-codegen-gradle/src/test/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenFunctionalTest.kt +++ b/supabase-codegen-gradle/src/test/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenFunctionalTest.kt @@ -49,4 +49,37 @@ class SupabaseCodegenFunctionalTest { "task must be configuration-cache compatible, got:\n${result.output}", ) } + + @Test + fun auto_sync_skips_instead_of_failing_when_credentials_are_missing() { + // With autoSync on, an unconfigured build (no url/key) must SUCCEED with a skip warning, + // so offline/CI builds and credential-less contributors aren't broken. + val dir = createTempDirectory("codegen-ft-auto").toFile() + File(dir, "settings.gradle.kts").writeText("""rootProject.name = "ft-auto"""") + File(dir, "build.gradle.kts").writeText( + """ + plugins { id("io.github.androidpoet.supabase.codegen") } + + supabaseCodegen { + url.set("") + key.set("") + packageName.set("com.example.db") + autoSync.set(true) + } + """.trimIndent(), + ) + + val result = + GradleRunner + .create() + .withProjectDir(dir) + .withPluginClasspath() + .withArguments("generateSupabaseModels") + .build() + + assertTrue( + "skipping auto-sync codegen" in result.output, + "expected a skip warning, got:\n${result.output}", + ) + } }