Repository: effector/effector (ts)

Issue:
Custom serialization for stores
<!--
Please fill out this entire template so that we can evaluate your proposal as quickly as possible.
-->

## Proposal

Currently we can only disable serialization of store's value via `serialize: 'ignore'` setting, but sometimes we do want to transfer state from server to the client, even if this state is represented in non-json shape - e.g. `Date` or `Map`

Idea is to add custom serialization config to stores:

```ts
const $map = createStore(new Map<number, number>(), {
      serialize: {
        write: map => [...map.entries()],
        read: jsonMap => new Map(jsonMap),
      },
})
```

Which would allow to introduce custom serialization to SSR usecases:
1. We have a store with non-serializable value - e.g. Map
```ts
    const $map = createStore(new Map<number, number>(), {
      serialize: {
        write: map => [...map.entries()],
        read: jsonMap => new Map(jsonMap),
      },
    })
```

2. During SSR we are creating separate `Scope` for each request, as usual, then run the logic
```ts
const scope = fork()

await allSettled(appStarted, {scope, params: req })
```

3. After logic is settled, we need to extract stores data to stringify it to JSON to transfer to client.
At this moment `serialize.write` method of `$map` will be called

```ts
const serverValues = serialize(scope)

serverValues[$map.sid] // has number[][], which is json-representation of Map<number, number>

const jsonString = JSON.stringify(serverValues) // no errors, which would happen because of Map
```

4. At the client we want to create a client scope and fill it with values from the server.
At this moment `serialize.read` method of `$map` is called

```ts
const clientScope = fork({
 values: serverValues
})

clientScope.getState($map) // Map<number, number> with values from the server

```

**Caveats**
We have two ways to setup `values` in `fork` call:
- `Map<Store, Value> | [Store, Value][]`, which is used for tests and sometimes as a di-mechanism
- `Record<string, Value>`, which is used for server-calculated values in the shape of json
We need a way to decide, when we need to call `serialize.read` methods and when we don't need it

I think, in the first case we have a clear user intent to set a specific value to specific store:
```ts
const scope = fork({
 values: [
    [$map, new Map(...)]
 ]
})
```
so here we don't need to deal with serialization at all.

The second case is not considered a public api for manual values setup - only to provide json with server values:
```ts
fork({
  values: {
    "-dsg4gf": [[0, 1], [2, 3]]
  }
})
```
so here we clearly need to use `serialize.read`

Pull request:
Add custom serialized for Store
Closes #756 

### Conventions
- [ ] Please check your messages [against the guidelines](https://cbea.ms/git-commit/) if not, perform [interactive rebase](https://thoughtbot.com/blog/git-interactive-rebase-squash-amend-rewriting-history)
- [ ] [Link an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) your pull request closes or relates


Patch:
diff --git a/packages/effector/index.d.ts b/packages/effector/index.d.ts
index e75bed2f..3e7a78fb 100644
--- a/packages/effector/index.d.ts
+++ b/packages/effector/index.d.ts
@@ -14,6 +14,18 @@ type Tuple<T = unknown> = [T?, ...T[]]
  */
 type NoInfer<T> = [T][T extends any ? 0 : never]
 
+/**
+ * Generic Json type
+ */
+ type Json =
+ | null
+ | undefined
+ | boolean
+ | string
+ | number
+ | Json[]
+ | {[k: string]: Json}
+
 // Type for extention purpose. Represents combinable sample source.
 export type Combinable = {[key: string]: Store<any>} | Tuple<Store<any>>
 // Helper type, which unwraps combinable sample source value.
@@ -300,22 +312,32 @@ export class Domain implements Unit<any> {
   }): Effect<Params, Done, Fail>
   domain(name?: string): Domain
   createDomain(name?: string): Domain
-  store<State>(
+  store<State, SerializedState extends Json = Json>(
     defaultState: State,
     config?: {
       name?: string
       sid?: string
       updateFilter?: (update: State, current: State) => boolean
-      serialize?: 'ignore'
+      serialize?:
+      | 'ignore'
+      | {
+          write: (state: State) => SerializedState
+          read: (json: SerializedState) => State
+        }
     },
   ): Store<State>
-  createStore<State>(
+  createStore<State, SerializedState extends Json = Json>(
     defaultState: State,
     config?: {
       name?: string
       sid?: string
       updateFilter?: (update: State, current: State) => boolean
-      serialize?: 'ignore'
+      serialize?:
+        | 'ignore'
+        | {
+            write: (state: State) => SerializedState
+            read: (json: SerializedState) => State
+          }
     },
   ): Store<State>
   sid: string | null
@@ -660,13 +682,18 @@ export function createEffect<Params, Done, Fail = Error>(config: {
  * @param defaultState default state
  * @param config optional configuration object
  */
-export function createStore<State>(
+export function createStore<State, SerializedState extends Json = Json>(
   defaultState: State,
   config?: {
     name?: string;
     sid?: string
     updateFilter?: (update: State, current: State) => boolean
-    serialize?: 'ignore'
+    serialize?:
+    | 'ignore'
+    | {
+        write: (state: State) => SerializedState
+        read: (json: SerializedState) => State
+      }
     domain?: Domain;
   },
 ): Store<State>
diff --git a/src/effector/createUnit.ts b/src/effector/createUnit.ts
index 9785677c..34072891 100644
--- a/src/effector/createUnit.ts
+++ b/src/effector/createUnit.ts
@@ -333,12 +333,21 @@ export function createStore<State>(
     meta,
     regional: true,
   })
+  const serializeMeta = getMeta(store, 'serialize')
   const derived = getMeta(store, 'derived')
-  const ignored = getMeta(store, 'serialize') === 'ignore'
+  const ignored = serializeMeta === 'ignore'
+  const customSerialize = !serializeMeta || ignored ? false : serializeMeta
   const sid: string | null = getMeta(store, 'sid')
   if (sid) {
     if (!ignored) setMeta(store, 'storeChange', true)
     plainState.sid = sid
+
+    if (customSerialize) {
+      plainState.meta = {
+        ...plainState?.meta,
+        serialize: customSerialize,
+      }
+    }
   }
   if (!sid && !ignored && !derived) {
     setMeta(store, 'warnSerialize', true)
diff --git a/src/effector/fork/createScope.ts b/src/effector/fork/createScope.ts
index 97913b5f..c564ce54 100644
--- a/src/effector/fork/createScope.ts
+++ b/src/effector/fork/createScope.ts
@@ -69,6 +69,11 @@ export function createScope(unit?: Domain): Scope {
             const sid = getMeta(storeNode, 'sid')
             forkPage.sidIdMap[sid] = id
             forkPage.sidValuesMap[sid] = value
+
+            const serialize = getMeta(storeNode, 'serialize')
+            if (serialize && serialize !== 'ignore') {
+              forkPage.sidSerializeMap[sid] = serialize.write
+            }
           }
         }
       }),
@@ -99,6 +104,7 @@ export function createScope(unit?: Domain): Scope {
     reg: page,
     sidValuesMap: {},
     sidIdMap: {},
+    sidSerializeMap: {},
     getState(store: StateRef | Store<any>) {
       if ('current' in store) {
         return getPageRef(currentPage, resultScope, null, store).current
diff --git a/src/effector/fork/fork.ts b/src/effector/fork/fork.ts
index 89d34163..863bff3f 100644
--- a/src/effector/fork/fork.ts
+++ b/src/effector/fork/fork.ts
@@ -37,6 +37,8 @@ export function fork(
         assert(is.store(unit), 'Values map can contain only stores as keys'),
       )
       Object.assign(scope.sidValuesMap, valuesSidMap)
+      scope.fromSerialize =
+        !Array.isArray(config.values) && !(config.values instanceof Map)
     }
     if (config.handlers) {
       scope.handlers = normalizeValues(config.handlers, unit =>
diff --git a/src/effector/fork/hydrate.ts b/src/effector/fork/hydrate.ts
index 85eb25ad..0e24d43b 100644
--- a/src/effector/fork/hydrate.ts
+++ b/src/effector/fork/hydrate.ts
@@ -5,7 +5,7 @@ import type {Domain, Scope, ValuesMap} from '../unit.h'
 import type {Node} from '../index.h'
 import {add, includes} from '../collection'
 import {normalizeValues, traverseStores} from './util'
-import {getGraph} from '../getter'
+import {getGraph, getMeta} from '../getter'
 
 /**
  hydrate state on client
@@ -39,6 +39,10 @@ export function hydrate(domain: Domain | Scope, {values}: {values: ValuesMap}) {
     // forkPage.sidIdMap[sid] = node.scope.state.id
     if (includes(valuesSidList, sid)) {
       add(storeNodes, node)
+      const serializer = getMeta(node, 'serialize')
+      if (serializer && serializer !== 'ignore') {
+        normalizedValues[sid] = serializer.read(normalizedValues[sid])
+      }
       add(storeValues, normalizedValues[sid])
     }
   })
diff --git a/src/effector/fork/serialize.ts b/src/effector/fork/serialize.ts
index 1fb9ef47..6910e472 100644
--- a/src/effector/fork/serialize.ts
+++ b/src/effector/fork/serialize.ts
@@ -4,6 +4,7 @@ import {assert} from '../throw'
 import {traverseStores} from './util'
 import {getGraph, getMeta} from '../getter'
 
+const noopSerializer = (x: any) => x
 /**
  serialize state on server
  */
@@ -21,11 +22,12 @@ export function serialize(
   forIn(scope.sidValuesMap, (value, sid) => {
     if (includes(ignoredStores, sid)) return
     const id = scope.sidIdMap[sid]
+    const serializer = scope.sidSerializeMap[sid] || noopSerializer
     // if (!scope.changedStores.has(id)) return
     if (id && id in scope.reg) {
-      result[sid] = scope.reg[id].current
+      result[sid] = serializer(scope.reg[id].current)
     } else {
-      result[sid] = value
+      result[sid] = serializer(value)
     }
   })
   if ('onlyChanges' in config && !config.onlyChanges) {
diff --git a/src/effector/index.h.ts b/src/effector/index.h.ts
index 0d03cffc..a1ac686a 100644
--- a/src/effector/index.h.ts
+++ b/src/effector/index.h.ts
@@ -19,6 +19,9 @@ export type StateRef = {
   before?: StateRefOp[]
   noInit?: boolean
   sid?: string
+  meta?: {
+    serialize?: 'ignore' | {read: (p: any) => any; write: (p: any) => any}
+  }
 }
 
 export type Config = {
diff --git a/src/effector/kernel.ts b/src/effector/kernel.ts
index 13574090..f2e0c883 100644
--- a/src/effector/kernel.ts
+++ b/src/effector/kernel.ts
@@ -455,11 +455,14 @@ export function launch(unit: any, payload?: any, upsert?: boolean) {
   forkPage = getForkPage(lastStartedState)
 }
 
+const noopParser = (x: any) => x
+
 export const initRefInScope = (
   scope: {
     reg: Record<string, StateRef>
     sidValuesMap: Record<string, any>
     sidIdMap: Record<string, string>
+    fromSerialize?: boolean
   },
   sourceRef: StateRef,
   isGetState?: boolean,
@@ -468,14 +471,20 @@ export const initRefInScope = (
 ) => {
   const refsMap = scope.reg
   const sid = sourceRef.sid
+  const serialize = sourceRef?.meta?.serialize
+  const parser =
+    scope.fromSerialize && serialize !== 'ignore'
+      ? serialize?.read || noopParser
+      : noopParser
   if (refsMap[sourceRef.id]) return
   const ref: StateRef = {
     id: sourceRef.id,
     current: sourceRef.current,
+    meta: sourceRef.meta,
   }
 
   if (sid && sid in scope.sidValuesMap && !(sid in scope.sidIdMap)) {
-    ref.current = scope.sidValuesMap[sid]
+    ref.current = parser(scope.sidValuesMap[sid])
   } else {
     if (sourceRef.before && !softRead) {
       let isFresh = false
diff --git a/src/effector/unit.h.ts b/src/effector/unit.h.ts
index 4086e025..83c57dfe 100644
--- a/src/effector/unit.h.ts
+++ b/src/effector/unit.h.ts
@@ -164,6 +164,7 @@ export interface Domain extends Unit {
 }
 
 export interface Scope extends Unit {
+  fromSerialize?: boolean
   kind: Kind
   graphite: Node
   reg: Record<string, StateRef>
@@ -173,6 +174,7 @@ export interface Scope extends Unit {
   /** value could be set only for stores with sid (they can be created by createStore, restore and combine) */
   sidValuesMap: Record<string, any>
   sidIdMap: Record<string, string>
+  sidSerializeMap: Record<string, (p: any) => any>
   additionalLinks: Record<string, Node[]>
   handlers: Record<string, (params: unknown) => any>
   fxCount: Node


Test patch:
diff --git a/src/effector/__tests__/fork/serialize.test.ts b/src/effector/__tests__/fork/serialize.test.ts
index 4b88fbcc..c5d6d123 100644
--- a/src/effector/__tests__/fork/serialize.test.ts
+++ b/src/effector/__tests__/fork/serialize.test.ts
@@ -1,4 +1,5 @@
 import {
+  Scope,
   createStore,
   createEvent,
   createDomain,
@@ -68,6 +69,125 @@ test('serialize: ignore', async () => {
   expect(serialize(scope)).toEqual({b: 1})
 })
 
+describe('serialize: custom', () => {
+  test('base case', async () => {
+    expect.assertions(4)
+    let currentScope: Scope
+    const aToStr = (v: number) => `${v}`
+    const inc = createEvent()
+    const $a = createStore(0, {
+      sid: 'a',
+      serialize: {
+        write: value => {
+          expect(currentScope.getState($a)).toEqual(value)
+          return aToStr(value)
+        },
+        read: json => {
+          expect(aToStr(currentScope.getState($a))).toEqual(json)
+          return Number(json)
+        },
+      },
+    })
+    const $b = createStore(0, {sid: 'b'})
+    $a.on(inc, x => x + 1)
+    $b.on(inc, x => x + 1)
+
+    const scope = fork()
+
+    await allSettled(inc, {scope})
+
+    currentScope = scope
+    const values = serialize(scope)
+
+    expect(values).toEqual({a: '1', b: 1})
+
+    const clientScope = fork({
+      values,
+    })
+    expect(clientScope.getState($a)).toEqual(1)
+  })
+
+  test('Map usecase', () => {
+    const $map = createStore(new Map<number, number>(), {
+      sid: 'map',
+      serialize: {
+        write: map => [...map.entries()],
+        read: jsonMap => new Map(jsonMap),
+      },
+    })
+
+    const scope = fork()
+
+    allSettled($map, {scope, params: new Map().set(1, 2).set(2, 3)})
+
+    const values = serialize(scope)
+
+    expect(values).toEqual({
+      map: [...new Map().set(1, 2).set(2, 3)],
+    })
+
+    const clientScope = fork({
+      values,
+    })
+
+    expect(clientScope.getState($map)).toEqual(new Map().set(1, 2).set(2, 3))
+  })
+
+  test('hydrate case', () => {
+    const domain = createDomain()
+    const $map = domain.createStore(new Map<number, number>(), {
+      sid: 'map',
+      serialize: {
+        write: map => [...map.entries()],
+        read: jsonMap => new Map(jsonMap),
+      },
+    })
+
+    const scope = fork(domain)
+
+    allSettled($map, {scope, params: new Map().set(1, 2).set(2, 3)})
+
+    const values = serialize(scope)
+
+    expect(values).toEqual({
+      map: [...new Map().set(1, 2).set(2, 3)],
+    })
+
+    const clientScope = fork(domain)
+
+    hydrate(clientScope, {values})
+
+    expect(clientScope.getState($map)).toEqual(new Map().set(1, 2).set(2, 3))
+  })
+  test('does not affect normal ref initialization', () => {
+    const parser = jest.fn()
+    const up = createEvent()
+    const $a = createStore(0, {
+      serialize: {
+        write: value => {
+          return `${value}`
+        },
+        read: _json => {
+          parser()
+          return 42
+        },
+      },
+    }).on(up, s => s + 1)
+
+    const scopeA = fork()
+    allSettled(up, {scope: scopeA})
+    expect(scopeA.getState($a)).toEqual(1)
+
+    const scopeB = fork({
+      values: [[$a, 6]],
+    })
+    allSettled(up, {scope: scopeB})
+    expect(scopeB.getState($a)).toEqual(7)
+
+    expect(parser).toBeCalledTimes(0)
+  })
+})
+
 it('serialize stores in nested domain', () => {
   const app = createDomain()
   const first = app.createDomain()
diff --git a/src/types/__tests__/effector/fork.test.ts b/src/types/__tests__/effector/fork.test.ts
index bddb75f7..c5a3a7a2 100644
--- a/src/types/__tests__/effector/fork.test.ts
+++ b/src/types/__tests__/effector/fork.test.ts
@@ -1,5 +1,5 @@
 /* eslint-disable no-unused-vars */
-import {createDomain, Store, fork, serialize, allSettled} from 'effector'
+import {createDomain, Store, fork, serialize, allSettled, Json} from 'effector'
 
 const typecheck = '{global}'
 
@@ -51,6 +51,73 @@ describe('serialize cases (should fail)', () => {
   })
 })
 
+describe('custom serialize for stores', () => {
+  test('Correct case', () => {
+    const d = createDomain()
+    const $map = d.createStore<Map<number, number>, [number, number][]>(
+      new Map<number, number>(),
+      {
+        serialize: {
+          write: map => {
+            const result = [...map.entries()]
+
+            return result
+          },
+          read: jsonMap => {
+            return new Map(jsonMap)
+          },
+        },
+      },
+    )
+
+    expect(typecheck).toMatchInlineSnapshot(`
+      "
+      no errors
+      "
+    `)
+  })
+  test('Incorrect case', () => {
+    const d = createDomain()
+    const $map = d.createStore<Map<number, number>>(new Map<number, number>(), {
+      serialize: {
+        // @ts-expect-error
+        write: map => {
+          return map // non-Json value
+        },
+        // @ts-expect-error
+        read: jsonMap => {
+          const serializedValue: typeof jsonMap extends Json
+            ? typeof jsonMap
+            : never = jsonMap
+
+          // @ts-expect-error
+          return new Map(serializedValue)
+        },
+      },
+    })
+
+    expect(typecheck).toMatchInlineSnapshot(`
+      "
+      Type '(map: Map<number, number>) => Map<number, number>' is not assignable to type '(state: Map<number, number>) => Json'.
+        Type 'Map<number, number>' is not assignable to type 'Json'.
+          Type 'Map<number, number>' is not assignable to type '{ [k: string]: Json; }'.
+            Index signature for type 'string' is missing in type 'Map<number, number>'.
+      Type '(jsonMap: Json) => Map<string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined, string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined>' is not assignable to type '(json: Json) => Map<number, number>'.
+        Type 'Map<string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined, string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined>' is not assignable to type 'Map<number, number>'.
+          Type 'string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined' is not assignable to type 'number'.
+            Type 'undefined' is not assignable to type 'number'.
+      No overload matches this call.
+        Overload 1 of 3, '(iterable: Iterable<readonly [string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined, string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined]>): Map<...>', gave the following error.
+          Argument of type 'Json' is not assignable to parameter of type 'Iterable<readonly [string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined, string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined]>'.
+            Type 'undefined' is not assignable to type 'Iterable<readonly [string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined, string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined]>'.
+        Overload 2 of 3, '(entries?: readonly (readonly [string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined, string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined])[] | null | undefined): Map<...>', gave the following error.
+          Argument of type 'Json' is not assignable to parameter of type 'readonly (readonly [string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined, string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined])[] | null | undefined'.
+            Type 'string' is not assignable to type 'readonly (readonly [string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined, string | number | boolean | Json[] | { [k: string]: Json; } | null | undefined])[] | null | undefined'.
+      "
+    `)
+  })
+})
+
 describe('fork values', () => {
   describe('without type annotations (should pass)', () => {
     test('fork values as js Map', () => {