easy_rest_sync 0.0.1
easy_rest_sync: ^0.0.1 copied to clipboard
An offline-first REST API synchronization package for Flutter with hidden local storage, reactive data, outbox queue, retry handling, cursor-based sync, and conflict management.
๐ easy_rest_sync #
A lightweight, beginner-friendly Flutter package for building offline-first applications that synchronize local data with a server through REST APIs.
This package is designed to make REST-based data synchronization simple. Your application communicates only with the clean EasyRestSync API, while local persistence, pending operations, retry handling, cursor tracking, and synchronization are managed internally.
Your backend must provide compatible push and pull REST endpoints.
โ Summary of Everything Included in This Package #
- โ๏ธ Offline-first local data storage
- โ๏ธ REST API based push and pull synchronization
- โ๏ธ Automatic local create and update handling
- โ๏ธ Offline delete and server delete synchronization
- โ๏ธ Pending operation queue
- โ๏ธ Automatic or manual synchronization
- โ๏ธ Cursor-based incremental data pulling
- โ๏ธ Batch push and pull support
- โ๏ธ Server-wins and client-wins conflict strategies
- โ๏ธ Failed operation retry support
- โ๏ธ Reactive list stream for Flutter UI
- โ๏ธ Single-record and list queries
- โ๏ธ Local filtering, sorting, and pagination
- โ๏ธ Bearer token provider support
- โ๏ธ Custom request headers
- โ๏ธ Multiple entity support
- โ๏ธ Sync status stream
- โ๏ธ Full Flutter example app
- โ๏ธ Pub.dev-ready formatting
โญ Features #
- ๐ฑ Read and write data without an internet connection
- ๐ Synchronize local changes with REST APIs
- ๐ฅ Pull only new server changes using a cursor
- ๐ค Push pending local operations in batches
- ๐พ Internal local database management
- ๐ต๏ธ No direct local database code required in the application
- โก Reactive
watchList()support - ๐ Filter and sort local records
- ๐ Local pagination support
- ๐งฉ Register multiple model types as sync entities
- ๐ Dynamic authentication token support
- ๐งฏ Retry failed operations
- โ๏ธ Configurable conflict handling
- ๐ Synchronization result and status reporting
- ๐งช Complete example application included
๐ How It Works #
Flutter Application
โ
EasyRestSync Public API
โ
Internal Local Storage
โ
Pending Operation Queue
โ
REST Push / Pull API
โ
Server Database
The application always reads from and writes to the package API.
UI โ EasyRestSync โ Local Data
When synchronization runs:
Pending Local Changes โ Push API โ Server
Server Changes โ Pull API โ Local Data โ UI
Because the UI reads local data, previously synchronized records remain available when the device is offline.
โ ๏ธ Important Backend Requirement #
easy_rest_sync is a client-side synchronization engine. It does not automatically create your backend endpoints or database change log.
Your backend must provide:
- A push endpoint for receiving local create, update, and delete operations
- A pull endpoint for returning server-side changes after a cursor
- A unique operation result for every pushed operation
- A server version for conflict detection
- An incremental cursor for pull synchronization
- Authentication and authorization suitable for your application
The backend can use any database or server technology, including:
- Oracle Database and ORDS
- PostgreSQL
- MySQL
- SQL Server
- MongoDB
- Node.js
- Laravel
- Django
- ASP.NET
- Spring Boot
๐ฆ Installation #
Add the package to your pubspec.yaml:
dependencies:
easy_rest_sync: ^0.0.1
Then run:
flutter pub get
For local package development:
dependencies:
easy_rest_sync:
path: ../
๐ Basic Initialization #
Import the package:
import 'package:easy_rest_sync/easy_rest_sync.dart';
import 'package:flutter/material.dart';
Create and initialize EasyRestSync before runApp():
late final EasyRestSync easySync;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
easySync = EasyRestSync(
config: EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
pushEndpoint: '/sync/push',
pullEndpoint: '/sync/pull',
scope: 'user_506618',
autoSyncOnWrite: true,
syncOnStart: true,
tokenProvider: () async {
return 'YOUR_ACCESS_TOKEN';
},
conflictStrategy: EasySyncConflictStrategy.serverWins,
),
);
easySync.registerEntity<Product>(
EasySyncEntity<Product>(
name: 'products',
idSelector: (product) => product.id,
toJson: (product) => product.toJson(),
fromJson: Product.fromJson,
),
);
await easySync.initialize();
runApp(const MyApp());
}
Register every entity before calling methods such as
save(),get(),getList(), orwatchList().
๐งฑ Create a Model #
Every sync entity needs:
- A unique string ID
- A
toJson()method - A
fromJson()factory
class Product {
final String id;
final String itemCode;
final String itemName;
final double quantity;
const Product({
required this.id,
required this.itemCode,
required this.itemName,
required this.quantity,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id']?.toString() ?? '',
itemCode: json['itemCode']?.toString() ?? '',
itemName: json['itemName']?.toString() ?? '',
quantity:
double.tryParse(json['quantity']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'itemCode': itemCode,
'itemName': itemName,
'quantity': quantity,
};
}
}
๐งฉ Register an Entity #
easySync.registerEntity<Product>(
EasySyncEntity<Product>(
name: 'products',
idSelector: (product) => product.id,
toJson: (product) => product.toJson(),
fromJson: Product.fromJson,
),
);
The entity name must match the entity value used by your push and pull APIs.
๐พ Save or Update Data #
The same save() method handles both create and update operations.
final product = Product(
id: '101',
itemCode: '34494',
itemName: 'Plastic Chair',
quantity: 10,
);
await easySync.save<Product>(
entity: 'products',
data: product,
);
The package will:
Save the record locally
โ
Create or update a pending sync operation
โ
Synchronize automatically when enabled
To control synchronization for a specific save:
await easySync.save<Product>(
entity: 'products',
data: product,
syncNow: false,
);
๐ Get a Single Record #
final product = await easySync.get<Product>(
entity: 'products',
id: '101',
);
if (product != null) {
debugPrint(product.itemName);
}
๐ Get All Records #
final products = await easySync.getList<Product>(
entity: 'products',
);
โก Watch a Reactive List #
watchList() emits a new list whenever local records for that entity change.
StreamBuilder<List<Product>>(
stream: easySync.watchList<Product>(
entity: 'products',
),
builder: (context, snapshot) {
final products = snapshot.data ?? <Product>[];
if (products.isEmpty) {
return const Center(
child: Text('No product found.'),
);
}
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.itemName),
subtitle: Text(product.itemCode),
trailing: Text(product.quantity.toString()),
);
},
);
},
);
The application does not need to import or access the package's internal database.
๐ Filter Local Data #
final availableProducts = await easySync.getList<Product>(
entity: 'products',
where: (product) => product.quantity > 0,
);
Reactive filtering:
final stream = easySync.watchList<Product>(
entity: 'products',
where: (product) => product.itemCode.startsWith('34'),
);
โ๏ธ Sort Local Data #
final products = await easySync.getList<Product>(
entity: 'products',
sort: (a, b) => a.itemName.compareTo(b.itemName),
);
Reactive sorting:
final stream = easySync.watchList<Product>(
entity: 'products',
sort: (a, b) => b.quantity.compareTo(a.quantity),
);
๐ Local Pagination #
Both page and pageSize must be greater than zero.
final firstPage = await easySync.getList<Product>(
entity: 'products',
page: 1,
pageSize: 20,
);
Reactive pagination:
final stream = easySync.watchList<Product>(
entity: 'products',
page: 1,
pageSize: 20,
);
Filtering and sorting are applied before local pagination.
๐ Delete Data #
await easySync.delete(
entity: 'products',
id: '101',
);
To delete locally without starting immediate synchronization:
await easySync.delete(
entity: 'products',
id: '101',
syncNow: false,
);
The package hides deleted records from get() and getList() while keeping the required delete operation pending until it is accepted by the server.
๐ Manual Synchronization #
final result = await easySync.sync();
if (result.isSuccess) {
debugPrint('Pushed: ${result.pushedCount}');
debugPrint('Pulled: ${result.pulledCount}');
debugPrint('Conflicts: ${result.conflictCount}');
debugPrint('Pending: ${result.pendingCount}');
} else {
debugPrint('Sync failed: ${result.message}');
}
Calling sync() while another synchronization is active returns the same active synchronization result instead of starting a duplicate process.
๐ Listen to Sync Status #
StreamBuilder<EasySyncStatus>(
stream: easySync.statusStream,
initialData: easySync.status,
builder: (context, snapshot) {
final status = snapshot.data ?? EasySyncStatus.idle;
return Text(status.name);
},
);
Available statuses:
EasySyncStatus.uninitialized
EasySyncStatus.idle
EasySyncStatus.pushing
EasySyncStatus.pulling
EasySyncStatus.completed
EasySyncStatus.offline
EasySyncStatus.failed
๐ค Automatic Sync on Save #
Enable automatic synchronization after local writes:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
autoSyncOnWrite: true,
)
Disable it when you want only manual synchronization:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
autoSyncOnWrite: false,
)
A method-level syncNow value overrides autoSyncOnWrite for that operation.
โถ๏ธ Sync on Application Start #
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
syncOnStart: true,
)
When enabled, synchronization is scheduled after package initialization.
๐ Authentication Token #
Use tokenProvider to provide the latest access token for every request:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
tokenProvider: () async {
return await authRepository.getAccessToken();
},
)
When a non-empty token is returned, the package automatically sends:
Authorization: Bearer YOUR_ACCESS_TOKEN
Do not hardcode production secrets in the application.
๐งพ Custom Request Headers #
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
headers: const <String, dynamic>{
'X-App-Version': '1.0.0',
'X-Platform': 'mobile',
},
)
The token provider will add Authorization when that header is not already provided.
๐ค Client ID and Scope #
A client ID identifies the current installation or sync client.
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
clientId: 'device-506618-01',
)
When clientId is not provided, the package creates and stores one automatically.
Use scope to isolate data, for example by user, company, organization, or workspace:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
scope: 'user_506618',
)
The selected scope is included in push and pull requests.
โ๏ธ Conflict Strategy #
Server Wins #
conflictStrategy: EasySyncConflictStrategy.serverWins,
When a conflict occurs, the server version replaces the conflicting local version. A conflict push response must include serverData unless the server record was deleted.
Client Wins #
conflictStrategy: EasySyncConflictStrategy.clientWins,
The local operation is rebased using the server version and remains pending for another push attempt.
The server must still validate permissions and business rules. Client-wins does not bypass backend validation.
โป๏ธ Pending and Failed Operations #
Get the current pending operation count:
final count = await easySync.pendingCount();
Reset failed operations to pending:
final retried = await easySync.retryFailed();
debugPrint('$retried failed operations were queued again.');
Operations become failed after reaching maxRetries.
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
maxRetries: 5,
)
๐งญ Read the Last Cursor #
final cursor = await easySync.getLastCursor();
debugPrint('Last cursor: $cursor');
Set a custom initial cursor:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
initialCursor: '0',
)
๐ Read the Last Sync Time #
final lastSyncTime = await easySync.getLastSyncTime();
debugPrint('Last sync: $lastSyncTime');
The value is null until a complete synchronization succeeds.
โ๏ธ Complete Configuration Example #
final easySync = EasyRestSync(
config: EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
pushEndpoint: '/sync/push',
pullEndpoint: '/sync/pull',
databaseName: 'my_application_sync',
scope: 'user_506618',
clientId: 'device-506618-01',
batchSize: 100,
maxRetries: 5,
maxPullPages: 100,
initialCursor: '0',
autoSyncOnWrite: true,
syncOnStart: true,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
headers: const <String, dynamic>{
'X-App-Name': 'Express ERP',
},
tokenProvider: () async {
return await authRepository.getAccessToken();
},
conflictStrategy: EasySyncConflictStrategy.serverWins,
),
);
โ๏ธ Push API Contract #
Default endpoint:
POST /sync/push
Content-Type: application/json
Request body:
{
"clientId": "device-506618-01",
"scope": "user_506618",
"operations": [
{
"operationId": "630d23c0-f7bb-42f9-85dd-fac2fac2f404",
"entity": "products",
"recordId": "101",
"type": "create",
"data": {
"id": "101",
"itemCode": "34494",
"itemName": "Plastic Chair",
"quantity": 10
},
"baseVersion": 0,
"createdAt": "2026-07-16T04:00:00.000Z"
}
]
}
Supported operation types:
create
update
delete
Each operation should be processed idempotently using operationId.
โ Successful Push Response #
The response must contain a result for every operation:
{
"results": [
{
"operationId": "630d23c0-f7bb-42f9-85dd-fac2fac2f404",
"status": "applied",
"serverVersion": 1,
"serverData": {
"id": "101",
"itemCode": "34494",
"itemName": "Plastic Chair",
"quantity": 10
}
}
]
}
Accepted success status values:
applied
success
completed
For a successful delete, the server may return:
{
"results": [
{
"operationId": "operation-uuid",
"status": "applied",
"serverVersion": 3,
"serverDeleted": true
}
]
}
โ ๏ธ Push Conflict Response #
{
"results": [
{
"operationId": "operation-uuid",
"status": "conflict",
"serverVersion": 5,
"serverData": {
"id": "101",
"itemCode": "34494",
"itemName": "Server Product Name",
"quantity": 20
}
}
]
}
When the server record was deleted:
{
"results": [
{
"operationId": "operation-uuid",
"status": "conflict",
"serverVersion": 5,
"serverDeleted": true
}
]
}
๐ฅ Pull API Contract #
Default endpoint:
GET /sync/pull?cursor=100&limit=100&scope=user_506618&clientId=device-506618-01
Response:
{
"changes": [
{
"cursor": "101",
"entity": "products",
"recordId": "101",
"type": "update",
"serverVersion": 2,
"updatedAt": "2026-07-16T04:10:00.000Z",
"data": {
"id": "101",
"itemCode": "34494",
"itemName": "Plastic Chair Updated",
"quantity": 20
}
}
],
"nextCursor": "101",
"hasMore": false
}
A non-delete change requires a data object.
๐ Pull Delete Change #
{
"changes": [
{
"cursor": "102",
"entity": "products",
"recordId": "101",
"type": "delete",
"serverVersion": 3,
"updatedAt": "2026-07-16T04:12:00.000Z"
}
],
"nextCursor": "102",
"hasMore": false
}
๐ Multiple Pull Pages #
When more changes are available, return a non-empty change page with hasMore: true:
{
"changes": [
{
"cursor": "200",
"entity": "products",
"recordId": "200",
"type": "update",
"serverVersion": 4,
"data": {
"id": "200",
"itemCode": "50001",
"itemName": "Another Product",
"quantity": 12
}
}
],
"nextCursor": "200",
"hasMore": true
}
The next response must advance nextCursor. Return hasMore: false on the final page. The package stops after maxPullPages to protect the application from an endless server loop.
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
maxPullPages: 100,
)
๐งช Complete Usage Example #
import 'package:easy_rest_sync/easy_rest_sync.dart';
import 'package:flutter/material.dart';
late final EasyRestSync easySync;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
easySync = EasyRestSync(
config: EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
scope: 'current_user',
autoSyncOnWrite: false,
syncOnStart: false,
tokenProvider: () async => null,
),
);
easySync.registerEntity<Product>(
EasySyncEntity<Product>(
name: 'products',
idSelector: (product) => product.id,
toJson: (product) => product.toJson(),
fromJson: Product.fromJson,
),
);
await easySync.initialize();
runApp(const MyApp());
}
class Product {
final String id;
final String name;
final double quantity;
const Product({
required this.id,
required this.name,
required this.quantity,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
quantity:
double.tryParse(json['quantity']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'name': name,
'quantity': quantity,
};
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Easy REST Sync'),
actions: <Widget>[
IconButton(
onPressed: () async {
await easySync.sync();
},
icon: const Icon(Icons.sync),
),
],
),
body: StreamBuilder<List<Product>>(
stream: easySync.watchList<Product>(
entity: 'products',
),
builder: (context, snapshot) {
final products = snapshot.data ?? <Product>[];
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
trailing: Text(product.quantity.toString()),
onLongPress: () async {
await easySync.delete(
entity: 'products',
id: product.id,
);
},
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final product = Product(
id: DateTime.now().microsecondsSinceEpoch.toString(),
name: 'New Product',
quantity: 1,
);
await easySync.save<Product>(
entity: 'products',
data: product,
);
},
child: const Icon(Icons.add),
),
),
);
}
}
โถ๏ธ Example App Run Instructions #
Create an example application from the package root:
flutter create example
Add the local package dependency to example/pubspec.yaml:
dependencies:
flutter:
sdk: flutter
easy_rest_sync:
path: ../
Go to the example folder:
cd example
Then run:
flutter clean
flutter pub get
flutter run
๐งน Dispose Resources #
Call dispose() when the sync instance is no longer needed:
await easySync.dispose();
This closes the sync status stream and the internally created network client.
โ Common Issues #
Entity is not registered #
Error:
Entity "products" is not registered. Call registerEntity() first.
Fix:
easySync.registerEntity<Product>(
EasySyncEntity<Product>(
name: 'products',
idSelector: (product) => product.id,
toJson: (product) => product.toJson(),
fromJson: Product.fromJson,
),
);
Package is not initialized #
Error:
Call EasyRestSync.initialize() before using the package.
Fix:
await easySync.initialize();
Push response does not contain results #
The push API must return:
{
"results": []
}
It must also return one result for every submitted operationId.
Pull response does not contain changes #
The pull API must return:
{
"changes": [],
"nextCursor": "0",
"hasMore": false
}
Sync status becomes offline #
The package reports EasySyncStatus.offline for connection errors and network timeouts. Local reads and writes can still continue, and pending operations can be synchronized later.
Failed operations are not being pushed #
Operations that reached maxRetries have the failed state. Reset them:
await easySync.retryFailed();
await easySync.sync();
Pagination throws an ArgumentError #
Provide both page and pageSize, and ensure both are greater than zero:
await easySync.getList<Product>(
entity: 'products',
page: 1,
pageSize: 20,
);
๐ API Overview #
EasyRestSync(
config: EasyRestSyncConfig(...),
);
easySync.registerEntity<Product>(...);
await easySync.initialize();
await easySync.save<Product>(
entity: 'products',
data: product,
);
await easySync.get<Product>(
entity: 'products',
id: '101',
);
await easySync.getList<Product>(
entity: 'products',
);
easySync.watchList<Product>(
entity: 'products',
);
await easySync.delete(
entity: 'products',
id: '101',
);
await easySync.sync();
await easySync.pendingCount();
await easySync.retryFailed();
await easySync.getLastCursor();
await easySync.getLastSyncTime();
await easySync.dispose();
๐ Security Best Practices #
- Always use HTTPS in production
- Validate the authenticated user on every push and pull request
- Never trust
scope,entity,recordId, or client-provided data without validation - Verify that the user can access every requested record
- Process
operationIdidempotently to prevent duplicate writes - Use server-side version checks for updates and deletes
- Do not store database credentials or backend secrets in the Flutter app
- Return only data belonging to the authenticated user's scope
- Apply request size and batch limits on the server
- Log rejected and conflicting operations for debugging
๐ Current Limitations #
- The backend push and pull endpoints must be implemented separately
- File and image synchronization are not included
- Field-level merge conflicts are not included
- Local filters and pagination run after loading records for the selected entity
- Background scheduling must be added by the application when required
- Custom conflict resolvers are not included in the current version
๐ License #
MIT License
๐จโ๐ป Author #
Developed by Nafim Ahmed.