brick_offline_first_with_supabase 2.0.0
brick_offline_first_with_supabase: ^2.0.0 copied to clipboard
A Brick domain that routes data fetching through local providers before a Supabase provider.
OfflineFirstWithSupabaseRepository
streamlines the Supabase integration with an OfflineFirstRepository
.
The OfflineFirstWithSupabase
domain uses all the same configurations and annotations as OfflineFirst
.
Repository #
The repository utilizes the OfflineFirstWithRestRepository
's queue because the Supabase client is a thin wrapper around the PostgREST API. There's a small amount of configuration to apply this queue:
// import brick.g.dart and brick/db/schema.g.dart
class MyRepository extends OfflineFirstWithSupabaseRepository {
static late MyRepository? _singleton;
MyRepository._({
required super.supabaseProvider,
required super.sqliteProvider,
required super.migrations,
required super.offlineRequestQueue,
super.memoryCacheProvider,
});
factory MyRepository() => _singleton!;
static void configure({
required String supabaseUrl,
required String supabaseAnonKey,
}) {
// Convenience method `.clientQueue` makes creating the queue and client easy.
final (client, queue) = OfflineFirstWithSupabaseRepository.clientQueue(
// For Flutter, use import 'package:sqflite/sqflite.dart' show databaseFactory;
// For unit testing (even in Flutter), use import 'package:sqflite_common_ffi/sqflite_ffi.dart' show databaseFactory;
databaseFactory: databaseFactory,
);
final provider = SupabaseProvider(
SupabaseClient(supabaseUrl, supabaseAnonKey, httpClient: client),
modelDictionary: supabaseModelDictionary,
);
// Finally, initialize the repository as normal.
_singleton = MyRepository._(
supabaseProvider: provider,
sqliteProvider: SqliteProvider(
'my_repository.sqlite',
databaseFactory: databaseFactory,
modelDictionary: sqliteModelDictionary,
),
migrations: migrations,
offlineRequestQueue: queue,
memoryCacheProvider: MemoryCacheProvider(),
);
}
}
When using supabase_flutter, create the client and queue before initializing:
final (client, queue) = OfflineFirstWithSupabaseRepository.clientQueue(databaseFactory: databaseFactory);
await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey, httpClient: client)
final supabaseProvider = SupabaseProvider(Supabase.instance.client, modelDictionary: ...)
Offline Queue Caveats #
For Flutter users, Supabase.instance.client
inherits this offline client. Brick works around Supabase's default endpoints: the offline queue will not cache and retry requests to Supabase's Auth or Storage.
To ensure the queue handles all requests, pass an empty set:
final (client, queue) = OfflineFirstWithSupabaseRepository.clientQueue(
databaseFactory: databaseFactory,
ignorePaths: {},
);
For implementations that do not wish to retry functions and need to handle a response, add '/functions/v1'
to this Set:
final (client, queue) = OfflineFirstWithSupabaseRepository.clientQueue(
databaseFactory: databaseFactory,
ignorePaths: {
'/auth/v1',
'/storage/v1',
'/functions/v1'
},
);
⚠️ This is an admittedly brittle solution for ignoring core Supabase paths. If you change the default values for ignorePaths
, you are responsible for maintaining the right paths when Supabase changes or upgrades their endpoint paths.
Realtime #
Brick can automatically update with Supabase realtime events. After setting up your table to broadcast, listen for changes in your application:
// Listen to all changes
final customers = MyRepository().subscribeToRealtime<Customer>();
// Or listen to results of a specific filter
final customers = MyRepository().subscribeToRealtime<Customer>(query: Query.where('id', 1));
// Use the stream results
final customersSubscription = customers.listen((value) {});
// Always close your streams
await customersSubscription.cancel();
Complex queries more than one level deep (e.g. with associations) or with comparison operators that are not supported by Supabase's PostgresChangeFilterType
will be ignored - when such invalid queries are used, the realtime connection will be unfiltered even though Brick will respect the query in the stream's results.
⚠️ Realtime can become expensive quickly. Be sure to design your application for appropriate scale. For cheaper, on-device reactivity, use .subscribe()
instead.
Models #
@ConnectOfflineFirstWithSupabase #
@ConnectOfflineFirstWithSupabase
decorates the model that can be serialized by one or more providers. Offline First does not have configuration at the class level and only extends configuration held by its providers:
@ConnectOfflineFirstWithSupabase(
supabaseConfig: SupabaseSerializable(),
sqliteConfig: SqliteSerializable(),
)
class MyModel extends OfflineFirstWithSupabaseModel {}
Associations and Foreign Keys #
Field types of classes that extends OfflineFirstWithSupabaseModel
will automatically be assumed as a foreign key in Supabase. You will only need to specify the column name if it differs from your field name to help Brick fetch the right data and serialize/deserialize it locally.
class User extends OfflineFirstWithSupabaseModel {
// The foreign key is a relation to the `id` column of the Address table
@Supabase(foreignKey: 'address_id')
final Address address;
// If the association will be created by the app, specify
// a field that maps directly to the foreign key column
// so that Brick can notify Supabase of the association.
@Sqlite(ignore: true)
String get addressId => address.id;
}
class Address extends OfflineFirstWithSupabaseModel{
final String id;
}
⚠️ If your association is nullable (e.g. Address?
), the Supabase response may include all User
s from the database from a loosely-specified query. This is caused by PostgREST's filtering. Brick does not use !inner
to query tables because there is no guarantee that a model does not have multiple fields relating to the same association; it instead explicitly declares the foreign key with not.is.null filtering. If a Dart association is nullable, Brick will not append the not.is.null
which could return all results. If you have a use case that requires a nullable association and you cannot circumvent this problem with Supabase's policies, please open an issue and provide extensive detail.
Recursive/Looped Associations
If a request includes a nested parent-child recursion, the generated Supabase query will remove the association to prevent a stack overflow.
For example, given the following models:
class Parent extends OfflineFirstWithSupabaseModel {
final String parentId;
final List<Child> children;
}
class Child extends OfflineFirstWithSupabaseModel {
final String childId;
final Parent parent;
}
A query for MyRepository().get<Parent>()
would generate a Supabase query that only gets the shallow properties of Parent on the second level of recursion:
parent:parent_table(
parentId,
children:child_table(
childId,
parent:parent_table(
parentId
)
)
)
Implementations using looping associations like this should design for their parent (first-level) models to accept null
or empty child associations:
class Parent extends OfflineFirstWithSupabaseModel {
final String parentId;
final List<Child> children;
Parent({
required this.parentId,
List<Child>? children,
}) : children = children ?? List<Child>[];
}
OfflineFirst(where:)
Ideally, @OfflineFirst(where:)
shouldn't be necessary to specify to make the association between local Brick and remote Supabase because the generated Supabase .select
should include all nested fields. However, if there are too many REST calls, it may be necessary to guide Brick to the right foreign keys.
@OfflineFirst(where: {'id': "data['otherId']"})
// Explicitly specifying `name:` can ensure that the two annotations
// definitely have the same values
// Alternatively, you can invoke nested maps (e.g. {'id': "data['pizza']['id']"})
@Supabase(name: 'otherId')
final Pizza pizza;
Unsupported Field Types #
- Any unsupported field types from
SupabaseProvider
, orSqliteProvider
- Future iterables of future models (i.e.
Future<List<Future<Model>>>
).