Skip to content

Commit

Permalink
fix(supabase): refetch associations for realtime subscriptions (#514) (
Browse files Browse the repository at this point in the history
  • Loading branch information
tshedor authored Jan 2, 2025
1 parent fe0c05b commit e473bcd
Show file tree
Hide file tree
Showing 9 changed files with 145 additions and 87 deletions.
4 changes: 4 additions & 0 deletions packages/brick_offline_first_with_supabase/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Unreleased

## 1.3.0

- If a model requested in a realtime subscription has an association, an extra fetch is performed (#514)

## 1.2.0

- Upgrade `brick_core` to `1.3.0`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,22 @@ abstract class OfflineFirstWithSupabaseRepository<
memoryCacheProvider.delete<TModel>(results.first, repository: this);

case PostgresChangeEvent.insert || PostgresChangeEvent.update:
// The supabase payload is not configurable and will not supply associations.
// For models that have associations, an additional network call must be
// made to retrieve all scoped data.
final modelHasAssociations = adapter.fieldsToSupabaseColumns.entries
.any((entry) => entry.value.association && !entry.value.associationIsNullable);

if (modelHasAssociations) {
await get<TModel>(
query: query,
policy: OfflineFirstGetPolicy.alwaysHydrate,
seedOnly: true,
);

return;
}

final instance = await adapter.fromSupabase(
payload.newRecord,
provider: remoteProvider,
Expand Down
2 changes: 1 addition & 1 deletion packages/brick_offline_first_with_supabase/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ homepage: https://github.com/GetDutchie/brick/tree/main/packages/brick_offline_f
issue_tracker: https://github.com/GetDutchie/brick/issues
repository: https://github.com/GetDutchie/brick

version: 1.2.0
version: 1.3.0

environment:
sdk: ">=3.0.0 <4.0.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ class Pizza extends OfflineFirstWithSupabaseModel {
required this.toppings,
required this.frozen,
});

@override
int get hashCode => id.hashCode ^ frozen.hashCode;

@override
bool operator ==(Object other) => other is Pizza && other.id == id && other.frozen == frozen;

@override
String toString() => 'Pizza(id: $id, toppings: $toppings, frozen: $frozen)';
}

enum Topping { olive, pepperoni }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,20 +433,26 @@ void main() async {
customers,
emitsInOrder([
[],
[],
[customer],
[customer],
]),
);

const req = SupabaseRequest<Customer>();
final resp = SupabaseResponse(
await mock.serialize(
customer,
realtimeEvent: PostgresChangeEvent.insert,
repository: repository,
mock.handle({
const SupabaseRequest<Customer>(realtime: true): SupabaseResponse(
await mock.serialize(
customer,
realtimeEvent: PostgresChangeEvent.insert,
repository: repository,
),
),
);
mock.handle({req: resp});
const SupabaseRequest<Customer>(): SupabaseResponse([
await mock.serialize(
customer,
repository: repository,
),
]),
});

// Wait for request to be handled
await Future.delayed(const Duration(milliseconds: 200));
Expand Down Expand Up @@ -474,13 +480,12 @@ void main() async {
expect(
customers,
emitsInOrder([
[customer],
[customer],
[],
]),
);

const req = SupabaseRequest<Customer>();
const req = SupabaseRequest<Customer>(realtime: true);
final resp = SupabaseResponse(
await mock.serialize(
customer,
Expand Down Expand Up @@ -515,6 +520,22 @@ void main() async {
],
);

mock.handle({
const SupabaseRequest<Customer>(realtime: true): SupabaseResponse(
await mock.serialize(
customer2,
realtimeEvent: PostgresChangeEvent.update,
repository: repository,
),
),
const SupabaseRequest<Customer>(): SupabaseResponse([
await mock.serialize(
customer2,
repository: repository,
),
]),
});

final id =
await repository.sqliteProvider.upsert<Customer>(customer1, repository: repository);
expect(id, isNotNull);
Expand All @@ -524,21 +545,11 @@ void main() async {
expect(
customers,
emitsInOrder([
[customer1],
[customer1],
[customer2],
[customer2],
]),
);

const req = SupabaseRequest<Customer>();
final resp = SupabaseResponse(
await mock.serialize(
customer2,
realtimeEvent: PostgresChangeEvent.update,
repository: repository,
),
);
mock.handle({req: resp});
});

group('as .all and ', () {
Expand All @@ -556,26 +567,32 @@ void main() async {
await repository.sqliteProvider.get<Customer>(repository: repository);
expect(sqliteResults, isEmpty);

mock.handle({
const SupabaseRequest<Customer>(realtime: true): SupabaseResponse(
await mock.serialize(
customer,
realtimeEvent: PostgresChangeEvent.insert,
repository: repository,
),
),
const SupabaseRequest<Customer>(): SupabaseResponse([
await mock.serialize(
customer,
repository: repository,
),
]),
});

final customers = repository.subscribeToRealtime<Customer>();
expect(
customers,
emitsInOrder([
[],
[],
[customer],
[customer],
]),
);

const req = SupabaseRequest<Customer>();
final resp = SupabaseResponse(
await mock.serialize(
customer,
realtimeEvent: PostgresChangeEvent.insert,
repository: repository,
),
);
mock.handle({req: resp});

// Wait for request to be handled
await Future.delayed(const Duration(milliseconds: 100));

Expand All @@ -601,13 +618,12 @@ void main() async {
expect(
customers,
emitsInOrder([
[customer],
[customer],
[],
]),
);

const req = SupabaseRequest<Customer>();
const req = SupabaseRequest<Customer>(realtime: true);
final resp = SupabaseResponse(
await mock.serialize(
customer,
Expand Down Expand Up @@ -650,70 +666,71 @@ void main() async {
expect(
customers,
emitsInOrder([
[customer1],
[customer1],
[customer2],
[customer2],
]),
);

const req = SupabaseRequest<Customer>();
final resp = SupabaseResponse(
await mock.serialize(
customer2,
realtimeEvent: PostgresChangeEvent.update,
repository: repository,
mock.handle({
const SupabaseRequest<Customer>(realtime: true): SupabaseResponse(
await mock.serialize(
customer2,
realtimeEvent: PostgresChangeEvent.update,
repository: repository,
),
),
);
mock.handle({req: resp});
const SupabaseRequest<Customer>(): SupabaseResponse(
[
await mock.serialize(
customer2,
repository: repository,
),
],
),
});
});

test('with multiple events', () async {
final customer1 = Customer(
final pizza1 = Pizza(
id: 1,
firstName: 'Thomas',
lastName: 'Guy',
pizzas: [
Pizza(id: 2, toppings: [Topping.pepperoni], frozen: false),
],
toppings: [],
frozen: false,
);
final customer2 = Customer(
final pizza2 = Pizza(
id: 1,
firstName: 'Guy',
lastName: 'Thomas',
pizzas: [
Pizza(id: 2, toppings: [Topping.pepperoni], frozen: false),
],
toppings: [],
frozen: true,
);

final customers = repository.subscribeToRealtime<Customer>();
final pizzas = repository.subscribeToRealtime<Pizza>();
expect(
customers,
pizzas,
emitsInOrder([
[],
[],
[customer1],
[customer2],
[pizza1],
[pizza2],
]),
);

const req = SupabaseRequest<Customer>();
final resp = SupabaseResponse(
await mock.serialize(
customer1,
realtimeEvent: PostgresChangeEvent.insert,
repository: repository,
),
realtimeSubsequentReplies: [
SupabaseResponse(
await mock.serialize(
customer2,
realtimeEvent: PostgresChangeEvent.update,
repository: repository,
),
mock.handle({
const SupabaseRequest<Pizza>(realtime: true): SupabaseResponse(
await mock.serialize<Pizza>(
pizza1,
realtimeEvent: PostgresChangeEvent.insert,
repository: repository,
),
],
);
mock.handle({req: resp});
realtimeSubsequentReplies: [
SupabaseResponse(
await mock.serialize<Pizza>(
pizza2,
realtimeEvent: PostgresChangeEvent.update,
repository: repository,
),
),
],
),
});
});
});
});
Expand Down
5 changes: 5 additions & 0 deletions packages/brick_supabase/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## Unreleased

## 1.3.0

- When testing realtime responses, `realtime: true` must be defined in `SupabaseRequest`. This also resolves a duplicate `emits` bug in tests; the most common resolution is to remove the first duplicated expected response (e.g. `emitsInOrder([[], [], [resp]])` becomes `emitsInOrder([[], [resp]])`)
- Associations are not serialized in the `SupabaseResponse`; only subscribed table data is provided

## 1.2.0

- **DEPRECATION** `Query(providerArgs: {'limitReferencedTable':})` has been removed in favor of `Query(limitBy:)`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ class SupabaseMockServer {
final realtimeFilter = requestJson['payload']['config']['postgres_changes'].first['filter'];

final matching = responses.entries
.firstWhereOrNull((r) => realtimeFilter == null || realtimeFilter == r.key.filter);
.firstWhereOrNull((r) => r.key.realtime && realtimeFilter == r.key.filter);

if (matching == null) return;

Expand Down Expand Up @@ -148,6 +148,7 @@ class SupabaseMockServer {
final url = request.uri.toString();

final matchingRequest = responses.entries.firstWhereOrNull((r) {
if (r.key.realtime) return false;
final matchesRequestMethod =
r.key.requestMethod == request.method || r.key.requestMethod == null;
final matchesPath = request.uri.path == r.key.toUri(modelDictionary).path;
Expand Down Expand Up @@ -213,10 +214,10 @@ class SupabaseMockServer {
// Delete records from realtime are strictly unique/indexed fields;
// uniqueness is not tracked by [RuntimeSupabaseColumnDefinition]
// so filtering out associations is the closest simulation of an incomplete payload
if (realtimeEvent == PostgresChangeEvent.delete) {
for (final value in adapter.fieldsToSupabaseColumns.values) {
if (value.association) serialized.remove(value.columnName);
}
//
// Associations are not provided by insert/update either
for (final value in adapter.fieldsToSupabaseColumns.values) {
if (value.association) serialized.remove(value.columnName);
}

return {
Expand Down
Loading

0 comments on commit e473bcd

Please sign in to comment.