Code Monkey home page Code Monkey logo

mst-persist's Introduction

mst-persist

package-json releases commits
dt dy dm dw
typings build status code coverage

Persist and hydrate MobX-state-tree stores.

Installation

npm i -S mst-persist

Usage

import { types } from 'mobx-state-tree'
import localForage from 'localForage'
import { persist } from 'mst-persist'

const SomeStore = types.model('Store', {
  name: 'John Doe',
  age: 32
})

const someStore = SomeStore.create()

persist('some', someStore, {
  storage: localForage,  // or AsyncStorage in react-native.
                         // default: localStorage
  jsonify: false  // if you use AsyncStorage, this shoud be true
                  // default: true
  whitelist: ['name']  // only these keys will be persisted
}).then(() => console.log('someStore has been hydrated'))

API

persist(key, store, options)

  • arguments

    • key string The key of your storage engine that you want to persist to.
    • store MST store The store to be persisted.
    • options object Additional configuration options.
      • storage localForage / AsyncStorage / localStorage Any Storage Engine that has a Promise-style API similar to localForage. The default is localStorage, which has a built-in adaptor to make it support Promises. For React Native, one may configure AsyncStorage instead.
        Any of redux-persist's Storage Engines should also be compatible with mst-persist.
      • jsonify bool Enables serialization as JSON (default: true).
      • whitelist Array<string> Only these keys will be persisted (defaults to all keys).
      • blacklist Array<string> These keys will not be persisted (defaults to all keys).
  • returns a void Promise

Node and Server-Side Rendering (SSR) Usage

Node environments are supported so long as you configure a Storage Engine that supports Node, such as redux-persist-node-storage, redux-persist-cookie-storage, etc. This allows you to hydrate your store server-side.

For SSR though, you may not want to hydrate your store server-side, so in that case you can call persist conditionally:

if (typeof window !== 'undefined') { // window is undefined in Node
  persist(...)
}

With this conditional check, your store will only be hydrated client-side.

Examples

None yet, but can take a look at agilgur5/react-native-manga-reader-app which uses it in production. Can view the commit that implements it here.

Can also view some of the internal tests.

How it works

Basically just a small wrapper around MST's onSnapshot and applySnapshot. The source code is currently shorter than this README, so take a look under the hood! :)

Credits

Inspiration for parts of the code and API came from redux-persist, mobx-persist, and this MST persist PoC gist

mst-persist's People

Contributors

agilgur5 avatar barbalex avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

mst-persist's Issues

Add tests

Noting that I'll likely need to mock out localStorage for tests. Not difficult given that a simple mock can be < 10 LoC (or use some in-memory package).

Unlike Jest, AVA doesn't support mocking out-of-the-box, but we don't really need sinon or too much mocking support for a small mock like this, so should be nbd

Should you clear storage when changing your schema?

Main Question
what do you think about clear storage, when its structure was upgraded (extended, updated, ...)?

Example

  1. development iteration 1: create basic model
const User = types
.model('User', {
    id: types.number,
    name: types.string,
  })
  
const UserStore = User.create({
  id: 1,
  name: 'John Doe',
})

persist('@UserStore', UserStore, {
  storage: AsyncStorage,
  jsonify: true,
})
  1. development iteration 2: model extended with email-property, BUT in AsyncStorage persisted OLD model already and restoration of it will fail due to difference in the structure.
const User = types
.model('User', {
    id: types.number,
    name: types.string,
    email: types.string,                 # added property
  })
...

THOUGHTS:
on the first run of application (after store's structure was upgraded) we should detect such changes and clear storage. on the next runs - we should work with persist as usual. it looks like upgrade of the databases saying in Android (migrations and etc)

[RFC] Transforms & Migrations (& Plugins?)

Motivation

This RFC is meant to discuss what the future of transforms might look like now, so that we have less breaking changes in the future to a potentially heavily used API. The big aspects of that future, in my mind at least, are Migrations and possibly Plugins (see below). There are probably use-cases I'm not foreseeing as well.

Transforms

I think the proposal for transforms that I have out in #16 has reached a solid state and is pretty good API now. If anyone's got a minute to review it, that would be very welcome!

The main question I have around the Transform API proposed there is whether ITransformArgs should accept more than just a snapshot. Like, say, if the user needs to operate on the store itself, or needs an option specified in the config. For example, merge strategies could be implemented as a transform, but it would need the store in order to a shallow or deep merge against it:

import { getSnapshot } from 'mobx-state-tree'
import { persist, ITransform } from 'mst-persist'

import { UserStore } from './models'

const user = UserStore.create({name: 'Joe'})
await persist('user', user, {
  transforms: [shallowMerge]
})

const shallowMerge: ITransform = {fromStorage: (snapshot, store) => {
  // get the store's initial state from .create() and any actions done prior to calling persist
  const newSnapshot = { ...getSnapshot(store), ...snapshot }
  return newSnapshot
}}

That being said, this could easily just be implemented as a function/closure like all the other internal transforms, it's just a bit of an awkward API as you would pass the store both to persist and to the "transform creator" let's call it:

import { getSnapshot } from 'mobx-state-tree'
import { persist, ITransform } from 'mst-persist'

import { UserStore } from './models'

const user = UserStore.create({name: 'Joe'})
await persist('user', user, {
  transforms: [shallowMerge(user)]
})

function shallowMerge (store) {
  const transform: ITransform = {fromStorage: (snapshot) => {
    // get the store's initial state from .create() and any actions done prior to calling persist
    const newSnapshot = { ...getSnapshot(store), ...snapshot }
    return newSnapshot
  }}
  return transform
}

This way, the "transform creator" way, there's no need to pass any additional arguments to a transform internally, and it's a lot cleaner of an API, albeit more awkward. This is a more composable approach than adding more arguments. All of the options could similarly be passed to any transform -- but that would require duplicating them (and potentially several times if you have multiple complex transforms). "Transform creators" could also make for better typing, as you could be more specific about the types of the arguments passed to your closure.

Migrations

Migrations, if they are to be implemented in this library, would be built on top of the Transform functionality above.

I think redux-persist's Migrations API is pretty good and makes some good trade-offs. The main thing I would change, that is highly requested in redux-persist itself, is specifying an upgrade and downgrade path (similar to how an ORM does migrations as well). I would also move the version argument into the transform itself, something like:

interface IMigrate {
  (version: number, migrations: IMigration[]): ITransform
}

interface IMigration {
  version: number, // this should be unique
  upgrade?: ITransformArgs,
  downgrade?: ITransformArgs,
}

But really the big question here is, should we even implement Migrations in this library? As in, due to the myriad ways a user models their data, does it make more sense to let the user handle migrations themselves in their store or elsewhere? Or should we give them a "best practice" built-in, but let them swap it out if they need to?

I was initially thinking we shouldn't, for a few reasons:

  1. Where do we store the version #? We have some internal logic around storage that really shouldn't be imported if it is meant to be a transform.
    The easiest solution is probably to hijack the snapshot instance and add some mst-persist specific attributes, which I believe is what redux-persist does with its state._persist.version logic (but not sure as I don't totally understand all the redux-persist internals). This would be removed prior to applySnapshot ofc.
    We could also use this to store things like the current mst-persist version or the version of a transform, which could be useful in the future to warn/error against breaking changes or auto-detect and auto-migrate the breaking change (which might not be breaking anymore then).
    This might put some ordering constraints on the migrate transform however and may make things like changing storage engines more difficult (or maybe not, idk)
  2. It moves the version away from the schema. Ideally the version should be as close to the schema as possible. We can workaround this by requesting users to add a special property or map to their model, but that adds a barrier to adoption / usage.

After comparing it more to how ORMs handle migrations, I think it does make more sense to have a "best practice" built-in. ORMs do typically store version numbers in a DB table (a separate one from the model's table), as well as information as to which migrations have been applied or not. There are typically no version #s in the schema definition either.
And, in general, migrations are a fairly common need for a persistence library, so having some out-of-the-box way of handling them is likely better than none, even if the out-of-the-box way is not necessarily the best for everyone's use case. We may not have a "best practice" default initially, but we should ideally strive to have the "best practice" built-in. Making it a transform that's easily swappable allows for competition and innovation.

The ORM comparison gave me some thoughts around whether migrations should be an array or object, whether/how to store which migrations have been run or not, whether to use an auto-generated hash of the schema or not, etc. Might want to examine more ORM code.
To an extent, I think that may add more complexity than necessary, especially for an MVP, and just going with a single version that is an integer that only moves up or down by one is much easier to reason about. We could iterate based on feedback from there.
There are still some questions on error handling that arise even from that, e.g. if migrations contain versions 7, 8, 10, but not 9 (because 7 ate 9) or other such gap, do we error out?, if migrations are not integers, do we error out?, if the current persisted version of data is 5 and we only have migrations for 7+, do we error out? (this last one would likely error on applySnapshot anyway), etc

Plugins

The concept of plugins would basically be a more generic version of transforms that is any to any type and allows you to swap out basically all the internal functionality of mst-persist. Basically, JSON.parse/JSON.stringify, onSnapshot/applySnapshot, and storage.getItem/storage.setItem could be implemented as plugins. Which would mean one could change them and fully customize everything if wanted. They could also change up the ordering of everything if wanted (though, if you're swapping out everything, you don't really need this library at all then). Transforms vs. Plugins would be kind of similar to Webpack's Loaders vs. Plugins.

Some "plugins" could be implemented as say, noop transforms that just have side-effects, but others might need to actually have different return types. While this could be implemented elsewhere, a plugin might be a logical place for some "migrateStorage" functionality that lets you move from one storage engine to another.

Plugins are more future-oriented and not necessarily going to be implemented soon (or ever), but I think the concept is relevant to think about regarding "the future of transforms", especially as transforms are a subset of plugins.


Any and all feedback, comments, and discussion is welcome! Thanks for providing your input ๐Ÿ™‚

Initial State isn't merged -- undefined is not assignable to type

I have the following store setup:

export const createStore = (): RootStoreModel => {
  const app = AppModel.create({});
  const auth = AuthModel.create({});
  const organizations = OrganizationsStore.create({});

  const rootStoreInstance = RootStore.create(
    {
      app,
      auth,
      organizations,
    },
  );

  persist('persistedRootStore', rootStoreInstance, {
    storage: AsyncStorage,
    jsonify: true,
    whitelist: ['auth', 'organizations'],
  }).then(() => {
    console.log('๐Ÿ’ฆ', 'App hydrated');
    rootStoreInstance.app.setHydrated(true);
  });

  return rootStoreInstance;
};

This throws the following error on hydration:

Error: [mobx-state-tree] Error while converting {"auth":{"user":null,"token":null},"organizations":{"list":[]}} to RootStore:

at path "/app" value `undefined` is not assignable to type: `AppModel` (Value is not a plain object).

Error: [mobx-state-tree] Error while converting {"auth":{"user":null,"token":null},"organizations":{"list":[]}} to RootStore:

When I remove the whitelist so that the whole store is persisted it works fine. Am I doing anything wrong? I'm on mobx-state-tree@^3.15.0. and mst-persist@^0.1.3.

Add configurable merge strategies

Per my comments in #1 (comment), should support something like redux-persist's State Reconcilers as merge strategies. This is basically just to handle state created in .create(...), unless persist is called after some actions have been performed (which wouldn't be the standard usage, but is possible ๐Ÿคทโ€โ™‚).

applySnapshot is equivalent to hardSet but with the model's defaults added. Users should be able to configure if they want a different merge strategy like a shallow merge (autoMergeLevel1).

CJS export is missing __esModule - Code transpiled to CJS can't use default export

See #3 (comment) onward.

This is a bug in tsdx (jaredpalmer/tsdx#165) caused by caused by this config option in its rollup config

This bug makes the v0.1.0 release unintentionally breaking (to an extent at least, as CJS support wasn't available before v0.1.0) and as such fixing it should be high prio. The workaround is to not use default exports, but default is used in the README and v0.1.0 should maintain backward compatibility (though minor releases prior to v1 are allowed to be breaking, this was unintentionally breaking)

mst peerDep is outdated

When running npm i this error occurs:

PS C:\Users\alexa\vermehrung> npm i
npm ERR! code ERESOLVE
npm ERR! ERESOLVE unable to resolve dependency tree
npm ERR! 
npm ERR! While resolving: [email protected]
npm ERR! Found: [email protected]
npm ERR! node_modules/mobx-state-tree
npm ERR!   mobx-state-tree@"5.1.8" from the root project
npm ERR! 
npm ERR! Could not resolve dependency:
npm ERR! peer mobx-state-tree@"^3.2.1" from [email protected]
npm ERR! node_modules/mst-persist
npm ERR!   mst-persist@"0.1.3" from the root project
npm ERR! 
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.
npm ERR! 
npm ERR! 
npm ERR! For a full report see:
npm ERR! C:\Users\alexa\AppData\Local\npm-cache\_logs\2023-07-12T13_22_49_699Z-eresolve-report.txt

npm ERR! A complete log of this run can be found in:
npm ERR!     C:\Users\alexa\AppData\Local\npm-cache\_logs\2023-07-12T13_22_49_699Z-debug-0.log

This forces to install using npm i --force which is not recommended.

As no one will be using mobx-state-tree@^3.2.1 any more, it would help to update the peer dependency to a newer version to Fix the upstream dependency conflict.

`persist` should return disposer

This is part feature, as it introduces new functionality, and part bug, as really it should have always returned a disposer to begin with.

Without a disposer there is, well, no way to dispose of the onSnapshot listener, and therefore no way to pause / stop persistence. A disposer is returned from onSnapshot and should be returned by persist. As it currently returns a Promise, it should be within that Promise.

While the current Promise returns the snapshot of persisted data, that's actually undocumented behavior as you can get the snapshot directly from the store anyway. It's not particularly useful behavior as a result; when I originally created this, I just figured I'd return something rather than nothing/void.
So while this would be breaking, I think it should be ok to change without a major as I don't believe anyone's using that functionality (though no one's requested this functionality either, as it's a fairly uncommon need).
As this is part feature and part breaking change, leaning toward releasing this change as a minor (and breaking changes in minors are ok in semver for 0.x releases) rather than a patch.

options.jsonify is always forced to true

The code in index.js has a bug: https://github.com/agilgur5/mst-persist/blob/master/src/index.ts

Look closely at the highlighted usages:

Note this line:

if (!jsonify) { jsonify = true } // default to true like mobx-persist

And note that there is no line after that which sets the jsonify variable's value.

Basically, the line above forces jsonify to always be true, even if the user explicitly sets it to false.

You probably meant this instead:

if (jsonify == null) { jsonify = true } // default to true like mobx-persist

Throws when key is not in storage

Hi there, awesome work!

There's an issue, however, when the storage key doesn't exist. Of course, it can be catched, and then after the first save happens, it won't throw again for that key, but it might be sensible to check for the existence of a key in storage and, if it doesn't exist, save the initial value, if it exists, only then retrieve and hydrate.

Otherwise we'll get ( w/ localforage):

Error: [mobx-state-tree] Error while converting `null` to `AnonymousModel`:
value `null` is not assignable to type: `AnonymousModel` (Value is not a plain object).

Refactor to TypeScript / Add typings

Because there's a high overlap between MobX devs and TS devs (due to MobX / MST being written in [some super complex] TS).

Could also use tsc or tsdx to output CJS builds and resolve #3, feeding two birds with one seed ๐Ÿ˜ƒ

Could add typings separately to DefinitelyTyped first in incremental progress, but I'll probably end up doing it all at once (and then no need to confuse users to switch back / remove @types or keep @types backward compatible)

Add CI

While tests are not yet completed (#4), we could add a simple CI layer to at least double-check that compilation from TS to JS passes. Potentially could test an npm pack afterward too.

Persist changed value from `true` to `false`

Hi.

Environment info

React native info output:

 System:
    OS: Linux 5.3 Ubuntu 18.04.4 LTS (Bionic Beaver)
    CPU: (8) x64 Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
    Memory: 12.78 GB / 31.23 GB
    Shell: 4.4.20 - /bin/bash
  Binaries:
    Node: 12.16.1 - /usr/bin/node
    Yarn: 1.21.1 - /usr/bin/yarn
    npm: 6.13.4 - /usr/bin/npm
  npmPackages:
    react: 16.9.0 => 16.9.0 
    react-native: 0.61.5 => 0.61.5

The problem is, when i persist RootStore with nested AuthStore, all my boolean value form AuthStore become from true to false.

When i just console.log its value, i have this behaviour

const { isLoading } = authStore;

console.log('isLoading => ', isLoading);

image

It is RootStore with nested AuthStore

import { types } from 'mobx-state-tree';
import { connectReduxDevtools } from 'mst-middlewares';
import AuthStore from './auth.store';
import { persist } from 'mst-persist';
import AsyncStorage from '@react-native-community/async-storage';

let store: any = null;

export default () => {
  if (store) return store;

  const RootStore = types
    .model('RootStore', {
      identifier: types.optional(types.identifier, 'RootStore'),
      auth: AuthStore,
    });

  store = RootStore.create({
    auth: {
      isLoading: true,
    },
  });

  /**
   * Not working in debug mode
   */
  persist('rootStore', store, {
    storage: AsyncStorage,
  }).then(() => {});

  // @ts-ignore
  if (__DEV__ && Boolean(window.navigator.userAgent)) {
    connectReduxDevtools(require('remotedev'), store);
  }

  return store;
};

There is AuthStore

import { types } from 'mobx-state-tree';
const AuthStore = types
  .model({
    isLoading: types.optional(types.boolean, true),
  });

export default AuthStore;

Persist Array in React Native, empty string? -- can't repro

Why if I add mst-persist all keys are kept except finishArr which becomes an empty string?

Isn't it possible to store arrays in it?

const Dice = types
.model('Dice', {
    startGame: types.boolean,
    count: types.number,
    players: types.number,
    message: types.string,
    multi: types.number,
    finishArr: types.array(types.boolean)
  })
  
const DiceStore = Dice.create({
  startGame: false,
  count: 6,
  players: 1,
  message: ' ',
  multi: 0,
  finishArr: [true, true, true, true, true, true]
})

persist('@DiceStore', DiceStore, {
  storage: AsyncStorage, // AsyncStorage for React-Native, localStorage for web
  jsonify: true, // Set to true if using AsyncStorage
  whitelist: ['startGame', 'count', 'players', 'message', 'multi', 'gamer', 'finishArr'] // only these keys will be persisted
})

When using AsyncLocalStorage, "Illegal invocation" error occurs

My config is pretty simple:

persist('some', storeM, {
	blacklist: [],
}).then(() => { ... });

Yet I get this error:

VM1685:1 Uncaught TypeError: Illegal invocation
    at eval (eval at callWithPromise (mst-persist.esm.js:42), <anonymous>:1:1)
    at callWithPromise (mst-persist.esm.js:42)
    at Object.getItem (mst-persist.esm.js:26)
    at persist (mst-persist.esm.js:89)
    at RootUIWrapper.ComponentWillMount (Root.js:198)
    at RootUIWrapper.UNSAFE_componentWillMount (index.js:694)
    at callComponentWillMount (react-dom.development.js:13371)
    at mountClassInstance (react-dom.development.js:13461)
    at updateClassComponent (react-dom.development.js:16986)
    at beginWork$1 (react-dom.development.js:18505)

Googled it, and it appears to be the same issue here: https://stackoverflow.com/questions/41126149/using-a-shortcut-function-gives-me-a-illegal-invocation-error

To fix, you can either bind the getItem/setItem functions to the localStorage object, or do the equivalent by using a (...args)=>localStorage.XXX(...args) wrapper function.

For now I am fixing it by globally applying the bind:

window.localStorage.getItem = window.localStorage.getItem.bind(window.localStorage);
window.localStorage.setItem = window.localStorage.setItem.bind(window.localStorage);

However, the library should not require the developer to apply this global binding.

Add Deep Whitelists & Blacklists

Go multiple levels deep with whitelists and blacklists. Right now you can only go one level deep, which misses a good number of use cases. So instead of just manga, could do manga.release too.

The dotted syntax makes sense to me, though not sure how to handle something like an array... just implement on all elements? Or require a more explicit []. or something?

This has popped up in agilgur5/react-native-manga-reader-app#27 and #26 .

Should really be implemented on top of Transforms #16 , but probably should be the default behavior too, not a separate transform.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.