diff --git a/lib/src/call_handlers/offline_call_handler.dart b/lib/src/call_handlers/offline_call_handler.dart index 3951dab..b20482d 100644 --- a/lib/src/call_handlers/offline_call_handler.dart +++ b/lib/src/call_handlers/offline_call_handler.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; @@ -82,6 +83,10 @@ class OfflineCallHandler extends CallHandler final endpoint = getEndpoint(params); final offlinePersistency = getOfflinePersistency(params); + if (offlinePersistency) { + params.headers['X-SDK-Offline'] = 'true'; + } + while (true) { try { // if offline, do offline stuff @@ -96,19 +101,36 @@ class OfflineCallHandler extends CallHandler final pathValues = routeMatch?.getPathValues(params.path); - final model = modelPattern.split('/').map((part) { - if (!part.startsWith('{') || !part.endsWith('}')) { - return part; + String replacePlaceholder( + String input, Map? pathValues) { + if (!input.startsWith('{') || !input.endsWith('}')) { + return input; } - return pathValues![part.substring(1, part.length - 1)]; + return pathValues![input.substring(1, input.length - 1)]!; + } + + final model = modelPattern.split('/').map((part) { + return replacePlaceholder(part, pathValues); }).join('/'); + final keyPattern = routeMatch?.getLabel('offline.key') ?? ''; + final key = replacePlaceholder( + keyPattern, + pathValues, + ); + + final containerKeyPattern = + routeMatch?.getLabel('offline.container-key') ?? ''; + final containerKey = replacePlaceholder( + containerKeyPattern, + pathValues, + ); + final cacheParams = CacheParams( model: model, - key: routeMatch?.getLabel('offline.key') as String, + key: key, responseIdKey: routeMatch?.getLabel('offline.response-key') as String, - responseContainerKey: - routeMatch?.getLabel('offline.container-key') as String, + responseContainerKey: containerKey, ); final uri = Uri.parse(endpoint + params.path); @@ -145,15 +167,23 @@ class OfflineCallHandler extends CallHandler final response = await next.handleCall(params); - // cache stuff - print('cached stuff...'); if (offlinePersistency) { + // cache stuff + print('cached stuff...'); + + final relationsHeader = response.headers['x-appwrite-relations']; + if (relationsHeader != null) { + final relations = (jsonDecode(relationsHeader) as List) + .cast>(); + await cacheCollections(relations); + } cacheResponse( cacheModel: cacheParams.model, cacheKey: cacheParams.key, cacheResponseIdKey: cacheParams.responseIdKey, - request: request, - response: response, + cacheResponseContainerKey: cacheParams.responseContainerKey, + requestMethod: request.method, + responseData: response.data, ); } @@ -170,7 +200,8 @@ class OfflineCallHandler extends CallHandler rethrow; } isOnline.value = false; - } catch (e) { + } catch (e, s) { + print(s); throw AppwriteException(e.toString()); } } diff --git a/lib/src/client_io.dart b/lib/src/client_io.dart index 7f10b48..e4f089c 100644 --- a/lib/src/client_io.dart +++ b/lib/src/client_io.dart @@ -374,6 +374,10 @@ class ClientIO extends ClientBase with ClientMixin { @override Future call(CallParams params) async { + while (!_initialized) { + await Future.delayed(Duration(milliseconds: 100)); + } + params.headers.addAll(this._headers!); final response = await _handler.handleCall( withOfflinePersistency( diff --git a/lib/src/client_offline_mixin.dart b/lib/src/client_offline_mixin.dart index 6af7493..7cf5646 100644 --- a/lib/src/client_offline_mixin.dart +++ b/lib/src/client_offline_mixin.dart @@ -366,45 +366,122 @@ class ClientOfflineMixin { return completer.future; } + void cacheRelated({ + required Map document, + }) { + // iterate over each attribute to see if it's nested data + document.entries.forEach((entry) { + if (entry.value is Map) { + final nestedDocument = entry.value as Map; + final nestedDatabaseId = nestedDocument['\$databaseId'] as String?; + final nestedCollectionId = nestedDocument['\$collectionId'] as String?; + final nestedDocumentId = nestedDocument['\$id'] as String?; + if (nestedDatabaseId == null || + nestedCollectionId == null || + nestedDocumentId == null) return; + final nestedModel = + "/databases/$nestedDatabaseId/collections/$nestedCollectionId/documents"; + cacheResponse( + cacheModel: nestedModel, + cacheKey: nestedDocumentId, + cacheResponseIdKey: "\$id", + cacheResponseContainerKey: '', + requestMethod: 'GET', + responseData: entry.value, + ); + document[entry.key] = { + '\$id': nestedDocumentId, + '\$databaseId': nestedDatabaseId, + '\$collectionId': nestedCollectionId, + }; + } else if (entry.value is List && + entry.value != null && + (entry.value as List).isNotEmpty) { + final values = (entry.value as List); + if (!(values.first is Map)) return; + final nestedDocument = values.first; + final nestedDatabaseId = nestedDocument['\$databaseId'] as String?; + final nestedCollectionId = nestedDocument['\$collectionId'] as String?; + if (nestedDatabaseId == null || nestedCollectionId == null) return; + final nestedModel = + "/databases/$nestedDatabaseId/collections/$nestedCollectionId/documents"; + cacheResponse( + cacheModel: nestedModel, + cacheKey: '', + cacheResponseIdKey: "\$id", + cacheResponseContainerKey: 'documents', + requestMethod: 'GET', + responseData: { + 'documents': entry.value, + }, + ); + document[entry.key] = values.map((value) { + final nestedDocumentId = value['\$id'] as String?; + return { + '\$id': nestedDocumentId, + '\$databaseId': nestedDatabaseId, + '\$collectionId': nestedCollectionId, + }; + }).toList(); + } + }); + } + + Future cacheCollections(List> relations) { + final futures = []; + for (var collection in relations) { + futures.add(_modelData.cacheModel(collection)); + } + return Future.wait(futures); + } + void cacheResponse({ required String cacheModel, required String cacheKey, required String cacheResponseIdKey, - required http.BaseRequest request, - required Response response, + required String cacheResponseContainerKey, + required String requestMethod, + required dynamic responseData, }) { if (cacheModel.isEmpty) return; - switch (request.method) { + switch (requestMethod) { case 'GET': - final clone = cloneMap(response.data); + final clone = cloneMap(responseData); if (cacheKey.isNotEmpty) { + cacheRelated(document: clone); _modelData.upsert(model: cacheModel, data: clone, key: cacheKey); } else { - clone.forEach((key, value) { - if (key == 'total') return; - _modelData.batchUpsert( - model: cacheModel, - dataList: value as List, - idKey: cacheResponseIdKey, - ); + final values = clone[cacheResponseContainerKey] as List; + values.forEach((value) { + cacheRelated(document: value); }); + _modelData.batchUpsert( + model: cacheModel, + dataList: values, + idKey: cacheResponseIdKey, + ); } break; case 'POST': case 'PUT': case 'PATCH': - Map clone = cloneMap(response.data); + Map clone = cloneMap(responseData); if (cacheKey.isEmpty) { cacheKey = clone['\$id'] as String; } if (cacheModel.endsWith('/prefs')) { - clone = response.data['prefs']; + clone = responseData['prefs']; } _modelData.upsert(model: cacheModel, data: clone, key: cacheKey); break; case 'DELETE': if (cacheKey.isNotEmpty) { + // _modelData.get(model: cacheModel, key: cacheKey).then((cachedData) { + // if (cachedData == null) { + // return; + // } + // }); _modelData.delete(model: cacheModel, key: cacheKey); } } diff --git a/lib/src/offline/route_mapping.dart b/lib/src/offline/route_mapping.dart index 1a591ef..81784f7 100644 --- a/lib/src/offline/route_mapping.dart +++ b/lib/src/offline/route_mapping.dart @@ -36,7 +36,7 @@ final routes = [ "offline": { "model": "\/databases\/{databaseId}\/collections\/{collectionId}\/documents", - "key": "{documentId}", + "key": "", "response-key": "\$id", "container-key": "", }, diff --git a/lib/src/offline/services/cache_size.dart b/lib/src/offline/services/cache_size.dart index cf1b0c8..0934179 100644 --- a/lib/src/offline/services/cache_size.dart +++ b/lib/src/offline/services/cache_size.dart @@ -19,23 +19,33 @@ class CacheSize { return encoded; } - Future applyChange(int change) async { - if (change == 0) return; + Future applyChange(Transaction txn, int change) async { + if (change == 0) return null; final record = getCacheSizeRecordRef(); - - final currentSize = await record.get(_db) ?? 0; - await record.put(_db, currentSize + change); + final currentSize = await record.get(txn) ?? 0; + return await record.put(txn, currentSize + change); } Future update({ - Map? oldData, + required RecordRef> recordRef, + required Transaction txn, Map? newData, }) async { + final oldData = await recordRef.get(txn); final oldSize = oldData != null ? encode(oldData).length : 0; final newSize = newData != null ? encode(newData).length : 0; final change = newSize - oldSize; - await applyChange(change); + final cacheSize = await applyChange(txn, change); + + if (change != 0) { + print([ + '${recordRef.key}: oldSize: $oldSize', + 'newSize: $newSize', + 'change: $change', + 'cacheSize: $cacheSize', + ].join(', ')); + } } void onChange(void callback(int? currentSize)) { diff --git a/lib/src/offline/services/model_data.dart b/lib/src/offline/services/model_data.dart index fbda478..a158278 100644 --- a/lib/src/offline/services/model_data.dart +++ b/lib/src/offline/services/model_data.dart @@ -1,5 +1,6 @@ import 'package:appwrite/src/offline/services/cache_size.dart'; import 'package:sembast/sembast.dart'; +import 'package:sembast/utils/value_utils.dart'; import '../../../appwrite.dart'; import 'accessed_at.dart'; @@ -8,6 +9,9 @@ class ModelData { final Database _db; final AccessedAt _accessedAt; final CacheSize _cacheSize; + final int maxDepth = 3; + final documentModelRegex = RegExp( + r'^/databases/([a-zA-Z0-9\-]*)/collections/([a-zA-Z0-9\-]*)/documents$'); ModelData(this._db) : _accessedAt = AccessedAt(_db), @@ -17,15 +21,128 @@ class ModelData { return stringMapStoreFactory.store(model); } + Future cacheModel(Map collection) { + final store = stringMapStoreFactory.store('collections'); + + return store + .record("${collection['databaseId']}|${collection['collectionId']}") + .put(_db, collection); + } + Future?> get({ required String model, required String key, + }) async { + final immutableRecord = await _getRecord(model: model, key: key); + + if (immutableRecord == null) return null; + + final record = cloneMap(immutableRecord); + await _populateRelated(record, 0); + + return record; + } + + Future?> _getRecord({ + required String model, + required String key, }) async { final store = getModelStore(model); final recordRef = store.record(key); return recordRef.get(_db); } + bool _isNestedDocument(Map record) { + return record.containsKey('\$databaseId') && + record.containsKey('\$collectionId') && + record.containsKey('\$id'); + } + + /// Given a record with $databaseId, $collectionId and $id, populate the rest + /// of the attributes from the cache and then populate the related records. + Future?> _populateRecord( + Map? record, int depth) async { + if (record == null) return record; + + if (!_isNestedDocument(record)) return record; + + final databaseId = record['\$databaseId'] as String; + final collectionId = record['\$collectionId'] as String; + final documentId = record['\$id'] as String; + + final nestedModel = + "/databases/$databaseId/collections/$collectionId/documents"; + + final cached = await _getRecord( + model: nestedModel, + key: documentId, + ); + + if (cached == null) return record; + + record.addAll(cloneMap(cached)); + + await _populateRelated(record, depth + 1); + + return record; + } + + /// Iterate over every attribute of a record and fetch related records from + /// the cache. + Future _populateRelated(Map? record, int depth) { + if (record == null) { + return Future.value(); + } + + // iterate over each attribute and check if it is a relation + final futures = []; + for (final attribute in record.entries) { + if (attribute.value is Map) { + final map = attribute.value as Map; + if (_isNestedDocument(record)) { + if (depth >= maxDepth) { + record[attribute.key] = null; + } else { + final future = _populateRecord(map, depth).then((populated) { + record[attribute.key] = populated; + }); + + futures.add(future); + } + } + } else if (attribute.value is List) { + final List list = attribute.value as List; + final futureList = ?>>[]; + if (list.isEmpty) continue; + + if (depth >= maxDepth && + list.first is Map && + _isNestedDocument(list.first)) { + record[attribute.key] = []; + continue; + } + + for (final map in list) { + if (map is! Map) { + continue; + } + futureList.add(_populateRecord(map, depth)); + } + + if (futureList.isEmpty) { + continue; + } + + final future = Future.wait(futureList).then((populated) { + record[attribute.key] = populated; + }); + + futures.add(future); + } + } + return Future.wait(futures); + } + Future> list({ required String model, required String cacheResponseContainerKey, @@ -50,6 +167,7 @@ class ModelData { final List equalFilters = []; value.forEach((v) { equalFilters.add(Filter.equals(q.params[0], v)); + equalFilters.add(Filter.equals("${q.params[0]}.\$id", v)); }); filters.add(Filter.or(equalFilters)); }); @@ -82,6 +200,30 @@ class ModelData { filters.add(Filter.matches(q.params[0], r'${q.params[1]}+')); break; + case 'isNull': + // TODO: Handle this case. + break; + + case 'isNotNull': + // TODO: Handle this case. + break; + + case 'between': + // TODO: Handle this case. + break; + + case 'startsWith': + // TODO: Handle this case. + break; + + case 'endsWith': + // TODO: Handle this case. + break; + + case 'select': + // TODO: Handle this case. + break; + case 'orderAsc': sortOrders.add(SortOrder(q.params[0] as String)); break; @@ -116,19 +258,27 @@ class ModelData { final records = await store.find(_db, finder: finder); final count = await store.count(_db, filter: filter); + final list = records.map((record) { + // convert to Map + final map = Map(); + record.value.entries.forEach((entry) { + map[entry.key] = entry.value; + }); + return map; + }).toList(); + + final futures = ?>>[]; + for (final record in list) { + futures.add(_populateRecord(record, 0)); + } + final keys = records.map((record) => record.key).toList(); _accessedAt.update(model: store.name, keys: keys); return { 'total': count, - cacheResponseContainerKey: records.map((record) { - final map = Map(); - record.value.entries.forEach((entry) { - map[entry.key] = entry.value; - }); - return map; - }).toList(), + cacheResponseContainerKey: await Future.wait(futures), }; } @@ -137,13 +287,90 @@ class ModelData { required Map data, required String key, }) async { - final store = getModelStore(model); + final match = documentModelRegex.firstMatch(model); + if (match?.groupCount == 2) { + // data is a document - final recordRef = store.record(key); - final record = await recordRef.get(_db); - _cacheSize.update(oldData: record, newData: data); + // match starting at 1 since 0 is the full match + final databaseId = match!.group(1)!; + final collectionId = match.group(2)!; - final result = await recordRef.put(_db, data, merge: true); + final collectionStore = getModelStore('collections'); + final recordRef = collectionStore.record('$databaseId|$collectionId'); + final collection = await recordRef.get(_db); + final attributes = (collection?['attributes'] ?? >{}) + as Map; + for (final attributeEntry in attributes.entries) { + final key = attributeEntry.key; + final attribute = attributeEntry.value as Map; + final relatedCollection = attribute['relatedCollection'] as String; + final relationType = attribute['relationType'] as String; + final side = attribute['side'] as String; + + if (!data.containsKey(key)) continue; + + final nestedModel = + "/databases/$databaseId/collections/$relatedCollection/documents"; + + if (relationType == 'oneToOne' || + (relationType == 'oneToMany' && side == 'child') || + (relationType == 'manyToOne' && side == 'parent')) { + // data[key] is a single document + String documentId = ''; + if (data[key] is String) { + // data[key] is a document ID + documentId = data[key] as String; + } else if (data[key] is Map) { + // data[key] is a nested document + final related = data[key] as Map; + documentId = (related['\$id'] ?? ID.unique()) as String; + await upsert(model: nestedModel, key: documentId, data: related); + } + data[key] = { + '\$databaseId': databaseId, + '\$collectionId': relatedCollection, + '\$id': documentId + }; + } else { + // data[key] is a list of documents + final result = >[]; + final relatedList = data[key] as List; + for (final related in relatedList) { + String documentId = ''; + if (related is String) { + // related is a document ID + documentId = related; + } else if (related is Map) { + // related is a nested document + documentId = (related['\$id'] ?? ID.unique()) as String; + await upsert(model: nestedModel, key: documentId, data: related); + } + result.add({ + '\$databaseId': databaseId, + '\$collectionId': relatedCollection, + '\$id': documentId + }); + } + data[key] = result; + } + } + } + + final result = await _db.transaction((txn) async { + final store = getModelStore(model); + + final recordRef = store.record(key); + final oldData = await recordRef.get(txn); + final oldSize = oldData != null ? _cacheSize.encode(oldData).length : 0; + + final result = await recordRef.put(txn, data, merge: true); + + final newSize = _cacheSize.encode(result).length; + final change = newSize - oldSize; + + await _cacheSize.applyChange(txn, change); + return result; + }); await _accessedAt.update(model: model, keys: [key]); return result; } @@ -167,16 +394,19 @@ class ModelData { Future delete({required String model, required String key}) async { final store = getModelStore(model); RecordSnapshot>? record; + final recordRef = store.record(key); - record = await store.record(key).getSnapshot(_db); + record = await recordRef.getSnapshot(_db); if (record == null) { return; } - _cacheSize.update(oldData: record.value); - - await record.ref.delete(_db); + await _db.transaction((txn) async { + final oldSize = _cacheSize.encode(record!.value).length; + await _cacheSize.applyChange(txn, oldSize * -1); + await record.ref.delete(_db); + }); await _accessedAt.delete(model: model, key: record.key); } diff --git a/lib/src/offline_db_io.dart b/lib/src/offline_db_io.dart index 61b3642..d6cf548 100644 --- a/lib/src/offline_db_io.dart +++ b/lib/src/offline_db_io.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:sembast/sembast.dart'; import 'package:sembast_sqflite/sembast_sqflite.dart'; import 'package:sqflite/sqflite.dart' as sqflite; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart' hide Database; class OfflineDatabase { static final OfflineDatabase instance = OfflineDatabase._internal();