Skip to content

Commit

Permalink
Synchronizes database events between multiple clients
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito committed Apr 14, 2021
1 parent e68a4f5 commit 3817687
Show file tree
Hide file tree
Showing 6 changed files with 1,210 additions and 18 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"jest": "^26.6.0",
"msw": "^0.28.0",
"node-fetch": "^2.6.1",
"page-with": "^0.3.5",
"prettier": "^2.2.1",
"rimraf": "^3.0.2",
"ts-jest": "^26.5.4",
Expand Down
73 changes: 73 additions & 0 deletions src/extensions/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Database, DatabaseEventsMap } from '../db/Database'

interface DatabaseMessageEventData<
OperationType extends keyof DatabaseEventsMap
> {
operationType: OperationType
payload: DatabaseEventsMap[OperationType]
}

function removeListeners<Event extends keyof DatabaseEventsMap>(
event: Event,
db: Database<any>,
) {
const listeners = db.events.listeners(event) as DatabaseEventsMap[Event][]
listeners.forEach((listener) => {
db.events.removeListener(event, listener)
})

return () => {
listeners.forEach((listener) => {
db.events.addListener(event, listener)
})
}
}

/**
* Synchronizes database operations across multiple clients.
*/
export function sync(db: Database<any>) {
const IS_BROWSER = typeof window !== 'undefined'
const SUPPORTS_BROADCAST_CHANNEL = typeof BroadcastChannel !== 'undefined'

if (!IS_BROWSER || !SUPPORTS_BROADCAST_CHANNEL) {
return
}

const channel = new BroadcastChannel('mswjs/data/sync')

channel.addEventListener(
'message',
(event: MessageEvent<DatabaseMessageEventData<any>>) => {
const { operationType, payload } = event.data
console.warn('[sync]', operationType, payload)

// Remove database event listener for the signaled operation
// to prevent an infinite loop when applying this operation.
const restoreListeners = removeListeners(operationType, db)

// Apply the database operation signaled from another client
// to the current database instance.
// @ts-ignore
db[operationType](...payload)

// Re-attach database event listeners.
restoreListeners()
},
)

function broadcastDatabaseEvent<Event extends keyof DatabaseEventsMap>(
operationType: Event,
) {
return (...args: Parameters<DatabaseEventsMap[Event]>) => {
channel.postMessage({
operationType,
payload: args,
})
}
}

db.events.on('create', broadcastDatabaseEvent('create'))
db.events.on('update', broadcastDatabaseEvent('update'))
db.events.on('delete', broadcastDatabaseEvent('delete'))
}
3 changes: 3 additions & 0 deletions src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Database } from './db/Database'
import { findPrimaryKey } from './utils/findPrimaryKey'
import { generateRestHandlers } from './model/generateRestHandlers'
import { generateGraphQLHandlers } from './model/generateGraphQLHandlers'
import { sync } from './extensions/sync'

/**
* Create a database with the given models.
Expand Down Expand Up @@ -45,6 +46,8 @@ function createModelApi<
) {
const primaryKey = findPrimaryKey(declaration)

sync(db)

if (typeof primaryKey === 'undefined') {
throw new OperationError(
OperationErrorType.MissingPrimaryKey,
Expand Down
10 changes: 10 additions & 0 deletions test/extensions/sync.runtime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { factory, primaryKey } from '@mswjs/data'

const db = factory({
user: {
id: primaryKey(String),
firstName: String,
},
})

window.db = db
138 changes: 138 additions & 0 deletions test/extensions/sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import * as path from 'path'
import { createBrowser, CreateBrowserApi, pageWith } from 'page-with'
import { FactoryAPI } from '../../src/glossary'

declare namespace window {
export const db: FactoryAPI<any>
}

let browser: CreateBrowserApi

beforeAll(async () => {
browser = await createBrowser({
serverOptions: {
webpackConfig: {
resolve: {
alias: {
'@mswjs/data': path.resolve(__dirname, '../..'),
},
},
},
},
})
})

afterAll(async () => {
await browser.cleanup()
})

it('synchornizes entity create across multiple clients', async () => {
const runtime = await pageWith({
example: path.resolve(__dirname, 'sync.runtime.js'),
})
const secondPage = await runtime.context.newPage()
await secondPage.goto(runtime.origin)
await runtime.page.bringToFront()

await runtime.page.evaluate(() => {
window.db.user.create({
id: 'abc-123',
firstName: 'John',
})
})

const users = await secondPage.evaluate(() => {
return window.db.user.getAll()
})
expect(users).toEqual([
{
__type: 'user',
__primaryKey: 'id',
id: 'abc-123',
firstName: 'John',
},
])
})

it('synchornizes entity update across multiple clients', async () => {
const runtime = await pageWith({
example: path.resolve(__dirname, 'sync.runtime.js'),
})
const secondPage = await runtime.context.newPage()
await secondPage.goto(runtime.origin)
await runtime.page.bringToFront()

await runtime.page.evaluate(() => {
window.db.user.create({
id: 'abc-123',
firstName: 'John',
})
})

await secondPage.evaluate(() => {
return window.db.user.update({
which: {
id: {
equals: 'abc-123',
},
},
data: {
firstName: 'Kate',
},
})
})

const expectedUsers = [
{
__type: 'user',
__primaryKey: 'id',
id: 'abc-123',
firstName: 'Kate',
},
]
const users = await secondPage.evaluate(() => {
return window.db.user.getAll()
})
expect(users).toEqual(expectedUsers)

const extraneousUsers = await runtime.page.evaluate(() => {
return window.db.user.getAll()
})
expect(extraneousUsers).toEqual(expectedUsers)
})

it('synchronizes entity delete across multiple clients', async () => {
const runtime = await pageWith({
example: path.resolve(__dirname, 'sync.runtime.js'),
})
const secondPage = await runtime.context.newPage()
await secondPage.goto(runtime.origin)
await runtime.page.bringToFront()

await runtime.page.evaluate(() => {
window.db.user.create({
id: 'abc-123',
firstName: 'John',
})
})

await secondPage.evaluate(() => {
window.db.user.delete({
which: {
id: {
equals: 'abc-123',
},
},
})
})

const users = await secondPage.evaluate(() => {
return window.db.user.getAll()
})
expect(users).toEqual([])

const extraneousUsers = await runtime.page.evaluate(() => {
return window.db.user.getAll()
})
expect(extraneousUsers).toEqual([])
})
Loading

0 comments on commit 3817687

Please sign in to comment.