diff --git a/lib/extensions.dart b/lib/extensions.dart index 7c520b9..5588873 100644 --- a/lib/extensions.dart +++ b/lib/extensions.dart @@ -10,3 +10,7 @@ extension StringX on String { extension RequestX on Request { Map get queryParameters => requestedUri.queryParameters; } + +extension DateTimeX on DateTime { + String get toUtcString => toUtc().toIso8601String(); +} diff --git a/lib/tudo_server.dart b/lib/tudo_server.dart index 218154c..af102c6 100644 --- a/lib/tudo_server.dart +++ b/lib/tudo_server.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; @@ -13,10 +14,42 @@ import 'package:web_socket_channel/web_socket_channel.dart'; import 'db_util.dart'; import 'extensions.dart'; +const _queries = { + 'users': ''' + SELECT users.id, users.name, users.is_deleted, users.hlc FROM + (SELECT user_id, max(created_at) AS created_at FROM + (SELECT list_id FROM user_lists WHERE user_id = ?1 AND is_deleted = 0) AS list_ids + JOIN user_lists ON user_lists.list_id = list_ids.list_id + GROUP BY user_lists.user_id + ) AS user_ids + JOIN users ON users.id = user_ids.user_id + ''', + 'user_lists': ''' + SELECT user_lists.list_id, user_id, position, user_lists.created_at, is_deleted, hlc FROM + (SELECT list_id, created_at FROM user_lists WHERE user_id = ?1) AS own_lists + JOIN user_lists ON own_lists.list_id = user_lists.list_id + ''', + 'lists': ''' + SELECT * FROM (SELECT lists.id, lists.name, lists.color, lists.creator_id, + lists.created_at, lists.is_deleted, lists.hlc, lists.node_id, + CASE WHEN lists.modified > user_lists.modified THEN lists.modified ELSE user_lists.modified END AS modified + FROM user_lists + JOIN lists ON list_id = lists.id AND user_id = ?1 AND user_lists.is_deleted = 0) a + ''', + 'todos': ''' + SELECT * FROM (SELECT todos.id, todos.list_id, todos.name, todos.done, todos.position, + todos.creator_id, todos.created_at, todos.done_at, todos.done_by, + todos.is_deleted, todos.hlc, todos.node_id, + CASE WHEN todos.modified > user_lists.modified THEN todos.modified ELSE user_lists.modified END AS modified + FROM user_lists + JOIN todos ON user_lists.list_id = todos.list_id AND user_id = ?1 AND user_lists.is_deleted = 0) a + ''', +}; + class TudoServer { final String? apiSecret; late final SqlCrdt _crdt; - late final CrdtSyncServer _crdtSync; + late final CrdtSyncServer _syncServer; TudoServer(this.apiSecret); @@ -28,22 +61,29 @@ class TudoServer { String? dbUsername, String? dbPassword, }) async { - _crdt = await PostgresCrdt.open( - database, - host: dbHost, - port: dbPort, - username: dbUsername, - password: dbPassword, - ); + try { + _crdt = await PostgresCrdt.open( + database, + host: dbHost, + port: dbPort, + username: dbUsername, + password: dbPassword, + ); + } catch (e) { + print('Failed to open Postgres database.'); + rethrow; + } await DbUtil.createTables(_crdt); - _crdtSync = CrdtSyncServer( + _syncServer = CrdtSyncServer( _crdt, - // verbose: true, + verbose: true, ); final router = Router() ..head('/check_version', _checkVersion) ..get('/auth/', _auth) + ..post('/lists//', _joinList) + ..get('/changeset//', _getChangeset) ..get('/ws/', _wsHandler); final handler = Pipeline() @@ -62,76 +102,65 @@ class TudoServer { : Response(HttpStatus.upgradeRequired); /// By the time we arrive here, both the secret and credentials have been validated - Response _auth(Request request) => Response(HttpStatus.noContent); + Response _auth(Request request, String userId) => + Response(HttpStatus.noContent); + + Future _joinList( + Request request, String userId, String listId) async { + await _crdt.transaction((txn) async { + final maxPosition = (await txn.query(''' + SELECT max(position) as max_position FROM user_lists + WHERE user_id = ?1 AND is_deleted = 0 + ''', [userId])).first['max_position'] as int? ?? -1; + await txn.execute(''' + INSERT INTO user_lists (user_id, list_id, created_at, position, is_deleted) + VALUES (?1, ?2, ?3, ?4, 0) + ON CONFLICT (user_id, list_id) DO UPDATE SET + created_at = ?3, + position = ?4, + is_deleted = 0 + ''', [userId, listId, DateTime.now().toUtcString, maxPosition + 1]); + }); + return Response(HttpStatus.created); + } + + Future _getChangeset( + Request request, String userId, String peerId) async { + final changeset = await CrdtSync.getChangeset( + _crdt, + _queries.map((table, sql) => MapEntry(table, (sql, [userId]))), + isClient: false, + peerId: peerId, + afterHlc: Hlc.zero(_crdt.nodeId), + ); + return Response.ok(jsonEncode(changeset)); + } Future _wsHandler(Request request, String userId) async { final handler = webSocketHandler( pingInterval: Duration(seconds: 20), - (WebSocketChannel webSocket) async { - late String remoteNodeId; - await _crdtSync.handle( + (WebSocketChannel webSocket) { + CrdtSync( + _crdt, webSocket, - tables: ['users', 'user_lists', 'lists', 'todos'], - onConnect: (nodeId, __) { - remoteNodeId = nodeId; - print( - '${userId.short} (${nodeId.short}): connect [${_crdtSync.clientCount}]'); - }, + isClient: false, + changesetQueries: + _queries.map((table, sql) => MapEntry(table, (sql, [userId]))), + onConnect: (nodeId, __) => print( + '${userId.short} (${nodeId.short}): connect [${_syncServer.clientCount}]'), onDisconnect: (nodeId, code, reason) => print( - '${userId.short} (${nodeId.short}): disconnect [${_crdtSync.clientCount}] $code ${reason ?? ''}'), - onChangesetReceived: (recordCounts) => print( - '⬇️ ${userId.short} (${remoteNodeId.short}) ${recordCounts.entries.map((e) => '${e.key}: ${e.value}').join(', ')}'), - onChangesetSent: (recordCounts) => print( - '⬆️ ${userId.short} (${remoteNodeId.short}) ${recordCounts.entries.map((e) => '${e.key}: ${e.value}').join(', ')}'), - queryBuilder: (table, lastModified, remoteNodeId) => - _queryBuilder(userId, table, lastModified, remoteNodeId), + '${userId.short} (${nodeId.short}): disconnect [${_syncServer.clientCount}] $code ${reason ?? ''}'), + onChangesetReceived: (nodeId, recordCounts) => print( + '⬇️ ${userId.short} (${nodeId.short}) ${recordCounts.entries.map((e) => '${e.key}: ${e.value}').join(', ')}'), + onChangesetSent: (nodeId, recordCounts) => print( + '⬆️ ${userId.short} (${nodeId.short}) ${recordCounts.entries.map((e) => '${e.key}: ${e.value}').join(', ')}'), + // verbose: true, ); }, ); return await handler(request); } - (String, List)? _queryBuilder( - String userId, String table, Hlc lastModified, String remoteNodeId) { - final query = switch (table) { - 'users' => ''' - SELECT users.id, users.name, users.is_deleted, users.hlc FROM - (SELECT user_id, max(created_at) AS created_at FROM - (SELECT list_id FROM user_lists WHERE user_id = ?1 AND is_deleted = 0) AS list_ids - JOIN user_lists ON user_lists.list_id = list_ids.list_id - GROUP BY user_lists.user_id - ) AS user_ids - JOIN users ON users.id = user_ids.user_id - WHERE node_id != ?2 - AND modified > CASE WHEN user_ids.created_at >= ?3 THEN '' ELSE ?3 END - ''', - 'user_lists' => ''' - SELECT user_lists.list_id, user_id, position, user_lists.created_at, is_deleted, hlc FROM - (SELECT list_id, created_at FROM user_lists WHERE user_id = ?1) AS own_lists - JOIN user_lists ON own_lists.list_id = user_lists.list_id - WHERE node_id != ?2 - AND modified > CASE WHEN own_lists.created_at >= ?3 THEN '' ELSE ?3 END - ''', - 'lists' => ''' - SELECT lists.id, lists.name, lists.color, lists.creator_id, - lists.created_at, lists.is_deleted, lists.hlc FROM user_lists - JOIN lists ON list_id = lists.id AND user_id = ?1 AND user_lists.is_deleted = 0 - WHERE lists.node_id != ?2 - AND lists.modified > CASE WHEN user_lists.created_at >= ?3 THEN '' ELSE ?3 END - ''', - 'todos' => ''' - SELECT todos.id, todos.list_id, todos.name, todos.done, todos.position, - todos.creator_id, todos.created_at, todos.done_at, todos.done_by, - todos.is_deleted, todos.hlc FROM user_lists - JOIN todos ON user_lists.list_id = todos.list_id AND user_id = ?1 AND user_lists.is_deleted = 0 - WHERE todos.node_id != ?2 - AND todos.modified > CASE WHEN user_lists.created_at >= ?3 THEN '' ELSE ?3 END - ''', - _ => throw "$table: I've never seen this table in my life!" - }; - return (query, [userId, remoteNodeId, lastModified]); - } - Handler _validateVersion(Handler innerHandler) => (request) => _isVersionSupported(request) ? innerHandler(request) : Response(426); @@ -160,8 +189,10 @@ class TudoServer { } final userId = - request.headers['user_id'] ?? request.url.pathSegments.last; - final token = request.requestedUri.queryParameters['token']; + request.headers['user_id'] ?? request.url.pathSegments[1]; + final token = request.headers[HttpHeaders.authorizationHeader] + ?.replaceFirst('bearer ', '') ?? + request.requestedUri.queryParameters['token']; // Validate user id length if (userId.length != 36) { @@ -169,7 +200,6 @@ class TudoServer { } // Validate token length - // print(token!.length); if (token == null || token.length < 32 || token.length > 128) { return Response.forbidden('Invalid token: $token'); } @@ -182,7 +212,7 @@ class TudoServer { // happen atomically final result = await txn .query('SELECT token FROM auth WHERE user_id = ?1', [userId]); - knownToken = result.isEmpty ? null : result.first['token'] as String?; + knownToken = result.firstOrNull?['token'] as String?; // Associate new token with user id if (knownToken == null) { @@ -205,7 +235,7 @@ class TudoServer { final userAgent = request.headers[HttpHeaders.userAgentHeader]!; final version = Version.parse(userAgent.substring( userAgent.indexOf('/') + 1, userAgent.indexOf(' '))); - return version >= Version(2, 3, 1); + return version >= Version(2, 3, 3); } } diff --git a/pubspec.lock b/pubspec.lock index 3cbe8e2..d714573 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0c80aeab9bc807ab10022cd3b2f4cf2ecdf231949dc1ddd9442406a003f19201" + sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 url: "https://pub.dev" source: hosted - version: "52.0.0" + version: "64.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: cd8ee83568a77f3ae6b913a36093a1c9b1264e7cb7f834d9ddd2311dade9c1f4 + sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "6.2.0" args: dependency: "direct main" description: @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: async - sha256: bfe67ef28df125b7dddcea62755991f807aa39a2492a23e1550161692950bbe0 + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" url: "https://pub.dev" source: hosted - version: "2.10.0" + version: "2.11.0" boolean_selector: dependency: transitive description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: collection - sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0 + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" convert: dependency: transitive description: @@ -77,34 +77,34 @@ packages: dependency: transitive description: name: coverage - sha256: d2494157c32b303f47dedee955b1479f2979c4ff66934eb7c0def44fd9e0267a + sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" url: "https://pub.dev" source: hosted - version: "1.6.1" + version: "1.6.3" crdt_sync: dependency: "direct main" description: name: crdt_sync - sha256: f873324980f189b1a83e8d03bfb4f3bc8c01034ab781808a242fd37ceba76d30 + sha256: "4bc4b026169f2d5ceb89389a9d2faf49ac6c42b96ba16e2216c9391c60db6419" url: "https://pub.dev" source: hosted - version: "0.0.2" + version: "0.0.4" crypto: dependency: transitive description: name: crypto - sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" file: dependency: transitive description: name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.0" frontend_server_client: dependency: transitive description: @@ -117,10 +117,10 @@ packages: dependency: transitive description: name: glob - sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" http: dependency: "direct main" description: @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: http_methods - sha256: c192bb6fb4ae99d06053f67a2c1c65350a29bc778a39d9a12b96bd2ec820e9dc + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" http_multi_server: dependency: transitive description: @@ -157,66 +157,66 @@ packages: dependency: transitive description: name: io - sha256: "0d4c73c3653ab85bf696d51a9657604c900a370549196a91f33e4c39af760852" + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" js: dependency: transitive description: name: js - sha256: "323b7c70073cccf6b9b8d8b334be418a3293cfb612a560dc2737160a37bf61bd" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.6.6" + version: "0.6.7" lints: dependency: "direct dev" description: name: lints - sha256: "5e4a9cd06d447758280a8ac2405101e0e2094d2a1dbdd3756aec3fe7775ba593" + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.1" logging: dependency: transitive description: name: logging - sha256: c0bbfe94d46aedf9b8b3e695cf3bd48c8e14b35e3b2c639e0aa7755d589ba946 + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0" matcher: dependency: transitive description: name: matcher - sha256: c94db23593b89766cda57aab9ac311e3616cf87c6fa4e9749df032f66f30dcb8 + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.14" + version: "0.12.16" meta: dependency: transitive description: name: meta - sha256: "6c268b42ed578a53088d834796959e4a1814b5e9e164f147f580a386e5decf42" + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "1.9.1" mime: dependency: transitive description: name: mime - sha256: "52e38f7e1143ef39daf532117d6b8f8f617bf4bcd6044ed8c29040d20d269630" + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" node_preamble: dependency: transitive description: name: node_preamble - sha256: "8ebdbaa3b96d5285d068f80772390d27c21e1fa10fb2df6627b1b9415043608d" + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" package_config: dependency: transitive description: @@ -261,18 +261,18 @@ packages: dependency: "direct main" description: name: postgres_crdt - sha256: "7f35d5db91783630994fc12ff70578bd5256accca00d945ec2f0e3843f3afd42" + sha256: "1d8780346e9740605b3c1b24d063c8d9977162fdb398c0167ddd33498b1344e3" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.1+1" pub_semver: dependency: transitive description: name: pub_semver - sha256: "307de764d305289ff24ad257ad5c5793ce56d04947599ad68b3baa124105fc17" + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" rxdart: dependency: "direct main" description: @@ -309,10 +309,10 @@ packages: dependency: transitive description: name: shelf_packages_handler - sha256: aef74dc9195746a384843102142ab65b6a4735bb3beea791e63527b88cc83306 + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" shelf_router: dependency: "direct main" description: @@ -325,10 +325,10 @@ packages: dependency: transitive description: name: shelf_static - sha256: e792b76b96a36d4a41b819da593aff4bdd413576b3ba6150df5d8d9996d2e74c + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" shelf_web_socket: dependency: "direct main" description: @@ -349,26 +349,26 @@ packages: dependency: transitive description: name: source_maps - sha256: "490098075234dcedb83c5d949b4c93dad5e6b7702748de000be2b57b8e6b2427" + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" url: "https://pub.dev" source: hosted - version: "0.10.11" + version: "0.10.12" source_span: dependency: transitive description: name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" sql_crdt: dependency: transitive description: name: sql_crdt - sha256: e07c1d02a46727d8b7876b95d2ce24ffc69aa8e9e260f18b1b173bccfe6ab07a + sha256: f64190be49a2c9e5105536e2c9d139fb40c803616639e00813dbf05b49388196 url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.1+1" sqlparser: dependency: transitive description: @@ -381,18 +381,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.11.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -413,34 +413,34 @@ packages: dependency: "direct dev" description: name: test - sha256: "98403d1090ac0aa9e33dfc8bf45cc2e0c1d5c58d7cb832cee1e50bf14f37961d" + sha256: "9b0dd8e36af4a5b1569029949d50a52cb2a2a2fdaa20cebb96e6603b9ae241f9" url: "https://pub.dev" source: hosted - version: "1.22.1" + version: "1.24.6" test_api: dependency: transitive description: name: test_api - sha256: c9282698e2982b6c3817037554e52f99d4daba493e8028f8112a83d68ccd0b12 + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.4.17" + version: "0.6.1" test_core: dependency: transitive description: name: test_core - sha256: c9e4661a5e6285b795d47ba27957ed8b6f980fc020e98b218e276e88aff02168 + sha256: "4bef837e56375537055fdbbbf6dd458b1859881f4c7e6da936158f77d61ab265" url: "https://pub.dev" source: hosted - version: "0.4.21" + version: "0.5.6" typed_data: dependency: transitive description: name: typed_data - sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.2" unorm_dart: dependency: transitive description: @@ -469,18 +469,18 @@ packages: dependency: transitive description: name: vm_service - sha256: e7fb6c2282f7631712b69c19d1bff82f3767eea33a2321c14fa59ad67ea391c7 + sha256: "0fae432c85c4ea880b33b497d32824b97795b04cdaa74d270219572a1f50268d" url: "https://pub.dev" source: hosted - version: "9.4.0" + version: "11.9.0" watcher: dependency: transitive description: name: watcher - sha256: "6a7f46926b01ce81bfc339da6a7f20afbe7733eff9846f6d6a5466aa4c6667c0" + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.1.0" web_socket_channel: dependency: "direct main" description: @@ -501,9 +501,9 @@ packages: dependency: transitive description: name: yaml - sha256: "23812a9b125b48d4007117254bca50abb6c712352927eece9e155207b1db2370" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" sdks: dart: ">=3.0.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index de0cfb9..1a8c409 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,10 +8,10 @@ environment: dependencies: args: ^2.4.2 - crdt_sync: ^0.0.2 + crdt_sync: ^0.0.4 # path: ../../crdt_sync http: ^1.1.0 - postgres_crdt: ^1.1.1 + postgres_crdt: ^1.1.1+1 # path: ../../postgres_crdt rxdart: ^0.27.7 shelf: ^1.4.1