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

feat: persist extension #277

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,31 @@ const server = setupServer(...handlers)
server.listen()
```

## Extensions

### persist

To persist database state and hydrate on page reload you can use persist extention. By default this one save database snapshoot in `sessionStorage` every time when any action was fired (like create new entity, update or delete).

```ts
import { factory, persist, primaryKey } from '@mswjs/data'

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

persist(db)
```

You can pass any key-value storage with `getItem` and `setItem` methods (like `localStorage`) by second argument:

```
persist(db, { storage: localStorage })
```

## Honorable mentions

- [Prisma](https://www.prisma.io) for inspiring the querying client.
Expand Down
46 changes: 46 additions & 0 deletions src/db/Database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
PrimaryKeyType,
PRIMARY_KEY,
} from '../glossary'
import { inheritInternalProperties } from '../utils/inheritInternalProperties'

export const SERIALIZED_INTERNAL_PROPERTIES_KEY =
'SERIALIZED_INTERNAL_PROPERTIES'
Expand Down Expand Up @@ -85,6 +86,26 @@ export class Database<Dictionary extends ModelDictionary> {
return md5(salt)
}

/**
* 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.
*/
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
}

private serializeEntity(entity: Entity<Dictionary, any>): SerializedEntity {
return {
...entity,
Expand All @@ -95,6 +116,31 @@ export class Database<Dictionary extends ModelDictionary> {
}
}

hydrate(data: Record<string, any>) {
Object.entries(data).forEach(([modelName, entities]) => {
for (const [, entity] of entities.entries()) {
this.create(modelName, this.deserializeEntity(entity))
}
})
}

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

for (const [, entity] of entities.entries()) {
modelJson.push(this.serializeEntity(entity))
}

json[modelName] = modelJson

return json
},
{},
)
}

getModel<ModelName extends keyof Dictionary>(name: ModelName) {
return this.models[name]
}
Expand Down
56 changes: 56 additions & 0 deletions src/extensions/persist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import debounce from 'lodash/debounce'
import { DATABASE_INSTANCE, FactoryAPI } from '../glossary'
import { isBrowser, supports } from '../utils/env'

type ExtensionOption = {
storage?: Pick<Storage, 'getItem' | 'setItem'>
keyPrefix?: string
}

const STORAGE_KEY_PREFIX = 'mswjs-data'

// Timout to persist state with some delay
const DEBOUNCE_PERSIST_TIME_MS = 10

/**
* Persist database in session storage
*/
export function persist(
factory: FactoryAPI<any>,
options: ExtensionOption = {},
) {
if (!isBrowser() || (!options.storage && !supports.sessionStorage())) {
Copy link

@chazmuzz chazmuzz Jun 17, 2023

Choose a reason for hiding this comment

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

Would be great if server-side persistence could be supported too. I have that need in my project. I want to serialise my mock DB and store it in Redis. Seems doable if all persist function needs is a storage method that has getItem/setItem methods?

Choose a reason for hiding this comment

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

If @kettanaito will ok with this API, I could change interface for custom storage

return
}

const storage = options.storage || sessionStorage
const keyPrefix = options.keyPrefix || STORAGE_KEY_PREFIX

const db = factory[DATABASE_INSTANCE]

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

const persistState = debounce(function persistState() {
const json = db.toJson()
storage.setItem(key, JSON.stringify(json))
}, DEBOUNCE_PERSIST_TIME_MS)

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

if (initialState) {
db.hydrate(JSON.parse(initialState))
}

// Add event listeners only after hydration
db.events.on('create', persistState)
db.events.on('update', persistState)
db.events.on('delete', persistState)
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', hydrateState)
} else {
hydrateState()
}
}
41 changes: 6 additions & 35 deletions src/extensions/sync.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { ENTITY_TYPE, PRIMARY_KEY, Entity } from '../glossary'
import {
Database,
DatabaseEventsMap,
SerializedEntity,
SERIALIZED_INTERNAL_PROPERTIES_KEY,
} from '../db/Database'
import { inheritInternalProperties } from '../utils/inheritInternalProperties'
import { isBrowser, supports } from '../utils/env'
import { Database, DatabaseEventsMap } from '../db/Database'

export type DatabaseMessageEventData =
| {
Expand Down Expand Up @@ -38,34 +32,11 @@ 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.
*/
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 All @@ -91,16 +62,16 @@ export function sync(db: Database<any>) {
switch (event.data.operationType) {
case 'create': {
const [modelName, entity, customPrimaryKey] = event.data.payload[1]
db.create(modelName, deserializeEntity(entity), customPrimaryKey)
db.create(modelName, db.deserializeEntity(entity), customPrimaryKey)
break
}

case 'update': {
const [modelName, prevEntity, nextEntity] = event.data.payload[1]
db.update(
modelName,
deserializeEntity(prevEntity),
deserializeEntity(nextEntity),
db.deserializeEntity(prevEntity),
db.deserializeEntity(nextEntity),
)
break
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { factory } from './factory'
export { persist } from './extensions/persist'
export { primaryKey } from './primaryKey'
export { nullable } from './nullable'
export { oneOf } from './relations/oneOf'
Expand Down
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'
},
}
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -814,7 +814,7 @@
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==

"@types/debug@^4.1.5", "@types/debug@^4.1.7":
"@types/debug@^4.1.7":
version "4.1.7"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82"
integrity sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==
Expand Down