palistor 0.0.25 → 0.0.27

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