Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Synchronizes database events between multiple clients #66

Merged
merged 4 commits into from
Apr 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ jobs:
- name: Install dependencies
run: yarn install --frozen-lockfile

# Build
- name: Build
run: yarn build

# Runs a single command using the runners shell
- name: Tests
run: yarn test

- name: Tests (typings)
run: yarn test:ts

# Build
- name: Build
run: yarn build
6 changes: 4 additions & 2 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export default {
transform: {
'^.+\\.tsx?$': 'ts-jest',
preset: 'ts-jest',
testTimeout: 60000,
moduleNameMapper: {
'^@mswjs/data(.*)': '<rootDir>/$1',
},
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
"author": "Artem Zakharchenko",
"license": "MIT",
"scripts": {
"start": "tsc -w",
"format": "prettier src/**/*.ts --write",
"test": "jest",
"test:ts": "tsc -p test/typings/tsconfig.json",
"clean": "rimraf ./lib",
"build": "yarn clean && tsc",
"prepublishOnly": "yarn test && yarn test:ts && yarn build"
"prepublishOnly": "yarn build && yarn test:ts && yarn test"
},
"files": [
"lib",
Expand All @@ -28,17 +29,20 @@
"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",
"ts-node": "^9.1.1",
"typescript": "^4.2.4"
},
"dependencies": {
"@types/md5": "^2.3.0",
"@types/pluralize": "^0.0.29",
"@types/uuid": "^8.3.0",
"date-fns": "^2.20.0",
"graphql": "^15.5.0",
"md5": "^2.3.0",
"pluralize": "^8.0.0",
"strict-event-emitter": "^0.2.0",
"uuid": "^8.3.1"
Expand Down
26 changes: 23 additions & 3 deletions src/db/Database.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import md5 from 'md5'
import { StrictEventEmitter } from 'strict-event-emitter'
import { EntityInstance, ModelDictionary, PrimaryKeyType } from '../glossary'

Expand All @@ -7,6 +8,7 @@ type Models<Dictionary extends ModelDictionary> = Record<
>

export type DatabaseMethodToEventFn<Method extends (...args: any[]) => any> = (
id: string,
...args: Parameters<Method>
) => void

Expand All @@ -16,7 +18,10 @@ export interface DatabaseEventsMap {
delete: DatabaseMethodToEventFn<Database<any>['delete']>
}

let callOrder = 0

export class Database<Dictionary extends ModelDictionary> {
public id: string
public events: StrictEventEmitter<DatabaseEventsMap>
private models: Models<ModelDictionary>

Expand All @@ -29,6 +34,21 @@ export class Database<Dictionary extends ModelDictionary> {
},
{},
)

callOrder++
this.id = this.generateId()
}

/**
* Generates a unique MD5 hash based on the database
* module location and invocation order. Used to reproducibly
* identify a database instance among sibling instances.
*/
private generateId() {
const { stack } = new Error()
const callFrame = stack?.split('\n')[4]
const salt = `${callOrder}-${callFrame?.trim()}`
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using an invocation order and database location to identify a single database instance.
The order of factory() calls is unlikely to change on runtime. Two first-created databases across clients aren't necessarily the same instances, as each client may load a different module, while the same BroadcastChannel controls the entire host. To account for that, I'm adding a module location to the hash salt.

Two database instances on multiple pages are equal if:

  1. They were called in the same order.
  2. They were created in the same module.

return md5(salt)
}

getModel(name: string) {
Expand All @@ -43,7 +63,7 @@ export class Database<Dictionary extends ModelDictionary> {
const primaryKey =
customPrimaryKey || (entity[entity.__primaryKey] as string)

this.events.emit('create', modelName, entity, customPrimaryKey)
this.events.emit('create', this.id, modelName, entity, customPrimaryKey)

return this.getModel(modelName).set(primaryKey, entity)
}
Expand All @@ -61,7 +81,7 @@ export class Database<Dictionary extends ModelDictionary> {
}

this.create(modelName, nextEntity, nextPrimaryKey as string)
this.events.emit('update', modelName, prevEntity, nextEntity)
this.events.emit('update', this.id, modelName, prevEntity, nextEntity)
}

has(modelName: string, primaryKey: PrimaryKeyType) {
Expand All @@ -74,7 +94,7 @@ export class Database<Dictionary extends ModelDictionary> {

delete(modelName: string, primaryKey: PrimaryKeyType) {
this.getModel(modelName).delete(primaryKey)
this.events.emit('delete', modelName, primaryKey)
this.events.emit('delete', this.id, modelName, primaryKey)
}

listEntities(modelName: string) {
Expand Down
79 changes: 79 additions & 0 deletions src/extensions/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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
const [sourceId, ...args] = payload

// Ignore messages originating from unrelated databases.
// Useful in case of multiple databases on the same page.
if (db.id !== sourceId) {
return
}

// 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](...args)

// 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
2 changes: 1 addition & 1 deletion test/db/drop.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { drop, factory, identity, primaryKey } from '../../src'
import { drop, factory, identity, primaryKey } from '@mswjs/data'

test('drops all records in the database', () => {
const db = factory({
Expand Down
11 changes: 7 additions & 4 deletions test/db/events.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { primaryKey } from '../../src'
import { primaryKey } from '@mswjs/data'
import { Database } from '../../src/db/Database'
import { createModel } from '../../src/model/createModel'

Expand All @@ -9,7 +9,8 @@ test('emits the "create" event when a new entity is created', (done) => {
},
})

db.events.on('create', (modelName, entity, primaryKey) => {
db.events.on('create', (id, modelName, entity, primaryKey) => {
expect(id).toEqual(db.id)
expect(modelName).toEqual('user')
expect(entity).toEqual({
__type: 'user',
Expand All @@ -31,7 +32,8 @@ test('emits the "update" event when an existing entity is updated', (done) => {
},
})

db.events.on('update', (modelName, prevEntity, nextEntity) => {
db.events.on('update', (id, modelName, prevEntity, nextEntity) => {
expect(id).toEqual(db.id)
expect(modelName).toEqual('user')
expect(prevEntity).toEqual({
__type: 'user',
Expand Down Expand Up @@ -67,7 +69,8 @@ test('emits the "delete" event when an existing entity is deleted', (done) => {
},
})

db.events.on('delete', (modelName, primaryKey) => {
db.events.on('delete', (id, modelName, primaryKey) => {
expect(id).toEqual(db.id)
expect(modelName).toEqual('user')
expect(primaryKey).toEqual('abc-123')
done()
Expand Down
15 changes: 15 additions & 0 deletions test/extensions/sync.multiple.runtime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { factory, primaryKey } from '@mswjs/data'

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

window.secondDb = factory({
user: {
id: primaryKey(String),
firstName: String,
},
})
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
Loading