From 47cd38ea367f6586decbdeed9ea67412c02fab09 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 17 Apr 2026 20:34:28 +0200 Subject: [PATCH 1/3] Add devtools extension --- .github/workflows/publish.yml | 23 +- demos/supabase-todolist/macos/Podfile.lock | 58 +-- devtools_options.yaml | 4 + packages/powersync/CHANGELOG.md | 4 + .../powersync/extension/devtools/config.yaml | 6 + .../lib/src/database/powersync_database.dart | 11 +- .../powersync/lib/src/devtools/devtools.dart | 62 +++ .../expose_credentials_connector.dart | 29 ++ .../powersync/lib/src/devtools/extension.dart | 133 +++++++ .../powersync/lib/src/devtools/protocol.dart | 94 +++++ .../powersync/lib/src/sync/instruction.dart | 14 + .../powersync_devtools_extension/.gitignore | 45 +++ .../powersync_devtools_extension/.metadata | 30 ++ .../powersync_devtools_extension/README.md | 34 ++ .../analysis_options.yaml | 1 + .../lib/main.dart | 46 +++ .../lib/state/databases.dart | 135 +++++++ .../lib/state/remote_database.dart | 255 ++++++++++++ .../lib/state/service.dart | 54 +++ .../lib/state/sync_status.dart | 77 ++++ .../lib/ui/appbar.dart | 57 +++ .../lib/ui/common.dart | 28 ++ .../lib/ui/overview.dart | 369 ++++++++++++++++++ .../lib/ui/sql.dart | 227 +++++++++++ .../powersync_devtools_extension/pubspec.yaml | 33 ++ .../tool/build.sh | 3 + .../web/icons/dark.svg | 1 + .../web/icons/light.svg | 1 + .../web/index.html | 46 +++ pubspec.lock | 126 +++++- pubspec.yaml | 1 + 31 files changed, 1945 insertions(+), 62 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 packages/powersync/extension/devtools/config.yaml create mode 100644 packages/powersync/lib/src/devtools/devtools.dart create mode 100644 packages/powersync/lib/src/devtools/expose_credentials_connector.dart create mode 100644 packages/powersync/lib/src/devtools/extension.dart create mode 100644 packages/powersync/lib/src/devtools/protocol.dart create mode 100644 packages/powersync_devtools_extension/.gitignore create mode 100644 packages/powersync_devtools_extension/.metadata create mode 100644 packages/powersync_devtools_extension/README.md create mode 100644 packages/powersync_devtools_extension/analysis_options.yaml create mode 100644 packages/powersync_devtools_extension/lib/main.dart create mode 100644 packages/powersync_devtools_extension/lib/state/databases.dart create mode 100644 packages/powersync_devtools_extension/lib/state/remote_database.dart create mode 100644 packages/powersync_devtools_extension/lib/state/service.dart create mode 100644 packages/powersync_devtools_extension/lib/state/sync_status.dart create mode 100644 packages/powersync_devtools_extension/lib/ui/appbar.dart create mode 100644 packages/powersync_devtools_extension/lib/ui/common.dart create mode 100644 packages/powersync_devtools_extension/lib/ui/overview.dart create mode 100644 packages/powersync_devtools_extension/lib/ui/sql.dart create mode 100644 packages/powersync_devtools_extension/pubspec.yaml create mode 100755 packages/powersync_devtools_extension/tool/build.sh create mode 100644 packages/powersync_devtools_extension/web/icons/dark.svg create mode 100644 packages/powersync_devtools_extension/web/icons/light.svg create mode 100644 packages/powersync_devtools_extension/web/index.html diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 791f5ae9..6687d3ba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,12 +36,29 @@ jobs: gh release upload "${{ github.ref_name }}" packages/powersync/assets/powersync_db.worker.js publish_powersync: + needs: [setup] permissions: id-token: write if: "${{ startsWith(github.ref_name, 'powersync-') }}" - uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 - with: - working-directory: packages/powersync + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + # Install Dart to install an OIDC token for publishing + - uses: dart-lang/setup-dart@v1 + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.x" + channel: "stable" + - run: dart pub get + - run: tool/build-sh + name: Build and copy devtools extension + working-directory: packages/powersync_devtools_extension + + - name: Publish to pub.dev + run: dart pub lish -f + working-directory: packages/powersync publish_powersync_flutter_libs: permissions: diff --git a/demos/supabase-todolist/macos/Podfile.lock b/demos/supabase-todolist/macos/Podfile.lock index 61c1fd42..4aecc941 100644 --- a/demos/supabase-todolist/macos/Podfile.lock +++ b/demos/supabase-todolist/macos/Podfile.lock @@ -2,85 +2,33 @@ PODS: - app_links (6.4.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - powersync-sqlite-core (0.4.10) - - powersync_flutter_libs (0.0.1): - - Flutter - - FlutterMacOS - - powersync-sqlite-core (~> 0.4.10) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (3.50.4): - - sqlite3/common (= 3.50.4) - - sqlite3/common (3.50.4) - - sqlite3/dbstatvtab (3.50.4): - - sqlite3/common - - sqlite3/fts5 (3.50.4): - - sqlite3/common - - sqlite3/math (3.50.4): - - sqlite3/common - - sqlite3/perf-threadsafe (3.50.4): - - sqlite3/common - - sqlite3/rtree (3.50.4): - - sqlite3/common - - sqlite3/session (3.50.4): - - sqlite3/common - - sqlite3_flutter_libs (0.0.1): - - Flutter - - FlutterMacOS - - sqlite3 (~> 3.50.4) - - sqlite3/dbstatvtab - - sqlite3/fts5 - - sqlite3/math - - sqlite3/perf-threadsafe - - sqlite3/rtree - - sqlite3/session - url_launcher_macos (0.0.1): - FlutterMacOS DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - - powersync_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/powersync_flutter_libs/darwin`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) -SPEC REPOS: - trunk: - - powersync-sqlite-core - - sqlite3 - EXTERNAL SOURCES: app_links: :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos FlutterMacOS: :path: Flutter/ephemeral - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin - powersync_flutter_libs: - :path: Flutter/ephemeral/.symlinks/plugins/powersync_flutter_libs/darwin shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin - sqlite3_flutter_libs: - :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: - app_links: c3185399a5cabc2e610ee5ad52fb7269b84ff869 + app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - powersync-sqlite-core: b30017e077c91915d53faebc5f7245384df78275 - powersync_flutter_libs: 06a54b2eb2afc6f6fc675859bf1020d9cd6c0e4d - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 - sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b - sqlite3_flutter_libs: 86f82662868ee26ff3451f73cac9c5fc2a1f57fa - url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..d5274ae5 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,4 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - powersync: true \ No newline at end of file diff --git a/packages/powersync/CHANGELOG.md b/packages/powersync/CHANGELOG.md index d3e9b34f..cbf7e39c 100644 --- a/packages/powersync/CHANGELOG.md +++ b/packages/powersync/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.0 (unreleased) + +- Add a DevTools extension to inspect running PowerSync databases in your app. + ## 2.0.1 - Fix documentation not generating. diff --git a/packages/powersync/extension/devtools/config.yaml b/packages/powersync/extension/devtools/config.yaml new file mode 100644 index 00000000..af5f561a --- /dev/null +++ b/packages/powersync/extension/devtools/config.yaml @@ -0,0 +1,6 @@ +name: powersync +issueTracker: https://github.com/powersync-ja/powersync.dart/issues +version: 0.0.1 +# We can only use Material Icons here, and they're all mediocre. This is the Material Icon "Sync". +materialIconCodePoint: '0xe627' +requiresConnection: true diff --git a/packages/powersync/lib/src/database/powersync_database.dart b/packages/powersync/lib/src/database/powersync_database.dart index 20223d85..0717d99b 100644 --- a/packages/powersync/lib/src/database/powersync_database.dart +++ b/packages/powersync/lib/src/database/powersync_database.dart @@ -13,6 +13,8 @@ import 'package:sqlite_async/sqlite_async.dart'; import '../abort_controller.dart'; import '../connector.dart'; import '../crud.dart'; +import '../devtools/devtools.dart' as devtools; +import '../devtools/expose_credentials_connector.dart'; import '../log.dart'; import '../platform_specific/platform_specific.dart'; import '../powersync_update_notification.dart'; @@ -102,7 +104,9 @@ abstract base class PowerSyncDatabase extends SqliteConnection { @protected Future get isInitialized; - PowerSyncDatabase._(); + PowerSyncDatabase._() { + devtools.handleCreated(this); + } /// Open a [PowerSyncDatabase]. /// @@ -265,6 +269,8 @@ abstract base class PowerSyncDatabase extends SqliteConnection { await disconnect(); if (!database.closed) { + devtools.handleClosed(this); + // Now we can close the database await database.close(); @@ -304,6 +310,9 @@ abstract base class PowerSyncDatabase extends SqliteConnection { params: params, ); + if (devtools.enable) { + connector = ExposeCredentialsConnector(connector, this); + } await _connections.connect(connector: connector, options: resolvedOptions); } diff --git a/packages/powersync/lib/src/devtools/devtools.dart b/packages/powersync/lib/src/devtools/devtools.dart new file mode 100644 index 00000000..453456db --- /dev/null +++ b/packages/powersync/lib/src/devtools/devtools.dart @@ -0,0 +1,62 @@ +@internal +library; + +import 'dart:developer' as dev; +import 'package:meta/meta.dart'; + +import '../connector.dart'; +import '../database/powersync_database.dart'; +import 'extension.dart'; + +// We want to avoid including this code for release-mode builds, since it's only +// relevant for development tooling. +const _releaseMode = bool.fromEnvironment('dart.vm.product'); +const enable = !_releaseMode; + +void postEvent(String type, Map data) { + dev.postEvent('powersync:$type', data); +} + +/// A PowerSync database made accessible to DevTools over a `dart:developer` IPC +/// protocol. +final class ExposedPowerSyncDatabase { + final PowerSyncDatabase database; + final int id; + + PowerSyncCredentials? lastCredentials; + + ExposedPowerSyncDatabase(this.database) : id = _nextId++ { + byDatabase[database] = this; + byId[id] = this; + } + + static int _nextId = 0; + + static Map byId = {}; + + /// Weak map from PowerSync databases to their [ExposedPowerSyncDatabase] + /// instance. + static final Expando byDatabase = Expando(); + + static void postChangeEvent() { + postEvent('databases-changed', {}); + } +} + +void handleCreated(PowerSyncDatabase database) { + if (enable) { + ExposedPowerSyncDatabase(database); + PowerSyncDevToolsExtension.registerIfNeeded(); + ExposedPowerSyncDatabase.postChangeEvent(); + } +} + +void handleClosed(PowerSyncDatabase database) { + if (enable) { + if (ExposedPowerSyncDatabase.byDatabase[database] case final tracked?) { + ExposedPowerSyncDatabase.byId.remove(tracked.id); + } + + ExposedPowerSyncDatabase.postChangeEvent(); + } +} diff --git a/packages/powersync/lib/src/devtools/expose_credentials_connector.dart b/packages/powersync/lib/src/devtools/expose_credentials_connector.dart new file mode 100644 index 00000000..5c85e54f --- /dev/null +++ b/packages/powersync/lib/src/devtools/expose_credentials_connector.dart @@ -0,0 +1,29 @@ +import 'package:powersync/src/database/powersync_database.dart'; + +import '../connector.dart'; +import 'devtools.dart'; + +/// A PowerSync backend connector that logs credentials over the VM service +/// protocol, allowing our DevTools extension to display the current token. +final class ExposeCredentialsConnector extends PowerSyncBackendConnector { + final PowerSyncDatabase database; + final PowerSyncBackendConnector inner; + + ExposeCredentialsConnector(this.inner, this.database); + + @override + Future fetchCredentials() async { + final credentials = await inner.fetchCredentials(); + if (ExposedPowerSyncDatabase.byDatabase[database] case final exposed?) { + exposed.lastCredentials = credentials; + ExposedPowerSyncDatabase.postChangeEvent(); + } + + return credentials; + } + + @override + Future uploadData(PowerSyncDatabase database) { + return inner.uploadData(database); + } +} diff --git a/packages/powersync/lib/src/devtools/extension.dart b/packages/powersync/lib/src/devtools/extension.dart new file mode 100644 index 00000000..8386082a --- /dev/null +++ b/packages/powersync/lib/src/devtools/extension.dart @@ -0,0 +1,133 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:path/path.dart' as p; + +import '../version.dart'; +import 'devtools.dart'; +import 'protocol.dart'; + +final class PowerSyncDevToolsExtension { + final Map> _clientSubscriptions = {}; + int _subscriptionId = 0; + + Future _handle(Map parameters) async { + final command = parameters['command']; + final databaseId = int.parse(parameters['db']!); + final tracked = ExposedPowerSyncDatabase.byId[databaseId]; + if (tracked == null) { + throw ArgumentError('Unknown database handle: $databaseId'); + } + + switch (command) { + case 'execute': + final sql = parameters['sql']!; + final sqlParameters = (json.decode(parameters['params']!) as List) + .map(decodeSqlValue) + .toList(); + + await tracked.database.execute(sql, sqlParameters); + return null; + case 'select': + final sql = parameters['sql']!; + final sqlParameters = (json.decode(parameters['params']!) as List) + .map(decodeSqlValue) + .toList(); + + final rs = await tracked.database + .writeLock((ctx) => ctx.getAll(sql, sqlParameters)); + return { + 'columnNames': rs.columnNames, + 'rows': [ + for (final row in rs) + [for (final column in row.values) encodeSqlValue(column)], + ], + }; + case 'schema': + return tracked.database.schema.toJson(); + case 'table-updates-listen': + final stream = tracked.database + .onChange(null, throttle: const Duration(milliseconds: 100)); + final id = _subscriptionId++; + _clientSubscriptions[id] = stream.listen((updateNotification) { + postEvent('table-updates', { + 'subscription': id, + 'tables': updateNotification.tables.toList(), + }); + }); + return id; + case 'status-listen': + final stream = tracked.database.statusStream; + final id = _subscriptionId++; + + _clientSubscriptions[id] = stream.listen((status) { + postEvent('status-updates', { + 'subscription': id, + 'status': serializeSyncStatus(status), + }); + }); + + return { + 'id': id, + 'current': serializeSyncStatus(tracked.database.currentStatus), + }; + case 'unsubscribe': + _clientSubscriptions.remove(int.parse(parameters['id']!))?.cancel(); + return null; + default: + throw UnsupportedError('Unsupported command: $command'); + } + } + + static bool _registered = false; + + /// Registers the `ext.powersync` extension if it has not yet been registered + /// on this isolate. + static void registerIfNeeded() { + if (!_registered) { + _registered = true; + + final extension = PowerSyncDevToolsExtension(); + registerExtension('ext.powersync.database', (method, parameters) async { + try { + final result = await extension._handle(parameters); + return ServiceExtensionResponse.result(json.encode({'ok': result})); + } catch (error, stackTrace) { + return ServiceExtensionResponse.error( + ServiceExtensionResponse.extensionErrorMin, + json.encode({ + 'error': error.toString(), + 'trace': stackTrace.toString(), + }), + ); + } + }); + + registerExtension('ext.powersync.version', (method, parameters) async { + return ServiceExtensionResponse.result( + json.encode({'version': libraryVersion})); + }); + + registerExtension('ext.powersync.list', (method, parameters) async { + return ServiceExtensionResponse.result(json.encode({ + 'databases': [ + for (final db in ExposedPowerSyncDatabase.byId.values) + { + 'id': db.id, + 'path': db.database.group.identifier, + 'name': p.basename(db.database.group.identifier), + 'lastCredentials': switch (db.lastCredentials) { + null => null, + final credentials => { + 'endpoint': credentials.endpoint, + 'token': credentials.token, + }, + } + } + ] + })); + }); + } + } +} diff --git a/packages/powersync/lib/src/devtools/protocol.dart b/packages/powersync/lib/src/devtools/protocol.dart new file mode 100644 index 00000000..f49dc356 --- /dev/null +++ b/packages/powersync/lib/src/devtools/protocol.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../sync/instruction.dart'; +import '../sync/stream.dart'; +import '../sync/sync_status.dart'; + +/// Encodes a Dart value that can appear as a SQL parameter or result to be +/// JSON serializable. +Object? encodeSqlValue(Object? value) { + return switch (value) { + final Uint8List binary => {'binary': base64.encode(binary)}, + _ => value, + }; +} + +Object? decodeSqlValue(Object? value) { + return switch (value) { + {'binary': final String binary} => base64.decode(binary), + _ => value, + }; +} + +Object? serializeSyncStatus(SyncStatus status) { + return { + 'connected': status.connected, + 'connecting': status.connecting, + 'downloading': status.downloading, + 'downloadProgress': switch (status.downloadProgress) { + null => null, + final progress => DownloadProgress( + InternalSyncDownloadProgress.ofPublic(progress).buckets) + .toJson() + }, + 'uploading': status.uploading, + 'lastSyncedAt': status.lastSyncedAt?.millisecondsSinceEpoch, + 'hasSynced': status.hasSynced, + 'uploadError': status.uploadError?.toString(), + 'downloadError': status.downloadError?.toString(), + 'priorityStatusEntries': [ + for (final entry in status.priorityStatusEntries) + { + 'priority': entry.priority.priorityNumber, + 'lastSyncedAt': entry.lastSyncedAt?.millisecondsSinceEpoch, + 'hasSynced': entry.hasSynced, + } + ], + 'internalSubscriptions': + status.internalSubscriptions?.map((s) => s.toJson()).toList(), + }; +} + +SyncStatus deserializeSyncStatus(Map serialized) { + DateTime? readDateTime(Object? timestamp) { + return timestamp == null + ? null + : DateTime.fromMillisecondsSinceEpoch(timestamp as int); + } + + return SyncStatus( + connected: serialized['connected'] as bool, + connecting: serialized['connecting'] as bool, + downloading: serialized['downloading'] as bool, + downloadProgress: switch (serialized['downloadProgress']) { + null => null, + final downloadProgress => InternalSyncDownloadProgress( + DownloadProgress.fromJson( + downloadProgress as Map) + .buckets) + .asSyncDownloadProgress + }, + uploading: serialized['uploading'] as bool, + lastSyncedAt: readDateTime(serialized['lastSyncedAt'] as int?), + hasSynced: serialized['hasSynced'] as bool?, + uploadError: serialized['uploadError'], + downloadError: serialized['downloadError'], + priorityStatusEntries: [ + for (final entry in (serialized['priorityStatusEntries'] as List) + .cast>()) + ( + priority: StreamPriority(entry['priority'] as int), + lastSyncedAt: readDateTime(entry['lastSyncedAt']), + hasSynced: entry['hasSynced'] as bool? + ) + ], + streamSubscriptions: switch (serialized['internalSubscriptions']) { + final List entries => entries + .map((e) => + CoreActiveStreamSubscription.fromJson(e as Map)) + .toList(), + _ => null, + }, + ); +} diff --git a/packages/powersync/lib/src/sync/instruction.dart b/packages/powersync/lib/src/sync/instruction.dart index 3479e281..f6b10c1a 100644 --- a/packages/powersync/lib/src/sync/instruction.dart +++ b/packages/powersync/lib/src/sync/instruction.dart @@ -122,6 +122,20 @@ final class DownloadProgress { })); } + Map toJson() { + return { + 'buckets': { + for (final MapEntry(:key, :value) in buckets.entries) + key: { + 'priority': value.priority.priorityNumber, + 'at_last': value.atLast, + 'since_last': value.sinceLast, + 'target_count': value.targetCount + }, + } + }; + } + static BucketProgress _bucketProgressFromJson(Map json) { return ( priority: StreamPriority(json['priority'] as int), diff --git a/packages/powersync_devtools_extension/.gitignore b/packages/powersync_devtools_extension/.gitignore new file mode 100644 index 00000000..3820a95c --- /dev/null +++ b/packages/powersync_devtools_extension/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/powersync_devtools_extension/.metadata b/packages/powersync_devtools_extension/.metadata new file mode 100644 index 00000000..91142856 --- /dev/null +++ b/packages/powersync_devtools_extension/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: web + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/powersync_devtools_extension/README.md b/packages/powersync_devtools_extension/README.md new file mode 100644 index 00000000..9e19ff33 --- /dev/null +++ b/packages/powersync_devtools_extension/README.md @@ -0,0 +1,34 @@ +# powersync_devtools_extension + +A [DevTools extension](https://pub.dev/packages/devtools_extensions) for PowerSync. + +## Getting Started + +To work on the extension, you can launch this project in a simulated environment. On the command line, +launch `flutter run -d chrome --dart-define=use_simulated_environment=true`. +If you want to launch from VS Code, this configuration might be convenient: + +```json + { + "name": "DevTools extension", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "cwd": "packages/powersync_devtools_extension", + "args": [ + "--dart-define=use_simulated_environment=true" + ] + } +``` + +Next, start a Dart or Flutter app using a PowerSync database. +When using `dart run`, include the `--observe` flag to start the VM service. For Flutter apps, +the service is started by default. + +As the app starts, look for a message similar to the following: + +``` +A Dart VM Service on macOS is available at: http://127.0.0.1:64161/nGx5zVEtGlk=/ +``` + +Copy that URL and paste it into the extension to inspect databases. diff --git a/packages/powersync_devtools_extension/analysis_options.yaml b/packages/powersync_devtools_extension/analysis_options.yaml new file mode 100644 index 00000000..f9b30346 --- /dev/null +++ b/packages/powersync_devtools_extension/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/packages/powersync_devtools_extension/lib/main.dart b/packages/powersync_devtools_extension/lib/main.dart new file mode 100644 index 00000000..9146fe79 --- /dev/null +++ b/packages/powersync_devtools_extension/lib/main.dart @@ -0,0 +1,46 @@ +import 'package:devtools_extensions/devtools_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:powersync_devtools_extension/ui/appbar.dart'; + +import 'ui/common.dart'; +import 'ui/overview.dart'; +import 'ui/sql.dart'; + +void main() { + runApp(const ProviderScope(child: PowerSyncDevToolsExtension())); +} + +final class PowerSyncDevToolsExtension extends StatelessWidget { + const PowerSyncDevToolsExtension({super.key}); + + @override + Widget build(BuildContext context) { + return DevToolsExtension( + child: DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + leadingWidth: 150, + leading: const PowerSyncLogo(), + actions: const [SelectPowerSyncDatabase()], + title: Text('Database Inspector'), + bottom: TabBar( + tabAlignment: .fill, + tabs: [ + Tab(text: 'Overview'), + Tab(text: 'SQL Console'), + ], + ), + ), + body: TabBarView( + children: [ + HasDatabaseGuard(child: OverviewPage()), + HasDatabaseGuard(child: SqlPage()), + ], + ), + ), + ), + ); + } +} diff --git a/packages/powersync_devtools_extension/lib/state/databases.dart b/packages/powersync_devtools_extension/lib/state/databases.dart new file mode 100644 index 00000000..783c366f --- /dev/null +++ b/packages/powersync_devtools_extension/lib/state/databases.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:powersync/powersync.dart'; +import 'package:vm_service/vm_service.dart'; + +import 'remote_database.dart'; +import 'service.dart'; + +final class DatabaseReference { + final int id; + final String name; + final String path; + final PowerSyncCredentials? lastCredentials; + + final IsolateRef isolate; + + DatabaseReference({ + required this.id, + required this.name, + required this.path, + this.lastCredentials, + required this.isolate, + }); +} + +final _databaseListChanged = StreamProvider.autoDispose((ref) { + return Stream.fromFuture(ref.watch(serviceProvider.future)).asyncExpand( + (serviceProvider) => serviceProvider.onExtensionEvent.where((event) { + return event.extensionKind == 'powersync:databases-changed'; + }), + ); +}); + +final databaseList = FutureProvider.autoDispose>(( + ref, +) async { + final service = await ref.watch(serviceProvider.future); + final isolate = ref.watch(isolateProvider).value; + ref.watch(_databaseListChanged); + + if (isolate == null) { + return const []; + } + + final list = await service.callServiceExtension( + 'ext.powersync.list', + isolateId: isolate.id, + ); + + final databases = list.json!['databases'] as List; + return [ + for (final serialized in databases.cast>()) + DatabaseReference( + id: serialized['id'] as int, + name: serialized['name'] as String, + path: serialized['path'] as String, + lastCredentials: switch (serialized['lastCredentials']) { + null => null, + final credentials as Map => PowerSyncCredentials( + endpoint: credentials['endpoint'] as String, + token: credentials['token'] as String, + ), + }, + isolate: isolate, + ), + ]; +}); + +final selectedDatabase = + StateNotifierProvider.autoDispose< + StateController, + RemoteDatabase? + >((ref) { + final service = ref.watch(serviceProvider); + final controller = StateController(null); + + if (service.value case final service?) { + ref.listen(databaseList, (previous, next) { + final databases = next.asData?.value ?? const []; + + if (databases.isEmpty) { + controller.state = null; + } else if (controller.state == null && + databases.every((e) => e.id != controller.state?.ref.id)) { + controller.state = RemoteDatabase(databases.first, service); + } + }, fireImmediately: true); + } + + return controller; + }); + +final class DecodedCredentials { + final PowerSyncCredentials original; + + final Map? decodedClaims; + final String userId; + + DecodedCredentials._(this.original, this.decodedClaims, this.userId); + + factory DecodedCredentials(PowerSyncCredentials original) { + try { + final [_, payload, _] = original.token.split('.'); + final decodedPayload = + (json.fuse(utf8)).decode(base64.decode(payload)) + as Map; + final userId = decodedPayload['sub'].toString(); + + return DecodedCredentials._(original, decodedPayload, userId); + } on Object { + // Couldn't parse, return bogus user id. + return DecodedCredentials._(original, null, 'unknown'); + } + } +} + +/// The last recorded credentials for [selectedDatabase]. +final lastCredentials = Provider((ref) { + final allDatabases = ref.watch(databaseList); + final selected = ref.watch(selectedDatabase); + if (selected == null) return null; + + for (final db in allDatabases.value ?? const []) { + if (db.id == selected.ref.id) { + return switch (db.lastCredentials) { + null => null, + final credentials => DecodedCredentials(credentials), + }; + } + } + + return null; +}); diff --git a/packages/powersync_devtools_extension/lib/state/remote_database.dart b/packages/powersync_devtools_extension/lib/state/remote_database.dart new file mode 100644 index 00000000..5cde4866 --- /dev/null +++ b/packages/powersync_devtools_extension/lib/state/remote_database.dart @@ -0,0 +1,255 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:powersync/powersync.dart'; +import 'package:sqlite3/common.dart'; +import 'package:sqlite_async/sqlite_async.dart'; +import 'package:vm_service/vm_service.dart'; + +// ignore: implementation_imports +import 'package:powersync/src/devtools/protocol.dart'; + +import 'databases.dart'; + +/// A [SqliteConnection] implemented by dispatching queries to a running app +/// over a DevTools RPC protocol. +/// +/// Note that most [SqliteConnection] methods are unimplemented, only those +/// needed for the extension are functional. +final class RemoteDatabase extends SqliteConnection { + final DatabaseReference ref; + final VmService vmService; + + SyncStatus? currentStatus; + + final StreamController _statusController = + StreamController.broadcast(); + final StreamController _updates = + StreamController.broadcast(); + + Stream get syncStatus => _statusController.stream; + + RemoteDatabase(this.ref, this.vmService) { + _forwardTableUpdates(); + _forwardStatusUpdates(); + } + + void _forwardTableUpdates() { + int? subscriptionId; + StreamSubscription? subscription; + + _updates.onListen = () async { + final response = await request('table-updates-listen'); + subscriptionId = response as int; + + subscription?.cancel(); + subscription = vmService.onExtensionEvent + .where( + (e) => + e.extensionKind == 'powersync:table-updates' && + e.extensionData?.data['subscription'] == subscriptionId, + ) + .listen((event) { + final changedTables = event.extensionData!.data['tables'] as List; + _updates.add( + UpdateNotification(changedTables.cast().toSet()), + ); + }); + }; + _updates.onCancel = () { + subscription?.cancel(); + if (subscriptionId != null) { + request('unsubscribe', payload: {'id': subscriptionId.toString()}); + } + }; + } + + void _forwardStatusUpdates() { + int? subscriptionId; + StreamSubscription? subscription; + + _statusController.onListen = () async { + final response = (await request('status-listen')) as Map; + subscriptionId = response['id'] as int; + + void addStatus(Map serialized) { + _statusController.add(deserializeSyncStatus(serialized)); + } + + addStatus(response['current'] as Map); + + subscription?.cancel(); + subscription = vmService.onExtensionEvent + .where( + (e) => + e.extensionKind == 'powersync:status-updates' && + e.extensionData?.data['subscription'] == subscriptionId, + ) + .listen((event) { + final status = + event.extensionData!.data['status'] as Map; + addStatus(status); + }); + }; + _statusController.onCancel = () { + subscription?.cancel(); + if (subscriptionId != null) { + request('unsubscribe', payload: {'id': subscriptionId.toString()}); + } + }; + } + + Future request( + String command, { + Map payload = const {}, + }) async { + final response = await vmService.callServiceExtension( + 'ext.powersync.database', + isolateId: ref.isolate.id, + args: {'command': command, 'db': ref.id, ...payload}, + ); + + final json = response.json!; + if (json.containsKey('error')) { + throw json['error']; + } + + return json['ok']; + } + + Future> serializedSchema() async { + return (await request('schema')) as Map; + } + + @override + Future abortableReadLock( + Future Function(SqliteReadContext tx) callback, { + Future? abortTrigger, + String? debugContext, + }) { + return callback(_WriteContext(this)); + } + + @override + Future abortableWriteLock( + Future Function(SqliteWriteContext tx) callback, { + Future? abortTrigger, + String? debugContext, + }) { + return callback(_WriteContext(this)); + } + + @override + Future close() async { + _updates.close(); + } + + @override + bool get closed => false; + + @override + Future getAutoCommit() async { + return true; // Doesn't matter in devtools extension + } + + @override + Future readTransaction( + Future Function(SqliteReadContext tx) callback, { + Duration? lockTimeout, + }) { + throw UnimplementedError(); + } + + @override + Stream get updates => _updates.stream; +} + +final class _WriteContext implements SqliteWriteContext { + final RemoteDatabase _database; + + _WriteContext(this._database); + + @override + bool get closed => false; + + @override + Future computeWithDatabase( + Future Function(CommonDatabase db) compute, + ) { + throw UnimplementedError(); + } + + @override + Future execute( + String sql, [ + List parameters = const [], + ]) async { + await _database.request( + 'execute', + payload: { + 'sql': sql, + 'params': json.encode(parameters.map(encodeSqlValue).toList()), + }, + ); + + return ResultSet([], null, []); + } + + @override + Future executeBatch(String sql, List parameterSets) { + throw UnimplementedError(); + } + + @override + Future executeMultiple(String sql) async { + await execute(sql); + } + + @override + Future get(String sql, [List parameters = const []]) async { + return (await getAll(sql, parameters)).first; + } + + @override + Future getAll( + String sql, [ + List parameters = const [], + ]) async { + final response = + (await _database.request( + 'select', + payload: { + 'sql': sql, + 'params': json.encode(parameters.map(encodeSqlValue).toList()), + }, + ))! + as Map; + + final columnNames = response['columnNames'] as List; + + return ResultSet(columnNames.cast(), null, [ + for (final row in (response['rows'] as List).cast()) + [for (final value in row) decodeSqlValue(value)], + ]); + } + + @override + Future getAutoCommit() async { + return false; + } + + @override + Future getOptional( + String sql, [ + List parameters = const [], + ]) async { + return (await getAll(sql, parameters)).firstOrNull; + } + + @override + Future writeTransaction( + Future Function(SqliteWriteContext tx) callback, + ) { + throw UnimplementedError(); + } +} diff --git a/packages/powersync_devtools_extension/lib/state/service.dart b/packages/powersync_devtools_extension/lib/state/service.dart new file mode 100644 index 00000000..9ec04b8a --- /dev/null +++ b/packages/powersync_devtools_extension/lib/state/service.dart @@ -0,0 +1,54 @@ +import 'package:devtools_extensions/devtools_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:vm_service/vm_service.dart'; + +extension on ValueListenable { + Stream get asStream { + return Stream.multi((listener) { + listener.add(value); + + void valueListener() { + listener.add(value); + } + + void addListener() { + this.addListener(valueListener); + } + + void removeListener() { + this.removeListener(valueListener); + } + + addListener(); + listener + ..onPause = removeListener + ..onResume = addListener + ..onCancel = removeListener; + }); + } +} + +final serviceProvider = StreamProvider((ref) { + final state = serviceManager.connectedState.asStream; + return state.where((c) => c.connected).map((_) => serviceManager.service!); +}); + +final isolateProvider = ChangeNotifierProvider>(( + ref, +) { + final selectedIsolateListenable = + serviceManager.isolateManager.selectedIsolate; + + // Since ChangeNotifierProvider calls `dispose` on the returned ChangeNotifier + // when the provider is destroyed, we can't simply return `selectedIsolateListenable`. + // So we're making a copy of it instead. + final notifier = ValueNotifier(selectedIsolateListenable.value); + + void listener() => notifier.value = selectedIsolateListenable.value; + selectedIsolateListenable.addListener(listener); + ref.onDispose(() => selectedIsolateListenable.removeListener(listener)); + + return notifier; +}); diff --git a/packages/powersync_devtools_extension/lib/state/sync_status.dart b/packages/powersync_devtools_extension/lib/state/sync_status.dart new file mode 100644 index 00000000..68786891 --- /dev/null +++ b/packages/powersync_devtools_extension/lib/state/sync_status.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:powersync/powersync.dart'; +import 'package:powersync_devtools_extension/state/databases.dart'; +import 'package:riverpod/riverpod.dart'; + +final class _SyncStatusNotifier extends Notifier { + StreamSubscription? _updates; + + @override + SyncStatus? build() { + final db = ref.watch(selectedDatabase); + _updates?.cancel(); + _updates = null; + + if (db != null) { + final subscription = _updates = db.syncStatus.listen((status) { + state = status; + }); + ref.onDispose(subscription.cancel); + } + + return db?.currentStatus; + } +} + +final syncStatus = NotifierProvider(_SyncStatusNotifier.new); + +/// How many items we currently have in `ps_crud`. +final pendingCrudItems = StreamProvider.autoDispose((ref) { + final db = ref.watch(selectedDatabase); + if (db != null) { + return db + .watchUnthrottled('SELECT COUNT(*) FROM ps_crud') + .map((rs) => rs[0].columnAt(0) as int); + } else { + return Stream.empty(); + } +}); + +final isWaitingForCheckpoint = StreamProvider.autoDispose((ref) { + final db = ref.watch(selectedDatabase); + if (db != null) { + return db + .watchUnthrottled( + r"SELECT 1 FROM ps_buckets WHERE target_op > last_op AND name = '$local'", + ) + .map((rs) => rs.isNotEmpty); + } else { + return Stream.empty(); + } +}); + +final uploadStatus = Provider((ref) { + final outstandingCrudItems = ref.watch(pendingCrudItems); + final waitingForCheckpoint = ref.watch(isWaitingForCheckpoint); + final status = ref.watch(syncStatus); + if (status == null) return 'unknown'; + + final description = StringBuffer(); + if (outstandingCrudItems.value case final value? when value > 0) { + description.write( + '⚠ $value local writes prevent new data from being synced. ', + ); + } else if (waitingForCheckpoint.value == true) { + description.write( + 'Waiting for a write checkpoint containing the previous upload. ', + ); + } + + if (status.uploadError case final error?) { + description.write('Upload error: $error'); + } else if (status.uploading) { + description.write('Waiting for uploadData()'); + } + return description.toString(); +}); diff --git a/packages/powersync_devtools_extension/lib/ui/appbar.dart b/packages/powersync_devtools_extension/lib/ui/appbar.dart new file mode 100644 index 00000000..3ecff1bf --- /dev/null +++ b/packages/powersync_devtools_extension/lib/ui/appbar.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:web/web.dart' as web; + +import '../state/databases.dart'; + +final class PowerSyncLogo extends StatelessWidget { + const PowerSyncLogo({super.key}); + + @override + Widget build(BuildContext context) { + final asset = switch (Theme.brightnessOf(context)) { + .light => 'light.svg', + .dark => 'dark.svg', + }; + + return Padding( + padding: const EdgeInsets.only(left: 8), + child: HtmlElementView.fromTagName( + key: ValueKey(asset), + tagName: 'img', + onElementCreated: (img) { + img as web.HTMLImageElement; + + img.alt = 'PowerSync Logo'; + img.src = '/icons/$asset'; + img.style + ..height = '100%' + ..width = '100%'; + }, + ), + ); + } +} + +final class SelectPowerSyncDatabase extends ConsumerWidget { + const SelectPowerSyncDatabase({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final databases = ref.watch(databaseList).value; + final selected = ref.watch(selectedDatabase); + final disabled = databases == null || databases.isEmpty || selected == null; + + return DropdownButton( + value: selected?.ref, + disabledHint: Text('No database found'), + items: disabled + ? null + : [ + for (final database in databases) + DropdownMenuItem(value: database, child: Text(database.name)), + ], + onChanged: disabled ? null : (value) {}, + ); + } +} diff --git a/packages/powersync_devtools_extension/lib/ui/common.dart b/packages/powersync_devtools_extension/lib/ui/common.dart new file mode 100644 index 00000000..0dfd36cb --- /dev/null +++ b/packages/powersync_devtools_extension/lib/ui/common.dart @@ -0,0 +1,28 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:powersync_devtools_extension/state/databases.dart'; + +final class HasDatabaseGuard extends ConsumerWidget { + final Widget child; + + const HasDatabaseGuard({super.key, required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final databases = ref.watch(databaseList); + + if (databases.isLoading) { + return Center(child: CircularProgressIndicator()); + } + + if (databases.error case final error?) { + return Text('Could not list databases: $error'); + } + + if (databases.hasValue && databases.requireValue.isEmpty) { + return Text('No PowerSyncDatabase instances found in app.'); + } + + return child; + } +} diff --git a/packages/powersync_devtools_extension/lib/ui/overview.dart b/packages/powersync_devtools_extension/lib/ui/overview.dart new file mode 100644 index 00000000..be785d05 --- /dev/null +++ b/packages/powersync_devtools_extension/lib/ui/overview.dart @@ -0,0 +1,369 @@ +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_extensions/devtools_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:powersync/powersync.dart' hide Column; +import 'package:powersync_devtools_extension/state/databases.dart'; + +import '../state/sync_status.dart'; + +class OverviewPage extends ConsumerWidget { + const OverviewPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = ref.watch(syncStatus); + + if (status == null) { + return Text('Sync status could not be resolved'); + } + + return ListView( + children: [ + _StatusSection( + title: Text('Database Status'), + body: _SyncStatus(status: status), + ), + _StatusSection( + title: Text('Sync Streams'), + body: _SyncStreams(status: status), + ), + ], + ); + } +} + +class _StatusSection extends StatelessWidget { + final Widget title; + final Widget body; + + const _StatusSection({required this.title, required this.body}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: IntrinsicHeight( + child: DevToolsAreaPane( + header: AreaPaneHeader(title: title), + child: SelectableRegion( + selectionControls: emptyTextSelectionControls, + child: Padding(padding: const EdgeInsets.all(12.0), child: body), + ), + ), + ), + ); + } +} + +class _SyncStatus extends ConsumerWidget { + final SyncStatus status; + + const _SyncStatus({required this.status}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final credentials = ref.watch(lastCredentials); + final filePath = ref.watch(selectedDatabase)?.ref.path; + + return Column( + crossAxisAlignment: .start, + children: [ + Row( + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Database path: ', + style: TextStyle(fontWeight: .bold), + ), + TextSpan(text: filePath), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(4), + child: DevToolsButton( + onPressed: () { + if (filePath != null) { + extensionManager.copyToClipboard(filePath); + } + }, + outlined: false, + tooltip: 'Copy path', + icon: Icons.copy, + ), + ), + ], + ), + if (credentials != null) ...[ + Row( + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Service URL: ', + style: TextStyle(fontWeight: .bold), + ), + TextSpan(text: credentials.original.endpoint), + ], + ), + ), + ], + ), + Row( + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'User ID: ', + style: TextStyle(fontWeight: .bold), + ), + TextSpan(text: credentials.userId), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(4), + child: RoundedButtonGroup( + items: [ + ButtonGroupItemData( + label: 'Show token', + onPressed: () { + showDialog( + barrierDismissible: true, + context: context, + builder: (_) => + _TokenDetailsDialog(credentials: credentials), + ); + }, + ), + ButtonGroupItemData( + label: 'Open in Diagnostics App', + onPressed: () { + final url = Uri.http('localhost:5173', '/', { + 'token': credentials.original.token, + 'endpoint': credentials.original.endpoint, + }); + launchUrl(url.toString()); + }, + ), + ], + ), + ), + ], + ), + ], + _SyncIssues(status: status), + ], + ); + } +} + +final class _SyncIssues extends ConsumerWidget { + final SyncStatus status; + + const _SyncIssues({required this.status}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pendingCrud = ref.watch(pendingCrudItems); + final waitingForCheckpoint = ref.watch(isWaitingForCheckpoint); + final issues = []; + + void trackIssue(Widget issue) { + if (issues.isNotEmpty) issues.add(const PaddedDivider.thin()); + issues.add(issue); + } + + if (!status.connected) { + if (status.connecting) { + trackIssue( + Text('Connection to PowerSync service is being established.'), + ); + } else { + trackIssue( + Text.rich( + TextSpan( + text: 'Disconnected (no ongoing connection). ', + children: [ + LinkTextSpan( + link: Link( + display: 'Learn how to connect to PowerSync', + url: + 'https://docs.powersync.com/intro/setup-guide#connect-to-powersync-service-instance', + ), + context: context, + ), + ], + ), + ), + ); + } + } + + if (status.downloadError case final downloadError?) { + trackIssue(Text('Download error: $downloadError')); + } + if (status.uploadError case final uploadError?) { + trackIssue(Text('Upload error: $uploadError')); + } + if (pendingCrud.value case final value? when value > 0) { + trackIssue( + Text( + '$value pending items in ps_crud prevent new data from being synced.', + ), + ); + } else if (waitingForCheckpoint.value == true) { + trackIssue( + Text( + 'Waiting for a write checkpoint containing previous uploads. If this status persist, new data would not be synced.', + ), + ); + } + + if (issues.isEmpty) { + return Text('Sync client is connected without reported issues.'); + } else { + return Column( + crossAxisAlignment: .stretch, + children: [ + Text( + 'The issues might affect PowerSync in your app', + style: TextTheme.of(context).bodyLarge, + ), + Padding( + padding: EdgeInsets.only(top: 16, left: 16), + child: Column(crossAxisAlignment: .start, children: issues), + ), + ], + ); + } + } +} + +class _SyncStreams extends StatelessWidget { + final SyncStatus status; + + const _SyncStreams({required this.status}); + + @override + Widget build(BuildContext context) { + if (status.syncStreams?.isEmpty != false) { + return Text.rich( + TextSpan( + text: 'No Sync Streams found. ', + children: [ + LinkTextSpan( + link: Link( + display: 'Learn more about Sync Streams', + url: 'https://docs.powersync.com/sync/streams/overview', + ), + context: context, + ), + ], + ), + ); + } + + return DataTable( + columns: [ + DataColumn(label: Text('Stream name')), + DataColumn(label: Text('Parameters')), + DataColumn(label: Text('Default')), + DataColumn(label: Text('Active')), + DataColumn(label: Text('Explicit')), + DataColumn(label: Text('Priority')), + DataColumn(label: Text('Last Synced')), + DataColumn(label: Text('Eviction Time')), + ], + rows: [ + for (final stream in status.syncStreams ?? const []) + DataRow( + cells: [ + DataCell(Text(stream.subscription.name)), + DataCell(switch (stream.subscription.parameters) { + null => Text('No parameters'), + final parameters => FormattedJson(json: parameters), + }), + DataCell(Text(stream.subscription.isDefault ? 'Yes' : 'No')), + DataCell(Text(stream.subscription.active ? 'Yes' : 'No')), + DataCell( + Text( + stream.subscription.hasExplicitSubscription ? 'Yes' : 'No', + ), + ), + DataCell(Text(stream.priority.priorityNumber.toString())), + DataCell( + Text( + stream.subscription.lastSyncedAt?.toIso8601String() ?? + 'never', + ), + ), + DataCell( + Text( + stream.subscription.expiresAt?.toIso8601String() ?? 'never', + ), + ), + ], + ), + ], + ); + } +} + +class _TokenDetailsDialog extends StatelessWidget { + final DecodedCredentials credentials; + + const _TokenDetailsDialog({required this.credentials}); + + @override + Widget build(BuildContext context) { + final tokenComponents = credentials.original.token.split('.'); + final componentColors = [Colors.orange, Colors.purple, Colors.green]; + + return DevToolsDialog( + includeDivider: false, + title: Text('Token details'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Column( + crossAxisAlignment: .start, + children: [ + SelectableText.rich( + TextSpan( + children: [ + for (final (i, component) in tokenComponents.indexed) ...[ + if (i != 0) TextSpan(text: '.'), + TextSpan( + text: component, + style: TextStyle( + color: componentColors[i % componentColors.length], + ), + ), + ], + ], + ), + ), + if (credentials.decodedClaims case final decoded?) ...[ + PaddedDivider(), + FormattedJson(json: decoded), + ], + ], + ), + ), + actions: [ + DevToolsButton( + onPressed: () { + Navigator.of(context).pop(); + }, + label: 'Done', + ), + ], + ); + } +} diff --git a/packages/powersync_devtools_extension/lib/ui/sql.dart b/packages/powersync_devtools_extension/lib/ui/sql.dart new file mode 100644 index 00000000..b9242969 --- /dev/null +++ b/packages/powersync_devtools_extension/lib/ui/sql.dart @@ -0,0 +1,227 @@ +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sqlite3/common.dart' hide Row; + +import '../state/databases.dart'; + +final _sql = StateProvider((ref) { + // Reset SQL when the selected database changes. + ref.watch(selectedDatabase); + return ''; +}); + +final class _ResultsNotifier extends Notifier> { + @override + AsyncValue build() { + ref.watch(selectedDatabase); + + return .data(null); + } + + void runQuery() async { + final db = ref.read(selectedDatabase); + if (state.isLoading || db == null) { + return; + } + + final sql = ref.read(_sql); + state = .loading(); + + state = await AsyncValue.guard(() async { + return await db.getAll(sql); + }); + } +} + +final _results = NotifierProvider(_ResultsNotifier.new); + +final definedTables = FutureProvider.autoDispose>((ref) async { + final db = ref.watch(selectedDatabase); + if (db == null) { + return const []; + } + + final serializedSchema = await db.serializedSchema(); + final foundTables = []; + + for (final table + in (serializedSchema['tables'] as List).cast>()) { + foundTables.add(table['name'] as String); + } + + for (final table + in (serializedSchema['raw_tables'] as List) + .cast>()) { + foundTables.add((table['table_name'] ?? table['name']) as String); + } + + return foundTables; +}); + +final class SqlPage extends ConsumerWidget { + const SqlPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return const Column( + crossAxisAlignment: .stretch, + children: [ + Padding(padding: EdgeInsets.all(8), child: _QueryWidget()), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: 16), + child: _ResultsWidget(), + ), + ), + ], + ); + } +} + +final class _QueryWidget extends HookConsumerWidget { + const _QueryWidget(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sql = ref.watch(_sql); + final controller = useTextEditingController(text: sql); + final isLoading = ref.watch(_results).isLoading; + final disabled = isLoading || sql.trim().isEmpty; + final tables = ref.watch(definedTables); + + ref.listen(_sql, (_, sql) => controller.text = sql); + + ButtonGroupItemData selectFromTable(String table) { + return ButtonGroupItemData( + label: table, + onPressed: () { + ref.read(_sql.notifier).state = 'SELECT * FROM $table'; + ref.read(_results.notifier).runQuery(); + }, + ); + } + + return RoundedOutlinedBorder( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + decoration: InputDecoration(label: Text('SQL')), + onChanged: (contents) { + ref.read(_sql.notifier).state = contents; + }, + ), + ), + VerticalDivider(), + DevToolsButton( + onPressed: disabled + ? null + : () { + ref.read(_results.notifier).runQuery(); + }, + label: 'Execute', + icon: Icons.play_arrow, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Text('Or select from: '), + Expanded( + child: SingleChildScrollView( + scrollDirection: .horizontal, + child: RoundedButtonGroup( + items: [ + selectFromTable('ps_crud'), + selectFromTable('ps_oplog'), + selectFromTable('ps_untyped'), + selectFromTable('ps_buckets'), + if (tables.value case final foundTables?) ...[ + for (final table in foundTables) + selectFromTable(table), + ], + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +final class _ResultsWidget extends ConsumerWidget { + const _ResultsWidget(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final results = ref.watch(_results); + + switch (results) { + case AsyncLoading(): + return Center(child: CircularProgressIndicator()); + case AsyncData(:final value): + if (value == null) { + return const SizedBox.shrink(); + } + + return DevToolsAreaPane( + header: AreaPaneHeader(title: Text('Query results')), + child: PaginatedDataTable( + showEmptyRows: false, + columns: [ + for (final column in value.columnNames) + DataColumn(label: Text(column)), + ], + source: ResultSetDataSource(value), + ), + ); + + case AsyncError(:final error, :final stackTrace): + return SingleChildScrollView( + child: SelectableText('Error: $error\n$stackTrace'), + ); + } + } +} + +final class ResultSetDataSource extends DataTableSource { + final ResultSet resultSet; + + ResultSetDataSource(this.resultSet); + + @override + DataRow? getRow(int index) { + if (index >= rowCount) return null; + + final row = resultSet.rows[index]; + return DataRow( + cells: [ + for (var cell in row) DataCell(Text((cell ?? '').toString())), + ], + ); + } + + @override + bool get isRowCountApproximate => false; + + @override + int get rowCount => resultSet.length; + + @override + int get selectedRowCount => 0; +} diff --git a/packages/powersync_devtools_extension/pubspec.yaml b/packages/powersync_devtools_extension/pubspec.yaml new file mode 100644 index 00000000..be66e174 --- /dev/null +++ b/packages/powersync_devtools_extension/pubspec.yaml @@ -0,0 +1,33 @@ +name: powersync_devtools_extension +description: "DevTools extension for PowerSync." +publish_to: 'none' +resolution: workspace + +version: 1.0.0+1 + +environment: + sdk: ^3.11.4 + +dependencies: + flutter: + sdk: flutter + devtools_extensions: ^0.5.0 + devtools_app_shared: ^0.5.0 + riverpod: ^3.2.1 + flutter_riverpod: ^3.3.1 + sqlite_async: ^0.14.0 + web: ^1.1.1 + vm_service: ^15.1.0 + sqlite3: ^3.3.1 + + powersync: + hooks_riverpod: ^3.3.1 + flutter_hooks: ^0.21.3+1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/packages/powersync_devtools_extension/tool/build.sh b/packages/powersync_devtools_extension/tool/build.sh new file mode 100755 index 00000000..27def7eb --- /dev/null +++ b/packages/powersync_devtools_extension/tool/build.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +dart run devtools_extensions build_and_copy --source=. --dest=../powersync/extension/devtools diff --git a/packages/powersync_devtools_extension/web/icons/dark.svg b/packages/powersync_devtools_extension/web/icons/dark.svg new file mode 100644 index 00000000..fb18c54d --- /dev/null +++ b/packages/powersync_devtools_extension/web/icons/dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/powersync_devtools_extension/web/icons/light.svg b/packages/powersync_devtools_extension/web/icons/light.svg new file mode 100644 index 00000000..9ba3578e --- /dev/null +++ b/packages/powersync_devtools_extension/web/icons/light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/powersync_devtools_extension/web/index.html b/packages/powersync_devtools_extension/web/index.html new file mode 100644 index 00000000..38d2afa4 --- /dev/null +++ b/packages/powersync_devtools_extension/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + powersync_devtools_extension + + + + + + + diff --git a/pubspec.lock b/pubspec.lock index 1598308d..e419d8ab 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -353,6 +353,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dap: + dependency: transitive + description: + name: dap + sha256: "42b0b083a09c59a118741769e218fc3738980ab591114f09d1026241d2b9c290" + url: "https://pub.dev" + source: hosted + version: "1.4.0" dart_jsonwebtoken: dependency: transitive description: @@ -361,6 +369,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.0" + dart_service_protocol_shared: + dependency: transitive + description: + name: dart_service_protocol_shared + sha256: "1737875c176d7e3d87bb3a359182828b542fe20a0b34198b8d31a81af5c7a76d" + url: "https://pub.dev" + source: hosted + version: "0.0.3" dart_style: dependency: transitive description: @@ -377,6 +393,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + dds_service_extensions: + dependency: transitive + description: + name: dds_service_extensions + sha256: afe0fce921953ac0c5bb276bccd7e36fa5035d7769567d122523fdd09beb4d03 + url: "https://pub.dev" + source: hosted + version: "2.1.0" decimal: dependency: transitive description: @@ -385,6 +409,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.4" + devtools_app_shared: + dependency: transitive + description: + name: devtools_app_shared + sha256: "565f51fcacfe0a44d5fb74679edb5a434307af2ed1aa241a2ee95f38ae8df33d" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + devtools_extensions: + dependency: transitive + description: + name: devtools_extensions + sha256: "98b069c5204518f71be04d27ef66107c54daac1148b2232bc0eb48e7a64604ab" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + devtools_shared: + dependency: transitive + description: + name: devtools_shared + sha256: "2daf7a9fba6a470668b26ecbd04200f7bf992aad81a2c31d12457c7791419dea" + url: "https://pub.dev" + source: hosted + version: "12.1.0" drift: dependency: transitive description: @@ -409,6 +457,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" + dtd: + dependency: transitive + description: + name: dtd + sha256: "09ddb228b3d1478a093556357692a4c203ff4f9d5f8cda05dfdca0ff3fb7c5d3" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + extension_discovery: + dependency: transitive + description: + name: extension_discovery + sha256: de1fce715ab013cdfb00befc3bdf0914bea5e409c3a567b7f8f144bc061611a7 + url: "https://pub.dev" + source: hosted + version: "2.1.0" fake_async: dependency: transitive description: @@ -824,6 +888,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.11.0" + json_rpc_2: + dependency: transitive + description: + name: json_rpc_2 + sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" + url: "https://pub.dev" + source: hosted + version: "4.1.0" jwt_decode: dependency: transitive description: @@ -1072,6 +1144,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointer_interceptor: + dependency: transitive + description: + name: pointer_interceptor + sha256: "57210410680379aea8b1b7ed6ae0c3ad349bfd56fe845b8ea934a53344b9d523" + url: "https://pub.dev" + source: hosted + version: "0.10.1+2" + pointer_interceptor_ios: + dependency: transitive + description: + name: pointer_interceptor_ios + sha256: "03c5fa5896080963ab4917eeffda8d28c90f22863a496fb5ba13bc10943e40e4" + url: "https://pub.dev" + source: hosted + version: "0.10.1+1" + pointer_interceptor_platform_interface: + dependency: transitive + description: + name: pointer_interceptor_platform_interface + sha256: "0597b0560e14354baeb23f8375cd612e8bd4841bf8306ecb71fcd0bb78552506" + url: "https://pub.dev" + source: hosted + version: "0.10.0+1" + pointer_interceptor_web: + dependency: transitive + description: + name: pointer_interceptor_web + sha256: "460b600e71de6fcea2b3d5f662c92293c049c4319e27f0829310e5a953b3ee2a" + url: "https://pub.dev" + source: hosted + version: "0.10.3" pointycastle: dependency: transitive description: @@ -1429,6 +1533,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.44.3" + sse: + dependency: transitive + description: + name: sse + sha256: a9a804dbde8bfd369da3b4aa241d44d63a6486a97388c54ec166073d88c52302 + url: "https://pub.dev" + source: hosted + version: "4.2.0" stack_trace: dependency: transitive description: @@ -1549,6 +1661,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unified_analytics: + dependency: transitive + description: + name: unified_analytics + sha256: "406724e9231f8e30119673133c1087f9b24e2a75ba7111ea071253d57bb8f3b9" + url: "https://pub.dev" + source: hosted + version: "8.0.14" universal_io: dependency: transitive description: @@ -1641,10 +1761,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.1.0" watcher: dependency: transitive description: @@ -1742,5 +1862,5 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.10.3 <4.0.0" + dart: ">=3.11.4 <4.0.0" flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 0ce9b9a6..9b7565b5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ workspace: - packages/powersync - packages/powersync_attachments_helper - packages/powersync_flutter_libs + - packages/powersync_devtools_extension - demos/benchmarks - demos/django-todolist From e6c88a8d13551e182428d24cb093a716e0a556ed Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 17 Apr 2026 20:35:13 +0200 Subject: [PATCH 2/3] Build in CI as well --- .github/workflows/check.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index cf6267a0..3882a0c5 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -32,6 +32,9 @@ jobs: run: dart analyze --fatal-infos --fatal-warnings - name: Publish dry-run run: melos publish --dry-run --yes + - name: Build DevTools extension + run: ./tool/build.sh + working-directory: packages/powersync_devtools_extension # TODO: Uncomment after releasing powersync_flutter_libs version 0.5.0+eol. # pana: From 1012ecb673290293b55f109964f942f401105455 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 17 Apr 2026 23:18:18 +0200 Subject: [PATCH 3/3] Use embedded string literals --- .../lib/ui/appbar.dart | 46 +++++++++++++++++-- .../web/icons/dark.svg | 1 - .../web/icons/light.svg | 1 - 3 files changed, 42 insertions(+), 6 deletions(-) delete mode 100644 packages/powersync_devtools_extension/web/icons/dark.svg delete mode 100644 packages/powersync_devtools_extension/web/icons/light.svg diff --git a/packages/powersync_devtools_extension/lib/ui/appbar.dart b/packages/powersync_devtools_extension/lib/ui/appbar.dart index 3ecff1bf..25963c25 100644 --- a/packages/powersync_devtools_extension/lib/ui/appbar.dart +++ b/packages/powersync_devtools_extension/lib/ui/appbar.dart @@ -1,17 +1,49 @@ +import 'dart:js_interop'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:web/web.dart' as web; import '../state/databases.dart'; -final class PowerSyncLogo extends StatelessWidget { +final class PowerSyncLogo extends StatefulWidget { const PowerSyncLogo({super.key}); + @override + State createState() => _PowerSyncLogoState(); +} + +class _PowerSyncLogoState extends State { + late String light, dark; + + @override + void initState() { + super.initState(); + + // Loading URI resources appears to be broken when the app gets copied as a + // DevTools extension, so we use web object URLs from embedded strings as a + // hacky workarond. + light = web.URL.createObjectURL( + web.Blob([_light.toJS].toJS, web.BlobPropertyBag(type: 'image/svg+xml')), + ); + dark = web.URL.createObjectURL( + web.Blob([_dark.toJS].toJS, web.BlobPropertyBag(type: 'image/svg+xml')), + ); + } + + @override + void dispose() { + super.dispose(); + + web.URL.revokeObjectURL(light); + web.URL.revokeObjectURL(dark); + } + @override Widget build(BuildContext context) { final asset = switch (Theme.brightnessOf(context)) { - .light => 'light.svg', - .dark => 'dark.svg', + .light => light, + .dark => dark, }; return Padding( @@ -23,7 +55,7 @@ final class PowerSyncLogo extends StatelessWidget { img as web.HTMLImageElement; img.alt = 'PowerSync Logo'; - img.src = '/icons/$asset'; + img.src = asset; img.style ..height = '100%' ..width = '100%'; @@ -31,6 +63,12 @@ final class PowerSyncLogo extends StatelessWidget { ), ); } + + static const _dark = + ''; + + static const _light = + ''; } final class SelectPowerSyncDatabase extends ConsumerWidget { diff --git a/packages/powersync_devtools_extension/web/icons/dark.svg b/packages/powersync_devtools_extension/web/icons/dark.svg deleted file mode 100644 index fb18c54d..00000000 --- a/packages/powersync_devtools_extension/web/icons/dark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/powersync_devtools_extension/web/icons/light.svg b/packages/powersync_devtools_extension/web/icons/light.svg deleted file mode 100644 index 9ba3578e..00000000 --- a/packages/powersync_devtools_extension/web/icons/light.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file