palistor 0.0.25 → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/README.md +574 -542
  2. package/README.ru.md +979 -0
  3. package/dist/index.d.ts +16 -27
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +17 -28
  6. package/dist/index.js.map +1 -1
  7. package/dist/react/createTrackingProxy.d.ts +27 -27
  8. package/dist/react/createTrackingProxy.d.ts.map +1 -1
  9. package/dist/react/createTrackingProxy.js +54 -53
  10. package/dist/react/createTrackingProxy.js.map +1 -1
  11. package/dist/react/useForm.d.ts +42 -43
  12. package/dist/react/useForm.d.ts.map +1 -1
  13. package/dist/react/useForm.js +38 -38
  14. package/dist/react/useForm.js.map +1 -1
  15. package/dist/react/usePersist.d.ts +14 -14
  16. package/dist/react/usePersist.js +18 -18
  17. package/dist/react/usePersist.js.map +1 -1
  18. package/dist/react/useStoreContext.d.ts +11 -11
  19. package/dist/react/useStoreContext.js +11 -11
  20. package/dist/react/useTranslator.d.ts +12 -12
  21. package/dist/react/useTranslator.js +12 -12
  22. package/dist/store/applyPatch/applyPatch.d.ts +8 -8
  23. package/dist/store/applyPatch/applyPatch.js +12 -12
  24. package/dist/store/applyPatch/applyPatch.js.map +1 -1
  25. package/dist/store/buildProxy/buildEntityProjectionProxy.js +6 -6
  26. package/dist/store/buildProxy/buildEntityProjectionProxy.js.map +1 -1
  27. package/dist/store/buildProxy/buildListProxy.d.ts +8 -8
  28. package/dist/store/buildProxy/buildListProxy.js +30 -30
  29. package/dist/store/buildProxy/buildListProxy.js.map +1 -1
  30. package/dist/store/buildProxy/buildProxy.d.ts +8 -8
  31. package/dist/store/buildProxy/buildProxy.js +39 -39
  32. package/dist/store/buildProxy/buildProxy.js.map +1 -1
  33. package/dist/store/buildProxy/computeProxyKeys.d.ts +8 -8
  34. package/dist/store/buildProxy/computeProxyKeys.js +9 -9
  35. package/dist/store/buildProxy/computeProxyKeys.js.map +1 -1
  36. package/dist/store/buildProxy/handleLazyResolve.d.ts +3 -3
  37. package/dist/store/buildProxy/handleLazyResolve.js +3 -3
  38. package/dist/store/buildProxy/initProxyCaches.d.ts +5 -5
  39. package/dist/store/buildProxy/initProxyCaches.js +5 -5
  40. package/dist/store/compute/computeFieldState.d.ts +3 -3
  41. package/dist/store/compute/computeFieldState.js +8 -8
  42. package/dist/store/compute/computeFieldState.js.map +1 -1
  43. package/dist/store/compute/fieldStateChanged.d.ts +1 -1
  44. package/dist/store/compute/fieldStateChanged.js +1 -1
  45. package/dist/store/compute/recompute/collectGroupComputeNodes.d.ts +4 -4
  46. package/dist/store/compute/recompute/collectGroupComputeNodes.js +7 -7
  47. package/dist/store/compute/recompute/collectGroupComputeNodes.js.map +1 -1
  48. package/dist/store/compute/recompute/recomputeAndNotify.d.ts +3 -3
  49. package/dist/store/compute/recompute/recomputeAndNotify.js +3 -3
  50. package/dist/store/compute/recompute/recomputeLeaves.d.ts +11 -10
  51. package/dist/store/compute/recompute/recomputeLeaves.d.ts.map +1 -1
  52. package/dist/store/compute/recompute/recomputeLeaves.js +26 -27
  53. package/dist/store/compute/recompute/recomputeLeaves.js.map +1 -1
  54. package/dist/store/compute/recompute/recomputeTargeted.d.ts +7 -7
  55. package/dist/store/compute/recompute/recomputeTargeted.js +12 -12
  56. package/dist/store/compute/recompute/recomputeTargeted.js.map +1 -1
  57. package/dist/store/compute/recompute/topologicalSortComputed.d.ts +4 -4
  58. package/dist/store/compute/recompute/topologicalSortComputed.d.ts.map +1 -1
  59. package/dist/store/compute/recompute/topologicalSortComputed.js +11 -13
  60. package/dist/store/compute/recompute/topologicalSortComputed.js.map +1 -1
  61. package/dist/store/compute/recompute/types.d.ts +4 -4
  62. package/dist/store/compute/resolveFlag.d.ts +2 -2
  63. package/dist/store/compute/resolveFlag.js +2 -2
  64. package/dist/store/compute/resolveString.d.ts +5 -5
  65. package/dist/store/compute/resolveString.js +6 -6
  66. package/dist/store/compute/resolveString.js.map +1 -1
  67. package/dist/store/compute/types.d.ts +3 -3
  68. package/dist/store/constants.d.ts +48 -48
  69. package/dist/store/constants.d.ts.map +1 -1
  70. package/dist/store/constants.js +45 -45
  71. package/dist/store/defineFieldMapping.d.ts +21 -19
  72. package/dist/store/defineFieldMapping.d.ts.map +1 -1
  73. package/dist/store/defineFieldMapping.js +21 -19
  74. package/dist/store/defineFieldMapping.js.map +1 -1
  75. package/dist/store/dirtyTracking/collectInitialSnapshot.js +1 -1
  76. package/dist/store/dirtyTracking/collectInitialSnapshot.js.map +1 -1
  77. package/dist/store/dirtyTracking/recomputeDirtyTargeted.d.ts +3 -3
  78. package/dist/store/dirtyTracking/recomputeDirtyTargeted.js +11 -11
  79. package/dist/store/dirtyTracking/recomputeDirtyTargeted.js.map +1 -1
  80. package/dist/store/dirtyTracking/setGroupRevalidate.d.ts +6 -6
  81. package/dist/store/dirtyTracking/setGroupRevalidate.js +7 -7
  82. package/dist/store/dirtyTracking/setGroupRevalidate.js.map +1 -1
  83. package/dist/store/entityRegistry/entityRegistry.d.ts +72 -72
  84. package/dist/store/entityRegistry/entityRegistry.d.ts.map +1 -1
  85. package/dist/store/entityRegistry/entityRegistry.js +91 -91
  86. package/dist/store/entityRegistry/entityRegistry.js.map +1 -1
  87. package/dist/store/entityRegistry/generateId.d.ts +3 -3
  88. package/dist/store/entityRegistry/generateId.js +3 -3
  89. package/dist/store/entityRegistry/types.d.ts +17 -17
  90. package/dist/store/entityRegistry/types.d.ts.map +1 -1
  91. package/dist/store/flow/buildFlowStepsProxy.d.ts +9 -9
  92. package/dist/store/flow/buildFlowStepsProxy.js +13 -13
  93. package/dist/store/flow/buildFlowStepsProxy.js.map +1 -1
  94. package/dist/store/flow/defineFlow.d.ts +27 -25
  95. package/dist/store/flow/defineFlow.d.ts.map +1 -1
  96. package/dist/store/flow/defineFlow.js +15 -13
  97. package/dist/store/flow/defineFlow.js.map +1 -1
  98. package/dist/store/flow/flowApi.d.ts +3 -2
  99. package/dist/store/flow/flowApi.d.ts.map +1 -1
  100. package/dist/store/flow/flowApi.js +1 -1
  101. package/dist/store/flow/flowApi.js.map +1 -1
  102. package/dist/store/flow/flowNavigation.d.ts +65 -63
  103. package/dist/store/flow/flowNavigation.d.ts.map +1 -1
  104. package/dist/store/flow/flowNavigation.js +74 -72
  105. package/dist/store/flow/flowNavigation.js.map +1 -1
  106. package/dist/store/flow/flowState.d.ts +16 -16
  107. package/dist/store/flow/flowState.d.ts.map +1 -1
  108. package/dist/store/flow/flowState.js +5 -5
  109. package/dist/store/flow/flowState.js.map +1 -1
  110. package/dist/store/groupDeps/createGroupDeps.d.ts +3 -3
  111. package/dist/store/groupDeps/createGroupDeps.js +6 -6
  112. package/dist/store/groupDeps/createGroupDeps.js.map +1 -1
  113. package/dist/store/groupDeps/createTrackingValues.d.ts +9 -9
  114. package/dist/store/groupDeps/createTrackingValues.js +11 -11
  115. package/dist/store/groupDeps/createTrackingValues.js.map +1 -1
  116. package/dist/store/groupDeps/getNodeGroupPath.d.ts +3 -3
  117. package/dist/store/groupDeps/getNodeGroupPath.js +5 -5
  118. package/dist/store/groupDeps/getNodeGroupPath.js.map +1 -1
  119. package/dist/store/groupDeps/getRecipientGroups.d.ts +1 -1
  120. package/dist/store/groupDeps/getRecipientGroups.js +1 -1
  121. package/dist/store/groupDeps/groupDeps.d.ts +7 -7
  122. package/dist/store/groupDeps/groupDeps.js +7 -7
  123. package/dist/store/groupDeps/pairKey.d.ts +3 -3
  124. package/dist/store/groupDeps/pairKey.d.ts.map +1 -1
  125. package/dist/store/groupDeps/pairKey.js +4 -4
  126. package/dist/store/groupDeps/pairKey.js.map +1 -1
  127. package/dist/store/groupDeps/resolveGroupByPath.d.ts +3 -3
  128. package/dist/store/groupDeps/resolveGroupByPath.js +3 -3
  129. package/dist/store/init/createNotificationHub.d.ts +31 -31
  130. package/dist/store/init/createNotificationHub.d.ts.map +1 -1
  131. package/dist/store/init/createNotificationHub.js +36 -37
  132. package/dist/store/init/createNotificationHub.js.map +1 -1
  133. package/dist/store/init/createResolveManager.d.ts +72 -69
  134. package/dist/store/init/createResolveManager.d.ts.map +1 -1
  135. package/dist/store/init/createResolveManager.js +101 -98
  136. package/dist/store/init/createResolveManager.js.map +1 -1
  137. package/dist/store/init/initGroupSubmitting.d.ts +2 -2
  138. package/dist/store/init/initGroupSubmitting.js +4 -4
  139. package/dist/store/init/initGroupSubmitting.js.map +1 -1
  140. package/dist/store/normalizeConfig.d.ts +20 -18
  141. package/dist/store/normalizeConfig.d.ts.map +1 -1
  142. package/dist/store/normalizeConfig.js +31 -29
  143. package/dist/store/normalizeConfig.js.map +1 -1
  144. package/dist/store/onChangePipeline/computeFieldKey.d.ts +2 -2
  145. package/dist/store/onChangePipeline/computeFieldKey.js +2 -2
  146. package/dist/store/onChangePipeline/findOnChangeAncestors.d.ts +2 -2
  147. package/dist/store/onChangePipeline/findOnChangeAncestors.js +4 -4
  148. package/dist/store/onChangePipeline/findOnChangeAncestors.js.map +1 -1
  149. package/dist/store/onChangePipeline/onChangePipeline.d.ts +13 -12
  150. package/dist/store/onChangePipeline/onChangePipeline.d.ts.map +1 -1
  151. package/dist/store/onChangePipeline/onChangePipeline.js +14 -13
  152. package/dist/store/onChangePipeline/onChangePipeline.js.map +1 -1
  153. package/dist/store/persist/drivers.d.ts +7 -7
  154. package/dist/store/persist/drivers.js +8 -8
  155. package/dist/store/persist/drivers.js.map +1 -1
  156. package/dist/store/persist/index.d.ts +1 -1
  157. package/dist/store/persist/persistManager.d.ts +23 -23
  158. package/dist/store/persist/persistManager.d.ts.map +1 -1
  159. package/dist/store/persist/persistManager.js +51 -51
  160. package/dist/store/persist/persistManager.js.map +1 -1
  161. package/dist/store/persist/types.d.ts +23 -23
  162. package/dist/store/persist/types.d.ts.map +1 -1
  163. package/dist/store/persist/types.js +4 -4
  164. package/dist/store/resetPipeline/buildResetPatch.d.ts +5 -5
  165. package/dist/store/resetPipeline/buildResetPatch.js +5 -5
  166. package/dist/store/resetPipeline/collectDefaults.d.ts +5 -5
  167. package/dist/store/resetPipeline/collectDefaults.js +6 -6
  168. package/dist/store/resetPipeline/collectDefaults.js.map +1 -1
  169. package/dist/store/resetPipeline/resetPipeline.d.ts +7 -7
  170. package/dist/store/resetPipeline/resetPipeline.js +15 -15
  171. package/dist/store/resetPipeline/resetPipeline.js.map +1 -1
  172. package/dist/store/resolvePipeline/applyPendingWrites.d.ts +10 -10
  173. package/dist/store/resolvePipeline/applyPendingWrites.d.ts.map +1 -1
  174. package/dist/store/resolvePipeline/applyPendingWrites.js +35 -38
  175. package/dist/store/resolvePipeline/applyPendingWrites.js.map +1 -1
  176. package/dist/store/resolvePipeline/createContextTrackingProxy.d.ts +8 -7
  177. package/dist/store/resolvePipeline/createContextTrackingProxy.d.ts.map +1 -1
  178. package/dist/store/resolvePipeline/createContextTrackingProxy.js +6 -5
  179. package/dist/store/resolvePipeline/createContextTrackingProxy.js.map +1 -1
  180. package/dist/store/resolvePipeline/createValuesTrackingProxy.d.ts +43 -43
  181. package/dist/store/resolvePipeline/createValuesTrackingProxy.d.ts.map +1 -1
  182. package/dist/store/resolvePipeline/createValuesTrackingProxy.js +70 -70
  183. package/dist/store/resolvePipeline/createValuesTrackingProxy.js.map +1 -1
  184. package/dist/store/resolvePipeline/executeListResolve.d.ts +14 -14
  185. package/dist/store/resolvePipeline/executeListResolve.js +27 -27
  186. package/dist/store/resolvePipeline/executeListResolve.js.map +1 -1
  187. package/dist/store/resolvePipeline/executeResolve.d.ts +15 -15
  188. package/dist/store/resolvePipeline/executeResolve.js +46 -46
  189. package/dist/store/resolvePipeline/executeResolve.js.map +1 -1
  190. package/dist/store/store/NodeRegistry/nodeRegistry.d.ts +61 -61
  191. package/dist/store/store/NodeRegistry/nodeRegistry.d.ts.map +1 -1
  192. package/dist/store/store/NodeRegistry/nodeRegistry.js +85 -86
  193. package/dist/store/store/NodeRegistry/nodeRegistry.js.map +1 -1
  194. package/dist/store/store/NodeRegistry/nodeUtils.d.ts +5 -5
  195. package/dist/store/store/NodeRegistry/nodeUtils.d.ts.map +1 -1
  196. package/dist/store/store/NodeRegistry/nodeUtils.js +5 -5
  197. package/dist/store/store/NodeRegistry/nodeUtils.js.map +1 -1
  198. package/dist/store/store/groupDepsMap.d.ts +23 -23
  199. package/dist/store/store/groupDepsMap.d.ts.map +1 -1
  200. package/dist/store/store/groupDepsMap.js +35 -34
  201. package/dist/store/store/groupDepsMap.js.map +1 -1
  202. package/dist/store/store/hasComputedProps.d.ts +2 -2
  203. package/dist/store/store/hasComputedProps.js +2 -2
  204. package/dist/store/store/nodeMap.d.ts +5 -5
  205. package/dist/store/store/nodeMap.js +7 -7
  206. package/dist/store/store/nodeMap.js.map +1 -1
  207. package/dist/store/store/palistor.d.ts +80 -80
  208. package/dist/store/store/palistor.d.ts.map +1 -1
  209. package/dist/store/store/palistor.js +149 -169
  210. package/dist/store/store/palistor.js.map +1 -1
  211. package/dist/store/store/registerNodes.d.ts +10 -10
  212. package/dist/store/store/registerNodes.js +15 -15
  213. package/dist/store/store/registerNodes.js.map +1 -1
  214. package/dist/store/store/serviceRegistry.d.ts +8 -8
  215. package/dist/store/store/serviceRegistry.d.ts.map +1 -1
  216. package/dist/store/store/serviceRegistry.js +8 -8
  217. package/dist/store/store/serviceRegistry.js.map +1 -1
  218. package/dist/store/store/types.d.ts +236 -234
  219. package/dist/store/store/types.d.ts.map +1 -1
  220. package/dist/store/submitPipeline/applyLeafBeforeSubmit.d.ts +2 -2
  221. package/dist/store/submitPipeline/applyLeafBeforeSubmit.js +2 -2
  222. package/dist/store/submitPipeline/collectLeafStates.d.ts +2 -2
  223. package/dist/store/submitPipeline/collectLeafStates.js +2 -2
  224. package/dist/store/submitPipeline/submitPipeline.d.ts +8 -8
  225. package/dist/store/submitPipeline/submitPipeline.js +12 -12
  226. package/dist/store/submitPipeline/submitPipeline.js.map +1 -1
  227. package/dist/store/submitPipeline/types.d.ts +1 -1
  228. package/dist/store/submitPipeline/types.d.ts.map +1 -1
  229. package/dist/store/traversal/nodeClassifier.d.ts +9 -9
  230. package/dist/store/traversal/nodeClassifier.d.ts.map +1 -1
  231. package/dist/store/traversal/nodeClassifier.js +11 -11
  232. package/dist/store/traversal/nodeClassifier.js.map +1 -1
  233. package/dist/store/traversal/walkFull.d.ts +14 -14
  234. package/dist/store/traversal/walkFull.js +2 -2
  235. package/dist/store/valuesCache/valuesCache.d.ts +9 -9
  236. package/dist/store/valuesCache/valuesCache.d.ts.map +1 -1
  237. package/dist/store/valuesCache/valuesCache.js +6 -6
  238. package/dist/store/writePipeline/formatPatch.d.ts +5 -5
  239. package/dist/store/writePipeline/formatPatch.js +7 -7
  240. package/dist/store/writePipeline/formatPatch.js.map +1 -1
  241. package/dist/store/writePipeline/formatValue.d.ts +5 -5
  242. package/dist/store/writePipeline/formatValue.js +5 -5
  243. package/dist/store/writePipeline/mergeChanged.d.ts +4 -4
  244. package/dist/store/writePipeline/mergeChanged.js +4 -4
  245. package/dist/store/writePipeline/runSetter.d.ts +6 -6
  246. package/dist/store/writePipeline/runSetter.js +8 -8
  247. package/dist/store/writePipeline/storeValue.d.ts +3 -3
  248. package/dist/store/writePipeline/storeValue.js +3 -3
  249. package/dist/store/writePipeline/types.d.ts +4 -4
  250. package/dist/store/writePipeline/types.d.ts.map +1 -1
  251. package/dist/store/writePipeline/writePipeline.d.ts +7 -7
  252. package/dist/store/writePipeline/writePipeline.d.ts.map +1 -1
  253. package/dist/store/writePipeline/writePipeline.js +15 -15
  254. package/dist/store/writePipeline/writePipeline.js.map +1 -1
  255. package/package.json +11 -4
package/README.md CHANGED
@@ -1,39 +1,97 @@
1
1
  # Palistor
2
2
 
3
- > Реактивный state manager для форм с гранулярным рендерингом
3
+ > Reactive form state manager for React with granular re-renders
4
4
 
5
- Palistor библиотека управления состоянием форм для React. Построена на двухслойной proxy-архитектуре: framework-agnostic ядро и React-интеграция с точечным трекингом подписок. Компонент перерендеривается **только** при изменении тех полей, которые он реально читал.
5
+ **English** | [Русский](./README.ru.md)
6
+
7
+ [![npm version](https://img.shields.io/npm/v/palistor.svg)](https://www.npmjs.com/package/palistor)
8
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
9
+ [![react](https://img.shields.io/badge/react-%5E19-61dafb.svg)](https://react.dev)
10
+
11
+ Palistor is a form state management library built on a two-layer proxy architecture: a framework-agnostic core plus a React binding with per-field subscription tracking. A component re-renders **only** when a field it actually read during render changes.
12
+
13
+ ```tsx
14
+ const store = new Palistor({
15
+ config: {
16
+ email: { value: "", isRequired: true },
17
+ phone: { value: "", isVisible: (v) => v.email !== "" },
18
+ },
19
+ });
20
+
21
+ function Form() {
22
+ const form = useForm(store);
23
+ return (
24
+ <input
25
+ value={form.email.value}
26
+ onChange={(e) => (form.email.value = e.target.value)}
27
+ />
28
+ );
29
+ }
30
+ ```
6
31
 
7
32
  ---
8
33
 
9
- ## Установка
34
+ ## Table of contents
35
+
36
+ - [Features](#features)
37
+ - [Installation](#installation)
38
+ - [Quick start](#quick-start)
39
+ - [Concepts](#concepts)
40
+ - [API reference](#api-reference)
41
+ - [Async resolvers](#async-resolvers)
42
+ - [Lists & entities](#lists--entities)
43
+ - [Flows (step wizards)](#flows-step-wizards)
44
+ - [Field mapping](#field-mapping)
45
+ - [Persist](#persist)
46
+ - [i18n](#i18n)
47
+ - [Notifications](#notifications)
48
+ - [Store context](#store-context)
49
+ - [TypeScript](#typescript)
50
+ - [License](#license)
10
51
 
11
- Пакет публикуется в **публичный npm-реестр** под именем `palistor`.
52
+ ---
53
+
54
+ ## Features
55
+
56
+ | | |
57
+ |---|---|
58
+ | **Granular re-renders** | A component subscribes only to the fields it read — nothing else triggers a re-render |
59
+ | **Computed field state** | `isVisible`, `isRequired`, `label`, validation errors are recomputed automatically from the config |
60
+ | **Proxy API** | Native syntax: `form.email.value = x` instead of dispatching actions |
61
+ | **Submit pipeline** | `beforeSubmit → validate → onSubmit → afterSubmit`, errors surface after the first failed submit |
62
+ | **Dirty tracking** | Per-field and per-group change flags; the baseline updates after resolve and reset |
63
+ | **Async resolvers** | Data loading with auto-tracked dependencies, retry, optimistic updates and React Suspense |
64
+ | **Lists & entities** | Normalized entity registry, list proxy with `add / remove / setItems`, per-entity templates |
65
+ | **Flows** | Step wizards via `defineFlow` / `defineStep`: navigation, branching, per-step validation |
66
+ | **Field mapping** | Rename field-state props to your UI kit's convention (`isRequired` → `required`, …) |
67
+ | **Persist** | Autosave to `localStorage`, `sessionStorage` or any custom driver — flow navigation included |
68
+ | **i18n** | Register a translator once — `label`, `placeholder`, `description` are translated everywhere |
69
+ | **Testability** | The core is framework-agnostic and fully testable without React |
70
+
71
+ ---
72
+
73
+ ## Installation
74
+
75
+ The package is published to the **public npm registry** as `palistor`.
12
76
 
13
77
  ```bash
14
78
  npm install palistor
15
- # или
79
+ # or
16
80
  yarn add palistor
17
- # или
81
+ # or
18
82
  pnpm add palistor
19
83
  ```
20
84
 
21
- **Peer-зависимости:** `react ^19`
85
+ **Peer dependency:** `react ^19`
22
86
 
23
- > **Альтернатива — GitHub Packages.** Тот же пакет доступен под scoped-именем
24
- > `@projectint/palistor`. Для установки добавьте в `.npmrc`:
87
+ > **Alternative — GitHub Packages.** The same package is available under the scoped
88
+ > name `@projectint/palistor`. Add to `.npmrc`:
25
89
  > ```
26
90
  > @projectint:registry=https://npm.pkg.github.com
27
91
  > ```
28
- > и (для приватного доступа) токен:
29
- > ```
30
- > //npm.pkg.github.com/:_authToken=YOUR_GITHUB_PAT
31
- > ```
32
- > затем `npm install @projectint/palistor`. Каноническим считается имя `palistor`.
92
+ > then `npm install @projectint/palistor`. The canonical name is `palistor`.
33
93
 
34
- ### Импорт
35
-
36
- Все публичные symbol'ы доступны из корневого модуля:
94
+ All public symbols are available from the root module:
37
95
 
38
96
  ```typescript
39
97
  import {
@@ -42,7 +100,11 @@ import {
42
100
  usePersist,
43
101
  useTranslator,
44
102
  useNotifier,
103
+ useStoreContext,
45
104
  defineList,
105
+ defineFlow,
106
+ defineStep,
107
+ defineFieldMapping,
46
108
  localStorageDriver,
47
109
  sessionStorageDriver,
48
110
  } from "palistor";
@@ -50,85 +112,54 @@ import {
50
112
 
51
113
  ---
52
114
 
53
- ## Содержание
115
+ ## Quick start
54
116
 
55
- - [Возможности](#возможности)
56
- - [Быстрый старт](#быстрый-старт)
57
- - [Концепции](#концепции)
58
- - [API Reference](#api-reference)
59
- - [Типизация](#типизация)
60
- - [Рецепты](#рецепты)
61
- - [Async Resolver](#async-resolver)
62
- - [Списки и сущности](#списки-и-сущности)
63
- - [Persist](#persist)
64
- - [i18n](#i18n)
65
- - [Уведомления](#уведомления)
66
- - [Структура модуля](#структура-модуля)
67
-
68
- ---
69
-
70
- ## Возможности
117
+ ### 1. Describe the form
71
118
 
72
- | | |
73
- |---|---|
74
- | **Гранулярные ре-рендеры** | Компонент подписывается только на те поля, которые он читал — больше ничего не триггерит ре-рендер |
75
- | **Computed Field State** | `isVisible`, `isRequired`, `error`, `dirty` пересчитываются автоматически по конфигу |
76
- | **Proxy API** | Нативный синтаксис: `field.value = x` вместо `dispatch({ type: "SET", field: "...", value: x })` |
77
- | **Submit pipeline** | `beforeSubmit → validate → onSubmit → afterSubmit` с revalidate-семантикой |
78
- | **Dirty tracking** | Per-field и per-group флаг изменений; baseline обновляется после resolve и reset |
79
- | **Async resolvers** | Загрузка данных с auto-deps, retry, optimistic updates и React Suspense |
80
- | **Списки / Entities** | Нормализованный реестр сущностей, list proxy с `add / remove / setItems` |
81
- | **Persist** | Автосохранение в `localStorage`, `sessionStorage` или любом кастомном драйвере |
82
- | **i18n** | Одна строка в layout — `label`, `placeholder`, `description` переводятся везде |
83
- | **Тестируемость** | Framework-agnostic ядро — тестируется без React |
84
-
85
- ---
86
-
87
- ## Быстрый старт
88
-
89
- ### 1. Создайте store
119
+ The config is declarative: field values, validation, visibility and lifecycle callbacks live in one tree. Create the store at module level.
90
120
 
91
121
  ```typescript
92
- import { createProxyStore } from "palistor";
122
+ import { Palistor } from "palistor";
93
123
 
94
- export const paymentStore = createProxyStore({
124
+ export const paymentStore = new Palistor({
95
125
  config: {
96
126
  paymentType: {
97
127
  value: "card",
98
- label: "Способ оплаты",
128
+ label: "Payment method",
99
129
  },
100
130
  cardNumber: {
101
131
  value: "",
102
- label: "Номер карты",
132
+ label: "Card number",
103
133
  placeholder: "0000 0000 0000 0000",
104
134
  isVisible: (v) => v.paymentType === "card",
105
135
  isRequired: (v) => v.paymentType === "card",
106
136
  validate: (value, v) =>
107
137
  v.paymentType === "card" && value.length < 16
108
- ? "Введите 16 цифр"
138
+ ? "Enter 16 digits"
109
139
  : undefined,
110
140
  },
111
141
  passport: {
112
142
  isVisible: (v) => v.paymentType === "bank",
113
- number: { value: "", label: "Серия и номер", isRequired: true },
114
- issueDate: { value: "", label: "Дата выдачи" },
143
+ number: { value: "", label: "Passport number", isRequired: true },
144
+ issueDate: { value: "", label: "Issue date" },
115
145
  },
116
- amount: { value: 0, label: "Сумма", isRequired: true },
146
+ amount: { value: 0, label: "Amount", isRequired: true },
117
147
  },
118
148
  initialValues: { paymentType: "card" },
119
149
  });
120
150
  ```
121
151
 
122
- ### 2. Используйте в компоненте
152
+ ### 2. Connect a component
123
153
 
124
154
  ```tsx
125
155
  import { useForm } from "palistor";
156
+ import { paymentStore } from "./paymentStore";
126
157
 
127
158
  function PaymentForm() {
128
159
  const form = useForm(paymentStore);
129
160
 
130
161
  return (
131
- <form onSubmit={() => paymentStore.submit()}>
162
+ <form onSubmit={(e) => { e.preventDefault(); paymentStore.submit(); }}>
132
163
  <Select
133
164
  value={form.paymentType.value}
134
165
  onChange={(e) => (form.paymentType.value = e.target.value)}
@@ -148,120 +179,128 @@ function PaymentForm() {
148
179
 
149
180
  {form.passport.isVisible && <PassportSection passport={form.passport} />}
150
181
 
151
- <Button type="submit" isLoading={form.submitting}>
152
- Оплатить
153
- </Button>
182
+ <Button type="submit" isLoading={form.submitting}>Pay</Button>
154
183
  </form>
155
184
  );
156
185
  }
157
186
  ```
158
187
 
159
- ### 3. Изолированный ре-рендер дочернего компонента
188
+ ### 3. Independent re-render for a child component
160
189
 
161
- Если дочернему компоненту нужен **независимый** трекинг, передайте поддерево в `useForm`:
190
+ If a child needs **independent** tracking, pass the subtree down as a prop and call `useForm` on it:
162
191
 
163
192
  ```tsx
164
193
  function PassportSection({ passport }) {
165
- // Собственный tracking proxy — ре-рендер только при изменении полей passport
194
+ // Own tracking proxy — re-renders only when passport fields change
166
195
  const p = useForm(passport);
167
196
 
168
197
  return (
169
198
  <>
170
- <Input value={p.number.value} onChange={(e) => (p.number.value = e.target.value)} label={p.number.label} />
199
+ <Input value={p.number.value} onChange={(e) => (p.number.value = e.target.value)} label={p.number.label} />
171
200
  <Input value={p.issueDate.value} onChange={(e) => (p.issueDate.value = e.target.value)} label={p.issueDate.label} />
172
201
  </>
173
202
  );
174
203
  }
175
204
  ```
176
205
 
177
- > **Без `useForm` в дочернем** компонент рендерится каскадно вместе с родителем. Подходит для простых листовых компонентов.
206
+ > **Without `useForm` in the child** the component re-renders in cascade with its parent. That's fine for simple leaf components.
178
207
 
179
208
  ---
180
209
 
181
- ## Концепции
210
+ ## Concepts
182
211
 
183
- ### Два узла: лист и группа
212
+ ### Two node kinds: leaf and group
184
213
 
185
- Тип узла определяется наличием свойства `value`:
214
+ The node kind is determined by the presence of a `value` property:
186
215
 
187
216
  ```
188
- Есть "value" → листовой узел (поле формы)
189
- Нет "value" групповой узел (контейнер или секция)
217
+ Has "value" → leaf node (a form field)
218
+ No "value" group node (a container / section)
190
219
  ```
191
220
 
192
221
  ```typescript
193
222
  const config = {
194
- // Листовой узелуправляет значением
223
+ // Leaf nodeowns a value
195
224
  email: { value: "", isRequired: true },
196
225
 
197
- // Групповой узелконтейнер с вычисленными свойствами
226
+ // Group nodea container with computed properties
198
227
  address: {
199
228
  isVisible: (v) => v.showAddress,
200
229
  city: { value: "" },
201
- country: { value: "RU" },
230
+ country: { value: "US" },
202
231
  },
203
232
  };
204
233
  ```
205
234
 
206
- ### Как работает трекинг
235
+ ### How tracking works
207
236
 
208
237
  ```
209
- Рендер: читаем form.email.value, form.phone.value
238
+ Render: the component reads form.email.value and form.phone.value
210
239
  → accessed = { emailNode, phoneNode }
211
240
 
212
- SET form.city.value → cityNode++ → snapshot не изменился нет ре-рендера
213
- SET form.email.value → emailNode++ → snapshot изменился ре-рендер
241
+ SET form.city.value → cityNode version++ → snapshot unchangedno re-render
242
+ SET form.email.value → emailNode version++ → snapshot changed re-render
214
243
  ```
215
244
 
216
- Родитель, который только навигирует (`form.passport`), но не читает поля **не ре-рендерится** при изменении полей внутри `passport`.
245
+ A parent that only navigates (`form.passport`) without reading field state does **not** re-render when fields inside `passport` change.
217
246
 
218
- ### Поток данных при SET
247
+ ### Write data flow
219
248
 
220
249
  ```
221
250
  form.email.value = "user@example.com"
222
251
 
223
- ├─ 1. formatter(rawValue, allValues) → нормализованное значение
224
- ├─ 2. storeValue() → nodeState обновлён, valuesCache O(1)
225
- ├─ 3. setter(value, allValues)? → patch → applyPatch() смежных полей
226
- ├─ 4. recompute(changedNodes) → таргетированный пересчёт FieldState
227
- ├─ 5. recomputeDirtyTargeted() dirty флаги вверх по дереву
228
- ├─ 6. notifyChanged() → version++ → useSyncExternalStore → ре-рендер
229
- └─ 7. onChangePipeline.fire() → onChange callback (fire-and-forget)
252
+ ├─ 1. formatter(rawValue, allValues) → normalized value
253
+ ├─ 2. store value → nodeState updated, valuesCache O(1)
254
+ ├─ 3. setter(value, allValues, prev)? → patch → applied to sibling fields
255
+ ├─ 4. recompute(changedNodes) → targeted FieldState recompute
256
+ ├─ 5. dirty flags propagated up the tree
257
+ ├─ 6. notify → version++ → useSyncExternalStore → re-render
258
+ └─ 7. onChange group onChange callback (fire-and-forget)
230
259
  ```
231
260
 
261
+ ### Validation timing (`revalidate`)
262
+
263
+ Validation errors are hidden until the first failed `submit()` of the enclosing group. After that the group's `revalidate` flag turns `true` and `isInvalid` / `errorMessage` update live on every keystroke.
264
+
232
265
  ---
233
266
 
234
- ## API Reference
267
+ ## API reference
235
268
 
236
- ### `createProxyStore(options)`
269
+ ### `new Palistor(options)`
237
270
 
238
271
  ```typescript
239
- import { createProxyStore } from "palistor";
272
+ import { Palistor } from "palistor";
240
273
 
241
- const store = createProxyStore({
242
- config: { /* дерево ConfigNode */ },
243
- initialValues?: { /* перекрывают значения из конфига */ },
274
+ const store = new Palistor({
275
+ config, // ConfigNode tree required
276
+ initialValues, // deep-partial values that override config defaults
277
+ context, // initial non-reactive context (see Store context)
278
+ fieldMapping, // prop renaming map (see Field mapping)
244
279
  });
245
280
  ```
246
281
 
247
- **`ProxyStore<TConfig>`возвращаемое значение:**
282
+ **Storepublic API:**
248
283
 
249
- | Свойство / метод | Тип | Описание |
284
+ | Property / method | Returns | Description |
250
285
  |---|---|---|
251
- | `store.proxy` | `ConfigProxy<TConfig>` | Реактивный проксиструктура повторяет конфиг |
252
- | `store.getValues()` | `DeepValues<TConfig>` | Все текущие значения вложенным объектом |
253
- | `store.submit()` | `Promise<SubmitResult>` | Submit root-группы |
254
- | `store.reset(values?)` | `void` | Сброс к defaults (или к переданным значениям) |
255
- | `store.set(data)` | `string` | Upsert entity; возвращает `id` |
256
- | `store.delete(id)` | `boolean` | Удалить entity из реестра |
257
- | `store.rekey(oldId, newId)` | `void` | Переименовать entity во всех списках |
258
- | `store.subscribe(node, fn)` | `() => void` | Подписка на изменения узла; возвращает unsubscribe |
259
- | `store.subscribeGlobal(fn)` | `() => void` | Подписка на любые изменения |
260
- | `store.getVersion()` | `number` | Глобальная версия (инкремент при каждом изменении) |
261
- | `store.getNodeVersion(node)` | `number` | Версия конкретного узла |
262
- | `store.setTranslator(fn \| null)` | `void` | Зарегистрировать i18n-функцию |
263
- | `store.setNotifier(fn \| null)` | `void` | Зарегистрировать функцию уведомлений |
264
- | `store.persist` | `PersistManager` | Менеджер персистенции |
286
+ | `store.proxy` | proxy | Reactive proxy mirroring the config. **Do not** pass it (or its subtrees) to `useForm` pass the store itself |
287
+ | `store.getValues()` | values | Deep **clone** of all current values as a nested object |
288
+ | `store.submit()` | `Promise<SubmitResult>` | Submit the root group |
289
+ | `store.reset(values?)` | `void` | Reset to config defaults (or to the provided values) |
290
+ | `store.setValues(patch)` | `void` | Bulk patch: one recompute + notify; skips setters and formatters |
291
+ | `store.set(data)` | `void` | Upsert an entity or an array of entities in the registry |
292
+ | `store.delete(id)` | `void` | Remove an entity (cascades to child entities it owns) |
293
+ | `store.rekey(oldId, newId)` | `void` | Rename an entity in the registry and in every list |
294
+ | `store.invalidate(id, template?)` | `void` | Clear an entity's resolved cache so its resolve re-runs |
295
+ | `store.subscribe(node, fn)` | unsubscribe | Subscribe to one node's changes |
296
+ | `store.subscribeGlobal(fn)` | unsubscribe | Subscribe to all changes |
297
+ | `store.getVersion()` | `number` | Global version (incremented on every change) |
298
+ | `store.getNodeVersion(node)` | `number` | Version of a specific node |
299
+ | `store.setTranslator(fn \| null)` | `void` | Register an i18n function |
300
+ | `store.setNotifier(fn \| null)` | `void` | Register a notification function |
301
+ | `store.setContext(ctx)` | `void` | Merge non-reactive context (see Store context) |
302
+ | `store.context` | object | Current non-reactive context |
303
+ | `store.persist` | `PersistManager` | Persistence manager (`enable / disable / flush`) |
265
304
 
266
305
  ---
267
306
 
@@ -270,26 +309,27 @@ const store = createProxyStore({
270
309
  ```typescript
271
310
  import { useForm } from "palistor";
272
311
 
273
- const form = useForm(store); // tracking поверх корневого proxy
274
- const section = useForm(form.address); // свой tracking для поддерева
312
+ const form = useForm(store); // tracking proxy over the whole store
313
+ const section = useForm(form.address); // independent tracking for a subtree (from a prop)
314
+ const entity = useForm(item, (s) => s.editForm); // bind an entity to a template
275
315
  ```
276
316
 
277
- Возвращает типизированный `ConfigProxy<TConfig>`. Ре-рендер только при изменении нод, к которым компонент обращался через `FIELD_STATE_PROPS`.
278
-
279
- **Паттерны подписки:**
317
+ Returns a typed tracking proxy. The component re-renders only when nodes it accessed change.
280
318
 
281
- | Паттерн | Когда использовать |
319
+ | Overload | When to use |
282
320
  |---|---|
283
- | `useForm(store)` + пропсы вниз | Простые формы, листовые UI-компоненты |
284
- | `useForm(subtree)` в дочернем | Крупные секции с независимым ре-рендером |
285
- | Проп без `useForm` | Pure UI-компоненты, рендерятся каскадно за родителем |
321
+ | `useForm(store)` | Root of a form; pass subtrees down as props |
322
+ | `useForm(subtreeProp)` | A large section with independent re-renders |
323
+ | `useForm(entityProxy, templateSelector)` | Render/edit a list entity through a template (binds on mount, unbinds on unmount) |
324
+
325
+ > ⚠️ Passing a raw `store.proxy` subtree (e.g. `useForm(store.proxy.address)`) is a **compile-time and runtime error**. Always go through `useForm(store)` first and drill into the returned proxy.
286
326
 
287
327
  ---
288
328
 
289
- ### Листовой узелдоступные свойства
329
+ ### Leaf nodeproxy properties
290
330
 
291
331
  ```typescript
292
- // Чтение (реактивнодобавляет ноду в tracking set)
332
+ // Reads are reactive they register the node in the tracking set
293
333
  form.email.value // → TValue
294
334
  form.email.label // → string | undefined
295
335
  form.email.placeholder // → string | undefined
@@ -301,13 +341,18 @@ form.email.isVisible // → boolean
301
341
  form.email.isInvalid // → boolean | undefined
302
342
  form.email.errorMessage // → string | undefined
303
343
  form.email.dirty // → boolean
344
+ form.email.loading // → boolean (per-field resolver)
304
345
 
305
- // Запись триггерит formatter → setter → recompute → notify
346
+ // Writes trigger formatter → setter → recompute → notify
306
347
  form.email.value = "new@example.com";
307
- form.email.onValueChange("new@example.com"); // эквивалентно
348
+ form.email.onValueChange("new@example.com"); // equivalent, handy as a callback prop
349
+
350
+ // A leaf can also be submitted on its own (runs the same pipeline)
351
+ form.email.submitting // → boolean
352
+ await form.email.submit(); // → SubmitResult
308
353
  ```
309
354
 
310
- ### Групповой узелдоступные свойства
355
+ ### Group nodeproxy properties
311
356
 
312
357
  ```typescript
313
358
  form.passport.isVisible // → boolean
@@ -317,71 +362,62 @@ form.passport.isDisabled // → boolean | undefined
317
362
  form.passport.isInvalid // → boolean | undefined
318
363
  form.passport.errorMessage // → string | undefined
319
364
  form.passport.submitting // → boolean
320
- form.passport.loading // → boolean (async resolver)
321
- form.passport.dirty // → boolean (хотя бы одно поле изменилось)
322
- form.passport.revalidate // → boolean (true после первого неудачного submit)
323
- form.passport.values // → Record<string, unknown> live-снапшот значений группы
365
+ form.passport.loading // → boolean (async resolver in progress)
366
+ form.passport.dirty // → boolean (at least one field changed)
367
+ form.passport.revalidate // → boolean (true after the first failed submit)
368
+ form.passport.values // → live snapshot of the group's values (stable reference)
324
369
 
325
- await form.passport.submit(); // → SubmitResult
326
- form.passport.reset({ number: "" }); // сброс поддерева
370
+ await form.passport.submit(); // → SubmitResult
371
+ form.passport.reset({ number: "" }); // reset the subtree
372
+ form.passport.setValues({ number: "AB1234" }); // bulk patch, no setters/formatters
327
373
  ```
328
374
 
329
- `values` живая ссылка на вложенный объект `valuesCache`, отражающий текущие значения всех листовых полей группы (рекурсивно). Обновляется in-place при каждой записистабильная ссылка, безопасна для передачи в API:
330
-
331
- ```typescript
332
- const vals = form.passport.values;
333
- // → { number: "AB1234", issueDate: "2020-01-01" }
334
-
335
- await api.submit(vals); // стабильная ссылка, всегда актуальна
336
- ```
375
+ `values` is a live reference into the values cache: it is updated in place on every write, the reference itself stays stable safe to hand to an API call. For a detached deep clone use `store.getValues()`.
337
376
 
338
377
  ---
339
378
 
340
- ### `ConfigNode` — схема поля
341
-
342
- ```typescript
343
- // Листовой узел (есть "value")
344
- interface LeafNode<TValue, TValues> {
345
- value?: TValue | ((values: TValues) => TValue);
346
- validate?: (value: TValue, values: TValues) => string | undefined | false;
347
- formatter?: (value: unknown, values: TValues) => TValue;
348
- setter?: (value: TValue, values: TValues) => DeepPartialValues<TValues>;
349
- beforeSubmit?: (value: TValue, values: TValues) => TValue;
350
- dependencies?: readonly string[]; // для топологической сортировки computed
351
- componentProps?: Record<string, unknown>;
352
-
353
- label?: string | ((values: TValues) => string);
354
- placeholder?: string | ((values: TValues) => string);
355
- description?: string | ((values: TValues) => string);
356
- isVisible?: boolean | ((values: TValues) => boolean); // default: true
357
- isRequired?: boolean | ((values: TValues) => boolean); // default: false
358
- isDisabled?: boolean | ((values: TValues) => boolean); // default: false
359
- isReadOnly?: boolean | ((values: TValues) => boolean); // default: false
379
+ ### `ConfigNode` — field schema
380
+
381
+ ```typescript
382
+ // Leaf node (has "value")
383
+ {
384
+ value?: TValue | ((values: TValues) => TValue), // static or computed
385
+ validate?: (value, values, t) => string | undefined | false,
386
+ formatter?: (raw, values) => TValue, // normalize on write
387
+ setter?: (value, values, previousValue) => DeepPartialValues<TValues>, // patch siblings
388
+ beforeSubmit?: (value, groupValues) => TValue, // transform before submit (no mutation)
389
+ resolve?: Resolve<TValue>, // per-field resolver (inside list templates)
390
+ dependencies?: string[], // topological order for computed chains
391
+ componentProps?: Record<string, unknown>,
392
+
393
+ label?: string | ((t, values) => string),
394
+ placeholder?: string | ((t, values) => string),
395
+ description?: string | ((t, values) => string),
396
+ isVisible?: boolean | ((values) => boolean), // default: true
397
+ isRequired?: boolean | ((values) => boolean), // default: false
398
+ isDisabled?: boolean | ((values) => boolean), // default: false
399
+ isReadOnly?: boolean | ((values) => boolean), // default: false
360
400
  }
361
401
 
362
- // Групповой узел (нет "value")
363
- interface GroupNode<TValues> {
364
- beforeSubmit?: (values: TValues) => TValues;
365
- onSubmit?: (values: TValues) => Promise<unknown> | unknown;
366
- afterSubmit?: (result: unknown, actions: { reset: () => void }) => void | Promise<void>;
367
- reset?: (defaults: TValues) => TValues;
368
- onChange?: (info: OnChangeInfo<TValues>) => DeepPartialValues<TValues> | void | Promise<...>;
369
- resolve?: ResolveConfig<TValues>;
370
-
371
- isVisible?: boolean | ((values: TValues) => boolean);
372
- isRequired?: boolean | ((values: TValues) => boolean);
373
- isDisabled?: boolean | ((values: TValues) => boolean);
374
- isReadOnly?: boolean | ((values: TValues) => boolean);
375
-
376
- [key: string]: LeafNode | GroupNode | ListNode | any;
402
+ // Group node (no "value")
403
+ {
404
+ beforeSubmit?: (values) => values,
405
+ onSubmit?: (values, store, parentProxy) => Promise<unknown> | unknown,
406
+ afterSubmit?: (result, { reset }) => void | Promise<void>,
407
+ reset?: (defaults) => values, // transform on reset
408
+ onChange?: ({ fieldKey, newValue, previousValue, allValues }) => patch | void,
409
+ resolve?: Resolve, // async resolver (see below)
410
+
411
+ isVisible?, isRequired?, isDisabled?, isReadOnly?, // same as leaf
412
+
413
+ [childKey]: LeafNode | GroupNode | ListNode, // children
377
414
  }
378
415
 
379
- // Список (массив длиной 1 или 2)
380
- type ListNode = [TemplateGroupNode] | [TemplateGroupNode, ListConfig];
416
+ // List node array of length 1 or 2 (or use defineList)
417
+ [templateGroupNode]
418
+ [templateGroupNode, { resolve: { resolver, deps?, onError? } }]
381
419
  ```
382
420
 
383
- ---
384
-
385
421
  ### `SubmitResult`
386
422
 
387
423
  ```typescript
@@ -390,85 +426,107 @@ type SubmitResult =
390
426
  | { success: false; errors: Array<{ path: string; message: string }> };
391
427
  ```
392
428
 
393
- ---
429
+ ### Recipes
394
430
 
395
- ## Типизация
431
+ ```typescript
432
+ // Computed value
433
+ total: { value: (v) => v.price * v.quantity, isReadOnly: true },
396
434
 
397
- ### Вывод типа значений из конфига
435
+ // Computed chain declare dependencies for topological ordering
436
+ tax: { value: (v) => v.price * 0.2, dependencies: ["price"] },
437
+ total: { value: (v) => v.price + v.tax, dependencies: ["price", "tax"] },
398
438
 
399
- `ExtractValues<typeof config>` извлекает плоский тип значений из конфига. Листовые узлы (`value`) тип значения; групповые — вложенный объект; списки — массив:
439
+ // formatternormalize on write
440
+ email: { value: "", formatter: (v) => String(v).trim().toLowerCase() },
400
441
 
401
- ```typescript
402
- const config = {
403
- name: { value: "" },
404
- age: { value: 0 },
405
- address: {
406
- city: { value: "" },
407
- country: { value: "RU" },
442
+ // setter — cascade changes to other fields
443
+ country: { value: "US", setter: (value) => ({ city: "" }) },
444
+
445
+ // group onChange — react to any field change inside the group
446
+ passport: {
447
+ onChange: ({ fieldKey }) => {
448
+ if (fieldKey === "number") return { issueDate: "" };
408
449
  },
409
- };
450
+ number: { value: "" },
451
+ issueDate: { value: "" },
452
+ },
410
453
 
411
- type FormValues = ExtractValues<typeof config>;
412
- // → { name: string; age: number; address: { city: string; country: string } }
454
+ // group submit with validation
455
+ company: {
456
+ onSubmit: async (values) => api.saveCompany(values),
457
+ afterSubmit: (_result, { reset }) => { showSuccessToast(); reset(); },
458
+ name: { value: "", isRequired: true },
459
+ taxId: { value: "" },
460
+ },
413
461
  ```
414
462
 
415
- `DeepPartialValues<FormValues>` — глубокая optional-версия, используется для `initialValues`, `setter`, `setValues`:
416
-
417
- ```typescript
418
- const initial: DeepPartialValues<FormValues> = { address: { city: "Москва" } };
419
- ```
463
+ ---
420
464
 
421
- ### Типизация пропсов без импорта конфига
465
+ ## Async resolvers
422
466
 
423
- `Palistor<T>` маппит интерфейс значений на прокси-дерево. Используйте его для типизации пропсов дочерних компонентов:
467
+ A resolver is configured on a group node. It loads data asynchronously with auto-tracked dependencies, retry and React Suspense support.
424
468
 
425
469
  ```typescript
426
- import type { Palistor } from "palistor";
427
-
428
- interface UserData { name: string; email: string; address: { city: string } }
470
+ const store = new Palistor({
471
+ config: {
472
+ userId: { value: "" },
429
473
 
430
- type Props = { user: Palistor<UserData> };
474
+ userInfo: {
475
+ resolve: {
476
+ // `values` is a tracking proxy: every GET becomes a dependency.
477
+ // When userId changes, the resolver re-runs automatically.
478
+ // `store` gives access to the rest of the store (and store.context).
479
+ resolver: async (values, store) => {
480
+ const data = await api.getUser(values.userId);
481
+ return { name: data.name, email: data.email };
482
+ },
431
483
 
432
- function UserForm({ user }: Props) {
433
- const u = useForm(user);
434
- return <input value={u.name.value} onChange={e => { u.name.value = e.target.value }} />;
435
- }
436
- // u.name → FieldProxyNode<string>
437
- // u.address → Palistor<{ city: string }> (GroupProxyNode + поля)
438
- ```
484
+ // Instant placeholder while the resolver is running
485
+ optimisticResolver: (values) => ({ name: "Loading…" }),
439
486
 
440
- Массивы в `T` обрабатываются автоматически:
487
+ onError: (error, ctx) => {
488
+ ctx.notify("Failed to load user", "USER_LOAD_ERROR");
489
+ },
441
490
 
442
- ```typescript
443
- interface FormData { users: Array<{ name: string; email: string }> }
444
- // form.users → ListProxyNode<Palistor<{ name: string; email: string }>>
445
- ```
491
+ deps: ["userId"], // explicit deps (used for the first run)
492
+ contextDeps: ["accountId"], // wait until store.context.accountId != null
446
493
 
447
- ### Типизированные entity-ссылки
494
+ options: {
495
+ lazy: true, // wait for the first access to the node (default: true)
496
+ suspense: false, // throw a promise for React Suspense (default: false)
497
+ retry: { attempts: 3, delay: 1000 },
498
+ },
499
+ },
448
500
 
449
- При передаче entity-прокси через пропсы используйте `PalistorRef<TEntity>`:
501
+ name: { value: "" },
502
+ email: { value: "" },
503
+ },
504
+ },
505
+ });
506
+ ```
450
507
 
451
- ```typescript
452
- import type { PalistorRef, InferEntity } from "palistor";
508
+ ```tsx
509
+ const form = useForm(store);
453
510
 
454
- function UserRow({ user }: { user: PalistorRef<{ name: string; email: string }> }) {
455
- const u = useForm(user, (s) => s.userTemplate);
456
- return <span>{u.name.value}</span>;
457
- }
511
+ // Without Suspense check loading manually
512
+ if (form.userInfo.loading) return <Spinner />;
458
513
 
459
- // Получить тип entity обратно из ссылки:
460
- type UserEntity = InferEntity<PalistorRef<{ name: string; email: string }>>;
461
- // → { name: string; email: string }
514
+ // With Suspense automatic
515
+ <Suspense fallback={<Spinner />}>
516
+ <UserInfoSection />
517
+ </Suspense>
462
518
  ```
463
519
 
464
- `PalistorList<TEntity>` типизированный список: `ListProxyNode<PalistorRef<TEntity>>`.
520
+ When a dependency changes, the resolve state resets, `optimisticResolver` applies instantly and the resolver re-runs. After success the dirty baseline is updated, so resolved data is not marked dirty.
521
+
522
+ ---
465
523
 
466
- ### defineList типизированный список
524
+ ## Lists & entities
467
525
 
468
- Предпочитайте `defineList<TEntity>()` вместо сырого массива. Он проверяет, что ключи `template` соответствуют entity, а `resolver` возвращает `Promise<TEntity[]>`:
526
+ Lists are declared with `defineList` (preferred fully typed) or as a raw array of length 1–2 where `[0]` is the item template.
469
527
 
470
528
  ```typescript
471
- import { defineList } from "palistor";
529
+ import { defineList, Palistor } from "palistor";
472
530
 
473
531
  interface User { id: string; name: string; email: string }
474
532
 
@@ -479,360 +537,274 @@ const users = defineList<User>({
479
537
  email: { value: "" },
480
538
  },
481
539
  resolve: {
482
- resolver: async (values) => api.getUsers(values.filter), // → Promise<User[]>
540
+ resolver: async (values, store) => api.getUsers(values.filter), // → Promise<User[]>
483
541
  deps: ["filter"],
484
542
  },
485
543
  });
486
544
 
487
- const config = { filter: { value: "" }, users };
488
- // store.proxy.users → ListProxyNode<PalistorRef<User>>
545
+ const store = new Palistor({
546
+ config: { filter: { value: "" }, users },
547
+ });
489
548
  ```
490
549
 
491
- ### Справочник типов
550
+ Items are stored in a **normalized entity registry**: the same entity can appear in multiple lists and be rendered through different templates without duplication.
492
551
 
493
- | Тип | Назначение |
494
- |-----|-----------|
495
- | `ExtractValues<TConfig>` | Плоский тип значений из конфига |
496
- | `ConfigProxy<TConfig>` | Полный прокси — тип, возвращаемый `useForm(store)` |
497
- | `Palistor<T>` | Proxy на основе интерфейса значений — для пропсов дочерних компонентов |
498
- | `PalistorRef<TEntity>` | Непрозрачная ссылка на entity — для пропсов одного элемента |
499
- | `PalistorList<TEntity>` | Типизированный список: `ListProxyNode<PalistorRef<TEntity>>` |
500
- | `InferEntity<T>` | Извлечь тип entity из `PalistorRef<TEntity>` |
501
- | `MaybeComputed<TResult, TValues>` | `isVisible`, `isRequired`, `value` — константа или `(values) => T` |
502
- | `MaybeTranslatable<TResult, TValues>` | `label`, `placeholder` — константа или `(t, values) => string` |
503
- | `DeepPartialValues<T>` | Глубокая partial-версия: `initialValues`, патчи `setter`, `setValues` |
504
- | `TranslateFn` | Совместим с next-intl `t`, i18next `t` и любым `(...args) => string` |
505
- | `TemplateConfig<TEntity>` | Типизированный шаблон — каждый ключ entity → `ConfigNode<TEntity[K]>` |
506
- | `ListResolver<TEntity>` | Типизированный resolver — `(values) => Promise<TEntity[]>` |
507
-
508
- ### Полный паттерн типизации
552
+ ### List proxy API
509
553
 
510
554
  ```typescript
511
- import { Palistor, defineList } from "palistor";
512
- import type { ExtractValues, PalistorRef, DeepPartialValues } from "palistor";
513
-
514
- // 1. Вывести тип значений из конфига
515
- const config = {
516
- filter: { value: "" },
517
- users: defineList<{ id: string; name: string }>({
518
- template: { id: { value: "" }, name: { value: "" } },
519
- }),
520
- };
521
-
522
- type Values = ExtractValues<typeof config>;
523
- // → { filter: string; users: Array<{ id: string; name: string }> }
555
+ const form = useForm(store);
524
556
 
525
- // 2. Создать store — TConfig выводится из конфига
526
- const store = new Palistor({ config, initialValues: { filter: "active" } });
557
+ // Read
558
+ form.users.items // ReadonlyArray<entity proxy>
559
+ form.users.length // number
560
+ form.users.loading // boolean
561
+ form.users.dirty // boolean — list membership changed vs. baseline
562
+ form.users.getValues() // Array<plain object>
527
563
 
528
- // 3. Типизировать пропсы через Palistor<Values>
529
- type FormProps = { form: Palistor<Values> };
564
+ // Iterate
565
+ form.users.map((item, index, id) => <Row key={id} item={item} />)
566
+ for (const item of form.users) { /* … */ }
530
567
 
531
- // 4. Типизировать entity-пропсы через PalistorRef
532
- type UserRef = PalistorRef<{ id: string; name: string }>;
568
+ // Mutate
569
+ form.users.add({ name: "Alice", email: "alice@ex.com" }); // object → upsert + append, returns the item proxy
570
+ form.users.add("existing-id"); // string → append an existing entity
571
+ form.users.remove("user-id");
572
+ form.users.setItems(["id1", "id2", "id3"]); // bulk replace
573
+ form.users.getById("user-id"); // → item proxy | undefined
533
574
  ```
534
575
 
535
- ---
536
-
537
- ## Рецепты
576
+ ### List item — proxy properties
538
577
 
539
- ### Computed value
578
+ Every element of `form.users.items` is an entity projection through the template:
540
579
 
541
580
  ```typescript
542
- createProxyStore({
543
- config: {
544
- price: { value: 100 },
545
- quantity: { value: 2 },
546
- total: { value: (v) => v.price * v.quantity, isReadOnly: true },
547
- },
548
- });
549
- ```
581
+ const item = form.users.items[0];
550
582
 
551
- ### Цепочка computed (топологическая сортировка)
583
+ item.id // string entity id
584
+ item.name.value // field value through the template (formatter/validate/isRequired apply)
585
+ item.name.label // computed label from the template
586
+ // …all leaf props: value, label, placeholder, isRequired, isReadOnly, isDisabled,
587
+ // isVisible, isInvalid, errorMessage, dirty, loading, onValueChange
552
588
 
553
- ```typescript
554
- config: {
555
- price: { value: 100 },
556
- tax: { value: (v) => v.price * 0.2, dependencies: ["price"] },
557
- total: { value: (v) => v.price + v.tax, dependencies: ["price", "tax"] },
558
- }
589
+ item.loading // boolean — a resolve is running for this entity
590
+ item.submitting // boolean — a submit is running for this entity
591
+ item.values // plain object of the entity's current values
592
+ await item.submit(); // SubmitResult validates + onSubmit from the template
559
593
  ```
560
594
 
561
- ### formatter нормализация при записи
595
+ ### Rendering an entity through another template
562
596
 
563
- ```typescript
564
- email: {
565
- value: "",
566
- formatter: (v) => String(v).trim().toLowerCase(),
567
- },
568
- ```
597
+ Bind an entity to any template group (e.g. an edit form) with the two-argument `useForm`:
569
598
 
570
- ### setter — каскадное изменение нескольких полей
571
-
572
- ```typescript
573
- country: {
574
- value: "RU",
575
- setter: (value) => ({ city: "" }), // сбрасываем city при смене country
576
- },
577
- ```
578
-
579
- ### onChange — реакция на изменение поля
580
-
581
- ```typescript
582
- passport: {
583
- onChange: ({ fieldKey, newValue, allValues }) => {
584
- if (fieldKey === "number") return { issueDate: "" };
585
- },
586
- number: { value: "" },
587
- issueDate: { value: "" },
588
- },
599
+ ```tsx
600
+ function UserRow({ user }: { user: PalistorRef<User> }) {
601
+ const u = useForm(user, (s) => s.editUserForm);
602
+ return <span>{u.name.value}</span>;
603
+ }
589
604
  ```
590
605
 
591
- ### beforeSubmit трансформация перед отправкой
592
-
593
- ```typescript
594
- phone: {
595
- value: "",
596
- beforeSubmit: (value) => value.replace(/\D/g, ""), // не мутирует store
597
- },
598
- ```
606
+ On mount the entity is bound to the template and the template's resolver runs once per entity+template pair (cached). Call `store.invalidate(id, template?)` to force it to run again.
599
607
 
600
- ### Групповой submit с валидацией
608
+ ### Working with entities directly
601
609
 
602
610
  ```typescript
603
- const store = createProxyStore({
604
- config: {
605
- company: {
606
- onSubmit: async (values) => api.saveCompany(values.company),
607
- afterSubmit: (_result, { reset }) => {
608
- showSuccessToast();
609
- reset();
610
- },
611
- name: { value: "", isRequired: true },
612
- taxId: { value: "" },
613
- },
614
- },
615
- });
611
+ // Create / update entities (single or batch — one recompute + notify)
612
+ store.set({ id: "u1", name: "Bob" });
613
+ store.set([{ id: "u1" }, { id: "u2" }]);
616
614
 
617
- const result = await store.proxy.company.submit();
618
- if (!result.success) {
619
- console.log(result.errors);
620
- // [{ path: "company.name", message: "Обязательное поле" }]
621
- }
622
- ```
623
-
624
- ### Получение значений для отправки
615
+ // If no id is provided, a temporary one is generated (_tmp_…).
616
+ // After the server assigns a real id:
617
+ store.rekey(tmpId, serverAssignedId);
625
618
 
626
- ```typescript
627
- const values = store.getValues();
628
- // → { paymentType: "bank", passport: { number: "AB123", issueDate: "2020-01-01" } }
629
- await api.submit(values);
619
+ // Remove an entity (children owned by it are removed in cascade)
620
+ store.delete("u1");
630
621
  ```
631
622
 
632
623
  ---
633
624
 
634
- ## Async Resolver
625
+ ## Flows (step wizards)
635
626
 
636
- Конфигурируется на групповом узле. Загружает данные асинхронно с авто-трекингом зависимостей, retry и поддержкой React Suspense.
627
+ `defineFlow` / `defineStep` build a step wizard on top of regular group nodes: navigation state, step statuses, branching and per-step validation.
637
628
 
638
629
  ```typescript
639
- const store = createProxyStore({
640
- config: {
641
- userId: { value: "" },
630
+ import { defineFlow, defineStep, Palistor } from "palistor";
642
631
 
643
- userInfo: {
644
- resolve: {
645
- // values — tracking proxy: GET-доступы автоматически становятся зависимостями.
646
- // При изменении userId resolver перезапустится.
647
- resolver: async (values) => {
648
- const data = await api.getUser(values.userId);
649
- return { name: data.name, email: data.email };
650
- },
632
+ const onboarding = defineFlow({
633
+ steps: [
634
+ defineStep("account", {
635
+ fullName: { value: "", isRequired: true },
636
+ email: { value: "", isRequired: true },
637
+ // Third onSubmit argument is the flow proxy — navigation methods are bound,
638
+ // so destructuring works:
639
+ onSubmit: (_values, _store, { nextStep }) => nextStep(),
640
+ }),
651
641
 
652
- // Мгновенный placeholder до завершения resolver
653
- optimisticResolver: (values) => ({ name: "Загрузка..." }),
642
+ defineStep("plan", {
643
+ plan: { value: "", isRequired: true },
644
+ onSubmit: (_values, _store, { nextStep }) => nextStep(),
645
+ }),
654
646
 
655
- onError: (error, ctx) => {
656
- ctx.notify("Не удалось загрузить данные", "USER_LOAD_ERROR");
657
- },
647
+ // Branching: a hidden step is skipped by nextStep() and excluded from
648
+ // final validation.
649
+ defineStep("company", {
650
+ isVisible: (values) => values.plan.plan === "enterprise",
651
+ companyName: { value: "", isRequired: true },
652
+ onSubmit: (_values, _store, { nextStep }) => nextStep(),
653
+ }),
658
654
 
659
- options: {
660
- lazy: true, // ждать первого обращения к узлу (default: true)
661
- suspense: false, // throw promise для React Suspense (default: false)
662
- retry: { attempts: 3, delay: 1000 },
663
- },
664
- },
655
+ defineStep("summary", {}), // read-only summary step
656
+ ],
665
657
 
666
- name: { value: "" },
667
- email: { value: "" },
668
- },
669
- },
658
+ // Flow-level finalization — runs through the standard submit pipeline
659
+ onSubmit: async (allValues, store) => api.completeOnboarding(allValues),
670
660
  });
661
+
662
+ const store = new Palistor({ config: { onboarding } });
671
663
  ```
672
664
 
665
+ ### Flow proxy API
666
+
673
667
  ```tsx
674
668
  const form = useForm(store);
669
+ const flow = form.onboarding;
675
670
 
676
- // Без Suspense — ручная проверка loading
677
- if (form.userInfo.loading) return <Spinner />;
671
+ // Navigation state (reactive)
672
+ flow.currentStepKey // "account" | "plan" | …
673
+ flow.currentStepIndex // number
674
+ flow.canGoBack // boolean — visit stack is non-empty
675
+ flow.history // readonly string[] — [...visitStack, currentStepKey]
676
+ flow.errors // FlowError[] — from the last validate() / finalization
678
677
 
679
- // С Suspense — автоматически
680
- <Suspense fallback={<Spinner />}>
681
- <UserInfoSection />
682
- </Suspense>
683
- ```
678
+ // Steps collection
679
+ flow.steps.current // proxy of the active step
680
+ flow.steps.account // by key
681
+ flow.steps[0] // by index
682
+ flow.steps.length // number of steps
683
+ [...flow.steps] // iterable
684
684
 
685
- **Поведение при изменении зависимости:**
685
+ // Step proxy = regular group proxy + status
686
+ flow.steps.account.status // "active" | "completed" | null (not visited yet)
687
+ flow.steps.account.email // field proxy — regular leaf
686
688
 
687
- ```
688
- userId меняется
689
- notifyChanged findResolvesToRetrigger
690
- → resetResolveState(idle) triggerResolve()
691
- optimisticResolver применяется мгновенно
692
- → resolver запускается заново
693
- ```
689
+ // Navigation
690
+ flow.nextStep(); // next VISIBLE step; if none ahead → finalize via flow.submit()
691
+ flow.back(); // pop the visit stack; no-op when empty
692
+ flow.goTo("plan"); // jump by key or index; throws on unknown key
693
+ flow.validate(); // validate visited visible steps → flow.errors
694
694
 
695
- ---
695
+ // Values & finalization
696
+ flow.values // accumulated values of all steps, keyed by step
697
+ await flow.submit(); // standard pipeline; hidden steps' errors are filtered out
698
+ ```
696
699
 
697
- ## Списки и сущности
700
+ ### Step lifecycle
698
701
 
699
- Списки объявляются как массив длиной 1 или 2, где `[0]` шаблон элемента:
702
+ A step config accepts two flow-specific callbacks in addition to everything a group supports:
700
703
 
701
704
  ```typescript
702
- const store = createProxyStore({
703
- config: {
704
- users: [
705
- // Шаблон описывает поля каждого элемента
706
- {
707
- id: { value: "" },
708
- name: { value: "", isRequired: true },
709
- email: { value: "" },
710
- },
711
- // Опциональный конфиг списка
712
- {
713
- resolve: {
714
- resolver: async () => {
715
- const data = await api.getUsers();
716
- return data; // Array<{ id, name, email }>
717
- },
718
- },
719
- },
720
- ],
721
- },
722
- });
705
+ defineStep("details", {
706
+ onEnter: (flowValues, store) => { /* fired on entering the step */ },
707
+ onReady: (flowValues, store) => { /* fired after the step's resolve completes */ },
708
+ resolve: { resolver: async (values, store) => api.getDetails(), onError: () => {} },
709
+ // …fields
710
+ })
723
711
  ```
724
712
 
725
- ### List Proxy API
726
-
727
- ```typescript
728
- const form = useForm(store);
729
-
730
- // Чтение
731
- form.users.items // ReadonlyArray<EntityProjectionProxy>
732
- form.users.length // number
733
- form.users.loading // boolean
734
- form.users.dirty // boolean — состав списка изменился vs. baseline
713
+ On entering a step: `onEnter → resolve (triggered eagerly) → onReady`. The first step of every flow is entered when the store is created. A step's resolve result is cached — re-entering does not re-run it. `reset()` of the flow (or any ancestor) resets navigation to the first step and re-runs the entry lifecycle.
735
714
 
736
- // Итерация
737
- form.users.map((item, index, id) => <Row key={id} item={item} />)
738
- for (const item of form.users) { ... }
715
+ Flow navigation (current step, history) is included in [persist](#persist) snapshots and restored on hydration.
739
716
 
740
- // Мутации
741
- form.users.add({ name: "Alice", email: "alice@example.com" }); // объект → upsert + добавить
742
- form.users.add("existing-id"); // строка → добавить существующую
743
- form.users.remove("user-id");
744
- form.users.setItems(["id1", "id2", "id3"]); // bulk замена
745
- form.users.getById("user-id"); // → EntityProjectionProxy | undefined
746
- ```
717
+ ---
747
718
 
748
- ### EntityProjectionProxy — свойства элемента списка
719
+ ## Field mapping
749
720
 
750
- Каждый элемент `form.users.items` это `EntityProjectionProxy`. Доступные свойства:
721
+ `fieldMapping` renames field-state props **at the proxy boundary** — GET, SET, tracking and spread — so `{...form.email}` can be spread directly into a component from MUI, Ant Design or plain HTML without adapters. The internal engine is unchanged.
751
722
 
752
723
  ```typescript
753
- const item = form.users.items[0];
754
-
755
- item.id // string — идентификатор entity
756
- item.name.value // значение поля через template (с formatter/validate/isRequired)
757
- item.name.label // computed label из template
758
- item.name.isRequired // computed isRequired из template
759
- // ... все leaf props: value, label, placeholder, isRequired, isReadOnly, isDisabled,
760
- // isVisible, isInvalid, errorMessage, dirty, onValueChange
761
-
762
- item.loading // boolean — идёт ли resolve для этого entity
763
- item.submitting // boolean — идёт ли submit для этого entity
764
- item.values // Record<string, unknown> — plain объект значений entity
765
- await item.submit(); // → SubmitResult — submit этого entity через template
766
- ```
724
+ import { Palistor, defineFieldMapping, useForm } from "palistor";
767
725
 
768
- `item.values` plain объект с текущими значениями полей entity, пригодный для передачи в API:
769
-
770
- ```typescript
771
- form.users.map((item) => {
772
- console.log(item.values); // → { name: "Alice", email: "alice@example.com" }
726
+ // defineFieldMapping preserves the literal types so renamed props are
727
+ // statically typed on store.proxy / useForm(store).
728
+ const uiFieldMapping = defineFieldMapping({
729
+ isRequired: "required",
730
+ isDisabled: "disabled",
731
+ isReadOnly: "readOnly",
732
+ isInvalid: "error",
733
+ errorMessage: "helperText",
734
+ description: "helpText",
773
735
  });
774
- ```
775
736
 
776
- ### Работа с entity
737
+ const store = new Palistor({
738
+ // The config is written in the SAME public vocabulary (external names).
739
+ // Writing an internal name (isRequired) of a remapped key is a type error.
740
+ config: {
741
+ email: {
742
+ value: "",
743
+ label: "Email",
744
+ required: true,
745
+ helpText: "We never share your email",
746
+ validate: (v: string) => (!v.includes("@") ? "Invalid email" : undefined),
747
+ },
748
+ },
749
+ fieldMapping: uiFieldMapping,
750
+ });
777
751
 
778
- ```typescript
779
- // Создать / обновить entity — возвращает id
780
- const id = store.set({ name: "Bob", email: "bob@example.com" });
752
+ function EmailField() {
753
+ const form = useForm(store);
754
+ return <TextField {...form.email} />; // required / error / helperText — as MUI expects
755
+ }
756
+ ```
781
757
 
782
- // Если id не передан генерируется временный (_tmp_...)
783
- // После сохранения на сервере переименуйте:
784
- store.rekey(tmpId, serverAssignedId);
758
+ Mappable keys: `value`, `label`, `placeholder`, `description`, `isRequired`, `isReadOnly`, `isDisabled`, `isVisible`, `isInvalid`, `errorMessage`, `dirty`, `loading`, `onValueChange`. Without `fieldMapping` there is zero overhead and no behavior change.
785
759
 
786
- // Удалить entity
787
- store.delete(id);
788
- ```
760
+ > Use `defineFieldMapping` (or `as const`) for a reusable map — a plain `: FieldMapping` annotation or `satisfies FieldMapping` widens the literals to `string` and the static renaming is lost.
789
761
 
790
762
  ---
791
763
 
792
764
  ## Persist
793
765
 
794
- Автосохранение состояния формы в любом хранилище.
766
+ Autosave form state to any storage.
795
767
 
796
- ### React-хук (рекомендуется)
768
+ ### React hook (recommended)
797
769
 
798
770
  ```tsx
799
- import { usePersist } from "palistor/react/usePersist";
771
+ import { usePersist, localStorageDriver } from "palistor";
800
772
 
801
773
  function PaymentPage({ orderId }: { orderId: string }) {
802
774
  usePersist(paymentStore, {
803
- key: `payment-${orderId}`, // ключ может зависеть от пропсов
775
+ key: `payment-${orderId}`, // key may depend on props / router
804
776
  driver: localStorageDriver,
805
777
  debounce: 500, // ms, default: 100
806
- pick: ["cardNumber"], // персистировать только эти top-level поля
807
- // omit: ["cvv"], // или исключить чувствительные поля
778
+ pick: ["cardNumber"], // persist only these top-level fields
779
+ // omit: ["cvv"], // …or exclude sensitive ones
808
780
  });
809
781
 
810
782
  const form = useForm(paymentStore);
811
- // ...
783
+ //
812
784
  }
813
785
  ```
814
786
 
815
- ### Вне React
787
+ The hook hydrates on mount, autosaves on change, and flushes + disables on unmount.
816
788
 
817
- ```typescript
818
- import { localStorageDriver, sessionStorageDriver } from "palistor/store/persist";
789
+ ### Outside React
819
790
 
820
- paymentStore.persist.enable({
821
- key: "payment",
822
- driver: localStorageDriver,
823
- });
791
+ ```typescript
792
+ import { localStorageDriver } from "palistor";
824
793
 
825
- await paymentStore.persist.flush(); // принудительное сохранение
794
+ paymentStore.persist.enable({ key: "payment", driver: localStorageDriver });
795
+ await paymentStore.persist.flush(); // force save
826
796
  paymentStore.persist.disable();
827
797
  ```
828
798
 
829
- ### Кастомный драйвер
799
+ ### Custom driver
800
+
801
+ Sync or async — both are supported (localStorage, IndexedDB, AsyncStorage, …):
830
802
 
831
803
  ```typescript
832
- import type { PersistDriver } from "palistor/store/persist";
804
+ import type { PersistDriver } from "palistor";
833
805
 
834
806
  const myDriver: PersistDriver = {
835
- getItem: (key) => myStorage.get(key),
807
+ getItem: (key) => myStorage.get(key), // string | null | Promise<…>
836
808
  setItem: (key, value) => myStorage.set(key, value),
837
809
  removeItem: (key) => myStorage.delete(key),
838
810
  };
@@ -840,25 +812,27 @@ const myDriver: PersistDriver = {
840
812
 
841
813
  **`PersistOptions`:**
842
814
 
843
- | Поле | Тип | По умолчанию | Описание |
815
+ | Option | Type | Default | Description |
844
816
  |---|---|---|---|
845
- | `key` | `string` | — | Ключ хранения |
846
- | `driver` | `PersistDriver` | — | Реализация хранилища |
847
- | `debounce` | `number` | `100` | Задержка записи, ms |
848
- | `serialize` | `fn` | `JSON.stringify` | Кастомный сериализатор |
849
- | `deserialize` | `fn` | `JSON.parse` | Кастомный десериализатор |
850
- | `pick` | `string[]` | — | Персистировать только эти top-level ключи |
851
- | `omit` | `string[]` | — | Исключить эти ключи (игнорируется если задан `pick`) |
817
+ | `key` | `string` | — | Storage key |
818
+ | `driver` | `PersistDriver` | — | Storage implementation |
819
+ | `debounce` | `number` | `100` | Write delay, ms (`0` = immediate) |
820
+ | `serialize` | `fn` | `JSON.stringify` | Custom serializer |
821
+ | `deserialize` | `fn` | `JSON.parse` | Custom deserializer |
822
+ | `pick` | `string[]` | — | Persist only these top-level keys |
823
+ | `omit` | `string[]` | — | Exclude these keys (ignored when `pick` is set) |
824
+
825
+ Persisted snapshots include list membership and flow navigation; both are restored on hydration.
852
826
 
853
827
  ---
854
828
 
855
829
  ## i18n
856
830
 
857
- Регистрируйте функцию перевода один разв layout или провайдере. Все компоненты с `useForm` получат переведённые `label`, `placeholder`, `description` автоматически. При смене локали все компоненты перерендерятся.
831
+ Register a translation function oncein a layout or provider. Every component using `useForm` gets translated `label` / `placeholder` / `description` automatically; changing the locale re-renders subscribed components.
858
832
 
859
833
  ```tsx
860
834
  import { useTranslations } from "next-intl";
861
- import { useTranslator } from "palistor/react/useTranslator";
835
+ import { useTranslator } from "palistor";
862
836
 
863
837
  function RootLayout({ children }: { children: React.ReactNode }) {
864
838
  const t = useTranslations();
@@ -867,7 +841,7 @@ function RootLayout({ children }: { children: React.ReactNode }) {
867
841
  }
868
842
  ```
869
843
 
870
- В конфиге `label` / `placeholder` задаются как функции-переводчики:
844
+ In the config, translatable strings are functions of `(t, values)`:
871
845
 
872
846
  ```typescript
873
847
  cardNumber: {
@@ -877,20 +851,21 @@ cardNumber: {
877
851
  },
878
852
  ```
879
853
 
880
- > Без translator функции возвращают свой результат вызова как есть удобно для тестов без i18n-окружения.
854
+ The `validate` callback also receives `t` as its third argument. Without a registered translator the functions receive an identity `t`convenient for tests and SSR without an i18n environment.
881
855
 
882
856
  ---
883
857
 
884
- ## Уведомления
858
+ ## Notifications
885
859
 
886
- Регистрирует функцию toast-уведомлений для использования внутри `resolve.onError`:
860
+ Register a toast/alert function once; resolvers receive it in `onError` via `ctx.notify`:
887
861
 
888
862
  ```tsx
889
- import { useNotifier } from "palistor/react/useNotifier";
863
+ import { useCallback } from "react";
864
+ import { useNotifier } from "palistor";
890
865
 
891
866
  function RootLayout({ children }: { children: React.ReactNode }) {
892
867
  const notifyError = useCallback((error: unknown, code?: string) => {
893
- addToast({ title: code ?? "Ошибка", color: "danger" });
868
+ addToast({ title: code ?? "Error", color: "danger" });
894
869
  }, []);
895
870
 
896
871
  useNotifier(paymentStore, notifyError);
@@ -900,48 +875,105 @@ function RootLayout({ children }: { children: React.ReactNode }) {
900
875
 
901
876
  ---
902
877
 
903
- ## Структура модуля
904
-
905
- ```
906
- palistor/
907
- ├── index.ts # Публичный API
908
- ├── store/
909
- │ ├── store/
910
- │ │ ├── palistor.ts # Palistor — ядро системы (kernel + ProxyStore)
911
- │ │ ├── registerNodes.ts # Инициализация leafNodes + nodeState
912
- │ │ └── types.ts # ConfigNode, ProxyStore, ListState и др.
913
- │ ├── buildProxy/
914
- │ │ ├── buildProxy.ts # Proxy слой 1: GET/SET traps
915
- │ │ ├── buildListProxy.ts # List proxy: items, add, remove, map…
916
- │ │ └── buildEntityProjectionProxy.ts # Entity proxy через template
917
- │ ├── compute/
918
- │ │ └── recompute/ # recomputeLeaves, recomputeTargeted, topologicalSort
919
- │ ├── writePipeline/ # formatter storeValue → setter
920
- │ ├── submitPipeline/ # beforeSubmit validate onSubmit afterSubmit
921
- │ ├── resetPipeline/ # Сброс к defaults
922
- │ ├── onChangePipeline/ # onChange callback
923
- │ ├── resolvePipeline/ # Async resolver: deps, retry, optimistic, suspense
924
- │ ├── dirtyTracking/ # captureInitialValues, recomputeDirtyTargeted
925
- │ ├── entityRegistry/ # Нормализованный реестр сущностей
926
- │ ├── groupDeps/ # Карта зависимостей между группами
927
- │ ├── traversal/ # isLeaf / isGroup / isListNode / walkFull
928
- │ ├── valuesCache/ # buildValuesCache + O(1) updateValuesCacheEntry
929
- │ ├── init/ # NotificationHub, ResolveManager, initGroupSubmitting
930
- │ └── persist/
931
- │ ├── persistManager.ts # enable / disable / flush
932
- │ ├── drivers.ts # localStorageDriver, sessionStorageDriver
933
- │ └── types.ts # PersistDriver, PersistOptions
934
- └── react/
935
- ├── useForm.ts # useSyncExternalStore + tracking proxy
936
- ├── createTrackingProxy.ts # Proxy слой 2: запись accessed нод
937
- ├── useTranslator.ts # Регистрация i18n-функции
938
- ├── useNotifier.ts # Регистрация функции уведомлений
939
- └── usePersist.ts # React-интеграция persist
878
+ ## Store context
879
+
880
+ Non-reactive data (account id, tenant, tokens…) available to every callback via `store.context`. It is not part of the form — it does not appear in `getValues()`, submit payloads or persisted state.
881
+
882
+ ```tsx
883
+ import { useStoreContext } from "palistor";
884
+
885
+ function Layout({ children }) {
886
+ const accountId = useAccountId();
887
+ useStoreContext(store, { accountId }); // merges into store.context
888
+ return <>{children}</>;
889
+ }
890
+ ```
891
+
892
+ ```typescript
893
+ resolve: {
894
+ resolver: async (values, store) => api.fetchUsers(store.context.accountId),
895
+ contextDeps: ["accountId"], // wait until context.accountId != null before the first run
896
+ },
940
897
  ```
941
898
 
899
+ Changing a context key re-triggers resolvers that depend on it (via `contextDeps` or a tracked `$context.…` path). You can also pass an initial context to the constructor: `new Palistor({ config, context: { accountId } })`.
900
+
942
901
  ---
943
902
 
944
- ## Лицензия
903
+ ## TypeScript
904
+
905
+ Palistor is fully typed — values, proxies, entities and even `fieldMapping` renames are inferred statically.
906
+
907
+ ### Infer values from a config
908
+
909
+ ```typescript
910
+ import type { ExtractValues, DeepPartialValues } from "palistor";
911
+
912
+ const config = {
913
+ name: { value: "" },
914
+ age: { value: 0 },
915
+ address: { city: { value: "" }, country: { value: "US" } },
916
+ };
917
+
918
+ type FormValues = ExtractValues<typeof config>;
919
+ // → { name: string; age: number; address: { city: string; country: string } }
920
+
921
+ const initial: DeepPartialValues<FormValues> = { address: { city: "Berlin" } };
922
+ ```
923
+
924
+ ### Type child-component props without importing the config
925
+
926
+ `PalistorProxy<T>` maps a plain values interface onto the proxy tree (the type is named `PalistorProxy` because the value `Palistor` is the store class):
927
+
928
+ ```typescript
929
+ import type { PalistorProxy } from "palistor";
930
+
931
+ interface UserData { name: string; email: string; address: { city: string } }
932
+
933
+ function UserForm({ user }: { user: PalistorProxy<UserData> }) {
934
+ const u = useForm(user);
935
+ return <input value={u.name.value} onChange={(e) => (u.name.value = e.target.value)} />;
936
+ }
937
+ ```
938
+
939
+ ### Typed entity references
940
+
941
+ ```typescript
942
+ import type { PalistorRef, PalistorList, InferEntity } from "palistor";
943
+
944
+ interface User { id: string; name: string }
945
+
946
+ function UserRow({ user }: { user: PalistorRef<User> }) {
947
+ const u = useForm(user, (s) => s.editUserForm);
948
+ return <span>{u.name.value}</span>;
949
+ }
950
+
951
+ type UserEntity = InferEntity<PalistorRef<User>>; // → User
952
+ type UsersList = PalistorList<User>; // typed list proxy
953
+ ```
954
+
955
+ ### Type reference
956
+
957
+ | Type | Purpose |
958
+ |-----|-----|
959
+ | `ExtractValues<TConfig>` | Values type inferred from a config |
960
+ | `ConfigProxy<TConfig>` | Full proxy type — what `useForm(store)` returns |
961
+ | `PalistorProxy<T>` | Proxy built from a plain values interface — for child-component props |
962
+ | `PalistorRef<TEntity>` | Opaque entity reference — for single-item props |
963
+ | `PalistorList<TEntity>` | Typed list proxy |
964
+ | `InferEntity<T>` | Extract the entity type from a `PalistorRef` |
965
+ | `FieldMapping` / `defineFieldMapping` | Prop renaming map (preserves literals) |
966
+ | `FlowProxyNode<S>` / `FlowStepProxy<C>` / `StepStatus` | Flow proxy types |
967
+ | `MaybeComputed<TResult, TValues>` | Constant or `(values) => T` — `isVisible`, `isRequired`, `value` |
968
+ | `MaybeTranslatable<TResult, TValues>` | Constant or `(t, values) => string` — `label`, `placeholder` |
969
+ | `DeepPartialValues<T>` | Deep-partial values: `initialValues`, `setter` patches, `setValues` |
970
+ | `TranslateFn` | Compatible with next-intl `t`, i18next `t`, any `(...args) => string` |
971
+ | `TemplateConfig<TEntity>` / `ListResolver<TEntity>` | Typed list template / resolver |
972
+ | `PersistDriver` / `PersistOptions` | Persistence contracts |
973
+ | `Resolve<T>` / `NotifyFn` / `ResolveErrorContext` | Resolver contracts |
974
+
975
+ ---
945
976
 
946
- MIT
977
+ ## License
947
978
 
979
+ [MIT](./LICENSE) © Yuri Palienko