From 6ff9735fb40caf9b97315898672d42ab9ef5415e Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 20 Oct 2026 14:47:33 +0200 Subject: [PATCH 1/3] Support direct tables --- packages/powersync/CHANGELOG.md | 1 + packages/powersync/lib/src/schema.dart | 31 +++++++++++ .../test/sync/in_memory_sync_test.dart | 54 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/packages/powersync/CHANGELOG.md b/packages/powersync/CHANGELOG.md index b823b2b5..7dbb8351 100644 --- a/packages/powersync/CHANGELOG.md +++ b/packages/powersync/CHANGELOG.md @@ -4,6 +4,7 @@ - The DevTools extension is now based on the shared [PowerSync DevTools](https://releases.powersync.com/announcements/powersync-devtools-inspect-your-app-s-live-database). This improves the SQL inspector, reports per-bucket progress, allows subscribing to Sync Streams and has a cleaner UI. - Fix `disconnect()`, `disconnectAndClear()` and `close()` hanging while a `requestCheckpoint()` call waits for an unresponsive service. Pending checkpoint requests now fail with a `CheckpointRequestException` on disconnect. +- Support [direct tables](https://docs.powersync.com/client-sdks/advanced/performance-optimization#direct-tables). ## 2.5.0 diff --git a/packages/powersync/lib/src/schema.dart b/packages/powersync/lib/src/schema.dart index a3333bc0..84b552ed 100644 --- a/packages/powersync/lib/src/schema.dart +++ b/packages/powersync/lib/src/schema.dart @@ -1,6 +1,8 @@ /// @docImport 'database/powersync_database.dart'; library; +import 'package:meta/meta.dart'; + import 'crud.dart'; import 'schema_logic.dart'; @@ -138,6 +140,9 @@ base class Table extends TableOptions { /// Override the name for the view final String? _viewNameOverride; + @experimental + final bool _isDirect; + /// powersync-sqlite-core limits the number of columns /// per table to 1999, due to internal SQLite limits. /// @@ -168,6 +173,7 @@ base class Table extends TableOptions { super.trackMetadata, super.trackPreviousValues, }) : _viewNameOverride = viewName, + _isDirect = false, super(insertOnly: false); /// Create a table that only exists locally. @@ -179,6 +185,7 @@ base class Table extends TableOptions { this.indexes = const [], String? viewName, }) : _viewNameOverride = viewName, + _isDirect = false, super(localOnly: true); /// Create a table that only supports inserts. @@ -198,8 +205,31 @@ base class Table extends TableOptions { super.trackPreviousValues, }) : indexes = const [], _viewNameOverride = viewName, + _isDirect = false, super(localOnly: false, insertOnly: true); + /// Creates a direct table, storing data directly instead of through an + /// intermediate view around a JSON table. + /// + /// Direct tables are also managed by the PowerSync SDK, which is responsible + /// for creating, altering and dropping them. + /// + /// You can query raw tables like JSON-based tables, and mutations are + /// consistently tracked as pending uploads. + /// + /// For more information on direct tables, see [the documentation](https://docs.powersync.com/client-sdks/advanced/performance-optimization#direct-tables). + const Table.direct( + this.name, + this.columns, { + this.indexes = const [], + super.trackMetadata, + super.trackPreviousValues, + super.localOnly, + super.insertOnly, + super.ignoreEmptyUpdates, + }) : _isDirect = true, + _viewNameOverride = null; + Column operator [](String columnName) { return columns.firstWhere((element) => element.name == columnName); } @@ -281,6 +311,7 @@ base class Table extends TableOptions { 'view_name': _viewNameOverride, 'columns': columns, 'indexes': indexes.map((e) => e.toJson(this)).toList(growable: false), + if (_isDirect) 'direct': true, ..._optionsToJson(), }; } diff --git a/packages/powersync/test/sync/in_memory_sync_test.dart b/packages/powersync/test/sync/in_memory_sync_test.dart index 43482e6a..de6bd6de 100644 --- a/packages/powersync/test/sync/in_memory_sync_test.dart +++ b/packages/powersync/test/sync/in_memory_sync_test.dart @@ -348,6 +348,60 @@ void _declareTests(String name, bool bson) { await expectLater(query, emits(isEmpty)); }); + test('direct tables', () async { + await database.close(); + (_, database) = await testUtils.openInMemoryDatabase( + schema: Schema([ + .direct('users', [.text('name')]), + ]), + ); + + await database.initialize(); + expect( + await database.get('pragma table_list(users)'), + containsPair('type', 'table'), + ); + + await waitForConnection(); + syncService + ..addLine( + checkpoint(lastOpId: 1, buckets: [bucketDescription('bucket')]), + ) + ..addLine({ + 'data': { + 'bucket': 'bucket', + 'data': [ + { + 'checksum': 0, + 'data': json.encode({'name': 'synced user'}), + 'op': 'PUT', + 'op_id': '1', + 'object_id': 'user_id', + 'object_type': 'users', + }, + ], + }, + }) + ..addLine(checkpointComplete()); + await database.waitForFirstSync(); + + expect(await database.get('SELECT * FROM users'), { + 'id': 'user_id', + 'name': 'synced user', + }); + await database.disconnect(); + + await database.execute('UPDATE users SET name = ?', ['local user']); + expect(await database.get('SELECT * FROM users'), { + 'id': 'user_id', + 'name': 'local user', + }); + final CrudTransaction(crud: [entry]) = (await database + .getNextCrudTransaction())!; + expect(entry.table, 'users'); + expect(entry.opData, {'name': 'local user'}); + }); + test('marks as connected even without sync line', () async { await waitForConnection(addKeepLive: false); expect(database.currentStatus.connected, isTrue); From 8cd7224e350c395467640760b99d01e592f12836 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 22 Oct 2026 09:09:08 +0200 Subject: [PATCH 2/3] Move experimental annotation to constructor --- packages/powersync/lib/src/schema.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/powersync/lib/src/schema.dart b/packages/powersync/lib/src/schema.dart index 84b552ed..507a4a2c 100644 --- a/packages/powersync/lib/src/schema.dart +++ b/packages/powersync/lib/src/schema.dart @@ -140,7 +140,6 @@ base class Table extends TableOptions { /// Override the name for the view final String? _viewNameOverride; - @experimental final bool _isDirect; /// powersync-sqlite-core limits the number of columns @@ -218,6 +217,7 @@ base class Table extends TableOptions { /// consistently tracked as pending uploads. /// /// For more information on direct tables, see [the documentation](https://docs.powersync.com/client-sdks/advanced/performance-optimization#direct-tables). + @experimental const Table.direct( this.name, this.columns, { From 3b01ea13516c7663d840403bb91d45d33307c09c Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 22 Oct 2026 09:20:16 +0200 Subject: [PATCH 3/3] Try fixing error reporting test --- packages/powersync/test/sync/utils.dart | 5 +++++ packages/powersync/test/web/sync_worker_test.dart | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/powersync/test/sync/utils.dart b/packages/powersync/test/sync/utils.dart index a70f6753..9b943be9 100644 --- a/packages/powersync/test/sync/utils.dart +++ b/packages/powersync/test/sync/utils.dart @@ -11,6 +11,7 @@ TypeMatcher isSyncStatus({ Object? downloadProgress, Object? syncStreams, Object? downloadError, + Object? uploading, }) { var matcher = isA(); if (downloading != null) { @@ -43,6 +44,10 @@ TypeMatcher isSyncStatus({ ); } + if (uploading != null) { + matcher = matcher.having((e) => e.uploading, 'uploading', uploading); + } + return matcher; } diff --git a/packages/powersync/test/web/sync_worker_test.dart b/packages/powersync/test/web/sync_worker_test.dart index c092441a..8a146d65 100644 --- a/packages/powersync/test/web/sync_worker_test.dart +++ b/packages/powersync/test/web/sync_worker_test.dart @@ -4,6 +4,7 @@ library; import 'dart:async'; import 'dart:convert'; +import 'package:async/async.dart'; import 'package:logging/logging.dart'; import 'package:powersync/powersync.dart'; import 'package:powersync/src/sync/streaming_sync.dart'; @@ -129,15 +130,23 @@ void main() { connector: _ThrowingBackendConnector(), options: options, ); + final statusChanges = StreamQueue(db.statusStream); await first.streamingSync(); - await db.statusStream.firstWhere((s) => s.downloadError != null); + + await expectLater( + statusChanges, + emitsThrough(isSyncStatus(downloadError: isNotNull)), + ); // Trigger an upload, emitting status updates that don't change the // download error. await db.execute( "INSERT INTO customers (id, name) VALUES (uuid(), 'name')", ); - await db.statusStream.firstWhere((s) => s.uploading); + await expectLater( + statusChanges, + emitsThrough(isSyncStatus(uploading: true)), + ); // A connecting client should receive the existing download error. final second = createWorkerHandle( @@ -156,6 +165,7 @@ void main() { contains('Expected error from fetchCredentials'), ); + await statusChanges.cancel(); await first.abort(); });