Skip to content
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
146 changes: 102 additions & 44 deletions src/db/Database.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,55 @@
import md5 from 'md5'
import { invariant } from 'outvariant'
import { StrictEventEmitter } from 'strict-event-emitter'
import {
InternalEntity,
InternalEntityProperty,
Entity,
ENTITY_TYPE,
KeyType,
ModelDictionary,
PrimaryKeyType,
PRIMARY_KEY,
} from '../glossary'

export const SERIALIZED_INTERNAL_PROPERTIES_KEY =
'SERIALIZED_INTERNAL_PROPERTIES'

type Models<Dictionary extends ModelDictionary> = Record<
string,
Map<PrimaryKeyType, InternalEntity<Dictionary, any>>
keyof Dictionary,
Map<PrimaryKeyType, Entity<Dictionary, any>>
>

export type DatabaseMethodToEventFn<Method extends (...args: any[]) => any> = (
id: string,
...args: Parameters<Method>
export interface SerializedInternalEntityProperties {
entityType: string
primaryKey: PrimaryKeyType
}

export interface SerializedEntity extends Entity<any, any> {
[SERIALIZED_INTERNAL_PROPERTIES_KEY]: SerializedInternalEntityProperties
}

export type DatabaseMethodToEventFn<ArgsType extends unknown[]> = (
sourceId: string,
args: ArgsType,
) => void

export interface DatabaseEventsMap {
create: DatabaseMethodToEventFn<Database<any>['create']>
update: DatabaseMethodToEventFn<Database<any>['update']>
delete: DatabaseMethodToEventFn<Database<any>['delete']>
create: DatabaseMethodToEventFn<
[
modelName: KeyType,
entity: SerializedEntity,
customPrimaryKey?: PrimaryKeyType,
]
>
update: DatabaseMethodToEventFn<
[
modelName: KeyType,
prevEntity: SerializedEntity,
nextEntity: SerializedEntity,
]
>
delete: DatabaseMethodToEventFn<
[modelName: KeyType, primaryKey: PrimaryKeyType]
>
}

let callOrder = 0
Expand All @@ -33,11 +62,11 @@ export class Database<Dictionary extends ModelDictionary> {
constructor(dictionary: Dictionary) {
this.events = new StrictEventEmitter()
this.models = Object.keys(dictionary).reduce<Models<Dictionary>>(
(acc, modelName) => {
acc[modelName] = new Map<string, InternalEntity<Dictionary, string>>()
(acc, modelName: keyof Dictionary) => {
acc[modelName] = new Map<string, Entity<Dictionary, string>>()
return acc
},
{},
{} as Models<Dictionary>,
)

callOrder++
Expand All @@ -56,64 +85,93 @@ export class Database<Dictionary extends ModelDictionary> {
return md5(salt)
}

getModel<ModelName extends string>(name: ModelName) {
private serializeEntity(entity: Entity<Dictionary, any>): SerializedEntity {
return {
...entity,
[SERIALIZED_INTERNAL_PROPERTIES_KEY]: {
entityType: entity[ENTITY_TYPE],
primaryKey: entity[PRIMARY_KEY],
},
}
}

getModel<ModelName extends keyof Dictionary>(name: ModelName) {
return this.models[name]
}

create<ModelName extends string>(
create<ModelName extends keyof Dictionary>(
modelName: ModelName,
entity: InternalEntity<Dictionary, any>,
entity: Entity<Dictionary, ModelName>,
customPrimaryKey?: PrimaryKeyType,
) {
const primaryKey =
customPrimaryKey ||
(entity[entity[InternalEntityProperty.primaryKey]] as string)
): Map<PrimaryKeyType, Entity<Dictionary, ModelName>> {
invariant(
entity[ENTITY_TYPE],
'Failed to create a new "%s" record: provided entity has no type. %j',
modelName,
entity,
)
invariant(
entity[PRIMARY_KEY],
'Failed to create a new "%s" record: provided entity has no primary key. %j',
modelName,
entity,
)

this.events.emit('create', this.id, modelName, entity, customPrimaryKey)
const primaryKey =
customPrimaryKey || (entity[entity[PRIMARY_KEY]] as string)

this.events.emit('create', this.id, [
modelName,
this.serializeEntity(entity),
customPrimaryKey,
])
return this.getModel(modelName).set(primaryKey, entity)
}

update<ModelName extends string>(
update<ModelName extends keyof Dictionary>(
modelName: ModelName,
prevEntity: InternalEntity<Dictionary, any>,
nextEntity: InternalEntity<Dictionary, any>,
) {
const prevPrimaryKey =
prevEntity[prevEntity[InternalEntityProperty.primaryKey]]
const nextPrimaryKey =
nextEntity[prevEntity[InternalEntityProperty.primaryKey]]
prevEntity: Entity<Dictionary, ModelName>,
nextEntity: Entity<Dictionary, ModelName>,
): void {
const prevPrimaryKey = prevEntity[prevEntity[PRIMARY_KEY]] as PrimaryKeyType
const nextPrimaryKey = nextEntity[prevEntity[PRIMARY_KEY]] as PrimaryKeyType

if (nextPrimaryKey !== prevPrimaryKey) {
this.delete(modelName, prevPrimaryKey as string)
this.delete(modelName, prevPrimaryKey)
}

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

// this.create(modelName, nextEntity, nextPrimaryKey)
this.events.emit('update', this.id, [
modelName,
this.serializeEntity(prevEntity),
this.serializeEntity(nextEntity),
])
}

delete<ModelName extends keyof Dictionary>(
modelName: ModelName,
primaryKey: PrimaryKeyType,
): void {
this.getModel(modelName).delete(primaryKey)
this.events.emit('delete', this.id, [modelName, primaryKey])
}

has<ModelName extends string>(
has<ModelName extends keyof Dictionary>(
modelName: ModelName,
primaryKey: PrimaryKeyType,
) {
): boolean {
return this.getModel(modelName).has(primaryKey)
}

count<ModelName extends string>(modelName: ModelName) {
return this.getModel(modelName).size
}

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

listEntities<ModelName extends string>(
listEntities<ModelName extends keyof Dictionary>(
modelName: ModelName,
): InternalEntity<Dictionary, ModelName>[] {
): Entity<Dictionary, ModelName>[] {
return Array.from(this.getModel(modelName).values())
}
}
4 changes: 2 additions & 2 deletions src/db/drop.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FactoryAPI } from '../glossary'

export function drop(db: FactoryAPI<any>): void {
Object.values(db).forEach((model) => {
export function drop(factoryApi: FactoryAPI<any>): void {
Object.values(factoryApi).forEach((model) => {
model.deleteMany({ where: {} })
})
}
87 changes: 71 additions & 16 deletions src/extensions/sync.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import { Database, DatabaseEventsMap } from '../db/Database'
import { ENTITY_TYPE, PRIMARY_KEY, Entity } from '../glossary'
import {
Database,
DatabaseEventsMap,
SerializedEntity,
SERIALIZED_INTERNAL_PROPERTIES_KEY,
} from '../db/Database'
import { inheritInternalProperties } from '../utils/inheritInternalProperties'

interface DatabaseMessageEventData<
OperationType extends keyof DatabaseEventsMap
> {
operationType: OperationType
payload: DatabaseEventsMap[OperationType]
}
export type DatabaseMessageEventData =
| {
operationType: 'create'
payload: Parameters<DatabaseEventsMap['create']>
}
| {
operationType: 'update'
payload: Parameters<DatabaseEventsMap['update']>
}
| {
operationType: 'delete'
payload: Parameters<DatabaseEventsMap['delete']>
}

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)
})
Expand All @@ -23,6 +38,26 @@ function removeListeners<Event extends keyof DatabaseEventsMap>(
}
}

/**
* Sets the serialized internal properties as symbols
* on the given entity.
* @note `Symbol` properties are stripped off when sending
* an object over an event emitter.
*/
function deserializeEntity(entity: SerializedEntity): Entity<any, any> {
const {
[SERIALIZED_INTERNAL_PROPERTIES_KEY]: internalProperties,
...publicProperties
} = entity

inheritInternalProperties(publicProperties, {
[ENTITY_TYPE]: internalProperties.entityType,
[PRIMARY_KEY]: internalProperties.primaryKey,
})

return publicProperties
}

/**
* Synchronizes database operations across multiple clients.
*/
Expand All @@ -38,9 +73,8 @@ export function sync(db: Database<any>) {

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

// Ignore messages originating from unrelated databases.
// Useful in case of multiple databases on the same page.
Expand All @@ -50,26 +84,47 @@ export function sync(db: Database<any>) {

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

// Apply the database operation signaled from another client
// to the current database instance.
// @ts-ignore
db[operationType](...args)
switch (event.data.operationType) {
case 'create': {
const [modelName, entity, customPrimaryKey] = event.data.payload[1]
db.create(modelName, deserializeEntity(entity), customPrimaryKey)
break
}

case 'update': {
const [modelName, prevEntity, nextEntity] = event.data.payload[1]
db.update(
modelName,
deserializeEntity(prevEntity),
deserializeEntity(nextEntity),
)
break
}

default: {
db[event.data.operationType](...event.data.payload[1])
}
}

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

// Broadcast the emitted event from this client
// to all the other connected clients.
function broadcastDatabaseEvent<Event extends keyof DatabaseEventsMap>(
operationType: Event,
) {
return (...args: Parameters<DatabaseEventsMap[Event]>) => {
return (...payload: Parameters<DatabaseEventsMap[Event]>) => {
channel.postMessage({
operationType,
payload: args,
})
payload,
} as DatabaseMessageEventData)
}
}

Expand Down
Loading