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

Supports client-side persistence #87

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export default {
preset: 'ts-jest',
testTimeout: 60000,
setupFilesAfterEnv: ['./jest.setup.ts'],
moduleNameMapper: {
'^@mswjs/data(.*)': '<rootDir>/$1',
},
Expand Down
22 changes: 22 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as path from 'path'
import { CreateBrowserApi, createBrowser } from 'page-with'

let browser: CreateBrowserApi

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

afterAll(async () => {
await browser.cleanup()
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@types/node-fetch": "^2.5.10",
"debug": "^4.3.1",
"faker": "^5.5.3",
"jest": "^26.6.0",
"jest": "^26.6.3",
"msw": "^0.28.2",
"node-fetch": "^2.6.1",
"page-with": "^0.3.5",
Expand Down
81 changes: 80 additions & 1 deletion src/db/Database.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import md5 from 'md5'
import { debug } from 'debug'
import { StrictEventEmitter } from 'strict-event-emitter'
import {
Entity,
InternalEntity,
InternalEntityProperty,
ModelDictionary,
PrimaryKeyType,
Relation,
RelationKind,
} from '../glossary'

const log = debug('Database')

type Models<Dictionary extends ModelDictionary> = Record<
string,
Map<PrimaryKeyType, InternalEntity<Dictionary, any>>
Expand Down Expand Up @@ -42,6 +48,8 @@ export class Database<Dictionary extends ModelDictionary> {

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

log('constructed a new Database (%s)', this.id, this.models)
}

/**
Expand Down Expand Up @@ -69,9 +77,10 @@ export class Database<Dictionary extends ModelDictionary> {
customPrimaryKey ||
(entity[entity[InternalEntityProperty.primaryKey]] as string)

const createdEntity = this.getModel(modelName).set(primaryKey, entity)
this.events.emit('create', this.id, modelName, entity, customPrimaryKey)

return this.getModel(modelName).set(primaryKey, entity)
return createdEntity
}

update<ModelName extends string>(
Expand Down Expand Up @@ -116,4 +125,74 @@ export class Database<Dictionary extends ModelDictionary> {
): InternalEntity<Dictionary, ModelName>[] {
return Array.from(this.getModel(modelName).values())
}

/**
* Serializes database entities into JSON.
*/
toJson(): Record<string, any> {
log('toJson', this.models)

return Object.entries(this.models).reduce<Record<string, any>>(
(json, [modelName, entities]) => {
const modelJson: [PrimaryKeyType, Entity<any, any>][] = []

for (const [primaryKey, entity] of entities.entries()) {
const descriptors = Object.getOwnPropertyDescriptors(entity)
const jsonEntity: Entity<any, any> = {} as any

log('"%s" entity', modelName, entity)
log('descriptors for "%s" model:', modelName, descriptors)

for (const propertyName in descriptors) {
const node = descriptors[propertyName]
const isRelationalProperty =
!node.hasOwnProperty('value') && node.hasOwnProperty('get')

log('analyzing "%s.%s"', modelName, propertyName)

if (isRelationalProperty) {
log(
'found a relational property "%s.%s"',
modelName,
propertyName,
)

/**
* @fixme Handle `manyOf` relation: this variable will be a list
* of relations in that case.
* THERE IS ALSO A SIMILAR LOGIC SOMEWHERE. REUSE?
*/
const resolvedRelationNode = node.get?.()! as any
log('resolved relational node', resolvedRelationNode)

const relation: Relation = {
kind: RelationKind.OneOf,
modelName: resolvedRelationNode[InternalEntityProperty.type],
unique: false,
primaryKey:
resolvedRelationNode[InternalEntityProperty.primaryKey],
}

jsonEntity[propertyName] = relation
} else {
log('property "%s.%s" is not relational', modelName, propertyName)
jsonEntity[propertyName] = node.value
}
}

log('JSON for "%s":\n', modelName, jsonEntity)

/**
* @todo How to persist relational properties?
* Need to write down pointers, kinda like they work internally.
*/
modelJson.push([primaryKey, jsonEntity])
}

json[modelName] = modelJson
return json
},
{},
)
}
}
39 changes: 39 additions & 0 deletions src/extensions/persist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Database } from '../db/Database'
import { isBrowser, supports } from '../utils/env'

const STORAGE_KEY_PREFIX = 'mswjs-data'

/**
* Persists database state into `sessionStorage` and
* hydrates from it on the initial page load.
*/
export function persist(db: Database<any>) {
if (!isBrowser() || !supports.sessionStorage()) {
return
}

const key = `${STORAGE_KEY_PREFIX}/${db.id}`

function persistState() {
const json = db.toJson()
console.log('persists state to storage...', json)
sessionStorage.setItem(key, JSON.stringify(json))
}

db.events.addListener('create', persistState)
db.events.addListener('update', persistState)
db.events.addListener('delete', persistState)

function hydrateState() {
const initialState = sessionStorage.getItem(key)

if (!initialState) {
return
}

console.log('should hydrate from "%s"', key, initialState)
db.hydrate(JSON.parse(initialState))
}

window.addEventListener('load', hydrateState)
}
6 changes: 2 additions & 4 deletions src/extensions/sync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Database, DatabaseEventsMap } from '../db/Database'
import { isBrowser, supports } from '../utils/env'

interface DatabaseMessageEventData<
OperationType extends keyof DatabaseEventsMap
Expand Down Expand Up @@ -27,10 +28,7 @@ function removeListeners<Event extends keyof DatabaseEventsMap>(
* 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) {
if (!isBrowser() || !supports.broadcastChannel()) {
return
}

Expand Down
16 changes: 6 additions & 10 deletions src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
InternalEntity,
FactoryAPI,
ModelAPI,
ModelDefinition,
ModelDictionary,
InternalEntityProperty,
} from './glossary'
Expand All @@ -18,6 +17,7 @@ import { generateRestHandlers } from './model/generateRestHandlers'
import { generateGraphQLHandlers } from './model/generateGraphQLHandlers'
import { sync } from './extensions/sync'
import { removeInternalProperties } from './utils/removeInternalProperties'
import { persist } from './extensions/persist'

/**
* Create a database with the given models.
Expand All @@ -27,11 +27,10 @@ export function factory<Dictionary extends ModelDictionary>(
): FactoryAPI<Dictionary> {
const db = new Database<Dictionary>(dictionary)

return Object.entries(dictionary).reduce<any>((acc, [modelName, props]) => {
return Object.keys(dictionary).reduce<any>((acc, modelName) => {
acc[modelName] = createModelApi<Dictionary, typeof modelName>(
dictionary,
modelName,
props,
db,
)
return acc
Expand All @@ -41,16 +40,13 @@ export function factory<Dictionary extends ModelDictionary>(
function createModelApi<
Dictionary extends ModelDictionary,
ModelName extends string
>(
dictionary: Dictionary,
modelName: ModelName,
definition: ModelDefinition,
db: Database<Dictionary>,
) {
const parsedModel = parseModelDefinition(dictionary, modelName, definition)
>(dictionary: Dictionary, modelName: ModelName, db: Database<Dictionary>) {
const definition = dictionary[modelName]
const parsedModel = parseModelDefinition(dictionary, modelName)
const { primaryKey } = parsedModel

sync(db)
persist(db)

if (typeof primaryKey === 'undefined') {
throw new OperationError(
Expand Down
2 changes: 1 addition & 1 deletion src/glossary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export type InternalEntity<
ModelName extends keyof Dictionary
> = InternalEntityProperties<ModelName> & Entity<Dictionary, ModelName>

export type ModelDictionary = Limit<Record<string, Record<string, any>>>
export type ModelDictionary = Limit<Record<string, ModelDefinition>>

export type Limit<T extends Record<string, any>> = {
[RK in keyof T]: {
Expand Down
11 changes: 10 additions & 1 deletion src/model/defineRelationalProperties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ export function defineRelationalProperties(
relation,
)

if (!(property in initialValues)) return properties
if (!(property in initialValues)) {
log(
'property "%s" does not exist in initial values, skipping...',
property,
initialValues,
)
return properties
}

// Take the relational entity reference from the initial values.
const entityRefs: Entity<any, any>[] = [].concat(initialValues[property])
Expand Down Expand Up @@ -83,6 +90,8 @@ export function defineRelationalProperties(
}
}

log('setting "%s" property as reltional on', property, entity)

properties[property] = {
enumerable: true,
get() {
Expand Down
11 changes: 8 additions & 3 deletions src/model/parseModelDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { debug } from 'debug'
import {
Relation,
RelationKind,
ModelDefinition,
PrimaryKeyType,
ModelDictionary,
} from '../glossary'
Expand All @@ -17,11 +16,15 @@ export interface ParsedModelDefinition {
relations: Record<string, Relation>
}

/**
* Parses a given model to determine its primary key, static properties,
* and relational properties.
*/
export function parseModelDefinition<Dictionary extends ModelDictionary>(
dictionary: Dictionary,
modelName: string,
definition: ModelDefinition,
modelName: keyof Dictionary,
): ParsedModelDefinition {
const definition = dictionary[modelName]
log(`parsing model definition for "${modelName}" entity`, definition)

const result = Object.entries(definition).reduce<{
Expand Down Expand Up @@ -76,5 +79,7 @@ export function parseModelDefinition<Dictionary extends ModelDictionary>(
)
}

log('parsed model definition for "%s"', modelName, result)

return result
}
12 changes: 12 additions & 0 deletions src/utils/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function isBrowser() {
return typeof window !== 'undefined'
}

export const supports = {
sessionStorage() {
return typeof sessionStorage !== 'undefined'
},
broadcastChannel() {
return typeof BroadcastChannel !== 'undefined'
},
}
8 changes: 4 additions & 4 deletions test/db/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ test('emits the "create" event when a new entity is created', (done) => {
createModel(
'user',
dictionary.user,
parseModelDefinition(dictionary, 'user', dictionary.user),
parseModelDefinition(dictionary, 'user'),
{
id: 'abc-123',
},
Expand Down Expand Up @@ -75,7 +75,7 @@ test('emits the "update" event when an existing entity is updated', (done) => {
createModel(
'user',
dictionary.user,
parseModelDefinition(dictionary, 'user', dictionary.user),
parseModelDefinition(dictionary, 'user'),
{ id: 'abc-123', firstName: 'John' },
db,
),
Expand All @@ -86,7 +86,7 @@ test('emits the "update" event when an existing entity is updated', (done) => {
createModel(
'user',
dictionary.user,
parseModelDefinition(dictionary, 'user', dictionary.user),
parseModelDefinition(dictionary, 'user'),
{ id: 'def-456', firstName: 'Kate' },
db,
),
Expand Down Expand Up @@ -117,7 +117,7 @@ test('emits the "delete" event when an existing entity is deleted', (done) => {
createModel(
'user',
dictionary.user,
parseModelDefinition(dictionary, 'user', dictionary.user),
parseModelDefinition(dictionary, 'user'),
{ id: 'abc-123', firstName: 'John' },
db,
),
Expand Down
Loading