Skill v1.0.0
LLM-judged scan90/100version: "1.0.0" name: supabase-flutter-migrate-v3 description: >- Migrates a Dart or Flutter project from supabase_flutter v2 to v3, including the renamed gotrue, realtime_client, storage_client and functions_client packages. Generates a typed database schema with the Supabase CLI, applies every breaking change from the migration guide, and moves queries from the untyped from() API to the typed table() API. Use when the user asks to upgrade or migrate Supabase to v3, bumps supabase_flutter to ^3, or hits v3 breaking changes.
Migrate supabase_flutter from v2 to v3
The full migration guide is in references/migration-v2-v3.md. Its section headings name the symbol that changed, so search it for the symbol in a compiler error or in the code you are changing. Every "Before" and "After" snippet there is authoritative. This file is the order to apply it in and the checks that make the migration complete.
Rules
- Follow the steps in order. Do not skip the Supabase CLI check.
- Never write the table definitions by hand. They come from
supabase gen types --lang dart, and the generated file is never edited.
- Every query ends up on the typed
table()API unless it matches one of the
exceptions in step 8. Report every exception to the user.
- Keep the behavior of the app. Where v3 changed a default, pin the v2
behavior explicitly unless the user asks for the new one.
- Do not suppress analyzer errors or warnings to get a clean run. Fix them.
- Ask the user before a choice that changes runtime behavior and is not
covered here.
1. Preflight
- Read
pubspec.yaml. Note which of these are dependencies and their
versions: supabase_flutter, supabase, gotrue, postgrest, realtime_client, storage_client, functions_client.
- Run
git status. If the tree is dirty, ask the user to commit or stash
first so the migration is one reviewable diff.
- Run
flutter analyzeand save the output. Issues that exist before the
migration are the baseline, and step 9 is measured against it.
- Check for a
supabase/folder withconfig.tomlandmigrations/. It
decides the source of the generated types in step 4.
2. Supabase CLI 2.122.0 or newer
Dart generation requires Supabase CLI 2.122.0 or newer.
supabase --version
If the project runs the CLI through npm (a supabase entry in package.json), check npx supabase --version instead.
If the command is missing or prints a version below 2.122.0, stop and give the user the command for their platform, then wait until the version check passes:
| Install method | Upgrade command | |
|---|---|---|
| Homebrew (macOS, Linux) | brew upgrade supabase/tap/supabase | |
| npm | npm install --save-dev supabase@latest | |
| Scoop (Windows) | scoop update supabase | |
| Not installed | https://supabase.com/docs/guides/local-development/cli/getting-started |
Do not continue on an older CLI and do not fall back to hand-written types.
3. Pin the changed order() defaults while still on v2
v3 changed two defaults of order() without a compile error. Both pins below already mean the same thing on v2, so add them before upgrading:
grep -rnE '\.order\(' lib test bin
- A call without
ascending:sorted descending on v2 and sorts ascending on
v3. Add ascending: false.
- A query that sorts descending (including the ones you just changed) put
NULLs last on v2 and puts them first on v3. When the column is nullable, add nullsFirst: false. This does not apply to stream().order(), which takes no null placement.
The direction pin applies to stream().order() too. The result is that every order() call states its direction and, on a descending query of a nullable column, its null placement.
4. Dependencies and the typed schema
Once supabase_typegen is added, its supabase-typegen-generate skill has the details of the generator, its options and its errors.
- Update the dependencies. Use the newest 3.x version of each package;
flutter pub outdated lists it, prereleases included.
supabase_flutterto 3.x.- Replace any direct dependency on a renamed package, and its imports:
| v2 | v3 | |
|---|---|---|
gotrue | supabase_auth | |
realtime_client | supabase_realtime | |
storage_client | supabase_storage | |
functions_client | supabase_functions |
A direct dependency that only duplicates what supabase_flutter already re-exports can be removed instead.
- Add the generator:
flutter pub add dev:supabase_typegen. - Run
flutter pub get. - Generate the schema. Pick the source:
- A
supabase/folder with migrations: runsupabase start(needs Docker),
then supabase gen types --lang dart --local.
- A project linked with
supabase link:--linked. - Otherwise ask the user for the project ref and use
--project-id <ref>.
Ask which schemas the app queries. Pass --schema for each schema other than public that the app uses. Write to lib/supabase_schema.g.dart unless the project already has a generated-code convention:
``sh supabase gen types --lang dart --local > lib/supabase_schema.g.dart ``
- Run
flutter analyze lib/supabase_schema.g.dart. It must be clean. If it is
not, report the error to the user rather than editing the file.
5. Mechanical renames
Apply these across the project before reading compiler errors, since they remove most of them. The tables are in the reference:
- "The client packages are renamed": imports and the
GoTrue*type names,
such as GoTrueClient to AuthClient.
- "Abbreviations in the public API are spelled out": for example
queryParams: to queryParameters: and opts: to options:.
- "Plural enum names singularized" and "One shared
SortDirectionfor every
sort direction".
- "Every deprecated API is gone": for example
User.confirmedAtto
User.emailConfirmedAt and AuthUser to User.
Supabase.initialize(anonKey: …)topublishableKey: …, and remove the
debug: argument. If the app wants console logs, add the Logger.root.onRecord listener from "The SDK no longer prints logs".
6. Compiler-driven pass
- Run
flutter analyze. - For each new error, search the reference for the symbol it names and apply
the "After" form of that section.
- Repeat until the only issues left are the baseline from step 1.
The sections that most often apply:
- "Sign-in methods that always issue a session return the
Session":
signInWithPassword() and others return the Session directly.
- "
AuthStateis a sealed class" and
"User.appMetadata is an AppMetadata and userMetadata is never null".
- "Realtime listener callbacks are now streams":
onPostgresChanges,
onBroadcast and the connection callbacks.
- "The auth client persists the session itself":
localStorage:and
LocalStorage subclasses become an AuthAsyncStorage.
- "Service exceptions share one base" and "
FunctionExceptionis sealed". - "Every client takes one
SupabaseRetryOptions". - "Timestamps are
DateTimeinstead ofStringorint". - "
rpcis a getter", "insert()andupsert()no longer take filters" and
"The rest client and its builders are stateless".
- "
supabasePersistSessionKeyis gone": keep the old key asstorageKeyonly
if the app wrote sessions under it through a custom storage, otherwise users are signed out once.
7. Changes the compiler will not catch
Go through every item. For each one, search with the given pattern, read every match, and fix or confirm it. Keep a list of the sites you could not decide for the summary in step 9.
| Change (reference section) | Search | Action | ||||
|---|---|---|---|---|---|---|
order() defaults | \.order\( | Confirm step 3 covered every call. | ||||
RealtimeClient.connectionState is now typed | connectionState | Compare against SocketState.open and friends, not strings. Prefer isConnected. | ||||
HttpMethod is one shared enum | HttpMethod\.[a-z]+\.index | Persist name instead of index. | ||||
Confirming an email or phone change emits userUpdated | AuthChangeEvent\.signedIn | Handle userUpdated where an email or phone change confirmation should be seen. | ||||
AuthChangeEvent.initialSession is emitted to every subscriber | onAuthStateChange | Listeners that act on signedIn at subscription time must also act on initialSession. | ||||
A client without an asyncStorage throws when it is created | SupabaseClient\( | Every directly constructed client passes AuthClientOptions(asyncStorage: …), uses the implicit flow, or uses a third-party accessToken. | ||||
| The retry backoff defaults are the same in every client | `retryOptions\ | SupabaseRetryOptions` | Ask whether the old PostgREST curve matters. If it does, pass the options shown in the reference. | |||
| The rest client and its builders are stateless | `=\sawait .\.(insert\ | upsert\ | update\ | delete)\(` | A write without select() resolves to void. Drop the assignment or add .select(). | |
userMetadata is never null | `userMetadata\s*(\?\ | ==\ | !=)` | Remove the dead null checks. | ||
SupabaseClient builds its own default HTTP client | runWithClient | Pass the client as httpClient: instead. | ||||
| A request that gets no response throws a transport exception | `on ClientException\ | on TimeoutException` | Catch SupabaseTransportException around Supabase calls. | |||
A failing accessToken callback throws AccessTokenException | accessToken: and transport exception catches | Catch AccessTokenException where token failures were handled. | ||||
| The request timeout bounds idle time | requestTimeout | Add .timeout(…) on the builder where a total deadline was intended. | ||||
| Realtime throws exceptions instead of strings | `on String\ | toString\(\)\.contains near subscribe, httpSend, track, send` | Catch StateError, RealtimeApiException or RealtimeTransportException. | |||
The session is persisted with SharedPreferencesAsync | SharedPreferences\.getInstance | Migrate the app's own preferences to SharedPreferencesAsync, or use the legacy storage from the reference. Widget tests that call Supabase.initialize need the InMemorySharedPreferencesAsync setup. |
Search lib, test, bin and any other source folders with grep -rnE '<pattern>'.
8. Move every query to the typed table() API
Follow the reference section "Moving from from() to the typed table API". Convert one table at a time and re-run flutter analyze after each.
Naming in the generated file: table books in public is Books, BooksRow, BooksInsert and BooksUpdate. A table in another schema carries the schema as a prefix, inventory.books is InventoryBooks, and supabase.schema('inventory').from('books') becomes supabase.table(InventoryBooks.table). Columns are camelCase constants on the table class, Books.authorId.
| Untyped | Typed | ||
|---|---|---|---|
.from('books').select() | .table(Books.table).select() | ||
.select('id, title') | .selectOnly([Books.id, Books.title]), read with row.read(Books.title) | ||
.select('title, authors(name)') | .selectOnly([Books.title, Books.authors(Authors.name)]) | ||
.eq('author_id', 2) | .where(Books.authorId.eq(2)) | ||
.or('price.lt.10,in_print.eq.false') | `.where(Books.price.lt(10) \ | Books.inPrint.isFalse())` | |
.order('created_at', ascending: false) | .order(Books.createdAt.desc()) | ||
.order('price', ascending: false, nullsFirst: false) | .order(Books.price.desc().nullsLast()) | ||
.limit(3, referencedTable: 'books') | .embedded(Authors.books, (books) => books.limit(3)) | ||
.insert({...}) | .insert(BooksInsert(...)) | ||
.insert([{...}, {...}]) | .insertAll([BooksInsert(...), ...]) | ||
.update({...}).eq('id', 1) | .update(BooksUpdate(...)).where(Books.id.eq(1)) | ||
.update({'price': null}) | .update(BooksUpdate().setPriceToNull()) | ||
.delete().eq('id', 1) | .delete().where(Books.id.eq(1)) | ||
.delete() with no filter | .delete().all() | ||
.upsert({...}, onConflict: 'name') | .upsert(AuthorsInsert(...), onConflict: [Authors.name]) | ||
.stream(primaryKey: ['id']).eq('author_id', 2) | .stream(primaryKey: [Books.id]).filter(Books.authorId.eq(2)) | ||
.rpc('search_books', params: {'query': q}) | .rpc.searchBooks(query: q) |
Rules for the conversion:
- Carry the direction pinned in step 3 over:
ascending: falsebecomes
.desc(), and nullsFirst: false becomes .nullsLast(). An order() with neither stays a plain column, which is ascending. A typed stream() takes the same orderings, but throws an ArgumentError on .nullsFirst() or .nullsLast(), since it always places NULLs last ascending and first descending.
- A typed
update()ordelete()sends nothing until it has.where(…)or
.all(). A bare await of one compiles and silently does nothing, and the analyzer reports it as an unused_result warning. Fix every one of them.
- Passing
nullto anInsertorUpdateparameter leaves the column out.
Use the generated set…ToNull() method to write SQL NULL.
- Replace
row['title'] as Stringwithrow.titlewherever the rows are
consumed. A model class that only mirrors a table is replaced by the generated row type. A model that adds behavior is built from the row getters, not from toJson().
- An operator with no typed method uses
Books.title.raw('eq.Dune'). A filter
on a JSON path uses PostgrestFilter.raw('metadata->>edition', 'eq.first').
from() and the untyped rpc('fn', params: …) remain only for:
- a table, view or function missing from the generated file because its
schema was not generated or is not exposed through the Data API,
- a table name computed at runtime,
- a function the generator gives no method, which it explains in a comment in
the generated file.
9. Verify and report
flutter analyzeshows nothing beyond the baseline from step 1.- Run
dart formaton every file you changed. Do not format
lib/supabase_schema.g.dart, which is already formatted.
- Run
flutter test. Fix failures the migration caused and report the rest. - Give the user a short summary:
- the dependency changes and the generated schema file and command,
- every site from step 7 that needs a human decision,
- every remaining
from()or untypedrpc()call and its reason, - that the schema must be regenerated with the same command after every
database migration.