@pilotiq/pilotiq 0.1.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 (1409) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +11 -0
  3. package/CLAUDE.md +207 -0
  4. package/LICENSE +21 -0
  5. package/dist/Cluster.d.ts +56 -0
  6. package/dist/Cluster.d.ts.map +1 -0
  7. package/dist/Cluster.js +62 -0
  8. package/dist/Cluster.js.map +1 -0
  9. package/dist/Column.d.ts +378 -0
  10. package/dist/Column.d.ts.map +1 -0
  11. package/dist/Column.js +434 -0
  12. package/dist/Column.js.map +1 -0
  13. package/dist/Global.d.ts +123 -0
  14. package/dist/Global.d.ts.map +1 -0
  15. package/dist/Global.js +124 -0
  16. package/dist/Global.js.map +1 -0
  17. package/dist/Page.d.ts +90 -0
  18. package/dist/Page.d.ts.map +1 -0
  19. package/dist/Page.js +107 -0
  20. package/dist/Page.js.map +1 -0
  21. package/dist/Pilotiq.d.ts +505 -0
  22. package/dist/Pilotiq.d.ts.map +1 -0
  23. package/dist/Pilotiq.js +463 -0
  24. package/dist/Pilotiq.js.map +1 -0
  25. package/dist/PilotiqRegistry.d.ts +10 -0
  26. package/dist/PilotiqRegistry.d.ts.map +1 -0
  27. package/dist/PilotiqRegistry.js +32 -0
  28. package/dist/PilotiqRegistry.js.map +1 -0
  29. package/dist/PilotiqServiceProvider.d.ts +16 -0
  30. package/dist/PilotiqServiceProvider.d.ts.map +1 -0
  31. package/dist/PilotiqServiceProvider.js +57 -0
  32. package/dist/PilotiqServiceProvider.js.map +1 -0
  33. package/dist/RelationManager.d.ts +372 -0
  34. package/dist/RelationManager.d.ts.map +1 -0
  35. package/dist/RelationManager.js +342 -0
  36. package/dist/RelationManager.js.map +1 -0
  37. package/dist/RenderHook.d.ts +86 -0
  38. package/dist/RenderHook.d.ts.map +1 -0
  39. package/dist/RenderHook.js +116 -0
  40. package/dist/RenderHook.js.map +1 -0
  41. package/dist/Resource.d.ts +290 -0
  42. package/dist/Resource.d.ts.map +1 -0
  43. package/dist/Resource.js +362 -0
  44. package/dist/Resource.js.map +1 -0
  45. package/dist/RightPanel.d.ts +92 -0
  46. package/dist/RightPanel.d.ts.map +1 -0
  47. package/dist/RightPanel.js +61 -0
  48. package/dist/RightPanel.js.map +1 -0
  49. package/dist/Tab.d.ts +92 -0
  50. package/dist/Tab.d.ts.map +1 -0
  51. package/dist/Tab.js +93 -0
  52. package/dist/Tab.js.map +1 -0
  53. package/dist/UserMenuItem.d.ts +76 -0
  54. package/dist/UserMenuItem.d.ts.map +1 -0
  55. package/dist/UserMenuItem.js +87 -0
  56. package/dist/UserMenuItem.js.map +1 -0
  57. package/dist/actions/Action.d.ts +888 -0
  58. package/dist/actions/Action.d.ts.map +1 -0
  59. package/dist/actions/Action.js +1652 -0
  60. package/dist/actions/Action.js.map +1 -0
  61. package/dist/actions/ActionGroup.d.ts +85 -0
  62. package/dist/actions/ActionGroup.d.ts.map +1 -0
  63. package/dist/actions/ActionGroup.js +132 -0
  64. package/dist/actions/ActionGroup.js.map +1 -0
  65. package/dist/actions/attachFactory.d.ts +67 -0
  66. package/dist/actions/attachFactory.d.ts.map +1 -0
  67. package/dist/actions/attachFactory.js +115 -0
  68. package/dist/actions/attachFactory.js.map +1 -0
  69. package/dist/actions/exportFactory.d.ts +88 -0
  70. package/dist/actions/exportFactory.d.ts.map +1 -0
  71. package/dist/actions/exportFactory.js +144 -0
  72. package/dist/actions/exportFactory.js.map +1 -0
  73. package/dist/actions/importFactory.d.ts +97 -0
  74. package/dist/actions/importFactory.d.ts.map +1 -0
  75. package/dist/actions/importFactory.js +143 -0
  76. package/dist/actions/importFactory.js.map +1 -0
  77. package/dist/actions/index.d.ts +3 -0
  78. package/dist/actions/index.d.ts.map +1 -0
  79. package/dist/actions/index.js +3 -0
  80. package/dist/actions/index.js.map +1 -0
  81. package/dist/applyPageHooks.d.ts +54 -0
  82. package/dist/applyPageHooks.d.ts.map +1 -0
  83. package/dist/applyPageHooks.js +149 -0
  84. package/dist/applyPageHooks.js.map +1 -0
  85. package/dist/cells/coerce.d.ts +25 -0
  86. package/dist/cells/coerce.d.ts.map +1 -0
  87. package/dist/cells/coerce.js +87 -0
  88. package/dist/cells/coerce.js.map +1 -0
  89. package/dist/clusterPaths.d.ts +9 -0
  90. package/dist/clusterPaths.d.ts.map +1 -0
  91. package/dist/clusterPaths.js +19 -0
  92. package/dist/clusterPaths.js.map +1 -0
  93. package/dist/columns/BadgeColumn.d.ts +16 -0
  94. package/dist/columns/BadgeColumn.d.ts.map +1 -0
  95. package/dist/columns/BadgeColumn.js +25 -0
  96. package/dist/columns/BadgeColumn.js.map +1 -0
  97. package/dist/columns/BooleanColumn.d.ts +10 -0
  98. package/dist/columns/BooleanColumn.d.ts.map +1 -0
  99. package/dist/columns/BooleanColumn.js +18 -0
  100. package/dist/columns/BooleanColumn.js.map +1 -0
  101. package/dist/columns/ColorColumn.d.ts +27 -0
  102. package/dist/columns/ColorColumn.d.ts.map +1 -0
  103. package/dist/columns/ColorColumn.js +35 -0
  104. package/dist/columns/ColorColumn.js.map +1 -0
  105. package/dist/columns/IconColumn.d.ts +22 -0
  106. package/dist/columns/IconColumn.d.ts.map +1 -0
  107. package/dist/columns/IconColumn.js +28 -0
  108. package/dist/columns/IconColumn.js.map +1 -0
  109. package/dist/columns/ImageColumn.d.ts +17 -0
  110. package/dist/columns/ImageColumn.d.ts.map +1 -0
  111. package/dist/columns/ImageColumn.js +24 -0
  112. package/dist/columns/ImageColumn.js.map +1 -0
  113. package/dist/columns/SelectColumn.d.ts +36 -0
  114. package/dist/columns/SelectColumn.d.ts.map +1 -0
  115. package/dist/columns/SelectColumn.js +52 -0
  116. package/dist/columns/SelectColumn.js.map +1 -0
  117. package/dist/columns/TextColumn.d.ts +18 -0
  118. package/dist/columns/TextColumn.d.ts.map +1 -0
  119. package/dist/columns/TextColumn.js +20 -0
  120. package/dist/columns/TextColumn.js.map +1 -0
  121. package/dist/columns/TextInputColumn.d.ts +47 -0
  122. package/dist/columns/TextInputColumn.d.ts.map +1 -0
  123. package/dist/columns/TextInputColumn.js +60 -0
  124. package/dist/columns/TextInputColumn.js.map +1 -0
  125. package/dist/columns/ToggleColumn.d.ts +32 -0
  126. package/dist/columns/ToggleColumn.d.ts.map +1 -0
  127. package/dist/columns/ToggleColumn.js +45 -0
  128. package/dist/columns/ToggleColumn.js.map +1 -0
  129. package/dist/columns/index.d.ts +10 -0
  130. package/dist/columns/index.d.ts.map +1 -0
  131. package/dist/columns/index.js +10 -0
  132. package/dist/columns/index.js.map +1 -0
  133. package/dist/defaultGlobalPages.d.ts +11 -0
  134. package/dist/defaultGlobalPages.d.ts.map +1 -0
  135. package/dist/defaultGlobalPages.js +87 -0
  136. package/dist/defaultGlobalPages.js.map +1 -0
  137. package/dist/defaultPages.d.ts +247 -0
  138. package/dist/defaultPages.d.ts.map +1 -0
  139. package/dist/defaultPages.js +558 -0
  140. package/dist/defaultPages.js.map +1 -0
  141. package/dist/elements/Form.d.ts +219 -0
  142. package/dist/elements/Form.d.ts.map +1 -0
  143. package/dist/elements/Form.js +259 -0
  144. package/dist/elements/Form.js.map +1 -0
  145. package/dist/elements/ListTabs.d.ts +17 -0
  146. package/dist/elements/ListTabs.d.ts.map +1 -0
  147. package/dist/elements/ListTabs.js +23 -0
  148. package/dist/elements/ListTabs.js.map +1 -0
  149. package/dist/elements/Table.d.ts +535 -0
  150. package/dist/elements/Table.d.ts.map +1 -0
  151. package/dist/elements/Table.js +481 -0
  152. package/dist/elements/Table.js.map +1 -0
  153. package/dist/elements/TableGroup.d.ts +121 -0
  154. package/dist/elements/TableGroup.d.ts.map +1 -0
  155. package/dist/elements/TableGroup.js +162 -0
  156. package/dist/elements/TableGroup.js.map +1 -0
  157. package/dist/elements/dispatchAction.d.ts +127 -0
  158. package/dist/elements/dispatchAction.d.ts.map +1 -0
  159. package/dist/elements/dispatchAction.js +254 -0
  160. package/dist/elements/dispatchAction.js.map +1 -0
  161. package/dist/elements/dispatchForm.d.ts +220 -0
  162. package/dist/elements/dispatchForm.d.ts.map +1 -0
  163. package/dist/elements/dispatchForm.js +1645 -0
  164. package/dist/elements/dispatchForm.js.map +1 -0
  165. package/dist/elements/dispatchTable.d.ts +69 -0
  166. package/dist/elements/dispatchTable.d.ts.map +1 -0
  167. package/dist/elements/dispatchTable.js +606 -0
  168. package/dist/elements/dispatchTable.js.map +1 -0
  169. package/dist/elements/index.d.ts +3 -0
  170. package/dist/elements/index.d.ts.map +1 -0
  171. package/dist/elements/index.js +3 -0
  172. package/dist/elements/index.js.map +1 -0
  173. package/dist/entries/BadgeEntry.d.ts +21 -0
  174. package/dist/entries/BadgeEntry.d.ts.map +1 -0
  175. package/dist/entries/BadgeEntry.js +32 -0
  176. package/dist/entries/BadgeEntry.js.map +1 -0
  177. package/dist/entries/CodeEntry.d.ts +38 -0
  178. package/dist/entries/CodeEntry.d.ts.map +1 -0
  179. package/dist/entries/CodeEntry.js +44 -0
  180. package/dist/entries/CodeEntry.js.map +1 -0
  181. package/dist/entries/ColorEntry.d.ts +32 -0
  182. package/dist/entries/ColorEntry.d.ts.map +1 -0
  183. package/dist/entries/ColorEntry.js +48 -0
  184. package/dist/entries/ColorEntry.js.map +1 -0
  185. package/dist/entries/ComponentEntry.d.ts +66 -0
  186. package/dist/entries/ComponentEntry.d.ts.map +1 -0
  187. package/dist/entries/ComponentEntry.js +86 -0
  188. package/dist/entries/ComponentEntry.js.map +1 -0
  189. package/dist/entries/Entry.d.ts +175 -0
  190. package/dist/entries/Entry.d.ts.map +1 -0
  191. package/dist/entries/Entry.js +233 -0
  192. package/dist/entries/Entry.js.map +1 -0
  193. package/dist/entries/IconEntry.d.ts +30 -0
  194. package/dist/entries/IconEntry.d.ts.map +1 -0
  195. package/dist/entries/IconEntry.js +34 -0
  196. package/dist/entries/IconEntry.js.map +1 -0
  197. package/dist/entries/ImageEntry.d.ts +33 -0
  198. package/dist/entries/ImageEntry.d.ts.map +1 -0
  199. package/dist/entries/ImageEntry.js +47 -0
  200. package/dist/entries/ImageEntry.js.map +1 -0
  201. package/dist/entries/KeyValueEntry.d.ts +30 -0
  202. package/dist/entries/KeyValueEntry.d.ts.map +1 -0
  203. package/dist/entries/KeyValueEntry.js +38 -0
  204. package/dist/entries/KeyValueEntry.js.map +1 -0
  205. package/dist/entries/RepeatableEntry.d.ts +122 -0
  206. package/dist/entries/RepeatableEntry.d.ts.map +1 -0
  207. package/dist/entries/RepeatableEntry.js +121 -0
  208. package/dist/entries/RepeatableEntry.js.map +1 -0
  209. package/dist/entries/TextEntry.d.ts +38 -0
  210. package/dist/entries/TextEntry.d.ts.map +1 -0
  211. package/dist/entries/TextEntry.js +49 -0
  212. package/dist/entries/TextEntry.js.map +1 -0
  213. package/dist/entries/index.d.ts +2 -0
  214. package/dist/entries/index.d.ts.map +1 -0
  215. package/dist/entries/index.js +8 -0
  216. package/dist/entries/index.js.map +1 -0
  217. package/dist/entries/registry.d.ts +41 -0
  218. package/dist/entries/registry.d.ts.map +1 -0
  219. package/dist/entries/registry.js +17 -0
  220. package/dist/entries/registry.js.map +1 -0
  221. package/dist/fields/BuilderField.d.ts +420 -0
  222. package/dist/fields/BuilderField.d.ts.map +1 -0
  223. package/dist/fields/BuilderField.js +359 -0
  224. package/dist/fields/BuilderField.js.map +1 -0
  225. package/dist/fields/CheckboxField.d.ts +18 -0
  226. package/dist/fields/CheckboxField.d.ts.map +1 -0
  227. package/dist/fields/CheckboxField.js +23 -0
  228. package/dist/fields/CheckboxField.js.map +1 -0
  229. package/dist/fields/CheckboxListField.d.ts +25 -0
  230. package/dist/fields/CheckboxListField.d.ts.map +1 -0
  231. package/dist/fields/CheckboxListField.js +46 -0
  232. package/dist/fields/CheckboxListField.js.map +1 -0
  233. package/dist/fields/ColorPickerField.d.ts +16 -0
  234. package/dist/fields/ColorPickerField.d.ts.map +1 -0
  235. package/dist/fields/ColorPickerField.js +21 -0
  236. package/dist/fields/ColorPickerField.js.map +1 -0
  237. package/dist/fields/DateField.d.ts +29 -0
  238. package/dist/fields/DateField.d.ts.map +1 -0
  239. package/dist/fields/DateField.js +45 -0
  240. package/dist/fields/DateField.js.map +1 -0
  241. package/dist/fields/EmailField.d.ts +8 -0
  242. package/dist/fields/EmailField.d.ts.map +1 -0
  243. package/dist/fields/EmailField.js +13 -0
  244. package/dist/fields/EmailField.js.map +1 -0
  245. package/dist/fields/Field.d.ts +485 -0
  246. package/dist/fields/Field.d.ts.map +1 -0
  247. package/dist/fields/Field.js +539 -0
  248. package/dist/fields/Field.js.map +1 -0
  249. package/dist/fields/FileUploadField.d.ts +43 -0
  250. package/dist/fields/FileUploadField.d.ts.map +1 -0
  251. package/dist/fields/FileUploadField.js +60 -0
  252. package/dist/fields/FileUploadField.js.map +1 -0
  253. package/dist/fields/HiddenField.d.ts +19 -0
  254. package/dist/fields/HiddenField.d.ts.map +1 -0
  255. package/dist/fields/HiddenField.js +24 -0
  256. package/dist/fields/HiddenField.js.map +1 -0
  257. package/dist/fields/KeyValueField.d.ts +36 -0
  258. package/dist/fields/KeyValueField.d.ts.map +1 -0
  259. package/dist/fields/KeyValueField.js +47 -0
  260. package/dist/fields/KeyValueField.js.map +1 -0
  261. package/dist/fields/MarkdownField.d.ts +79 -0
  262. package/dist/fields/MarkdownField.d.ts.map +1 -0
  263. package/dist/fields/MarkdownField.js +117 -0
  264. package/dist/fields/MarkdownField.js.map +1 -0
  265. package/dist/fields/NumberField.d.ts +17 -0
  266. package/dist/fields/NumberField.d.ts.map +1 -0
  267. package/dist/fields/NumberField.js +27 -0
  268. package/dist/fields/NumberField.js.map +1 -0
  269. package/dist/fields/RadioField.d.ts +26 -0
  270. package/dist/fields/RadioField.d.ts.map +1 -0
  271. package/dist/fields/RadioField.js +47 -0
  272. package/dist/fields/RadioField.js.map +1 -0
  273. package/dist/fields/RepeaterField.d.ts +594 -0
  274. package/dist/fields/RepeaterField.d.ts.map +1 -0
  275. package/dist/fields/RepeaterField.js +504 -0
  276. package/dist/fields/RepeaterField.js.map +1 -0
  277. package/dist/fields/RowButton.d.ts +86 -0
  278. package/dist/fields/RowButton.d.ts.map +1 -0
  279. package/dist/fields/RowButton.js +85 -0
  280. package/dist/fields/RowButton.js.map +1 -0
  281. package/dist/fields/SelectField.d.ts +127 -0
  282. package/dist/fields/SelectField.d.ts.map +1 -0
  283. package/dist/fields/SelectField.js +160 -0
  284. package/dist/fields/SelectField.js.map +1 -0
  285. package/dist/fields/SliderField.d.ts +31 -0
  286. package/dist/fields/SliderField.d.ts.map +1 -0
  287. package/dist/fields/SliderField.js +45 -0
  288. package/dist/fields/SliderField.js.map +1 -0
  289. package/dist/fields/SlugField.d.ts +11 -0
  290. package/dist/fields/SlugField.d.ts.map +1 -0
  291. package/dist/fields/SlugField.js +19 -0
  292. package/dist/fields/SlugField.js.map +1 -0
  293. package/dist/fields/TagsInputField.d.ts +65 -0
  294. package/dist/fields/TagsInputField.d.ts.map +1 -0
  295. package/dist/fields/TagsInputField.js +104 -0
  296. package/dist/fields/TagsInputField.js.map +1 -0
  297. package/dist/fields/TextField.d.ts +11 -0
  298. package/dist/fields/TextField.d.ts.map +1 -0
  299. package/dist/fields/TextField.js +19 -0
  300. package/dist/fields/TextField.js.map +1 -0
  301. package/dist/fields/TextareaField.d.ts +40 -0
  302. package/dist/fields/TextareaField.d.ts.map +1 -0
  303. package/dist/fields/TextareaField.js +51 -0
  304. package/dist/fields/TextareaField.js.map +1 -0
  305. package/dist/fields/ToggleButtonsField.d.ts +24 -0
  306. package/dist/fields/ToggleButtonsField.d.ts.map +1 -0
  307. package/dist/fields/ToggleButtonsField.js +41 -0
  308. package/dist/fields/ToggleButtonsField.js.map +1 -0
  309. package/dist/fields/ToggleField.d.ts +8 -0
  310. package/dist/fields/ToggleField.d.ts.map +1 -0
  311. package/dist/fields/ToggleField.js +13 -0
  312. package/dist/fields/ToggleField.js.map +1 -0
  313. package/dist/fields/optionsResolver.d.ts +54 -0
  314. package/dist/fields/optionsResolver.d.ts.map +1 -0
  315. package/dist/fields/optionsResolver.js +62 -0
  316. package/dist/fields/optionsResolver.js.map +1 -0
  317. package/dist/fields/resolveField.d.ts +21 -0
  318. package/dist/fields/resolveField.d.ts.map +1 -0
  319. package/dist/fields/resolveField.js +26 -0
  320. package/dist/fields/resolveField.js.map +1 -0
  321. package/dist/filters/BooleanFilter.d.ts +20 -0
  322. package/dist/filters/BooleanFilter.d.ts.map +1 -0
  323. package/dist/filters/BooleanFilter.js +31 -0
  324. package/dist/filters/BooleanFilter.js.map +1 -0
  325. package/dist/filters/DateRangeFilter.d.ts +68 -0
  326. package/dist/filters/DateRangeFilter.d.ts.map +1 -0
  327. package/dist/filters/DateRangeFilter.js +137 -0
  328. package/dist/filters/DateRangeFilter.js.map +1 -0
  329. package/dist/filters/Filter.d.ts +140 -0
  330. package/dist/filters/Filter.d.ts.map +1 -0
  331. package/dist/filters/Filter.js +99 -0
  332. package/dist/filters/Filter.js.map +1 -0
  333. package/dist/filters/FormFilter.d.ts +103 -0
  334. package/dist/filters/FormFilter.d.ts.map +1 -0
  335. package/dist/filters/FormFilter.js +180 -0
  336. package/dist/filters/FormFilter.js.map +1 -0
  337. package/dist/filters/MultiSelectFilter.d.ts +41 -0
  338. package/dist/filters/MultiSelectFilter.d.ts.map +1 -0
  339. package/dist/filters/MultiSelectFilter.js +67 -0
  340. package/dist/filters/MultiSelectFilter.js.map +1 -0
  341. package/dist/filters/QueryBuilderFilter.d.ts +145 -0
  342. package/dist/filters/QueryBuilderFilter.d.ts.map +1 -0
  343. package/dist/filters/QueryBuilderFilter.js +323 -0
  344. package/dist/filters/QueryBuilderFilter.js.map +1 -0
  345. package/dist/filters/SelectFilter.d.ts +26 -0
  346. package/dist/filters/SelectFilter.d.ts.map +1 -0
  347. package/dist/filters/SelectFilter.js +35 -0
  348. package/dist/filters/SelectFilter.js.map +1 -0
  349. package/dist/filters/TernaryFilter.d.ts +35 -0
  350. package/dist/filters/TernaryFilter.d.ts.map +1 -0
  351. package/dist/filters/TernaryFilter.js +71 -0
  352. package/dist/filters/TernaryFilter.js.map +1 -0
  353. package/dist/filters/TrashedFilter.d.ts +28 -0
  354. package/dist/filters/TrashedFilter.d.ts.map +1 -0
  355. package/dist/filters/TrashedFilter.js +52 -0
  356. package/dist/filters/TrashedFilter.js.map +1 -0
  357. package/dist/filters/queryBuilder/BooleanConstraint.d.ts +13 -0
  358. package/dist/filters/queryBuilder/BooleanConstraint.d.ts.map +1 -0
  359. package/dist/filters/queryBuilder/BooleanConstraint.js +27 -0
  360. package/dist/filters/queryBuilder/BooleanConstraint.js.map +1 -0
  361. package/dist/filters/queryBuilder/Constraint.d.ts +74 -0
  362. package/dist/filters/queryBuilder/Constraint.d.ts.map +1 -0
  363. package/dist/filters/queryBuilder/Constraint.js +45 -0
  364. package/dist/filters/queryBuilder/Constraint.js.map +1 -0
  365. package/dist/filters/queryBuilder/DateConstraint.d.ts +18 -0
  366. package/dist/filters/queryBuilder/DateConstraint.d.ts.map +1 -0
  367. package/dist/filters/queryBuilder/DateConstraint.js +63 -0
  368. package/dist/filters/queryBuilder/DateConstraint.js.map +1 -0
  369. package/dist/filters/queryBuilder/NumberConstraint.d.ts +12 -0
  370. package/dist/filters/queryBuilder/NumberConstraint.d.ts.map +1 -0
  371. package/dist/filters/queryBuilder/NumberConstraint.js +61 -0
  372. package/dist/filters/queryBuilder/NumberConstraint.js.map +1 -0
  373. package/dist/filters/queryBuilder/SelectConstraint.d.ts +22 -0
  374. package/dist/filters/queryBuilder/SelectConstraint.d.ts.map +1 -0
  375. package/dist/filters/queryBuilder/SelectConstraint.js +66 -0
  376. package/dist/filters/queryBuilder/SelectConstraint.js.map +1 -0
  377. package/dist/filters/queryBuilder/TextConstraint.d.ts +18 -0
  378. package/dist/filters/queryBuilder/TextConstraint.d.ts.map +1 -0
  379. package/dist/filters/queryBuilder/TextConstraint.js +58 -0
  380. package/dist/filters/queryBuilder/TextConstraint.js.map +1 -0
  381. package/dist/filters/queryBuilder/index.d.ts +7 -0
  382. package/dist/filters/queryBuilder/index.d.ts.map +1 -0
  383. package/dist/filters/queryBuilder/index.js +7 -0
  384. package/dist/filters/queryBuilder/index.js.map +1 -0
  385. package/dist/icons/index.d.ts +3 -0
  386. package/dist/icons/index.d.ts.map +1 -0
  387. package/dist/icons/index.js +3 -0
  388. package/dist/icons/index.js.map +1 -0
  389. package/dist/icons/lucide.d.ts +16 -0
  390. package/dist/icons/lucide.d.ts.map +1 -0
  391. package/dist/icons/lucide.js +173 -0
  392. package/dist/icons/lucide.js.map +1 -0
  393. package/dist/icons/registry.d.ts +27 -0
  394. package/dist/icons/registry.d.ts.map +1 -0
  395. package/dist/icons/registry.js +35 -0
  396. package/dist/icons/registry.js.map +1 -0
  397. package/dist/icons/types.d.ts +38 -0
  398. package/dist/icons/types.d.ts.map +1 -0
  399. package/dist/icons/types.js +23 -0
  400. package/dist/icons/types.js.map +1 -0
  401. package/dist/index.d.ts +118 -0
  402. package/dist/index.d.ts.map +1 -0
  403. package/dist/index.js +135 -0
  404. package/dist/index.js.map +1 -0
  405. package/dist/io/csv.d.ts +51 -0
  406. package/dist/io/csv.d.ts.map +1 -0
  407. package/dist/io/csv.js +168 -0
  408. package/dist/io/csv.js.map +1 -0
  409. package/dist/notifications/Notification.d.ts +181 -0
  410. package/dist/notifications/Notification.d.ts.map +1 -0
  411. package/dist/notifications/Notification.js +290 -0
  412. package/dist/notifications/Notification.js.map +1 -0
  413. package/dist/notifications/broadcast.d.ts +58 -0
  414. package/dist/notifications/broadcast.d.ts.map +1 -0
  415. package/dist/notifications/broadcast.js +72 -0
  416. package/dist/notifications/broadcast.js.map +1 -0
  417. package/dist/notifications/database.d.ts +164 -0
  418. package/dist/notifications/database.d.ts.map +1 -0
  419. package/dist/notifications/database.js +321 -0
  420. package/dist/notifications/database.js.map +1 -0
  421. package/dist/notifications/dispatchNotificationAction.d.ts +48 -0
  422. package/dist/notifications/dispatchNotificationAction.d.ts.map +1 -0
  423. package/dist/notifications/dispatchNotificationAction.js +100 -0
  424. package/dist/notifications/dispatchNotificationAction.js.map +1 -0
  425. package/dist/notifications/flash.d.ts +34 -0
  426. package/dist/notifications/flash.d.ts.map +1 -0
  427. package/dist/notifications/flash.js +51 -0
  428. package/dist/notifications/flash.js.map +1 -0
  429. package/dist/notifications/index.d.ts +8 -0
  430. package/dist/notifications/index.d.ts.map +1 -0
  431. package/dist/notifications/index.js +6 -0
  432. package/dist/notifications/index.js.map +1 -0
  433. package/dist/notifications/registerBroadcastAuth.d.ts +45 -0
  434. package/dist/notifications/registerBroadcastAuth.d.ts.map +1 -0
  435. package/dist/notifications/registerBroadcastAuth.js +86 -0
  436. package/dist/notifications/registerBroadcastAuth.js.map +1 -0
  437. package/dist/notifications/resolveSavedNotification.d.ts +21 -0
  438. package/dist/notifications/resolveSavedNotification.d.ts.map +1 -0
  439. package/dist/notifications/resolveSavedNotification.js +43 -0
  440. package/dist/notifications/resolveSavedNotification.js.map +1 -0
  441. package/dist/notifications/types.d.ts +87 -0
  442. package/dist/notifications/types.d.ts.map +1 -0
  443. package/dist/notifications/types.js +2 -0
  444. package/dist/notifications/types.js.map +1 -0
  445. package/dist/orm/m2mAccessor.d.ts +49 -0
  446. package/dist/orm/m2mAccessor.d.ts.map +1 -0
  447. package/dist/orm/m2mAccessor.js +45 -0
  448. package/dist/orm/m2mAccessor.js.map +1 -0
  449. package/dist/orm/modelDefaults.d.ts +347 -0
  450. package/dist/orm/modelDefaults.d.ts.map +1 -0
  451. package/dist/orm/modelDefaults.js +375 -0
  452. package/dist/orm/modelDefaults.js.map +1 -0
  453. package/dist/pageData.d.ts +778 -0
  454. package/dist/pageData.d.ts.map +1 -0
  455. package/dist/pageData.js +3725 -0
  456. package/dist/pageData.js.map +1 -0
  457. package/dist/plugins/index.d.ts +2 -0
  458. package/dist/plugins/index.d.ts.map +1 -0
  459. package/dist/plugins/index.js +2 -0
  460. package/dist/plugins/index.js.map +1 -0
  461. package/dist/plugins/themeEditor.d.ts +17 -0
  462. package/dist/plugins/themeEditor.d.ts.map +1 -0
  463. package/dist/plugins/themeEditor.js +23 -0
  464. package/dist/plugins/themeEditor.js.map +1 -0
  465. package/dist/react/AppShell.d.ts +58 -0
  466. package/dist/react/AppShell.d.ts.map +1 -0
  467. package/dist/react/AppShell.js +58 -0
  468. package/dist/react/AppShell.js.map +1 -0
  469. package/dist/react/CommandPalette.d.ts +21 -0
  470. package/dist/react/CommandPalette.d.ts.map +1 -0
  471. package/dist/react/CommandPalette.js +236 -0
  472. package/dist/react/CommandPalette.js.map +1 -0
  473. package/dist/react/FormStateContext.d.ts +83 -0
  474. package/dist/react/FormStateContext.d.ts.map +1 -0
  475. package/dist/react/FormStateContext.js +284 -0
  476. package/dist/react/FormStateContext.js.map +1 -0
  477. package/dist/react/HeadHooks.d.ts +26 -0
  478. package/dist/react/HeadHooks.d.ts.map +1 -0
  479. package/dist/react/HeadHooks.js +141 -0
  480. package/dist/react/HeadHooks.js.map +1 -0
  481. package/dist/react/NotificationActionStrip.d.ts +39 -0
  482. package/dist/react/NotificationActionStrip.d.ts.map +1 -0
  483. package/dist/react/NotificationActionStrip.js +129 -0
  484. package/dist/react/NotificationActionStrip.js.map +1 -0
  485. package/dist/react/NotificationBell.d.ts +20 -0
  486. package/dist/react/NotificationBell.d.ts.map +1 -0
  487. package/dist/react/NotificationBell.js +273 -0
  488. package/dist/react/NotificationBell.js.map +1 -0
  489. package/dist/react/RenderHookSlot.d.ts +20 -0
  490. package/dist/react/RenderHookSlot.d.ts.map +1 -0
  491. package/dist/react/RenderHookSlot.js +24 -0
  492. package/dist/react/RenderHookSlot.js.map +1 -0
  493. package/dist/react/RightSidebar.d.ts +33 -0
  494. package/dist/react/RightSidebar.d.ts.map +1 -0
  495. package/dist/react/RightSidebar.js +82 -0
  496. package/dist/react/RightSidebar.js.map +1 -0
  497. package/dist/react/RightSidebarContext.d.ts +62 -0
  498. package/dist/react/RightSidebarContext.d.ts.map +1 -0
  499. package/dist/react/RightSidebarContext.js +178 -0
  500. package/dist/react/RightSidebarContext.js.map +1 -0
  501. package/dist/react/RightSidebarTrigger.d.ts +16 -0
  502. package/dist/react/RightSidebarTrigger.d.ts.map +1 -0
  503. package/dist/react/RightSidebarTrigger.js +24 -0
  504. package/dist/react/RightSidebarTrigger.js.map +1 -0
  505. package/dist/react/SchemaRenderer.d.ts +63 -0
  506. package/dist/react/SchemaRenderer.d.ts.map +1 -0
  507. package/dist/react/SchemaRenderer.js +3458 -0
  508. package/dist/react/SchemaRenderer.js.map +1 -0
  509. package/dist/react/SearchTrigger.d.ts +13 -0
  510. package/dist/react/SearchTrigger.d.ts.map +1 -0
  511. package/dist/react/SearchTrigger.js +30 -0
  512. package/dist/react/SearchTrigger.js.map +1 -0
  513. package/dist/react/ThemeProvider.d.ts +18 -0
  514. package/dist/react/ThemeProvider.d.ts.map +1 -0
  515. package/dist/react/ThemeProvider.js +66 -0
  516. package/dist/react/ThemeProvider.js.map +1 -0
  517. package/dist/react/ThemeSettingsPage.d.ts +10 -0
  518. package/dist/react/ThemeSettingsPage.d.ts.map +1 -0
  519. package/dist/react/ThemeSettingsPage.js +293 -0
  520. package/dist/react/ThemeSettingsPage.js.map +1 -0
  521. package/dist/react/ThemeToggle.d.ts +2 -0
  522. package/dist/react/ThemeToggle.d.ts.map +1 -0
  523. package/dist/react/ThemeToggle.js +8 -0
  524. package/dist/react/ThemeToggle.js.map +1 -0
  525. package/dist/react/Toaster.d.ts +25 -0
  526. package/dist/react/Toaster.d.ts.map +1 -0
  527. package/dist/react/Toaster.js +89 -0
  528. package/dist/react/Toaster.js.map +1 -0
  529. package/dist/react/UserMenu.d.ts +23 -0
  530. package/dist/react/UserMenu.d.ts.map +1 -0
  531. package/dist/react/UserMenu.js +78 -0
  532. package/dist/react/UserMenu.js.map +1 -0
  533. package/dist/react/WidgetDataContext.d.ts +64 -0
  534. package/dist/react/WidgetDataContext.d.ts.map +1 -0
  535. package/dist/react/WidgetDataContext.js +89 -0
  536. package/dist/react/WidgetDataContext.js.map +1 -0
  537. package/dist/react/cells/EditableCell.d.ts +20 -0
  538. package/dist/react/cells/EditableCell.d.ts.map +1 -0
  539. package/dist/react/cells/EditableCell.js +251 -0
  540. package/dist/react/cells/EditableCell.js.map +1 -0
  541. package/dist/react/fieldJsHandler.d.ts +33 -0
  542. package/dist/react/fieldJsHandler.d.ts.map +1 -0
  543. package/dist/react/fieldJsHandler.js +61 -0
  544. package/dist/react/fieldJsHandler.js.map +1 -0
  545. package/dist/react/fields/BuilderInput.d.ts +21 -0
  546. package/dist/react/fields/BuilderInput.d.ts.map +1 -0
  547. package/dist/react/fields/BuilderInput.js +553 -0
  548. package/dist/react/fields/BuilderInput.js.map +1 -0
  549. package/dist/react/fields/CheckboxInput.d.ts +9 -0
  550. package/dist/react/fields/CheckboxInput.d.ts.map +1 -0
  551. package/dist/react/fields/CheckboxInput.js +23 -0
  552. package/dist/react/fields/CheckboxInput.js.map +1 -0
  553. package/dist/react/fields/CheckboxListInput.d.ts +19 -0
  554. package/dist/react/fields/CheckboxListInput.d.ts.map +1 -0
  555. package/dist/react/fields/CheckboxListInput.js +53 -0
  556. package/dist/react/fields/CheckboxListInput.js.map +1 -0
  557. package/dist/react/fields/ColorInput.d.ts +12 -0
  558. package/dist/react/fields/ColorInput.d.ts.map +1 -0
  559. package/dist/react/fields/ColorInput.js +29 -0
  560. package/dist/react/fields/ColorInput.js.map +1 -0
  561. package/dist/react/fields/DateFieldInput.d.ts +8 -0
  562. package/dist/react/fields/DateFieldInput.d.ts.map +1 -0
  563. package/dist/react/fields/DateFieldInput.js +39 -0
  564. package/dist/react/fields/DateFieldInput.js.map +1 -0
  565. package/dist/react/fields/DateTimeInput.d.ts +13 -0
  566. package/dist/react/fields/DateTimeInput.d.ts.map +1 -0
  567. package/dist/react/fields/DateTimeInput.js +29 -0
  568. package/dist/react/fields/DateTimeInput.js.map +1 -0
  569. package/dist/react/fields/FieldShell.d.ts +23 -0
  570. package/dist/react/fields/FieldShell.d.ts.map +1 -0
  571. package/dist/react/fields/FieldShell.js +46 -0
  572. package/dist/react/fields/FieldShell.js.map +1 -0
  573. package/dist/react/fields/FileUploadInput.d.ts +21 -0
  574. package/dist/react/fields/FileUploadInput.d.ts.map +1 -0
  575. package/dist/react/fields/FileUploadInput.js +120 -0
  576. package/dist/react/fields/FileUploadInput.js.map +1 -0
  577. package/dist/react/fields/HiddenInput.d.ts +11 -0
  578. package/dist/react/fields/HiddenInput.d.ts.map +1 -0
  579. package/dist/react/fields/HiddenInput.js +14 -0
  580. package/dist/react/fields/HiddenInput.js.map +1 -0
  581. package/dist/react/fields/KeyValueInput.d.ts +18 -0
  582. package/dist/react/fields/KeyValueInput.d.ts.map +1 -0
  583. package/dist/react/fields/KeyValueInput.js +122 -0
  584. package/dist/react/fields/KeyValueInput.js.map +1 -0
  585. package/dist/react/fields/MarkdownInput.d.ts +29 -0
  586. package/dist/react/fields/MarkdownInput.d.ts.map +1 -0
  587. package/dist/react/fields/MarkdownInput.js +250 -0
  588. package/dist/react/fields/MarkdownInput.js.map +1 -0
  589. package/dist/react/fields/RadioInput.d.ts +18 -0
  590. package/dist/react/fields/RadioInput.d.ts.map +1 -0
  591. package/dist/react/fields/RadioInput.js +34 -0
  592. package/dist/react/fields/RadioInput.js.map +1 -0
  593. package/dist/react/fields/RepeaterInput.d.ts +92 -0
  594. package/dist/react/fields/RepeaterInput.d.ts.map +1 -0
  595. package/dist/react/fields/RepeaterInput.js +705 -0
  596. package/dist/react/fields/RepeaterInput.js.map +1 -0
  597. package/dist/react/fields/SelectFieldInput.d.ts +23 -0
  598. package/dist/react/fields/SelectFieldInput.d.ts.map +1 -0
  599. package/dist/react/fields/SelectFieldInput.js +146 -0
  600. package/dist/react/fields/SelectFieldInput.js.map +1 -0
  601. package/dist/react/fields/SliderInput.d.ts +16 -0
  602. package/dist/react/fields/SliderInput.d.ts.map +1 -0
  603. package/dist/react/fields/SliderInput.js +37 -0
  604. package/dist/react/fields/SliderInput.js.map +1 -0
  605. package/dist/react/fields/TagsInput.d.ts +27 -0
  606. package/dist/react/fields/TagsInput.d.ts.map +1 -0
  607. package/dist/react/fields/TagsInput.js +189 -0
  608. package/dist/react/fields/TagsInput.js.map +1 -0
  609. package/dist/react/fields/TextLikeInput.d.ts +18 -0
  610. package/dist/react/fields/TextLikeInput.d.ts.map +1 -0
  611. package/dist/react/fields/TextLikeInput.js +46 -0
  612. package/dist/react/fields/TextLikeInput.js.map +1 -0
  613. package/dist/react/fields/ToggleButtonsInput.d.ts +20 -0
  614. package/dist/react/fields/ToggleButtonsInput.d.ts.map +1 -0
  615. package/dist/react/fields/ToggleButtonsInput.js +42 -0
  616. package/dist/react/fields/ToggleButtonsInput.js.map +1 -0
  617. package/dist/react/fields/ToggleFieldInput.d.ts +7 -0
  618. package/dist/react/fields/ToggleFieldInput.d.ts.map +1 -0
  619. package/dist/react/fields/ToggleFieldInput.js +30 -0
  620. package/dist/react/fields/ToggleFieldInput.js.map +1 -0
  621. package/dist/react/fields/rowChromeButton.d.ts +84 -0
  622. package/dist/react/fields/rowChromeButton.d.ts.map +1 -0
  623. package/dist/react/fields/rowChromeButton.js +111 -0
  624. package/dist/react/fields/rowChromeButton.js.map +1 -0
  625. package/dist/react/fields/syncRowGates.d.ts +11 -0
  626. package/dist/react/fields/syncRowGates.d.ts.map +1 -0
  627. package/dist/react/fields/syncRowGates.js +55 -0
  628. package/dist/react/fields/syncRowGates.js.map +1 -0
  629. package/dist/react/formStateHelpers.d.ts +44 -0
  630. package/dist/react/formStateHelpers.d.ts.map +1 -0
  631. package/dist/react/formStateHelpers.js +230 -0
  632. package/dist/react/formStateHelpers.js.map +1 -0
  633. package/dist/react/hooks/use-mobile.d.ts +2 -0
  634. package/dist/react/hooks/use-mobile.d.ts.map +1 -0
  635. package/dist/react/hooks/use-mobile.js +16 -0
  636. package/dist/react/hooks/use-mobile.js.map +1 -0
  637. package/dist/react/icon-context.d.ts +35 -0
  638. package/dist/react/icon-context.d.ts.map +1 -0
  639. package/dist/react/icon-context.js +45 -0
  640. package/dist/react/icon-context.js.map +1 -0
  641. package/dist/react/index.d.ts +26 -0
  642. package/dist/react/index.d.ts.map +1 -0
  643. package/dist/react/index.js +28 -0
  644. package/dist/react/index.js.map +1 -0
  645. package/dist/react/layouts/SidebarLayout.d.ts +3 -0
  646. package/dist/react/layouts/SidebarLayout.d.ts.map +1 -0
  647. package/dist/react/layouts/SidebarLayout.js +85 -0
  648. package/dist/react/layouts/SidebarLayout.js.map +1 -0
  649. package/dist/react/layouts/TopbarLayout.d.ts +3 -0
  650. package/dist/react/layouts/TopbarLayout.d.ts.map +1 -0
  651. package/dist/react/layouts/TopbarLayout.js +103 -0
  652. package/dist/react/layouts/TopbarLayout.js.map +1 -0
  653. package/dist/react/navigate.d.ts +22 -0
  654. package/dist/react/navigate.d.ts.map +1 -0
  655. package/dist/react/navigate.js +30 -0
  656. package/dist/react/navigate.js.map +1 -0
  657. package/dist/react/registry.d.ts +35 -0
  658. package/dist/react/registry.d.ts.map +1 -0
  659. package/dist/react/registry.js +22 -0
  660. package/dist/react/registry.js.map +1 -0
  661. package/dist/react/right-panel-registry.d.ts +32 -0
  662. package/dist/react/right-panel-registry.d.ts.map +1 -0
  663. package/dist/react/right-panel-registry.js +20 -0
  664. package/dist/react/right-panel-registry.js.map +1 -0
  665. package/dist/react/theme-preview/apply.d.ts +11 -0
  666. package/dist/react/theme-preview/apply.d.ts.map +1 -0
  667. package/dist/react/theme-preview/apply.js +93 -0
  668. package/dist/react/theme-preview/apply.js.map +1 -0
  669. package/dist/react/theme-preview/build-html.d.ts +3 -0
  670. package/dist/react/theme-preview/build-html.d.ts.map +1 -0
  671. package/dist/react/theme-preview/build-html.js +437 -0
  672. package/dist/react/theme-preview/build-html.js.map +1 -0
  673. package/dist/react/ui/button.d.ts +9 -0
  674. package/dist/react/ui/button.d.ts.map +1 -0
  675. package/dist/react/ui/button.js +35 -0
  676. package/dist/react/ui/button.js.map +1 -0
  677. package/dist/react/ui/calendar.d.ts +5 -0
  678. package/dist/react/ui/calendar.d.ts.map +1 -0
  679. package/dist/react/ui/calendar.js +34 -0
  680. package/dist/react/ui/calendar.js.map +1 -0
  681. package/dist/react/ui/checkbox.d.ts +4 -0
  682. package/dist/react/ui/checkbox.d.ts.map +1 -0
  683. package/dist/react/ui/checkbox.js +9 -0
  684. package/dist/react/ui/checkbox.js.map +1 -0
  685. package/dist/react/ui/dialog.d.ts +12 -0
  686. package/dist/react/ui/dialog.d.ts.map +1 -0
  687. package/dist/react/ui/dialog.js +34 -0
  688. package/dist/react/ui/dialog.js.map +1 -0
  689. package/dist/react/ui/dropdown-menu.d.ts +12 -0
  690. package/dist/react/ui/dropdown-menu.d.ts.map +1 -0
  691. package/dist/react/ui/dropdown-menu.js +23 -0
  692. package/dist/react/ui/dropdown-menu.js.map +1 -0
  693. package/dist/react/ui/input.d.ts +4 -0
  694. package/dist/react/ui/input.d.ts.map +1 -0
  695. package/dist/react/ui/input.js +8 -0
  696. package/dist/react/ui/input.js.map +1 -0
  697. package/dist/react/ui/label.d.ts +4 -0
  698. package/dist/react/ui/label.d.ts.map +1 -0
  699. package/dist/react/ui/label.js +7 -0
  700. package/dist/react/ui/label.js.map +1 -0
  701. package/dist/react/ui/popover.d.ts +6 -0
  702. package/dist/react/ui/popover.d.ts.map +1 -0
  703. package/dist/react/ui/popover.js +14 -0
  704. package/dist/react/ui/popover.js.map +1 -0
  705. package/dist/react/ui/select.d.ts +17 -0
  706. package/dist/react/ui/select.d.ts.map +1 -0
  707. package/dist/react/ui/select.js +39 -0
  708. package/dist/react/ui/select.js.map +1 -0
  709. package/dist/react/ui/separator.d.ts +4 -0
  710. package/dist/react/ui/separator.d.ts.map +1 -0
  711. package/dist/react/ui/separator.js +9 -0
  712. package/dist/react/ui/separator.js.map +1 -0
  713. package/dist/react/ui/sheet.d.ts +15 -0
  714. package/dist/react/ui/sheet.d.ts.map +1 -0
  715. package/dist/react/ui/sheet.js +37 -0
  716. package/dist/react/ui/sheet.js.map +1 -0
  717. package/dist/react/ui/sidebar.d.ts +64 -0
  718. package/dist/react/ui/sidebar.d.ts.map +1 -0
  719. package/dist/react/ui/sidebar.js +257 -0
  720. package/dist/react/ui/sidebar.js.map +1 -0
  721. package/dist/react/ui/skeleton.d.ts +3 -0
  722. package/dist/react/ui/skeleton.d.ts.map +1 -0
  723. package/dist/react/ui/skeleton.js +7 -0
  724. package/dist/react/ui/skeleton.js.map +1 -0
  725. package/dist/react/ui/slider.d.ts +4 -0
  726. package/dist/react/ui/slider.d.ts.map +1 -0
  727. package/dist/react/ui/slider.js +8 -0
  728. package/dist/react/ui/slider.js.map +1 -0
  729. package/dist/react/ui/switch.d.ts +4 -0
  730. package/dist/react/ui/switch.d.ts.map +1 -0
  731. package/dist/react/ui/switch.js +8 -0
  732. package/dist/react/ui/switch.js.map +1 -0
  733. package/dist/react/ui/table.d.ts +11 -0
  734. package/dist/react/ui/table.d.ts.map +1 -0
  735. package/dist/react/ui/table.js +28 -0
  736. package/dist/react/ui/table.js.map +1 -0
  737. package/dist/react/ui/tabs.d.ts +7 -0
  738. package/dist/react/ui/tabs.d.ts.map +1 -0
  739. package/dist/react/ui/tabs.js +17 -0
  740. package/dist/react/ui/tabs.js.map +1 -0
  741. package/dist/react/ui/textarea.d.ts +4 -0
  742. package/dist/react/ui/textarea.d.ts.map +1 -0
  743. package/dist/react/ui/textarea.js +7 -0
  744. package/dist/react/ui/textarea.js.map +1 -0
  745. package/dist/react/ui/tooltip.d.ts +7 -0
  746. package/dist/react/ui/tooltip.d.ts.map +1 -0
  747. package/dist/react/ui/tooltip.js +17 -0
  748. package/dist/react/ui/tooltip.js.map +1 -0
  749. package/dist/react/useResizableWidth.d.ts +47 -0
  750. package/dist/react/useResizableWidth.d.ts.map +1 -0
  751. package/dist/react/useResizableWidth.js +99 -0
  752. package/dist/react/useResizableWidth.js.map +1 -0
  753. package/dist/react/utils.d.ts +3 -0
  754. package/dist/react/utils.d.ts.map +1 -0
  755. package/dist/react/utils.js +6 -0
  756. package/dist/react/utils.js.map +1 -0
  757. package/dist/react/widgetRegistry.d.ts +33 -0
  758. package/dist/react/widgetRegistry.d.ts.map +1 -0
  759. package/dist/react/widgetRegistry.js +15 -0
  760. package/dist/react/widgetRegistry.js.map +1 -0
  761. package/dist/react/widgets/StatsOverviewRenderer.d.ts +6 -0
  762. package/dist/react/widgets/StatsOverviewRenderer.d.ts.map +1 -0
  763. package/dist/react/widgets/StatsOverviewRenderer.js +124 -0
  764. package/dist/react/widgets/StatsOverviewRenderer.js.map +1 -0
  765. package/dist/react/widgets/TableWidgetRenderer.d.ts +6 -0
  766. package/dist/react/widgets/TableWidgetRenderer.d.ts.map +1 -0
  767. package/dist/react/widgets/TableWidgetRenderer.js +123 -0
  768. package/dist/react/widgets/TableWidgetRenderer.js.map +1 -0
  769. package/dist/react/widgets/ViewRenderer.d.ts +16 -0
  770. package/dist/react/widgets/ViewRenderer.d.ts.map +1 -0
  771. package/dist/react/widgets/ViewRenderer.js +26 -0
  772. package/dist/react/widgets/ViewRenderer.js.map +1 -0
  773. package/dist/richtext/index.d.ts +2 -0
  774. package/dist/richtext/index.d.ts.map +1 -0
  775. package/dist/richtext/index.js +2 -0
  776. package/dist/richtext/index.js.map +1 -0
  777. package/dist/richtext/registry.d.ts +55 -0
  778. package/dist/richtext/registry.d.ts.map +1 -0
  779. package/dist/richtext/registry.js +66 -0
  780. package/dist/richtext/registry.js.map +1 -0
  781. package/dist/routes.d.ts +9 -0
  782. package/dist/routes.d.ts.map +1 -0
  783. package/dist/routes.js +3116 -0
  784. package/dist/routes.js.map +1 -0
  785. package/dist/schema/Alert.d.ts +33 -0
  786. package/dist/schema/Alert.d.ts.map +1 -0
  787. package/dist/schema/Alert.js +41 -0
  788. package/dist/schema/Alert.js.map +1 -0
  789. package/dist/schema/Block.d.ts +112 -0
  790. package/dist/schema/Block.d.ts.map +1 -0
  791. package/dist/schema/Block.js +136 -0
  792. package/dist/schema/Block.js.map +1 -0
  793. package/dist/schema/Breadcrumbs.d.ts +31 -0
  794. package/dist/schema/Breadcrumbs.d.ts.map +1 -0
  795. package/dist/schema/Breadcrumbs.js +30 -0
  796. package/dist/schema/Breadcrumbs.js.map +1 -0
  797. package/dist/schema/Card.d.ts +17 -0
  798. package/dist/schema/Card.d.ts.map +1 -0
  799. package/dist/schema/Card.js +31 -0
  800. package/dist/schema/Card.js.map +1 -0
  801. package/dist/schema/Divider.d.ts +12 -0
  802. package/dist/schema/Divider.d.ts.map +1 -0
  803. package/dist/schema/Divider.js +19 -0
  804. package/dist/schema/Divider.js.map +1 -0
  805. package/dist/schema/Element.d.ts +150 -0
  806. package/dist/schema/Element.d.ts.map +1 -0
  807. package/dist/schema/Element.js +124 -0
  808. package/dist/schema/Element.js.map +1 -0
  809. package/dist/schema/EmptyState.d.ts +48 -0
  810. package/dist/schema/EmptyState.d.ts.map +1 -0
  811. package/dist/schema/EmptyState.js +57 -0
  812. package/dist/schema/EmptyState.js.map +1 -0
  813. package/dist/schema/Fieldset.d.ts +25 -0
  814. package/dist/schema/Fieldset.d.ts.map +1 -0
  815. package/dist/schema/Fieldset.js +39 -0
  816. package/dist/schema/Fieldset.js.map +1 -0
  817. package/dist/schema/Grid.d.ts +23 -0
  818. package/dist/schema/Grid.d.ts.map +1 -0
  819. package/dist/schema/Grid.js +36 -0
  820. package/dist/schema/Grid.js.map +1 -0
  821. package/dist/schema/Group.d.ts +19 -0
  822. package/dist/schema/Group.d.ts.map +1 -0
  823. package/dist/schema/Group.js +26 -0
  824. package/dist/schema/Group.js.map +1 -0
  825. package/dist/schema/Heading.d.ts +25 -0
  826. package/dist/schema/Heading.d.ts.map +1 -0
  827. package/dist/schema/Heading.js +34 -0
  828. package/dist/schema/Heading.js.map +1 -0
  829. package/dist/schema/Html.d.ts +48 -0
  830. package/dist/schema/Html.d.ts.map +1 -0
  831. package/dist/schema/Html.js +60 -0
  832. package/dist/schema/Html.js.map +1 -0
  833. package/dist/schema/Icon.d.ts +34 -0
  834. package/dist/schema/Icon.d.ts.map +1 -0
  835. package/dist/schema/Icon.js +40 -0
  836. package/dist/schema/Icon.js.map +1 -0
  837. package/dist/schema/Image.d.ts +38 -0
  838. package/dist/schema/Image.d.ts.map +1 -0
  839. package/dist/schema/Image.js +48 -0
  840. package/dist/schema/Image.js.map +1 -0
  841. package/dist/schema/LinkTag.d.ts +48 -0
  842. package/dist/schema/LinkTag.d.ts.map +1 -0
  843. package/dist/schema/LinkTag.js +16 -0
  844. package/dist/schema/LinkTag.js.map +1 -0
  845. package/dist/schema/Markdown.d.ts +57 -0
  846. package/dist/schema/Markdown.d.ts.map +1 -0
  847. package/dist/schema/Markdown.js +75 -0
  848. package/dist/schema/Markdown.js.map +1 -0
  849. package/dist/schema/MetaTag.d.ts +41 -0
  850. package/dist/schema/MetaTag.d.ts.map +1 -0
  851. package/dist/schema/MetaTag.js +16 -0
  852. package/dist/schema/MetaTag.js.map +1 -0
  853. package/dist/schema/RelationTabs.d.ts +50 -0
  854. package/dist/schema/RelationTabs.d.ts.map +1 -0
  855. package/dist/schema/RelationTabs.js +48 -0
  856. package/dist/schema/RelationTabs.js.map +1 -0
  857. package/dist/schema/ScriptTag.d.ts +63 -0
  858. package/dist/schema/ScriptTag.d.ts.map +1 -0
  859. package/dist/schema/ScriptTag.js +16 -0
  860. package/dist/schema/ScriptTag.js.map +1 -0
  861. package/dist/schema/Section.d.ts +93 -0
  862. package/dist/schema/Section.d.ts.map +1 -0
  863. package/dist/schema/Section.js +127 -0
  864. package/dist/schema/Section.js.map +1 -0
  865. package/dist/schema/ServerDataElement.d.ts +101 -0
  866. package/dist/schema/ServerDataElement.d.ts.map +1 -0
  867. package/dist/schema/ServerDataElement.js +135 -0
  868. package/dist/schema/ServerDataElement.js.map +1 -0
  869. package/dist/schema/Split.d.ts +31 -0
  870. package/dist/schema/Split.d.ts.map +1 -0
  871. package/dist/schema/Split.js +41 -0
  872. package/dist/schema/Split.js.map +1 -0
  873. package/dist/schema/Stat.d.ts +92 -0
  874. package/dist/schema/Stat.d.ts.map +1 -0
  875. package/dist/schema/Stat.js +116 -0
  876. package/dist/schema/Stat.js.map +1 -0
  877. package/dist/schema/StatsOverview.d.ts +76 -0
  878. package/dist/schema/StatsOverview.d.ts.map +1 -0
  879. package/dist/schema/StatsOverview.js +71 -0
  880. package/dist/schema/StatsOverview.js.map +1 -0
  881. package/dist/schema/StyleTag.d.ts +32 -0
  882. package/dist/schema/StyleTag.d.ts.map +1 -0
  883. package/dist/schema/StyleTag.js +38 -0
  884. package/dist/schema/StyleTag.js.map +1 -0
  885. package/dist/schema/TableWidget.d.ts +148 -0
  886. package/dist/schema/TableWidget.d.ts.map +1 -0
  887. package/dist/schema/TableWidget.js +190 -0
  888. package/dist/schema/TableWidget.js.map +1 -0
  889. package/dist/schema/Tabs.d.ts +40 -0
  890. package/dist/schema/Tabs.d.ts.map +1 -0
  891. package/dist/schema/Tabs.js +66 -0
  892. package/dist/schema/Tabs.js.map +1 -0
  893. package/dist/schema/Text.d.ts +33 -0
  894. package/dist/schema/Text.d.ts.map +1 -0
  895. package/dist/schema/Text.js +40 -0
  896. package/dist/schema/Text.js.map +1 -0
  897. package/dist/schema/UnorderedList.d.ts +36 -0
  898. package/dist/schema/UnorderedList.d.ts.map +1 -0
  899. package/dist/schema/UnorderedList.js +42 -0
  900. package/dist/schema/UnorderedList.js.map +1 -0
  901. package/dist/schema/View.d.ts +81 -0
  902. package/dist/schema/View.d.ts.map +1 -0
  903. package/dist/schema/View.js +81 -0
  904. package/dist/schema/View.js.map +1 -0
  905. package/dist/schema/Wizard.d.ts +67 -0
  906. package/dist/schema/Wizard.d.ts.map +1 -0
  907. package/dist/schema/Wizard.js +94 -0
  908. package/dist/schema/Wizard.js.map +1 -0
  909. package/dist/schema/index.d.ts +26 -0
  910. package/dist/schema/index.d.ts.map +1 -0
  911. package/dist/schema/index.js +26 -0
  912. package/dist/schema/index.js.map +1 -0
  913. package/dist/schema/resolveSchema.d.ts +122 -0
  914. package/dist/schema/resolveSchema.d.ts.map +1 -0
  915. package/dist/schema/resolveSchema.js +648 -0
  916. package/dist/schema/resolveSchema.js.map +1 -0
  917. package/dist/schema/sanitize.d.ts +21 -0
  918. package/dist/schema/sanitize.d.ts.map +1 -0
  919. package/dist/schema/sanitize.js +46 -0
  920. package/dist/schema/sanitize.js.map +1 -0
  921. package/dist/search.d.ts +53 -0
  922. package/dist/search.d.ts.map +1 -0
  923. package/dist/search.js +114 -0
  924. package/dist/search.js.map +1 -0
  925. package/dist/sessionFilters.d.ts +8 -0
  926. package/dist/sessionFilters.d.ts.map +1 -0
  927. package/dist/sessionFilters.js +115 -0
  928. package/dist/sessionFilters.js.map +1 -0
  929. package/dist/summarizers/Summarizer.d.ts +65 -0
  930. package/dist/summarizers/Summarizer.d.ts.map +1 -0
  931. package/dist/summarizers/Summarizer.js +98 -0
  932. package/dist/summarizers/Summarizer.js.map +1 -0
  933. package/dist/summarizers/index.d.ts +2 -0
  934. package/dist/summarizers/index.d.ts.map +1 -0
  935. package/dist/summarizers/index.js +2 -0
  936. package/dist/summarizers/index.js.map +1 -0
  937. package/dist/theme/base-colors.d.ts +3 -0
  938. package/dist/theme/base-colors.d.ts.map +1 -0
  939. package/dist/theme/base-colors.js +64 -0
  940. package/dist/theme/base-colors.js.map +1 -0
  941. package/dist/theme/chart-colors.d.ts +3 -0
  942. package/dist/theme/chart-colors.d.ts.map +1 -0
  943. package/dist/theme/chart-colors.js +46 -0
  944. package/dist/theme/chart-colors.js.map +1 -0
  945. package/dist/theme/colors.d.ts +56 -0
  946. package/dist/theme/colors.d.ts.map +1 -0
  947. package/dist/theme/colors.js +410 -0
  948. package/dist/theme/colors.js.map +1 -0
  949. package/dist/theme/generate-css.d.ts +9 -0
  950. package/dist/theme/generate-css.d.ts.map +1 -0
  951. package/dist/theme/generate-css.js +36 -0
  952. package/dist/theme/generate-css.js.map +1 -0
  953. package/dist/theme/generate-scale.d.ts +3 -0
  954. package/dist/theme/generate-scale.d.ts.map +1 -0
  955. package/dist/theme/generate-scale.js +89 -0
  956. package/dist/theme/generate-scale.js.map +1 -0
  957. package/dist/theme/icon-map.d.ts +9 -0
  958. package/dist/theme/icon-map.d.ts.map +1 -0
  959. package/dist/theme/icon-map.js +40 -0
  960. package/dist/theme/icon-map.js.map +1 -0
  961. package/dist/theme/index.d.ts +15 -0
  962. package/dist/theme/index.d.ts.map +1 -0
  963. package/dist/theme/index.js +13 -0
  964. package/dist/theme/index.js.map +1 -0
  965. package/dist/theme/migrate.d.ts +14 -0
  966. package/dist/theme/migrate.d.ts.map +1 -0
  967. package/dist/theme/migrate.js +79 -0
  968. package/dist/theme/migrate.js.map +1 -0
  969. package/dist/theme/presets.d.ts +30 -0
  970. package/dist/theme/presets.d.ts.map +1 -0
  971. package/dist/theme/presets.js +128 -0
  972. package/dist/theme/presets.js.map +1 -0
  973. package/dist/theme/radius.d.ts +11 -0
  974. package/dist/theme/radius.d.ts.map +1 -0
  975. package/dist/theme/radius.js +17 -0
  976. package/dist/theme/radius.js.map +1 -0
  977. package/dist/theme/resolve.d.ts +13 -0
  978. package/dist/theme/resolve.d.ts.map +1 -0
  979. package/dist/theme/resolve.js +91 -0
  980. package/dist/theme/resolve.js.map +1 -0
  981. package/dist/theme/spacing.d.ts +14 -0
  982. package/dist/theme/spacing.d.ts.map +1 -0
  983. package/dist/theme/spacing.js +17 -0
  984. package/dist/theme/spacing.js.map +1 -0
  985. package/dist/theme/theme-colors.d.ts +9 -0
  986. package/dist/theme/theme-colors.d.ts.map +1 -0
  987. package/dist/theme/theme-colors.js +84 -0
  988. package/dist/theme/theme-colors.js.map +1 -0
  989. package/dist/theme/types.d.ts +94 -0
  990. package/dist/theme/types.d.ts.map +1 -0
  991. package/dist/theme/types.js +2 -0
  992. package/dist/theme/types.js.map +1 -0
  993. package/dist/uploads/UploadAdapter.d.ts +34 -0
  994. package/dist/uploads/UploadAdapter.d.ts.map +1 -0
  995. package/dist/uploads/UploadAdapter.js +2 -0
  996. package/dist/uploads/UploadAdapter.js.map +1 -0
  997. package/dist/uploads/index.d.ts +3 -0
  998. package/dist/uploads/index.d.ts.map +1 -0
  999. package/dist/uploads/index.js +2 -0
  1000. package/dist/uploads/index.js.map +1 -0
  1001. package/dist/uploads/localUpload.d.ts +25 -0
  1002. package/dist/uploads/localUpload.d.ts.map +1 -0
  1003. package/dist/uploads/localUpload.js +65 -0
  1004. package/dist/uploads/localUpload.js.map +1 -0
  1005. package/dist/validation/Validator.d.ts +40 -0
  1006. package/dist/validation/Validator.d.ts.map +1 -0
  1007. package/dist/validation/Validator.js +25 -0
  1008. package/dist/validation/Validator.js.map +1 -0
  1009. package/dist/validation/index.d.ts +5 -0
  1010. package/dist/validation/index.d.ts.map +1 -0
  1011. package/dist/validation/index.js +5 -0
  1012. package/dist/validation/index.js.map +1 -0
  1013. package/dist/validation/rules.d.ts +9 -0
  1014. package/dist/validation/rules.d.ts.map +1 -0
  1015. package/dist/validation/rules.js +61 -0
  1016. package/dist/validation/rules.js.map +1 -0
  1017. package/dist/validation/runValidators.d.ts +30 -0
  1018. package/dist/validation/runValidators.d.ts.map +1 -0
  1019. package/dist/validation/runValidators.js +438 -0
  1020. package/dist/validation/runValidators.js.map +1 -0
  1021. package/dist/validation/uniqueValidator.d.ts +61 -0
  1022. package/dist/validation/uniqueValidator.d.ts.map +1 -0
  1023. package/dist/validation/uniqueValidator.js +80 -0
  1024. package/dist/validation/uniqueValidator.js.map +1 -0
  1025. package/dist/vite.d.ts +19 -0
  1026. package/dist/vite.d.ts.map +1 -0
  1027. package/dist/vite.js +696 -0
  1028. package/dist/vite.js.map +1 -0
  1029. package/dist/widgets/index.d.ts +2 -0
  1030. package/dist/widgets/index.d.ts.map +1 -0
  1031. package/dist/widgets/index.js +7 -0
  1032. package/dist/widgets/index.js.map +1 -0
  1033. package/dist/widgets/registry.d.ts +32 -0
  1034. package/dist/widgets/registry.d.ts.map +1 -0
  1035. package/dist/widgets/registry.js +17 -0
  1036. package/dist/widgets/registry.js.map +1 -0
  1037. package/package.json +101 -0
  1038. package/src/Cluster.test.ts +283 -0
  1039. package/src/Cluster.ts +83 -0
  1040. package/src/Column.test.ts +140 -0
  1041. package/src/Column.ts +612 -0
  1042. package/src/Global.test.ts +367 -0
  1043. package/src/Global.ts +169 -0
  1044. package/src/Page.test.ts +50 -0
  1045. package/src/Page.ts +139 -0
  1046. package/src/Pilotiq.test.ts +47 -0
  1047. package/src/Pilotiq.ts +705 -0
  1048. package/src/PilotiqRegistry.ts +36 -0
  1049. package/src/PilotiqServiceProvider.ts +69 -0
  1050. package/src/RelationManager.test.ts +400 -0
  1051. package/src/RelationManager.ts +527 -0
  1052. package/src/RenderHook.test.ts +252 -0
  1053. package/src/RenderHook.ts +226 -0
  1054. package/src/Resource.test.ts +240 -0
  1055. package/src/Resource.ts +439 -0
  1056. package/src/RightPanel.test.ts +202 -0
  1057. package/src/RightPanel.ts +132 -0
  1058. package/src/Tab.test.ts +91 -0
  1059. package/src/Tab.ts +156 -0
  1060. package/src/UserMenuItem.ts +145 -0
  1061. package/src/actions/Action.test.ts +2479 -0
  1062. package/src/actions/Action.ts +2124 -0
  1063. package/src/actions/ActionGroup.test.ts +112 -0
  1064. package/src/actions/ActionGroup.ts +173 -0
  1065. package/src/actions/attachFactory.ts +172 -0
  1066. package/src/actions/exportFactory.ts +215 -0
  1067. package/src/actions/importFactory.ts +222 -0
  1068. package/src/actions/index.ts +17 -0
  1069. package/src/applyPageHooks.test.ts +298 -0
  1070. package/src/applyPageHooks.ts +242 -0
  1071. package/src/authorization.test.ts +483 -0
  1072. package/src/breadcrumbs.test.ts +238 -0
  1073. package/src/cells/coerce.test.ts +85 -0
  1074. package/src/cells/coerce.ts +84 -0
  1075. package/src/clusterPaths.ts +35 -0
  1076. package/src/columns/BadgeColumn.test.ts +54 -0
  1077. package/src/columns/BadgeColumn.ts +32 -0
  1078. package/src/columns/BooleanColumn.test.ts +41 -0
  1079. package/src/columns/BooleanColumn.ts +18 -0
  1080. package/src/columns/ColorColumn.test.ts +37 -0
  1081. package/src/columns/ColorColumn.ts +38 -0
  1082. package/src/columns/IconColumn.test.ts +54 -0
  1083. package/src/columns/IconColumn.ts +37 -0
  1084. package/src/columns/ImageColumn.test.ts +41 -0
  1085. package/src/columns/ImageColumn.ts +28 -0
  1086. package/src/columns/SelectColumn.ts +60 -0
  1087. package/src/columns/TextColumn.test.ts +190 -0
  1088. package/src/columns/TextColumn.ts +20 -0
  1089. package/src/columns/TextInputColumn.ts +68 -0
  1090. package/src/columns/ToggleColumn.ts +46 -0
  1091. package/src/columns/editableColumns.test.ts +193 -0
  1092. package/src/columns/index.ts +9 -0
  1093. package/src/defaultGlobalPages.ts +95 -0
  1094. package/src/defaultPages.test.ts +634 -0
  1095. package/src/defaultPages.ts +614 -0
  1096. package/src/defaultViewPage.test.ts +147 -0
  1097. package/src/elements/Form.test.ts +223 -0
  1098. package/src/elements/Form.ts +397 -0
  1099. package/src/elements/ListTabs.ts +28 -0
  1100. package/src/elements/Table.test.ts +422 -0
  1101. package/src/elements/Table.ts +816 -0
  1102. package/src/elements/TableGroup.test.ts +149 -0
  1103. package/src/elements/TableGroup.ts +199 -0
  1104. package/src/elements/dispatchAction.test.ts +463 -0
  1105. package/src/elements/dispatchAction.ts +355 -0
  1106. package/src/elements/dispatchForm.test.ts +455 -0
  1107. package/src/elements/dispatchForm.ts +1855 -0
  1108. package/src/elements/dispatchTable.test.ts +1247 -0
  1109. package/src/elements/dispatchTable.ts +666 -0
  1110. package/src/elements/index.ts +21 -0
  1111. package/src/entries/BadgeEntry.ts +39 -0
  1112. package/src/entries/CodeEntry.test.ts +40 -0
  1113. package/src/entries/CodeEntry.ts +52 -0
  1114. package/src/entries/ColorEntry.ts +63 -0
  1115. package/src/entries/ComponentEntry.test.ts +173 -0
  1116. package/src/entries/ComponentEntry.ts +95 -0
  1117. package/src/entries/Entry.ts +304 -0
  1118. package/src/entries/IconEntry.ts +49 -0
  1119. package/src/entries/ImageEntry.ts +61 -0
  1120. package/src/entries/KeyValueEntry.ts +47 -0
  1121. package/src/entries/RepeatableEntry.test.ts +239 -0
  1122. package/src/entries/RepeatableEntry.ts +173 -0
  1123. package/src/entries/TextEntry.test.ts +394 -0
  1124. package/src/entries/TextEntry.ts +60 -0
  1125. package/src/entries/index.ts +12 -0
  1126. package/src/entries/leaves.test.ts +306 -0
  1127. package/src/entries/registry.ts +54 -0
  1128. package/src/fields/BuilderField.test.ts +1188 -0
  1129. package/src/fields/BuilderField.ts +568 -0
  1130. package/src/fields/BuilderRelationship.test.ts +811 -0
  1131. package/src/fields/CheckboxField.test.ts +44 -0
  1132. package/src/fields/CheckboxField.ts +27 -0
  1133. package/src/fields/CheckboxListField.test.ts +99 -0
  1134. package/src/fields/CheckboxListField.ts +66 -0
  1135. package/src/fields/ColorPickerField.test.ts +33 -0
  1136. package/src/fields/ColorPickerField.ts +25 -0
  1137. package/src/fields/DateField.ts +54 -0
  1138. package/src/fields/DateTimeField.test.ts +55 -0
  1139. package/src/fields/EmailField.ts +16 -0
  1140. package/src/fields/Field.test.ts +639 -0
  1141. package/src/fields/Field.ts +773 -0
  1142. package/src/fields/FileUploadField.test.ts +97 -0
  1143. package/src/fields/FileUploadField.ts +71 -0
  1144. package/src/fields/HiddenField.test.ts +27 -0
  1145. package/src/fields/HiddenField.ts +28 -0
  1146. package/src/fields/KeyValueField.test.ts +105 -0
  1147. package/src/fields/KeyValueField.ts +55 -0
  1148. package/src/fields/MarkdownField.test.ts +167 -0
  1149. package/src/fields/MarkdownField.ts +151 -0
  1150. package/src/fields/NumberField.ts +33 -0
  1151. package/src/fields/RadioField.test.ts +94 -0
  1152. package/src/fields/RadioField.ts +67 -0
  1153. package/src/fields/RepeaterField.test.ts +1806 -0
  1154. package/src/fields/RepeaterField.ts +791 -0
  1155. package/src/fields/RepeaterRelationship.test.ts +1630 -0
  1156. package/src/fields/RepeaterSimple.test.ts +248 -0
  1157. package/src/fields/RowButton.test.ts +149 -0
  1158. package/src/fields/RowButton.ts +125 -0
  1159. package/src/fields/SelectField.test.ts +192 -0
  1160. package/src/fields/SelectField.ts +235 -0
  1161. package/src/fields/SliderField.test.ts +50 -0
  1162. package/src/fields/SliderField.ts +53 -0
  1163. package/src/fields/SlugField.ts +24 -0
  1164. package/src/fields/TagsInputField.test.ts +154 -0
  1165. package/src/fields/TagsInputField.ts +133 -0
  1166. package/src/fields/TextField.ts +24 -0
  1167. package/src/fields/TextareaField.test.ts +58 -0
  1168. package/src/fields/TextareaField.ts +59 -0
  1169. package/src/fields/ToggleButtonsField.test.ts +106 -0
  1170. package/src/fields/ToggleButtonsField.ts +59 -0
  1171. package/src/fields/ToggleField.ts +16 -0
  1172. package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +319 -0
  1173. package/src/fields/optionsResolver.ts +95 -0
  1174. package/src/fields/resolveField.ts +28 -0
  1175. package/src/filters/BooleanFilter.ts +35 -0
  1176. package/src/filters/DateRangeFilter.test.ts +194 -0
  1177. package/src/filters/DateRangeFilter.ts +148 -0
  1178. package/src/filters/Filter.test.ts +268 -0
  1179. package/src/filters/Filter.ts +184 -0
  1180. package/src/filters/FormFilter.test.ts +238 -0
  1181. package/src/filters/FormFilter.ts +215 -0
  1182. package/src/filters/MultiSelectFilter.test.ts +119 -0
  1183. package/src/filters/MultiSelectFilter.ts +78 -0
  1184. package/src/filters/QueryBuilderFilter.test.ts +644 -0
  1185. package/src/filters/QueryBuilderFilter.ts +398 -0
  1186. package/src/filters/SelectFilter.ts +46 -0
  1187. package/src/filters/TernaryFilter.test.ts +160 -0
  1188. package/src/filters/TernaryFilter.ts +72 -0
  1189. package/src/filters/TrashedFilter.test.ts +149 -0
  1190. package/src/filters/TrashedFilter.ts +55 -0
  1191. package/src/filters/queryBuilder/BooleanConstraint.ts +31 -0
  1192. package/src/filters/queryBuilder/Constraint.ts +115 -0
  1193. package/src/filters/queryBuilder/DateConstraint.ts +69 -0
  1194. package/src/filters/queryBuilder/NumberConstraint.ts +66 -0
  1195. package/src/filters/queryBuilder/SelectConstraint.ts +72 -0
  1196. package/src/filters/queryBuilder/TextConstraint.ts +65 -0
  1197. package/src/filters/queryBuilder/index.ts +12 -0
  1198. package/src/icons/index.ts +2 -0
  1199. package/src/icons/lucide.ts +204 -0
  1200. package/src/icons/registry.test.ts +56 -0
  1201. package/src/icons/registry.ts +41 -0
  1202. package/src/icons/types.ts +47 -0
  1203. package/src/index.ts +521 -0
  1204. package/src/io/csv.test.ts +142 -0
  1205. package/src/io/csv.ts +170 -0
  1206. package/src/nestedRelationManagerData.test.ts +526 -0
  1207. package/src/notifications/Notification.test.ts +210 -0
  1208. package/src/notifications/Notification.ts +354 -0
  1209. package/src/notifications/broadcast.test.ts +110 -0
  1210. package/src/notifications/broadcast.ts +95 -0
  1211. package/src/notifications/database.test.ts +383 -0
  1212. package/src/notifications/database.ts +398 -0
  1213. package/src/notifications/databaseNotifications.test.ts +187 -0
  1214. package/src/notifications/dispatchNotificationAction.test.ts +341 -0
  1215. package/src/notifications/dispatchNotificationAction.ts +142 -0
  1216. package/src/notifications/flash.test.ts +89 -0
  1217. package/src/notifications/flash.ts +71 -0
  1218. package/src/notifications/index.ts +45 -0
  1219. package/src/notifications/registerBroadcastAuth.test.ts +134 -0
  1220. package/src/notifications/registerBroadcastAuth.ts +100 -0
  1221. package/src/notifications/resolveSavedNotification.test.ts +82 -0
  1222. package/src/notifications/resolveSavedNotification.ts +59 -0
  1223. package/src/notifications/types.ts +93 -0
  1224. package/src/orm/m2mAccessor.ts +66 -0
  1225. package/src/orm/modelDefaults.test.ts +633 -0
  1226. package/src/orm/modelDefaults.ts +632 -0
  1227. package/src/pageData.test.ts +1121 -0
  1228. package/src/pageData.ts +4662 -0
  1229. package/src/plugins/index.ts +1 -0
  1230. package/src/plugins/themeEditor.ts +24 -0
  1231. package/src/react/AppShell.tsx +148 -0
  1232. package/src/react/CommandPalette.tsx +375 -0
  1233. package/src/react/FormStateContext.tsx +398 -0
  1234. package/src/react/HeadHooks.tsx +126 -0
  1235. package/src/react/NotificationActionStrip.tsx +263 -0
  1236. package/src/react/NotificationBell.tsx +426 -0
  1237. package/src/react/RenderHookSlot.tsx +32 -0
  1238. package/src/react/RightSidebar.tsx +257 -0
  1239. package/src/react/RightSidebarContext.tsx +211 -0
  1240. package/src/react/RightSidebarTrigger.tsx +53 -0
  1241. package/src/react/SchemaRenderer.tsx +6128 -0
  1242. package/src/react/SearchTrigger.tsx +46 -0
  1243. package/src/react/ThemeProvider.tsx +93 -0
  1244. package/src/react/ThemeSettingsPage.tsx +579 -0
  1245. package/src/react/ThemeToggle.tsx +20 -0
  1246. package/src/react/Toaster.tsx +158 -0
  1247. package/src/react/UserMenu.tsx +196 -0
  1248. package/src/react/WidgetDataContext.tsx +157 -0
  1249. package/src/react/cells/EditableCell.tsx +376 -0
  1250. package/src/react/fieldJsHandler.test.ts +166 -0
  1251. package/src/react/fieldJsHandler.ts +79 -0
  1252. package/src/react/fields/BuilderInput.tsx +995 -0
  1253. package/src/react/fields/CheckboxInput.tsx +39 -0
  1254. package/src/react/fields/CheckboxListInput.tsx +81 -0
  1255. package/src/react/fields/ColorInput.tsx +51 -0
  1256. package/src/react/fields/DateFieldInput.tsx +70 -0
  1257. package/src/react/fields/DateTimeInput.tsx +42 -0
  1258. package/src/react/fields/FieldShell.tsx +107 -0
  1259. package/src/react/fields/FileUploadInput.tsx +189 -0
  1260. package/src/react/fields/HiddenInput.tsx +17 -0
  1261. package/src/react/fields/KeyValueInput.tsx +200 -0
  1262. package/src/react/fields/MarkdownInput.tsx +333 -0
  1263. package/src/react/fields/RadioInput.tsx +60 -0
  1264. package/src/react/fields/RepeaterInput.test.ts +116 -0
  1265. package/src/react/fields/RepeaterInput.tsx +1313 -0
  1266. package/src/react/fields/SelectFieldInput.tsx +257 -0
  1267. package/src/react/fields/SliderInput.tsx +63 -0
  1268. package/src/react/fields/TagsInput.tsx +265 -0
  1269. package/src/react/fields/TextLikeInput.tsx +54 -0
  1270. package/src/react/fields/ToggleButtonsInput.tsx +60 -0
  1271. package/src/react/fields/ToggleFieldInput.tsx +35 -0
  1272. package/src/react/fields/rowChromeButton.tsx +225 -0
  1273. package/src/react/fields/syncRowGates.test.ts +202 -0
  1274. package/src/react/fields/syncRowGates.ts +66 -0
  1275. package/src/react/formStateHelpers.test.ts +295 -0
  1276. package/src/react/formStateHelpers.ts +218 -0
  1277. package/src/react/hooks/use-mobile.ts +19 -0
  1278. package/src/react/icon-context.tsx +60 -0
  1279. package/src/react/index.ts +85 -0
  1280. package/src/react/layouts/SidebarLayout.tsx +239 -0
  1281. package/src/react/layouts/TopbarLayout.tsx +245 -0
  1282. package/src/react/navigate.tsx +37 -0
  1283. package/src/react/registry.ts +48 -0
  1284. package/src/react/right-panel-registry.tsx +47 -0
  1285. package/src/react/theme-preview/apply.ts +99 -0
  1286. package/src/react/theme-preview/build-html.ts +436 -0
  1287. package/src/react/ui/button.tsx +51 -0
  1288. package/src/react/ui/calendar.tsx +67 -0
  1289. package/src/react/ui/checkbox.tsx +29 -0
  1290. package/src/react/ui/dialog.tsx +108 -0
  1291. package/src/react/ui/dropdown-menu.tsx +97 -0
  1292. package/src/react/ui/input.tsx +20 -0
  1293. package/src/react/ui/label.tsx +21 -0
  1294. package/src/react/ui/popover.tsx +50 -0
  1295. package/src/react/ui/select.tsx +169 -0
  1296. package/src/react/ui/separator.tsx +25 -0
  1297. package/src/react/ui/sheet.tsx +136 -0
  1298. package/src/react/ui/sidebar.tsx +723 -0
  1299. package/src/react/ui/skeleton.tsx +13 -0
  1300. package/src/react/ui/slider.tsx +34 -0
  1301. package/src/react/ui/switch.tsx +28 -0
  1302. package/src/react/ui/table.tsx +105 -0
  1303. package/src/react/ui/tabs.tsx +63 -0
  1304. package/src/react/ui/textarea.tsx +18 -0
  1305. package/src/react/ui/tooltip.tsx +64 -0
  1306. package/src/react/useResizableWidth.ts +139 -0
  1307. package/src/react/utils.ts +6 -0
  1308. package/src/react/widgetRegistry.test.ts +43 -0
  1309. package/src/react/widgetRegistry.ts +50 -0
  1310. package/src/react/widgets/StatsOverviewRenderer.tsx +232 -0
  1311. package/src/react/widgets/TableWidgetRenderer.tsx +231 -0
  1312. package/src/react/widgets/ViewRenderer.tsx +71 -0
  1313. package/src/relationManagerData.test.ts +1146 -0
  1314. package/src/richtext/index.ts +8 -0
  1315. package/src/richtext/registry.ts +89 -0
  1316. package/src/routes-nested-relations.test.ts +676 -0
  1317. package/src/routes-relations.test.ts +972 -0
  1318. package/src/routes.test.ts +1886 -0
  1319. package/src/routes.ts +3262 -0
  1320. package/src/schema/Alert.test.ts +63 -0
  1321. package/src/schema/Alert.ts +49 -0
  1322. package/src/schema/Block.ts +169 -0
  1323. package/src/schema/Breadcrumbs.ts +40 -0
  1324. package/src/schema/Card.ts +35 -0
  1325. package/src/schema/Divider.ts +20 -0
  1326. package/src/schema/Element.ts +219 -0
  1327. package/src/schema/EmptyState.test.ts +37 -0
  1328. package/src/schema/EmptyState.ts +63 -0
  1329. package/src/schema/Fieldset.ts +43 -0
  1330. package/src/schema/Grid.ts +43 -0
  1331. package/src/schema/Group.ts +30 -0
  1332. package/src/schema/Heading.ts +39 -0
  1333. package/src/schema/Html.ts +67 -0
  1334. package/src/schema/Icon.ts +54 -0
  1335. package/src/schema/Image.ts +57 -0
  1336. package/src/schema/LinkTag.ts +41 -0
  1337. package/src/schema/Markdown.ts +85 -0
  1338. package/src/schema/MetaTag.ts +41 -0
  1339. package/src/schema/RelationTabs.ts +71 -0
  1340. package/src/schema/ScriptTag.ts +55 -0
  1341. package/src/schema/Section.ts +143 -0
  1342. package/src/schema/ServerDataElement.test.ts +140 -0
  1343. package/src/schema/ServerDataElement.ts +156 -0
  1344. package/src/schema/Split.ts +50 -0
  1345. package/src/schema/Stat.test.ts +118 -0
  1346. package/src/schema/Stat.ts +154 -0
  1347. package/src/schema/StatsOverview.test.ts +141 -0
  1348. package/src/schema/StatsOverview.ts +119 -0
  1349. package/src/schema/StyleTag.ts +35 -0
  1350. package/src/schema/TableWidget.test.ts +297 -0
  1351. package/src/schema/TableWidget.ts +289 -0
  1352. package/src/schema/Tabs.ts +79 -0
  1353. package/src/schema/Text.ts +58 -0
  1354. package/src/schema/UnorderedList.ts +49 -0
  1355. package/src/schema/View.test.ts +111 -0
  1356. package/src/schema/View.ts +127 -0
  1357. package/src/schema/Wizard.ts +108 -0
  1358. package/src/schema/containers.test.ts +446 -0
  1359. package/src/schema/headTags.test.ts +134 -0
  1360. package/src/schema/index.ts +39 -0
  1361. package/src/schema/primes.test.ts +269 -0
  1362. package/src/schema/resolveSchema.test.ts +329 -0
  1363. package/src/schema/resolveSchema.ts +807 -0
  1364. package/src/schema/sanitize.ts +49 -0
  1365. package/src/search.test.ts +446 -0
  1366. package/src/search.ts +178 -0
  1367. package/src/sessionFilters.test.ts +352 -0
  1368. package/src/sessionFilters.ts +133 -0
  1369. package/src/summarizers/Summarizer.test.ts +84 -0
  1370. package/src/summarizers/Summarizer.ts +123 -0
  1371. package/src/summarizers/index.ts +11 -0
  1372. package/src/theme/base-colors.ts +68 -0
  1373. package/src/theme/chart-colors.ts +50 -0
  1374. package/src/theme/colors.ts +447 -0
  1375. package/src/theme/generate-css.test.ts +139 -0
  1376. package/src/theme/generate-css.ts +44 -0
  1377. package/src/theme/generate-scale.test.ts +106 -0
  1378. package/src/theme/generate-scale.ts +97 -0
  1379. package/src/theme/icon-map.ts +42 -0
  1380. package/src/theme/index.ts +28 -0
  1381. package/src/theme/migrate.ts +81 -0
  1382. package/src/theme/presets.ts +135 -0
  1383. package/src/theme/radius.ts +18 -0
  1384. package/src/theme/resolve.test.ts +238 -0
  1385. package/src/theme/resolve.ts +96 -0
  1386. package/src/theme/spacing.ts +18 -0
  1387. package/src/theme/theme-colors.ts +88 -0
  1388. package/src/theme/types.ts +125 -0
  1389. package/src/uploads/UploadAdapter.ts +35 -0
  1390. package/src/uploads/index.ts +2 -0
  1391. package/src/uploads/localUpload.test.ts +70 -0
  1392. package/src/uploads/localUpload.ts +84 -0
  1393. package/src/validation/Validator.ts +49 -0
  1394. package/src/validation/index.ts +28 -0
  1395. package/src/validation/rules.ts +78 -0
  1396. package/src/validation/runValidators.ts +435 -0
  1397. package/src/validation/uniqueValidator.test.ts +196 -0
  1398. package/src/validation/uniqueValidator.ts +133 -0
  1399. package/src/validation/validators.test.ts +268 -0
  1400. package/src/vite.ts +758 -0
  1401. package/src/widgets/index.ts +10 -0
  1402. package/src/widgets/registry.ts +45 -0
  1403. package/src/widgets.test.ts +592 -0
  1404. package/tsconfig.build.json +11 -0
  1405. package/tsconfig.json +4 -0
  1406. package/tsconfig.test.json +10 -0
  1407. package/views/react/Dashboard.tsx +27 -0
  1408. package/views/react/Resources/Form.tsx +102 -0
  1409. package/views/react/Resources/Index.tsx +49 -0
package/src/routes.ts ADDED
@@ -0,0 +1,3262 @@
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 { Form } from './elements/Form.js'
6
+ import { resolveSchema, type SchemaContext } from './schema/resolveSchema.js'
7
+ import { dispatchFormSubmit, findForms, selectForm } from './elements/dispatchForm.js'
8
+ import { dispatchAction, findActions, findRowExtraActions, parseActionBody, type ResolveRecord } from './elements/dispatchAction.js'
9
+ import { flashNotifications } from './notifications/flash.js'
10
+ import {
11
+ listFiltersKey,
12
+ readPersistedListQuery,
13
+ writePersistedListQuery,
14
+ readPersistedLastTab,
15
+ writePersistedLastTab,
16
+ encodePersistedQuery,
17
+ } from './sessionFilters.js'
18
+ import {
19
+ panelInfo, callPageSchema, tagFormActions, tagActionDispatch,
20
+ dashboardData, resourceIndexData, resourceTableData,
21
+ resourceCreateData, resourceEditData,
22
+ resourceViewData, globalEditData, globalViewData, customPageData,
23
+ formStateData, type FormStateScope,
24
+ formWizardData,
25
+ formCreateOptionData,
26
+ mentionResolveData,
27
+ searchData,
28
+ relationManagerData, findRelatedResource, safeManagerPolicy,
29
+ resolveRelationChain, type ResolvedChain,
30
+ widgetData, type WidgetScope,
31
+ } from './pageData.js'
32
+ import {
33
+ listForUser as listDatabaseNotifications,
34
+ findOneForUser as findDatabaseNotificationForUser,
35
+ markAsRead as markDatabaseNotificationAsRead,
36
+ markAsUnread as markDatabaseNotificationAsUnread,
37
+ markAllAsRead as markAllDatabaseNotificationsAsRead,
38
+ } from './notifications/database.js'
39
+ import { dispatchNotificationAction } from './notifications/dispatchNotificationAction.js'
40
+ import { registerBroadcastAuth } from './notifications/registerBroadcastAuth.js'
41
+ import {
42
+ RelationManager, RESERVED_RELATIONSHIP_TOKENS,
43
+ normalizeRelationMode,
44
+ type RelationMode,
45
+ } from './RelationManager.js'
46
+ import {
47
+ modelSave, modelLoadRecord, findRecord, getPrimaryKey, getRelationType,
48
+ getMorphRelationDescriptor, computeMorphPayload,
49
+ } from './orm/modelDefaults.js'
50
+ import { Table } from './elements/Table.js'
51
+ import { Column } from './Column.js'
52
+ import { coerceCellValue, CellCoerceError } from './cells/coerce.js'
53
+ import type { ThemeConfig } from './theme/types.js'
54
+ import { presets } from './theme/presets.js'
55
+ import { baseColors } from './theme/base-colors.js'
56
+ import { HUE_NAMES } from './theme/colors.js'
57
+ import { migrateThemeOverrides } from './theme/migrate.js'
58
+ import { radiusMap } from './theme/radius.js'
59
+ import { resourceBasePath, globalBasePath, pageBasePath } from './clusterPaths.js'
60
+ import type { ClusterClass } from './Cluster.js'
61
+
62
+ /** True when the client wants a JSON response (modal-form action submitting
63
+ * via fetch), false for a browser-style form post that wants a 303 redirect.
64
+ * Both action endpoints honor this so confirm/handler buttons (form-post)
65
+ * keep working unchanged while modal dialogs use fetch. */
66
+ function wantsJson(req: AppRequest): boolean {
67
+ const headers = req.headers ?? {}
68
+ const accept = headers['accept'] ?? headers['Accept'] ?? ''
69
+ return accept.includes('application/json')
70
+ }
71
+
72
+ /**
73
+ * Read the request body as a `Record<string, unknown>`. The hono adapter
74
+ * auto-parses JSON, but `application/x-www-form-urlencoded` and
75
+ * `multipart/form-data` need a manual fall-through to Hono's own parser.
76
+ */
77
+ async function readFormBody(req: AppRequest): Promise<Record<string, unknown>> {
78
+ if (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) {
79
+ return { ...(req.body as Record<string, unknown>) }
80
+ }
81
+ const raw = req.raw as { req?: { parseBody?: () => Promise<Record<string, unknown>> } } | undefined
82
+ if (raw?.req?.parseBody) {
83
+ try {
84
+ const parsed = await raw.req.parseBody()
85
+ return parsed && typeof parsed === 'object' ? { ...parsed } : {}
86
+ } catch {
87
+ return {}
88
+ }
89
+ }
90
+ return {}
91
+ }
92
+
93
+ /**
94
+ * Normalize a user-supplied redirect URL. Returns absolute URLs and
95
+ * scheme-prefixed URLs unchanged. Bare relative paths (no leading `/`)
96
+ * are joined under the panel's `basePath` — without this, the browser
97
+ * resolves the redirect against the current request URL and produces
98
+ * paths like `/admin/articles/{id}/articles/{id}/edit`.
99
+ *
100
+ * `getRedirectUrl` page hooks and `Form.redirectAfterSave` callbacks
101
+ * are user-authored; this protects the framework against the common
102
+ * authoring slip while keeping absolute URLs (the documented form)
103
+ * working as-is.
104
+ */
105
+ function normalizeRedirect(url: string | undefined, basePath: string): string | undefined {
106
+ if (!url) return undefined
107
+ if (url.startsWith('/')) return url
108
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url // http(s):, mailto:, etc.
109
+ const trimmedBase = basePath.replace(/\/$/, '')
110
+ return `${trimmedBase}/${url}`
111
+ }
112
+
113
+ /** Strip framework meta keys (`_formId`, `_method`, `_continueCreate`)
114
+ * from a parsed body. `continueCreate` mirrors the secondary
115
+ * "Create & create another" submit on `CreatePage`: when `'1'`, the
116
+ * create POST handler routes the redirect back to the create URL
117
+ * instead of the new record's edit page. */
118
+ function splitMeta(body: Record<string, unknown>): {
119
+ values: Record<string, unknown>
120
+ formId: string | undefined
121
+ continueCreate: boolean
122
+ } {
123
+ const { _formId, _method: _omitMethod, _continueCreate, ...rest } = body
124
+ return {
125
+ values: rest,
126
+ formId: typeof _formId === 'string' ? _formId : undefined,
127
+ continueCreate: _continueCreate === '1' || _continueCreate === 1 || _continueCreate === true,
128
+ }
129
+ }
130
+
131
+ /** Strip control characters (`"\\\r\n`) from a download filename so
132
+ * the `Content-Disposition: attachment; filename="…"` header stays
133
+ * unbreakable. Defends against a handler that returns a hostile
134
+ * filename string. Empty fallback `'export'`. */
135
+ function sanitizeFilename(name: string): string {
136
+ const cleaned = (name ?? '').replace(/[\r\n"\\]/g, '').trim()
137
+ return cleaned.length > 0 ? cleaned : 'export'
138
+ }
139
+
140
+ /** Write an `Action`-handler download envelope as the response. Sets
141
+ * `Content-Type` + `Content-Disposition: attachment` and ends with
142
+ * the body. Mutually exclusive with redirect — call sites consult
143
+ * `result.download` first. */
144
+ function sendDownload(
145
+ res: AppResponse,
146
+ env: { filename: string; contentType: string; body: string },
147
+ ): void {
148
+ res.header('Content-Type', env.contentType)
149
+ res.header('Content-Disposition', `attachment; filename="${sanitizeFilename(env.filename)}"`)
150
+ res.send(env.body)
151
+ }
152
+
153
+ /** Plan #10 — send a 403 response. Branches on `Accept: application/json`
154
+ * the same way the action / form dispatch paths do. Used by every route
155
+ * after a `Resource.canX(...)` check fails. We deliberately do NOT
156
+ * redirect to login: 403 means "authenticated but not allowed"; the
157
+ * 401-unauthenticated case is `Pilotiq.guard()`'s job. */
158
+ function forbidden(res: AppResponse, json: boolean): unknown {
159
+ res.status(403)
160
+ if (json) return res.json({ ok: false, error: 'Forbidden' })
161
+ return res.send('Forbidden')
162
+ }
163
+
164
+ /** Run a `canX(...)` predicate, treating throws as `false`. The predicate
165
+ * is user-authored and we want a flaky check to fail closed (deny) rather
166
+ * than 500 the page. */
167
+ async function checkPolicy(fn: () => boolean | Promise<boolean>): Promise<boolean> {
168
+ try { return Boolean(await fn()) } catch { return false }
169
+ }
170
+
171
+ async function policyAccess(
172
+ owner: {
173
+ canAccess: (user: unknown) => boolean | Promise<boolean>
174
+ cluster?: { canAccess: (user: unknown) => boolean | Promise<boolean> }
175
+ },
176
+ user: unknown,
177
+ ): Promise<boolean> {
178
+ const [ownerOk, clusterOk] = await Promise.all([
179
+ checkPolicy(() => owner.canAccess(user)),
180
+ owner.cluster
181
+ ? checkPolicy(() => owner.cluster!.canAccess(user))
182
+ : Promise.resolve(true),
183
+ ])
184
+ return ownerOk && clusterOk
185
+ }
186
+
187
+ /**
188
+ * Locate an action by name in a resolved page schema. Looks at both
189
+ * page-level actions (`findActions`) AND row-scoped extraItemActions on
190
+ * Repeater/Builder fields (`findRowExtraActions`). When the match is
191
+ * row-scoped, also returns the parent field reference and the form
192
+ * schema array — the dispatcher uses both to coerce the form body and
193
+ * navigate to the right row when stamping `ctx.row`.
194
+ *
195
+ * Page-level matches win when a page-level + row-scoped action share the
196
+ * same name (page-level is strictly more privileged: it has access to
197
+ * the full form, not just one row). The collision is undocumented
198
+ * behavior — authors should use distinct names.
199
+ */
200
+ function resolveDispatchTarget(
201
+ elements: import('./schema/Element.js').Element[],
202
+ actionName: string,
203
+ ): {
204
+ action: import('./actions/Action.js').Action
205
+ rowField?: import('./fields/RepeaterField.js').RepeaterField | import('./fields/BuilderField.js').BuilderField
206
+ formSchema?: import('./schema/Element.js').Element[]
207
+ } | null {
208
+ const pageLevel = findActions(elements).find(a => a.name === actionName)
209
+ if (pageLevel) return { action: pageLevel }
210
+
211
+ const rowMatches = findRowExtraActions(elements).filter(r => r.action.name === actionName)
212
+ if (rowMatches.length === 0) return null
213
+ if (rowMatches.length > 1) {
214
+ console.warn(
215
+ `[pilotiq] Action "${actionName}" registered as extraItemActions on multiple ` +
216
+ `fields. Using the first match — disambiguate by renaming.`,
217
+ )
218
+ }
219
+ const first = rowMatches[0]!
220
+ // `formSchema` is the entire page tree for v1 — `coerceFormValues`
221
+ // needs the field schema rooted at the form, not just the one row's
222
+ // children. Passing the page tree is over-broad but safe (the function
223
+ // walks until it finds the field). A future polish can narrow to the
224
+ // owning Form once we walk back from the matched field.
225
+ return { action: first.action, rowField: first.field, formSchema: elements }
226
+ }
227
+
228
+ /**
229
+ * Plan #5 — handle a partial-resolve POST. The body shape is
230
+ * `{ changed, values }`; `formId` comes from the URL path. Response
231
+ * is `{ ok, form, dirty }` on success or `{ ok: false, error }` for
232
+ * missing form / unknown field.
233
+ */
234
+ interface FormStateBody {
235
+ changed?: unknown
236
+ values?: unknown
237
+ }
238
+
239
+ async function handleFormState(
240
+ req: AppRequest,
241
+ res: AppResponse,
242
+ pilotiq: Pilotiq,
243
+ scope: FormStateScope,
244
+ formId: string,
245
+ ): Promise<unknown> {
246
+ const body = (await readFormBody(req)) as FormStateBody
247
+ const changed = typeof body.changed === 'string' ? body.changed : ''
248
+ const values = (body.values && typeof body.values === 'object' && !Array.isArray(body.values))
249
+ ? body.values as Record<string, unknown>
250
+ : {}
251
+ if (!formId || !changed) {
252
+ res.status(400)
253
+ return res.json({ ok: false, error: 'Missing formId or changed field' })
254
+ }
255
+
256
+ try {
257
+ const result = await formStateData(pilotiq, scope, { formId, changed, values }, req)
258
+ if (result === null) {
259
+ res.status(404)
260
+ return res.json({ ok: false, error: 'Page not found' })
261
+ }
262
+ if (!result.ok) {
263
+ res.status(result.status)
264
+ return res.json({ ok: false, error: result.error })
265
+ }
266
+ return res.json({ ok: true, form: result.form, dirty: result.dirty })
267
+ } catch (err) {
268
+ const message = err instanceof Error ? err.message : 'Form update failed'
269
+ res.status(500)
270
+ return res.json({ ok: false, error: message })
271
+ }
272
+ }
273
+
274
+ interface FormWizardBody {
275
+ step?: unknown
276
+ values?: unknown
277
+ }
278
+
279
+ async function handleFormWizard(
280
+ req: AppRequest,
281
+ res: AppResponse,
282
+ pilotiq: Pilotiq,
283
+ scope: FormStateScope,
284
+ formId: string,
285
+ ): Promise<unknown> {
286
+ const body = (await readFormBody(req)) as FormWizardBody
287
+ const stepN = typeof body.step === 'number' ? body.step
288
+ : typeof body.step === 'string' ? Number(body.step)
289
+ : NaN
290
+ const values = (body.values && typeof body.values === 'object' && !Array.isArray(body.values))
291
+ ? body.values as Record<string, unknown>
292
+ : {}
293
+ if (!formId || !Number.isFinite(stepN) || stepN < 0) {
294
+ res.status(400)
295
+ return res.json({ ok: false, error: 'Missing formId or invalid step' })
296
+ }
297
+
298
+ try {
299
+ const result = await formWizardData(pilotiq, scope, { formId, step: stepN, values }, req)
300
+ if (result === null) {
301
+ res.status(404)
302
+ return res.json({ ok: false, error: 'Page not found' })
303
+ }
304
+ if (!result.ok) {
305
+ res.status(result.status)
306
+ const payload: Record<string, unknown> = { ok: false }
307
+ if (result.error) payload['error'] = result.error
308
+ if (result.errors) payload['errors'] = result.errors
309
+ return res.json(payload)
310
+ }
311
+ return res.json({ ok: true })
312
+ } catch (err) {
313
+ const message = err instanceof Error ? err.message : 'Wizard step validation failed'
314
+ res.status(500)
315
+ return res.json({ ok: false, error: message })
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Audit row 2026-05-07 cont'd⁸ — `SelectField.createOptionForm()` modal
321
+ * submit. Body carries `{ values }`; `formId` + `fieldName` come from
322
+ * the URL path. Returns `{ ok, option: { value, label } }` on success
323
+ * or `{ ok: false, error }` for missing scope / form / field, 403 for
324
+ * authorize failure, or 422 with `errors` for validation.
325
+ *
326
+ * One handler shared across all four scopes (resource-create /
327
+ * resource-edit / global-edit / custom-page) — caller passes the
328
+ * matching `FormStateScope` so the same `canAccess + canCreate / canEdit`
329
+ * predicates apply to the parent form's policy gate.
330
+ */
331
+ interface FormCreateOptionBody {
332
+ values?: unknown
333
+ }
334
+
335
+ async function handleFormCreateOption(
336
+ req: AppRequest,
337
+ res: AppResponse,
338
+ pilotiq: Pilotiq,
339
+ scope: FormStateScope,
340
+ formId: string,
341
+ fieldName: string,
342
+ ): Promise<unknown> {
343
+ const body = (await readFormBody(req)) as FormCreateOptionBody
344
+ const values = (body.values && typeof body.values === 'object' && !Array.isArray(body.values))
345
+ ? body.values as Record<string, unknown>
346
+ : {}
347
+ if (!formId || !fieldName) {
348
+ res.status(400)
349
+ return res.json({ ok: false, error: 'Missing formId or fieldName' })
350
+ }
351
+
352
+ try {
353
+ const result = await formCreateOptionData(pilotiq, scope, { formId, fieldName, values }, req)
354
+ if (result === null) {
355
+ res.status(404)
356
+ return res.json({ ok: false, error: 'Page not found' })
357
+ }
358
+ if (!result.ok) {
359
+ res.status(result.status)
360
+ const payload: Record<string, unknown> = { ok: false }
361
+ if (result.error) payload['error'] = result.error
362
+ if (result.errors) payload['errors'] = result.errors
363
+ return res.json(payload)
364
+ }
365
+ return res.json({ ok: true, option: result.option })
366
+ } catch (err) {
367
+ const message = err instanceof Error ? err.message : 'createOption failed'
368
+ res.status(500)
369
+ return res.json({ ok: false, error: message })
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Async-mention round-trip handler. Body is `{ field, trigger, query }`;
375
+ * `formId` comes from the URL path. Returns `{ ok, items }` on success
376
+ * or `{ ok: false, error }` for missing form / field / trigger.
377
+ *
378
+ * Each scope (resource-create, resource-edit, global-edit, custom-page)
379
+ * registers its own route — the auth gate matches the matching `_form/
380
+ * :formId/state` endpoint so the same `canAccess + canCreate / canEdit`
381
+ * predicates apply.
382
+ */
383
+ interface FormMentionsBody {
384
+ field?: unknown
385
+ trigger?: unknown
386
+ query?: unknown
387
+ }
388
+
389
+ async function handleFormMentions(
390
+ req: AppRequest,
391
+ res: AppResponse,
392
+ pilotiq: Pilotiq,
393
+ scope: FormStateScope,
394
+ formId: string,
395
+ ): Promise<unknown> {
396
+ const body = (await readFormBody(req)) as FormMentionsBody
397
+ const field = typeof body.field === 'string' ? body.field : ''
398
+ const trigger = typeof body.trigger === 'string' ? body.trigger : ''
399
+ const query = typeof body.query === 'string' ? body.query : ''
400
+ if (!formId || !field || trigger.length !== 1) {
401
+ res.status(400)
402
+ return res.json({ ok: false, error: 'Missing formId / field / trigger' })
403
+ }
404
+
405
+ // Cap query length — the resolver runs the user's code; the trigger
406
+ // never sends more than a word's worth of characters in practice.
407
+ const cappedQuery = query.length > 200 ? query.slice(0, 200) : query
408
+
409
+ try {
410
+ const result = await mentionResolveData(
411
+ pilotiq,
412
+ scope,
413
+ { formId, field, trigger, query: cappedQuery },
414
+ req,
415
+ )
416
+ if (result === null) {
417
+ res.status(404)
418
+ return res.json({ ok: false, error: 'Page not found' })
419
+ }
420
+ if (!result.ok) {
421
+ res.status(result.status)
422
+ return res.json({ ok: false, error: result.error })
423
+ }
424
+ return res.json({ ok: true, items: result.items })
425
+ } catch (err) {
426
+ const message = err instanceof Error ? err.message : 'Mention resolve failed'
427
+ res.status(500)
428
+ return res.json({ ok: false, error: message })
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Plan #15 — handle a widget polling POST. Body is `{ filter? }`;
434
+ * `:id` comes from the URL. Returns `{ ok, data, timestamp }` on
435
+ * success or `{ ok: false, error }` on failure. Used by lazy-loading
436
+ * widgets (first fetch on mount) and `poll(seconds)` widgets (interval
437
+ * re-fetch).
438
+ */
439
+ interface WidgetBody {
440
+ filter?: unknown
441
+ }
442
+
443
+ async function handleWidgetData(
444
+ req: AppRequest,
445
+ res: AppResponse,
446
+ pilotiq: Pilotiq,
447
+ scope: WidgetScope,
448
+ id: string,
449
+ ): Promise<unknown> {
450
+ if (!id) {
451
+ res.status(400)
452
+ return res.json({ ok: false, error: 'Missing widget id' })
453
+ }
454
+ const body = (await readFormBody(req)) as WidgetBody
455
+ const filter = typeof body.filter === 'string' ? body.filter : undefined
456
+
457
+ try {
458
+ const result = await widgetData(
459
+ pilotiq,
460
+ scope,
461
+ filter !== undefined ? { id, filter } : { id },
462
+ req,
463
+ )
464
+ if (!result.ok) {
465
+ res.status(result.status)
466
+ return res.json({ ok: false, error: result.error })
467
+ }
468
+ return res.json({ ok: true, data: result.data, timestamp: result.timestamp })
469
+ } catch (err) {
470
+ res.status(500)
471
+ return res.json({ ok: false, error: err instanceof Error ? err.message : 'Widget request failed' })
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Handle a single file upload from a `FileUpload` field. Validates
477
+ * accept / maxSize against the (optional) per-request hints, hands
478
+ * the file off to the configured adapter, returns `{ ok, url }`.
479
+ *
480
+ * Body shape (multipart/form-data):
481
+ * - `file`: the file blob
482
+ * - `directory`: optional sub-directory hint
483
+ * - `accept`: optional comma-separated MIME list to enforce
484
+ * - `maxSize`: optional byte cap
485
+ * - `fieldName`: optional tag forwarded to the adapter for routing
486
+ */
487
+ async function handleUploadRequest(
488
+ req: AppRequest,
489
+ res: AppResponse,
490
+ pilotiq: Pilotiq,
491
+ ): Promise<unknown> {
492
+ const cfg = pilotiq.getConfig()
493
+ if (!cfg.uploads) {
494
+ res.status(500)
495
+ return res.json({ ok: false, error: 'No upload adapter configured' })
496
+ }
497
+
498
+ // Auth: panel-wide `guard` and per-request `user`. We don't enforce
499
+ // per-resource canEdit here because the field doesn't know which
500
+ // resource it belongs to — apps that need it should hook into
501
+ // their adapter's `put()` and consult their own auth there.
502
+ if (cfg.guard && !await cfg.guard(req)) {
503
+ res.status(401)
504
+ return res.json({ ok: false, error: 'Unauthorized' })
505
+ }
506
+
507
+ // Parse multipart body. Hono's parseBody returns `Record<string, File | string>`.
508
+ const raw = req.raw as { req?: { parseBody?: (opts?: { all?: boolean }) => Promise<Record<string, unknown>> } } | undefined
509
+ if (!raw?.req?.parseBody) {
510
+ res.status(500)
511
+ return res.json({ ok: false, error: 'Multipart parsing unavailable' })
512
+ }
513
+ let body: Record<string, unknown>
514
+ try {
515
+ body = await raw.req.parseBody()
516
+ } catch (err) {
517
+ res.status(400)
518
+ return res.json({ ok: false, error: err instanceof Error ? err.message : 'Bad request' })
519
+ }
520
+
521
+ const file = body['file']
522
+ if (!file || !(file instanceof File)) {
523
+ res.status(422)
524
+ return res.json({ ok: false, error: 'No file provided' })
525
+ }
526
+
527
+ const directory = typeof body['directory'] === 'string' ? body['directory'] : undefined
528
+ const fieldName = typeof body['fieldName'] === 'string' ? body['fieldName'] : ''
529
+
530
+ // Server-side validation. Both accept and maxSize are advisory hints
531
+ // shipped by the field meta, so we re-check here so a tampered client
532
+ // can't bypass the limits.
533
+ const acceptStr = typeof body['accept'] === 'string' ? body['accept'] : ''
534
+ if (acceptStr) {
535
+ const accept = acceptStr.split(',').map(s => s.trim()).filter(Boolean)
536
+ if (accept.length > 0 && !accept.includes(file.type)) {
537
+ res.status(422)
538
+ return res.json({ ok: false, error: `File type "${file.type}" not allowed` })
539
+ }
540
+ }
541
+ const maxSizeStr = typeof body['maxSize'] === 'string' ? body['maxSize'] : ''
542
+ if (maxSizeStr) {
543
+ const maxSize = Number(maxSizeStr)
544
+ if (Number.isFinite(maxSize) && file.size > maxSize) {
545
+ res.status(422)
546
+ return res.json({ ok: false, error: `File exceeds ${maxSize} bytes` })
547
+ }
548
+ }
549
+
550
+ try {
551
+ const result = await cfg.uploads.adapter.put({
552
+ file,
553
+ ...(directory ? { directory } : {}),
554
+ fieldName,
555
+ })
556
+ return res.json({ ok: true, url: result.url, ...(result.meta ? { meta: result.meta } : {}) })
557
+ } catch (err) {
558
+ res.status(500)
559
+ return res.json({ ok: false, error: err instanceof Error ? err.message : 'Upload failed' })
560
+ }
561
+ }
562
+
563
+ export function registerPilotiqRoutes(
564
+ router: Router,
565
+ pilotiq: Pilotiq,
566
+ ): void {
567
+ const cfg = pilotiq.getConfig()
568
+ const base = cfg.path
569
+
570
+ // Fail fast at boot — a silent 404 at request time is much harder to
571
+ // debug than a clear error here. Dangling-reference checks run even
572
+ // when `cfg.clusters` is empty.
573
+ const clusterSet = new Set(cfg.clusters)
574
+ const assertClusterRegistered = (
575
+ kind: 'Resource' | 'Global' | 'Page',
576
+ items: Array<{ name: string; cluster?: ClusterClass }>,
577
+ ): void => {
578
+ for (const item of items) {
579
+ if (item.cluster === undefined) continue
580
+ if (!clusterSet.has(item.cluster)) {
581
+ throw new Error(
582
+ `[Pilotiq] ${kind} ${item.name} references cluster ${item.cluster.name} which is not registered. ` +
583
+ `Add it to Pilotiq.clusters([…]).`,
584
+ )
585
+ }
586
+ }
587
+ }
588
+ assertClusterRegistered('Resource', cfg.resources)
589
+ assertClusterRegistered('Global', cfg.globals)
590
+ assertClusterRegistered('Page', cfg.pages)
591
+
592
+ if (cfg.clusters.length > 0) {
593
+ const seenClusterSlug = new Set<string>()
594
+ for (const C of cfg.clusters) {
595
+ const s = C.getSlug()
596
+ if (s === '' || /^_/.test(s) || s === 'theme' || s === 'api') {
597
+ throw new Error(
598
+ `[Pilotiq] Cluster ${C.name} uses reserved slug "${s}". ` +
599
+ `Cluster slugs cannot be empty, start with "_", or equal "theme" / "api".`,
600
+ )
601
+ }
602
+ if (seenClusterSlug.has(s)) {
603
+ throw new Error(
604
+ `[Pilotiq] Two clusters share slug "${s}". Cluster slugs must be unique.`,
605
+ )
606
+ }
607
+ seenClusterSlug.add(s)
608
+ }
609
+ // Top-level (no cluster) child slugs must not collide with cluster
610
+ // slugs — `<panel-base>/<slug>` would resolve to the cluster first.
611
+ const assertNoSlugCollision = <T extends { name: string; cluster?: ClusterClass; getSlug(): string }>(
612
+ kind: 'Resource' | 'Global' | 'Page',
613
+ items: T[],
614
+ skip?: (item: T) => boolean,
615
+ ): void => {
616
+ for (const item of items) {
617
+ if (item.cluster || (skip?.(item) ?? false)) continue
618
+ if (seenClusterSlug.has(item.getSlug())) {
619
+ const hint = kind === 'Resource'
620
+ ? ` Either rename the resource or move it inside the cluster.`
621
+ : ''
622
+ throw new Error(
623
+ `[Pilotiq] ${kind} ${item.name} slug "${item.getSlug()}" collides with a registered cluster slug.${hint}`,
624
+ )
625
+ }
626
+ }
627
+ }
628
+ assertNoSlugCollision('Resource', cfg.resources)
629
+ assertNoSlugCollision('Global', cfg.globals)
630
+ assertNoSlugCollision('Page', cfg.pages, P => P === cfg.dashboardPage)
631
+ // landingPage sanity — a cluster's landing page must be inside the
632
+ // cluster (or the redirect would jump out of the cluster URL space).
633
+ for (const C of cfg.clusters) {
634
+ const lp = C.landingPage
635
+ if (lp === undefined) continue
636
+ if (!cfg.pages.includes(lp)) {
637
+ throw new Error(
638
+ `[Pilotiq] Cluster ${C.name}.landingPage references ${lp.name} which is not registered in Pilotiq.pages([…]).`,
639
+ )
640
+ }
641
+ if (lp.cluster !== C) {
642
+ throw new Error(
643
+ `[Pilotiq] Cluster ${C.name}.landingPage = ${lp.name}, but ${lp.name}.cluster does not point back at ${C.name}.`,
644
+ )
645
+ }
646
+ }
647
+ }
648
+
649
+ // Plan #11 — fail fast at boot when any relation manager's
650
+ // `relationship` collides with a reserved URL token. A silent 404 at
651
+ // request time is much harder to debug.
652
+ //
653
+ // Phase B nested resources — same validation walks managers declared
654
+ // via `M.relations()` (one level deep). Depth-3+ is rejected here too:
655
+ // declaring `relations()` on a nested manager isn't supported in
656
+ // Phase B (Filament also caps at depth 2).
657
+ for (const R of cfg.resources) {
658
+ for (const M of R.relations()) {
659
+ const rel = M.getRelationship()
660
+ if (RESERVED_RELATIONSHIP_TOKENS.has(rel)) {
661
+ throw new Error(
662
+ `[Pilotiq] RelationManager ${M.name} on ${R.name} uses reserved relationship "${rel}". ` +
663
+ `Reserved tokens: ${[...RESERVED_RELATIONSHIP_TOKENS].join(', ')}. Rename it.`,
664
+ )
665
+ }
666
+ for (const N of M.relations()) {
667
+ const nestedRel = N.getRelationship()
668
+ if (RESERVED_RELATIONSHIP_TOKENS.has(nestedRel)) {
669
+ throw new Error(
670
+ `[Pilotiq] Nested RelationManager ${N.name} under ${M.name} on ${R.name} uses reserved relationship "${nestedRel}". ` +
671
+ `Reserved tokens: ${[...RESERVED_RELATIONSHIP_TOKENS].join(', ')}. Rename it.`,
672
+ )
673
+ }
674
+ if (N.relations().length > 0) {
675
+ throw new Error(
676
+ `[Pilotiq] Nested RelationManager ${N.name} under ${M.name} on ${R.name} declares its own relations(). ` +
677
+ `Phase B caps nesting at depth 2 (Filament does too). Drop the nested relations() override.`,
678
+ )
679
+ }
680
+ }
681
+ }
682
+ }
683
+
684
+ // Reorderable rows — fail fast at boot when a Resource declares
685
+ // `Table.reorderable()` but the bound model can't actually persist a
686
+ // new order. We invoke `R.table(Table.make())` once per resource (the
687
+ // same call shape `defaultPages` uses at request time) and inspect
688
+ // `_reorderableColumn`. The model.reorder check is symmetric with
689
+ // Plan #13's restore/forceDelete guards. Result is cached per-resource
690
+ // so the route loop below can decide whether to mount `_reorder`.
691
+ const reorderEnabled = new Map<string, string>() // slug → column
692
+ for (const R of cfg.resources) {
693
+ let probeColumn: string | undefined
694
+ try { probeColumn = R.table(Table.make()).getReorderableColumn() }
695
+ catch { continue } // user-side throw — not a reorder concern
696
+ if (probeColumn === undefined) continue
697
+ if (!R.model || typeof R.model.reorder !== 'function') {
698
+ throw new Error(
699
+ `[Pilotiq] ${R.name}.table() calls reorderable("${probeColumn}") but the bound model has no reorder(ids) method. ` +
700
+ `Implement \`async reorder(ids)\` on the rudder Model (or remove the .reorderable() call).`,
701
+ )
702
+ }
703
+ reorderEnabled.set(R.getSlug(), probeColumn)
704
+ }
705
+
706
+ // Editable cell columns — fail fast at boot when a Resource declares
707
+ // at least one TextInput/Toggle/SelectColumn but the bound model
708
+ // can't persist a single-column update. Mirrors the reorder guard
709
+ // above. Result is cached per-resource so the route loop below can
710
+ // decide whether to mount `_cell`.
711
+ const editableEnabled = new Set<string>()
712
+ for (const R of cfg.resources) {
713
+ let hasEditable = false
714
+ try {
715
+ hasEditable = (R.table(Table.make()).getChildren() ?? [])
716
+ .some(c => c instanceof Column && c.isEditable())
717
+ } catch { continue }
718
+ if (!hasEditable) continue
719
+ if (!R.model || typeof R.model.update !== 'function') {
720
+ throw new Error(
721
+ `[Pilotiq] ${R.name}.table() declares an editable cell column ` +
722
+ `(TextInputColumn / ToggleColumn / SelectColumn) but the bound ` +
723
+ `model has no update(id, data) method. Set Resource.model = M ` +
724
+ `(rudder ORM convention) or drop the editable column.`,
725
+ )
726
+ }
727
+ editableEnabled.add(R.getSlug())
728
+ }
729
+
730
+ // ── Dashboard (1-segment) ─────────────────────────────
731
+ router.get(base, async (req, res) => {
732
+ // Plan #15 — when `panel.dashboard(P)` is set, gate the dashboard
733
+ // route through the page's `canAccess` predicate. Same posture as
734
+ // custom pages — fail-closed on throw.
735
+ if (cfg.dashboardPage) {
736
+ const user = await pilotiq.resolveUser(req)
737
+ if (!await policyAccess(cfg.dashboardPage!, user)) {
738
+ return forbidden(res, wantsJson(req))
739
+ }
740
+ }
741
+ return view('pilotiq.dashboard', await dashboardData(pilotiq, req))
742
+ })
743
+
744
+ // ── File uploads (FileUpload field POST target) ───────
745
+ router.post(`${base}/_uploads`, async (req, res) => {
746
+ return handleUploadRequest(req, res, pilotiq)
747
+ })
748
+
749
+ // ── Plan #15 dashboard widget polling ─────────────────
750
+ // POST ${base}/_widget/:id — re-resolves the dashboard page schema,
751
+ // finds widget by id, runs `getServerData(ctx)`. Body: `{ filter? }`.
752
+ // Mounted unconditionally — widgetData() returns 404 when no
753
+ // dashboard page is registered, so this stays cheap when unused.
754
+ router.post(`${base}/_widget/:id`, async (req, res) => {
755
+ if (cfg.dashboardPage) {
756
+ const user = await pilotiq.resolveUser(req)
757
+ if (!await policyAccess(cfg.dashboardPage!, user)) return forbidden(res, true)
758
+ }
759
+ return handleWidgetData(req, res, pilotiq, { kind: 'panel' }, req.params['id']!)
760
+ })
761
+
762
+ // ── Plan #12 global search ────────────────────────────
763
+ // GET ${base}/_search?q=…&limit=… → { ok, results }
764
+ // No 403 on unrecognised users — `searchAllResources` filters per
765
+ // resource. The Pilotiq.guard() layer above is the panel-level gate.
766
+ router.get(`${base}/_search`, async (req, res) => {
767
+ const query = req.query as Record<string, unknown> | undefined
768
+ const rawQ = query?.['q']
769
+ const q = typeof rawQ === 'string' ? rawQ.slice(0, 200) : ''
770
+ const data = await searchData(pilotiq, q, req)
771
+ return res.json(data)
772
+ })
773
+
774
+ // ── Database notifications (bell-icon dropdown) ───────
775
+ // Only mounted when `Pilotiq.databaseNotifications()` was called.
776
+ // Every route 401s when no user resolves so a non-authenticated
777
+ // request never sees another user's inbox. The `notifiable_type`
778
+ // value is configurable but defaults to `'users'` to match
779
+ // `@rudderjs/notification`'s `DatabaseChannel` writes.
780
+ if (cfg.databaseNotifications?.enabled) {
781
+ const dn = cfg.databaseNotifications
782
+ const notifiableType = dn.notifiableType ?? 'users'
783
+ const pageSize = dn.pageSize ?? 25
784
+
785
+ /** Resolve `{ id }` from the panel's user resolver. Returns null
786
+ * when no user / unknown id — every route then 401s. The user
787
+ * object is opaque to pilotiq; we duck-type `.id`. */
788
+ const resolveUserId = async (req: AppRequest): Promise<string | null> => {
789
+ const user = await pilotiq.resolveUser(req)
790
+ if (!user || typeof user !== 'object') return null
791
+ const id = (user as { id?: unknown }).id
792
+ if (id === undefined || id === null) return null
793
+ return String(id)
794
+ }
795
+
796
+ // GET ${base}/_notifications → { notifications, unreadCount }
797
+ router.get(`${base}/_notifications`, async (req, res) => {
798
+ const id = await resolveUserId(req)
799
+ if (id === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
800
+ const url = new URL(req.url ?? '/', 'http://localhost')
801
+ const unreadOnly = url.searchParams.get('unread') === 'true'
802
+ const limitRaw = Number(url.searchParams.get('limit') ?? pageSize)
803
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 100) : pageSize
804
+ const data = await listDatabaseNotifications({
805
+ notifiableType,
806
+ notifiableId: id,
807
+ limit,
808
+ unreadOnly,
809
+ })
810
+ return res.json({ ok: true, ...data })
811
+ })
812
+
813
+ // POST ${base}/_notifications/:id/read
814
+ router.post(`${base}/_notifications/:id/read`, async (req, res) => {
815
+ const userId = await resolveUserId(req)
816
+ if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
817
+ const rowId = (req.params as Record<string, string | undefined>)['id'] ?? ''
818
+ const updated = await markDatabaseNotificationAsRead(rowId, {
819
+ notifiableType,
820
+ notifiableId: userId,
821
+ })
822
+ return res.json({ ok: updated })
823
+ })
824
+
825
+ // POST ${base}/_notifications/:id/unread
826
+ router.post(`${base}/_notifications/:id/unread`, async (req, res) => {
827
+ const userId = await resolveUserId(req)
828
+ if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
829
+ const rowId = (req.params as Record<string, string | undefined>)['id'] ?? ''
830
+ const updated = await markDatabaseNotificationAsUnread(rowId, {
831
+ notifiableType,
832
+ notifiableId: userId,
833
+ })
834
+ return res.json({ ok: updated })
835
+ })
836
+
837
+ // POST ${base}/_notifications/read-all
838
+ router.post(`${base}/_notifications/read-all`, async (req, res) => {
839
+ const userId = await resolveUserId(req)
840
+ if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
841
+ const count = await markAllDatabaseNotificationsAsRead({
842
+ notifiableType,
843
+ notifiableId: userId,
844
+ })
845
+ return res.json({ ok: true, count })
846
+ })
847
+
848
+ // POST ${base}/_notifications/:id/_action/:actionName
849
+ //
850
+ // Notification action dispatch — looks up the stored action on the
851
+ // row, resolves the named handler against the panel's
852
+ // `notificationHandlers` registry, and runs it with the row's
853
+ // stored payload. Optionally flips `read_at` server-side when the
854
+ // action carried `markAsRead: true`.
855
+ //
856
+ // Defends in depth: 404s on missing row / wrong owner / action
857
+ // missing / non-string handler / unknown registry name. Body is
858
+ // ignored — payload reads exclusively from the stored row, so a
859
+ // tampered client can't inject extra payload keys.
860
+ router.post(`${base}/_notifications/:id/_action/:actionName`, async (req, res) => {
861
+ const user = await pilotiq.resolveUser(req)
862
+ const userId = user && typeof user === 'object'
863
+ ? ((user as { id?: unknown }).id !== undefined && (user as { id?: unknown }).id !== null
864
+ ? String((user as { id: unknown }).id) : null)
865
+ : null
866
+ if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
867
+
868
+ const params = (req.params as Record<string, string | undefined>)
869
+ const result = await dispatchNotificationAction(pilotiq, {
870
+ notificationId: params['id'] ?? '',
871
+ actionName: params['actionName'] ?? '',
872
+ notifiableType,
873
+ notifiableId: userId,
874
+ user,
875
+ request: req,
876
+ })
877
+ if (!result.ok) {
878
+ res.status(result.status)
879
+ return res.json({ ok: false, error: result.error })
880
+ }
881
+ return res.json(result)
882
+ })
883
+
884
+ // Phase 2 — register the broadcast auth callback for private
885
+ // `pilotiq-notifications.<userId>` channels. Soft-fails when
886
+ // `@rudderjs/broadcast` isn't installed; apps that haven't enabled
887
+ // broadcast on the toggle stay quiet either way.
888
+ void registerBroadcastAuth(pilotiq)
889
+ }
890
+
891
+ // ── Resource routes ───────────────────────────────────
892
+ for (const R of cfg.resources) {
893
+ const slug = R.getSlug()
894
+ const resourceBase = resourceBasePath(base, R)
895
+ const pages = R.resolvePages()
896
+
897
+ // Index — GET ${resourceBase}
898
+ if (pages.index) {
899
+ const PageClass = pages.index
900
+ const indexUrl = resourceBase
901
+ router.get(indexUrl, async (req, res) => {
902
+ const user = await pilotiq.resolveUser(req)
903
+ if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
904
+ if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, wantsJson(req))
905
+
906
+ if (R.persistFiltersInSession) {
907
+ const query = (req.query as Record<string, unknown> | undefined) ?? {}
908
+ const sessionSlug = resourceBase.slice(base.length + 1)
909
+ if (Object.keys(query).length === 0) {
910
+ const restoreTab = readPersistedLastTab(req, base, sessionSlug) ?? ''
911
+ const stored = readPersistedListQuery(req, listFiltersKey(base, sessionSlug, restoreTab))
912
+ if (stored) {
913
+ const qs = encodePersistedQuery(stored, restoreTab)
914
+ if (qs !== '') return res.redirect(`${indexUrl}?${qs}`, 302)
915
+ }
916
+ } else {
917
+ const tab = typeof query['tab'] === 'string' ? query['tab'] : ''
918
+ writePersistedListQuery(req, listFiltersKey(base, sessionSlug, tab), query)
919
+ writePersistedLastTab(req, base, sessionSlug, tab)
920
+ }
921
+ }
922
+
923
+ const data = await resourceIndexData(pilotiq, slug, req.query, req)
924
+ return view('pilotiq.slug', data ?? {})
925
+ })
926
+
927
+ router.post(`${indexUrl}/_widget/:id`, async (req, res) => {
928
+ const user = await pilotiq.resolveUser(req)
929
+ if (!await policyAccess(R, user)) return forbidden(res, true)
930
+ if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, true)
931
+ return handleWidgetData(req, res, pilotiq, { kind: 'resource', slug }, req.params['id']!)
932
+ })
933
+
934
+ if (R.deferLoading) {
935
+ router.get(`${indexUrl}/_table`, async (req, res) => {
936
+ const user = await pilotiq.resolveUser(req)
937
+ if (!await policyAccess(R, user)) return forbidden(res, true)
938
+ if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, true)
939
+ const data = await resourceTableData(pilotiq, slug, req.query as Record<string, string>, req)
940
+ if (!data) { res.status(404); return res.json({ ok: false, error: 'Resource not found' }) }
941
+ return res.json({ ok: true, ...data })
942
+ })
943
+ }
944
+
945
+ // Action dispatch — POST ${resourceBase}/_action/:actionName
946
+ router.post(`${indexUrl}/_action/:actionName`, async (req, res) => {
947
+ const user = await pilotiq.resolveUser(req)
948
+ if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
949
+
950
+ const actionName = req.params['actionName']!
951
+ const json = wantsJson(req)
952
+ const body = await readFormBody(req)
953
+ const input = parseActionBody(body)
954
+
955
+ const ctx: SchemaContext = { mode: 'table', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
956
+ const elements = await callPageSchema(PageClass, ctx)
957
+ tagActionDispatch(elements, indexUrl)
958
+ const target = resolveDispatchTarget(elements, actionName)
959
+ if (!target) {
960
+ if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
961
+ res.status(404)
962
+ return res.send(`Action "${actionName}" not found on ${R.label}`)
963
+ }
964
+
965
+ const resolveRecord: ResolveRecord | undefined = R.model
966
+ ? (id: string) => findRecord(R, id, { user })
967
+ : undefined
968
+
969
+ const result = await dispatchAction(target.action, {
970
+ ...input,
971
+ request: req,
972
+ user,
973
+ ...(target.rowField ? { rowField: target.rowField } : {}),
974
+ ...(target.formSchema ? { formSchema: target.formSchema } : {}),
975
+ }, resolveRecord)
976
+ if (!result.ok) {
977
+ if (json) {
978
+ res.status(result.errors ? 422 : 500)
979
+ return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
980
+ }
981
+ res.status(500)
982
+ return res.send(result.error)
983
+ }
984
+ // Download envelope wins over redirect — `Action.export` and friends
985
+ // return the file body inline. Notifications dropped on this branch
986
+ // because the binary response has no JSON envelope to carry them;
987
+ // the file itself is the success signal.
988
+ if (result.download) return sendDownload(res, result.download)
989
+ const redirect = normalizeRedirect(result.redirect, base) ?? indexUrl
990
+ if (json) {
991
+ return res.json({
992
+ ok: true,
993
+ redirect,
994
+ ...(result.notifications ? { notifications: result.notifications } : {}),
995
+ })
996
+ }
997
+ flashNotifications(req, result.notifications)
998
+ return res.redirect(redirect, 303)
999
+ })
1000
+
1001
+ // Reorderable rows — POST ${resourceBase}/_reorder { ids: [] }
1002
+ // Only mounted when `Resource.table()` opts in (boot-time probe
1003
+ // populates `reorderEnabled`).
1004
+ if (reorderEnabled.has(slug)) {
1005
+ router.post(`${indexUrl}/_reorder`, async (req, res) => {
1006
+ const user = await pilotiq.resolveUser(req)
1007
+ if (!await policyAccess(R, user)) return forbidden(res, true)
1008
+ // List-level edit gate. The drop affects many rows at once;
1009
+ // there's no single record to authorize against, so we pass
1010
+ // `undefined` and let user-supplied `canEdit` overrides branch
1011
+ // on `record === undefined` if they want row-level granularity.
1012
+ if (!await checkPolicy(() => R.canEdit(user, undefined))) return forbidden(res, true)
1013
+
1014
+ const body = await readFormBody(req)
1015
+ const raw = (body as { ids?: unknown }).ids
1016
+ if (!Array.isArray(raw) || raw.length === 0) {
1017
+ res.status(400)
1018
+ return res.json({ ok: false, error: 'Missing or empty ids array' })
1019
+ }
1020
+ const ids = raw.filter((id): id is string | number =>
1021
+ typeof id === 'string' || typeof id === 'number',
1022
+ )
1023
+ if (ids.length !== raw.length) {
1024
+ res.status(400)
1025
+ return res.json({ ok: false, error: 'ids must contain only strings or numbers' })
1026
+ }
1027
+
1028
+ try {
1029
+ // Boot already verified `R.model?.reorder` exists; the `!`
1030
+ // assertions are safe.
1031
+ await R.model!.reorder!(ids)
1032
+ return res.json({ ok: true })
1033
+ } catch (err) {
1034
+ res.status(422)
1035
+ return res.json({
1036
+ ok: false,
1037
+ error: err instanceof Error ? err.message : 'Reorder failed',
1038
+ })
1039
+ }
1040
+ })
1041
+ }
1042
+
1043
+ // Editable cell columns — POST ${resourceBase}/:id/_cell/:column
1044
+ // { value: <coerced> }. Only mounted when the resource declares at
1045
+ // least one editable column (boot-time probe populates
1046
+ // `editableEnabled`).
1047
+ if (editableEnabled.has(slug)) {
1048
+ router.post(`${indexUrl}/:id/_cell/:column`, async (req, res) => {
1049
+ const user = await pilotiq.resolveUser(req)
1050
+ if (!await policyAccess(R, user)) return forbidden(res, true)
1051
+
1052
+ const id = req.params['id']!
1053
+ const colName = req.params['column']!
1054
+
1055
+ // Locate the column on the table. We re-derive `Table.make()`
1056
+ // here (same probe shape used by the boot guard + reorder route)
1057
+ // so the column instance carries its validators / discriminator.
1058
+ const probe = R.table(Table.make())
1059
+ const col = (probe.getChildren() ?? [])
1060
+ .find((c): c is Column => c instanceof Column && c.name === colName)
1061
+ if (!col) {
1062
+ res.status(400)
1063
+ return res.json({ ok: false, error: `Unknown column "${colName}"` })
1064
+ }
1065
+ if (!col.isEditable()) {
1066
+ res.status(400)
1067
+ return res.json({ ok: false, error: `Column "${colName}" is not editable` })
1068
+ }
1069
+
1070
+ // Boot already verified `R.model?.update`; the `!` is safe.
1071
+ const record = await findRecord(R, id, { user })
1072
+ if (record === null || record === undefined) {
1073
+ res.status(404)
1074
+ return res.json({ ok: false, error: 'Record not found' })
1075
+ }
1076
+ if (!await checkPolicy(() => R.canEdit(user, record))) return forbidden(res, true)
1077
+
1078
+ const body = await readFormBody(req)
1079
+ const raw = (body as { value?: unknown }).value
1080
+
1081
+ let value: unknown
1082
+ try { value = coerceCellValue(col, raw) }
1083
+ catch (err) {
1084
+ const message = err instanceof CellCoerceError ? err.message
1085
+ : err instanceof Error ? err.message
1086
+ : 'Invalid value'
1087
+ res.status(422)
1088
+ return res.json({ ok: false, errors: { value: [message] } })
1089
+ }
1090
+
1091
+ const errors = await col.runValidators(value, { record })
1092
+ if (errors.length > 0) {
1093
+ res.status(422)
1094
+ return res.json({ ok: false, errors: { value: errors } })
1095
+ }
1096
+
1097
+ try {
1098
+ await R.model!.update(id, { [col.name]: value })
1099
+ } catch (err) {
1100
+ res.status(422)
1101
+ return res.json({
1102
+ ok: false,
1103
+ error: err instanceof Error ? err.message : 'Update failed',
1104
+ })
1105
+ }
1106
+
1107
+ return res.json({ ok: true, value, notifications: [] })
1108
+ })
1109
+ }
1110
+ }
1111
+
1112
+ // Plan #5 — partial-resolve endpoint for create-mode forms.
1113
+ // POST ${resourceBase}/_form/:formId/state
1114
+ if (pages.create) {
1115
+ router.post(`${resourceBase}/_form/:formId/state`, async (req, res) => {
1116
+ const user = await pilotiq.resolveUser(req)
1117
+ if (!await policyAccess(R, user)) return forbidden(res, true)
1118
+ if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
1119
+ const formId = req.params['formId']!
1120
+ return handleFormState(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
1121
+ })
1122
+
1123
+ // Plan #8 — wizard step-validate endpoint for create-mode forms.
1124
+ router.post(`${resourceBase}/_form/:formId/wizard`, async (req, res) => {
1125
+ const user = await pilotiq.resolveUser(req)
1126
+ if (!await policyAccess(R, user)) return forbidden(res, true)
1127
+ if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
1128
+ const formId = req.params['formId']!
1129
+ return handleFormWizard(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
1130
+ })
1131
+
1132
+ // Async-mention endpoint for create-mode forms.
1133
+ router.post(`${resourceBase}/_form/:formId/mentions`, async (req, res) => {
1134
+ const user = await pilotiq.resolveUser(req)
1135
+ if (!await policyAccess(R, user)) return forbidden(res, true)
1136
+ if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
1137
+ const formId = req.params['formId']!
1138
+ return handleFormMentions(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
1139
+ })
1140
+
1141
+ // SelectField inline-create modal endpoint for create-mode forms.
1142
+ router.post(`${resourceBase}/_form/:formId/create-option/:fieldName`, async (req, res) => {
1143
+ const user = await pilotiq.resolveUser(req)
1144
+ if (!await policyAccess(R, user)) return forbidden(res, true)
1145
+ if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
1146
+ const formId = req.params['formId']!
1147
+ const fieldName = req.params['fieldName']!
1148
+ return handleFormCreateOption(req, res, pilotiq, { kind: 'resource-create', slug }, formId, fieldName)
1149
+ })
1150
+ }
1151
+
1152
+ // Plan #5 — partial-resolve endpoint for edit-mode forms.
1153
+ // POST ${resourceBase}/:id/_form/:formId/state
1154
+ if (pages.edit) {
1155
+ router.post(`${resourceBase}/:id/_form/:formId/state`, async (req, res) => {
1156
+ const recordId = req.params['id']!
1157
+ const formId = req.params['formId']!
1158
+ const user = await pilotiq.resolveUser(req)
1159
+ if (!await policyAccess(R, user)) return forbidden(res, true)
1160
+ const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1161
+ if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
1162
+ return handleFormState(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId)
1163
+ })
1164
+
1165
+ // Plan #8 — wizard step-validate endpoint for edit-mode forms.
1166
+ router.post(`${resourceBase}/:id/_form/:formId/wizard`, async (req, res) => {
1167
+ const recordId = req.params['id']!
1168
+ const formId = req.params['formId']!
1169
+ const user = await pilotiq.resolveUser(req)
1170
+ if (!await policyAccess(R, user)) return forbidden(res, true)
1171
+ const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1172
+ if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
1173
+ return handleFormWizard(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId)
1174
+ })
1175
+
1176
+ // Async-mention endpoint for edit-mode forms.
1177
+ router.post(`${resourceBase}/:id/_form/:formId/mentions`, async (req, res) => {
1178
+ const recordId = req.params['id']!
1179
+ const formId = req.params['formId']!
1180
+ const user = await pilotiq.resolveUser(req)
1181
+ if (!await policyAccess(R, user)) return forbidden(res, true)
1182
+ const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1183
+ if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
1184
+ return handleFormMentions(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId)
1185
+ })
1186
+
1187
+ // SelectField inline-create modal endpoint for edit-mode forms.
1188
+ router.post(`${resourceBase}/:id/_form/:formId/create-option/:fieldName`, async (req, res) => {
1189
+ const recordId = req.params['id']!
1190
+ const formId = req.params['formId']!
1191
+ const fieldName = req.params['fieldName']!
1192
+ const user = await pilotiq.resolveUser(req)
1193
+ if (!await policyAccess(R, user)) return forbidden(res, true)
1194
+ const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1195
+ if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
1196
+ return handleFormCreateOption(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId, fieldName)
1197
+ })
1198
+ }
1199
+
1200
+ // Create — GET ${resourceBase}/create
1201
+ if (pages.create) {
1202
+ const PageClass = pages.create
1203
+ const createUrl = `${resourceBase}/create`
1204
+
1205
+ router.get(createUrl, async (req, res) => {
1206
+ const user = await pilotiq.resolveUser(req)
1207
+ if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1208
+ if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
1209
+ const data = await resourceCreateData(pilotiq, slug, undefined, req)
1210
+ return view('pilotiq.resource-create', data ?? {})
1211
+ })
1212
+
1213
+ // Create — POST ${resourceBase}/create
1214
+ router.post(createUrl, async (req, res) => {
1215
+ const user = await pilotiq.resolveUser(req)
1216
+ if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1217
+ if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
1218
+
1219
+ const body = await readFormBody(req)
1220
+ const { values, formId, continueCreate } = splitMeta(body)
1221
+ const json = wantsJson(req)
1222
+
1223
+ const ctx: SchemaContext = { mode: 'create', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
1224
+ const elements = await callPageSchema(PageClass, ctx)
1225
+ tagFormActions(elements, createUrl)
1226
+ const form = selectForm(findForms(elements), formId)
1227
+ if (!form) {
1228
+ if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
1229
+ res.status(404)
1230
+ return res.send('No form found on page')
1231
+ }
1232
+
1233
+ const result = await dispatchFormSubmit(form, values, {
1234
+ values,
1235
+ basePath: base,
1236
+ ...(R.model ? { parentModel: R.model } : {}),
1237
+ })
1238
+
1239
+ if (!result.ok) {
1240
+ if (json) {
1241
+ res.status(422)
1242
+ return res.json({ ok: false, errors: result.errors })
1243
+ }
1244
+ // Re-render through the same builder so the page is identical to GET,
1245
+ // just with values + errors prefilled.
1246
+ const data = await resourceCreateData(pilotiq, slug, { values, errors: result.errors })
1247
+ res.status(422)
1248
+ return view('pilotiq.resource-create', data ?? {})
1249
+ }
1250
+
1251
+ const recordId = (result.record as { id?: unknown })?.id
1252
+ // "Create & create another" — when the secondary submit fired,
1253
+ // route back to the create page with a fresh form. Skips any
1254
+ // user-supplied `redirectAfterSave`: the user clicked the
1255
+ // button asking explicitly to create another, so the
1256
+ // continue-intent wins. `force: true` tells the SPA-mode
1257
+ // FormRenderer to navigate even though the redirect URL
1258
+ // matches the current page (otherwise the same-URL skip
1259
+ // would preserve the just-submitted values on screen).
1260
+ const fallback = continueCreate
1261
+ ? createUrl
1262
+ : recordId !== undefined ? `${resourceBase}/${String(recordId)}/edit` : `${resourceBase}`
1263
+ const redirect = continueCreate
1264
+ ? createUrl
1265
+ : normalizeRedirect(result.redirect, base) ?? fallback
1266
+ if (json) {
1267
+ return res.json({
1268
+ ok: true,
1269
+ redirect,
1270
+ ...(continueCreate ? { force: true } : {}),
1271
+ ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
1272
+ })
1273
+ }
1274
+ flashNotifications(req, result.notifications)
1275
+ return res.redirect(redirect, 303)
1276
+ })
1277
+
1278
+ // Action dispatch — POST ${createUrl}/_action/:actionName
1279
+ // Handles both page-level handler-style actions AND Repeater /
1280
+ // Builder `extraItemActions` rows. The latter pass `_rowPath` in
1281
+ // the body so the dispatcher hydrates `ctx.row` from the form's
1282
+ // coerced values.
1283
+ router.post(`${createUrl}/_action/:actionName`, async (req, res) => {
1284
+ const user = await pilotiq.resolveUser(req)
1285
+ if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1286
+ if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
1287
+
1288
+ const actionName = req.params['actionName']!
1289
+ const json = wantsJson(req)
1290
+ const body = await readFormBody(req)
1291
+ const input = parseActionBody(body)
1292
+
1293
+ const ctx: SchemaContext = { mode: 'create', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
1294
+ const elements = await callPageSchema(PageClass, ctx)
1295
+ tagActionDispatch(elements, createUrl)
1296
+ const target = resolveDispatchTarget(elements, actionName)
1297
+ if (!target) {
1298
+ if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
1299
+ res.status(404)
1300
+ return res.send(`Action "${actionName}" not found on ${R.label}`)
1301
+ }
1302
+
1303
+ const result = await dispatchAction(target.action, {
1304
+ ...input,
1305
+ request: req,
1306
+ user,
1307
+ ...(target.rowField ? { rowField: target.rowField } : {}),
1308
+ ...(target.formSchema ? { formSchema: target.formSchema } : {}),
1309
+ })
1310
+ if (!result.ok) {
1311
+ if (json) {
1312
+ res.status(result.errors ? 422 : 500)
1313
+ return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
1314
+ }
1315
+ res.status(500)
1316
+ return res.send(result.error)
1317
+ }
1318
+ if (result.download) return sendDownload(res, result.download)
1319
+ const redirect = normalizeRedirect(result.redirect, base) ?? createUrl
1320
+ if (json) {
1321
+ return res.json({
1322
+ ok: true,
1323
+ redirect,
1324
+ ...(result.notifications ? { notifications: result.notifications } : {}),
1325
+ })
1326
+ }
1327
+ flashNotifications(req, result.notifications)
1328
+ return res.redirect(redirect, 303)
1329
+ })
1330
+ }
1331
+
1332
+ // View — GET ${resourceBase}/:id (literal `create` matches first via
1333
+ // Hono's literal-over-param routing, so `:id` only catches everything else.)
1334
+ if (pages.view) {
1335
+ router.get(`${resourceBase}/:id`, async (req, res) => {
1336
+ const recordId = req.params['id']!
1337
+ // Hono routes both `/create` and `/:id` against this slot; only the
1338
+ // literal `create` segment hits the create route. Defensive guard:
1339
+ if (recordId === 'create') return // handled by create route
1340
+
1341
+ const user = await pilotiq.resolveUser(req)
1342
+ if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1343
+ // Load the record once so canView can inspect it. Stub `{ id }`
1344
+ // when the resource has no model wired — the user-authored
1345
+ // predicate gets to decide what to do with it.
1346
+ const record = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1347
+ if (!await checkPolicy(() => R.canView(user, record))) return forbidden(res, wantsJson(req))
1348
+
1349
+ const data = await resourceViewData(pilotiq, slug, recordId, req)
1350
+ return view('pilotiq.resource-view', data ?? {})
1351
+ })
1352
+
1353
+ // Delete — POST ${resourceBase}/:id/delete
1354
+ router.post(`${resourceBase}/:id/delete`, async (req, res) => {
1355
+ const recordId = req.params['id']!
1356
+ const json = wantsJson(req)
1357
+ const indexUrl = `${resourceBase}`
1358
+
1359
+ const user = await pilotiq.resolveUser(req)
1360
+ if (!await policyAccess(R, user)) return forbidden(res, json)
1361
+ const record = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1362
+ if (!await checkPolicy(() => R.canDelete(user, record))) return forbidden(res, json)
1363
+
1364
+ try {
1365
+ await R.deleteRecord(recordId)
1366
+ } catch (err) {
1367
+ const message = err instanceof Error ? err.message : 'Delete failed'
1368
+ if (json) {
1369
+ res.status(500)
1370
+ return res.json({ ok: false, error: message })
1371
+ }
1372
+ res.status(500)
1373
+ return res.send(message)
1374
+ }
1375
+ if (json) {
1376
+ // Build a synthetic deletion notification so the SPA path gets
1377
+ // the same toast UX as a JSON-dispatched action handler. The
1378
+ // form-method 303 path doesn't have the form-lifecycle toast
1379
+ // pipeline, so we surface confirmation here. Plan #13: use
1380
+ // "moved to trash" framing on soft-delete resources so users
1381
+ // know the row is recoverable.
1382
+ const title = R.softDeletes
1383
+ ? `${R.labelSingular} moved to trash`
1384
+ : `${R.labelSingular} deleted`
1385
+ const notifications = [
1386
+ { id: `n-delete-${recordId}-${Date.now()}`, type: 'success', title },
1387
+ ]
1388
+ return res.json({ ok: true, redirect: indexUrl, notifications })
1389
+ }
1390
+ return res.redirect(indexUrl, 303)
1391
+ })
1392
+ }
1393
+
1394
+ // ─── Plan #13 soft-delete routes (restore / force-delete) ─────
1395
+ // Both routes opt-in only when `Resource.softDeletes = true`. They
1396
+ // load the target row through `withTrashed()` so the lookup finds
1397
+ // currently-trashed records (which the default scope hides). The
1398
+ // `restore` route undoes a prior soft-delete; `force-delete`
1399
+ // bypasses soft-delete entirely.
1400
+ if (R.softDeletes) {
1401
+ // Boot-time guard — yell loudly if the rudder ORM model isn't
1402
+ // wired up. Keeps "why didn't restore work?" debug sessions
1403
+ // short. Pilotiq's flag and rudder's flag are deliberately
1404
+ // independent (see plan doc).
1405
+ if (!R.model) {
1406
+ throw new Error(
1407
+ `[Pilotiq] ${R.name}: softDeletes = true requires a Resource.model. Wire one up or unset softDeletes.`,
1408
+ )
1409
+ }
1410
+ if (typeof R.model.restore !== 'function' || typeof R.model.forceDelete !== 'function') {
1411
+ throw new Error(
1412
+ `[Pilotiq] ${R.name}: softDeletes = true but model.restore / model.forceDelete are missing. ` +
1413
+ `Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`,
1414
+ )
1415
+ }
1416
+
1417
+ const M = R.model
1418
+ const pk = (M.primaryKey ?? 'id') as string
1419
+
1420
+ // Helper — load a row through `withTrashed` so currently-trashed
1421
+ // records resolve. Returns undefined when the lookup misses (route
1422
+ // converts to 404).
1423
+ const loadTrashable = async (id: string): Promise<unknown> => {
1424
+ const q = M.query()
1425
+ if (typeof q.withTrashed !== 'function') return M.find(id).catch(() => undefined)
1426
+ const result = await q.withTrashed()
1427
+ .where(pk, '=', id)
1428
+ .paginate(1, 1)
1429
+ .catch(() => ({ data: [] as unknown[] }))
1430
+ return Array.isArray(result.data) ? result.data[0] : undefined
1431
+ }
1432
+
1433
+ // Restore — POST ${resourceBase}/:id/restore
1434
+ router.post(`${resourceBase}/:id/restore`, async (req, res) => {
1435
+ const recordId = req.params['id']!
1436
+ const json = wantsJson(req)
1437
+ const indexUrl = `${resourceBase}`
1438
+
1439
+ const user = await pilotiq.resolveUser(req)
1440
+ if (!await policyAccess(R, user)) return forbidden(res, json)
1441
+ const record = await loadTrashable(recordId)
1442
+ if (!record) {
1443
+ res.status(404)
1444
+ return json ? res.json({ ok: false, error: 'Not found' }) : res.send('Not found')
1445
+ }
1446
+ if (!await checkPolicy(() => R.canRestore(user, record))) return forbidden(res, json)
1447
+
1448
+ try {
1449
+ await M.restore!(recordId)
1450
+ } catch (err) {
1451
+ const message = err instanceof Error ? err.message : 'Restore failed'
1452
+ res.status(500)
1453
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
1454
+ }
1455
+
1456
+ if (json) {
1457
+ const notifications = [
1458
+ { id: `n-restore-${recordId}-${Date.now()}`, type: 'success', title: `${R.labelSingular} restored` },
1459
+ ]
1460
+ return res.json({ ok: true, redirect: indexUrl, notifications })
1461
+ }
1462
+ return res.redirect(indexUrl, 303)
1463
+ })
1464
+
1465
+ // Force-delete — POST ${resourceBase}/:id/force-delete
1466
+ router.post(`${resourceBase}/:id/force-delete`, async (req, res) => {
1467
+ const recordId = req.params['id']!
1468
+ const json = wantsJson(req)
1469
+ const indexUrl = `${resourceBase}`
1470
+
1471
+ const user = await pilotiq.resolveUser(req)
1472
+ if (!await policyAccess(R, user)) return forbidden(res, json)
1473
+ const record = await loadTrashable(recordId)
1474
+ if (!record) {
1475
+ res.status(404)
1476
+ return json ? res.json({ ok: false, error: 'Not found' }) : res.send('Not found')
1477
+ }
1478
+ if (!await checkPolicy(() => R.canForceDelete(user, record))) return forbidden(res, json)
1479
+
1480
+ try {
1481
+ await M.forceDelete!(recordId)
1482
+ } catch (err) {
1483
+ const message = err instanceof Error ? err.message : 'Force-delete failed'
1484
+ res.status(500)
1485
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
1486
+ }
1487
+
1488
+ if (json) {
1489
+ const notifications = [
1490
+ { id: `n-fdelete-${recordId}-${Date.now()}`, type: 'success', title: `${R.labelSingular} permanently deleted` },
1491
+ ]
1492
+ return res.json({ ok: true, redirect: indexUrl, notifications })
1493
+ }
1494
+ return res.redirect(indexUrl, 303)
1495
+ })
1496
+ }
1497
+
1498
+ // Edit — GET ${resourceBase}/:id/edit
1499
+ if (pages.edit) {
1500
+ const PageClass = pages.edit
1501
+
1502
+ router.get(`${resourceBase}/:id/edit`, async (req, res) => {
1503
+ const recordId = req.params['id']!
1504
+ const user = await pilotiq.resolveUser(req)
1505
+ if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1506
+ const record = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1507
+ if (!await checkPolicy(() => R.canEdit(user, record))) return forbidden(res, wantsJson(req))
1508
+
1509
+ const data = await resourceEditData(pilotiq, slug, recordId, undefined, req)
1510
+ return view('pilotiq.resource-edit', data ?? {})
1511
+ })
1512
+
1513
+ // Edit — POST ${resourceBase}/:id/edit
1514
+ router.post(`${resourceBase}/:id/edit`, async (req, res) => {
1515
+ const recordId = req.params['id']!
1516
+ const editUrl = `${resourceBase}/${recordId}/edit`
1517
+ const body = await readFormBody(req)
1518
+ const { values, formId } = splitMeta(body)
1519
+ const json = wantsJson(req)
1520
+
1521
+ const user = await pilotiq.resolveUser(req)
1522
+ if (!await policyAccess(R, user)) return forbidden(res, json)
1523
+ const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1524
+ if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, json)
1525
+
1526
+ const ctx: SchemaContext = { mode: 'edit', recordId, basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
1527
+ const elements = await callPageSchema(PageClass, ctx)
1528
+ tagFormActions(elements, editUrl)
1529
+ const form = selectForm(findForms(elements), formId)
1530
+ if (!form) {
1531
+ if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
1532
+ res.status(404)
1533
+ return res.send('No form found on page')
1534
+ }
1535
+
1536
+ // Try to load the record so validators with cross-field rules see it.
1537
+ let record: unknown = undefined
1538
+ if (form.getLoadRecord()) {
1539
+ try { record = await form.getLoadRecord()!(recordId, { values }) } catch { /* ignore */ }
1540
+ }
1541
+
1542
+ const result = await dispatchFormSubmit(
1543
+ form,
1544
+ values,
1545
+ {
1546
+ values,
1547
+ basePath: base,
1548
+ ...(record !== undefined ? { record } : {}),
1549
+ ...(R.model ? { parentModel: R.model } : {}),
1550
+ },
1551
+ )
1552
+
1553
+ if (!result.ok) {
1554
+ if (json) {
1555
+ res.status(422)
1556
+ return res.json({ ok: false, errors: result.errors })
1557
+ }
1558
+ const data = await resourceEditData(pilotiq, slug, recordId, { values, errors: result.errors })
1559
+ res.status(422)
1560
+ return view('pilotiq.resource-edit', data ?? {})
1561
+ }
1562
+
1563
+ const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
1564
+ if (json) {
1565
+ return res.json({
1566
+ ok: true,
1567
+ redirect,
1568
+ ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
1569
+ })
1570
+ }
1571
+ flashNotifications(req, result.notifications)
1572
+ return res.redirect(redirect, 303)
1573
+ })
1574
+
1575
+ // Action dispatch — POST ${editUrl}/_action/:actionName
1576
+ // Same shape as the create-page _action route. The `:id` segment
1577
+ // gates record-aware policy (canEdit per record); row-scoped
1578
+ // dispatch reuses the form schema we resolve here for `coerceFormValues`.
1579
+ router.post(`${resourceBase}/:id/_action/:actionName`, async (req, res) => {
1580
+ const recordId = req.params['id']!
1581
+ // Hono routes `/edit` and `/delete` against this slot too — bail
1582
+ // out so the dedicated handlers downstream pick them up. The
1583
+ // `:actionName` capture catches anything; the explicit guard
1584
+ // mirrors the view-route `recordId === 'create'` defensive branch.
1585
+ const actionName = req.params['actionName']!
1586
+
1587
+ const user = await pilotiq.resolveUser(req)
1588
+ if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1589
+ const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1590
+ if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, wantsJson(req))
1591
+
1592
+ const json = wantsJson(req)
1593
+ const body = await readFormBody(req)
1594
+ const input = parseActionBody(body)
1595
+
1596
+ const editUrl = `${resourceBase}/${recordId}/edit`
1597
+ const ctx: SchemaContext = { mode: 'edit', recordId, basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
1598
+ const elements = await callPageSchema(PageClass, ctx)
1599
+ tagActionDispatch(elements, editUrl)
1600
+ const target = resolveDispatchTarget(elements, actionName)
1601
+ if (!target) {
1602
+ if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
1603
+ res.status(404)
1604
+ return res.send(`Action "${actionName}" not found on ${R.label}`)
1605
+ }
1606
+
1607
+ const resolveRecord: ResolveRecord | undefined = R.model
1608
+ ? (id: string) => findRecord(R, id, { user })
1609
+ : undefined
1610
+
1611
+ const result = await dispatchAction(target.action, {
1612
+ ...input,
1613
+ request: req,
1614
+ user,
1615
+ ...(target.rowField ? { rowField: target.rowField } : {}),
1616
+ ...(target.formSchema ? { formSchema: target.formSchema } : {}),
1617
+ }, resolveRecord)
1618
+ if (!result.ok) {
1619
+ if (json) {
1620
+ res.status(result.errors ? 422 : 500)
1621
+ return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
1622
+ }
1623
+ res.status(500)
1624
+ return res.send(result.error)
1625
+ }
1626
+ if (result.download) return sendDownload(res, result.download)
1627
+ const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
1628
+ if (json) {
1629
+ return res.json({
1630
+ ok: true,
1631
+ redirect,
1632
+ ...(result.notifications ? { notifications: result.notifications } : {}),
1633
+ })
1634
+ }
1635
+ flashNotifications(req, result.notifications)
1636
+ return res.redirect(redirect, 303)
1637
+ })
1638
+ }
1639
+
1640
+ // ── Plan #11 relation manager routes ───────────────
1641
+ // Per-manager: list, create (GET/POST), edit (GET/POST), delete (POST).
1642
+ // Mounted under ${resourceBase}/:id/${rel} — the `:id` segment is the
1643
+ // PARENT record id; the `:childId` segment (where present) is the
1644
+ // related record's id. Authorization runs in two layers: parent
1645
+ // canAccess + canEdit(parent), then manager-scoped can*.
1646
+ for (const M of R.relations()) {
1647
+ const rel = M.getRelationship()
1648
+ const parentBase = `${resourceBase}/:id/${rel}`
1649
+
1650
+ // Read the relation type once at registration so the (R, M)-
1651
+ // scoped closures all see the same mode without re-reading the
1652
+ // relations map per request. `R.model` is asserted by
1653
+ // `requireParent` at request time; here it may legitimately be
1654
+ // missing during late binding, in which case we fall back to
1655
+ // 'hasMany' (the safe default — no special action injection / no
1656
+ // factory short-circuiting). See `normalizeRelationMode` for the
1657
+ // M2M / polymorphic mappings.
1658
+ const relationType = R.model ? getRelationType(R.model, rel) : 'hasMany'
1659
+ const mode: RelationMode = normalizeRelationMode(relationType)
1660
+
1661
+ // Common policy prelude: load parent, gate access. Returns the
1662
+ // parent record on success or a thrown 403/404 response. Returns
1663
+ // `undefined` when the route should bail out (response already sent).
1664
+ const requireParent = async (req: AppRequest, res: AppResponse, json: boolean): Promise<{ user: unknown; parent: unknown; recordId: string } | undefined> => {
1665
+ const recordId = req.params['id']!
1666
+ const user = await pilotiq.resolveUser(req)
1667
+ if (!await policyAccess(R, user)) { forbidden(res, json); return undefined }
1668
+ if (!R.model) {
1669
+ res.status(500)
1670
+ if (json) res.json({ ok: false, error: `Resource "${R.name}" has relations but no static model` })
1671
+ else res.send(`Resource "${R.name}" has relations but no static model`)
1672
+ return undefined
1673
+ }
1674
+ const parent = await findRecord(R, recordId, { user }).catch(() => undefined)
1675
+ if (!parent) { res.status(404); if (json) res.json({ ok: false, error: 'Parent not found' }); else res.send('Parent not found'); return undefined }
1676
+ if (!await checkPolicy(() => R.canEdit(user, parent))) { forbidden(res, json); return undefined }
1677
+ return { user, parent, recordId }
1678
+ }
1679
+
1680
+ // List — GET ${resourceBase}/:id/${rel}
1681
+ // Manager-level canViewAny is enforced inside relationManagerData via
1682
+ // safeManagerPolicy (with related-resource fall-through). We just
1683
+ // surface the {ok:false,status:403} from the data builder as 403.
1684
+ router.get(parentBase, async (req, res) => {
1685
+ const json = wantsJson(req)
1686
+ const ctx = await requireParent(req, res, json)
1687
+ if (!ctx) return
1688
+ const data = await relationManagerData(pilotiq, {
1689
+ kind: 'relation-list', slug, recordId: ctx.recordId, relationship: rel, query: req.query as Record<string, string>,
1690
+ }, req)
1691
+ if (data === null) { res.status(404); return res.send('Not found') }
1692
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
1693
+ return view('pilotiq.relation-list', data)
1694
+ })
1695
+
1696
+ // Create — GET ${resourceBase}/:id/${rel}/create
1697
+ router.get(`${parentBase}/create`, async (req, res) => {
1698
+ const json = wantsJson(req)
1699
+ const ctx = await requireParent(req, res, json)
1700
+ if (!ctx) return
1701
+ const data = await relationManagerData(pilotiq, {
1702
+ kind: 'relation-create', slug, recordId: ctx.recordId, relationship: rel,
1703
+ }, req)
1704
+ if (data === null) { res.status(404); return res.send('Not found') }
1705
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
1706
+ return view('pilotiq.relation-create', data)
1707
+ })
1708
+
1709
+ // Create submit — POST ${resourceBase}/:id/${rel}/create
1710
+ router.post(`${parentBase}/create`, async (req, res) => {
1711
+ const json = wantsJson(req)
1712
+ const pre = await requireParent(req, res, json)
1713
+ if (!pre) return
1714
+
1715
+ const Related = findRelatedResource(M, R, cfg)
1716
+ if (!Related) {
1717
+ res.status(500)
1718
+ const msg = `RelationManager ${M.name}: cannot resolve related Resource for create`
1719
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
1720
+ }
1721
+ if (!await safeManagerPolicy(M, 'canCreate', Related, pre.user, pre.parent)) return forbidden(res, json)
1722
+
1723
+ const body = await readFormBody(req)
1724
+ const { values } = splitMeta(body)
1725
+
1726
+ const createUrl = `${parentBase}/create`.replace(':id', pre.recordId)
1727
+ const listUrl = parentBase.replace(':id', pre.recordId)
1728
+ const form = M.form(Form.make(), {
1729
+ basePath: base,
1730
+ parentSlug: slug,
1731
+ parentId: pre.recordId,
1732
+ relationship: rel,
1733
+ parentRecord: pre.parent,
1734
+ related: Related,
1735
+ mode,
1736
+ })
1737
+ if (Related.model) {
1738
+ if (!form.getSave()) form.save(modelSave(Related.model))
1739
+ if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related))
1740
+ }
1741
+
1742
+ // Polymorphic auto-injection — when the parent's relation entry
1743
+ // is `morphMany` / `morphOne`, fill the `{morphName}Id` and
1744
+ // `{morphName}Type` columns on the child before persistence.
1745
+ // Compose with any user-supplied `mutateDataBeforeCreate` and
1746
+ // run AFTER it so morph values overwrite anything the form
1747
+ // body or user hook might have set — the parent record is the
1748
+ // single source of truth for who owns the new child, and a
1749
+ // submitted form field cannot be allowed to tamper with that.
1750
+ if (mode === 'morphMany' && R.model) {
1751
+ const morphDesc = getMorphRelationDescriptor(R.model, rel)
1752
+ if (!morphDesc) {
1753
+ res.status(500)
1754
+ const msg = `RelationManager ${M.name}: relations[${JSON.stringify(rel)}] reports a polymorphic type but is missing morphName.`
1755
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
1756
+ }
1757
+ const morphPayload = computeMorphPayload(pre.parent, morphDesc)
1758
+ const existing = form.getMutateDataBeforeCreate()
1759
+ form.mutateDataBeforeCreate(async (data, ctx) => {
1760
+ const next = existing ? await existing(data, ctx) : data
1761
+ return { ...next, ...morphPayload }
1762
+ })
1763
+ }
1764
+
1765
+ // Stamp parent context onto FormContext so user hooks
1766
+ // (mutateDataBeforeCreate, redirectAfterSave, etc.) can default
1767
+ // foreign-key columns or build URLs from the parent.
1768
+ const formCtx = {
1769
+ values,
1770
+ basePath: base,
1771
+ parent: pre.parent,
1772
+ parentId: pre.recordId,
1773
+ relationship: rel,
1774
+ }
1775
+
1776
+ const result = await dispatchFormSubmit(form, values, formCtx)
1777
+ if (!result.ok) {
1778
+ if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
1779
+ const data = await relationManagerData(pilotiq, {
1780
+ kind: 'relation-create', slug, recordId: pre.recordId, relationship: rel,
1781
+ prefill: { values, errors: result.errors ?? {} },
1782
+ }, req)
1783
+ res.status(422)
1784
+ return view('pilotiq.relation-create', data ?? {})
1785
+ }
1786
+
1787
+ const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
1788
+ if (json) {
1789
+ return res.json({
1790
+ ok: true, redirect,
1791
+ ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
1792
+ })
1793
+ }
1794
+ flashNotifications(req, result.notifications)
1795
+ return res.redirect(redirect, 303)
1796
+ })
1797
+
1798
+ // View — GET ${resourceBase}/:id/${rel}/:childId (Phase A nested
1799
+ // resources). 5-segment URL. The literal `${parentBase}/create`
1800
+ // route is registered above and Hono prefers static segments over
1801
+ // wildcards, but the `childId === 'create'` guard belt-and-suspenders
1802
+ // against any router that doesn't.
1803
+ router.get(`${parentBase}/:childId`, async (req, res) => {
1804
+ const json = wantsJson(req)
1805
+ const pre = await requireParent(req, res, json)
1806
+ if (!pre) return
1807
+ const childId = req.params['childId']!
1808
+ if (childId === 'create') { res.status(404); return res.send('Not found') }
1809
+ const data = await relationManagerData(pilotiq, {
1810
+ kind: 'relation-view', slug, recordId: pre.recordId, relationship: rel, childId,
1811
+ }, req)
1812
+ if (data === null) { res.status(404); return res.send('Not found') }
1813
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
1814
+ return view('pilotiq.relation-view', data)
1815
+ })
1816
+
1817
+ // Edit — GET ${resourceBase}/:id/${rel}/:childId/edit
1818
+ router.get(`${parentBase}/:childId/edit`, async (req, res) => {
1819
+ const json = wantsJson(req)
1820
+ const pre = await requireParent(req, res, json)
1821
+ if (!pre) return
1822
+ const childId = req.params['childId']!
1823
+ const data = await relationManagerData(pilotiq, {
1824
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
1825
+ }, req)
1826
+ if (data === null) { res.status(404); return res.send('Not found') }
1827
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
1828
+ return view('pilotiq.relation-edit', data)
1829
+ })
1830
+
1831
+ // Edit submit — POST ${resourceBase}/:id/${rel}/:childId/edit
1832
+ router.post(`${parentBase}/:childId/edit`, async (req, res) => {
1833
+ const json = wantsJson(req)
1834
+ const pre = await requireParent(req, res, json)
1835
+ if (!pre) return
1836
+ const childId = req.params['childId']!
1837
+
1838
+ const Related = findRelatedResource(M, R, cfg)
1839
+ if (!Related?.model) {
1840
+ res.status(500)
1841
+ const msg = `RelationManager ${M.name}: cannot resolve related Resource for edit`
1842
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
1843
+ }
1844
+
1845
+ // IDOR + load via the data builder's gating: re-use it to verify
1846
+ // the child belongs to this parent, then do the form submit.
1847
+ const childCheck = await relationManagerData(pilotiq, {
1848
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
1849
+ }, req)
1850
+ if (childCheck === null) { res.status(404); return res.send('Not found') }
1851
+ if ('ok' in childCheck && childCheck.ok === false) return forbidden(res, json)
1852
+
1853
+ const body = await readFormBody(req)
1854
+ const { values } = splitMeta(body)
1855
+
1856
+ const editUrl = `${parentBase}/${childId}/edit`.replace(':id', pre.recordId)
1857
+ const form = M.form(Form.make(), {
1858
+ basePath: base,
1859
+ parentSlug: slug,
1860
+ parentId: pre.recordId,
1861
+ relationship: rel,
1862
+ parentRecord: pre.parent,
1863
+ related: Related,
1864
+ mode,
1865
+ })
1866
+ if (!form.getSave()) form.save(modelSave(Related.model))
1867
+ if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related))
1868
+
1869
+ // Re-load child for FormContext so cross-field validators see it.
1870
+ let child: unknown = undefined
1871
+ try { child = await findRecord(Related, childId, { user: pre.user }) } catch { /* ignore */ }
1872
+ if (!child) { res.status(404); return res.send('Not found') }
1873
+
1874
+ // Polymorphic re-stamp on update — same posture as the create
1875
+ // path. Re-injecting the morph columns from the live parent
1876
+ // record ensures a tampered body (`commentableId=…` /
1877
+ // `commentableType=…` posted by an attacker) can't reassign
1878
+ // the child to another polymorphic parent. Composed AFTER any
1879
+ // user `mutateDataBeforeUpdate` so the framework wins.
1880
+ if (mode === 'morphMany' && R.model) {
1881
+ const morphDesc = getMorphRelationDescriptor(R.model, rel)
1882
+ if (morphDesc) {
1883
+ const morphPayload = computeMorphPayload(pre.parent, morphDesc)
1884
+ const existing = form.getMutateDataBeforeUpdate()
1885
+ form.mutateDataBeforeUpdate(async (data, ctx) => {
1886
+ const next = existing ? await existing(data, ctx) : data
1887
+ return { ...next, ...morphPayload }
1888
+ })
1889
+ }
1890
+ }
1891
+
1892
+ const formCtx = {
1893
+ values,
1894
+ basePath: base,
1895
+ record: child,
1896
+ parent: pre.parent,
1897
+ parentId: pre.recordId,
1898
+ relationship: rel,
1899
+ }
1900
+
1901
+ const result = await dispatchFormSubmit(form, values, formCtx)
1902
+ if (!result.ok) {
1903
+ if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
1904
+ const data = await relationManagerData(pilotiq, {
1905
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
1906
+ prefill: { values, errors: result.errors ?? {} },
1907
+ }, req)
1908
+ res.status(422)
1909
+ return view('pilotiq.relation-edit', data ?? {})
1910
+ }
1911
+
1912
+ const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
1913
+ if (json) {
1914
+ return res.json({
1915
+ ok: true, redirect,
1916
+ ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
1917
+ })
1918
+ }
1919
+ flashNotifications(req, result.notifications)
1920
+ return res.redirect(redirect, 303)
1921
+ })
1922
+
1923
+ // Delete — POST ${resourceBase}/:id/${rel}/:childId/delete
1924
+ router.post(`${parentBase}/:childId/delete`, async (req, res) => {
1925
+ const json = wantsJson(req)
1926
+ const pre = await requireParent(req, res, json)
1927
+ if (!pre) return
1928
+ const childId = req.params['childId']!
1929
+
1930
+ const Related = findRelatedResource(M, R, cfg)
1931
+ if (!Related?.model) {
1932
+ res.status(500)
1933
+ const msg = `RelationManager ${M.name}: cannot resolve related Resource for delete`
1934
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
1935
+ }
1936
+
1937
+ // Anti-IDOR: re-use the data builder's child-belongs check.
1938
+ const childCheck = await relationManagerData(pilotiq, {
1939
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
1940
+ }, req)
1941
+ if (childCheck === null) { res.status(404); return res.send('Not found') }
1942
+ if ('ok' in childCheck && childCheck.ok === false) return forbidden(res, json)
1943
+
1944
+ const child = await findRecord(Related, childId, { user: pre.user }).catch(() => undefined)
1945
+ if (!child) { res.status(404); return res.send('Not found') }
1946
+
1947
+ if (!await safeManagerPolicy(M, 'canDelete', Related, pre.user, pre.parent, child)) return forbidden(res, json)
1948
+
1949
+ const listUrl = parentBase.replace(':id', pre.recordId)
1950
+ try {
1951
+ await Related.model.delete(childId)
1952
+ } catch (err) {
1953
+ const message = err instanceof Error ? err.message : 'Delete failed'
1954
+ res.status(500)
1955
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
1956
+ }
1957
+
1958
+ if (json) {
1959
+ const notifications = [
1960
+ { id: `n-rdelete-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} deleted` },
1961
+ ]
1962
+ return res.json({ ok: true, redirect: listUrl, notifications })
1963
+ }
1964
+ return res.redirect(listUrl, 303)
1965
+ })
1966
+
1967
+ // ── Plan #13 polish — relation restore / force-delete ─────
1968
+ // Mirror the resource-side soft-delete routes, scoped under the
1969
+ // parent record. Both routes opt in only when the related Resource
1970
+ // has `softDeletes = true` AND its model carries `restore` /
1971
+ // `forceDelete`. Two-layer auth: parent canAccess + canEdit, then
1972
+ // manager `canRestore / canForceDelete` (with related-Resource
1973
+ // fall-through). IDOR check re-runs the parent's relation query
1974
+ // through `withTrashed()` so trashed children still resolve.
1975
+ const RelatedForSoft = findRelatedResource(M, R, cfg)
1976
+ if (RelatedForSoft?.softDeletes) {
1977
+ const RM = RelatedForSoft.model
1978
+ if (!RM) {
1979
+ throw new Error(
1980
+ `[Pilotiq] RelationManager ${M.name} on ${R.name}: related Resource ${RelatedForSoft.name} has softDeletes = true but no model. ` +
1981
+ `Wire one up or unset softDeletes.`,
1982
+ )
1983
+ }
1984
+ if (typeof RM.restore !== 'function' || typeof RM.forceDelete !== 'function') {
1985
+ throw new Error(
1986
+ `[Pilotiq] RelationManager ${M.name} on ${R.name}: related Resource ${RelatedForSoft.name} has softDeletes = true but model.restore / model.forceDelete are missing. ` +
1987
+ `Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`,
1988
+ )
1989
+ }
1990
+
1991
+ // IDOR-safe load through the parent's relation query, broadened
1992
+ // with `withTrashed()` so currently-trashed children resolve.
1993
+ // Returns undefined when the child doesn't belong to this parent
1994
+ // (under the broadened scope) or the lookup misses.
1995
+ const loadTrashableChild = async (parent: unknown, childId: string): Promise<unknown> => {
1996
+ if (!R.model) return undefined
1997
+ const pk = (RM.primaryKey ?? 'id') as string
1998
+ try {
1999
+ const q: import('./orm/modelDefaults.js').ModelQuery = R.model.relatedQuery
2000
+ ? R.model.relatedQuery(parent, rel)
2001
+ : (parent as { related: (n: string) => import('./orm/modelDefaults.js').ModelQuery }).related(rel)
2002
+ const broadened = typeof q.withTrashed === 'function' ? q.withTrashed() : q
2003
+ const result = await broadened.where(pk, '=', childId).paginate(1, 1)
2004
+ return Array.isArray(result.data) ? result.data[0] : undefined
2005
+ } catch {
2006
+ return undefined
2007
+ }
2008
+ }
2009
+
2010
+ // Restore — POST ${resourceBase}/:id/${rel}/:childId/restore
2011
+ router.post(`${parentBase}/:childId/restore`, async (req, res) => {
2012
+ const json = wantsJson(req)
2013
+ const pre = await requireParent(req, res, json)
2014
+ if (!pre) return
2015
+ const childId = req.params['childId']!
2016
+
2017
+ const child = await loadTrashableChild(pre.parent, childId)
2018
+ if (!child) { res.status(404); return res.send('Not found') }
2019
+
2020
+ if (!await safeManagerPolicy(M, 'canRestore', RelatedForSoft, pre.user, pre.parent, child)) return forbidden(res, json)
2021
+
2022
+ const listUrl = parentBase.replace(':id', pre.recordId)
2023
+ try {
2024
+ await RM.restore!(childId)
2025
+ } catch (err) {
2026
+ const message = err instanceof Error ? err.message : 'Restore failed'
2027
+ res.status(500)
2028
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
2029
+ }
2030
+
2031
+ if (json) {
2032
+ const notifications = [
2033
+ { id: `n-rrestore-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} restored` },
2034
+ ]
2035
+ return res.json({ ok: true, redirect: listUrl, notifications })
2036
+ }
2037
+ return res.redirect(listUrl, 303)
2038
+ })
2039
+
2040
+ // Force-delete — POST ${resourceBase}/:id/${rel}/:childId/force-delete
2041
+ router.post(`${parentBase}/:childId/force-delete`, async (req, res) => {
2042
+ const json = wantsJson(req)
2043
+ const pre = await requireParent(req, res, json)
2044
+ if (!pre) return
2045
+ const childId = req.params['childId']!
2046
+
2047
+ const child = await loadTrashableChild(pre.parent, childId)
2048
+ if (!child) { res.status(404); return res.send('Not found') }
2049
+
2050
+ if (!await safeManagerPolicy(M, 'canForceDelete', RelatedForSoft, pre.user, pre.parent, child)) return forbidden(res, json)
2051
+
2052
+ const listUrl = parentBase.replace(':id', pre.recordId)
2053
+ try {
2054
+ await RM.forceDelete!(childId)
2055
+ } catch (err) {
2056
+ const message = err instanceof Error ? err.message : 'Force-delete failed'
2057
+ res.status(500)
2058
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
2059
+ }
2060
+
2061
+ if (json) {
2062
+ const notifications = [
2063
+ { id: `n-rforce-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} permanently deleted` },
2064
+ ]
2065
+ return res.json({ ok: true, redirect: listUrl, notifications })
2066
+ }
2067
+ return res.redirect(listUrl, 303)
2068
+ })
2069
+ }
2070
+
2071
+ // ── M2M follow-up — manager-scoped action dispatch + detach ─────
2072
+ // Two new routes per relation manager. Mounted unconditionally
2073
+ // (even on hasMany managers) because handler-style actions are
2074
+ // useful beyond M2M — any user-defined `Action.handler(...)` on a
2075
+ // manager table needs a place to dispatch. The detach route is
2076
+ // M2M-specific but cheap enough to register either way; non-M2M
2077
+ // managers' `Action.relationDetach` factories return `visible=false`
2078
+ // anyway, so the URL is unreachable in practice.
2079
+
2080
+ // Action dispatch — POST ${parentBase}/_action/:actionName
2081
+ // Resolves the manager's table elements, finds the named action,
2082
+ // and dispatches it with `ctx.relation = { parent, parentId, rel }`
2083
+ // so M2M handlers can call `parent.related(rel).attach / detach`.
2084
+ // Records hydrate against the related model (the rows visible in
2085
+ // the manager's table are related-model records).
2086
+ router.post(`${parentBase}/_action/:actionName`, async (req, res) => {
2087
+ const json = wantsJson(req)
2088
+ const pre = await requireParent(req, res, json)
2089
+ if (!pre) return
2090
+
2091
+ const Related = findRelatedResource(M, R, cfg)
2092
+ const actionName = req.params['actionName']!
2093
+ const body = await readFormBody(req)
2094
+ const input = parseActionBody(body)
2095
+
2096
+ // Rebuild the manager's table so the dispatcher can find the
2097
+ // action by name. Pure recreation — same context the page-data
2098
+ // builder uses — so factories that close over `ctx` (URL,
2099
+ // mode, parent record) see the same shape as at page render.
2100
+ const managerCtx = {
2101
+ basePath: base,
2102
+ parentSlug: slug,
2103
+ parentId: pre.recordId,
2104
+ relationship: rel,
2105
+ parentRecord: pre.parent,
2106
+ related: Related,
2107
+ mode,
2108
+ }
2109
+ const table = M.table(Table.make(), managerCtx)
2110
+ const elements: import('./schema/Element.js').Element[] = [table]
2111
+ // Stamp dispatch URLs so any nested action factories that read
2112
+ // `dispatchUrl` (rare — most read it from the meta at render
2113
+ // time) still see something sensible.
2114
+ const listUrl = parentBase.replace(':id', pre.recordId)
2115
+ tagActionDispatch(elements, listUrl)
2116
+
2117
+ const target = resolveDispatchTarget(elements, actionName)
2118
+ if (!target) {
2119
+ if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
2120
+ res.status(404)
2121
+ return res.send(`Action "${actionName}" not found on ${M.name}`)
2122
+ }
2123
+
2124
+ const resolveRecord: ResolveRecord | undefined = Related?.model
2125
+ ? (id: string) => Related.model!.find(id)
2126
+ : undefined
2127
+
2128
+ const result = await dispatchAction(target.action, {
2129
+ ...input,
2130
+ request: req,
2131
+ user: pre.user,
2132
+ relation: { parent: pre.parent, parentId: pre.recordId, relationship: rel },
2133
+ ...(target.rowField ? { rowField: target.rowField } : {}),
2134
+ ...(target.formSchema ? { formSchema: target.formSchema } : {}),
2135
+ }, resolveRecord)
2136
+
2137
+ if (!result.ok) {
2138
+ if (json) {
2139
+ res.status(result.errors ? 422 : 500)
2140
+ return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
2141
+ }
2142
+ res.status(500)
2143
+ return res.send(result.error)
2144
+ }
2145
+ const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
2146
+ if (json) {
2147
+ return res.json({
2148
+ ok: true,
2149
+ redirect,
2150
+ ...(result.notifications ? { notifications: result.notifications } : {}),
2151
+ })
2152
+ }
2153
+ flashNotifications(req, result.notifications)
2154
+ return res.redirect(redirect, 303)
2155
+ })
2156
+
2157
+ // Detach — POST ${parentBase}/:childId/_detach
2158
+ // Direct row-action target for `Action.relationDetach`. Removes the
2159
+ // pivot row only; the related record stays in place. IDOR check:
2160
+ // verify the child is currently attached before calling detach so
2161
+ // a tampered URL can't probe random ids.
2162
+ router.post(`${parentBase}/:childId/_detach`, async (req, res) => {
2163
+ const json = wantsJson(req)
2164
+ const pre = await requireParent(req, res, json)
2165
+ if (!pre) return
2166
+ const childId = req.params['childId']!
2167
+
2168
+ if (mode !== 'belongsToMany' && mode !== 'morphToMany' && mode !== 'morphedByMany') {
2169
+ // Detach is meaningless for hasMany — the user wants `delete`.
2170
+ // Surface a clear 404 instead of silently no-op'ing.
2171
+ res.status(404)
2172
+ const msg = 'Detach is only supported on M2M relations (belongsToMany, morphToMany, morphedByMany)'
2173
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2174
+ }
2175
+
2176
+ // Manager-only canDetach: pivot ops don't fall through to the
2177
+ // related Resource. We don't have the related child loaded yet —
2178
+ // pass `undefined` for the per-record arg; canDetach gates on
2179
+ // (user, parent) by default and only sees `record` when a
2180
+ // manager has explicitly overridden with a per-row predicate.
2181
+ // Authors who need per-row gating can detect undefined and either
2182
+ // load the child themselves or short-circuit.
2183
+ // Two distinct accessors are needed under the real
2184
+ // `@rudderjs/orm`:
2185
+ // - `parent.related(rel)` returns a deferred QueryBuilder
2186
+ // with `where / paginate` (IDOR read-side check).
2187
+ // - `parent[rel]()` returns the pivot-mutation accessor with
2188
+ // `attach / detach / sync` (write-side).
2189
+ // Test stubs may collapse both onto the same `parent.related(rel)`
2190
+ // shape — handle that fallback so existing tests keep passing.
2191
+ let child: unknown = undefined
2192
+ const readSide = (pre.parent as { related?: (n: string) => { where?: (...a: unknown[]) => unknown; paginate?: (p: number, pp: number) => Promise<{ data: unknown[] }> } })
2193
+ ?.related?.(rel)
2194
+ if (!readSide) {
2195
+ res.status(500)
2196
+ const msg = `Parent.related("${rel}") missing — wrong relation type or ORM version?`
2197
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2198
+ }
2199
+ try {
2200
+ // IDOR: confirm the child is currently attached.
2201
+ if (typeof readSide.paginate === 'function') {
2202
+ const Related = findRelatedResource(M, R, cfg)
2203
+ const pk = Related?.model ? getPrimaryKey(Related.model) : 'id'
2204
+ 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)
2205
+ child = Array.isArray(out.data) ? out.data[0] : undefined
2206
+ }
2207
+ } catch {
2208
+ // fall through; null child means we couldn't verify — safer to 404
2209
+ }
2210
+ if (child === undefined) { res.status(404); return res.send('Not found') }
2211
+
2212
+ if (!await safeManagerPolicy(M, 'canDetach', undefined, pre.user, pre.parent, child)) return forbidden(res, json)
2213
+
2214
+ // Real ORM: `parent[rel]()` returns the pivot accessor. Test
2215
+ // stubs: `parent.related(rel)` may carry `detach` directly.
2216
+ // Try the prototype-installed instance method first, then fall
2217
+ // back to the read-side shape.
2218
+ let writeAccessor: { detach?: (ids: unknown) => Promise<unknown> } | undefined
2219
+ const inst = (pre.parent as Record<string, unknown>)[rel]
2220
+ if (typeof inst === 'function') {
2221
+ try {
2222
+ const out = (inst as () => unknown).call(pre.parent) as { detach?: (ids: unknown) => Promise<unknown> } | undefined
2223
+ if (out && typeof out.detach === 'function') writeAccessor = out
2224
+ } catch { /* fall through to legacy shape */ }
2225
+ }
2226
+ if (!writeAccessor && typeof (readSide as { detach?: unknown }).detach === 'function') {
2227
+ writeAccessor = readSide as { detach: (ids: unknown) => Promise<unknown> }
2228
+ }
2229
+ if (!writeAccessor) {
2230
+ res.status(500)
2231
+ const msg = `Pivot accessor missing on ${rel} — wrong relation type or ORM version?`
2232
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2233
+ }
2234
+
2235
+ try {
2236
+ await writeAccessor.detach!([childId])
2237
+ } catch (err) {
2238
+ const message = err instanceof Error ? err.message : 'Detach failed'
2239
+ res.status(500)
2240
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
2241
+ }
2242
+
2243
+ const listUrl = parentBase.replace(':id', pre.recordId)
2244
+ if (json) {
2245
+ const notifications = [
2246
+ { id: `n-rdetach-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} detached` },
2247
+ ]
2248
+ return res.json({ ok: true, redirect: listUrl, notifications })
2249
+ }
2250
+ return res.redirect(listUrl, 303)
2251
+ })
2252
+
2253
+ // ── Phase B nested relation routes ──────────────────
2254
+ // For each manager N declared under M.relations(), mount the
2255
+ // depth-2 list/create/view/edit/delete handlers. Auth + chain
2256
+ // IDOR are centralized in `nestedRelationManagerData` — route
2257
+ // bodies dispatch the data builder and unwrap the tagged
2258
+ // {ok:false,status:403} / null shapes. Surface area mirrors
2259
+ // Phase A: no M2M attach/detach, no soft-delete restore on
2260
+ // nested managers in v1 (open follow-ups if a consumer asks).
2261
+ for (const N of M.relations()) {
2262
+ const nestedRel = N.getRelationship()
2263
+ const nestedBase = `${parentBase}/:childId/${nestedRel}`
2264
+
2265
+ // Build a `chain` tuple from the URL params for relayed calls
2266
+ // into `relationManagerData`. The childId of the *outer* manager
2267
+ // is the recordId of the leaf step.
2268
+ const buildChain = (id: string, childId1: string): [{ recordId: string; relationship: string }, { recordId: string; relationship: string }] => [
2269
+ { recordId: id, relationship: rel },
2270
+ { recordId: childId1, relationship: nestedRel },
2271
+ ]
2272
+
2273
+ // ── List ──
2274
+ router.get(nestedBase, async (req, res) => {
2275
+ const json = wantsJson(req)
2276
+ const id = req.params['id']!
2277
+ const childId1 = req.params['childId']!
2278
+ const data = await relationManagerData(pilotiq, {
2279
+ kind: 'nested-relation-list', slug,
2280
+ chain: buildChain(id, childId1),
2281
+ query: req.query as Record<string, string>,
2282
+ }, req)
2283
+ if (data === null) { res.status(404); return res.send('Not found') }
2284
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
2285
+ return view('pilotiq.nested-relation-list', data)
2286
+ })
2287
+
2288
+ // ── Create (GET) ──
2289
+ router.get(`${nestedBase}/create`, async (req, res) => {
2290
+ const json = wantsJson(req)
2291
+ const id = req.params['id']!
2292
+ const childId1 = req.params['childId']!
2293
+ const data = await relationManagerData(pilotiq, {
2294
+ kind: 'nested-relation-create', slug,
2295
+ chain: buildChain(id, childId1),
2296
+ }, req)
2297
+ if (data === null) { res.status(404); return res.send('Not found') }
2298
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
2299
+ return view('pilotiq.nested-relation-create', data)
2300
+ })
2301
+
2302
+ // ── Create (POST) ──
2303
+ router.post(`${nestedBase}/create`, async (req, res) => {
2304
+ const json = wantsJson(req)
2305
+ const id = req.params['id']!
2306
+ const childId1 = req.params['childId']!
2307
+ // Run the chain walk once to verify auth + IDOR + load child1.
2308
+ // Any failure returns the same tagged shape we serve on GET.
2309
+ const pre = await relationManagerData(pilotiq, {
2310
+ kind: 'nested-relation-create', slug,
2311
+ chain: buildChain(id, childId1),
2312
+ }, req)
2313
+ if (pre === null) { res.status(404); return res.send('Not found') }
2314
+ if ('ok' in pre && pre.ok === false) return forbidden(res, json)
2315
+
2316
+ // Re-resolve the leaf manager's bits for form submit. We need
2317
+ // the leaf parent record (`child1`) and the related class for
2318
+ // save/loadRecord wiring. Reuse `findRelatedResource` against
2319
+ // the chain walk's intermediate Resource (Related1).
2320
+ const Related1 = findRelatedResource(M, R, cfg)
2321
+ if (!Related1) {
2322
+ res.status(500)
2323
+ const msg = `Nested manager ${N.name}: cannot resolve middle Resource for create`
2324
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2325
+ }
2326
+ const Related2 = findRelatedResource(N, Related1, cfg)
2327
+ if (!Related2?.model) {
2328
+ res.status(500)
2329
+ const msg = `Nested manager ${N.name}: cannot resolve related Resource for create`
2330
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2331
+ }
2332
+ const user = await pilotiq.resolveUser(req)
2333
+ const child1 = await findRecord(Related1, childId1, { user }).catch(() => undefined)
2334
+ if (!child1) { res.status(404); return res.send('Not found') }
2335
+
2336
+ const body = await readFormBody(req)
2337
+ const { values } = splitMeta(body)
2338
+
2339
+ const createUrl = `${nestedBase}/create`.replace(':id', id).replace(':childId', childId1)
2340
+ const listUrl = nestedBase.replace(':id', id).replace(':childId', childId1)
2341
+
2342
+ const nestedMode: RelationMode = Related1.model
2343
+ ? normalizeRelationMode(getRelationType(Related1.model, nestedRel))
2344
+ : 'hasMany'
2345
+
2346
+ const form = N.form(Form.make(), {
2347
+ basePath: base,
2348
+ parentSlug: slug,
2349
+ parentId: childId1,
2350
+ relationship: nestedRel,
2351
+ parentRecord: child1,
2352
+ related: Related2,
2353
+ mode: nestedMode,
2354
+ chain: [{ slug, recordId: id, relationship: rel }],
2355
+ })
2356
+ if (Related2.model) {
2357
+ if (!form.getSave()) form.save(modelSave(Related2.model))
2358
+ if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related2))
2359
+ }
2360
+
2361
+ // Polymorphic morph-column auto-injection mirrors the depth-1
2362
+ // create handler — uses Related1 (the leaf parent's owner) as
2363
+ // the morph source on the leaf relation.
2364
+ if (nestedMode === 'morphMany' && Related1.model) {
2365
+ const morphDesc = getMorphRelationDescriptor(Related1.model, nestedRel)
2366
+ if (!morphDesc) {
2367
+ res.status(500)
2368
+ const msg = `Nested manager ${N.name}: relations[${JSON.stringify(nestedRel)}] reports a polymorphic type but is missing morphName.`
2369
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2370
+ }
2371
+ const morphPayload = computeMorphPayload(child1, morphDesc)
2372
+ const existing = form.getMutateDataBeforeCreate()
2373
+ form.mutateDataBeforeCreate(async (data, ctx) => {
2374
+ const next = existing ? await existing(data, ctx) : data
2375
+ return { ...next, ...morphPayload }
2376
+ })
2377
+ }
2378
+
2379
+ const formCtx = {
2380
+ values,
2381
+ basePath: base,
2382
+ parent: child1,
2383
+ parentId: childId1,
2384
+ relationship: nestedRel,
2385
+ }
2386
+
2387
+ const result = await dispatchFormSubmit(form, values, formCtx)
2388
+ if (!result.ok) {
2389
+ if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
2390
+ const data = await relationManagerData(pilotiq, {
2391
+ kind: 'nested-relation-create', slug,
2392
+ chain: buildChain(id, childId1),
2393
+ prefill: { values, errors: result.errors ?? {} },
2394
+ }, req)
2395
+ res.status(422)
2396
+ return view('pilotiq.nested-relation-create', data ?? {})
2397
+ }
2398
+
2399
+ const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
2400
+ if (json) {
2401
+ return res.json({
2402
+ ok: true, redirect,
2403
+ ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
2404
+ })
2405
+ }
2406
+ flashNotifications(req, result.notifications)
2407
+ return res.redirect(redirect, 303)
2408
+ // `createUrl` referenced above is intentionally unused on
2409
+ // success — kept for parity with the depth-1 path's prefill
2410
+ // re-render shape if a future caller wants to redirect to it.
2411
+ void createUrl
2412
+ })
2413
+
2414
+ // ── View ──
2415
+ router.get(`${nestedBase}/:childId2`, async (req, res) => {
2416
+ const json = wantsJson(req)
2417
+ const id = req.params['id']!
2418
+ const childId1 = req.params['childId']!
2419
+ const childId2 = req.params['childId2']!
2420
+ if (childId2 === 'create') { res.status(404); return res.send('Not found') }
2421
+ const data = await relationManagerData(pilotiq, {
2422
+ kind: 'nested-relation-view', slug,
2423
+ chain: buildChain(id, childId1),
2424
+ childId: childId2,
2425
+ }, req)
2426
+ if (data === null) { res.status(404); return res.send('Not found') }
2427
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
2428
+ return view('pilotiq.nested-relation-view', data)
2429
+ })
2430
+
2431
+ // ── Edit (GET) ──
2432
+ router.get(`${nestedBase}/:childId2/edit`, async (req, res) => {
2433
+ const json = wantsJson(req)
2434
+ const id = req.params['id']!
2435
+ const childId1 = req.params['childId']!
2436
+ const childId2 = req.params['childId2']!
2437
+ const data = await relationManagerData(pilotiq, {
2438
+ kind: 'nested-relation-edit', slug,
2439
+ chain: buildChain(id, childId1),
2440
+ childId: childId2,
2441
+ }, req)
2442
+ if (data === null) { res.status(404); return res.send('Not found') }
2443
+ if ('ok' in data && data.ok === false) return forbidden(res, json)
2444
+ return view('pilotiq.nested-relation-edit', data)
2445
+ })
2446
+
2447
+ // ── Edit (POST) ──
2448
+ router.post(`${nestedBase}/:childId2/edit`, async (req, res) => {
2449
+ const json = wantsJson(req)
2450
+ const id = req.params['id']!
2451
+ const childId1 = req.params['childId']!
2452
+ const childId2 = req.params['childId2']!
2453
+
2454
+ // Replay the chain to verify auth, IDOR, load child1+child2.
2455
+ const pre = await relationManagerData(pilotiq, {
2456
+ kind: 'nested-relation-edit', slug,
2457
+ chain: buildChain(id, childId1),
2458
+ childId: childId2,
2459
+ }, req)
2460
+ if (pre === null) { res.status(404); return res.send('Not found') }
2461
+ if ('ok' in pre && pre.ok === false) return forbidden(res, json)
2462
+
2463
+ const Related1 = findRelatedResource(M, R, cfg)
2464
+ if (!Related1) {
2465
+ res.status(500)
2466
+ const msg = `Nested manager ${N.name}: cannot resolve middle Resource for edit`
2467
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2468
+ }
2469
+ const Related2 = findRelatedResource(N, Related1, cfg)
2470
+ if (!Related2?.model) {
2471
+ res.status(500)
2472
+ const msg = `Nested manager ${N.name}: cannot resolve related Resource for edit`
2473
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2474
+ }
2475
+
2476
+ const user = await pilotiq.resolveUser(req)
2477
+ const child1 = await findRecord(Related1, childId1, { user }).catch(() => undefined)
2478
+ if (!child1) { res.status(404); return res.send('Not found') }
2479
+ const child2 = await findRecord(Related2, childId2, { user }).catch(() => undefined)
2480
+ if (!child2) { res.status(404); return res.send('Not found') }
2481
+
2482
+ const body = await readFormBody(req)
2483
+ const { values } = splitMeta(body)
2484
+
2485
+ const editUrl = `${nestedBase}/${childId2}/edit`.replace(':id', id).replace(':childId', childId1)
2486
+
2487
+ const nestedMode: RelationMode = Related1.model
2488
+ ? normalizeRelationMode(getRelationType(Related1.model, nestedRel))
2489
+ : 'hasMany'
2490
+
2491
+ const form = N.form(Form.make(), {
2492
+ basePath: base,
2493
+ parentSlug: slug,
2494
+ parentId: childId1,
2495
+ relationship: nestedRel,
2496
+ parentRecord: child1,
2497
+ related: Related2,
2498
+ mode: nestedMode,
2499
+ chain: [{ slug, recordId: id, relationship: rel }],
2500
+ })
2501
+ if (!form.getSave()) form.save(modelSave(Related2.model))
2502
+ if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related2))
2503
+
2504
+ if (nestedMode === 'morphMany' && Related1.model) {
2505
+ const morphDesc = getMorphRelationDescriptor(Related1.model, nestedRel)
2506
+ if (morphDesc) {
2507
+ const morphPayload = computeMorphPayload(child1, morphDesc)
2508
+ const existing = form.getMutateDataBeforeUpdate()
2509
+ form.mutateDataBeforeUpdate(async (data, ctx) => {
2510
+ const next = existing ? await existing(data, ctx) : data
2511
+ return { ...next, ...morphPayload }
2512
+ })
2513
+ }
2514
+ }
2515
+
2516
+ const formCtx = {
2517
+ values,
2518
+ basePath: base,
2519
+ record: child2,
2520
+ parent: child1,
2521
+ parentId: childId1,
2522
+ relationship: nestedRel,
2523
+ }
2524
+
2525
+ const result = await dispatchFormSubmit(form, values, formCtx)
2526
+ if (!result.ok) {
2527
+ if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
2528
+ const data = await relationManagerData(pilotiq, {
2529
+ kind: 'nested-relation-edit', slug,
2530
+ chain: buildChain(id, childId1),
2531
+ childId: childId2,
2532
+ prefill: { values, errors: result.errors ?? {} },
2533
+ }, req)
2534
+ res.status(422)
2535
+ return view('pilotiq.nested-relation-edit', data ?? {})
2536
+ }
2537
+
2538
+ const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
2539
+ if (json) {
2540
+ return res.json({
2541
+ ok: true, redirect,
2542
+ ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
2543
+ })
2544
+ }
2545
+ flashNotifications(req, result.notifications)
2546
+ return res.redirect(redirect, 303)
2547
+ })
2548
+
2549
+ // ── Delete ──
2550
+ router.post(`${nestedBase}/:childId2/delete`, async (req, res) => {
2551
+ const json = wantsJson(req)
2552
+ const id = req.params['id']!
2553
+ const childId1 = req.params['childId']!
2554
+ const childId2 = req.params['childId2']!
2555
+
2556
+ // Replay the chain to verify auth + IDOR + load child2.
2557
+ // We piggy-back on the edit scope's checks (canEdit on the
2558
+ // leaf manager — same gate the depth-1 delete uses today via
2559
+ // the relation-edit scope).
2560
+ const pre = await relationManagerData(pilotiq, {
2561
+ kind: 'nested-relation-edit', slug,
2562
+ chain: buildChain(id, childId1),
2563
+ childId: childId2,
2564
+ }, req)
2565
+ if (pre === null) { res.status(404); return res.send('Not found') }
2566
+ if ('ok' in pre && pre.ok === false) return forbidden(res, json)
2567
+
2568
+ const Related1 = findRelatedResource(M, R, cfg)
2569
+ if (!Related1) {
2570
+ res.status(500)
2571
+ const msg = `Nested manager ${N.name}: cannot resolve middle Resource for delete`
2572
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2573
+ }
2574
+ const Related2 = findRelatedResource(N, Related1, cfg)
2575
+ if (!Related2?.model) {
2576
+ res.status(500)
2577
+ const msg = `Nested manager ${N.name}: cannot resolve related Resource for delete`
2578
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2579
+ }
2580
+
2581
+ const user = await pilotiq.resolveUser(req)
2582
+ const child1 = await findRecord(Related1, childId1, { user }).catch(() => undefined)
2583
+ if (!child1) { res.status(404); return res.send('Not found') }
2584
+ const child2 = await findRecord(Related2, childId2, { user }).catch(() => undefined)
2585
+ if (!child2) { res.status(404); return res.send('Not found') }
2586
+
2587
+ if (!await safeManagerPolicy(N, 'canDelete', Related2, user, child1, child2)) return forbidden(res, json)
2588
+
2589
+ const listUrl = nestedBase.replace(':id', id).replace(':childId', childId1)
2590
+ try {
2591
+ await Related2.model.delete(childId2)
2592
+ } catch (err) {
2593
+ const message = err instanceof Error ? err.message : 'Delete failed'
2594
+ res.status(500)
2595
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
2596
+ }
2597
+
2598
+ if (json) {
2599
+ const notifications = [
2600
+ { id: `n-nrdelete-${childId2}-${Date.now()}`, type: 'success', title: `${N.getLabelSingular()} deleted` },
2601
+ ]
2602
+ return res.json({ ok: true, redirect: listUrl, notifications })
2603
+ }
2604
+ return res.redirect(listUrl, 303)
2605
+ })
2606
+
2607
+ // ── Phase B follow-up — nested action / detach / soft-delete ──
2608
+ // Mirror the depth-1 manager surface (`_action`, `_detach`,
2609
+ // `restore`, `force-delete`) under the nested manager. Auth +
2610
+ // chain IDOR centralized in `resolveRelationChain`; each route
2611
+ // layers its own scope-specific gate (canDetach / canRestore /
2612
+ // canForceDelete; the action route mirrors depth-1 by not adding
2613
+ // an extra manager-level gate beyond the chain walk).
2614
+ const nestedChainSlug = slug
2615
+ const requireNestedChain = async (req: AppRequest, res: AppResponse, json: boolean): Promise<{
2616
+ user: unknown
2617
+ resolved: ResolvedChain
2618
+ parentId: string
2619
+ child1Id: string
2620
+ } | undefined> => {
2621
+ const id = req.params['id']!
2622
+ const child1Id = req.params['childId']!
2623
+ const user = await pilotiq.resolveUser(req)
2624
+ const resolved = await resolveRelationChain(pilotiq, {
2625
+ kind: 'nested-relation-list',
2626
+ slug: nestedChainSlug,
2627
+ chain: [
2628
+ { recordId: id, relationship: rel },
2629
+ { recordId: child1Id, relationship: nestedRel },
2630
+ ],
2631
+ }, user)
2632
+ if (resolved === null) { res.status(404); res.send('Not found'); return undefined }
2633
+ if ('ok' in resolved) { forbidden(res, json); return undefined }
2634
+ return { user, resolved, parentId: id, child1Id }
2635
+ }
2636
+
2637
+ // Listing URL (filled per request — `:id` / `:childId` get baked
2638
+ // in once the params are known). All four routes redirect here
2639
+ // on success so users land back on the nested-relation list.
2640
+ const nestedListUrlFor = (id: string, child1Id: string): string =>
2641
+ nestedBase.replace(':id', id).replace(':childId', child1Id)
2642
+
2643
+ // ── Action dispatch — POST ${nestedBase}/_action/:actionName ──
2644
+ // Resolves N's table elements, finds the named action, dispatches
2645
+ // it with `ctx.relation = { parent: child1, parentId, rel }` so
2646
+ // M2M handlers on the nested manager can call accessor methods.
2647
+ // Handler-style actions are useful on hasMany too — mounted
2648
+ // unconditionally.
2649
+ router.post(`${nestedBase}/_action/:actionName`, async (req, res) => {
2650
+ const json = wantsJson(req)
2651
+ const pre = await requireNestedChain(req, res, json)
2652
+ if (!pre) return
2653
+ const { resolved } = pre
2654
+ const { Related1, child1, M2, Related2, child2Mode } = resolved
2655
+
2656
+ const actionName = req.params['actionName']!
2657
+ const body = await readFormBody(req)
2658
+ const input = parseActionBody(body)
2659
+
2660
+ // Manager ctx for N — same shape `nestedManagerCtx` builds for
2661
+ // the data-builder side, so factories that close over `ctx`
2662
+ // (URL templates, mode-aware visibility) see the same view as
2663
+ // at page render.
2664
+ const nestedManagerCtxObj = {
2665
+ basePath: base,
2666
+ parentSlug: resolved.R.getSlug(),
2667
+ parentId: pre.child1Id, // immediate parent of N = child1
2668
+ relationship: nestedRel,
2669
+ parentRecord: child1,
2670
+ related: Related2,
2671
+ mode: child2Mode,
2672
+ chain: [{
2673
+ slug: resolved.R.getSlug(),
2674
+ recordId: pre.parentId,
2675
+ relationship: rel,
2676
+ }],
2677
+ }
2678
+ const table = M2.table(Table.make(), nestedManagerCtxObj)
2679
+ const elements: import('./schema/Element.js').Element[] = [table]
2680
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
2681
+ tagActionDispatch(elements, listUrl)
2682
+
2683
+ const target = resolveDispatchTarget(elements, actionName)
2684
+ if (!target) {
2685
+ if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
2686
+ res.status(404)
2687
+ return res.send(`Action "${actionName}" not found on ${M2.name}`)
2688
+ }
2689
+
2690
+ const resolveRecord: ResolveRecord | undefined = Related2?.model
2691
+ ? (id: string) => Related2.model!.find(id)
2692
+ : undefined
2693
+
2694
+ const result = await dispatchAction(target.action, {
2695
+ ...input,
2696
+ request: req,
2697
+ user: pre.user,
2698
+ relation: { parent: child1, parentId: pre.child1Id, relationship: nestedRel },
2699
+ ...(target.rowField ? { rowField: target.rowField } : {}),
2700
+ ...(target.formSchema ? { formSchema: target.formSchema } : {}),
2701
+ }, resolveRecord)
2702
+
2703
+ if (!result.ok) {
2704
+ if (json) {
2705
+ res.status(result.errors ? 422 : 500)
2706
+ return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
2707
+ }
2708
+ res.status(500)
2709
+ return res.send(result.error)
2710
+ }
2711
+ const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
2712
+ if (json) {
2713
+ return res.json({
2714
+ ok: true,
2715
+ redirect,
2716
+ ...(result.notifications ? { notifications: result.notifications } : {}),
2717
+ })
2718
+ }
2719
+ flashNotifications(req, result.notifications)
2720
+ return res.redirect(redirect, 303)
2721
+ })
2722
+
2723
+ // ── Detach — POST ${nestedBase}/:childId2/_detach ──
2724
+ // M2M-only direct row-detach. IDOR-checks the grandchild against
2725
+ // child1.related(nestedRel), then calls accessor.detach. Mirrors
2726
+ // the depth-1 detach route at line 1955.
2727
+ router.post(`${nestedBase}/:childId2/_detach`, async (req, res) => {
2728
+ const json = wantsJson(req)
2729
+ const pre = await requireNestedChain(req, res, json)
2730
+ if (!pre) return
2731
+ const childId2 = req.params['childId2']!
2732
+ const { resolved } = pre
2733
+ const { Related1, child1, M2, Related2, child2Mode } = resolved
2734
+
2735
+ if (child2Mode !== 'belongsToMany' && child2Mode !== 'morphToMany' && child2Mode !== 'morphedByMany') {
2736
+ res.status(404)
2737
+ const msg = 'Detach is only supported on M2M relations (belongsToMany, morphToMany, morphedByMany)'
2738
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2739
+ }
2740
+
2741
+ // IDOR: confirm child2 is currently attached to child1 under
2742
+ // nestedRel. Read-side accessor (`child1.related(nestedRel)`)
2743
+ // returns a deferred QueryBuilder; we never bypass it.
2744
+ const readSide = (child1 as { related?: (n: string) => { where?: (...a: unknown[]) => unknown; paginate?: (p: number, pp: number) => Promise<{ data: unknown[] }> } })
2745
+ ?.related?.(nestedRel)
2746
+ if (!readSide) {
2747
+ res.status(500)
2748
+ const msg = `child1.related("${nestedRel}") missing — wrong relation type or ORM version?`
2749
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2750
+ }
2751
+ let child2: unknown = undefined
2752
+ try {
2753
+ if (typeof readSide.paginate === 'function') {
2754
+ const pk = Related2?.model ? getPrimaryKey(Related2.model) : 'id'
2755
+ 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)
2756
+ child2 = Array.isArray(out.data) ? out.data[0] : undefined
2757
+ }
2758
+ } catch { /* fall through */ }
2759
+ if (child2 === undefined) { res.status(404); return res.send('Not found') }
2760
+
2761
+ if (!await safeManagerPolicy(M2, 'canDetach', Related2, pre.user, child1, child2)) return forbidden(res, json)
2762
+
2763
+ // Real ORM: child1[nestedRel]() returns the pivot accessor
2764
+ // with attach/detach/sync. Test stubs may collapse onto
2765
+ // `child1.related(nestedRel)` — try both.
2766
+ let writeAccessor: { detach?: (ids: unknown) => Promise<unknown> } | undefined
2767
+ const inst = (child1 as Record<string, unknown>)[nestedRel]
2768
+ if (typeof inst === 'function') {
2769
+ try {
2770
+ const out = (inst as () => unknown).call(child1) as { detach?: (ids: unknown) => Promise<unknown> } | undefined
2771
+ if (out && typeof out.detach === 'function') writeAccessor = out
2772
+ } catch { /* fall through */ }
2773
+ }
2774
+ if (!writeAccessor && typeof (readSide as { detach?: unknown }).detach === 'function') {
2775
+ writeAccessor = readSide as { detach: (ids: unknown) => Promise<unknown> }
2776
+ }
2777
+ if (!writeAccessor) {
2778
+ res.status(500)
2779
+ const msg = `Pivot accessor missing on ${nestedRel} — wrong relation type or ORM version?`
2780
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2781
+ }
2782
+
2783
+ try {
2784
+ await writeAccessor.detach!([childId2])
2785
+ } catch (err) {
2786
+ const message = err instanceof Error ? err.message : 'Detach failed'
2787
+ res.status(500)
2788
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
2789
+ }
2790
+
2791
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
2792
+ if (json) {
2793
+ const notifications = [
2794
+ { id: `n-nrdetach-${childId2}-${Date.now()}`, type: 'success', title: `${M2.getLabelSingular()} detached` },
2795
+ ]
2796
+ return res.json({ ok: true, redirect: listUrl, notifications })
2797
+ }
2798
+ return res.redirect(listUrl, 303)
2799
+ })
2800
+
2801
+ // ── Soft-delete: restore + force-delete ───────────────────────
2802
+ // Opt in only when Related2 has `softDeletes = true` AND its
2803
+ // model carries `restore` / `forceDelete`. Mirrors the depth-1
2804
+ // routes at line 1804+. IDOR runs against child1.related(nestedRel)
2805
+ // broadened with `withTrashed()` so trashed grandchildren resolve.
2806
+ const Related1ForSoft = findRelatedResource(M, R, cfg)
2807
+ const Related2ForSoft = Related1ForSoft ? findRelatedResource(N, Related1ForSoft, cfg) : undefined
2808
+ if (Related2ForSoft?.softDeletes) {
2809
+ const RM2 = Related2ForSoft.model
2810
+ if (!RM2) {
2811
+ throw new Error(
2812
+ `[Pilotiq] Nested RelationManager ${N.name} on ${M.name} (${R.name}): related Resource ${Related2ForSoft.name} has softDeletes = true but no model. ` +
2813
+ `Wire one up or unset softDeletes.`,
2814
+ )
2815
+ }
2816
+ if (typeof RM2.restore !== 'function' || typeof RM2.forceDelete !== 'function') {
2817
+ throw new Error(
2818
+ `[Pilotiq] Nested RelationManager ${N.name} on ${M.name} (${R.name}): related Resource ${Related2ForSoft.name} has softDeletes = true but model.restore / model.forceDelete are missing. ` +
2819
+ `Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`,
2820
+ )
2821
+ }
2822
+
2823
+ // Like the depth-1 helper: load the grandchild via the parent's
2824
+ // relation query, broadened with `withTrashed()`. Returns
2825
+ // undefined when the lookup misses or the grandchild doesn't
2826
+ // belong to child1 under nestedRel.
2827
+ const loadTrashableGrandchild = async (parentChild: unknown, child2Id: string): Promise<unknown> => {
2828
+ const pk = (RM2.primaryKey ?? 'id') as string
2829
+ try {
2830
+ const q: import('./orm/modelDefaults.js').ModelQuery = (parentChild as { related: (n: string) => import('./orm/modelDefaults.js').ModelQuery }).related(nestedRel)
2831
+ const broadened = typeof q.withTrashed === 'function' ? q.withTrashed() : q
2832
+ const result = await broadened.where(pk, '=', child2Id).paginate(1, 1)
2833
+ return Array.isArray(result.data) ? result.data[0] : undefined
2834
+ } catch {
2835
+ return undefined
2836
+ }
2837
+ }
2838
+
2839
+ // Restore — POST ${nestedBase}/:childId2/restore
2840
+ router.post(`${nestedBase}/:childId2/restore`, async (req, res) => {
2841
+ const json = wantsJson(req)
2842
+ const pre = await requireNestedChain(req, res, json)
2843
+ if (!pre) return
2844
+ const childId2 = req.params['childId2']!
2845
+ const child2 = await loadTrashableGrandchild(pre.resolved.child1, childId2)
2846
+ if (!child2) { res.status(404); return res.send('Not found') }
2847
+
2848
+ if (!await safeManagerPolicy(N, 'canRestore', Related2ForSoft, pre.user, pre.resolved.child1, child2)) return forbidden(res, json)
2849
+
2850
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
2851
+ try {
2852
+ await RM2.restore!(childId2)
2853
+ } catch (err) {
2854
+ const message = err instanceof Error ? err.message : 'Restore failed'
2855
+ res.status(500)
2856
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
2857
+ }
2858
+
2859
+ if (json) {
2860
+ const notifications = [
2861
+ { id: `n-nrrestore-${childId2}-${Date.now()}`, type: 'success', title: `${N.getLabelSingular()} restored` },
2862
+ ]
2863
+ return res.json({ ok: true, redirect: listUrl, notifications })
2864
+ }
2865
+ return res.redirect(listUrl, 303)
2866
+ })
2867
+
2868
+ // Force-delete — POST ${nestedBase}/:childId2/force-delete
2869
+ router.post(`${nestedBase}/:childId2/force-delete`, async (req, res) => {
2870
+ const json = wantsJson(req)
2871
+ const pre = await requireNestedChain(req, res, json)
2872
+ if (!pre) return
2873
+ const childId2 = req.params['childId2']!
2874
+ const child2 = await loadTrashableGrandchild(pre.resolved.child1, childId2)
2875
+ if (!child2) { res.status(404); return res.send('Not found') }
2876
+
2877
+ if (!await safeManagerPolicy(N, 'canForceDelete', Related2ForSoft, pre.user, pre.resolved.child1, child2)) return forbidden(res, json)
2878
+
2879
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
2880
+ try {
2881
+ await RM2.forceDelete!(childId2)
2882
+ } catch (err) {
2883
+ const message = err instanceof Error ? err.message : 'Force-delete failed'
2884
+ res.status(500)
2885
+ return json ? res.json({ ok: false, error: message }) : res.send(message)
2886
+ }
2887
+
2888
+ if (json) {
2889
+ const notifications = [
2890
+ { id: `n-nrforce-${childId2}-${Date.now()}`, type: 'success', title: `${N.getLabelSingular()} permanently deleted` },
2891
+ ]
2892
+ return res.json({ ok: true, redirect: listUrl, notifications })
2893
+ }
2894
+ return res.redirect(listUrl, 303)
2895
+ })
2896
+ }
2897
+ }
2898
+ }
2899
+ }
2900
+
2901
+ // ── Globals (singletons — 2-segment, no /:id) ────────
2902
+ for (const G of cfg.globals) {
2903
+ const slug = G.getSlug()
2904
+ const editUrl = globalBasePath(base, G)
2905
+ const pages = G.resolvePages()
2906
+
2907
+ if (pages.edit) {
2908
+ const PageClass = pages.edit
2909
+
2910
+ // Plan #5 partial-resolve endpoint for the global's edit form.
2911
+ // POST ${editUrl}/_form/:formId/state
2912
+ router.post(`${editUrl}/_form/:formId/state`, async (req, res) => {
2913
+ const user = await pilotiq.resolveUser(req)
2914
+ if (!await policyAccess(G, user)) return forbidden(res, true)
2915
+ if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
2916
+ const formId = req.params['formId']!
2917
+ return handleFormState(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
2918
+ })
2919
+
2920
+ // Plan #8 wizard step-validate endpoint for the global's edit form.
2921
+ router.post(`${editUrl}/_form/:formId/wizard`, async (req, res) => {
2922
+ const user = await pilotiq.resolveUser(req)
2923
+ if (!await policyAccess(G, user)) return forbidden(res, true)
2924
+ if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
2925
+ const formId = req.params['formId']!
2926
+ return handleFormWizard(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
2927
+ })
2928
+
2929
+ // Async-mention endpoint for the global's edit form.
2930
+ router.post(`${editUrl}/_form/:formId/mentions`, async (req, res) => {
2931
+ const user = await pilotiq.resolveUser(req)
2932
+ if (!await policyAccess(G, user)) return forbidden(res, true)
2933
+ if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
2934
+ const formId = req.params['formId']!
2935
+ return handleFormMentions(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
2936
+ })
2937
+
2938
+ // SelectField inline-create modal endpoint for the global's edit form.
2939
+ router.post(`${editUrl}/_form/:formId/create-option/:fieldName`, async (req, res) => {
2940
+ const user = await pilotiq.resolveUser(req)
2941
+ if (!await policyAccess(G, user)) return forbidden(res, true)
2942
+ if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
2943
+ const formId = req.params['formId']!
2944
+ const fieldName = req.params['fieldName']!
2945
+ return handleFormCreateOption(req, res, pilotiq, { kind: 'global-edit', slug }, formId, fieldName)
2946
+ })
2947
+
2948
+ router.get(editUrl, async (req, res) => {
2949
+ const user = await pilotiq.resolveUser(req)
2950
+ if (!await policyAccess(G, user)) return forbidden(res, wantsJson(req))
2951
+ // Globals carry their record on the singleton form's `loadRecord`;
2952
+ // we don't pre-load here — pass a stub so canEdit's signature is
2953
+ // honored, and let user code decide whether to consult it.
2954
+ if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, wantsJson(req))
2955
+ const data = await globalEditData(pilotiq, slug, undefined, req)
2956
+ return view('pilotiq.slug', data ?? {})
2957
+ })
2958
+
2959
+ router.post(editUrl, async (req, res) => {
2960
+ const body = await readFormBody(req)
2961
+ const { values, formId } = splitMeta(body)
2962
+ const json = wantsJson(req)
2963
+
2964
+ const user = await pilotiq.resolveUser(req)
2965
+ if (!await policyAccess(G, user)) return forbidden(res, json)
2966
+ if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, json)
2967
+
2968
+ const ctx: SchemaContext = { mode: 'edit', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
2969
+ const elements = await callPageSchema(PageClass, ctx)
2970
+ tagFormActions(elements, editUrl)
2971
+ const form = selectForm(findForms(elements), formId)
2972
+ if (!form) {
2973
+ if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
2974
+ res.status(404)
2975
+ return res.send('No form found on page')
2976
+ }
2977
+
2978
+ // Provide the existing singleton record to the lifecycle context
2979
+ // so cross-field validators / mutateData see prior state.
2980
+ let record: unknown = undefined
2981
+ if (form.getLoadRecord()) {
2982
+ try { record = await form.getLoadRecord()!('', { values }) } catch { /* ignore */ }
2983
+ }
2984
+
2985
+ const result = await dispatchFormSubmit(
2986
+ form,
2987
+ values,
2988
+ record !== undefined ? { values, record, basePath: base } : { values, basePath: base },
2989
+ )
2990
+
2991
+ if (!result.ok) {
2992
+ if (json) {
2993
+ res.status(422)
2994
+ return res.json({ ok: false, errors: result.errors })
2995
+ }
2996
+ const data = await globalEditData(pilotiq, slug, { values, errors: result.errors })
2997
+ res.status(422)
2998
+ return view('pilotiq.slug', data ?? {})
2999
+ }
3000
+
3001
+ const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
3002
+ if (json) {
3003
+ return res.json({
3004
+ ok: true,
3005
+ redirect,
3006
+ ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
3007
+ })
3008
+ }
3009
+ flashNotifications(req, result.notifications)
3010
+ return res.redirect(redirect, 303)
3011
+ })
3012
+ }
3013
+
3014
+ // Optional view page when the user opts in via pages().view
3015
+ if (pages.view) {
3016
+ router.get(`${editUrl}/view`, async (req, res) => {
3017
+ const user = await pilotiq.resolveUser(req)
3018
+ if (!await policyAccess(G, user)) return forbidden(res, wantsJson(req))
3019
+ if (!await checkPolicy(() => G.canView(user, undefined))) return forbidden(res, wantsJson(req))
3020
+ const data = await globalViewData(pilotiq, slug, req)
3021
+ return view('pilotiq.resource-view', data ?? {})
3022
+ })
3023
+ }
3024
+ }
3025
+
3026
+ // ── Custom pages (2-segment, slug route) ──────────────
3027
+ for (const PageClass of cfg.pages) {
3028
+ // Plan #15 — the dashboard page lives at `${base}` (handled by the
3029
+ // dashboard route above), so skip it here to avoid registering a
3030
+ // duplicate `${pageUrl}` route or a broken `${base}/` (when
3031
+ // `slug = ''`).
3032
+ if (cfg.dashboardPage === PageClass) continue
3033
+
3034
+ const pageSlug = PageClass.getSlug()
3035
+ const pageUrl = pageBasePath(base, PageClass)
3036
+
3037
+ // Plan #15 — per-page widget polling endpoint. Mirrors the
3038
+ // panel-scope `${base}/_widget/:id` but resolves the custom page's
3039
+ // schema instead of the dashboard's.
3040
+ router.post(`${pageUrl}/_widget/:id`, async (req, res) => {
3041
+ const user = await pilotiq.resolveUser(req)
3042
+ if (!await policyAccess(PageClass, user)) return forbidden(res, true)
3043
+ return handleWidgetData(req, res, pilotiq, { kind: 'page', pageSlug }, req.params['id']!)
3044
+ })
3045
+
3046
+ // Plan #5 partial-resolve endpoint for custom pages with reactive forms.
3047
+ // POST ${base}/${pageSlug}/_form/:formId/state
3048
+ router.post(`${pageUrl}/_form/:formId/state`, async (req, res) => {
3049
+ const user = await pilotiq.resolveUser(req)
3050
+ if (!await policyAccess(PageClass, user)) return forbidden(res, true)
3051
+ const formId = req.params['formId']!
3052
+ return handleFormState(req, res, pilotiq, { kind: 'page', pageSlug }, formId)
3053
+ })
3054
+
3055
+ // Plan #8 wizard step-validate endpoint for custom pages.
3056
+ router.post(`${pageUrl}/_form/:formId/wizard`, async (req, res) => {
3057
+ const user = await pilotiq.resolveUser(req)
3058
+ if (!await policyAccess(PageClass, user)) return forbidden(res, true)
3059
+ const formId = req.params['formId']!
3060
+ return handleFormWizard(req, res, pilotiq, { kind: 'page', pageSlug }, formId)
3061
+ })
3062
+
3063
+ // Async-mention endpoint for custom pages.
3064
+ router.post(`${pageUrl}/_form/:formId/mentions`, async (req, res) => {
3065
+ const user = await pilotiq.resolveUser(req)
3066
+ if (!await policyAccess(PageClass, user)) return forbidden(res, true)
3067
+ const formId = req.params['formId']!
3068
+ return handleFormMentions(req, res, pilotiq, { kind: 'page', pageSlug }, formId)
3069
+ })
3070
+
3071
+ // SelectField inline-create modal endpoint for custom pages.
3072
+ router.post(`${pageUrl}/_form/:formId/create-option/:fieldName`, async (req, res) => {
3073
+ const user = await pilotiq.resolveUser(req)
3074
+ if (!await policyAccess(PageClass, user)) return forbidden(res, true)
3075
+ const formId = req.params['formId']!
3076
+ const fieldName = req.params['fieldName']!
3077
+ return handleFormCreateOption(req, res, pilotiq, { kind: 'page', pageSlug }, formId, fieldName)
3078
+ })
3079
+
3080
+ router.get(pageUrl, async (req, res) => {
3081
+ const user = await pilotiq.resolveUser(req)
3082
+ if (!await policyAccess(PageClass, user)) return forbidden(res, wantsJson(req))
3083
+ const data = await customPageData(pilotiq, pageSlug, req)
3084
+ return view('pilotiq.slug', data ?? {})
3085
+ })
3086
+
3087
+ // Action dispatch — POST ${base}/${pageSlug}/_action/:actionName
3088
+ router.post(`${pageUrl}/_action/:actionName`, async (req, res) => {
3089
+ const user = await pilotiq.resolveUser(req)
3090
+ if (!await policyAccess(PageClass, user)) return forbidden(res, wantsJson(req))
3091
+
3092
+ const actionName = req.params['actionName']!
3093
+ const json = wantsJson(req)
3094
+ const body = await readFormBody(req)
3095
+ const input = parseActionBody(body)
3096
+
3097
+ const ctx: SchemaContext = user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}
3098
+ const elements = await callPageSchema(PageClass, ctx)
3099
+ tagActionDispatch(elements, pageUrl)
3100
+ const target = resolveDispatchTarget(elements, actionName)
3101
+ if (!target) {
3102
+ if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
3103
+ res.status(404)
3104
+ return res.send(`Action "${actionName}" not found on page`)
3105
+ }
3106
+
3107
+ const result = await dispatchAction(target.action, {
3108
+ ...input,
3109
+ request: req,
3110
+ user,
3111
+ ...(target.rowField ? { rowField: target.rowField } : {}),
3112
+ ...(target.formSchema ? { formSchema: target.formSchema } : {}),
3113
+ })
3114
+ if (!result.ok) {
3115
+ if (json) {
3116
+ res.status(result.errors ? 422 : 500)
3117
+ return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
3118
+ }
3119
+ res.status(500)
3120
+ return res.send(result.error)
3121
+ }
3122
+ if (result.download) return sendDownload(res, result.download)
3123
+ const redirect = normalizeRedirect(result.redirect, base) ?? pageUrl
3124
+ if (json) {
3125
+ return res.json({
3126
+ ok: true,
3127
+ redirect,
3128
+ ...(result.notifications ? { notifications: result.notifications } : {}),
3129
+ })
3130
+ }
3131
+ flashNotifications(req, result.notifications)
3132
+ return res.redirect(redirect, 303)
3133
+ })
3134
+
3135
+ // Custom pages can also accept submits when their schema includes a Form.
3136
+ router.post(pageUrl, async (req, res) => {
3137
+ const body = await readFormBody(req)
3138
+ const { values, formId } = splitMeta(body)
3139
+ const json = wantsJson(req)
3140
+
3141
+ const user = await pilotiq.resolveUser(req)
3142
+ if (!await policyAccess(PageClass, user)) return forbidden(res, json)
3143
+
3144
+ const ctx: SchemaContext = user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}
3145
+ const elements = await callPageSchema(PageClass, ctx)
3146
+ tagFormActions(elements, pageUrl)
3147
+ const form = selectForm(findForms(elements), formId)
3148
+ if (!form) {
3149
+ if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
3150
+ res.status(404)
3151
+ return res.send('No form found on page')
3152
+ }
3153
+
3154
+ const result = await dispatchFormSubmit(form, values, { values, basePath: base })
3155
+
3156
+ if (!result.ok) {
3157
+ if (json) {
3158
+ res.status(422)
3159
+ return res.json({ ok: false, errors: result.errors })
3160
+ }
3161
+ form.withValues(values).withErrors(result.errors)
3162
+ const schemaData = await resolveSchema(elements, ctx)
3163
+ res.status(422)
3164
+ return view('pilotiq.slug', {
3165
+ pageType: 'page',
3166
+ panel: await panelInfo(pilotiq, req),
3167
+ page: PageClass.toMeta(),
3168
+ schemaData,
3169
+ basePath: base,
3170
+ layout: cfg.layout,
3171
+ hasErrors: true,
3172
+ })
3173
+ }
3174
+
3175
+ const redirect = normalizeRedirect(result.redirect, base) ?? pageUrl
3176
+ if (json) {
3177
+ return res.json({
3178
+ ok: true,
3179
+ redirect,
3180
+ ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
3181
+ })
3182
+ }
3183
+ flashNotifications(req, result.notifications)
3184
+ return res.redirect(redirect, 303)
3185
+ })
3186
+ }
3187
+
3188
+ // ── Theme editor ──────────────────────────────────────
3189
+ if (cfg.themeEditor) {
3190
+ router.get(`${base}/theme`, async (req) => {
3191
+ return view('pilotiq.theme', {
3192
+ panel: await panelInfo(pilotiq, req),
3193
+ basePath: base,
3194
+ layout: cfg.layout,
3195
+ themeConfig: pilotiq.getMergedTheme() ?? {},
3196
+ })
3197
+ })
3198
+
3199
+ router.get(`${base}/api/_theme`, async (_req, res) => {
3200
+ let overrides: Partial<ThemeConfig> | null = null
3201
+ try {
3202
+ const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
3203
+ const prisma = app().make('prisma') as any
3204
+ const slug = `${cfg.name}__theme`
3205
+ const row = await prisma.panelGlobal.findUnique({ where: { slug } })
3206
+ if (row?.data) {
3207
+ const raw = typeof row.data === 'string' ? JSON.parse(row.data as string) : row.data
3208
+ overrides = migrateThemeOverrides(raw)
3209
+ }
3210
+ } catch { /* no DB or no table — that's fine */ }
3211
+
3212
+ return res.json({
3213
+ config: cfg.theme ?? {},
3214
+ overrides: overrides ?? {},
3215
+ options: {
3216
+ presets: Object.keys(presets),
3217
+ baseColors: Object.keys(baseColors),
3218
+ themeColors: ['base', ...HUE_NAMES],
3219
+ chartColors: ['base', ...HUE_NAMES],
3220
+ radii: Object.keys(radiusMap),
3221
+ iconLibraries: ['lucide', 'tabler', 'phosphor', 'remix'],
3222
+ },
3223
+ })
3224
+ })
3225
+
3226
+ router.put(`${base}/api/_theme`, async (req, res) => {
3227
+ try {
3228
+ const overrides = req.body as Partial<ThemeConfig>
3229
+ const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
3230
+ const prisma = app().make('prisma') as any
3231
+ const slug = `${cfg.name}__theme`
3232
+
3233
+ await prisma.panelGlobal.upsert({
3234
+ where: { slug },
3235
+ update: { data: JSON.stringify(overrides) },
3236
+ create: { slug, data: JSON.stringify(overrides) },
3237
+ })
3238
+
3239
+ pilotiq.setThemeOverrides(overrides)
3240
+ return res.json({ ok: true })
3241
+ } catch (e) {
3242
+ return res.status(500).json({ message: e instanceof Error ? e.message : 'Failed to save theme' })
3243
+ }
3244
+ })
3245
+
3246
+ router.delete(`${base}/api/_theme`, async (_req, res) => {
3247
+ try {
3248
+ const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
3249
+ const prisma = app().make('prisma') as any
3250
+ const slug = `${cfg.name}__theme`
3251
+ await prisma.panelGlobal.delete({ where: { slug } }).catch(() => {})
3252
+ pilotiq.setThemeOverrides(undefined)
3253
+ } catch { /* ignore */ }
3254
+ return res.json({ ok: true })
3255
+ })
3256
+ }
3257
+ }
3258
+
3259
+ // ─── Lifecycle helpers exported for tests ────────────────
3260
+ export { dispatchFormSubmit, findForms, selectForm }
3261
+ export { loadTableRecords, parseTableQuery, findTables } from './elements/dispatchTable.js'
3262
+ export type { Form }