@worldbrain/storex-sync
Version:
Offline-first syncing between multiple databases using Storex
235 lines (207 loc) • 10.2 kB
text/typescript
import * as expect from 'expect'
import { setupStorexTest } from '@worldbrain/storex-pattern-modules/lib/index.tests'
import { SharedSyncLogStorage } from './shared-sync-log/storex';
import { ClientSyncLogStorage } from './client-sync-log';
import { CustomAutoPkMiddleware } from './custom-auto-pk';
import { SyncLoggingMiddleware } from './logging-middleware';
import { shareLogEntries, receiveLogEntries, doSync } from '.';
import { reconcileSyncLog } from './reconciliation';
type PromiseContentType<T> = T extends Promise<infer U> ? U : T
describe('Storex sync integration tests', () => {
async function setupBackend(options) {
return setupStorexTest<{sharedSyncLog : SharedSyncLogStorage}>({
dbName: 'backend',
collections: {},
modules: {
sharedSyncLog: ({storageManager}) => new SharedSyncLogStorage({storageManager})
}
})
}
function createGetNow(options : {start : number}) {
let now = options.start
return () => now++
}
async function setupClient(options : {backend, clientName : string, getNow : () => number, pkGenerator : () => string}) {
const { storageManager, modules } = await setupStorexTest<{clientSyncLog : ClientSyncLogStorage}>({
dbName: `client-${options.clientName}`,
collections: {
user: {
version: new Date('2019-01-01'),
fields: {
displayName: { type: 'string' }
}
},
email: {
version: new Date('2019-01-01'),
fields: {
address: { type: 'string' },
},
relationships: [
{ childOf: 'user' }
]
}
},
modules: {
clientSyncLog: ({storageManager}) => new ClientSyncLogStorage({storageManager})
}
})
const includeCollections = ['user', 'email']
const pkMiddleware = new CustomAutoPkMiddleware({ pkGenerator: options.pkGenerator })
pkMiddleware.setup({ storageRegistry: storageManager.registry, collections: includeCollections })
const syncLoggingMiddleware = new SyncLoggingMiddleware({ storageManager, clientSyncLog: modules.clientSyncLog, includeCollections })
syncLoggingMiddleware._getNow = options.getNow
storageManager.setMiddleware([
pkMiddleware,
syncLoggingMiddleware
])
return { storageManager, modules, deviceId: null, objects: {} }
}
async function setupTest(options : {clients? : {name : string}[], getNow : () => number}) {
let idsGenerated = 0
const pkGenerator = () => `id-${++idsGenerated}`
const userId = 1
const backend = await setupBackend({})
const clients : {[name : string] : PromiseContentType<ReturnType<typeof setupClient>>} = {}
for (const { name } of options.clients || []) {
clients[name] = await setupClient({backend, clientName: name, getNow: options.getNow, pkGenerator})
clients[name].deviceId = await backend.modules.sharedSyncLog.createDeviceId({ userId, sharedUntil: 10 })
}
return { backend, clients, userId }
}
describe('shareLogEntries()', () => {
async function setupShareTest() {
const { backend, clients, userId } = await setupTest({clients: [{ name: 'one' }, { name: 'two' } ], getNow: createGetNow({start: 2})})
await clients.one.storageManager.collection('user').createObject({displayName: 'Joe', emails: [{address: 'joe@doe.com'}]})
const share = (options : {now: number}) => shareLogEntries({
sharedSyncLog: backend.modules.sharedSyncLog, clientSyncLog: clients.one.modules.clientSyncLog,
userId, deviceId: clients.one.deviceId, now: options.now
})
return { backend, clients, userId, share }
}
it('should correctly share log entries', async () => {
const { backend, clients, userId, share } = await setupShareTest()
await share({now: 55})
expect(await backend.modules.sharedSyncLog.getUnsyncedEntries({deviceId: clients.two.deviceId})).toEqual([
{
id: expect.anything(),
userId,
deviceId: clients.one.deviceId,
createdOn: 2,
sharedOn: 55,
data: '{"operation":"create","collection":"user","pk":"id-1","field":null,"value":{"displayName":"Joe"}}',
},
{
id: expect.anything(),
userId,
deviceId: clients.one.deviceId,
createdOn: 3,
sharedOn: 55,
data: '{"operation":"create","collection":"email","pk":"id-2","field":null,"value":{"address":"joe@doe.com"}}',
},
])
})
it('should not reshare entries that are already shared', async () => {
const { backend, clients, share } = await setupShareTest()
await share({now: 55})
const entries = await backend.modules.sharedSyncLog.getUnsyncedEntries({deviceId: clients.two.deviceId})
await share({now: 60})
expect(await backend.modules.sharedSyncLog.getUnsyncedEntries({deviceId: clients.two.deviceId})).toEqual(entries)
})
})
describe('receiveLogEntries()', () => {
async function setupReceiveTest() {
const { backend, clients, userId } = await setupTest({clients: [{ name: 'one' }, { name: 'two' } ], getNow: createGetNow({start: 2})})
const receive = async (options : {now : number}) => {
await receiveLogEntries({
clientSyncLog: clients.one.modules.clientSyncLog,
sharedSyncLog: backend.modules.sharedSyncLog,
deviceId: clients.one.deviceId,
now: options.now,
})
}
return { backend, clients, userId, receive }
}
it('should correctly receive unsynced entries and write them to the local log marked as needing integration', async () => {
const { backend, clients, userId, receive } = await setupReceiveTest()
await clients.one.storageManager.collection('user').createObject({displayName: 'Bob'})
await backend.modules.sharedSyncLog.writeEntries([
{
userId,
deviceId: clients.one.deviceId,
createdOn: 5,
sharedOn: 55,
data: '{"operation":"create","collection":"user","pk":"id-2","field":null,"value":{"displayName":"Joe"}}',
},
{
userId,
deviceId: clients.one.deviceId,
createdOn: 7,
sharedOn: 55,
data: '{"operation":"create","collection":"email","pk":"id-3","field":null,"value":{"address":"joe@doe.com"}}',
},
], { userId, deviceId: clients.one.deviceId })
await receive({now: 60})
expect(await clients.one.modules.clientSyncLog.getEntriesCreatedAfter(1)).toEqual([
{
id: expect.anything(),
createdOn: 2,
sharedOn: null,
needsIntegration: false,
collection: 'user',
pk: 'id-1',
operation: 'create',
value: { displayName: 'Bob' },
},
{
id: expect.anything(),
createdOn: 5,
sharedOn: 60,
needsIntegration: true,
collection: 'user',
pk: 'id-2',
field: null,
operation: 'create',
value: { displayName: 'Joe' },
},
{
id: expect.anything(),
createdOn: 7,
sharedOn: 60,
needsIntegration: true,
collection: 'email',
pk: 'id-3',
field: null,
operation: 'create',
value: { address: 'joe@doe.com' },
}
])
})
})
describe('doSync()', () => {
async function setupSyncTest() {
const { backend, clients, userId } = await setupTest({ clients: [{ name: 'one' }, { name: 'two' }], getNow: createGetNow({ start: 50 }) })
const sync = async (options : {clientName : string, now : number}) => {
const client = clients[options.clientName]
await doSync({
clientSyncLog: client.modules.clientSyncLog,
sharedSyncLog: backend.modules.sharedSyncLog,
storageManager: client.storageManager,
reconciler: reconcileSyncLog,
now: options.now,
userId,
deviceId: client.deviceId
})
}
return { clients, sync }
}
it('should work when pulling changes after being offline', async () => {
const { clients, sync } = await setupSyncTest()
const orig = (await clients.one.storageManager.collection('user').createObject({displayName: 'Joe', emails: [{address: 'joe@doe.com'}]})).object
const { emails, ...user } = orig
await sync({clientName: 'one', now: 55})
await sync({clientName: 'two', now: 60})
expect(await clients.two.storageManager.collection('user').findObject({id: user.id})).toEqual(user)
expect(await clients.two.storageManager.collection('email').findObject({id: emails[0].id})).toEqual(emails[0])
})
})
})