From 5d3dcb8ccd00f9459b93e4436104b141bf6a35d3 Mon Sep 17 00:00:00 2001 From: Ranbir Singh Date: Mon, 22 Jun 2026 12:58:40 +0530 Subject: [PATCH] Codegen: clear stale generated models on regen + add fetch timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the generator-owned subpackages (tables/, enums/) before writing so a table/enum dropped from the schema doesn't leave an orphan file — critical for autoSync (regenerates every build). Cleanup is scoped to those subpackages, so hand-written code in the package is untouched; a new invariant test asserts every generated file lands under a cleaned dir. Add 30s connect / 60s request timeouts to SchemaFetcher so a stalled fetch fails fast instead of hanging the compile. --- CHANGELOG.md | 10 ++++++++++ .../codegen/gradle/SupabaseCodegenPlugin.kt | 10 +++++++++- .../github/androidpoet/supabase/codegen/Main.kt | 7 +++++++ .../supabase/codegen/SchemaFetcher.kt | 9 ++++++++- .../supabase/codegen/SupabaseModelGenerator.kt | 8 ++++++++ .../codegen/SupabaseModelGeneratorTest.kt | 16 ++++++++++++++++ 6 files changed, 58 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4aec71..045d243c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +### Fixed + +- **Codegen clears stale models on regeneration.** Before writing, the CLI and Gradle task now + delete the generator-owned subpackages (`tables/`, `enums/`) under the target package, so a table + or enum dropped from the schema no longer leaves an orphan file that compiles against a column/table + that no longer exists. This matters most with `autoSync` (which regenerates every build). Cleanup is + scoped to those subpackages, so hand-written code elsewhere in the package is untouched. +- **Codegen schema fetch now has timeouts** (30s connect, 60s request) so a stalled connection fails + the build with a clear error instead of hanging the compile pipeline indefinitely. + ## 0.9.3 ### Added 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 b770d303..469c9044 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 @@ -111,10 +111,18 @@ public abstract class GenerateSupabaseModelsTask : DefaultTask() { } logger.lifecycle("Fetching Supabase schema from ${projectUrl.trimEnd('/')}/rest/v1/ …") + val pkg = packageName.get() val spec = SchemaFetcher.fetch(projectUrl, apiKey) - val files = SupabaseModelGenerator.generate(spec, packageName.get()) + val files = SupabaseModelGenerator.generate(spec, pkg) val root = outputDir.get().asFile + // Clear the generator-owned subpackages first so a table/enum dropped from the schema + // doesn't leave a stale file. Scoped to those dirs, so hand-written code elsewhere is safe. + val packageDir = root.resolve(pkg.replace('.', '/')) + for (sub in SupabaseModelGenerator.generatedSubpackages) { + packageDir.resolve(sub).deleteRecursively() + } + for (file in files) { val target = root.resolve(file.relativePath) target.parentFile.mkdirs() diff --git a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/Main.kt b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/Main.kt index a6ad4dbe..bef63249 100644 --- a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/Main.kt +++ b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/Main.kt @@ -32,6 +32,13 @@ public fun main(args: Array) { val spec = SchemaFetcher.fetch(url, key) val files = SupabaseModelGenerator.generate(spec, packageName) + // Clear the generator-owned subpackages first so a table/enum dropped from the schema + // doesn't leave a stale file. Scoped to those dirs, so hand-written code elsewhere is safe. + val packageDir = Path.of(outDir, *packageName.split('.').toTypedArray()) + for (sub in SupabaseModelGenerator.generatedSubpackages) { + packageDir.resolve(sub).toFile().deleteRecursively() + } + for (file in files) { val target = Path.of(outDir, file.relativePath) Files.createDirectories(target.parent) diff --git a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SchemaFetcher.kt b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SchemaFetcher.kt index 0a2b1e96..26c08292 100644 --- a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SchemaFetcher.kt +++ b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SchemaFetcher.kt @@ -4,6 +4,7 @@ import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse +import java.time.Duration /** * Fetches a project's OpenAPI schema from `GET {projectUrl}/rest/v1/`. This is a @@ -11,17 +12,23 @@ import java.net.http.HttpResponse * (use a service/secret key at build time so RLS doesn't hide tables from codegen). */ public object SchemaFetcher { + private val client = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build() + public fun fetch(projectUrl: String, apiKey: String): String { val uri = URI.create("${projectUrl.trimEnd('/')}/rest/v1/") val request = HttpRequest .newBuilder(uri) + // Bound the whole request so a stalled connection fails the build instead of + // hanging it forever (this runs inside the Gradle/compile pipeline). + .timeout(Duration.ofSeconds(60)) .header("apikey", apiKey) .header("Authorization", "Bearer $apiKey") .header("Accept", "application/openapi+json") .GET() .build() - val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()) + val response = client.send(request, HttpResponse.BodyHandlers.ofString()) require(response.statusCode() in 200..299) { "Failed to fetch schema: HTTP ${response.statusCode()} from $uri" } diff --git a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGenerator.kt b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGenerator.kt index ac1a5d73..fcf0a7d3 100644 --- a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGenerator.kt +++ b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGenerator.kt @@ -52,6 +52,14 @@ public object SupabaseModelGenerator { private const val TABLES_SUBPACKAGE = "tables" private const val ENUMS_SUBPACKAGE = "enums" + /** + * The sub-packages (under the configured package) that this generator fully owns and emits + * into. Writers should delete these before writing a fresh run so a table/enum dropped from + * the schema doesn't leave an orphan file behind — scoping the cleanup to these dirs keeps + * any hand-written code elsewhere in the package safe. + */ + public val generatedSubpackages: List = listOf(TABLES_SUBPACKAGE, ENUMS_SUBPACKAGE) + private val header = listOf( "Generated from the Supabase schema. Do not edit by hand.", diff --git a/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGeneratorTest.kt b/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGeneratorTest.kt index 8f35a5f3..61e8459f 100644 --- a/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGeneratorTest.kt +++ b/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGeneratorTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertTrue class SupabaseModelGeneratorTest { // A representative slice of a REAL PostgREST OpenAPI document. Note the integer formats @@ -52,6 +53,21 @@ class SupabaseModelGeneratorTest { assertContains(paths, "com/example/db/enums/PriorityLevel.kt") } + @Test + fun every_generated_file_lives_under_a_declared_owned_subpackage() { + // The writers delete `generatedSubpackages` before each run to clear stale output, so every + // file MUST live under one of them — otherwise a dropped table/enum would orphan a file the + // cleanup never touches. This guards that invariant if a new model kind is ever added. + val owned = SupabaseModelGenerator.generatedSubpackages.map { "com/example/db/$it/" } + val files = SupabaseModelGenerator.generate(fixture, "com.example.db") + for (file in files) { + assertTrue( + owned.any { file.relativePath.startsWith(it) }, + "generated file ${file.relativePath} is outside the cleaned subpackages $owned", + ) + } + } + @Test fun required_columns_are_non_null_and_nullable_columns_are_optional() { assertContains(generated, "val id: String") // required (uuid → String)