powersync prototype (WIP)

This commit is contained in:
Dieter Plaetinck
2024-08-02 23:33:49 +03:00
parent 044cf6a0aa
commit fe48597b16
12 changed files with 508 additions and 1 deletions

60
lib/api_client.dart Normal file
View File

@@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<void> upsert(Map<String, dynamic> record) async {
await http.put(
Uri.parse('$baseUrl/api/upload_data/'),
headers: {'Content-Type': 'application/json'},
body: json.encode(record),
);
}
Future<void> update(Map<String, dynamic> record) async {
await http.patch(
Uri.parse('$baseUrl/api/upload_data/'),
headers: {'Content-Type': 'application/json'},
body: json.encode(record),
);
}
Future<void> delete(Map<String, dynamic> record) async {
await http.delete(
Uri.parse('$baseUrl/api/upload_data/'),
headers: {'Content-Type': 'application/json'},
body: json.encode(record),
);
}
}

4
lib/app_config.dart Normal file
View File

@@ -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';
}

View File

@@ -16,10 +16,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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

54
lib/models/schema.dart Normal file
View File

@@ -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'),
*/

50
lib/models/todo_item.dart Normal file
View File

@@ -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<void> 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<void> delete() async {
await db.execute('DELETE FROM $todosTable WHERE id = ?', [id]);
}
static Future<void> addPhoto(String photoId, String id) async {
await db.execute('UPDATE $todosTable SET photo_id = ? WHERE id = ?', [photoId, id]);
}
}

96
lib/models/todo_list.dart Normal file
View File

@@ -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<List<TodoList>> 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<List<TodoList>> 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<TodoList> 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<List<TodoItem>> 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<void> delete() async {
await db.execute('DELETE FROM lists WHERE id = ?', [id]);
}
/// Find list item.
static Future<TodoList> 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<TodoItem> 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);
}
}

132
lib/powersync.dart Normal file
View File

@@ -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<RegExp> 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<PowerSyncCredentials?> 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<void> 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<bool> isLoggedIn() async {
final prefs = await SharedPreferences.getInstance(); // Initialize SharedPreferences
final userId = prefs.getString('id');
return userId != null;
}
Future<String> getDatabasePath() async {
final dir = await getApplicationSupportDirectory();
return join(dir.path, 'powersync-demo.db');
}
// opens the database and connects if logged in
Future<void> 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<void> logout() async {
await db.disconnectAndClear();
}
/// id of the user currently logged in
Future<String?> getUserId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('id');
}

View File

@@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <powersync_flutter_libs/powersync_flutter_libs_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
@@ -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);

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
powersync_flutter_libs
sqlite3_flutter_libs
url_launcher_linux
)

View File

@@ -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"))

View File

@@ -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:

View File

@@ -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