netcoresync_moor 1.0.2 netcoresync_moor: ^1.0.2 copied to clipboard
A database synchronization framework where each client's local offline database can be synchronized on-demand via network into a single centralized database hosted on a server.
netcoresync_moor #
Client | Client Generator | Server |
---|---|---|
A database synchronization framework where each client's local offline database (on each client's multiple devices) can be synchronized on-demand via network into a single centralized database hosted on a server. Data which are stored locally within each device of a single client can all be synchronized after each device have successfully performed the synchronization operation. This is the Flutter version of the original NETCoreSync framework.
Features #
- Supports synchronizing offline local database for each user with multiple devices. A single user is identified with a unique "syncId", which can have one or more databases on each separate devices on all platforms (android, ios, macos, windows, linux, and web).
- Each user's "syncId" can be linked with several different users' "syncId" (known as "linkedSyncIds") to allow that user to read and modify the linked user's data, and those changes can still be synchronized back to each linked user's databases.
Requirements #
- This framework is built on top of Flutter's Moor library, so it is required to use Moor as the database API in the project.
- There's also a separate package called netcoresync_moor_generator that is required to be listed as the project's
dev_dependencies
in thepubspec.yaml
. This package is to generate the needed source code during development time in the project. - The single centralized database hosted on a server can be any kind of relational database (RDBMS such as PostgreSQL, MySQL, SQL Server, etc.), and it is controlled by a separate server application. The server application needs to be written in Microsoft .NET 5.0, and it will use a server component called NETCoreSyncServer, which is an ASP .NET Core Middleware component that use WebSockets for its network communication with the Flutter clients.
- Not like other synchronization frameworks, this framework doesn't use tracking tables, tombstone tables, and triggers. So the tables in the database needs to add some additional columns to indicate certain states for the synchronization purposes, and the standard Moor's function calls for data insert / update / delete must be converted into this framework ones.
- This framework requires that all of your data in your database to have a unique primary key. The Moor table's primary key is expected to be a
TextColumn
type and should contain unique Uuid values (probably generated from theuuid
package). - The database design must use the Soft Delete approach when deleting record from tables, means that for any delete operation, the data is not physically deleted from the table, but only flag the record with a boolean column that indicates whether this record is already deleted or not.
How It Works #
To know how the synchronization works in detail, there is a special in-depth explanation page that covers the step-by-step logic of the synchronization, along with some activity simulation examples in this page here. These simulation steps are also already made as a unit test in the netcoresync_moor
package here (in a test
section called SyncSession Synchronize) to ensure the synchronization logic is always work as intended.
Example #
Check the built-in example on the Github repository:
- The client project (called
clientsample
) here is a Flutter project that usesmoor
(and it's generator),netcoresync_moor
andnetcoresync_moor_generator
packages. This client project can run on all platforms (android, ios, macos, windows, linux, and web). This client project has a database (SQLite from Moor) with tables that can be synchronized with the server-side database. - The server project (called
WebSample
) here is a Microsoft .NET 5.0 ASP .NET Core project, with PostgreSQL database controlled by Entity Framework Core library. The synchronization request from clients is handled by theNETCoreSyncServer
middleware component that is inserted inside the server project's request pipeline. - Each client's database table has a corresponding table on the server side's database. For example, the client
Persons
table is synchronized into the serverSyncPerson
table, etc. These tables also added with special columns, which explained in detail in the usage section below. - After each client inserts or modify any local data, client can invoke the synchronization function on-demand to start synchronizing its local data with the server's database.
Usage #
This section shows examples in detail for all required tasks, both in the client and server projects. For clarity, the guide jumps back and forth between client and server's code in chronological order to illustrate relationship between them.
All of the usage explanation below are demonstrated correctly in the built-in Example.
- Prerequisites
- Dependencies Registration
- Client Side Data Annotation
- Server Side Data Annotation
- Client Side Code Generation
- Client Side Initialization
- Client Side Moor Code Adaptation
- Server Side SyncEngine Implementation
- Server Side Middleware Configuration
- Client Side Synchronization
Prerequisites #
- The client side framework expects that a Flutter client project is already available and working, and it is already uses the Moor package, and it has correctly implements the standard usage of Moor first, such as creating Dart table classes, and then generating the Moor's code, etc. Also the database operations should be tested first, make sure that the Flutter project can already insert / update / delete data into its SQLite database using Moor functions. The Moor's guide can be read here. The following tasks will add the framework functionalities on top of this Flutter client project.
- The server side framework expects that a .NET 5.0 ASP .NET Core Web project is already available and working, and any choice of database should also be available and can be manipulated by the web project (the web project can insert / update / delete into the database). The following tasks assume that the web project uses the Entity Framework Core for its database API and will add the framework functionalities on top of this web project. Adapt accordingly for other database API.
Dependencies Registration #
Client Side Dependencies
The following shows the minimum dependendencies that are required for using netcoresync_moor
, netcoresync_moor_generator
, and moor
(and its generator) in the client project's pubspec.yaml
:
dependencies:
moor: ^4.4.1
sqlite3_flutter_libs: ^0.5.0
netcoresync_moor: ^1.0.0
dev_dependencies:
moor_generator: ^4.4.1
build_runner: ^2.1.1
netcoresync_moor_generator: ^1.0.0
For every dart files in the client project that requires to reference the netcoresync_moor
package, add the import
directive on top of the file:
import 'package:netcoresync_moor/netcoresync_moor.dart';
Server Side Dependencies
The following shows the required depencencies for using the NETCoreSyncServer
middleware in the server project's .csproj
:
<ItemGroup>
<PackageReference Include="NETCoreSyncServer" Version="1.0.0" />
</ItemGroup>
For every C# files in the server project that requires to reference the NETCoreSync
classes, add the using
directive on top of the file:
using NETCoreSyncServer;
Client Side Data Annotation #
Moor Data Classes
For any Moor tables that needs to be synchronized, the data classes needs to be annotated with @NetCoreSyncTable
. For example:
@NetCoreSyncTable
class Persons extends Table {
// The primary key uses uuid package to generate unique key
TextColumn get id => text().withLength(max: 36).clientDefault(() => Uuid().v4())();
// ... other fields here ...
// synchronization fields
TextColumn get syncId => text().withLength(max: 36).withDefault(Constant(""))();
TextColumn get knowledgeId => text().withLength(max: 36).withDefault(Constant(""))();
BoolColumn get synced => boolean().withDefault(const Constant(false)();
BoolColumn get deleted => boolean().withDefault(const Constant(false)();
@override
Set<Column> get primaryKey => { id };
}
The required synchronization fields that needs to be present on each table are:
id
: a primary key field that is unique.syncId
: a unique value that identifies a single user.knowledgeId
: a unique value that identifies a device.synced
: an indicator to detect whether this particular row is already synchronized or not.deleted
: an indicator to detect whether this particular row is already deleted or not.
These synchronization field values should not be changed in the client code manually, and will be handled by the framework during database operations and synchronization.
If some of the synchronization field names have conflict with the existing table column names, the names can be overriden in the @NETCoreSyncTable
's constructor like the following:
@NetCoreSyncTable(
idFieldName: "pk",
syncIdFieldName: "syncSyncId",
knowledgeIdFieldName: "syncKnowledgeId",
syncedFieldName: "syncSynced",
deletedFieldName: "syncDeleted",
)
class Area extends Table {
TextColumn get pk => text().withLength(max: 36).clientDefault(() => Uuid().v4())();
// ... other fields here ...
// synchronization fields
TextColumn get syncSyncId => text().withLength(max: 36).withDefault(Constant(""))();
TextColumn get syncKnowledgeId => text().withLength(max: 36).withDefault(Constant(""))();
BoolColumn get syncSynced => boolean().withDefault(const Constant(false)();
BoolColumn get syncDeleted => boolean().withDefault(const Constant(false)();
@override
Set<Column> get primaryKey => { id };
}
Moor Custom Row Classes
In case the client project uses Moor's @UseRowClass
, the @NETCoreSyncTable
annotation still should be applied:
@NetCoreSyncTable
@UseRowClass(CustomObject, constructor: "fromDb")
class CustomObjects extends Table {
// other fields and synchronization fields are here
}
There are several restrictions for using Custom Row Class:
-
The custom class fields should be made mutable, means that each of its field values can be assigned independently during run-time by the framework.
-
The custom class should implement a factory method called
fromJson()
and an instance method calledtoJson()
that serialize and deserialize JSON objects. For example:factory CustomObject.fromJson(Map<String, dynamic> json) { CustomObject customObject = CustomObject(); // Write the implementation here return customObject; } Map<String, dynamic> toJson() { return <String, dynamic>{ // Write implementation here }; }
-
The custom class must also implement an instance method called
toCompanion()
which returns its Moor'sCompanion
class. This is required by the framework for its internal implementation later during updates. This should be implemented exactly like Moor does, by returning an instance of its Companion version, where it is constructed by passing all of your Row Class fields values, and each of those fields values is wrapped with theValue()
class. Take a look on how Moor implement it for your tables thatextends DataClass
in the generated*.g.dart
file, especially when handling thenullToAbsent
argument, where it is expected to useValue.absent()
for your nullable fields if thenullToAbsent
parameter is set totrue
. For example:CustomObjectsCompanion toCompanion(bool nullToAbsent) { return CustomObjectsCompanion( id: Value(id), fieldStringNullable: nullToAbsent && fieldStringNullable == null ? Value.absent() : Value(fieldStringNullable), fieldIntNullable: nullToAbsent && fieldIntNullable == null ? Value.absent() : Value(fieldIntNullable), syncId: Value(syncId), knowledgeId: Value(knowledgeId), synced: Value(synced), deleted: Value(deleted), ); }
If your data classes are generated by Moor (not using
@UseRowClass
), it is expected that they already have these functions (toJson()
,fromJson()
, andtoCompanion()
), so modification is not necessary for the standard Moor data classes.
Server Side Data Annotation #
SyncTable Annotation
Every synchronized client table must have its counterpart in the server project. The following shows an example of the client's Person
counterpart called SyncPerson
class on the server:
[SyncTable("Person", order: 2)]
public class SyncPerson
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
[SyncProperty(SyncPropertyAttribute.PropertyIndicatorEnum.ID)]
public Guid ID { get; set; }
// ... other fields here ...
// synchronization fields
[SyncProperty(SyncPropertyAttribute.PropertyIndicatorEnum.SyncID)]
public string SyncID { get; set; } = null!;
[SyncProperty(SyncPropertyAttribute.PropertyIndicatorEnum.KnowledgeID)]
public string KnowledgeID { get; set; } = null!;
[SyncProperty(SyncPropertyAttribute.PropertyIndicatorEnum.TimeStamp)]
public long TimeStamp { get; set; }
[SyncProperty(SyncPropertyAttribute.PropertyIndicatorEnum.Deleted)]
public bool Deleted { get; set; }
}
The SyncPerson
class in the example above is the counterpart of the client's Person
class. The SyncPerson
class is an Entity Framework Core model class. To indicate such relationship, the SyncPerson
class is annotated with the SyncTable
annotation with the following constructor parameters:
-
ClientClassName
: parameter that indicates the class name on the client project for this particular table (which is "Person" on the client). -
Order
: parameter that indicates the processing order for all synchronized tables. The order specified here should follow the relational table foreign key relationship, ordered from the most independent table to the most dependent table. This is to ensure that the referenced foreign key value that points to some master table is already processed earlier, so missing foreign key violation can be avoided during synchronization. For example, the following model classes have these relationships:[SyncTable(clientClassName: "Product", order: 1)] public class Product { [Key] [DatabaseGenerated(DatabaseGeneratedOption.None)] [SyncProperty(SyncPropertyAttribute.PropertyIndicatorEnum.ID)] public Guid ID { get; set; } public string Name { get; set; } = null!; [JsonIgnore] [ForeignKey("ProductID")] public ICollection<Order> Orders { get; set; } = null!; // ... synchronization fields here ... } [SyncTable(clientClassName: "Order", order: 2)] public class Order { [Key] [DatabaseGenerated(DatabaseGeneratedOption.None)] [SyncProperty(SyncPropertyAttribute.PropertyIndicatorEnum.ID)] public Guid ID { get; set; } public DateTime? OrderDate { get; set; } = null!; public decimal TotalPrice { get; set; } = 0; public Guid? ProductID { get; set; } [JsonIgnore] public Product? Product { get; set; } // ... synchronization fields here ... }
In the example above, the most independent table is the
Product
class, and theOrder
class is dependent on theProduct
class. Therefore, theSyncTable
'sOrder
is set to1
forProduct
, and2
forOrder
.Notice that in the example above, the
Product
navigation property:Orders
, and theOrder
navigation property:Product
, are marked with[JsonIgnore]
. This is necessary to avoid those navigation property values to be serialized to the client which can lead to unpredictable results during synchronization. These navigation properties are only useful for usage within the server project itself.
SyncProperty Annotation
Each server model class requires synchronization fields to be present, which are:
ID
: a primary key field that is unique. This field correlates with the client'sid
.SyncID
: a unique value that identifies a single user. This field correlates with the client'ssyncId
.KnowledgeId
: a unique value that identifies a device. This field correlates with the client'sknowledgeId
.TimeStamp
: the framework will increase this numeric value whenever this particular row is modified.deleted
: an indicator to detect whether this particular row is already deleted or not. This field correlates with the client'sdeleted
.
Use the SyncProperty
annotation to indicate which class properties belongs to which type of synchronization fields. In the example above, the ID
property is marked with [SyncProperty(SyncPropertyAttribute.PropertyIndicatorEnum.ID)]
annotation to indicate that this field is a unique primary key for synchronization. Also the SyncID
property is marked with [SyncProperty(SyncPropertyAttribute.PropertyIndicatorEnum.SyncID)]
to indicate that this field is the field to hold synchronization ID values, and so on. These synchronization field values should not be changed in the server code manually, and will be handled by the framework during database operations and synchronization.
Client Side Code Generation #
To generate the code for this framework, these steps should be performed first:
-
On the Moor's database class (the class with
@UseMoor
annotation), add theNetCoreSyncClient
and theNetCoreSyncClientUser
mixin into the class. For example:@UseMoor( tables: [ Areas, Persons, NetCoreSyncKnowledges, ], ) class Database extends _$Database with NetCoreSyncClient, NetCoreSyncClientUser { Database(QueryExecutor queryExecutor) : super(queryExecutor); @override int get schemaVersion => 1; }
-
Notice in the example above, aside from the registered
Areas
andPersons
data classes in the Moor'stables
, the built-inNetCoreSyncKnowledges
data class table from the client framework should also be included in thetables
list property of the@UseMoor
annotation. -
Before starting the code generation, the
moor_generator
have builder options as specified here. This framework expect that the Moor code generation should use the standard Moor builder options. The following is the restriction of how the Moor builder options should be to correctly work with this framework (where most of the options are standard):-
use_data_class_name_for_companions
should befalse
(default). -
data_class_to_companions
should betrue
(default).
-
-
Run the code generator (on the project's root folder in terminal):
flutter packages pub run build_runner build
The
netcoresync_moor
code will be generated along with Moor's generated code inside the Moor's standard generated file, the[yourdatabasefile].g.dart
.To generate builder code from a clean state:
flutter packages pub run build_runner clean flutter clean flutter pub get flutter packages pub run build_runner build --delete-conflicting-outputs
Client Side Initialization #
-
The framework should be initialized once during startup, and the required initialization function is already generated during client side code generation. After the Moor's database class has been instantiated, it should followed with the framework initialization call. The following shows example of initializing the framework:
void main() async { // This line instantiated the Moor's database class (the constructDatabase method // is only an example, you may have different method to instantiate the Moor's database) Database database = await constructDatabase(logStatements: false); // This line initialized the framework await database.netCoreSyncInitialize(); runApp(MyApp()); }
-
After it has been initialized, before doing any database operations, the framework should be activated with an active
syncId
first. This usually happens whenever a user is logged into the application:void userHasLoggedIn(String username) { // Get the database instance here, probably from a state management final database = getDatabaseInstance(); database.netCoreSyncSetSyncIdInfo(SyncIdInfo( syncId: userName, linkedSyncIds: [], )); }
The
netCoreSetSyncIdInfo
method call uses a class calledSyncIdInfo
for its parameter. TheSyncIdInfo
class purpose is to hold the activesyncId
information, along with thelinkedSyncIds
(an array of other user'ssyncId
s). In the example above, the application uses the passed-inusername
parameter as thesyncId
value for the logged-in user. ThesyncId
can be anything, as long as its uniqueness among users of the application can be guaranteed. ThelinkedSyncIds
in the example above is specified with an empty array, means that the logged-in user does not associate with other user'ssyncId
s. -
If the application supports the
linkedSyncIds
feature, then the logged in user can switch betweensyncId
s. For example:// In this example, a specific user with syncId = ABC has already logged-in, // and the user ABC is associated with the DEF and GHI accounts. // The netCoreSyncSetSyncIdInfo has already been called with parameter: // SyncIdInfo(syncId: 'ABC', linkedSyncIds: ['DEF', 'GHI']) void switchActiveSyncId(String selectedSyncId) { // Get the database instance here, probably from a state management final database = getDatabaseInstance(); // The parameter selectedSyncId has already been checked, with the possibility // of only between DEF or GHI for this particular user. database.netCoreSyncSetActiveSyncId(selectedSyncId); }
By switching to any of the
linkedSyncIds
using thenetCoreSyncSetActiveSyncId
method call, the current logged-insyncId
can read and modify the other user's data.
Client Side Moor Code Adaptation #
The existing Moor functions called by the application needs to be altered. Instead of calling the Moor standard functions to read and modify data, use the netcoresync_moor
functions to ensure the synchronization works correctly later. The following lists the changes that needs to be applied for each supported Moor function calls.
Category | Moor's Method | Framework's Method | Example |
---|---|---|---|
Generated Table Variable | persons |
syncPersons |
For a Persons data class, Moor will generate its corresponding table variable called persons . The framework will also generate its synchronized version called syncPersons (the variable name always begins with sync and camel-cased). Use the framework version instead. The framework version has already filtered the data with correct syncId and its related linkedSyncIds . |
Read Data | select(persons) |
syncSelect(syncPersons) |
Moor:await select(persons).get(); Framework: await syncSelect(syncPersons).get(); |
Read Data | selectOnly(persons) |
syncSelectOnly(syncPersons) |
Moor:await (selectOnly(persons)..addColumns([persons.name])).get(); Framework: await (syncSelectOnly(syncPersons)..addColumns([syncPersons.name])).get(); |
Read Data | join(others) |
syncJoin(syncOthers) |
Moor:await (select(persons).join([leftOuterJoin(others, others.id.equalsExp(persons.otherId))]).get(); Framework: await (syncSelect(syncPersons).syncJoin([leftOuterJoin(syncOthers, syncOthers.id.equalsExp(syncPersons.otherId))]).get(); |
Insert Data | into(persons).insert(data) |
syncInto(syncPersons).syncInsert(data) |
Moor:await into(persons).insert(data); Framework: await syncInto(syncPersons).syncInsert(data); |
Insert Data | into(persons).insertOnConflictUpdate(data) |
syncInto(syncPersons).syncInsertOnConflictUpdate(data) |
Moor:await into(persons).insertOnConflictUpdate(data); Framework: await syncInto(syncPersons).syncInsertOnConflictUpdate(data); |
Insert Data | Insert onConflict: DoUpdate(updateFunc) |
Insert onConflict: SyncDoUpdate(updateFunc) |
Moor:await into(persons).insert(data, onConflict: DoUpdate(updateFunc)); Framework: await syncInto(syncPersons).syncInsert(data, onConflict: SyncDoUpdate(updateFunc)); |
Insert Data | Insert onConflict: UpsertMultiple(func) |
Insert onConflict: SyncUpsertMultiple(func) |
Moor:await into(persons).insert(data, onConflict: UpsertMultiple(func)); Framework: await syncInto(syncPersons).syncInsert(data, onConflict: SyncUpsertMultiple(func)); |
Insert Data | into(persons).insertReturning(data) |
syncInto(syncPersons).syncInsertReturning(data) |
Moor:await into(persons).insertReturning(data); Framework: await syncInto(syncPersons).syncInsertReturning(data); |
Update Data | update(persons).replace(data) |
syncUpdate(syncPersons).syncReplace(data) |
Moor:await update(persons).replace(data); Framework: await syncUpdate(syncPersons).syncReplace(data); |
Update Data | update(persons).write(data) |
syncUpdate(syncPersons).syncWrite(data) |
Moor:await (update(persons)..whereSamePrimaryKey(data)).write(value); Framework: await (syncUpdate(syncPersons)..whereSamePrimaryKey(data)).syncWrite(value); |
Delete Data | delete(persons).go() |
syncDelete(syncPersons).go() |
Moor:await (delete(persons)..whereSamePrimaryKey(data)).go(); Framework: await (syncDelete(syncPersons)..whereSamePrimaryKey(data)).go(); |
Transactions | await database.transaction(() async {} |
No Change |
For other Moor's method calls that is not mentioned above, at the moment, they are not supported, such as (and perhaps not limited to):
- The batch method
- The InsertMode:
replace
andinsertOrReplace
, because they may physically delete an existing already-synchronized row.
Server Side SyncEngine Implementation #
The server side component (NETCoreSyncServer
) needs to have a custom class that is derived from its abstract SyncEngine
class. This way, the framework can be directed to communicate with any kind of database. The following shows an example of subclassing the SyncEngine
class (the databaseContext
variable is using the Entity Framework Core framework):
public class CustomSyncEngine : SyncEngine
{
private readonly DatabaseContext databaseContext;
public CustomSyncEngine(DatabaseContext databaseContext)
{
this.databaseContext = databaseContext;
}
override public long GetNextTimeStamp()
{
return databaseContext.GetNextTimeStamp();
}
override public IQueryable GetQueryable(Type type)
{
if (type == typeof(SyncArea)) return databaseContext.Areas.AsQueryable();
if (type == typeof(SyncPerson)) return databaseContext.Persons.AsQueryable();
throw new NotImplementedException();
}
override public Dictionary<string, string> ClientPropertyNameToServerPropertyName(Type type)
{
if (type == typeof(SyncPerson))
{
return new Dictionary<string, string>() { ["clientAreaId"] = "ServerAreaID" };
}
return base.ClientPropertyNameToServerPropertyName(type);
}
override public void Insert(Type type, dynamic serverData)
{
if (type == typeof(SyncArea)) databaseContext.Areas.Add(serverData);
else if (type == typeof(SyncPerson)) databaseContext.Persons.Add(serverData);
else throw new NotImplementedException();
databaseContext.SaveChanges();
}
override public void Update(Type type, dynamic serverData)
{
if (type == typeof(SyncArea)) databaseContext.Areas.Update(serverData);
else if (type == typeof(SyncPerson)) databaseContext.Persons.Update(serverData);
else throw new NotImplementedException();
databaseContext.SaveChanges();
}
}
The following lists all of the abstract ``SyncEnginemethods that needs to be implemented (and also
virtual` methods that can be overriden) in the custom class:
abstract public long GetNextTimeStamp()
: should return an increasinglong
value that should be consistent and never reset. Usually this is obtained from the server's clock, or a database query that returns the server's epoch millisecond.abstract public IQueryable GetQueryable(Type type)
: should return a LINQIQueryable
for the specifiedtype
parameter. Thetype
parameter value will be one of the model class types that is annotated withSyncTable
annotation.abstract public void Insert(Type type, dynamic serverData)
: should perform a database insert here. Thetype
parameter value will be one of the model class types that is annotated withSyncTable
annotation. TheserverData
will be the object with the type:type
that needs to be inserted into the database.abstract public void Update(Type type, dynamic serverData)
: same explanation as theInsert
method above, but this is for database update operations.virtual public Dictionary<string, string> ClientPropertyNameToServerPropertyName(Type type)
: this is an optional (virtual
) method that can be used to specify mapping between client field names and their corresponding server field names. If the specifiedtype
parameter needs to have field name conversion between client and server, then this method should return aDictionary
with the client field names that wants to be mapped in the dictionary keys, and the mapped server field names in the dictionary values. By default, this method will return an empty dictionary (no conversion).virtual public dynamic PopulateServerData(Type type, Dictionary<string, object?> clientData, dynamic? serverData)
: this is an optional method to be overriden, and for most cases, this should not be overriden at all. This method populates theserverData
object with theclientData
dictionary values. If incoming data is a new data, (where theserverData
is also null), then the framework will use the .NET Reflection'sActivator.CreateInstance()
with empty constructor (some ORM does not support creating data object with empty constructor, so this will be a good cause to override this method). Also, the default Moor's serialization forDateTime
value is into epoch millisecond (a number) which is already handled by this default implementation. After theserverData
is populated, it will be returned at the end of this method.virtual public void ModifySerializedServerData(Dictionary<string, object?> serializedServerData)
: this is an optional method to be overriden. When the server finished serializing its data into aDictionary
as specified in theserializedServerData
parameter, then this is the chance to change the serialization result before transmitted to the clients (if necessary) through the server's response.
Aside from the implementation of abstract
and virtual
methods, The SyncEngine
also provides a property called CustomInfo
, implemented as Dictionary<string, object?>
, which is the custom information that is passed in during the client netCoreSyncSynchronize()
method call. This property can then be inspected inside each implementation method, if necessary.
Server Side Middleware Configuration #
Middleware configuration takes place in the web project's Startup.cs
class.
Intercept Synchronization Request
To intercept synchronization request, use the SyncEvent
class to register a callback handler that is invoked whenever a client tries to perform synchronization. For example, this can force your users to upgrade their application first before continuing the synchronization process. Application features shall evolve over time along with its database, so the schema may also be changed. The server's database will always likely to represent the latest version, so forcing users to upgrade first is the correct move to do. User upgrades (using Moor's Migration techniques) should bring existing client databases to the latest changes, therefore it will be safe to continue the synchronization process with the server's database.
Scenarios for supporting backward-compatibility (support older database schemas) seems too complicated, and will likely require
NETCoreSyncServer
component to do complex work and deeper integration, so this is not supported for now.
To use the SyncEvent
class, the following illustrates its usage:
public void ConfigureServices(IServiceCollection services)
{
// ... some other code here ...
var moorMinimumSchemaVersion = Configuration.GetValue<int>("moorMinimumSchemaVersion");
SyncEvent syncEvent = new SyncEvent();
syncEvent.OnHandshake = (request) =>
{
if (request.SchemaVersion <= testMinimumSchemaVersion)
{
return "Please update your application first before performing synchronization";
}
return null;
};
// ... some other code here ...
}
The SyncEvent.OnHandshake
callback has to return either a null
value (to indicate that the connected client is allowed to proceed), or a string
value that explain the reason why the client is not allowed. This returned string
value will be treated as an errorMessage
which will be explained in the Synchronization Result Explanation section below.
The SyncEvent.OnHandshake
callback has the request
parameter, and the request
parameter carries these information:
SchemaVersion
: the Moor client database'sschemaVersion
of the connected client.SyncIdInfo
: thesyncId
and itslinkedSyncIds
of the connected client.CustomInfo
: aDictionary<string, object?>
that can contain custom information which can be passed from the client. This can be used to provide more information to the server that is not provided by the framework. Read aboutcustomInfo
usage in the Client Side Execute Synchronization section.
Register SyncEngine Implementation Class Service
After the custom class (the SyncEngine
implementation class) is ready, it needs to be registered as one of the ServiceCollection using the ASP .NET Core Dependency Injection method. The following shows an example of registering the CustomSyncEngine
class (which is the implementation class from SyncEngine
) inside the Startup
's ConfigureServices()
method:
public void ConfigureServices(IServiceCollection services)
{
// ... some other code here ...
services.AddScoped<SyncEngine, CustomSyncEngine>();
// ... some other code here ...
}
If using Entity Framework Core, your subclass of
SyncEngine
's service lifetime is supposed to follow your EF CoreAddDbContext()
's lifetime, therefore wheneverSyncEngine
subclass is instantiated inside the middleware, it will always have the same lifetime as the database. In the example above, the registration usesAddScoped()
, because the EF CoreAddDbContext()
by default also usesAddScoped()
. Also notice that the service type is registered asSyncEngine
abstract class type, while the implementation uses the subclass (CustomSyncEngine
) type.
Register NETCoreSyncServer Service
The following shows an example to register the NETCoreSyncServer
using the ASP .NET Core Dependency Injection method:
The
SyncEngine
implementation registration above must take place before theservices.AddNETCoreSyncServer()
below.
public void ConfigureServices(IServiceCollection services)
{
// ... some other code here ...
services.AddNETCoreSyncServer(syncEvent: syncEvent);
// ... some other code here ...
}
Register NETCoreSyncServer Middleware Pipeline
The following example shows how to activate the middleware in the pipeline:
The method call below should take place after the
app.UseRouting()
and before theapp.UseEndpoints()
.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ... some other code here ...
app.UseNETCoreSyncServer();
// ... some other code here ...
}
By default, the method call above will open a WebSocket listener on: /netcoresyncserver
path. So, if the web project is configured with https, run on localhost
, and listening on port 5001, the full url for the WebSocket will be: wss://localhost:5001/netcoresyncserver
. There is a NetCoreSyncServerOptions
class that can be instantiated to configure several parameters such as:
Path
: by default, the listening path for the WebSocket is/netcoresyncserver
KeepAliveIntervalInSeconds
: Increase the keep-alive interval if the network connection is not stable. The default value is120
seconds.SendReceiveBufferSizeInBytes
: The WebSocket buffer size in bytes for processing requests from clients and send responses. The default value is4096
bytes.
Then the NetCoreSyncOptions
instance can be passed as an argument for the app.UseNETCoreSyncServer()
to adjust its WebSocket endpoint behaviors.
Client Side Synchronization #
Initiate Synchronization Process
Synchronization process is always initiated by the clients. The following shows an example of client starts a synchronization process:
// SyncEvent can be used to show synchronization progress to users
SyncEvent syncEvent =
SyncEvent(progressEvent: (eventMessage, indeterminate, value) {
// Update visual indicators here using the provided parameters
});
// Get the database instance here, probably from a state management
final database = getDatabaseInstance();
final syncResult = await database.netCoreSyncSynchronize(
url: "wss://localhost:5001/netcoresyncserver",
syncEvent: syncEvent,
syncResultLogLevel: SyncResultLogLevel.fullData,
);
if (syncResult.errorMessage != null) {
// if there's an error happened, the errorMessage contains the String text of the error.
} else if (syncResult.error != null) {
// if there's an error happened, the error contains the Object (usually an Exception) of the error.
} else {
// The synchronization is finished and successfully executed without errors.
}
The netCoreSyncSynchronize()
method is the method to initiate synchronization process. The arguments are:
url
is theNETCoreSyncServer
WebSocket url, as explained in the Register NETCoreSyncServer Middleware Pipeline section.syncEvent
is theSyncEvent
object for monitoring the synchronization progress (optional). TheSyncEvent
class will be explained in the following sections.syncResultLogLevel
is anenum
to indicate the verbosity level of the logs inside the synchronization result. The verbosity levels are:countsOnly
,syncFieldsOnly
, andfullData
(this is the default). The verbosity level will be explained in the following sections.
Synchronization Progress Event
The SyncEvent
class provides information that can be used to display visual indicators for the synchronization progress:
eventMessage
is a default text message provided by the framework of each stages that happens in the synchronization.indeterminate
is abool
value that indicates whether the stage can determine a progress value or not. If it istrue
, then thevalue
parameter will contain a progress value, and a non-deterministic visual indicator such asCircleProgressIndicator
can be used. If it isfalse
, then a visual indicator such asLinearProgressIndicator
can be presented with thevalue
parameter as its current progression value.value
is adouble
value that indicates the current stage progression. Thevalue
parameter is always ranged from0.0
to1.0
, so it is already compatible to be used directly with common visual indicators. In the case of non-deterministic (indeterminate
is false), this value will always be zero.- To know the exact details of what the
eventMessage
will be, read the Synchronization Logs section.
Synchronization Result Explanation
The resulted value from the netCoreSyncSynchronize()
method call is a SyncResult
type, which has properties to indicate:
- Errors that happens during synchronization process (if any).
- The detailed process steps of the synchronization process.
Synchronization Errors
The SyncResult
type has the following properties to indicate errors:
errorMessage
: if an error happens during synchronization, this will be the text value of the error message.error
: if an error happens during synchronization, this will be theObject
(usually anException
) of the error.
The errorMessage
and the error
have these relationships:
errorMessage
will always have theString
representation (using.toString()
) of theerror
. For any type oferror
, the framework always try to use a "safe and generic"errorMessage
text, such as: "Error while connecting to server. Please check your network connection, or try again later.". This way, theerrorMessage
itself can be directly presented to the end-users. Nevertheless, in production, theerror
object should be checked thoroughly to detect whether it carries sensitive information to be presented to the end-users or not.- There's a possibility that the
error
object isnull
while theerrorMessage
still contains the error message text. This can be caused by thesyncEvent.OnHandshake()
handler in the server side (as explained in Intercept Synchronization Request) returns an error message.
For the error
object types, the following lists the class type possibilities provided by the framework to indicate various errors:
NetCoreSyncNotInitializedException
: The client code hasn't initialized (call thenetCoreSyncInitialize()
) yet.NetCoreSyncSyncIdInfoNotSetException
: The client code tries to do database operations without setting theSyncIdInfo
first.NetCoreSyncMustNotInsideTransactionException
: This exception is raised if the client code tries to do synchronization inside a Moor's database transaction. Running synchronization inside a transaction is not supported.NetCoreSyncTypeNotRegisteredException
: The framework detects a database operation performed by the client code that uses unregistered table types (the class type is not annotated with@NetCoreSyncTable
).NetCoreSyncSocketException
: This exception type will be raised for all errors related to WebSockets. For example, an error with this type is raised when the client code tries to performnetCoreSyncSynchronize()
repeatedly withoutawait
the results (which make the last active WebSocket still trying to connect to server).NetCoreSyncServerSyncIdInfoOverlappedException
: TheNETCoreSyncServer
server side framework regulates the client synchronization requests. It inspects theSyncIdInfo
client request, and deny the request with this exception type if theSyncIdInfo
information (thesyncId
and also itslinkedSyncIds
) overlaps with currently synchronizing clients. This is to ensure that database conflicts can be avoided for clients that have the samesyncId
(or overlappinglinkedSyncIds
).NetCoreSyncException
: the generic type of exception that is generated by the client framework that is not covered in the above explanation.NetCoreSyncServerException
: the generic type of exception that is generated by the server framework that is not covered in the above explanation.
Synchronization Logs
The SyncResult
type has a property called logs
, which is a list of structured log data (implemented as List<Map<String, dynamic>>
) that contains full information of the synchronization process steps from start to finish. The ordered stages of synchronization process that is reflected in the log list are:
-
connectRequest
: connection request initiated by the client.{ "action":"connectRequest", "data":{ "url":"wss://localhost:5001/netcoresyncserver" } }
- The
data.url
value is theNETCoreSyncServer
WebSocket url that is specified in thenetCoreSyncSynchronize()
call method. - If the
SyncEvent
'sprogressEvent
is configured, itseventMessage
will be "Connecting..." message.
- The
-
connectResponse
: connection response from server.{ "action":"connectResponse", "data":{ "connectionId":"08e63daa-ae3f-44ab-b033-1c102d74cd45" } }
- The
data.connectionId
value is a unique value obtained from the server middleware, which is generated for every client connection. In most cases this information is not necessary, it is only useful for debugging both client and server where this value correlates between them.
- The
-
handshakeRequest
: client is requesting to start the synchronization with the server.{ "action":"handshakeRequest", "data":{ "schemaVersion":1, "syncIdInfo":{ "syncId":"aaa", "linkedSyncIds":[] }, "customInfo":{ "a":"abc", "b":1000 } } }
- The
data
properties are the information that is sent by the client, and will be available on the server during the server'sSyncEvent.OnHandshake()
callback handler (therequest
parameter) as explained in the Intercept Synchronization Request. - If the
SyncEvent
'sprogressEvent
is configured, itseventMessage
will be "Acquiring access..." message.
- The
-
handshakeResponse
: response from server that allows client to continue the synchronization.{ "action":"handshakeResponse", "data":{ "orderedClassNames":[ "AreaData", "Person" ] } }
- The
data.orderedClassNames
are the client Moor's data class types that participate in the synchronization process, and ordered as dictated by the server'sSyncTable.order
annotation on each corresponding models.
- The
-
syncTableRequest
: client start sending data that is not synchronized yet to the server for eachorderedClassNames
. So according to thehandshakeResponse
example above, thesyncTableRequest
will be logged twice (one for theAreaData
class, and followed byPerson
class). The following shows an example forAreaData
'ssyncTableRequest
:{ "action":"syncTableRequest", "data":{ "className":"AreaData", "annotations":{ "idFieldName":"pk", "syncIdFieldName":"syncSyncId", "knowledgeIdFieldName":"syncKnowledgeId", "syncedFieldName":"syncSynced", "deletedFieldName":"syncDeleted", "columnFieldNames":[ "pk", "city", "district", "syncSyncId", "syncKnowledgeId", "syncSynced", "syncDeleted" ] }, "unsyncedRows":[ { "pk":"b13a0305-091e-4160-bf31-127f0edfb124", "city":"Tokyo", "district":"Shibuya", "syncSyncId":"aaa", "syncKnowledgeId":"1347ecf8-47f6-496f-a1c3-ed376b959044", "syncSynced":false, "syncDeleted":false } ], "knowledges":[ { "id":"1347ecf8-47f6-496f-a1c3-ed376b959044", "syncId":"aaa", "local":true, "lastTimeStamp":0, "meta":"" } ], "customInfo":{ "a":"abc", "b":1000 } } }
- The
data.annotations
contains the synchronization field names (along with other field names) from the client that is useful in server processing. - The
data.unsyncedRows
contains a list of rows for the particular table that is not synchronized yet with the server. ThesyncResultLogLevel
enumeration that is passed in as argument in thenetCoreSyncSynchronize()
method call will determine how verbose this list will be:fullData
(default): the rows will be logged as it is without stripping any information, which can result in large data if the number of unsynchronized rows is high.syncFieldsOnly
: the rows will be logged with its synchronization fields only, other fields will be stripped (omitted).countsOnly
: the rows list will be replaced by a single number that indicates the number of the actual rows.
- The
data.knowledges
is an internal information sent by the client framework to indicate the current knowledge of the client data. For more information aboutknowledges
, read the detailed explanation in the How It Works section. - The
data.customInfo
is the same as the custom information that is passed in thenetCoreSyncSynchronize()
method call. - If the
SyncEvent
'sprogressEvent
is configured, itseventMessage
will be "Synchronizing..." message, withindeterminate
is set tofalse
, and thevalue
contains adouble
value ranged from0.0
to1.0
that tells the progression value, starting from the first table to the last table.
- The
-
syncTableResponse
: the server respond back to the client's lastsyncTableRequest
, which means that for eachsyncTableRequest
there will be a correspondingsyncTableResponse
. The following shows an example ofsyncTableResponse
for the lastAreaData
'ssyncTableRequest
:{ "action":"syncTableResponse", "data":{ "className":"AreaData", "annotations":{ "timeStampFieldName":"timeStamp", "idFieldName":"id", "syncIdFieldName":"syncID", "knowledgeIdFieldName":"knowledgeID", "deletedFieldName":"deleted" }, "unsyncedRows":[], "knowledges":[ { "id":"1347ecf8-47f6-496f-a1c3-ed376b959044", "syncId":"aaa", "local":true, "lastTimeStamp":1629955542602, "meta":"" } ], "deletedIds":[], "logs":{ "inserts":[ { "id":"b13a0305-091e-4160-bf31-127f0edfb124", "city":"Tokyo", "district":"Shibuya", "syncID":"aaa", "knowledgeID":"1347ecf8-47f6-496f-a1c3-ed376b959044", "deleted":false, "timeStamp":1629955542602 } ], "updates":[], "deletes":[], "ignores":[] } } }
- The
data.annotations
contains the synchronization field names (along with other field names) from the server that is useful in client processing. - The
data.unsyncedRows
contains a list of rows for the particular table that the server knows, but they are not known by the client (for example, the data was created by the user on other devices, or created by other user that is allowed to modify the user's data throughlinkedSyncIds
), based on the lastknowledges
that the client sent earlier in thesyncTableRequest
. The verbosity level of these rows are also affected by thesyncResultLogLevel
enumeration, as explained in thesyncTableRequest
'sdata.unsyncedRows
explanation. - The
data.knowledges
is an internal information that the server replied back (based on the lastsyncTableRequest
'sdata.knowledges
) to the client. Esentially the rows are the same with the request, but theirlastTimeStamp
values are already up-to-date from the server. For more information aboutknowledges
, read the detailed explanation in the How It Works section. - The
data.deletedIds
is an internal information that contains which rows that have been deleted in the server, so the client framework can adjust its data accordingly. For more info about this, read the detailed explanation in the How It Works section. - The
data.logs
object is the result of server data modification for the rows that was sent in thesyncTableRequest
'sdata.unsyncedRows
earlier. It contains different lists for each different database operations, such asinserts
,updates
, anddeletes
(Theignores
list is used to handle an already deleted row is synchronized to the client. For more info about this, read the detailed explanation in the How It Works section). Each data inside each list is also affected by thesyncResultLogLevel
enumeration, as explained in thesyncTableRequest
'sdata.unsyncedRows
explanation.
- The
-
responseApplyRows
: This is the result of client data modification for the rows that was sent by the server in thesyncTableResponse
'sdata.unsyncedRows
earlier. The following shows an example ofresponseApplyRows
for the lastAreaData
'ssyncTableResponse
:{ "action":"responseApplyRows", "data":{ "className":"AreaData", "logs":{ "inserts":[], "updates":[], "deletes":[], "ignores":[], "deletedIds":[] } } }
- The
data.logs
object explanation is the same as thesyncTableResponse
'sdata.logs
object. The difference is, it contains thedeletedIds
list, which has the same explanation as thesyncTableResponse
'sdata.deletedIds
.
- The
-
closeRequest
: close request initiated by the client.{ "action":"closeRequest", "data":{} }
- If the
SyncEvent
'sprogressEvent
is configured, itseventMessage
will be "Disconnecting..." message.
- If the
-
closeResponse
: close response from server.{ "action":"closeResponse", "data":{} }