From fe48597b1608a056fd8d723b073d05edf68de0bd Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Fri, 2 Aug 2024 23:33:49 +0300 Subject: [PATCH] powersync prototype (WIP) --- lib/api_client.dart | 60 ++++++++ lib/app_config.dart | 4 + lib/main.dart | 21 +++ lib/models/schema.dart | 54 +++++++ lib/models/todo_item.dart | 50 +++++++ lib/models/todo_list.dart | 96 +++++++++++++ lib/powersync.dart | 132 ++++++++++++++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 82 ++++++++++- pubspec.yaml | 3 + 12 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 lib/api_client.dart create mode 100644 lib/app_config.dart create mode 100644 lib/models/schema.dart create mode 100644 lib/models/todo_item.dart create mode 100644 lib/models/todo_list.dart create mode 100644 lib/powersync.dart diff --git a/lib/api_client.dart b/lib/api_client.dart new file mode 100644 index 000000000..d6ff9c40a --- /dev/null +++ b/lib/api_client.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +final log = Logger('powersync-test'); + +class ApiClient { + final String baseUrl; + + ApiClient(this.baseUrl); + + Future> authenticate(String username, String password) async { + final response = await http.post( + Uri.parse('$baseUrl/api/auth/'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'username': username, 'password': password}), + ); + if (response.statusCode == 200) { + return json.decode(response.body); + } else { + throw Exception('Failed to authenticate'); + } + } + + Future> getToken(String userId) async { + final response = await http.get( + Uri.parse('$baseUrl/api/get_powersync_token/'), + headers: {'Content-Type': 'application/json'}, + ); + if (response.statusCode == 200) { + return json.decode(response.body); + } else { + throw Exception('Failed to fetch token'); + } + } + + Future upsert(Map record) async { + await http.put( + Uri.parse('$baseUrl/api/upload_data/'), + headers: {'Content-Type': 'application/json'}, + body: json.encode(record), + ); + } + + Future update(Map record) async { + await http.patch( + Uri.parse('$baseUrl/api/upload_data/'), + headers: {'Content-Type': 'application/json'}, + body: json.encode(record), + ); + } + + Future delete(Map record) async { + await http.delete( + Uri.parse('$baseUrl/api/upload_data/'), + headers: {'Content-Type': 'application/json'}, + body: json.encode(record), + ); + } +} diff --git a/lib/app_config.dart b/lib/app_config.dart new file mode 100644 index 000000000..e751f6945 --- /dev/null +++ b/lib/app_config.dart @@ -0,0 +1,4 @@ +class AppConfig { + static const String djangoUrl = 'http://192.168.2.223:6061'; + static const String powersyncUrl = 'http://192.168.2.223:8080'; +} diff --git a/lib/main.dart b/lib/main.dart index ac4abfe1a..ba514036f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,10 +16,12 @@ * along with this program. If not, see . */ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; import 'package:wger/core/locator.dart'; +import 'package:wger/powersync.dart'; import 'package:wger/providers/add_exercise.dart'; import 'package:wger/providers/base_provider.dart'; import 'package:wger/providers/body_weight.dart'; @@ -52,15 +54,34 @@ import 'package:wger/screens/workout_plans_screen.dart'; import 'package:wger/theme/theme.dart'; import 'package:wger/widgets/core/about.dart'; import 'package:wger/widgets/core/settings.dart'; +import 'package:logging/logging.dart'; import 'providers/auth.dart'; void main() async { //zx.setLogEnabled(kDebugMode); + Logger.root.level = Level.INFO; + Logger.root.onRecord.listen((record) { + if (kDebugMode) { + print('[${record.loggerName}] ${record.level.name}: ${record.time}: ${record.message}'); + + if (record.error != null) { + print(record.error); + } + if (record.stackTrace != null) { + print(record.stackTrace); + } + } + }); // Needs to be called before runApp WidgetsFlutterBinding.ensureInitialized(); + await openDatabase(); + + final loggedIn = await isLoggedIn(); + print('is logged in $loggedIn'); + // Locator to initialize exerciseDB await ServiceLocator().configure(); // Application diff --git a/lib/models/schema.dart b/lib/models/schema.dart new file mode 100644 index 000000000..b85c5f103 --- /dev/null +++ b/lib/models/schema.dart @@ -0,0 +1,54 @@ +import 'package:powersync/powersync.dart'; + +const todosTable = 'todos'; + +// these are the same ones as in postgres, except for 'id' +Schema schema = const Schema(([ + Table(todosTable, [ + Column.text('list_id'), + Column.text('created_at'), + Column.text('completed_at'), + Column.text('description'), + Column.integer('completed'), + Column.text('created_by'), + Column.text('completed_by'), + ], indexes: [ + // Index to allow efficient lookup within a list + Index('list', [IndexedColumn('list_id')]) + ]), + Table('lists', + [Column.text('created_at'), Column.text('name'), Column.text('owner_id')]) +])); + +// post gres columns: +// todos: +// id | created_at | completed_at | description | completed | created_by | completed_by | list_id +// lists: +// id | created_at | name | owner_id + +// diagnostics app: +/* +new Schema([ + new Table({ + name: 'lists', // same as flutter + columns: [ + new Column({ name: 'created_at', type: ColumnType.TEXT }), + new Column({ name: 'name', type: ColumnType.TEXT }), + new Column({ name: 'owner_id', type: ColumnType.TEXT }) + ] + }), + new Table({ + name: 'todos', // misses completed_at and completed_by, until these actually get populated with something + columns: [ + new Column({ name: 'created_at', type: ColumnType.TEXT }), + new Column({ name: 'description', type: ColumnType.TEXT }), + new Column({ name: 'completed', type: ColumnType.INTEGER }), + new Column({ name: 'created_by', type: ColumnType.TEXT }), + new Column({ name: 'list_id', type: ColumnType.TEXT }) + ] + }) +]) + + Column.text('completed_at'), + Column.text('completed_by'), +*/ diff --git a/lib/models/todo_item.dart b/lib/models/todo_item.dart new file mode 100644 index 000000000..8bd10864d --- /dev/null +++ b/lib/models/todo_item.dart @@ -0,0 +1,50 @@ +import 'package:wger/models/schema.dart'; + +import '../powersync.dart'; +import 'package:powersync/sqlite3.dart' as sqlite; + +/// TodoItem represents a result row of a query on "todos". +/// +/// This class is immutable - methods on this class do not modify the instance +/// directly. Instead, watch or re-query the data to get the updated item. +/// confirm how the watch works. this seems like a weird pattern +class TodoItem { + final String id; + final String description; + final String? photoId; + final bool completed; + + TodoItem( + {required this.id, + required this.description, + required this.completed, + required this.photoId}); + + factory TodoItem.fromRow(sqlite.Row row) { + return TodoItem( + id: row['id'], + description: row['description'], + photoId: row['photo_id'], + completed: row['completed'] == 1); + } + + Future toggle() async { + if (completed) { + await db.execute( + 'UPDATE $todosTable SET completed = FALSE, completed_by = NULL, completed_at = NULL WHERE id = ?', + [id]); + } else { + await db.execute( + 'UPDATE $todosTable SET completed = TRUE, completed_by = ?, completed_at = datetime() WHERE id = ?', + [await getUserId(), id]); + } + } + + Future delete() async { + await db.execute('DELETE FROM $todosTable WHERE id = ?', [id]); + } + + static Future addPhoto(String photoId, String id) async { + await db.execute('UPDATE $todosTable SET photo_id = ? WHERE id = ?', [photoId, id]); + } +} diff --git a/lib/models/todo_list.dart b/lib/models/todo_list.dart new file mode 100644 index 000000000..17e848b2c --- /dev/null +++ b/lib/models/todo_list.dart @@ -0,0 +1,96 @@ +import 'package:powersync/sqlite3.dart' as sqlite; + +import 'todo_item.dart'; +import '../powersync.dart'; + +/// TodoList represents a result row of a query on "lists". +/// +/// This class is immutable - methods on this class do not modify the instance +/// directly. Instead, watch or re-query the data to get the updated list. +class TodoList { + /// List id (UUID). + final String id; + + /// Descriptive name. + final String name; + + /// Number of completed todos in this list. + final int? completedCount; + + /// Number of pending todos in this list. + final int? pendingCount; + + TodoList({required this.id, required this.name, this.completedCount, this.pendingCount}); + + factory TodoList.fromRow(sqlite.Row row) { + return TodoList( + id: row['id'], + name: row['name'], + completedCount: row['completed_count'], + pendingCount: row['pending_count']); + } + + /// Watch all lists. + static Stream> watchLists() { + // This query is automatically re-run when data in "lists" or "todos" is modified. + return db.watch('SELECT * FROM lists ORDER BY created_at, id').map((results) { + return results.map(TodoList.fromRow).toList(growable: false); + }); + } + + /// Watch all lists, with [completedCount] and [pendingCount] populated. + static Stream> watchListsWithStats() { + // This query is automatically re-run when data in "lists" or "todos" is modified. + return db.watch(''' + SELECT + *, + (SELECT count() FROM todos WHERE list_id = lists.id AND completed = TRUE) as completed_count, + (SELECT count() FROM todos WHERE list_id = lists.id AND completed = FALSE) as pending_count + FROM lists + ORDER BY created_at + ''').map((results) { + return results.map(TodoList.fromRow).toList(growable: false); + }); + } + + /// Create a new list + static Future create(String name) async { + final results = await db.execute(''' + INSERT INTO + lists(id, created_at, name, owner_id) + VALUES(uuid(), datetime(), ?, ?) + RETURNING * + ''', [name, await getUserId()]); + return TodoList.fromRow(results.first); + } + + /// Watch items within this list. + Stream> watchItems() { + return db.watch('SELECT * FROM todos WHERE list_id = ? ORDER BY created_at DESC, id', + parameters: [id]).map((event) { + return event.map(TodoItem.fromRow).toList(growable: false); + }); + } + + /// Delete this list. + Future delete() async { + await db.execute('DELETE FROM lists WHERE id = ?', [id]); + } + + /// Find list item. + static Future find(id) async { + final results = await db.get('SELECT * FROM lists WHERE id = ?', [id]); + return TodoList.fromRow(results); + } + + /// Add a new todo item to this list. + Future add(String description) async { + final results = await db.execute(''' + INSERT INTO + todos(id, created_at, completed, list_id, description, created_by) + VALUES(uuid(), datetime(), FALSE, ?, ?, ?) + RETURNING * + ''', [id, description, await getUserId()]); + return TodoItem.fromRow(results.first); + } +} diff --git a/lib/powersync.dart b/lib/powersync.dart new file mode 100644 index 000000000..0eaa6c7b8 --- /dev/null +++ b/lib/powersync.dart @@ -0,0 +1,132 @@ +// This file performs setup of the PowerSync database +import 'package:logging/logging.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:powersync/powersync.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wger/api_client.dart'; + +import './app_config.dart'; +import './models/schema.dart'; + +final log = Logger('powersync-django'); + +/// Postgres Response codes that we cannot recover from by retrying. +final List fatalResponseCodes = [ + // Class 22 — Data Exception + // Examples include data type mismatch. + RegExp(r'^22...$'), + // Class 23 — Integrity Constraint Violation. + // Examples include NOT NULL, FOREIGN KEY and UNIQUE violations. + RegExp(r'^23...$'), + // INSUFFICIENT PRIVILEGE - typically a row-level security violation + RegExp(r'^42501$'), +]; + +class DjangoConnector extends PowerSyncBackendConnector { + PowerSyncDatabase db; + + DjangoConnector(this.db); + + final ApiClient apiClient = ApiClient(AppConfig.djangoUrl); + + /// Get a token to authenticate against the PowerSync instance. + @override + Future fetchCredentials() async { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('id'); + if (userId == null) { + throw Exception('User does not have session'); + } + // Somewhat contrived to illustrate usage, see auth docs here: + // https://docs.powersync.com/usage/installation/authentication-setup/custom + final session = await apiClient.getToken(userId); + return PowerSyncCredentials(endpoint: AppConfig.powersyncUrl, token: session['token']); + } + + // Upload pending changes to Postgres via Django backend + // this is generic. on the django side we inspect the request and do model-specific operations + // would it make sense to do api calls here specific to the relevant model? (e.g. put to a todo-specific endpoint) + @override + Future uploadData(PowerSyncDatabase database) async { + final transaction = await database.getNextCrudTransaction(); + + if (transaction == null) { + return; + } + + try { + for (var op in transaction.crud) { + final record = { + 'table': op.table, + 'data': {'id': op.id, ...?op.opData}, + }; + + switch (op.op) { + case UpdateType.put: + await apiClient.upsert(record); + break; + case UpdateType.patch: + await apiClient.update(record); + break; + case UpdateType.delete: + await apiClient.delete(record); + break; + } + } + await transaction.complete(); + } on Exception catch (e) { + log.severe('Error uploading data', e); + // Error may be retryable - e.g. network error or temporary server error. + // Throwing an error here causes this call to be retried after a delay. + rethrow; + } + } +} + +/// Global reference to the database +late final PowerSyncDatabase db; + +// Hacky flag to ensure the database is only initialized once, better to do this with listeners +bool _dbInitialized = false; + +Future isLoggedIn() async { + final prefs = await SharedPreferences.getInstance(); // Initialize SharedPreferences + final userId = prefs.getString('id'); + return userId != null; +} + +Future getDatabasePath() async { + final dir = await getApplicationSupportDirectory(); + return join(dir.path, 'powersync-demo.db'); +} + +// opens the database and connects if logged in +Future openDatabase() async { + // Open the local database + if (!_dbInitialized) { + db = PowerSyncDatabase(schema: schema, path: await getDatabasePath(), logger: attachedLogger); + await db.initialize(); + _dbInitialized = true; + } + + DjangoConnector? currentConnector; + + if (await isLoggedIn()) { + // If the user is already logged in, connect immediately. + // Otherwise, connect once logged in. + currentConnector = DjangoConnector(db); + db.connect(connector: currentConnector); + } +} + +/// Explicit sign out - clear database and log out. +Future logout() async { + await db.disconnectAndClear(); +} + +/// id of the user currently logged in +Future getUserId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('id'); +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 9f99dda79..e1098f6ba 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) powersync_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PowersyncFlutterLibsPlugin"); + powersync_flutter_libs_plugin_register_with_registrar(powersync_flutter_libs_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 74369f251..296e5b2f2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + powersync_flutter_libs sqlite3_flutter_libs url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e5d0d3081..20619c357 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import file_selector_macos import package_info_plus import path_provider_foundation +import powersync_flutter_libs import rive_common import shared_preferences_foundation import sqlite3_flutter_libs @@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + PowersyncFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "PowersyncFlutterLibsPlugin")) RivePlugin.register(with: registry.registrar(forPlugin: "RivePlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 7f1ca41d8..cf227c53d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -329,6 +329,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + fetch_api: + dependency: transitive + description: + name: fetch_api + sha256: "97f46c25b480aad74f7cc2ad7ccba2c5c6f08d008e68f95c1077286ce243d0e6" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + fetch_client: + dependency: transitive + description: + name: fetch_client + sha256: "9666ee14536778474072245ed5cba07db81ae8eb5de3b7bf4a2d1e2c49696092" + url: "https://pub.dev" + source: hosted + version: "1.1.2" ffi: dependency: transitive description: @@ -821,7 +837,7 @@ packages: source: hosted version: "1.0.2" logging: - dependency: transitive + dependency: "direct main" description: name: logging sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" @@ -892,6 +908,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.3" + mutex: + dependency: transitive + description: + name: mutex + sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2" + url: "https://pub.dev" + source: hosted + version: "3.1.0" nested: dependency: transitive description: @@ -1060,6 +1084,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + powersync: + dependency: "direct main" + description: + name: powersync + sha256: c6975007493617fdfc5945c3fab24ea2e6999ae300dd4d19d739713a4f2bcd96 + url: "https://pub.dev" + source: hosted + version: "1.6.3" + powersync_flutter_libs: + dependency: transitive + description: + name: powersync_flutter_libs + sha256: "449063aa4956c6be215ea7dfb9cc61255188e82cf7bc3f75621796fcc6615b70" + url: "https://pub.dev" + source: hosted + version: "0.1.0" process: dependency: transitive description: @@ -1233,6 +1273,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqlite3: dependency: transitive description: @@ -1249,6 +1297,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.24" + sqlite3_web: + dependency: transitive + description: + name: sqlite3_web + sha256: "51fec34757577841cc72d79086067e3651c434669d5af557a5c106787198a76f" + url: "https://pub.dev" + source: hosted + version: "0.1.2-wip" + sqlite_async: + dependency: "direct main" + description: + name: sqlite_async + sha256: "79e636c857ed43f6cd5e5be72b36967a29f785daa63ff5b078bd34f74f44cb54" + url: "https://pub.dev" + source: hosted + version: "0.8.1" sqlparser: dependency: transitive description: @@ -1337,6 +1401,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" url_launcher: dependency: "direct main" description: @@ -1401,6 +1473,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + uuid: + dependency: transitive + description: + name: uuid + sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" + url: "https://pub.dev" + source: hosted + version: "4.4.2" vector_graphics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3e27b5f71..1ec936fb8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: sdk: flutter android_metadata: ^0.2.1 + powersync: ^1.5.5 collection: ^1.17.0 cupertino_icons: ^1.0.8 equatable: ^2.0.5 @@ -70,6 +71,8 @@ dependencies: freezed_annotation: ^2.4.1 clock: ^1.1.1 flutter_svg_icons: ^0.0.1 + sqlite_async: ^0.8.1 + logging: ^1.2.0 dependency_overrides: intl: ^0.19.0