- Use 2 spaces for indentation (configured in
.editorconfig) - Enable strict concurrency checking (
StrictConcurrencyfeature) - Use
ExistentialAnyfeature for explicit existential types - Follow Swift API Design Guidelines
- Prefer
async/awaitover completion handlers - Mark types as
Sendablewhere appropriate for concurrency safety
-
Use a Swift
enumonly when the value is genuinely closed: the cases carry associated values (SignUpResult,SignedURLResult,RealtimeClientStatus,AuthStatus), every case is fixed by the language or protocol itself (HTTP methods, aCodingKeysset), or the SDK has to implement each case so a value it has no code for is meaningless (RealtimePostgresIsValue). Adding a case to one of these is a major bump. Every public value enum isHashableunless a payload cannot be. -
A status or event type the SDK produces follows one test: does any case carry a payload? If yes, it is an
enum, and its doc comment says that new cases arrive only in a major release (AuthStatus,RealtimeClientStatus,RealtimeChannelEvent,HeartbeatEvent). If no, it is an open struct (AuthEvent). -
Any enum-like value sent to or received from the backend, and any payload-free status or event name a consumer will
switchover (AuthEvent,Provider), must be aRawRepresentablestruct instead, so a value the backend adds later (or that this SDK just doesn't have a case for yet) round-trips through.rawValueinstead of failing to decode or blocking construction until an SDK upgrade. The consumer-facing policy lives inSources/Supabase/Supabase.docc/EnumsAndOpenSets.md; keep it in sync when a type changes shape:public struct Provider: RawRepresentable, Codable, Hashable, Sendable, ExpressibleByStringLiteral { public let rawValue: String public init(rawValue: String) { self.rawValue = rawValue } public init(stringLiteral value: String) { self.init(rawValue: value) } public static let apple: Provider = "apple" public static let github: Provider = "github" }
-
Conform to exactly what the value's actual usage needs —
Codableonly if it's ever decoded,Encodable-only if it's write-only, noCodable/Encodableat all if it's read via.rawValuedirectly into a header or query string (never throughJSONEncoder/JSONDecoder). Don't upgrade a type's conformance just because a sibling type in the same file has more. -
Don't add a
CaseIterable/Identifiablereplacement (aknownCasesarray, a customIdentifiable) to the SDK type unless something inside this package actually needs one — a consuming app can keep its own list of the values it cares about (seeExamples/Examples/Auth/KnownProviders.swift). -
init(rawValue:)is intentionally non-failable — it always succeeds, which is what makes constructing/decoding an unrecognized value safe instead of an error. Any breaking-change migration note for a type like this must call out thatif let x = X(rawValue:)goes from compiling to a compile error, and that interpolating the value directly ("\(x)") silently stops printing the case name. -
Every open set has the same shape:
public let rawValue,init(rawValue:),init(stringLiteral:)(orinit(integerLiteral:)for anIntset), andHashable & Sendable, plus only the conformances its usage needs (see "Conform to exactly what the value's actual usage needs" above). It has no other initializers. UpdateEnumsAndOpenSets.mdin the same commit when you add, remove, or change an enum or an open set.
These rules add to the Swift API Design Guidelines. They make the same idea read the same way in every module.
- Acronyms. Write acronyms in uniform case in every public name:
ID,URL,URI,JWT,OTP(userID,redirectURL,jwksURI;idandurlwhen they start a name). Never writeId,Url, orUri. When the wire key differs, map it inCodingKeys(see "Codable Conformance"). - One concept, one type, one name. A concept has one Swift type and one name, in requests and in responses. Links and endpoints are
URL. An ID isUUIDwhen the wire format guarantees a UUID. An opaque ID, such as the OAuth authorization ID, isString. Spans areDuration, and points in time areDate; neverTimeInterval. A typed wrapper the SDK accepts (CacheControl,WebSocketCloseCode) is also the type it returns. Metadata isJSONObject, not a spelled-out[String: JSONValue]. - Same setting, same name. A setting that means the same thing in two modules has the same name and type (
redirectTo,observesAppLifecycle,retryEnabled,clock). A facade option has the same name as the module field it forwards to. - Booleans. Boolean properties and settings read as assertions:
isEnabled,isPrivate,observesAppLifecycle. - Type suffixes.
<Action>Optionsis a struct of optional request settings (see "Method Parameters").<Noun>Resultis a closed enum of outcomes.<Noun>Pageis one page of a paged list.<Owner>Statusis a state value. A request payload is named for its action (CreateOAuthClient), not*Paramsor*Attributes, and is passed without a label. A plain value gets a plain noun. A term of art from the server or a standard keeps its name, even when it breaks these suffixes:PasskeyRegistrationOptionscarries the W3C WebAuthn credential creation options, and the server calls its endpointpasskeys/registration/options. - Verbs. Resource methods on a client use
list<Noun>s,<noun>(id:),create<Noun>(_:),update<Noun>(id:_:), anddelete<Noun>(id:). A sub-client named for its resource (admin.customProviders,passkeys) uses the bare verbs:list,create,update,delete. A sub-client named for a feature (admin.oauth,admin.mfa) names the noun in the verb, as supabase-js does:listClients,listFactors. - Labels. A label names the entity it identifies:
revokeGrant(clientID:), notrevokeGrant(id:), when the argument is a client's ID. One argument has one label across overloads and modules.
- A public response or model struct has
public varstored properties and a public memberwise init, so apps can build fixtures and previews. Do not mixletandvarin one type. Two cases are exempt because a public memberwise init would break an invariant:Nonce(hashedis the SHA-256 ofraw) andTypedPostgresChange(it holds the decoder that the SDK sets). - A response wrapper uses
status: HTTPResponse.Status,headers: HTTPFields,body(DataorHTTPBody), andrequestID: String?. - A per-call
encoder:ordecoder:parameter isJSONEncoder?orJSONDecoder?with a default ofnil.nilmeans the client's configured coder. Do not expose shared static coder instances.
- Client setup types end in
Configuration. Per-call settings end inOptions(see "Method Parameters"). Never use one suffix for the other, and never useConfig. - Each module client has
init(configuration: <Module>Client.Configuration). The configuration is a struct nested in the client, and it holds every setting,urlincluded. Clients have no flat convenience inits. - Each client exposes the value it was created with as
public let configuration. Every configuration field ispublic var. - Configuration init parameters come in this order:
url, required module fields,headers: HTTPFields = [:], optional module fields,http: HTTPClientConfiguration,logger: Logger(never optional; defaults tosupabaseDefaultLogger(label: "io.supabase.<module>")),encoder/decoder,retryEnabled,clock. - An HTTP module has no
accessTokensetting. The caller addsAccessTokenMiddlewaretohttp.middlewares; that is the one way to put a token on a request (see ARCHITECTURE.md "HTTP Layer"). Realtime is the one exception: its socket needs the token outside any HTTP request, soRealtimeClient.Configuration.accessTokenis its only token source, and it also covershttpSend. - Every module that retries takes
retryEnabled:. Every module that waits or retries takesclock:. - The facade follows the same naming:
SupabaseClient(supabaseURL:supabaseKey:configuration:)takes aSupabaseClient.Configuration, with one nested type per sub-client (SupabaseClient.Configuration.Auth,.Database,.Storage,.Functions,.Realtime) and.Globalfor shared settings. A facade type never reuses a module configuration type, because the facade derivesurland the key itself. - Public header APIs use
HTTPFieldsandHTTPField.Name, never[String: String].
- Every observable source is a method that returns a stream, never a callback or a token. A snapshot property may sit beside it, but the stream itself is never a property. Name it
statusChanges()for state, andevents()or<noun>s()for discrete events. Astatussnapshot property sits next tostatusChanges(). - A state stream emits the current value first, then every change. An event stream never replays. A stream whose elements carry the full state is a state stream, whatever it is named:
AuthClient.events()yieldsAuthChange, which holds theAuthStatus, so it starts withinitialSession, asonAuthStateChangedoes in supabase-js. - Status and event streams are
AsyncStreamand never throw; a failure shows up in the status. Data streams and paged sequences can throw. - Each call returns a new stream for one consumer. The stream ends when the consuming task is cancelled or when the owner is released.
- A one-shot call takes a
progress:closure. A long-lived handle exposes progress as a stream. - A paged endpoint returns an
AsyncSequenceof elements that also offers.pages. The page-size parameter ispageSize.
Use these rules for every public method. Apply them in order.
- Inline the values that identify the target or the action: the required inputs (
path,provider,email,token,type) and the source and destination of a move or copy (toBucket,sourceVersionId). These are always inline, even when they are optional. An optional value that only selects a variant of the result, such asversionIdortransformon a URL, is a setting, not an identifier. - Put optional request settings in an
<Action>Optionsstruct when one of these is true:- The method has three or more optional settings.
- The method shares a group of two or more settings with another method. A single shared
setting is not a group. For example,
redirectToandcaptchaTokentogether appear insignInWithSSO,resend, andresetPassword.downloadandcacheNoncetogether appear increateSignedURL,createSignedURLs, andpublicURL. - The method already takes an options struct. Then all of its optional settings go in that
struct, as
transformdoes inDownloadOptions.
- Overloads of one method use the same shape.
resend(phone:type:options:)takes options becauseresend(email:type:options:)does, even though it has only one setting. This rule wins over rule 4. - Otherwise, one or two optional settings stay inline when the matching supabase-js method has
no more optional settings than these. A lone
captchaTokenorredirectTois fine:signIn(email:password:captchaToken:),signInAnonymously(userMetadata:captchaToken:),updateUser(_:redirectTo:),createSignedUploadURL(path:upsert:),listUsers(page:pageSize:),info(path:versionId:),disconnect(code:reason:). - The options parameter is named
optionsand has a default of an empty value (.init()orXOptions()). It comes after the required inputs. It comes before the parameters that are not request settings, which always stay inline:presenter,progress,as:,decoder,encoder, and the payload (body,values).
Rule 2 decides whether settings go in a struct. It does not mean that methods share one struct.
Give each method (or each overload family) its own options type, even when two types have the
same fields today, as SSOOptions and ResetPasswordOptions do. The server can add a field to
one action and not the other. Methods that send the same request share a type, as upload,
update, and uploadToSignedURL share UploadOptions. Mirror the field names of supabase-js
where they exist, and follow the Swift API Design Guidelines for the type and parameter names.
These are deliberate exceptions. They keep their inline parameters:
- PostgREST builder modifiers:
select,insert,upsert,update,delete,order,explain,textSearch, andrpc. Each call is one step in a chain, and its labels read like the SQL clause they make. - Realtime
postgresChanges(...). Its parameters describe the subscription, so rule 1 applies. - Initializers of configuration, model, error, and options types.
Use standard file headers with copyright:
//
// FileName.swift
// ModuleName
//
// Created by Author Name on DD/MM/YY.
//- Each module is independent and can be used standalone
- Use
@_exported importin main Supabase module to re-export all sub-modules - Keep module dependencies minimal
- Prefer protocol-oriented design
- Only conform a type to the direction the SDK actually uses: a type the SDK
only decodes from a server response gets
Decodable, a type the SDK only encodes into a request body getsEncodable. Conform to fullCodableonly when the same type is genuinely used in both directions (e.g. a type the SDK both sends and receives over the wire, or persists to disk viaJSONEncoder/JSONDecoder, like Auth'sSession). - Before narrowing a type from
CodabletoDecodable/Encodable, check whether any other type embeds it and relies on synthesizedCodable— a container relying on synthesized conformance stops compiling the moment a field it holds drops the direction the container needs, so the container must narrow the same way (or gain its own hand-written coder). The same check applies in reverse when widening a type back towardCodable: every stored property must be at least as conformant as the type you're widening to. - Don't hand-write
encode(to:)/init(from:)for the unused direction "for symmetry" — dead coders accumulate and mislead readers about how the type is actually used. Seedocs/migrations/v3/for prior cleanup along these lines (the SDK-1473 pass). - SDK models map wire keys with explicit
CodingKeysthat spell the literal wire key (case userID = "user_id"). Do not setkeyDecodingStrategyorkeyEncodingStrategyon SDK coders: a key strategy fights acronym names and silently drops fields that have their ownCodingKeys.
- Report issues using
IssueReporting(from swift-issue-reporting on Swift 6.4+, from xctest-dynamic-overlay on earlier toolchains — see the comment atopPackage@swift-6.2.swift)
- One error per module. Each module has one public error struct,
<Module>Error. It conforms toSupabaseErrorand lives in its own module, never in Helpers. Its fields arekind,message,code,response, andunderlyingError. Errors are notEquatable. Public API uses untypedthrows. - Kinds.
Kindis a nested open-set struct. Every module definesserver,transport,decoding, andinvalidRequest. Every module that can send an access token also definesaccessToken. A module adds its own kinds only for failures these cannot describe (sessionMissing, …). Every kind a module throws is public. - Server codes. A machine-readable code from the server is
public var code: <Module>Error.Code?.Codeis a nested open set (see "Enum-like Values"). It isnilwhen the server sent no code; never use a placeholder such as.unknown. Store the code once. - Retry classification. Retry decisions belong to the SDK, so no public error exposes
isRetryable, andSupabaseErrordoes not require it. A module that retries on its own keeps the flag atpackageaccess and derives it fromkindby default, asRealtimeErrordoes. - Wrapping. A client-side refusal (a value that cannot be encoded, a URL that cannot be built)
is
.invalidRequest, with the cause inunderlyingError. ADecodingErrorfrom a 2xx body is.decoding. AURLErroris.transport; an HTTP timeout is.transportwithURLError(.timedOut). An HTTP rejection is.server, or a module kind chosen from the status, withresponseset. A failure in an access-token provider (AccessTokenMiddleware, the facade's Auth middleware, or Realtime'saccessToken) is.accessToken. Raw Foundation or Helpers errors do not escape. If one must, the method's- Throws:names it. - Cancellation. A cancelled task always surfaces as
CancellationError, never wrapped. So does a cancellation the SDK causes for the caller:disconnect(), sign-out,cancel(), or a sheet the user dismisses.HTTPClientmapsURLError(.cancelled)once, for the response head and the body. Modules do not repeat that mapping. - Error bodies.
HTTPErrorResponse.bodykeeps at most the first 1 MiB. Helpers applies the cap in one place. - Description.
descriptionanderrorDescriptioncome from the sharedSupabaseErrorimplementation. Modules do not override them. - Pattern matching. Every
KindandCodehas a~=againstany Error, socatch StorageError.Kind.serverworks in every module, as it does in Auth today.
The dividing line is who can reach the value, not who supplied it.
- Public initializers throw on a bad configuration. A server builds clients at runtime from
configuration that varies per tenant, so a bad URL there is dynamic input, not a programmer
error. Every client initializer (
SupabaseClient,AuthClient,PostgrestClient,StorageClient,FunctionsClient,RealtimeClient) throws its module error with kindinvalidConfigurationwhen the URL has no scheme or no host. - Internal invariants trap. When the SDK has already checked a value, or builds it itself,
precondition/preconditionFailure(ortry!on a call that cannot fail for that value) is the right tool: a failure is an SDK bug.SupabaseClientcheckssupabaseURLonce, so the getters that build its sub-clients usetry!. Apackagetuning constant traps too. - Varies at runtime, or comes from the server — a per-call parameter, a response header, a
decoded payload, a WebSocket close code. Never trap. Throw if the context already throws;
otherwise
reportIssueand fall back.
Degrading instead of throwing would bury the mistake behind an unrelated failure much later. See SDK-1793.
This project uses the Swift Testing framework, and only Swift Testing — do not use XCTest or XCTestCase.
- Test files should mirror source file structure (
Foo.swift→FooTests.swift) - Suite naming: the type name matches the file name (
FooTests.swift→struct FooTests), with an explicit@Suiteattribute even when no custom name/tags are needed - Test function names drop the
testprefix (the@Testattribute already conveys that) — writefooBehavior(), nottestFooBehavior() - Use
@testable importfor internal access - Prefer
#expect/#requirefor assertions;#expect(x != nil, "message")reads the same asXCTAssertNotNil(x, "message")did expectNoDifference(CustomDump) andwithExpectedIssue/reportIssue(IssueReporting) work unchanged at call sites- Use snapshot testing for complex data structures (via swift-snapshot-testing);
assertSnapshot/assertInlineSnapshotwork inside@Testfunctions - Stub HTTP at the
ClientTransportseam withClosureTransportorRecordingTransportfrom TestHelpers, passed ashttp: HTTPClientConfiguration(transport:). Facade tests pass it throughglobal.http.RecordingTransport.stub(_:_:status:data:headers:) { snapshot }answers a method and path and compares an inline curl snapshot of the request. UseURLProtocolonly in the tests of the URLSession adapters (URLSessionTransportTests,WebSocketTests). Do not copy these stubs into a test target. - Use CustomDump for test assertions with better output
- Keep integration tests separate in
IntegrationTestsdirectory - Test targets get full Swift 6 language mode checking, matching production targets
Example test structure (Swift Testing):
import Testing
@testable import ModuleName
@Suite
struct FeatureTests {
@Test
func featureBehavior() {
// Arrange
let input = "test"
// Act
let result = feature(input)
// Assert
#expect(result == expected)
}
}- Every public symbol has a
///summary, including the static members of open sets and the properties of error structs. - Every public
throwsfunction has a- Throws:line that names the module error and the conditions. - Every library product has a
Sources/<Module>/<Module>.docccatalog. Its landing page follows one template: a one-line summary,## Overview, then## Topicswith### Essentials(the articles), the type groups, and### Errorslast. Each catalog has aHandlingErrorsarticle that lists every error that can escape unwrapped. A module that can run withoutSupabaseClientalso has aStandalonearticle. Article titles use Title Case. - Every type a doc names must exist. A public-API doc (a DocC article,
EnumsAndOpenSets.md) names only public types. Contributor docs (this file, ARCHITECTURE.md) may also namepackagetypes.