sqlite-replication
A Typescript module to replicate SQLite DB with server.
sqlite-replication
was designed for collaborative offline-first mobile app built with capacitor sqlite plugin.
- Master > Slaves design
- Conflicts resolution on server side
- Framework agnostic (VueJs, React...)
- Server protocol agnostic (Rest API, GraphQL, websocket...)
- Query builder agnostic (no limit for SQL queries)
- Also Works with jeep-sqlite
Inspired from
Installation
npm install sqlite-replication --save
Usage
const db = ...
const storage = new ReplicationSQLiteStorage(db);
const replicationService = new ReplicationService(storage,
{
collections: [
getDefaultCollectionOptions(db, 'users'),
{
name: 'todos',
batchSize: 99,
countDocumentsUpdatedAt: async (updatedAt: number) => { ... },
upsertAll: (documents: any[]) => { ... },
deleteAll: (documents: any[]) => { ...},
}
],
fetchPull: async (pullConfig: any) => (await api.post(`${URL_BASE}replicationPull`, pullConfig)).data,
fetchPush: async () => (await api.post(`${URL_BASE}replicationPush`, {})).data,
}
);
await replicationService.init();
await replicationService.replicate();
Client requirements
Ensure your tables have at least these columns :
id
as primary key (UUID recommended)updateAt
as timestamp, the last document change datedeletedAt
as timestamp, the deletion date (if deleted)_forkParent
as text, used for conflict handling.
Server requirements
- Ensure documents properties include :
- note that
_forkParent
is not required in server storage and could be removed - API should return deleted documents ! Ensure no deletion, just add a
deletedAt
timestamp instead - API should return documents in a predicable order. If you use SQL like database use
ORDER BY "updatedAt", "id"
- drop foreign constraints, sorry but it's not relevant in a distributed context
- ensure
id
type and filled in a compatible way with distributed context (prefer UUID or similar)
Server Example with node Express API
const mobilePullBodySchema = {
body: {
type: 'object',
properties: {
collections: {
type: 'object',
properties: {},
additionalProperties: {
type: 'object',
properties: {
cursor: { type: 'number', format: 'int64' },
limit: { type: 'number', format: 'int64' },
offset: { type: 'number', format: 'int64' },
},
required: ['cursor', 'limit', 'offset'],
},
},
},
required: ['collections'],
},
};
app.post('/replicationPull', validate(mobilePullBodySchema), async (req: APIRequest, res) => {
const collections = {};
if ( req.body.collections.users ) {
const { cursor, offset, limit } = req.body.collections.users;
const users = await database.select(
'id',
'name',
'updatedAt',
'deletedAt',
)
.from('users')
.orderBy(['updatedAt', 'id'])
.where(database.knex.raw(`date_trunc('milliseconds',"updatedAt")`), '>=', cursor)
.limit(limit + 1)
.offset(offset);
}
collections.users = {
documents: users.length > limit ? users.slice(0, -1) : users,
hasMoreChanges: users.length > limit,
cursor,
offset,
limit,
});
}
res.json({ collections });
});
router.post('/replicationPush', validate(replicationPushSchema), async (req: APIRequest, res) => {
const collections = req.body.collections;
if (collections.users) {
await Promise.all(collections.users.map(pushUser));
}
res.json('ok');
});
async function pushUser(user) {
const forkParent = user._forkParent;
delete user._forkParent;
if (forkParent.updatedAt === null) {
return usersService.create(user)
}
else if (user.deletedAt) {
return usersService.update({...user,updateAt:Date.now()});
}
else if (forkParent.updatedAt !== user.updatedAt) {
const serverDocument = await usersService.getById(user.id),
if (!serverDocument || serverDocument.deletedAt) {
return;
}
else if (serverDocument.updatedAt === forkParent.updatedAt) {
return usersService.update({...user,updateAt:Date.now()});
} else {
const remoteChangedKeys = Object.keys(user).filter(
(key) => JSON.stringify(user[key]) !== JSON.stringify(forkParent[key]),
);
const serverChangedKeys = Object.keys(user).filter(
(key) => JSON.stringify(serverDocument[key]) !== JSON.stringify(forkParent[key]),
);
...
}
}
}
Data model upgrades
capacitor-community/sqlite UpgradeDatabaseVersion works well and is provided by SQLite itself.