@pilotiq/pilotiq 0.7.2 → 0.8.0

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 (367) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +142 -0
  3. package/CLAUDE.md +59 -3
  4. package/dist/Pilotiq.d.ts +83 -0
  5. package/dist/Pilotiq.d.ts.map +1 -1
  6. package/dist/Pilotiq.js +39 -0
  7. package/dist/Pilotiq.js.map +1 -1
  8. package/dist/actions/Action.d.ts +27 -99
  9. package/dist/actions/Action.d.ts.map +1 -1
  10. package/dist/actions/Action.js +52 -754
  11. package/dist/actions/Action.js.map +1 -1
  12. package/dist/actions/bulkFactories.d.ts +46 -0
  13. package/dist/actions/bulkFactories.d.ts.map +1 -0
  14. package/dist/actions/bulkFactories.js +144 -0
  15. package/dist/actions/bulkFactories.js.map +1 -0
  16. package/dist/actions/crudFactories.d.ts +94 -0
  17. package/dist/actions/crudFactories.d.ts.map +1 -0
  18. package/dist/actions/crudFactories.js +209 -0
  19. package/dist/actions/crudFactories.js.map +1 -0
  20. package/dist/actions/factoryHelpers.d.ts +108 -0
  21. package/dist/actions/factoryHelpers.d.ts.map +1 -0
  22. package/dist/actions/factoryHelpers.js +138 -0
  23. package/dist/actions/factoryHelpers.js.map +1 -0
  24. package/dist/actions/m2mFactories.d.ts +47 -0
  25. package/dist/actions/m2mFactories.d.ts.map +1 -0
  26. package/dist/actions/m2mFactories.js +173 -0
  27. package/dist/actions/m2mFactories.js.map +1 -0
  28. package/dist/actions/relationFactories.d.ts +93 -0
  29. package/dist/actions/relationFactories.d.ts.map +1 -0
  30. package/dist/actions/relationFactories.js +321 -0
  31. package/dist/actions/relationFactories.js.map +1 -0
  32. package/dist/elements/dispatchForm.js +1 -1
  33. package/dist/elements/dispatchForm.js.map +1 -1
  34. package/dist/elements/dispatchTable.js +1 -1
  35. package/dist/elements/dispatchTable.js.map +1 -1
  36. package/dist/fields/Field.d.ts +31 -0
  37. package/dist/fields/Field.d.ts.map +1 -1
  38. package/dist/fields/Field.js +25 -0
  39. package/dist/fields/Field.js.map +1 -1
  40. package/dist/pageData/breadcrumbs.d.ts +42 -0
  41. package/dist/pageData/breadcrumbs.d.ts.map +1 -0
  42. package/dist/pageData/breadcrumbs.js +172 -0
  43. package/dist/pageData/breadcrumbs.js.map +1 -0
  44. package/dist/pageData/forms.d.ts +137 -0
  45. package/dist/pageData/forms.d.ts.map +1 -0
  46. package/dist/pageData/forms.js +427 -0
  47. package/dist/pageData/forms.js.map +1 -0
  48. package/dist/pageData/helpers.d.ts +239 -0
  49. package/dist/pageData/helpers.d.ts.map +1 -0
  50. package/dist/pageData/helpers.js +703 -0
  51. package/dist/pageData/helpers.js.map +1 -0
  52. package/dist/pageData/misc.d.ts +76 -0
  53. package/dist/pageData/misc.d.ts.map +1 -0
  54. package/dist/pageData/misc.js +263 -0
  55. package/dist/pageData/misc.js.map +1 -0
  56. package/dist/pageData/navigation.d.ts +292 -0
  57. package/dist/pageData/navigation.d.ts.map +1 -0
  58. package/dist/pageData/navigation.js +591 -0
  59. package/dist/pageData/navigation.js.map +1 -0
  60. package/dist/pageData/relationPages.d.ts +172 -0
  61. package/dist/pageData/relationPages.d.ts.map +1 -0
  62. package/dist/pageData/relationPages.js +867 -0
  63. package/dist/pageData/relationPages.js.map +1 -0
  64. package/dist/pageData/relationTabs.d.ts +65 -0
  65. package/dist/pageData/relationTabs.d.ts.map +1 -0
  66. package/dist/pageData/relationTabs.js +258 -0
  67. package/dist/pageData/relationTabs.js.map +1 -0
  68. package/dist/pageData/resourcePages.d.ts +48 -0
  69. package/dist/pageData/resourcePages.d.ts.map +1 -0
  70. package/dist/pageData/resourcePages.js +504 -0
  71. package/dist/pageData/resourcePages.js.map +1 -0
  72. package/dist/pageData.d.ts +12 -792
  73. package/dist/pageData.d.ts.map +1 -1
  74. package/dist/pageData.js +24 -3797
  75. package/dist/pageData.js.map +1 -1
  76. package/dist/react/AppShell.d.ts +8 -0
  77. package/dist/react/AppShell.d.ts.map +1 -1
  78. package/dist/react/AppShell.js +11 -1
  79. package/dist/react/AppShell.js.map +1 -1
  80. package/dist/react/CollabExtensionFactoryRegistry.d.ts +47 -0
  81. package/dist/react/CollabExtensionFactoryRegistry.d.ts.map +1 -0
  82. package/dist/react/CollabExtensionFactoryRegistry.js +14 -0
  83. package/dist/react/CollabExtensionFactoryRegistry.js.map +1 -0
  84. package/dist/react/CollabRoomContext.d.ts +37 -0
  85. package/dist/react/CollabRoomContext.d.ts.map +1 -0
  86. package/dist/react/CollabRoomContext.js +12 -0
  87. package/dist/react/CollabRoomContext.js.map +1 -0
  88. package/dist/react/FormCollabBindingRegistry.d.ts +62 -0
  89. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -0
  90. package/dist/react/FormCollabBindingRegistry.js +14 -0
  91. package/dist/react/FormCollabBindingRegistry.js.map +1 -0
  92. package/dist/react/RecordWrapperGate.d.ts +25 -0
  93. package/dist/react/RecordWrapperGate.d.ts.map +1 -0
  94. package/dist/react/RecordWrapperGate.js +30 -0
  95. package/dist/react/RecordWrapperGate.js.map +1 -0
  96. package/dist/react/RecordWrapperRegistry.d.ts +31 -0
  97. package/dist/react/RecordWrapperRegistry.d.ts.map +1 -0
  98. package/dist/react/RecordWrapperRegistry.js +15 -0
  99. package/dist/react/RecordWrapperRegistry.js.map +1 -0
  100. package/dist/react/SchemaRenderer.d.ts +17 -23
  101. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  102. package/dist/react/SchemaRenderer.js +71 -3647
  103. package/dist/react/SchemaRenderer.js.map +1 -1
  104. package/dist/react/component-slots.d.ts +103 -0
  105. package/dist/react/component-slots.d.ts.map +1 -0
  106. package/dist/react/component-slots.js +18 -0
  107. package/dist/react/component-slots.js.map +1 -0
  108. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  109. package/dist/react/fields/BuilderInput.js +21 -117
  110. package/dist/react/fields/BuilderInput.js.map +1 -1
  111. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  112. package/dist/react/fields/MarkdownInput.js +1 -3
  113. package/dist/react/fields/MarkdownInput.js.map +1 -1
  114. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  115. package/dist/react/fields/RepeaterInput.js +22 -127
  116. package/dist/react/fields/RepeaterInput.js.map +1 -1
  117. package/dist/react/fields/rowState.d.ts +40 -0
  118. package/dist/react/fields/rowState.d.ts.map +1 -0
  119. package/dist/react/fields/rowState.js +60 -0
  120. package/dist/react/fields/rowState.js.map +1 -0
  121. package/dist/react/fields/useRowReorderDnd.d.ts +28 -0
  122. package/dist/react/fields/useRowReorderDnd.d.ts.map +1 -0
  123. package/dist/react/fields/useRowReorderDnd.js +51 -0
  124. package/dist/react/fields/useRowReorderDnd.js.map +1 -0
  125. package/dist/react/index.d.ts +9 -0
  126. package/dist/react/index.d.ts.map +1 -1
  127. package/dist/react/index.js +8 -0
  128. package/dist/react/index.js.map +1 -1
  129. package/dist/react/layouts/SidebarLayout.d.ts +1 -1
  130. package/dist/react/layouts/SidebarLayout.d.ts.map +1 -1
  131. package/dist/react/layouts/SidebarLayout.js +10 -2
  132. package/dist/react/layouts/SidebarLayout.js.map +1 -1
  133. package/dist/react/layouts/TopbarLayout.d.ts +1 -1
  134. package/dist/react/layouts/TopbarLayout.d.ts.map +1 -1
  135. package/dist/react/layouts/TopbarLayout.js +19 -11
  136. package/dist/react/layouts/TopbarLayout.js.map +1 -1
  137. package/dist/react/parseRecordEditUrl.d.ts +29 -0
  138. package/dist/react/parseRecordEditUrl.d.ts.map +1 -0
  139. package/dist/react/parseRecordEditUrl.js +25 -0
  140. package/dist/react/parseRecordEditUrl.js.map +1 -0
  141. package/dist/react/persistedState.d.ts +19 -0
  142. package/dist/react/persistedState.d.ts.map +1 -0
  143. package/dist/react/persistedState.js +51 -0
  144. package/dist/react/persistedState.js.map +1 -0
  145. package/dist/react/schemaRenderer/AlertRenderer.d.ts +12 -0
  146. package/dist/react/schemaRenderer/AlertRenderer.d.ts.map +1 -0
  147. package/dist/react/schemaRenderer/AlertRenderer.js +61 -0
  148. package/dist/react/schemaRenderer/AlertRenderer.js.map +1 -0
  149. package/dist/react/schemaRenderer/EntryRenderer.d.ts +13 -0
  150. package/dist/react/schemaRenderer/EntryRenderer.d.ts.map +1 -0
  151. package/dist/react/schemaRenderer/EntryRenderer.js +277 -0
  152. package/dist/react/schemaRenderer/EntryRenderer.js.map +1 -0
  153. package/dist/react/schemaRenderer/SectionRenderer.d.ts +16 -0
  154. package/dist/react/schemaRenderer/SectionRenderer.d.ts.map +1 -0
  155. package/dist/react/schemaRenderer/SectionRenderer.js +62 -0
  156. package/dist/react/schemaRenderer/SectionRenderer.js.map +1 -0
  157. package/dist/react/schemaRenderer/SimpleElements.d.ts +25 -0
  158. package/dist/react/schemaRenderer/SimpleElements.d.ts.map +1 -0
  159. package/dist/react/schemaRenderer/SimpleElements.js +147 -0
  160. package/dist/react/schemaRenderer/SimpleElements.js.map +1 -0
  161. package/dist/react/schemaRenderer/TabsRenderer.d.ts +17 -0
  162. package/dist/react/schemaRenderer/TabsRenderer.d.ts.map +1 -0
  163. package/dist/react/schemaRenderer/TabsRenderer.js +31 -0
  164. package/dist/react/schemaRenderer/TabsRenderer.js.map +1 -0
  165. package/dist/react/schemaRenderer/WizardRenderer.d.ts +34 -0
  166. package/dist/react/schemaRenderer/WizardRenderer.d.ts.map +1 -0
  167. package/dist/react/schemaRenderer/WizardRenderer.js +208 -0
  168. package/dist/react/schemaRenderer/WizardRenderer.js.map +1 -0
  169. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts +21 -0
  170. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts.map +1 -0
  171. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js +82 -0
  172. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js.map +1 -0
  173. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts +30 -0
  174. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts.map +1 -0
  175. package/dist/react/schemaRenderer/action/ActionModalDialog.js +182 -0
  176. package/dist/react/schemaRenderer/action/ActionModalDialog.js.map +1 -0
  177. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts +17 -0
  178. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts.map +1 -0
  179. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js +19 -0
  180. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js.map +1 -0
  181. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts +16 -0
  182. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts.map +1 -0
  183. package/dist/react/schemaRenderer/action/HandlerActionButton.js +16 -0
  184. package/dist/react/schemaRenderer/action/HandlerActionButton.js.map +1 -0
  185. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts +22 -0
  186. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts.map +1 -0
  187. package/dist/react/schemaRenderer/action/MethodActionButton.js +26 -0
  188. package/dist/react/schemaRenderer/action/MethodActionButton.js.map +1 -0
  189. package/dist/react/schemaRenderer/action/buttons.d.ts +18 -0
  190. package/dist/react/schemaRenderer/action/buttons.d.ts.map +1 -0
  191. package/dist/react/schemaRenderer/action/buttons.js +74 -0
  192. package/dist/react/schemaRenderer/action/buttons.js.map +1 -0
  193. package/dist/react/schemaRenderer/action/helpers.d.ts +26 -0
  194. package/dist/react/schemaRenderer/action/helpers.d.ts.map +1 -0
  195. package/dist/react/schemaRenderer/action/helpers.js +126 -0
  196. package/dist/react/schemaRenderer/action/helpers.js.map +1 -0
  197. package/dist/react/schemaRenderer/action/renderAction.d.ts +21 -0
  198. package/dist/react/schemaRenderer/action/renderAction.d.ts.map +1 -0
  199. package/dist/react/schemaRenderer/action/renderAction.js +102 -0
  200. package/dist/react/schemaRenderer/action/renderAction.js.map +1 -0
  201. package/dist/react/schemaRenderer/columnFormat.d.ts +10 -0
  202. package/dist/react/schemaRenderer/columnFormat.d.ts.map +1 -0
  203. package/dist/react/schemaRenderer/columnFormat.js +76 -0
  204. package/dist/react/schemaRenderer/columnFormat.js.map +1 -0
  205. package/dist/react/schemaRenderer/constants.d.ts +8 -0
  206. package/dist/react/schemaRenderer/constants.d.ts.map +1 -0
  207. package/dist/react/schemaRenderer/constants.js +45 -0
  208. package/dist/react/schemaRenderer/constants.js.map +1 -0
  209. package/dist/react/schemaRenderer/form/FormRenderer.d.ts +29 -0
  210. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -0
  211. package/dist/react/schemaRenderer/form/FormRenderer.js +152 -0
  212. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -0
  213. package/dist/react/schemaRenderer/form/renderField.d.ts +6 -0
  214. package/dist/react/schemaRenderer/form/renderField.d.ts.map +1 -0
  215. package/dist/react/schemaRenderer/form/renderField.js +239 -0
  216. package/dist/react/schemaRenderer/form/renderField.js.map +1 -0
  217. package/dist/react/schemaRenderer/helpers.d.ts +32 -0
  218. package/dist/react/schemaRenderer/helpers.d.ts.map +1 -0
  219. package/dist/react/schemaRenderer/helpers.js +52 -0
  220. package/dist/react/schemaRenderer/helpers.js.map +1 -0
  221. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts +60 -0
  222. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts.map +1 -0
  223. package/dist/react/schemaRenderer/table/CardsLayoutBody.js +189 -0
  224. package/dist/react/schemaRenderer/table/CardsLayoutBody.js.map +1 -0
  225. package/dist/react/schemaRenderer/table/TableRenderer.d.ts +29 -0
  226. package/dist/react/schemaRenderer/table/TableRenderer.d.ts.map +1 -0
  227. package/dist/react/schemaRenderer/table/TableRenderer.js +85 -0
  228. package/dist/react/schemaRenderer/table/TableRenderer.js.map +1 -0
  229. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts +18 -0
  230. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts.map +1 -0
  231. package/dist/react/schemaRenderer/table/TableRendererBody.js +555 -0
  232. package/dist/react/schemaRenderer/table/TableRendererBody.js.map +1 -0
  233. package/dist/react/schemaRenderer/table/filters.d.ts +263 -0
  234. package/dist/react/schemaRenderer/table/filters.d.ts.map +1 -0
  235. package/dist/react/schemaRenderer/table/filters.js +497 -0
  236. package/dist/react/schemaRenderer/table/filters.js.map +1 -0
  237. package/dist/react/schemaRenderer/table/formatCell.d.ts +11 -0
  238. package/dist/react/schemaRenderer/table/formatCell.d.ts.map +1 -0
  239. package/dist/react/schemaRenderer/table/formatCell.js +172 -0
  240. package/dist/react/schemaRenderer/table/formatCell.js.map +1 -0
  241. package/dist/react/schemaRenderer/table/links.d.ts +42 -0
  242. package/dist/react/schemaRenderer/table/links.d.ts.map +1 -0
  243. package/dist/react/schemaRenderer/table/links.js +55 -0
  244. package/dist/react/schemaRenderer/table/links.js.map +1 -0
  245. package/dist/react/schemaRenderer/table/renderRowActions.d.ts +13 -0
  246. package/dist/react/schemaRenderer/table/renderRowActions.d.ts.map +1 -0
  247. package/dist/react/schemaRenderer/table/renderRowActions.js +25 -0
  248. package/dist/react/schemaRenderer/table/renderRowActions.js.map +1 -0
  249. package/dist/react/schemaRenderer/table/url.d.ts +41 -0
  250. package/dist/react/schemaRenderer/table/url.d.ts.map +1 -0
  251. package/dist/react/schemaRenderer/table/url.js +114 -0
  252. package/dist/react/schemaRenderer/table/url.js.map +1 -0
  253. package/dist/routes/globals.d.ts +13 -0
  254. package/dist/routes/globals.d.ts.map +1 -0
  255. package/dist/routes/globals.js +131 -0
  256. package/dist/routes/globals.js.map +1 -0
  257. package/dist/routes/helpers.d.ts +217 -0
  258. package/dist/routes/helpers.d.ts.map +1 -0
  259. package/dist/routes/helpers.js +498 -0
  260. package/dist/routes/helpers.js.map +1 -0
  261. package/dist/routes/pages.d.ts +15 -0
  262. package/dist/routes/pages.d.ts.map +1 -0
  263. package/dist/routes/pages.js +145 -0
  264. package/dist/routes/pages.js.map +1 -0
  265. package/dist/routes/panel.d.ts +19 -0
  266. package/dist/routes/panel.d.ts.map +1 -0
  267. package/dist/routes/panel.js +191 -0
  268. package/dist/routes/panel.js.map +1 -0
  269. package/dist/routes/relations.d.ts +21 -0
  270. package/dist/routes/relations.d.ts.map +1 -0
  271. package/dist/routes/relations.js +1239 -0
  272. package/dist/routes/relations.js.map +1 -0
  273. package/dist/routes/resources.d.ts +28 -0
  274. package/dist/routes/resources.d.ts.map +1 -0
  275. package/dist/routes/resources.js +741 -0
  276. package/dist/routes/resources.js.map +1 -0
  277. package/dist/routes/theme.d.ts +12 -0
  278. package/dist/routes/theme.d.ts.map +1 -0
  279. package/dist/routes/theme.js +82 -0
  280. package/dist/routes/theme.js.map +1 -0
  281. package/dist/routes.d.ts.map +1 -1
  282. package/dist/routes.js +64 -3078
  283. package/dist/routes.js.map +1 -1
  284. package/dist/vite.d.ts +1 -0
  285. package/dist/vite.d.ts.map +1 -1
  286. package/dist/vite.js +26 -5
  287. package/dist/vite.js.map +1 -1
  288. package/package.json +2 -1
  289. package/src/Pilotiq.ts +95 -0
  290. package/src/actions/Action.ts +79 -723
  291. package/src/actions/bulkFactories.ts +168 -0
  292. package/src/actions/crudFactories.ts +220 -0
  293. package/src/actions/factoryHelpers.ts +177 -0
  294. package/src/actions/m2mFactories.ts +193 -0
  295. package/src/actions/relationFactories.ts +372 -0
  296. package/src/elements/dispatchForm.ts +1 -1
  297. package/src/elements/dispatchTable.ts +1 -1
  298. package/src/fields/Field.ts +39 -0
  299. package/src/pageData/breadcrumbs.ts +288 -0
  300. package/src/pageData/forms.ts +578 -0
  301. package/src/pageData/helpers.ts +764 -0
  302. package/src/pageData/misc.ts +347 -0
  303. package/src/pageData/navigation.ts +779 -0
  304. package/src/pageData/relationPages.ts +1246 -0
  305. package/src/pageData/relationTabs.ts +286 -0
  306. package/src/pageData/resourcePages.ts +593 -0
  307. package/src/pageData.ts +122 -4731
  308. package/src/react/AppShell.tsx +27 -1
  309. package/src/react/CollabExtensionFactoryRegistry.ts +55 -0
  310. package/src/react/CollabRoomContext.ts +42 -0
  311. package/src/react/FormCollabBindingRegistry.ts +72 -0
  312. package/src/react/RecordWrapperGate.tsx +40 -0
  313. package/src/react/RecordWrapperRegistry.ts +39 -0
  314. package/src/react/SchemaRenderer.tsx +230 -6479
  315. package/src/react/component-slots.test.ts +103 -0
  316. package/src/react/component-slots.ts +116 -0
  317. package/src/react/fields/BuilderInput.tsx +29 -117
  318. package/src/react/fields/MarkdownInput.tsx +0 -1
  319. package/src/react/fields/RepeaterInput.tsx +29 -130
  320. package/src/react/fields/rowState.ts +106 -0
  321. package/src/react/fields/useRowReorderDnd.ts +78 -0
  322. package/src/react/index.ts +38 -0
  323. package/src/react/layouts/SidebarLayout.tsx +39 -28
  324. package/src/react/layouts/TopbarLayout.tsx +70 -57
  325. package/src/react/parseRecordEditUrl.test.ts +75 -0
  326. package/src/react/parseRecordEditUrl.ts +55 -0
  327. package/src/react/persistedState.ts +40 -0
  328. package/src/react/schemaRenderer/AlertRenderer.tsx +112 -0
  329. package/src/react/schemaRenderer/EntryRenderer.tsx +501 -0
  330. package/src/react/schemaRenderer/SectionRenderer.tsx +120 -0
  331. package/src/react/schemaRenderer/SimpleElements.tsx +306 -0
  332. package/src/react/schemaRenderer/TabsRenderer.tsx +62 -0
  333. package/src/react/schemaRenderer/WizardRenderer.tsx +338 -0
  334. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +177 -0
  335. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +273 -0
  336. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +61 -0
  337. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +43 -0
  338. package/src/react/schemaRenderer/action/MethodActionButton.tsx +64 -0
  339. package/src/react/schemaRenderer/action/buttons.tsx +99 -0
  340. package/src/react/schemaRenderer/action/helpers.ts +140 -0
  341. package/src/react/schemaRenderer/action/renderAction.tsx +245 -0
  342. package/src/react/schemaRenderer/columnFormat.ts +65 -0
  343. package/src/react/schemaRenderer/constants.ts +50 -0
  344. package/src/react/schemaRenderer/form/FormRenderer.tsx +233 -0
  345. package/src/react/schemaRenderer/form/renderField.tsx +511 -0
  346. package/src/react/schemaRenderer/helpers.tsx +81 -0
  347. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +308 -0
  348. package/src/react/schemaRenderer/table/TableRenderer.tsx +123 -0
  349. package/src/react/schemaRenderer/table/TableRendererBody.tsx +974 -0
  350. package/src/react/schemaRenderer/table/filters.tsx +1233 -0
  351. package/src/react/schemaRenderer/table/formatCell.tsx +264 -0
  352. package/src/react/schemaRenderer/table/links.tsx +112 -0
  353. package/src/react/schemaRenderer/table/renderRowActions.tsx +52 -0
  354. package/src/react/schemaRenderer/table/url.tsx +143 -0
  355. package/src/routes/globals.ts +154 -0
  356. package/src/routes/helpers.ts +668 -0
  357. package/src/routes/pages.ts +173 -0
  358. package/src/routes/panel.ts +204 -0
  359. package/src/routes/relations.ts +1219 -0
  360. package/src/routes/resources.ts +786 -0
  361. package/src/routes/theme.ts +109 -0
  362. package/src/routes.test.ts +1 -1
  363. package/src/routes.ts +64 -3176
  364. package/src/schema/TableWidget.test.ts +2 -2
  365. package/src/theme/migrate.test.ts +178 -0
  366. package/src/vite.test.ts +184 -0
  367. package/src/vite.ts +26 -4
@@ -0,0 +1,1219 @@
1
+ import type { Router } from '@rudderjs/router'
2
+ import type { AppRequest, AppResponse } from '@rudderjs/contracts'
3
+ import { view } from '@rudderjs/view'
4
+ import type { Pilotiq } from '../Pilotiq.js'
5
+ import type { ResourceClass } from '../Resource.js'
6
+ import { Form } from '../elements/Form.js'
7
+ import { type SchemaContext } from '../schema/resolveSchema.js'
8
+ import { dispatchFormSubmit, findForms, selectForm } from '../elements/dispatchForm.js'
9
+ import { dispatchAction, parseActionBody, type ResolveRecord } from '../elements/dispatchAction.js'
10
+ import { Table } from '../elements/Table.js'
11
+ import {
12
+ tagActionDispatch,
13
+ relationManagerData, findRelatedResource, safeManagerPolicy,
14
+ resolveRelationChain, type ResolvedChain,
15
+ } from '../pageData.js'
16
+ import {
17
+ RelationManager,
18
+ normalizeRelationMode,
19
+ type RelationMode,
20
+ } from '../RelationManager.js'
21
+ import {
22
+ modelSave, modelLoadRecord, findRecord, getPrimaryKey, getRelationType,
23
+ getMorphRelationDescriptor, computeMorphPayload,
24
+ } from '../orm/modelDefaults.js'
25
+ import { resourceBasePath } from '../clusterPaths.js'
26
+ import {
27
+ wantsJson,
28
+ readFormBody,
29
+ normalizeRedirect,
30
+ splitMeta,
31
+ forbidden,
32
+ checkPolicy,
33
+ resolveDispatchTarget,
34
+ sendActionResult,
35
+ sendMutationSuccess,
36
+ sendRedirectResponse,
37
+ findInQueryWithTrashed,
38
+ loadAccessGated,
39
+ } from './helpers.js'
40
+
41
+ /**
42
+ * Register the relation manager routes for one Resource — every
43
+ * relation declared via `R.relations()` mounts a depth-1 strip
44
+ * (list / create / view / edit / delete / restore / force-delete /
45
+ * `_action` / `_detach`); each nested `M.relations()` entry mounts a
46
+ * depth-2 strip with the same shape under a `nestedBase` prefix.
47
+ *
48
+ * Authorization is two-layered: parent `canAccess + canEdit` runs first,
49
+ * then manager-scoped `canX` (with fall-through to the related Resource
50
+ * via `safeManagerPolicy`). Reserved-token / depth-3 / morphTo-no-target
51
+ * guards run at boot in the host barrel (`routes.ts`), not here.
52
+ *
53
+ * Pulled out of `registerPilotiqRoutes` in 2026-05-12 (Phase 4 of the
54
+ * routes.ts split). Called once per `cfg.resources` entry from
55
+ * `registerResourceRoutes`.
56
+ */
57
+ export function registerRelationRoutes(
58
+ router: Router,
59
+ pilotiq: Pilotiq,
60
+ R: ResourceClass,
61
+ base: string,
62
+ ): void {
63
+ const cfg = pilotiq.getConfig()
64
+ const slug = R.getSlug()
65
+ const resourceBase = resourceBasePath(base, R)
66
+
67
+ for (const M of R.relations()) {
68
+ const rel = M.getRelationship()
69
+ const parentBase = `${resourceBase}/:id/${rel}`
70
+
71
+ // Read the relation type once at registration so the (R, M)-
72
+ // scoped closures all see the same mode without re-reading the
73
+ // relations map per request. `R.model` is asserted by
74
+ // `requireParent` at request time; here it may legitimately be
75
+ // missing during late binding, in which case we fall back to
76
+ // 'hasMany' (the safe default — no special action injection / no
77
+ // factory short-circuiting). See `normalizeRelationMode` for the
78
+ // M2M / polymorphic mappings.
79
+ const relationType = R.model ? getRelationType(R.model, rel) : 'hasMany'
80
+ const mode: RelationMode = normalizeRelationMode(relationType)
81
+ // Hoist out of the per-handler closures: `findRelatedResource` does
82
+ // a linear scan over `cfg.resources` and the result is invariant
83
+ // per (R, M) pair. Compute once at registration so each request
84
+ // skips the scan.
85
+ const Related = findRelatedResource(M, R, cfg)
86
+
87
+ // Common policy prelude: load parent, gate access. Returns the
88
+ // parent record on success or a thrown 403/404 response. Returns
89
+ // `undefined` when the route should bail out (response already sent).
90
+ const requireParent = async (req: AppRequest, res: AppResponse, json: boolean): Promise<{ user: unknown; parent: unknown; recordId: string } | undefined> => {
91
+ const recordId = req.params['id']!
92
+ if (!R.model) {
93
+ // Async resolve is still needed to keep error-shape identical.
94
+ await pilotiq.resolveUser(req)
95
+ res.status(500)
96
+ if (json) res.json({ ok: false, error: `Resource "${R.name}" has relations but no static model` })
97
+ else res.send(`Resource "${R.name}" has relations but no static model`)
98
+ return undefined
99
+ }
100
+ const user = await pilotiq.resolveUser(req)
101
+ // Parallelize the access probe and the parent load — both depend
102
+ // only on `user`, and the access check + parent existence check
103
+ // happen on parallel round-trips instead of sequential.
104
+ const { access, record: parent } = await loadAccessGated(R, recordId, user)
105
+ if (!access) { forbidden(res, json); return undefined }
106
+ if (!parent) { res.status(404); if (json) res.json({ ok: false, error: 'Parent not found' }); else res.send('Parent not found'); return undefined }
107
+ if (!await checkPolicy(() => R.canEdit(user, parent))) { forbidden(res, json); return undefined }
108
+ return { user, parent, recordId }
109
+ }
110
+
111
+ // List — GET ${resourceBase}/:id/${rel}
112
+ // Manager-level canViewAny is enforced inside relationManagerData via
113
+ // safeManagerPolicy (with related-resource fall-through). We just
114
+ // surface the {ok:false,status:403} from the data builder as 403.
115
+ router.get(parentBase, async (req, res) => {
116
+ const json = wantsJson(req)
117
+ const ctx = await requireParent(req, res, json)
118
+ if (!ctx) return
119
+ const data = await relationManagerData(pilotiq, {
120
+ kind: 'relation-list', slug, recordId: ctx.recordId, relationship: rel, query: req.query as Record<string, string>,
121
+ }, req)
122
+ if (data === null) { res.status(404); return res.send('Not found') }
123
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
124
+ return view('pilotiq.relation-list', data)
125
+ })
126
+
127
+ // Create — GET ${resourceBase}/:id/${rel}/create
128
+ router.get(`${parentBase}/create`, async (req, res) => {
129
+ const json = wantsJson(req)
130
+ const ctx = await requireParent(req, res, json)
131
+ if (!ctx) return
132
+ const data = await relationManagerData(pilotiq, {
133
+ kind: 'relation-create', slug, recordId: ctx.recordId, relationship: rel,
134
+ }, req)
135
+ if (data === null) { res.status(404); return res.send('Not found') }
136
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
137
+ return view('pilotiq.relation-create', data)
138
+ })
139
+
140
+ // Create submit — POST ${resourceBase}/:id/${rel}/create
141
+ router.post(`${parentBase}/create`, async (req, res) => {
142
+ const json = wantsJson(req)
143
+ const pre = await requireParent(req, res, json)
144
+ if (!pre) return
145
+
146
+ if (!Related) {
147
+ res.status(500)
148
+ const msg = `RelationManager ${M.name}: cannot resolve related Resource for create`
149
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
150
+ }
151
+ if (!await safeManagerPolicy(M, 'canCreate', Related, pre.user, pre.parent)) return forbidden(res, json)
152
+
153
+ const body = await readFormBody(req)
154
+ const { values } = splitMeta(body)
155
+
156
+ const createUrl = `${parentBase}/create`.replace(':id', pre.recordId)
157
+ const listUrl = parentBase.replace(':id', pre.recordId)
158
+ const form = M.form(Form.make(), {
159
+ basePath: base,
160
+ parentSlug: slug,
161
+ parentId: pre.recordId,
162
+ relationship: rel,
163
+ parentRecord: pre.parent,
164
+ related: Related,
165
+ mode,
166
+ })
167
+ if (Related.model) {
168
+ if (!form.getSave()) form.save(modelSave(Related.model))
169
+ if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related))
170
+ }
171
+
172
+ // Polymorphic auto-injection — when the parent's relation entry
173
+ // is `morphMany` / `morphOne`, fill the `{morphName}Id` and
174
+ // `{morphName}Type` columns on the child before persistence.
175
+ // Compose with any user-supplied `mutateDataBeforeCreate` and
176
+ // run AFTER it so morph values overwrite anything the form
177
+ // body or user hook might have set — the parent record is the
178
+ // single source of truth for who owns the new child, and a
179
+ // submitted form field cannot be allowed to tamper with that.
180
+ if (mode === 'morphMany' && R.model) {
181
+ const morphDesc = getMorphRelationDescriptor(R.model, rel)
182
+ if (!morphDesc) {
183
+ res.status(500)
184
+ const msg = `RelationManager ${M.name}: relations[${JSON.stringify(rel)}] reports a polymorphic type but is missing morphName.`
185
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
186
+ }
187
+ const morphPayload = computeMorphPayload(pre.parent, morphDesc)
188
+ const existing = form.getMutateDataBeforeCreate()
189
+ form.mutateDataBeforeCreate(async (data, ctx) => {
190
+ const next = existing ? await existing(data, ctx) : data
191
+ return { ...next, ...morphPayload }
192
+ })
193
+ }
194
+
195
+ // Stamp parent context onto FormContext so user hooks
196
+ // (mutateDataBeforeCreate, redirectAfterSave, etc.) can default
197
+ // foreign-key columns or build URLs from the parent.
198
+ const formCtx = {
199
+ values,
200
+ basePath: base,
201
+ parent: pre.parent,
202
+ parentId: pre.recordId,
203
+ relationship: rel,
204
+ }
205
+
206
+ const result = await dispatchFormSubmit(form, values, formCtx)
207
+ if (!result.ok) {
208
+ if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
209
+ const data = await relationManagerData(pilotiq, {
210
+ kind: 'relation-create', slug, recordId: pre.recordId, relationship: rel,
211
+ prefill: { values, errors: result.errors ?? {} },
212
+ }, req)
213
+ res.status(422)
214
+ return view('pilotiq.relation-create', data ?? {})
215
+ }
216
+
217
+ const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
218
+ return sendRedirectResponse(req, res, json, redirect, result.notifications)
219
+ })
220
+
221
+ // View — GET ${resourceBase}/:id/${rel}/:childId (Phase A nested
222
+ // resources). 5-segment URL. The literal `${parentBase}/create`
223
+ // route is registered above and Hono prefers static segments over
224
+ // wildcards, but the `childId === 'create'` guard belt-and-suspenders
225
+ // against any router that doesn't.
226
+ router.get(`${parentBase}/:childId`, async (req, res) => {
227
+ const json = wantsJson(req)
228
+ const pre = await requireParent(req, res, json)
229
+ if (!pre) return
230
+ const childId = req.params['childId']!
231
+ if (childId === 'create') { res.status(404); return res.send('Not found') }
232
+ const data = await relationManagerData(pilotiq, {
233
+ kind: 'relation-view', slug, recordId: pre.recordId, relationship: rel, childId,
234
+ }, req)
235
+ if (data === null) { res.status(404); return res.send('Not found') }
236
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
237
+ return view('pilotiq.relation-view', data)
238
+ })
239
+
240
+ // Edit — GET ${resourceBase}/:id/${rel}/:childId/edit
241
+ router.get(`${parentBase}/:childId/edit`, async (req, res) => {
242
+ const json = wantsJson(req)
243
+ const pre = await requireParent(req, res, json)
244
+ if (!pre) return
245
+ const childId = req.params['childId']!
246
+ const data = await relationManagerData(pilotiq, {
247
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
248
+ }, req)
249
+ if (data === null) { res.status(404); return res.send('Not found') }
250
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
251
+ return view('pilotiq.relation-edit', data)
252
+ })
253
+
254
+ // Edit submit — POST ${resourceBase}/:id/${rel}/:childId/edit
255
+ router.post(`${parentBase}/:childId/edit`, async (req, res) => {
256
+ const json = wantsJson(req)
257
+ const pre = await requireParent(req, res, json)
258
+ if (!pre) return
259
+ const childId = req.params['childId']!
260
+
261
+ if (!Related?.model) {
262
+ res.status(500)
263
+ const msg = `RelationManager ${M.name}: cannot resolve related Resource for edit`
264
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
265
+ }
266
+
267
+ // IDOR + load via the data builder's gating: re-use it to verify
268
+ // the child belongs to this parent, then do the form submit.
269
+ const childCheck = await relationManagerData(pilotiq, {
270
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
271
+ }, req)
272
+ if (childCheck === null) { res.status(404); return res.send('Not found') }
273
+ if ('ok' in childCheck && childCheck.ok === false) return forbidden(res, json)
274
+
275
+ const body = await readFormBody(req)
276
+ const { values } = splitMeta(body)
277
+
278
+ const editUrl = `${parentBase}/${childId}/edit`.replace(':id', pre.recordId)
279
+ const form = M.form(Form.make(), {
280
+ basePath: base,
281
+ parentSlug: slug,
282
+ parentId: pre.recordId,
283
+ relationship: rel,
284
+ parentRecord: pre.parent,
285
+ related: Related,
286
+ mode,
287
+ })
288
+ if (!form.getSave()) form.save(modelSave(Related.model))
289
+ if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related))
290
+
291
+ // Re-load child for FormContext so cross-field validators see it.
292
+ let child: unknown = undefined
293
+ try { child = await findRecord(Related, childId, { user: pre.user }) } catch { /* ignore */ }
294
+ if (!child) { res.status(404); return res.send('Not found') }
295
+
296
+ // Polymorphic re-stamp on update — same posture as the create
297
+ // path. Re-injecting the morph columns from the live parent
298
+ // record ensures a tampered body (`commentableId=…` /
299
+ // `commentableType=…` posted by an attacker) can't reassign
300
+ // the child to another polymorphic parent. Composed AFTER any
301
+ // user `mutateDataBeforeUpdate` so the framework wins.
302
+ if (mode === 'morphMany' && R.model) {
303
+ const morphDesc = getMorphRelationDescriptor(R.model, rel)
304
+ if (morphDesc) {
305
+ const morphPayload = computeMorphPayload(pre.parent, morphDesc)
306
+ const existing = form.getMutateDataBeforeUpdate()
307
+ form.mutateDataBeforeUpdate(async (data, ctx) => {
308
+ const next = existing ? await existing(data, ctx) : data
309
+ return { ...next, ...morphPayload }
310
+ })
311
+ }
312
+ }
313
+
314
+ const formCtx = {
315
+ values,
316
+ basePath: base,
317
+ record: child,
318
+ parent: pre.parent,
319
+ parentId: pre.recordId,
320
+ relationship: rel,
321
+ }
322
+
323
+ const result = await dispatchFormSubmit(form, values, formCtx)
324
+ if (!result.ok) {
325
+ if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
326
+ const data = await relationManagerData(pilotiq, {
327
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
328
+ prefill: { values, errors: result.errors ?? {} },
329
+ }, req)
330
+ res.status(422)
331
+ return view('pilotiq.relation-edit', data ?? {})
332
+ }
333
+
334
+ const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
335
+ return sendRedirectResponse(req, res, json, redirect, result.notifications)
336
+ })
337
+
338
+ // Delete — POST ${resourceBase}/:id/${rel}/:childId/delete
339
+ router.post(`${parentBase}/:childId/delete`, async (req, res) => {
340
+ const json = wantsJson(req)
341
+ const pre = await requireParent(req, res, json)
342
+ if (!pre) return
343
+ const childId = req.params['childId']!
344
+
345
+ if (!Related?.model) {
346
+ res.status(500)
347
+ const msg = `RelationManager ${M.name}: cannot resolve related Resource for delete`
348
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
349
+ }
350
+
351
+ // Anti-IDOR: re-use the data builder's child-belongs check.
352
+ const childCheck = await relationManagerData(pilotiq, {
353
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
354
+ }, req)
355
+ if (childCheck === null) { res.status(404); return res.send('Not found') }
356
+ if ('ok' in childCheck && childCheck.ok === false) return forbidden(res, json)
357
+
358
+ const child = await findRecord(Related, childId, { user: pre.user }).catch(() => undefined)
359
+ if (!child) { res.status(404); return res.send('Not found') }
360
+
361
+ if (!await safeManagerPolicy(M, 'canDelete', Related, pre.user, pre.parent, child)) return forbidden(res, json)
362
+
363
+ const listUrl = parentBase.replace(':id', pre.recordId)
364
+ try {
365
+ await Related.model.delete(childId)
366
+ } catch (err) {
367
+ const message = err instanceof Error ? err.message : 'Delete failed'
368
+ res.status(500)
369
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
370
+ }
371
+
372
+ return sendMutationSuccess(req, res, json, {
373
+ id: childId, kind: 'rdelete', title: `${M.getLabelSingular()} deleted`, redirect: listUrl,
374
+ })
375
+ })
376
+
377
+ // ── Plan #13 polish — relation restore / force-delete ─────
378
+ // Mirror the resource-side soft-delete routes, scoped under the
379
+ // parent record. Both routes opt in only when the related Resource
380
+ // has `softDeletes = true` AND its model carries `restore` /
381
+ // `forceDelete`. Two-layer auth: parent canAccess + canEdit, then
382
+ // manager `canRestore / canForceDelete` (with related-Resource
383
+ // fall-through). IDOR check re-runs the parent's relation query
384
+ // through `withTrashed()` so trashed children still resolve.
385
+ const RelatedForSoft = Related
386
+ if (RelatedForSoft?.softDeletes) {
387
+ const RM = RelatedForSoft.model
388
+ if (!RM) {
389
+ throw new Error(
390
+ `[Pilotiq] RelationManager ${M.name} on ${R.name}: related Resource ${RelatedForSoft.name} has softDeletes = true but no model. ` +
391
+ `Wire one up or unset softDeletes.`,
392
+ )
393
+ }
394
+ if (typeof RM.restore !== 'function' || typeof RM.forceDelete !== 'function') {
395
+ throw new Error(
396
+ `[Pilotiq] RelationManager ${M.name} on ${R.name}: related Resource ${RelatedForSoft.name} has softDeletes = true but model.restore / model.forceDelete are missing. ` +
397
+ `Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`,
398
+ )
399
+ }
400
+
401
+ // IDOR-safe load through the parent's relation query, broadened
402
+ // with `withTrashed()` so currently-trashed children resolve.
403
+ // Returns undefined when the child doesn't belong to this parent
404
+ // (under the broadened scope) or the lookup misses.
405
+ const loadTrashableChild = async (parent: unknown, childId: string): Promise<unknown> => {
406
+ if (!R.model) return undefined
407
+ const pk = (RM.primaryKey ?? 'id') as string
408
+ const q: import('../orm/modelDefaults.js').ModelQuery = R.model.relatedQuery
409
+ ? R.model.relatedQuery(parent, rel)
410
+ : (parent as { related: (n: string) => import('../orm/modelDefaults.js').ModelQuery }).related(rel)
411
+ return findInQueryWithTrashed(q, pk, childId)
412
+ }
413
+
414
+ // Restore — POST ${resourceBase}/:id/${rel}/:childId/restore
415
+ router.post(`${parentBase}/:childId/restore`, async (req, res) => {
416
+ const json = wantsJson(req)
417
+ const pre = await requireParent(req, res, json)
418
+ if (!pre) return
419
+ const childId = req.params['childId']!
420
+
421
+ const child = await loadTrashableChild(pre.parent, childId)
422
+ if (!child) { res.status(404); return res.send('Not found') }
423
+
424
+ if (!await safeManagerPolicy(M, 'canRestore', RelatedForSoft, pre.user, pre.parent, child)) return forbidden(res, json)
425
+
426
+ const listUrl = parentBase.replace(':id', pre.recordId)
427
+ try {
428
+ await RM.restore!(childId)
429
+ } catch (err) {
430
+ const message = err instanceof Error ? err.message : 'Restore failed'
431
+ res.status(500)
432
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
433
+ }
434
+
435
+ return sendMutationSuccess(req, res, json, {
436
+ id: childId, kind: 'rrestore', title: `${M.getLabelSingular()} restored`, redirect: listUrl,
437
+ })
438
+ })
439
+
440
+ // Force-delete — POST ${resourceBase}/:id/${rel}/:childId/force-delete
441
+ router.post(`${parentBase}/:childId/force-delete`, async (req, res) => {
442
+ const json = wantsJson(req)
443
+ const pre = await requireParent(req, res, json)
444
+ if (!pre) return
445
+ const childId = req.params['childId']!
446
+
447
+ const child = await loadTrashableChild(pre.parent, childId)
448
+ if (!child) { res.status(404); return res.send('Not found') }
449
+
450
+ if (!await safeManagerPolicy(M, 'canForceDelete', RelatedForSoft, pre.user, pre.parent, child)) return forbidden(res, json)
451
+
452
+ const listUrl = parentBase.replace(':id', pre.recordId)
453
+ try {
454
+ await RM.forceDelete!(childId)
455
+ } catch (err) {
456
+ const message = err instanceof Error ? err.message : 'Force-delete failed'
457
+ res.status(500)
458
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
459
+ }
460
+
461
+ return sendMutationSuccess(req, res, json, {
462
+ id: childId, kind: 'rforce', title: `${M.getLabelSingular()} permanently deleted`, redirect: listUrl,
463
+ })
464
+ })
465
+ }
466
+
467
+ // ── M2M follow-up — manager-scoped action dispatch + detach ─────
468
+ // Two new routes per relation manager. Mounted unconditionally
469
+ // (even on hasMany managers) because handler-style actions are
470
+ // useful beyond M2M — any user-defined `Action.handler(...)` on a
471
+ // manager table needs a place to dispatch. The detach route is
472
+ // M2M-specific but cheap enough to register either way; non-M2M
473
+ // managers' `Action.relationDetach` factories return `visible=false`
474
+ // anyway, so the URL is unreachable in practice.
475
+
476
+ // Action dispatch — POST ${parentBase}/_action/:actionName
477
+ // Resolves the manager's table elements, finds the named action,
478
+ // and dispatches it with `ctx.relation = { parent, parentId, rel }`
479
+ // so M2M handlers can call `parent.related(rel).attach / detach`.
480
+ // Records hydrate against the related model (the rows visible in
481
+ // the manager's table are related-model records).
482
+ router.post(`${parentBase}/_action/:actionName`, async (req, res) => {
483
+ const json = wantsJson(req)
484
+ const pre = await requireParent(req, res, json)
485
+ if (!pre) return
486
+
487
+ const actionName = req.params['actionName']!
488
+ const body = await readFormBody(req)
489
+ const input = parseActionBody(body)
490
+
491
+ // Rebuild the manager's table so the dispatcher can find the
492
+ // action by name. Pure recreation — same context the page-data
493
+ // builder uses — so factories that close over `ctx` (URL,
494
+ // mode, parent record) see the same shape as at page render.
495
+ const managerCtx = {
496
+ basePath: base,
497
+ parentSlug: slug,
498
+ parentId: pre.recordId,
499
+ relationship: rel,
500
+ parentRecord: pre.parent,
501
+ related: Related,
502
+ mode,
503
+ }
504
+ const table = M.table(Table.make(), managerCtx)
505
+ const elements: import('../schema/Element.js').Element[] = [table]
506
+ // Stamp dispatch URLs so any nested action factories that read
507
+ // `dispatchUrl` (rare — most read it from the meta at render
508
+ // time) still see something sensible.
509
+ const listUrl = parentBase.replace(':id', pre.recordId)
510
+ tagActionDispatch(elements, listUrl)
511
+
512
+ const target = resolveDispatchTarget(elements, actionName)
513
+ if (!target) {
514
+ if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
515
+ res.status(404)
516
+ return res.send(`Action "${actionName}" not found on ${M.name}`)
517
+ }
518
+
519
+ const resolveRecord: ResolveRecord | undefined = Related?.model
520
+ ? (id: string) => Related.model!.find(id)
521
+ : undefined
522
+
523
+ const result = await dispatchAction(target.action, {
524
+ ...input,
525
+ request: req,
526
+ user: pre.user,
527
+ relation: { parent: pre.parent, parentId: pre.recordId, relationship: rel },
528
+ ...(target.rowField ? { rowField: target.rowField } : {}),
529
+ ...(target.formSchema ? { formSchema: target.formSchema } : {}),
530
+ }, resolveRecord)
531
+ return sendActionResult(req, res, json, result, base, listUrl)
532
+ })
533
+
534
+ // Detach — POST ${parentBase}/:childId/_detach
535
+ // Direct row-action target for `Action.relationDetach`. Removes the
536
+ // pivot row only; the related record stays in place. IDOR check:
537
+ // verify the child is currently attached before calling detach so
538
+ // a tampered URL can't probe random ids.
539
+ router.post(`${parentBase}/:childId/_detach`, async (req, res) => {
540
+ const json = wantsJson(req)
541
+ const pre = await requireParent(req, res, json)
542
+ if (!pre) return
543
+ const childId = req.params['childId']!
544
+
545
+ if (mode !== 'belongsToMany' && mode !== 'morphToMany' && mode !== 'morphedByMany') {
546
+ // Detach is meaningless for hasMany — the user wants `delete`.
547
+ // Surface a clear 404 instead of silently no-op'ing.
548
+ res.status(404)
549
+ const msg = 'Detach is only supported on M2M relations (belongsToMany, morphToMany, morphedByMany)'
550
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
551
+ }
552
+
553
+ // Manager-only canDetach: pivot ops don't fall through to the
554
+ // related Resource. We don't have the related child loaded yet —
555
+ // pass `undefined` for the per-record arg; canDetach gates on
556
+ // (user, parent) by default and only sees `record` when a
557
+ // manager has explicitly overridden with a per-row predicate.
558
+ // Authors who need per-row gating can detect undefined and either
559
+ // load the child themselves or short-circuit.
560
+ // Two distinct accessors are needed under the real
561
+ // `@rudderjs/orm`:
562
+ // - `parent.related(rel)` returns a deferred QueryBuilder
563
+ // with `where / paginate` (IDOR read-side check).
564
+ // - `parent[rel]()` returns the pivot-mutation accessor with
565
+ // `attach / detach / sync` (write-side).
566
+ // Test stubs may collapse both onto the same `parent.related(rel)`
567
+ // shape — handle that fallback so existing tests keep passing.
568
+ let child: unknown = undefined
569
+ const readSide = (pre.parent as { related?: (n: string) => { where?: (...a: unknown[]) => unknown; paginate?: (p: number, pp: number) => Promise<{ data: unknown[] }> } })
570
+ ?.related?.(rel)
571
+ if (!readSide) {
572
+ res.status(500)
573
+ const msg = `Parent.related("${rel}") missing — wrong relation type or ORM version?`
574
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
575
+ }
576
+ try {
577
+ // IDOR: confirm the child is currently attached.
578
+ if (typeof readSide.paginate === 'function') {
579
+ const pk = Related?.model ? getPrimaryKey(Related.model) : 'id'
580
+ const out = await (readSide as unknown as { where: (col: string, op: string, val: unknown) => { paginate: (p: number, pp: number) => Promise<{ data: unknown[] }> } }).where(pk, '=', childId).paginate(1, 1)
581
+ child = Array.isArray(out.data) ? out.data[0] : undefined
582
+ }
583
+ } catch {
584
+ // fall through; null child means we couldn't verify — safer to 404
585
+ }
586
+ if (child === undefined) { res.status(404); return res.send('Not found') }
587
+
588
+ if (!await safeManagerPolicy(M, 'canDetach', undefined, pre.user, pre.parent, child)) return forbidden(res, json)
589
+
590
+ // Real ORM: `parent[rel]()` returns the pivot accessor. Test
591
+ // stubs: `parent.related(rel)` may carry `detach` directly.
592
+ // Try the prototype-installed instance method first, then fall
593
+ // back to the read-side shape.
594
+ let writeAccessor: { detach?: (ids: unknown) => Promise<unknown> } | undefined
595
+ const inst = (pre.parent as Record<string, unknown>)[rel]
596
+ if (typeof inst === 'function') {
597
+ try {
598
+ const out = (inst as () => unknown).call(pre.parent) as { detach?: (ids: unknown) => Promise<unknown> } | undefined
599
+ if (out && typeof out.detach === 'function') writeAccessor = out
600
+ } catch { /* fall through to legacy shape */ }
601
+ }
602
+ if (!writeAccessor && typeof (readSide as { detach?: unknown }).detach === 'function') {
603
+ writeAccessor = readSide as { detach: (ids: unknown) => Promise<unknown> }
604
+ }
605
+ if (!writeAccessor) {
606
+ res.status(500)
607
+ const msg = `Pivot accessor missing on ${rel} — wrong relation type or ORM version?`
608
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
609
+ }
610
+
611
+ try {
612
+ await writeAccessor.detach!([childId])
613
+ } catch (err) {
614
+ const message = err instanceof Error ? err.message : 'Detach failed'
615
+ res.status(500)
616
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
617
+ }
618
+
619
+ const listUrl = parentBase.replace(':id', pre.recordId)
620
+ return sendMutationSuccess(req, res, json, {
621
+ id: childId, kind: 'rdetach', title: `${M.getLabelSingular()} detached`, redirect: listUrl,
622
+ })
623
+ })
624
+
625
+ // ── Phase B nested relation routes ──────────────────
626
+ // For each manager N declared under M.relations(), mount the
627
+ // depth-2 list/create/view/edit/delete handlers. Auth + chain
628
+ // IDOR are centralized in `nestedRelationManagerData` — route
629
+ // bodies dispatch the data builder and unwrap the tagged
630
+ // {ok:false,status:403} / null shapes. Surface area mirrors
631
+ // Phase A: no M2M attach/detach, no soft-delete restore on
632
+ // nested managers in v1 (open follow-ups if a consumer asks).
633
+ for (const N of M.relations()) {
634
+ const nestedRel = N.getRelationship()
635
+ const nestedBase = `${parentBase}/:childId/${nestedRel}`
636
+
637
+ // Hoist the depth-2 related-resource lookups out of per-handler
638
+ // closures — same rationale as the depth-1 `Related` hoist above.
639
+ // `Related1` is the (M-side) related Resource (already hoisted as
640
+ // `Related`); `Related2` is the (N-side) related Resource.
641
+ const Related1 = Related
642
+ const Related2 = Related1 ? findRelatedResource(N, Related1, cfg) : undefined
643
+
644
+ // Build a `chain` tuple from the URL params for relayed calls
645
+ // into `relationManagerData`. The childId of the *outer* manager
646
+ // is the recordId of the leaf step.
647
+ const buildChain = (id: string, childId1: string): [{ recordId: string; relationship: string }, { recordId: string; relationship: string }] => [
648
+ { recordId: id, relationship: rel },
649
+ { recordId: childId1, relationship: nestedRel },
650
+ ]
651
+
652
+ // ── List ──
653
+ router.get(nestedBase, async (req, res) => {
654
+ const json = wantsJson(req)
655
+ const id = req.params['id']!
656
+ const childId1 = req.params['childId']!
657
+ const data = await relationManagerData(pilotiq, {
658
+ kind: 'nested-relation-list', slug,
659
+ chain: buildChain(id, childId1),
660
+ query: req.query as Record<string, string>,
661
+ }, req)
662
+ if (data === null) { res.status(404); return res.send('Not found') }
663
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
664
+ return view('pilotiq.nested-relation-list', data)
665
+ })
666
+
667
+ // ── Create (GET) ──
668
+ router.get(`${nestedBase}/create`, async (req, res) => {
669
+ const json = wantsJson(req)
670
+ const id = req.params['id']!
671
+ const childId1 = req.params['childId']!
672
+ const data = await relationManagerData(pilotiq, {
673
+ kind: 'nested-relation-create', slug,
674
+ chain: buildChain(id, childId1),
675
+ }, req)
676
+ if (data === null) { res.status(404); return res.send('Not found') }
677
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
678
+ return view('pilotiq.nested-relation-create', data)
679
+ })
680
+
681
+ // ── Create (POST) ──
682
+ router.post(`${nestedBase}/create`, async (req, res) => {
683
+ const json = wantsJson(req)
684
+ const id = req.params['id']!
685
+ const childId1 = req.params['childId']!
686
+ // Run the chain walk once to verify auth + IDOR + load child1.
687
+ // Any failure returns the same tagged shape we serve on GET.
688
+ const pre = await relationManagerData(pilotiq, {
689
+ kind: 'nested-relation-create', slug,
690
+ chain: buildChain(id, childId1),
691
+ }, req)
692
+ if (pre === null) { res.status(404); return res.send('Not found') }
693
+ if ('ok' in pre && pre.ok === false) return forbidden(res, json)
694
+
695
+ // Re-resolve the leaf manager's bits for form submit. We need
696
+ // the leaf parent record (`child1`) and the related class for
697
+ // save/loadRecord wiring. Reuse `findRelatedResource` against
698
+ // the chain walk's intermediate Resource (Related1).
699
+ if (!Related1) {
700
+ res.status(500)
701
+ const msg = `Nested manager ${N.name}: cannot resolve middle Resource for create`
702
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
703
+ }
704
+ if (!Related2?.model) {
705
+ res.status(500)
706
+ const msg = `Nested manager ${N.name}: cannot resolve related Resource for create`
707
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
708
+ }
709
+ const user = await pilotiq.resolveUser(req)
710
+ const child1 = await findRecord(Related1, childId1, { user }).catch(() => undefined)
711
+ if (!child1) { res.status(404); return res.send('Not found') }
712
+
713
+ const body = await readFormBody(req)
714
+ const { values } = splitMeta(body)
715
+
716
+ const listUrl = nestedBase.replace(':id', id).replace(':childId', childId1)
717
+
718
+ const nestedMode: RelationMode = Related1.model
719
+ ? normalizeRelationMode(getRelationType(Related1.model, nestedRel))
720
+ : 'hasMany'
721
+
722
+ const form = N.form(Form.make(), {
723
+ basePath: base,
724
+ parentSlug: slug,
725
+ parentId: childId1,
726
+ relationship: nestedRel,
727
+ parentRecord: child1,
728
+ related: Related2,
729
+ mode: nestedMode,
730
+ chain: [{ slug, recordId: id, relationship: rel }],
731
+ })
732
+ if (Related2.model) {
733
+ if (!form.getSave()) form.save(modelSave(Related2.model))
734
+ if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related2))
735
+ }
736
+
737
+ // Polymorphic morph-column auto-injection mirrors the depth-1
738
+ // create handler — uses Related1 (the leaf parent's owner) as
739
+ // the morph source on the leaf relation.
740
+ if (nestedMode === 'morphMany' && Related1.model) {
741
+ const morphDesc = getMorphRelationDescriptor(Related1.model, nestedRel)
742
+ if (!morphDesc) {
743
+ res.status(500)
744
+ const msg = `Nested manager ${N.name}: relations[${JSON.stringify(nestedRel)}] reports a polymorphic type but is missing morphName.`
745
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
746
+ }
747
+ const morphPayload = computeMorphPayload(child1, morphDesc)
748
+ const existing = form.getMutateDataBeforeCreate()
749
+ form.mutateDataBeforeCreate(async (data, ctx) => {
750
+ const next = existing ? await existing(data, ctx) : data
751
+ return { ...next, ...morphPayload }
752
+ })
753
+ }
754
+
755
+ const formCtx = {
756
+ values,
757
+ basePath: base,
758
+ parent: child1,
759
+ parentId: childId1,
760
+ relationship: nestedRel,
761
+ }
762
+
763
+ const result = await dispatchFormSubmit(form, values, formCtx)
764
+ if (!result.ok) {
765
+ if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
766
+ const data = await relationManagerData(pilotiq, {
767
+ kind: 'nested-relation-create', slug,
768
+ chain: buildChain(id, childId1),
769
+ prefill: { values, errors: result.errors ?? {} },
770
+ }, req)
771
+ res.status(422)
772
+ return view('pilotiq.nested-relation-create', data ?? {})
773
+ }
774
+
775
+ const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
776
+ return sendRedirectResponse(req, res, json, redirect, result.notifications)
777
+ })
778
+
779
+ // ── View ──
780
+ router.get(`${nestedBase}/:childId2`, async (req, res) => {
781
+ const json = wantsJson(req)
782
+ const id = req.params['id']!
783
+ const childId1 = req.params['childId']!
784
+ const childId2 = req.params['childId2']!
785
+ if (childId2 === 'create') { res.status(404); return res.send('Not found') }
786
+ const data = await relationManagerData(pilotiq, {
787
+ kind: 'nested-relation-view', slug,
788
+ chain: buildChain(id, childId1),
789
+ childId: childId2,
790
+ }, req)
791
+ if (data === null) { res.status(404); return res.send('Not found') }
792
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
793
+ return view('pilotiq.nested-relation-view', data)
794
+ })
795
+
796
+ // ── Edit (GET) ──
797
+ router.get(`${nestedBase}/:childId2/edit`, async (req, res) => {
798
+ const json = wantsJson(req)
799
+ const id = req.params['id']!
800
+ const childId1 = req.params['childId']!
801
+ const childId2 = req.params['childId2']!
802
+ const data = await relationManagerData(pilotiq, {
803
+ kind: 'nested-relation-edit', slug,
804
+ chain: buildChain(id, childId1),
805
+ childId: childId2,
806
+ }, req)
807
+ if (data === null) { res.status(404); return res.send('Not found') }
808
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
809
+ return view('pilotiq.nested-relation-edit', data)
810
+ })
811
+
812
+ // ── Edit (POST) ──
813
+ router.post(`${nestedBase}/:childId2/edit`, async (req, res) => {
814
+ const json = wantsJson(req)
815
+ const id = req.params['id']!
816
+ const childId1 = req.params['childId']!
817
+ const childId2 = req.params['childId2']!
818
+
819
+ // Replay the chain to verify auth, IDOR, load child1+child2.
820
+ const pre = await relationManagerData(pilotiq, {
821
+ kind: 'nested-relation-edit', slug,
822
+ chain: buildChain(id, childId1),
823
+ childId: childId2,
824
+ }, req)
825
+ if (pre === null) { res.status(404); return res.send('Not found') }
826
+ if ('ok' in pre && pre.ok === false) return forbidden(res, json)
827
+
828
+ if (!Related1) {
829
+ res.status(500)
830
+ const msg = `Nested manager ${N.name}: cannot resolve middle Resource for edit`
831
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
832
+ }
833
+ if (!Related2?.model) {
834
+ res.status(500)
835
+ const msg = `Nested manager ${N.name}: cannot resolve related Resource for edit`
836
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
837
+ }
838
+
839
+ const user = await pilotiq.resolveUser(req)
840
+ // Parallelize child1 + child2 loads — both depend only on `user`.
841
+ const [child1, child2] = await Promise.all([
842
+ findRecord(Related1, childId1, { user }).catch(() => undefined),
843
+ findRecord(Related2, childId2, { user }).catch(() => undefined),
844
+ ])
845
+ if (!child1) { res.status(404); return res.send('Not found') }
846
+ if (!child2) { res.status(404); return res.send('Not found') }
847
+
848
+ const body = await readFormBody(req)
849
+ const { values } = splitMeta(body)
850
+
851
+ const editUrl = `${nestedBase}/${childId2}/edit`.replace(':id', id).replace(':childId', childId1)
852
+
853
+ const nestedMode: RelationMode = Related1.model
854
+ ? normalizeRelationMode(getRelationType(Related1.model, nestedRel))
855
+ : 'hasMany'
856
+
857
+ const form = N.form(Form.make(), {
858
+ basePath: base,
859
+ parentSlug: slug,
860
+ parentId: childId1,
861
+ relationship: nestedRel,
862
+ parentRecord: child1,
863
+ related: Related2,
864
+ mode: nestedMode,
865
+ chain: [{ slug, recordId: id, relationship: rel }],
866
+ })
867
+ if (!form.getSave()) form.save(modelSave(Related2.model))
868
+ if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related2))
869
+
870
+ if (nestedMode === 'morphMany' && Related1.model) {
871
+ const morphDesc = getMorphRelationDescriptor(Related1.model, nestedRel)
872
+ if (morphDesc) {
873
+ const morphPayload = computeMorphPayload(child1, morphDesc)
874
+ const existing = form.getMutateDataBeforeUpdate()
875
+ form.mutateDataBeforeUpdate(async (data, ctx) => {
876
+ const next = existing ? await existing(data, ctx) : data
877
+ return { ...next, ...morphPayload }
878
+ })
879
+ }
880
+ }
881
+
882
+ const formCtx = {
883
+ values,
884
+ basePath: base,
885
+ record: child2,
886
+ parent: child1,
887
+ parentId: childId1,
888
+ relationship: nestedRel,
889
+ }
890
+
891
+ const result = await dispatchFormSubmit(form, values, formCtx)
892
+ if (!result.ok) {
893
+ if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
894
+ const data = await relationManagerData(pilotiq, {
895
+ kind: 'nested-relation-edit', slug,
896
+ chain: buildChain(id, childId1),
897
+ childId: childId2,
898
+ prefill: { values, errors: result.errors ?? {} },
899
+ }, req)
900
+ res.status(422)
901
+ return view('pilotiq.nested-relation-edit', data ?? {})
902
+ }
903
+
904
+ const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
905
+ return sendRedirectResponse(req, res, json, redirect, result.notifications)
906
+ })
907
+
908
+ // ── Delete ──
909
+ router.post(`${nestedBase}/:childId2/delete`, async (req, res) => {
910
+ const json = wantsJson(req)
911
+ const id = req.params['id']!
912
+ const childId1 = req.params['childId']!
913
+ const childId2 = req.params['childId2']!
914
+
915
+ // Replay the chain to verify auth + IDOR + load child2.
916
+ // We piggy-back on the edit scope's checks (canEdit on the
917
+ // leaf manager — same gate the depth-1 delete uses today via
918
+ // the relation-edit scope).
919
+ const pre = await relationManagerData(pilotiq, {
920
+ kind: 'nested-relation-edit', slug,
921
+ chain: buildChain(id, childId1),
922
+ childId: childId2,
923
+ }, req)
924
+ if (pre === null) { res.status(404); return res.send('Not found') }
925
+ if ('ok' in pre && pre.ok === false) return forbidden(res, json)
926
+
927
+ if (!Related1) {
928
+ res.status(500)
929
+ const msg = `Nested manager ${N.name}: cannot resolve middle Resource for delete`
930
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
931
+ }
932
+ if (!Related2?.model) {
933
+ res.status(500)
934
+ const msg = `Nested manager ${N.name}: cannot resolve related Resource for delete`
935
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
936
+ }
937
+
938
+ const user = await pilotiq.resolveUser(req)
939
+ // Parallelize child1 + child2 loads — both depend only on `user`.
940
+ const [child1, child2] = await Promise.all([
941
+ findRecord(Related1, childId1, { user }).catch(() => undefined),
942
+ findRecord(Related2, childId2, { user }).catch(() => undefined),
943
+ ])
944
+ if (!child1) { res.status(404); return res.send('Not found') }
945
+ if (!child2) { res.status(404); return res.send('Not found') }
946
+
947
+ if (!await safeManagerPolicy(N, 'canDelete', Related2, user, child1, child2)) return forbidden(res, json)
948
+
949
+ const listUrl = nestedBase.replace(':id', id).replace(':childId', childId1)
950
+ try {
951
+ await Related2.model.delete(childId2)
952
+ } catch (err) {
953
+ const message = err instanceof Error ? err.message : 'Delete failed'
954
+ res.status(500)
955
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
956
+ }
957
+
958
+ return sendMutationSuccess(req, res, json, {
959
+ id: childId2, kind: 'nrdelete', title: `${N.getLabelSingular()} deleted`, redirect: listUrl,
960
+ })
961
+ })
962
+
963
+ // ── Phase B follow-up — nested action / detach / soft-delete ──
964
+ // Mirror the depth-1 manager surface (`_action`, `_detach`,
965
+ // `restore`, `force-delete`) under the nested manager. Auth +
966
+ // chain IDOR centralized in `resolveRelationChain`; each route
967
+ // layers its own scope-specific gate (canDetach / canRestore /
968
+ // canForceDelete; the action route mirrors depth-1 by not adding
969
+ // an extra manager-level gate beyond the chain walk).
970
+ const nestedChainSlug = slug
971
+ const requireNestedChain = async (req: AppRequest, res: AppResponse, json: boolean): Promise<{
972
+ user: unknown
973
+ resolved: ResolvedChain
974
+ parentId: string
975
+ child1Id: string
976
+ } | undefined> => {
977
+ const id = req.params['id']!
978
+ const child1Id = req.params['childId']!
979
+ const user = await pilotiq.resolveUser(req)
980
+ const resolved = await resolveRelationChain(pilotiq, {
981
+ kind: 'nested-relation-list',
982
+ slug: nestedChainSlug,
983
+ chain: [
984
+ { recordId: id, relationship: rel },
985
+ { recordId: child1Id, relationship: nestedRel },
986
+ ],
987
+ }, user)
988
+ if (resolved === null) { res.status(404); res.send('Not found'); return undefined }
989
+ if ('ok' in resolved) { forbidden(res, json); return undefined }
990
+ return { user, resolved, parentId: id, child1Id }
991
+ }
992
+
993
+ // Listing URL (filled per request — `:id` / `:childId` get baked
994
+ // in once the params are known). All four routes redirect here
995
+ // on success so users land back on the nested-relation list.
996
+ const nestedListUrlFor = (id: string, child1Id: string): string =>
997
+ nestedBase.replace(':id', id).replace(':childId', child1Id)
998
+
999
+ // ── Action dispatch — POST ${nestedBase}/_action/:actionName ──
1000
+ // Resolves N's table elements, finds the named action, dispatches
1001
+ // it with `ctx.relation = { parent: child1, parentId, rel }` so
1002
+ // M2M handlers on the nested manager can call accessor methods.
1003
+ // Handler-style actions are useful on hasMany too — mounted
1004
+ // unconditionally.
1005
+ router.post(`${nestedBase}/_action/:actionName`, async (req, res) => {
1006
+ const json = wantsJson(req)
1007
+ const pre = await requireNestedChain(req, res, json)
1008
+ if (!pre) return
1009
+ const { resolved } = pre
1010
+ const { Related1, child1, M2, Related2, child2Mode } = resolved
1011
+
1012
+ const actionName = req.params['actionName']!
1013
+ const body = await readFormBody(req)
1014
+ const input = parseActionBody(body)
1015
+
1016
+ // Manager ctx for N — same shape `nestedManagerCtx` builds for
1017
+ // the data-builder side, so factories that close over `ctx`
1018
+ // (URL templates, mode-aware visibility) see the same view as
1019
+ // at page render.
1020
+ const nestedManagerCtxObj = {
1021
+ basePath: base,
1022
+ parentSlug: resolved.R.getSlug(),
1023
+ parentId: pre.child1Id, // immediate parent of N = child1
1024
+ relationship: nestedRel,
1025
+ parentRecord: child1,
1026
+ related: Related2,
1027
+ mode: child2Mode,
1028
+ chain: [{
1029
+ slug: resolved.R.getSlug(),
1030
+ recordId: pre.parentId,
1031
+ relationship: rel,
1032
+ }],
1033
+ }
1034
+ const table = M2.table(Table.make(), nestedManagerCtxObj)
1035
+ const elements: import('../schema/Element.js').Element[] = [table]
1036
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
1037
+ tagActionDispatch(elements, listUrl)
1038
+
1039
+ const target = resolveDispatchTarget(elements, actionName)
1040
+ if (!target) {
1041
+ if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
1042
+ res.status(404)
1043
+ return res.send(`Action "${actionName}" not found on ${M2.name}`)
1044
+ }
1045
+
1046
+ const resolveRecord: ResolveRecord | undefined = Related2?.model
1047
+ ? (id: string) => Related2.model!.find(id)
1048
+ : undefined
1049
+
1050
+ const result = await dispatchAction(target.action, {
1051
+ ...input,
1052
+ request: req,
1053
+ user: pre.user,
1054
+ relation: { parent: child1, parentId: pre.child1Id, relationship: nestedRel },
1055
+ ...(target.rowField ? { rowField: target.rowField } : {}),
1056
+ ...(target.formSchema ? { formSchema: target.formSchema } : {}),
1057
+ }, resolveRecord)
1058
+ return sendActionResult(req, res, json, result, base, listUrl)
1059
+ })
1060
+
1061
+ // ── Detach — POST ${nestedBase}/:childId2/_detach ──
1062
+ // M2M-only direct row-detach. IDOR-checks the grandchild against
1063
+ // child1.related(nestedRel), then calls accessor.detach. Mirrors
1064
+ // the depth-1 detach route at line 1955.
1065
+ router.post(`${nestedBase}/:childId2/_detach`, async (req, res) => {
1066
+ const json = wantsJson(req)
1067
+ const pre = await requireNestedChain(req, res, json)
1068
+ if (!pre) return
1069
+ const childId2 = req.params['childId2']!
1070
+ const { resolved } = pre
1071
+ const { Related1, child1, M2, Related2, child2Mode } = resolved
1072
+
1073
+ if (child2Mode !== 'belongsToMany' && child2Mode !== 'morphToMany' && child2Mode !== 'morphedByMany') {
1074
+ res.status(404)
1075
+ const msg = 'Detach is only supported on M2M relations (belongsToMany, morphToMany, morphedByMany)'
1076
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
1077
+ }
1078
+
1079
+ // IDOR: confirm child2 is currently attached to child1 under
1080
+ // nestedRel. Read-side accessor (`child1.related(nestedRel)`)
1081
+ // returns a deferred QueryBuilder; we never bypass it.
1082
+ const readSide = (child1 as { related?: (n: string) => { where?: (...a: unknown[]) => unknown; paginate?: (p: number, pp: number) => Promise<{ data: unknown[] }> } })
1083
+ ?.related?.(nestedRel)
1084
+ if (!readSide) {
1085
+ res.status(500)
1086
+ const msg = `child1.related("${nestedRel}") missing — wrong relation type or ORM version?`
1087
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
1088
+ }
1089
+ let child2: unknown = undefined
1090
+ try {
1091
+ if (typeof readSide.paginate === 'function') {
1092
+ const pk = Related2?.model ? getPrimaryKey(Related2.model) : 'id'
1093
+ const out = await (readSide as unknown as { where: (col: string, op: string, val: unknown) => { paginate: (p: number, pp: number) => Promise<{ data: unknown[] }> } }).where(pk, '=', childId2).paginate(1, 1)
1094
+ child2 = Array.isArray(out.data) ? out.data[0] : undefined
1095
+ }
1096
+ } catch { /* fall through */ }
1097
+ if (child2 === undefined) { res.status(404); return res.send('Not found') }
1098
+
1099
+ if (!await safeManagerPolicy(M2, 'canDetach', Related2, pre.user, child1, child2)) return forbidden(res, json)
1100
+
1101
+ // Real ORM: child1[nestedRel]() returns the pivot accessor
1102
+ // with attach/detach/sync. Test stubs may collapse onto
1103
+ // `child1.related(nestedRel)` — try both.
1104
+ let writeAccessor: { detach?: (ids: unknown) => Promise<unknown> } | undefined
1105
+ const inst = (child1 as Record<string, unknown>)[nestedRel]
1106
+ if (typeof inst === 'function') {
1107
+ try {
1108
+ const out = (inst as () => unknown).call(child1) as { detach?: (ids: unknown) => Promise<unknown> } | undefined
1109
+ if (out && typeof out.detach === 'function') writeAccessor = out
1110
+ } catch { /* fall through */ }
1111
+ }
1112
+ if (!writeAccessor && typeof (readSide as { detach?: unknown }).detach === 'function') {
1113
+ writeAccessor = readSide as { detach: (ids: unknown) => Promise<unknown> }
1114
+ }
1115
+ if (!writeAccessor) {
1116
+ res.status(500)
1117
+ const msg = `Pivot accessor missing on ${nestedRel} — wrong relation type or ORM version?`
1118
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
1119
+ }
1120
+
1121
+ try {
1122
+ await writeAccessor.detach!([childId2])
1123
+ } catch (err) {
1124
+ const message = err instanceof Error ? err.message : 'Detach failed'
1125
+ res.status(500)
1126
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
1127
+ }
1128
+
1129
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
1130
+ return sendMutationSuccess(req, res, json, {
1131
+ id: childId2, kind: 'nrdetach', title: `${M2.getLabelSingular()} detached`, redirect: listUrl,
1132
+ })
1133
+ })
1134
+
1135
+ // ── Soft-delete: restore + force-delete ───────────────────────
1136
+ // Opt in only when Related2 has `softDeletes = true` AND its
1137
+ // model carries `restore` / `forceDelete`. Mirrors the depth-1
1138
+ // routes at line 1804+. IDOR runs against child1.related(nestedRel)
1139
+ // broadened with `withTrashed()` so trashed grandchildren resolve.
1140
+ const Related1ForSoft = Related1
1141
+ const Related2ForSoft = Related2
1142
+ if (Related2ForSoft?.softDeletes) {
1143
+ const RM2 = Related2ForSoft.model
1144
+ if (!RM2) {
1145
+ throw new Error(
1146
+ `[Pilotiq] Nested RelationManager ${N.name} on ${M.name} (${R.name}): related Resource ${Related2ForSoft.name} has softDeletes = true but no model. ` +
1147
+ `Wire one up or unset softDeletes.`,
1148
+ )
1149
+ }
1150
+ if (typeof RM2.restore !== 'function' || typeof RM2.forceDelete !== 'function') {
1151
+ throw new Error(
1152
+ `[Pilotiq] Nested RelationManager ${N.name} on ${M.name} (${R.name}): related Resource ${Related2ForSoft.name} has softDeletes = true but model.restore / model.forceDelete are missing. ` +
1153
+ `Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`,
1154
+ )
1155
+ }
1156
+
1157
+ // Like the depth-1 helper: load the grandchild via the parent's
1158
+ // relation query, broadened with `withTrashed()`. Returns
1159
+ // undefined when the lookup misses or the grandchild doesn't
1160
+ // belong to child1 under nestedRel.
1161
+ const loadTrashableGrandchild = async (parentChild: unknown, child2Id: string): Promise<unknown> => {
1162
+ const pk = (RM2.primaryKey ?? 'id') as string
1163
+ const q: import('../orm/modelDefaults.js').ModelQuery = (parentChild as { related: (n: string) => import('../orm/modelDefaults.js').ModelQuery }).related(nestedRel)
1164
+ return findInQueryWithTrashed(q, pk, child2Id)
1165
+ }
1166
+
1167
+ // Restore — POST ${nestedBase}/:childId2/restore
1168
+ router.post(`${nestedBase}/:childId2/restore`, async (req, res) => {
1169
+ const json = wantsJson(req)
1170
+ const pre = await requireNestedChain(req, res, json)
1171
+ if (!pre) return
1172
+ const childId2 = req.params['childId2']!
1173
+ const child2 = await loadTrashableGrandchild(pre.resolved.child1, childId2)
1174
+ if (!child2) { res.status(404); return res.send('Not found') }
1175
+
1176
+ if (!await safeManagerPolicy(N, 'canRestore', Related2ForSoft, pre.user, pre.resolved.child1, child2)) return forbidden(res, json)
1177
+
1178
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
1179
+ try {
1180
+ await RM2.restore!(childId2)
1181
+ } catch (err) {
1182
+ const message = err instanceof Error ? err.message : 'Restore failed'
1183
+ res.status(500)
1184
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
1185
+ }
1186
+
1187
+ return sendMutationSuccess(req, res, json, {
1188
+ id: childId2, kind: 'nrrestore', title: `${N.getLabelSingular()} restored`, redirect: listUrl,
1189
+ })
1190
+ })
1191
+
1192
+ // Force-delete — POST ${nestedBase}/:childId2/force-delete
1193
+ router.post(`${nestedBase}/:childId2/force-delete`, async (req, res) => {
1194
+ const json = wantsJson(req)
1195
+ const pre = await requireNestedChain(req, res, json)
1196
+ if (!pre) return
1197
+ const childId2 = req.params['childId2']!
1198
+ const child2 = await loadTrashableGrandchild(pre.resolved.child1, childId2)
1199
+ if (!child2) { res.status(404); return res.send('Not found') }
1200
+
1201
+ if (!await safeManagerPolicy(N, 'canForceDelete', Related2ForSoft, pre.user, pre.resolved.child1, child2)) return forbidden(res, json)
1202
+
1203
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
1204
+ try {
1205
+ await RM2.forceDelete!(childId2)
1206
+ } catch (err) {
1207
+ const message = err instanceof Error ? err.message : 'Force-delete failed'
1208
+ res.status(500)
1209
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
1210
+ }
1211
+
1212
+ return sendMutationSuccess(req, res, json, {
1213
+ id: childId2, kind: 'nrforce', title: `${N.getLabelSingular()} permanently deleted`, redirect: listUrl,
1214
+ })
1215
+ })
1216
+ }
1217
+ }
1218
+ }
1219
+ }