Core — API Reference
supabase-core is the foundation layer of Supabase KMP. It defines the SupabaseResult<T> sealed type that every call returns instead of throwing, a normalized SupabaseError model with coarse error categories, the Column<T> value class, and the typed filter DSL (where { } / query { }). It has no dependency on a transport or a specific Supabase service, so every other module (supabase-database, supabase-auth, supabase-storage, supabase-realtime, …) depends on it.
- Artifact:
io.github.androidpoet:supabase-core - Version:
1.0.0 - Packages:
io.github.androidpoet.supabase.core.result,…core.models,…core.paging,…core.util
You rarely add this dependency directly — pulling in any feature module brings supabase-core transitively. Import it explicitly only when you write shared helpers that pass SupabaseResult or Column around without touching a specific service.
SupabaseResult
public sealed interface SupabaseResult<out T> {
public data class Success<out T>(public val value: T) : SupabaseResult<T>
public data class Failure(public val error: SupabaseError) : SupabaseResult<Nothing>
public val isSuccess: Boolean
public val isFailure: Boolean
public fun getOrNull(): T?
public fun getOrThrow(): T
public fun errorOrNull(): SupabaseError?
public companion object {
public inline fun <T> catching(block: () -> T): SupabaseResult<T>
public suspend inline fun <T> suspendCatching(block: suspend () -> T): SupabaseResult<T>
}
}The return type of every fallible SDK call. It is a closed two-case type — either a Success holding a value of type T, or a Failure holding a SupabaseError. It is deliberately not kotlin.Result: the failure side carries a structured SupabaseError (not an arbitrary Throwable), and it does not box.
Variants
Success<T>(value: T)— a successful outcome.valueis the payload (a row, a list, a session,Unit, …). Adata class, socomponent1()/copy()/equals/hashCodeare available.Failure(error: SupabaseError)— a failed outcome.erroris the normalized failure. Declared asSupabaseResult<Nothing>, so aFailureis assignable to anySupabaseResult<T>(this is why combinators can returnthison the failure branch).
Members
isSuccess/isFailure— convenience predicates;isSuccessisthis is Success.getOrNull(): T?— the value on success, ornullon failure.getOrThrow(): T— the value on success, or throwsSupabaseException(viaerror.toException()) on failure. The bridge into exception-based code.errorOrNull(): SupabaseError?— the error on failure, ornullon success.
Companion factories
catching(block)— runsblock, wrapping its return inSuccess. A thrownSupabaseExceptionis unwrapped to itserror; any otherThrowablebecomes a genericSupabaseError.CancellationExceptionis always re-thrown so coroutine cancellation is honoured.suspendCatching(block)— thesuspendform ofcatchingfor suspending bodies.
val result: SupabaseResult<Int> = SupabaseResult.catching { "42".toInt() }
when (result) {
is SupabaseResult.Success -> println("got ${result.value}")
is SupabaseResult.Failure -> println("failed: ${result.error.message}")
}Result combinators
All combinators are extension functions on SupabaseResult<T> in the …core.result package. They let you transform, inspect and unwrap a result without ever writing a when. On a Failure, every transform short-circuits and passes the failure through unchanged.
Transforming the success value
public inline fun <T, R> SupabaseResult<T>.map(transform: (T) -> R): SupabaseResult<R>
public inline fun <T, R> SupabaseResult<T>.flatMap(transform: (T) -> SupabaseResult<R>): SupabaseResult<R>map— appliestransformto the success value; aFailurepasses through.flatMap— likemap, buttransformitself returns aSupabaseResult, so failures from chained calls compose. The success path of one call feeds the next.
val name: SupabaseResult<String> =
database.selectSingle<User>("users") // SupabaseResult<User>
.map { it.name } // SupabaseResult<String>
.flatMap { validateNonEmpty(it) } // SupabaseResult<String>Transforming or recovering from the error
public inline fun <T> SupabaseResult<T>.mapError(transform: (SupabaseError) -> SupabaseError): SupabaseResult<T>
public inline fun <T> SupabaseResult<T>.recover(transform: (SupabaseError) -> T): SupabaseResult<T>
public inline fun <T> SupabaseResult<T>.flatMapError(transform: (SupabaseError) -> SupabaseResult<T>): SupabaseResult<T>mapError— rewrites theSupabaseErroron aFailure; aSuccesspasses through. Useful to add context.recover— turns aFailureinto aSuccessby computing a fallback value from the error.flatMapError— the failure-side mirror offlatMap: maps a failure to anotherSupabaseResult(e.g. retry after a token refresh). ASuccesspasses through.
val profile = api.fetchProfile()
.flatMapError { err -> if (err.category == SupabaseErrorCategory.UNAUTHORIZED) refreshAndRetry() else SupabaseResult.Failure(err) }
.recover { Profile.GUEST } // never fails after thisAsserting invariants
public inline fun <T> SupabaseResult<T>.validate(predicate: (T) -> Boolean, lazyError: (T) -> SupabaseError): SupabaseResult<T>
public inline fun <T> SupabaseResult<T>.filter(lazyError: (T) -> SupabaseError = …, predicate: (T) -> Boolean): SupabaseResult<T>
public inline fun <T> SupabaseResult<T>.filterNot(lazyError: (T) -> SupabaseError = …, predicate: (T) -> Boolean): SupabaseResult<T>validate— turns aSuccesswhose value failspredicateinto aFailurebuilt bylazyError. A passing value (and any existingFailure) passes through.filter— lightervalidate; keeps aSuccessonly while its value satisfiespredicate, otherwise fails withlazyError(default message: “Value did not match the predicate”).filterNot— the negation offilter: aSuccesssurvives only whenpredicateis false (default message: “Value matched the excluded predicate”).
api.fetchCount()
.filter(lazyError = { SupabaseError("count must be positive, was $it") }) { it > 0 }Combining several results
public inline fun <A, B, R> SupabaseResult<A>.zip(other: SupabaseResult<B>, transform: (A, B) -> R): SupabaseResult<R>
public inline fun <A, B, C, R> SupabaseResult<A>.zip(second: SupabaseResult<B>, third: SupabaseResult<C>, transform: (A, B, C) -> R): SupabaseResult<R>
public fun <T> mergeAll(results: List<SupabaseResult<T>>): SupabaseResult<List<T>>
public fun <T> mergeAll(vararg results: SupabaseResult<T>): SupabaseResult<List<T>>zip(two- and three-arg) — combines independent results: aSuccessoftransformapplied to all values when every result succeeds, otherwise the firstFailurein receiver →second→thirdorder.mergeAll— collapses many homogeneous results into a singleSupabaseResult<List<T>>, preserving order. Short-circuits on the firstFailure, so the success path guarantees every input succeeded. Available as aListand avarargoverload.
val both = api.fetchUser().zip(api.fetchSettings()) { user, settings -> Screen(user, settings) }
val all = mergeAll(page1, page2, page3) // SupabaseResult<List<Page>>Unwrapping (terminal operations)
public fun <T> SupabaseResult<T>.getOrDefault(defaultValue: T): T
public inline fun <T> SupabaseResult<T>.getOrElse(defaultValue: (SupabaseError) -> T): T
public inline fun <T, R> SupabaseResult<T>.fold(onSuccess: (T) -> R, onFailure: (SupabaseError) -> R): RgetOrDefault— the success value, or an eagerly-supplieddefaultValueon failure.getOrElse— the success value, or a value computed from the error (the lazy sibling ofgetOrDefault).fold— collapses both cases to a singleRby supplying a handler for each branch. The most general terminal operator.
val label: String = api.fetchName().fold(
onSuccess = { it },
onFailure = { "anonymous (${it.category})" },
)Side effects
public inline fun <T> SupabaseResult<T>.onSuccess(action: (T) -> Unit): SupabaseResult<T>
public inline fun <T> SupabaseResult<T>.onFailure(action: (SupabaseError) -> Unit): SupabaseResult<T>
public inline fun <T> SupabaseResult<T>.onFailureCategory(category: SupabaseErrorCategory, action: (SupabaseError) -> Unit): SupabaseResult<T>
public inline fun <T, C> SupabaseResult<T>.onFailureCategory(category: C, classifier: (SupabaseError) -> C, action: (SupabaseError) -> Unit): SupabaseResult<T>
public inline fun <T> SupabaseResult<T>.onConflict(action: (SupabaseError) -> Unit): SupabaseResult<T>
public inline fun <T> SupabaseResult<T>.onNotFound(action: (SupabaseError) -> Unit): SupabaseResult<T>
public inline fun <T> SupabaseResult<T>.onUnauthorized(action: (SupabaseError) -> Unit): SupabaseResult<T>
public inline fun <T> SupabaseResult<T>.onRateLimited(action: (SupabaseError) -> Unit): SupabaseResult<T>
public inline fun <T> SupabaseResult<T>.onNetworkError(action: (SupabaseError) -> Unit): SupabaseResult<T>Each runs action for the matching case and returns the same result unchanged, so they chain. onFailureCategory fires only when error.category equals category; its second overload lets you supply a custom classifier. The five named helpers (onConflict, onNotFound, onUnauthorized, onRateLimited, onNetworkError) are shorthands for onFailureCategory with the corresponding SupabaseErrorCategory.
api.deleteRow(id)
.onSuccess { toast("Deleted") }
.onNotFound { toast("Already gone") }
.onUnauthorized { signOut() }
.onFailure { log(it.message) }Suspending variants
For when an action/transform must itself suspend. Behaviour matches the non-suspending sibling above.
public suspend inline fun <T> SupabaseResult<T>.onSuccessSuspend(action: suspend (T) -> Unit): SupabaseResult<T>
public suspend inline fun <T> SupabaseResult<T>.onFailureSuspend(action: suspend (SupabaseError) -> Unit): SupabaseResult<T>
public suspend inline fun <T> SupabaseResult<T>.onFailureCategorySuspend(category: SupabaseErrorCategory, action: suspend (SupabaseError) -> Unit): SupabaseResult<T>
public suspend inline fun <T, C> SupabaseResult<T>.onFailureCategorySuspend(category: C, classifier: (SupabaseError) -> C, action: suspend (SupabaseError) -> Unit): SupabaseResult<T>
public suspend inline fun <T, R> SupabaseResult<T>.foldSuspend(onSuccess: suspend (T) -> R, onFailure: suspend (SupabaseError) -> R): R
public suspend inline fun <T, R> SupabaseResult<T>.mapSuspend(transform: suspend (T) -> R): SupabaseResult<R>
public suspend inline fun <T> SupabaseResult<T>.recoverSuspend(transform: suspend (SupabaseError) -> T): SupabaseResult<T>
public suspend inline fun <T> SupabaseResult<T>.flatMapErrorSuspend(transform: suspend (SupabaseError) -> SupabaseResult<T>): SupabaseResult<T>kotlin.Result interop
public fun <T> SupabaseResult<T>.toKotlinResult(): Result<T>
public inline fun <T> Result<T>.toSupabaseResult(mapThrowable: (Throwable) -> SupabaseError = …): SupabaseResult<T>toKotlinResult— converts to a standardkotlin.Result; aFailurebecomes aResult.failurecarrying theerror.toException().toSupabaseResult— converts akotlin.Resultback. By default a wrappedSupabaseExceptionkeeps itserror; any other throwable becomes a genericSupabaseError(override viamapThrowable).CancellationExceptionis re-thrown.
Flow bridges
public fun <T> SupabaseResult<T>.asFlow(): Flow<SupabaseResult<T>>
public fun <T> supabaseFlow(block: suspend () -> SupabaseResult<T>): Flow<SupabaseResult<T>>
public fun <T> dataFlow(block: suspend () -> SupabaseResult<T>): Flow<T>
public fun <T> SupabaseResult<T>.toFlow(): Flow<T>
public inline fun <T, R> SupabaseResult<T>.toFlow(transform: (T) -> R): Flow<R>
public inline fun <T, R> SupabaseResult<T>.toSuspendFlow(transform: suspend (T) -> R): Flow<R>asFlow— emits an already-computed result as a single-value coldFlow, keeping the wrapper so a failure (and its error) survives into the stream. Handy for seeding a stream with a cached result before live values arrive.supabaseFlow— wraps a not-yet-run suspending call in a coldFlowthat runs it on collection and emits the result once, keeping theSupabaseResultwrapper.dataFlow— the no-Resultescape hatch: emits the plain value on success and throws (SupabaseException) on failure, so the wrapper never enters your stream. Pairs with exception-basedFlow<T>.asResult()UI helpers.toFlow()— emits the success value, or an empty flow on failure (failures are dropped silently). Atransformoverload maps before emitting.toSuspendFlow—toFlowwith asuspendtransform run in the collection context.
supabaseFlow { database.selectTyped<Todo>("todos") }
.collect { result -> result.onSuccess(::render).onFailure(::showError) }Concurrent fan-out
public fun <T> CoroutineScope.deferredResult(block: suspend () -> SupabaseResult<T>): Deferred<SupabaseResult<T>>
public suspend fun <T> awaitMerged(deferreds: List<Deferred<SupabaseResult<T>>>): SupabaseResult<List<T>>
public suspend fun <T> awaitMerged(vararg deferreds: Deferred<SupabaseResult<T>>): SupabaseResult<List<T>>deferredResult— launchesblockon the scope and returns aDeferredto its result, so independent calls run concurrently. Any throwable is captured as aFailure, soawait()never throws and one failure can’t cancel siblings; cooperative cancellation still propagates.awaitMerged— awaits every deferred andmergeAlls them: an ordered success list when all succeed, or the firstFailure. The collect step for adeferredResultfan-out (Listandvarargoverloads).
val pages = ids.map { id -> scope.deferredResult { api.fetchPage(id) } }
val all: SupabaseResult<List<Page>> = awaitMerged(pages)Error model
SupabaseError
@Serializable
public data class SupabaseError(
public val message: String,
public val code: String? = null,
public val details: JsonElement? = null,
public val hint: String? = null,
public val httpStatus: Int? = null,
public val retryAfterSeconds: Long? = null,
)The normalized failure — the error half of a Failure, unifying PostgREST, GoTrue, Storage, Realtime and client-side faults.
message— human-readable description (required).code— machine-readable service code (e.g. a PostgRESTPGRST…/SQLSTATE, a GoTrue slug) or a syntheticSupabaseErrorCodes.Clientcode, when known. Defaultnull.details— additional structured context from the error body, as a rawJsonElement. Defaultnull.hint— a suggested remedy, when the server supplies one. Defaultnull.httpStatus— the HTTP status that produced this error, captured independently ofcodesocategorykeeps working even when the body has no machine-readable code. Defaultnull.retryAfterSeconds— the server’sRetry-Afterhint in seconds, typically on a429. Useful for “try again in N seconds” UI. Defaultnull.
val err = SupabaseError(message = "Row not found", code = "PGRST116", httpStatus = 406)
println(err.category) // ValidationSupabaseException
public class SupabaseException(public val error: SupabaseError, cause: Throwable? = null) : Exception(error.message, cause)
public fun SupabaseError.toException(cause: Throwable? = null): SupabaseExceptionThe throwable form of a SupabaseError, thrown by getOrThrow() / dataFlow and caught by catching. It keeps the original error so a catch block can still inspect error.code/error.category, and an optional cause so the underlying throwable stays in the stack trace. toException() is the convenience wrapper.
SupabaseErrorCategory
public enum class SupabaseErrorCategory {
CONFLICT, NOT_FOUND, UNAUTHORIZED, RATE_LIMITED, VALIDATION, INTERNAL, NETWORK, UNKNOWN
}
public val SupabaseError.category: SupabaseErrorCategory
public val SupabaseErrorCategory.isRetryable: BooleanA coarse classification of a failure so callers can branch on the kind of error without matching individual codes.
CONFLICT— uniqueness/foreign-key clash or already-existing resource (HTTP 409).NOT_FOUND— the table, row, user or object does not exist (HTTP 404).UNAUTHORIZED— missing/invalid/insufficient credentials or permissions (HTTP 401/403).RATE_LIMITED— a rate limit was exceeded; back off and retry (HTTP 429).VALIDATION— the request was malformed or failed validation (HTTP 400/406/416/422).INTERNAL— a server-side failure unrelated to request content (HTTP 5xx, plus transient 408/425).NETWORK— the request never reached the server or never produced a usable response (offline, timeout, connection or decode failure). Distinct fromUNKNOWNso you can show offline/retry UI.UNKNOWN— the catch-all fallback when nothing more specific applied.
category resolves from the textual code first (matched against the per-service code sets in SupabaseErrorCodes), then falls back to httpStatus (or a numeric code in the HTTP range), so structured bodies without a textual code are still classified.
isRetryable is true for the transient categories — RATE_LIMITED, INTERNAL, NETWORK — and false for client-fault categories that would fail again unchanged.
result.onFailure { err ->
if (err.category.isRetryable) scheduleRetry(after = err.retryAfterSeconds)
else showPermanentError(err)
}Code-specific predicates
public fun SupabaseError.isUniquenessViolation(): Boolean // Postgres 23505 — duplicate row
public fun SupabaseError.isForeignKeyViolation(): Boolean // Postgres 23503 — missing referenced row
public fun SupabaseError.isInvalidCredentials(): Boolean // GoTrue invalid_credentials
public fun SupabaseError.isUserAlreadyExists(): Boolean // GoTrue user_already_exists
public fun SupabaseError.isFileNotFound(): Boolean // Storage NoSuchKey
public fun SupabaseError.isNetworkError(): Boolean // == (category == Network)Convenience checks against the most common individual codes, for when a category is too coarse.
SupabaseErrorCodes
public object SupabaseErrorCodes {
public object Database { /* PGRST… + SQLSTATE constants */ }
public object Auth { /* GoTrue slug constants */ }
public object Storage { /* object/bucket + S3 constants */ }
public object Realtime { /* channel/auth/topic constants */ }
public object Functions { /* Edge Functions constants */ }
public object Client { /* synthetic client-side constants */ }
public object Management { /* project/org constants */ }
}A catalog of the string error codes Supabase services return, grouped by service, so matching against SupabaseError.code never needs magic strings. These drive the category sets behind category and the is* helpers. Every constant is a public const val String.
Database— PostgREST (PGRST000…PGRST303,PGRSTX00) and raw PostgreSQLSQLSTATEcodes:FOREIGN_KEY_VIOLATION(23503),UNIQUENESS_VIOLATION(23505),UNDEFINED_TABLE(42P01),INSUFFICIENT_PRIVILEGE(42501),STATEMENT_TIMEOUT(57014),TABLE_NOT_FOUND,COLUMN_NOT_FOUND,FUNCTION_NOT_FOUND,JWT_INVALID,SINGULAR_RESPONSE_VIOLATION, and more.Auth— GoTrue codes:INVALID_CREDENTIALS,USER_NOT_FOUND,USER_ALREADY_EXISTS,EMAIL_NOT_CONFIRMED,WEAK_PASSWORD,OTP_EXPIRED,TOO_MANY_REQUESTS,MFA_VERIFICATION_FAILED,SIGNUP_DISABLED, and others.Storage— object/bucket and S3-compatibility codes:NO_SUCH_BUCKET,NO_SUCH_KEY,KEY_ALREADY_EXISTS,ENTITY_TOO_LARGE,INVALID_MIME_TYPE,ACCESS_DENIED,THROTTLING,INTERNAL_ERROR, and more.Realtime— WebSocket codes:CHANNEL_RATE_LIMIT_REACHED,CONNECTION_RATE_LIMIT_REACHED,ERROR_AUTHORIZING_WEBSOCKET,AUTH_EXPIRED,INVALID_TOPIC,PAYLOAD_TOO_LARGE, and others.Functions— Edge Functions codes:BOOT_ERROR,WORKER_ERROR,WORKER_LIMIT,DEPLOYMENT_FAILED,UNSUPPORTED_NODE_VERSION,FUNCTION_NOT_RETURNING_RESPONSE.Client— the only group not emitted by a server; synthesized by the SDK when a request never produced a response:NETWORK_ERROR,TIMEOUT,CONNECTION_FAILED. These letcategoryresolve toNETWORKinstead ofUNKNOWN.Management— Management API codes:PROJECT_NOT_FOUND,ORGANIZATION_NOT_FOUND,INVALID_PROJECT_REF,UNAUTHORIZED,FORBIDDEN,RATE_LIMIT_EXCEEDED.
result.onFailure { err ->
when (err.code) {
SupabaseErrorCodes.Database.UNIQUENESS_VIOLATION -> toast("Already exists")
SupabaseErrorCodes.Auth.WEAK_PASSWORD -> showPasswordHint()
}
}Column and query enums
Column
@JvmInline
public value class Column<T>(public val name: String)A type-safe handle to a table column, carrying both its wire name and the Kotlin type T of the values it holds. Passing Column<T> into the filter operators makes the compiler reject type-mismatched comparisons like age eq "oops". Being a value class, it costs nothing at runtime — it is just the String name. This is the only value class in supabase-core; typed IDs (user, bucket, session, channel, …) live in their respective feature modules.
public object Profiles {
public val age: Column<Int> = Column("age")
public val status: Column<String> = Column("status")
}Order
public enum class Order { ASC, DESC }Sort direction for QueryBuilder.orderBy. ASC renders to PostgREST asc, DESC to desc.
Nulls
public enum class Nulls { FIRST, LAST }Placement of NULL values relative to non-null values for orderBy. FIRST → nullsfirst, LAST → nullslast.
TextSearchType
public enum class TextSearchType { Raw, Plain, Phrase, Websearch }Full-text search mode for WhereBuilder.textSearch, selecting how the query is parsed into a tsquery. Maps to a PostgREST operator prefix: Raw → fts, Plain → plfts, Phrase → phfts, Websearch → wfts.
Filter
public sealed interface FilterAn immutable filter expression — the value the filter DSL builds internally. A Filter is either a single column predicate or a logical combination of others. It has no public implementations; you never construct one by hand, you build it through where { }. It is rendered to PostgREST query parameters when a request is issued.
FilterDsl
@DslMarker
public annotation class FilterDslA DSL marker that scopes the receiver of nested DSL blocks (where/or/and/not), so an inner block can’t accidentally call methods on an outer builder.
Filter DSL
WhereBuilder
@FilterDsl
public class WhereBuilderThe receiver of a filter block. Each statement adds one predicate; multiple statements are AND-ed together. The operators are infix extensions on Column<T>, type-checked against T. String operands are escaped per PostgREST quoting rules when they contain a structural character (comma, parens, quote, backslash); numbers and booleans are emitted verbatim.
Comparison (eq/neq for any T; the ordering operators require T : Comparable<T>):
public infix fun <T> Column<T>.eq(value: T) // column = value
public infix fun <T> Column<T>.neq(value: T) // column <> value
public infix fun <T> Column<T>.isDistinctFrom(value: T) // IS DISTINCT FROM (null-aware)
public infix fun <T : Comparable<T>> Column<T>.greater(value: T) // >
public infix fun <T : Comparable<T>> Column<T>.greaterEq(value: T) // >=
public infix fun <T : Comparable<T>> Column<T>.less(value: T) // <
public infix fun <T : Comparable<T>> Column<T>.lessEq(value: T) // <=
public infix fun <T : Comparable<T>> Column<T>.within(range: ClosedRange<T>) // >= start AND <= endNull / boolean IS:
public fun Column<*>.isNull() // IS NULL
public fun Column<*>.isNotNull() // IS NOT NULL
public infix fun Column<Boolean>.isExactly(value: Boolean) // IS TRUE / IS FALSEPattern matching (on Column<String>):
public infix fun Column<String>.like(pattern: String) // LIKE (case-sensitive, % = any run)
public infix fun Column<String>.ilike(pattern: String) // ILIKE (case-insensitive)
public infix fun Column<String>.matches(pattern: String) // ~ POSIX regex (case-sensitive)
public infix fun Column<String>.imatches(pattern: String) // ~* POSIX regex (case-insensitive)
public infix fun Column<String>.likeAllOf(patterns: List<String>) // LIKE ALL
public infix fun Column<String>.likeAnyOf(patterns: List<String>) // LIKE ANY
public infix fun Column<String>.ilikeAllOf(patterns: List<String>) // ILIKE ALL
public infix fun Column<String>.ilikeAnyOf(patterns: List<String>) // ILIKE ANYMembership:
public infix fun <T> Column<T>.inList(values: List<T>) // IN (…)
public infix fun <T> Column<T>.notInList(values: List<T>) // NOT IN (…)Array (on Column<List<T>>):
public infix fun <T> Column<List<T>>.contains(values: List<T>) // @> contains every element
public infix fun <T> Column<List<T>>.containedBy(values: List<T>) // <@ contained by
public infix fun <T> Column<List<T>>.overlaps(values: List<T>) // && shares any elementRange columns (int4range, tstzrange, … — range is a Postgres range literal passed verbatim, e.g. "[1,10)"):
public infix fun Column<*>.rangeGt(range: String) // >> strictly right of
public infix fun Column<*>.rangeGte(range: String) // &> does not extend left of
public infix fun Column<*>.rangeLt(range: String) // << strictly left of
public infix fun Column<*>.rangeLte(range: String) // &< does not extend right of
public infix fun Column<*>.rangeAdjacent(range: String) // -|- adjacent toFull-text search:
public fun Column<String>.textSearch(query: String, config: String? = null, type: TextSearchType = TextSearchType.PLAIN)query— the search string.config— an optional text-search configuration (e.g."english"), wrapped in parentheses on the wire. Defaultnull.type— the parse mode; defaultTextSearchType.PLAIN.
Logical grouping and escape hatch:
public fun or(referencedTable: String? = null, block: WhereBuilder.() -> Unit) // OR group
public fun and(referencedTable: String? = null, block: WhereBuilder.() -> Unit) // explicit AND group
public fun not(block: WhereBuilder.() -> Unit) // negates every predicate inside
public fun raw(column: Column<*>, operator: String, value: String) // column=<operator>.<value>, value escapedor/and— combine the predicates inblockwith that operator; the optionalreferencedTablescopes the group to an embedded resource.not— negates every predicate declared inblock(SQLNOT).raw— an escape hatch for operators the DSL doesn’t expose yet.
where {
Profiles.status eq "active"
Profiles.age within 18..30
or {
Profiles.status eq "pending"
Profiles.status eq "invited"
}
}QueryBuilder
@FilterDsl
public class QueryBuilder {
public fun where(block: WhereBuilder.() -> Unit)
public fun orderBy(column: Column<*>, order: Order = Order.ASC, nulls: Nulls? = null, referencedTable: String? = null)
public fun limit(count: Int, referencedTable: String? = null)
public fun offset(count: Int, referencedTable: String? = null)
public fun range(from: Int, to: Int, referencedTable: String? = null)
}The receiver of a full read query: a where { } predicate plus result modifiers. Modifiers are kept distinct from filters, so a not { limit(1) } is not expressible.
where— the filter predicate; multiple statements inside are AND-ed.orderBy— sort bycolumn.orderdefaults toOrder.ASC;nullsis optional null placement;referencedTablescopes the sort to an embedded resource. Successive calls add secondary sort keys (merged into oneorderparam).limit— cap the result atcountrows; last call wins.offset— skip the firstcountrows; last call wins.range— an inclusive, zero-based row window[from, to], emitted as anoffsetplus a derivedlimit(to - from + 1). Requiresto >= from.
where / query
public inline fun where(block: WhereBuilder.() -> Unit): List<Pair<String, String>>
public inline fun query(block: QueryBuilder.() -> Unit): List<Pair<String, String>>The two entry points that materialize a DSL block into PostgREST query parameters. where { } builds a filter-only predicate (used by update/delete); query { } builds a full read query (filter + modifiers, used by select). You normally pass the block straight to a feature-module method rather than calling these directly.
val params = query {
where { Profiles.status eq "active" }
orderBy(Profiles.age, Order.DESC)
limit(20)
}
// → [(status, eq.active), (order, age.desc), (limit, 20)]Response model
PostgrestResponse
@Serializable
public data class PostgrestResponse<T>(
public val data: T,
public val count: Long? = null,
)A successful PostgREST response: the decoded data plus an optional row count.
data— the decoded body (a row, a list of rows, or a scalar).count— the total row count, populated only when the request asked for it (aPrefer: count=…header), e.g. to drive pagination;nullotherwise.
Paging
Paginator
public class Paginator<T>(
pageSize: Int = 20,
fetch: suspend (offset: Int, limit: Int) -> List<T>,
) {
public val items: StateFlow<List<T>>
public val isLoading: StateFlow<Boolean>
public val isEndReached: StateFlow<Boolean>
public val error: StateFlow<Throwable?>
public suspend fun loadNext()
public suspend fun refresh()
}A small, dependency-free, demand-driven paginator for infinite-scroll UIs. It loads the next page only when asked, exposing its state as observable StateFlows. It depends on nothing but coroutines, so it works on every target with or without Compose.
pageSize— rows requested per page; must be> 0. Default20.fetch— returns one page given anoffsetandlimit. Expected to throw on failure (e.g. viagetOrThrow()); the throwable is captured intoerrorrather than propagated, exceptCancellationException, which is always re-thrown.
State:
items— the rows loaded so far, growing with each successfulloadNext.isLoading—truewhile a page is being fetched.isEndReached—trueonce a page came back shorter thanpageSize.error— the lastfetchfailure, ornull; cleared when a new load starts.
Operations:
loadNext()— loads and appends the next page. A no-op if a load is running orisEndReachedis set.refresh()— resets to the first page and reloads it (pull-to-refresh); any in-flight page is discarded so it cannot append onto the cleared list.
val pager = Paginator(pageSize = 20) { offset, limit ->
database.selectTyped<Todo>("todos") { range(offset, offset + limit - 1) }.getOrThrow()
}
// observe pager.items; near the end of the list:
LaunchedEffect(lastVisibleIndex) { pager.loadNext() }Utilities
urlEncode
public fun urlEncode(value: String): StringPercent-encodes value for use as a single URL component (RFC 3986). The unreserved set (A-Z a-z 0-9 - . _ ~) passes through unchanged; everything else is encoded from its UTF-8 bytes. Shared so each module needn’t carry its own copy.
urlEncode("a b/c") // "a%20b%2Fc"Design notes
Result-first, no exceptions. Every fallible operation returns a SupabaseResult<T> rather than throwing. Failure is a value (Failure(SupabaseError)), not a stack-unwinding event, so the compiler forces you to acknowledge both branches and the happy path composes with map/flatMap/zip. Exceptions remain available as an opt-in (getOrThrow, dataFlow, toKotlinResult) for code that prefers try/catch, but they are never the default.
Not kotlin.Result. The failure side is a structured, serializable SupabaseError — with a code, an httpStatus, a category and a Retry-After hint — not an opaque Throwable. That lets you branch on the kind of failure (onUnauthorized, category.isRetryable) without string-matching messages, and lets errors cross serialization boundaries.
Cancellation is sacred. Every helper that catches throwables (catching, deferredResult, Paginator, toSupabaseResult) re-throws CancellationException, so structured concurrency and coroutine cancellation always work as expected.
Zero-cost types where it counts. Column<T> is a value class, so type-safe filters carry no runtime overhead over a bare String. Filters are modelled as an immutable Filter tree and rendered in a single recursive walk, so logical combinators stay trivial and no already-serialized output is ever re-parsed.
Thin over the REST API. The filter DSL maps directly onto PostgREST operators; nothing here hides or reinterprets the wire protocol, and the raw escape hatch is always available for operators the DSL doesn’t yet name.