From 6ca1b1b5d0355dbb00db1e173f0784fd39afb675 Mon Sep 17 00:00:00 2001 From: amorgunov Date: Sun, 21 May 2023 10:17:38 +0300 Subject: [PATCH 1/2] feat: add persists extension --- README.md | 6 +++++ src/db/Database.ts | 46 ++++++++++++++++++++++++++++++++ src/extensions/persist.ts | 56 +++++++++++++++++++++++++++++++++++++++ src/extensions/sync.ts | 41 +++++----------------------- src/index.ts | 1 + src/utils/env.ts | 12 +++++++++ yarn.lock | 2 +- 7 files changed, 128 insertions(+), 36 deletions(-) create mode 100644 src/extensions/persist.ts create mode 100644 src/utils/env.ts diff --git a/README.md b/README.md index 216016eb..fc0d0bfe 100644 --- a/README.md +++ b/README.md @@ -1190,6 +1190,12 @@ const server = setupServer(...handlers) server.listen() ``` +## Extensions + +### persist + +TODO:// + ## Honorable mentions - [Prisma](https://www.prisma.io) for inspiring the querying client. diff --git a/src/db/Database.ts b/src/db/Database.ts index 92745e13..72e3cb90 100644 --- a/src/db/Database.ts +++ b/src/db/Database.ts @@ -9,6 +9,7 @@ import { PrimaryKeyType, PRIMARY_KEY, } from '../glossary' +import { inheritInternalProperties } from '../utils/inheritInternalProperties' export const SERIALIZED_INTERNAL_PROPERTIES_KEY = 'SERIALIZED_INTERNAL_PROPERTIES' @@ -85,6 +86,26 @@ export class Database { 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 { + const { + [SERIALIZED_INTERNAL_PROPERTIES_KEY]: internalProperties, + ...publicProperties + } = entity + + inheritInternalProperties(publicProperties, { + [ENTITY_TYPE]: internalProperties.entityType, + [PRIMARY_KEY]: internalProperties.primaryKey, + }) + + return publicProperties + } + private serializeEntity(entity: Entity): SerializedEntity { return { ...entity, @@ -95,6 +116,31 @@ export class Database { } } + hydrate(data: Record) { + 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>( + (json, [modelName, entities]) => { + const modelJson: Entity[] = [] + + for (const [, entity] of entities.entries()) { + modelJson.push(this.serializeEntity(entity)) + } + + json[modelName] = modelJson + + return json + }, + {}, + ) + } + getModel(name: ModelName) { return this.models[name] } diff --git a/src/extensions/persist.ts b/src/extensions/persist.ts new file mode 100644 index 00000000..553e38e3 --- /dev/null +++ b/src/extensions/persist.ts @@ -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 + 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, + options: ExtensionOption = {}, +) { + if (!isBrowser() || (!options.storage && !supports.sessionStorage())) { + 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() + } +} diff --git a/src/extensions/sync.ts b/src/extensions/sync.ts index 90bb4576..f03e7fb4 100644 --- a/src/extensions/sync.ts +++ b/src/extensions/sync.ts @@ -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 = | { @@ -38,34 +32,11 @@ function removeListeners( } } -/** - * 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 { - 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) { - const IS_BROWSER = typeof window !== 'undefined' - const SUPPORTS_BROADCAST_CHANNEL = typeof BroadcastChannel !== 'undefined' - - if (!IS_BROWSER || !SUPPORTS_BROADCAST_CHANNEL) { + if (!isBrowser() || !supports.broadcastChannel()) { return } @@ -91,7 +62,7 @@ export function sync(db: Database) { 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 } @@ -99,8 +70,8 @@ export function sync(db: Database) { const [modelName, prevEntity, nextEntity] = event.data.payload[1] db.update( modelName, - deserializeEntity(prevEntity), - deserializeEntity(nextEntity), + db.deserializeEntity(prevEntity), + db.deserializeEntity(nextEntity), ) break } diff --git a/src/index.ts b/src/index.ts index 08a43593..343f8119 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/utils/env.ts b/src/utils/env.ts new file mode 100644 index 00000000..61647dfe --- /dev/null +++ b/src/utils/env.ts @@ -0,0 +1,12 @@ +export function isBrowser() { + return typeof window !== 'undefined' +} + +export const supports = { + sessionStorage() { + return typeof sessionStorage !== 'undefined' + }, + broadcastChannel() { + return typeof BroadcastChannel !== 'undefined' + }, +} diff --git a/yarn.lock b/yarn.lock index 2d3ef2f3..bb279f32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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== From 57fd7c7f8ef0aad4237a6c389cfa58b0991d40f8 Mon Sep 17 00:00:00 2001 From: amorgunov Date: Fri, 26 May 2023 10:03:38 +0300 Subject: [PATCH 2/2] fix: update README.md --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fc0d0bfe..1f78acb0 100644 --- a/README.md +++ b/README.md @@ -1194,7 +1194,26 @@ server.listen() ### persist -TODO:// +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