Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ public fun main(args: Array<String>) {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,31 @@ 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
* read-only request; it only needs an API key whose role can see the target tables
* (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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = listOf(TABLES_SUBPACKAGE, ENUMS_SUBPACKAGE)

private val header =
listOf(
"Generated from the Supabase schema. Do not edit by hand.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading