The north star for every public-API decision in this SDK. Read this before adding or
changing anything in a published module's surface (anything visible in an api/*.api
dump). When a change is ambiguous, the identity below is the tie-breaker.
A thin, explicit, Result-first Supabase client for Kotlin Multiplatform. We are deliberately unlike the established Kotlin Supabase SDK, and the difference is semantic, not syntactic:
| Principle | What it means | What we are NOT |
|---|---|---|
| Result-first | Every fallible call returns SupabaseResult<T>, never throws. |
No RestException-style throwing APIs. |
| Explicit / no magic | Modules are constructed with create<X>Client(...) factories. Typed decoding is opt-in (selectTyped<T>). |
No plugin-install DSL on a god-client; no reflection-y type registration. |
| Thin over REST | One public call ≈ one HTTP request. The mapping to PostgREST/GoTrue is honest and visible. | No long-lived fluent chains (from().select().eq().execute()) that hide the request boundary. |
| Type-safe where it counts | Column<T> value-class tokens make name eq 5 a compile error. |
No stringly-typed filter keys as the primary path (raw(...) is the documented escape hatch). |
| Minimal deps | Don't leak third-party types (Ktor, kotlinx-serialization) into the surface unless they're a deliberate, documented power-user seam. | No transitive types in signatures for convenience. |
The filter DSL block (
select(...) { where { col eq x } }) is the idiomatic Kotlin type-safe-builder pattern (same asbuildList {}, Ktor, Exposed). Using it is not imitation — our identity lives in the semantics above, not the filter syntax. Keep the DSL; never replace it with a fluent chain.
- Surface failure by operation kind — don't reflexively wrap everything in a Result.
This is the rule industry research corrected us on: no shipping Kotlin SDK (supabase-kt,
Firebase, Apollo, Ktor, AWS, SqlDelight) wraps streaming or local-DB ops in a result type,
because a
Result<Channel>onsubscribe()describes only the first attempt and goes stale on the mid-stream disconnect that actually matters. So:- Request/response (HTTP: postgrest/auth/storage/functions) → return
SupabaseResult<T>. This is our deliberate differentiator from the throwing established SDK, and the one place a per-call result genuinely fits. (kotlin.Resultis officially not for domain errors — KEEP — so our own sealedSupabaseResultcarryingSupabaseErroris the sanctioned shape.) - Connection / streaming (realtime) → surface failure in-band: a
status: StateFlowand sealed events in theFlow, not aResultonsubscribe(). Prefer a coldcallbackFlow { … awaitClose { unsubscribe() } }; if a stream is hot (replay = 0, lifecycle owned bysubscribe/unsubscribe), the KDoc must say so — never mislabel it cold. - Local-DB / cache writes (sync) → let them throw (SqlDelight-native) and surface the
pipeline outcome as a
status: StateFlow<SyncStatus>— mirroring Firebase's offline model. The discoverable, short-named method is always the safe one; a throwing variant on an HTTP call, if needed, is the explicit…OrThrow.
- Request/response (HTTP: postgrest/auth/storage/functions) → return
- The default name returns the rich/safe type. Don't ship
foo()(lossy/throwing) next tofooWithResult()(rich) — fold it into one. AvoidWithResult/Unit/OrThrowsuffix sprawl: pick one axis (areturning/formatparam) over parallel methods. - Model closed sets as
enum/sealed, neverString. Providers, channels, formats, statuses, error categories. AStringthat has a fixed value set is a bug. - No secrets in
data class. Auto-toString()on adata classprints fields — never put tokens/keys/secrets in one (Session, OAuth client secrets, config). Use a plainclasswith a redactingtoString(). Also: don't make adata classwhoseequals/copyare meaningless (e.g. lambda fields) — use a plain class. - One construction idiom:
create<X>Client(client, …)factories (+ agetX()accessor where the module attaches toSupabaseClient). New modules expose factories, not bare public constructors. - One verb per concept SDK-wide:
get(read one) ·list(enumerate) ·create/insert(by semantics) ·update·delete·upsert. Don't introducefetch/retrieve/load/removesynonyms. Keep an argument's position consistent across methods (e.g.accessTokenalways first). - Coroutines: no hardcoded
Dispatchers.*in the public surface; don't take or store a rootCoroutineScopeyou never cancel (preferchannelFlow { awaitClose { } }); always re-throwCancellationExceptionbefore a catch. - Streaming, not whole-buffer, for IO that can be large. Storage/transfer APIs take/return
kotlinx.ioSource/RawSink(orFlow<ByteArray>), not just an in-memoryByteArray. - Flows: cold vs hot must match the KDoc. If it's a hot
SharedFlow/replay=0, say so and say who owns the lifecycle. ExposeStateFlow, neverMutableStateFlow. AFlow-returning function must not throw eagerly — defer intoflow { }. - Read-only collections in signatures (
List/Map/Set, not theMutable*variants). - Don't leak internals. Wire DTOs, generated DB types, and impl helpers are
internal.@PublishedApi internalis only for inline-function support.constin aprivate companionstill leaks as a public static field — make it a non-constprivate val. data classin a public API is an ABI hazard (copy/componentNbreak when a field is added). Use them for genuinely stable value types; before tagging 1.0, freeze the field set or switch config-style holders to a plain class + named-arg constructor.- Typed ids: if a
value classid (UserId,BucketId, …) exists, use it at the boundary — don't ship unused id scaffolding alongside raw-Stringparameters. Either adopt or delete.
Breaking changes are free now and expensive after 1.0 — so make the right surface decision now rather than the cheapest. A rename/return-type change that aligns with the rules above is worth doing before the tag; an additive convenience can wait for 1.x.
See CONTRIBUTING.md for the mechanics (explicitApi, BCV apiDump,
detekt, coverage gate) that enforce the surface.
The outcome of the full-SDK audit (6 surface auditors) reconciled against industry research
(Kotlin/AndroidX guidelines, Google AIP, and the conventions of supabase-kt, Firebase, Apollo,
Ktor, AWS, SqlDelight, jOOQ, Exposed). Findings were source-verified — a .api dump hides
toString/equals overrides, KDoc, and deprecations, so several dump-based "P0s" were already
handled in source.
Done (stabilized):
- Credential
toString()redaction — already present onSession/OAuthClient(false-positive P0). ErrorResponse(wire-only) →internal; deleted unused typed-id value classes (dead public API).SupabaseConfig→ plainclass(function-typed fields; no meaningfulcopy/equals).MAX_PULL_PAGES/DEFAULT_PAGE_SIZEno longer leak as public static fields.asFlow()relabeled hot (was mislabeled "Cold");streamLines/invokeSsedefaults defer their throw into the coldflow { }instead of throwing eagerly.- Database query response shape unified into
ResponseFormat—select/rpc/rpcGet/selectRangetook 4 mutually-exclusivehead/single/csv/geojsonbooleans (illegal combos caught at runtime byrequire()); now a singleformat: ResponseFormatenum makes the illegal combinations unrepresentable. The thin wrappers (selectCsv/selectHead/rpcCsv/rpcGetSingleTyped/…) just pin aformat. The redundantselectTyped(single = …)flag was dropped (the typed terminalselectSingleTypedalready covers the single-row case). The one orthogonal modifier left,stripNulls, is still rejected at runtime against CSV/GEOJSON (a format-vs-modifier guard, not a format-vs-format one).
Won't-fix (industry says current design is correct):
- Realtime + sync are not converted to
SupabaseResult. Reactive in-band (statusStateFlow- sealed events) and throw-plus-status-Flow are the idiomatic, more-correct surfaces — see Rule 1.
SupabaseResultstays (notkotlin.Result).- Sync
PullResult/PushResult/SyncResultkeep theResultsuffix. They are plain operation-summary data classes (counts, accepted/rejected ids, next cursor), the idiomatic name for "the result of an operation" (cf. WorkManager). They never appear inside aSupabaseResult, so there's no monad confusion at a call site; renaming to*Summary/*Outcomeis churn without a clear ergonomic gain. (PullProgressalready uses a non-Resultname where it read better.) - Paging width split (
Longin the store layer,Int pageSizeinsupabase-sync-paging) is intentional — the AndroidX Paging 3 API isInt-based, while the low-levelLocalStore/TableAdapteruseLongfor large offsets. Each matches its layer's convention.
Do before 1.0 (breaking — free now, expensive later):
-
Database query surface (format enum):DONE —select/rpc/rpcGet/selectRangenow take oneResponseFormatenum instead of the exclusive booleans; the typed terminals stay separate (cardinality changes the return type). See the Done section above. -
Database method sprawl:DONE — therpcGettyped terminals (rpcGetTyped/…Unit/…ListTyped/…SingleTyped/…MaybeSingleTyped/…Csv/…Head) each dropped their redundantList<Pair>convenience overload and standardized on oneMap<String,String> = emptyMap()form (RPC arguments are named, so they are Map-shaped; the pair-list seam stays on the rawDatabaseClient.rpcGetinterface method for the rare ordered case). TheRequest-object typed overloads are unchanged. -
Naming sweep:DONE — landed across modules:- Closed sets → enums:
MessagingChannel(auth phone channel) andHttpLogLevel(client config) replace the stringly-typed values; Ktor'sLogLevelno longer leaks through config. - Wire DTOs internalized: ~46 request/response data classes in auth + storage are now
internal. - One verb / consistent nouns: auth
fetchJwks→getJwks,retrieveSsoUrl→getSsoUrl; auth-adminauditLogEvents→listAuditLogEvents,signOut(jwt=)→signOut(accessToken=); storageemptyBucket→clearBucket, Icebergload*→get*/drop*→delete*, duplicatecommitTablefolded intoupdateTable; realtime unified on the Subscription noun (removeChannel*→removeSubscription*,removeAllChannels→removeAllSubscriptions) with aget*prefix for snapshots (activeChannels→getActiveChannelNames,activeChannelDetails→getActiveChannels). accessTokenposition consistent: MFA methods takeaccessTokenfirst.- Result-shaping twins collapsed (Rule 2 — base name returns the rich/safe type):
storage
remove+removeWithResult→deleteObjects(richList<FileObject>); authverifyOtp/verifyOtpWithTokenHash(and their shorthands +*AndSaveSession) now returnOtpVerifyResult, deleting every*WithResulttwin; functionsinvokeUnit/invokeWithBodyUnitdropped (invoke/invokeWithBodyalready return a branchableSupabaseResult). - Factory/accessor parity: auth-admin
authAdmin(key)→createAuthAdminClient(client, key); addedSupabaseClient.functionsaccessor (mirrors.auth/.database/.storage); sync gainedcreateSyncEngine/createSupabaseRemoteSourcefactories. (Realtime stays factory-only by design — it owns a live WebSocket, so a fresh-per-access property would drop subscriptions.) - SQLDelight leakage closed:
SqlDelightLocalStore's generated-type constructor isinternal(consumers use theSqlDriverctor /openOfflineSyncStore) and the generated…sync.store.dbpackage — including theCursorthat collided with the hand-writtensync.Cursor— is excluded from the tracked ABI.
- Closed sets → enums:
-
DONE —selectRangerawPairreturn:selectRangereturnedSupabaseResult<Pair<String, PostgrestRange>>(callers had to remember.first/.second); it now returns a namedPostgrestRawPage(body, count, range), the raw-string analogue ofPostgrestPage<T>.
Decide before 1.0 (additive, can land in 1.x but design now):
- Storage streaming: add
kotlinx.ioSource/Sink(file-backed) overloads so large upload/download isn't forced through an in-memoryByteArray. A bytes-returning functions response accessor (invokeForBytes) is the same shape of additive gap — both are purely new surface, safe to land post-1.0.
A second, naming-only sweep across every module's .klib.api (7 parallel auditors +
a cross-module acronym/factory-verb/enum-casing analysis), judged against the Kotlin/JetBrains
API guidelines. Verdict: the surface was already in good shape — all auditors reported "no
blockers." A handful of genuine, contained violations of the SDK's own conventions were
fixed; the rest are deliberate, recorded decisions.
Fixed (the SDK's own convention was violated, and the change was contained):
invokeSSE→invokeSse— acronyms are word-cased in every other identifier (Url,Otp,Jwt,Csv,GeoJson); this was the single all-caps outlier in the whole SDK.Paginator.endReached→isEndReached— booleans read as assertions; its sibling isisLoading.selectWithCount→selectWithCountTyped— it decodes intoT, so it joins the*Typedfamily.RealtimeChannelBuilder.setPrivate→configurePrivate— matches the builder'sconfigure*group.- storage
createUploadSignedUrl(WithPath)→createSignedUploadUrl(WithPath)—Signed+direction order, matchingcreateSignedUrl/getSignedDownloadUrl. VectorDistanceMetric.DOTPRODUCT→DOT_PRODUCT; auth-admin OIDCjwksUri→jwksUrl(both keep their wire value via@SerialName).- Removed two exact duplicates: core
toResultFlow()(≡asFlow()) and realtimestatusFlow()(≡ thestatusproperty).
Perfection pass — every enum now one casing, every remaining outlier resolved:
- All enum entries are now
UPPER_SNAKE, SDK-wide. The two PascalCase hold-outs (SupabaseErrorCategory=CONFLICT/NOT_FOUND/… andTextSearchType=RAW/PLAIN/PHRASE/WEB_SEARCH) were converted to match the ~38 other enums. Both were verified non-wire (SupabaseErrorCategoryis derived from HTTP status;TextSearchTypecarries its PostgREST token in a constructor arg), so the change is name-only. RealtimeSubscription.channel: String→channelName— it returns a name, not aRealtimeChannel; this also aligns it with the builder's existingchannelName.sync.Record/sync.Cursor→SyncRecord/SyncCursor— qualified domain nouns (matchingPendingChange/PullResult), andSyncCursorno longer collides with the generated…store.db.Cursor.- storage
ObjectListV2Result→ObjectListV2Response(the lone*Resultamong*Responselist DTOs); auth-adminAuditLogEntry→AuditLogEvent(the method islistAuditLogEvents); auth-admin OIDCuserinfoUrl→userInfoUrl(camelCase, siblingsauthorizationUrl/tokenUrl;@SerialNamekeeps theuserinfo_urlwire field); client configlogLevel→httpLogLevel(disambiguates the wire-verbosityHttpLogLevelfrom logger-severitySupabaseLogLevel).
Deliberate — kept by design (recorded so they aren't "fixed" later):
- Filter-operator vocabulary (
eq/neqabbreviated,greater/greaterEqspelled, range opsrangeGt/rangeGte) is kept — the query DSL was reviewed and blessed in the main audit; the range ops are a distinct PostgREST range-operator family ("strictly right of", "doesn't extend left") deliberately mirroring the supabase-js range tokens, sorangeGreaterwould wrongly imply scalar-greater semantics. Not reopened. - Factory verbs differ by what they return, on purpose:
Supabase.create(root builder),createXClient(feature clients),googleAuthProvider/appleAuthProvider(return a provider descriptor you hand to config, not a client),openOfflineSyncStore(opens a DB-backed resource). rpcTyped=single vsselectTyped=list is intentional: an RPC returns one value by nature, aselectreturns rows.rpcListTyped/rpcSingleTypedmake the other cardinalities explicit.- Pseudo-namespaced auth methods (
mfa*/oauth*/passkey*) read noun-first by design — a flattened stand-in for sub-clients; admin stays verb-first CRUD.getUserById/updateUserByIdkeepByIdbecause the non-admingetUseralready means "by access token." *OrThrow/*WithResult/*WithAcksuffixes are the deliberate explicit-exception / await-confirmation escape hatches on top of the Result-first defaults — kept, not collapsed.
Still deliberately kept (renaming would not be an improvement):
- Factory verbs differ by what they return, on purpose:
Supabase.create(root builder),createXClient(feature clients),googleAuthProvider/appleAuthProvider(return a provider descriptor you hand to config, not a client),openOfflineSyncStore(opens a DB-backed resource). rpcTyped=single vsselectTyped=list — an RPC returns one value by nature, aselectreturns rows;rpcListTyped/rpcSingleTypedmake the other cardinalities explicit.- Pseudo-namespaced auth methods (
mfa*/oauth*/passkey*) read noun-first by design — a flattened stand-in for sub-clients; admin stays verb-first CRUD.getUserById/updateUserByIdkeepByIdbecause the non-admingetUseralready means "by access token." *OrThrow/*WithResult/*WithAcksuffixes are the deliberate explicit-exception / await-confirmation escape hatches on top of the Result-first defaults.