@pilotiq/pilotiq 0.24.1 → 0.24.3

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 (518) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/boost/guidelines.md +571 -0
  3. package/boost/skills/pilotiq-actions/SKILL.md +49 -0
  4. package/boost/skills/pilotiq-actions/rules/dispatch-modes.md +177 -0
  5. package/boost/skills/pilotiq-actions/rules/factories.md +130 -0
  6. package/boost/skills/pilotiq-actions/rules/visibility-and-authorization.md +125 -0
  7. package/boost/skills/pilotiq-fields/SKILL.md +47 -0
  8. package/boost/skills/pilotiq-fields/rules/field-catalog.md +288 -0
  9. package/boost/skills/pilotiq-fields/rules/reactive-fields.md +199 -0
  10. package/boost/skills/pilotiq-fields/rules/validation.md +198 -0
  11. package/boost/skills/pilotiq-relations/SKILL.md +47 -0
  12. package/boost/skills/pilotiq-relations/rules/relation-managers.md +256 -0
  13. package/boost/skills/pilotiq-relations/rules/repeater-relationship.md +177 -0
  14. package/boost/skills/pilotiq-resource/SKILL.md +61 -0
  15. package/boost/skills/pilotiq-resource/rules/authorization.md +242 -0
  16. package/boost/skills/pilotiq-resource/rules/defining-resources.md +228 -0
  17. package/boost/skills/pilotiq-resource/rules/page-overrides.md +296 -0
  18. package/dist/Pilotiq.d.ts +31 -0
  19. package/dist/Pilotiq.d.ts.map +1 -1
  20. package/dist/Pilotiq.js +3 -1
  21. package/dist/Pilotiq.js.map +1 -1
  22. package/dist/PilotiqRegistry.d.ts +13 -0
  23. package/dist/PilotiqRegistry.d.ts.map +1 -1
  24. package/dist/PilotiqRegistry.js +15 -0
  25. package/dist/PilotiqRegistry.js.map +1 -1
  26. package/dist/pageData/misc.d.ts.map +1 -1
  27. package/dist/pageData/misc.js +6 -0
  28. package/dist/pageData/misc.js.map +1 -1
  29. package/dist/pageData/navigation.d.ts +1 -0
  30. package/dist/pageData/navigation.d.ts.map +1 -1
  31. package/dist/pageData/navigation.js +3 -0
  32. package/dist/pageData/navigation.js.map +1 -1
  33. package/dist/pageData/relationPages.d.ts.map +1 -1
  34. package/dist/pageData/relationPages.js +3 -0
  35. package/dist/pageData/relationPages.js.map +1 -1
  36. package/dist/pageData/resourcePages.d.ts.map +1 -1
  37. package/dist/pageData/resourcePages.js +8 -0
  38. package/dist/pageData/resourcePages.js.map +1 -1
  39. package/dist/react/AppShell.d.ts +8 -0
  40. package/dist/react/AppShell.d.ts.map +1 -1
  41. package/dist/react/AppShell.js.map +1 -1
  42. package/dist/react/layouts/SidebarLayout.d.ts.map +1 -1
  43. package/dist/react/layouts/SidebarLayout.js +10 -2
  44. package/dist/react/layouts/SidebarLayout.js.map +1 -1
  45. package/dist/react/widgets/StatsOverviewRenderer.d.ts.map +1 -1
  46. package/dist/react/widgets/StatsOverviewRenderer.js +32 -18
  47. package/dist/react/widgets/StatsOverviewRenderer.js.map +1 -1
  48. package/dist/routes/relations.d.ts.map +1 -1
  49. package/dist/routes/relations.js +25 -18
  50. package/dist/routes/relations.js.map +1 -1
  51. package/dist/routes/resources.js.map +1 -1
  52. package/package.json +10 -5
  53. package/.turbo/turbo-build.log +0 -8
  54. package/CLAUDE.md +0 -265
  55. package/src/Cluster.test.ts +0 -283
  56. package/src/Cluster.ts +0 -83
  57. package/src/Column.test.ts +0 -199
  58. package/src/Column.ts +0 -710
  59. package/src/Global.test.ts +0 -367
  60. package/src/Global.ts +0 -169
  61. package/src/Page.test.ts +0 -114
  62. package/src/Page.ts +0 -208
  63. package/src/Pilotiq.perf.test.ts +0 -252
  64. package/src/Pilotiq.test.ts +0 -129
  65. package/src/Pilotiq.ts +0 -1158
  66. package/src/PilotiqRegistry.ts +0 -36
  67. package/src/PilotiqServiceProvider.ts +0 -121
  68. package/src/RelationManager.test.ts +0 -400
  69. package/src/RelationManager.ts +0 -527
  70. package/src/RenderHook.test.ts +0 -252
  71. package/src/RenderHook.ts +0 -242
  72. package/src/Resource.test.ts +0 -284
  73. package/src/Resource.ts +0 -526
  74. package/src/RightPanel.test.ts +0 -202
  75. package/src/RightPanel.ts +0 -132
  76. package/src/Tab.test.ts +0 -91
  77. package/src/Tab.ts +0 -156
  78. package/src/UserMenuItem.ts +0 -145
  79. package/src/actions/Action.test.ts +0 -2526
  80. package/src/actions/Action.ts +0 -1515
  81. package/src/actions/ActionGroup.test.ts +0 -112
  82. package/src/actions/ActionGroup.ts +0 -173
  83. package/src/actions/attachFactory.ts +0 -172
  84. package/src/actions/bulkFactories.ts +0 -168
  85. package/src/actions/crudFactories.ts +0 -220
  86. package/src/actions/exportFactory.ts +0 -225
  87. package/src/actions/factoryHelpers.ts +0 -177
  88. package/src/actions/importFactory.ts +0 -243
  89. package/src/actions/index.ts +0 -17
  90. package/src/actions/m2mFactories.ts +0 -193
  91. package/src/actions/relationFactories.ts +0 -372
  92. package/src/applyPageHooks.test.ts +0 -463
  93. package/src/applyPageHooks.ts +0 -330
  94. package/src/authorization.test.ts +0 -483
  95. package/src/breadcrumbs.test.ts +0 -238
  96. package/src/cells/coerce.test.ts +0 -85
  97. package/src/cells/coerce.ts +0 -84
  98. package/src/clusterPaths.ts +0 -35
  99. package/src/columns/BadgeColumn.test.ts +0 -54
  100. package/src/columns/BadgeColumn.ts +0 -32
  101. package/src/columns/BooleanColumn.test.ts +0 -41
  102. package/src/columns/BooleanColumn.ts +0 -18
  103. package/src/columns/ColorColumn.test.ts +0 -37
  104. package/src/columns/ColorColumn.ts +0 -38
  105. package/src/columns/IconColumn.test.ts +0 -54
  106. package/src/columns/IconColumn.ts +0 -37
  107. package/src/columns/ImageColumn.test.ts +0 -41
  108. package/src/columns/ImageColumn.ts +0 -28
  109. package/src/columns/SelectColumn.ts +0 -98
  110. package/src/columns/TextColumn.test.ts +0 -190
  111. package/src/columns/TextColumn.ts +0 -20
  112. package/src/columns/TextInputColumn.ts +0 -68
  113. package/src/columns/ToggleColumn.ts +0 -46
  114. package/src/columns/editableColumns.test.ts +0 -238
  115. package/src/columns/index.ts +0 -9
  116. package/src/defaultGlobalPages.ts +0 -95
  117. package/src/defaultPages.test.ts +0 -634
  118. package/src/defaultPages.ts +0 -617
  119. package/src/defaultViewPage.test.ts +0 -147
  120. package/src/elements/Form.test.ts +0 -223
  121. package/src/elements/Form.ts +0 -416
  122. package/src/elements/ListTabs.ts +0 -28
  123. package/src/elements/Table.test.ts +0 -422
  124. package/src/elements/Table.ts +0 -850
  125. package/src/elements/TableGroup.test.ts +0 -260
  126. package/src/elements/TableGroup.ts +0 -334
  127. package/src/elements/dispatchAction.test.ts +0 -463
  128. package/src/elements/dispatchAction.ts +0 -355
  129. package/src/elements/dispatchForm.test.ts +0 -477
  130. package/src/elements/dispatchForm.ts +0 -1993
  131. package/src/elements/dispatchTable.test.ts +0 -1514
  132. package/src/elements/dispatchTable.ts +0 -745
  133. package/src/elements/index.ts +0 -21
  134. package/src/entries/BadgeEntry.ts +0 -39
  135. package/src/entries/CodeEntry.test.ts +0 -40
  136. package/src/entries/CodeEntry.ts +0 -52
  137. package/src/entries/ColorEntry.ts +0 -63
  138. package/src/entries/ComponentEntry.test.ts +0 -173
  139. package/src/entries/ComponentEntry.ts +0 -95
  140. package/src/entries/Entry.ts +0 -304
  141. package/src/entries/IconEntry.ts +0 -49
  142. package/src/entries/ImageEntry.ts +0 -61
  143. package/src/entries/KeyValueEntry.ts +0 -47
  144. package/src/entries/RepeatableEntry.test.ts +0 -239
  145. package/src/entries/RepeatableEntry.ts +0 -173
  146. package/src/entries/TextEntry.test.ts +0 -394
  147. package/src/entries/TextEntry.ts +0 -60
  148. package/src/entries/index.ts +0 -12
  149. package/src/entries/leaves.test.ts +0 -306
  150. package/src/entries/registry.ts +0 -54
  151. package/src/fields/BuilderField.test.ts +0 -1188
  152. package/src/fields/BuilderField.ts +0 -605
  153. package/src/fields/BuilderRelationship.test.ts +0 -811
  154. package/src/fields/CheckboxField.test.ts +0 -44
  155. package/src/fields/CheckboxField.ts +0 -27
  156. package/src/fields/CheckboxListField.test.ts +0 -99
  157. package/src/fields/CheckboxListField.ts +0 -66
  158. package/src/fields/ColorPickerField.test.ts +0 -33
  159. package/src/fields/ColorPickerField.ts +0 -25
  160. package/src/fields/DateField.ts +0 -54
  161. package/src/fields/DateTimeField.test.ts +0 -55
  162. package/src/fields/EmailField.ts +0 -16
  163. package/src/fields/Field.test.ts +0 -654
  164. package/src/fields/Field.ts +0 -817
  165. package/src/fields/FileUploadField.test.ts +0 -143
  166. package/src/fields/FileUploadField.ts +0 -159
  167. package/src/fields/HiddenField.test.ts +0 -27
  168. package/src/fields/HiddenField.ts +0 -28
  169. package/src/fields/KeyValueField.test.ts +0 -105
  170. package/src/fields/KeyValueField.ts +0 -55
  171. package/src/fields/MarkdownField.test.ts +0 -167
  172. package/src/fields/MarkdownField.ts +0 -162
  173. package/src/fields/NumberField.ts +0 -33
  174. package/src/fields/RadioField.test.ts +0 -94
  175. package/src/fields/RadioField.ts +0 -67
  176. package/src/fields/RepeaterField.test.ts +0 -1806
  177. package/src/fields/RepeaterField.ts +0 -939
  178. package/src/fields/RepeaterRelationship.test.ts +0 -1923
  179. package/src/fields/RepeaterSimple.test.ts +0 -248
  180. package/src/fields/RowButton.test.ts +0 -219
  181. package/src/fields/RowButton.ts +0 -135
  182. package/src/fields/SelectField.test.ts +0 -192
  183. package/src/fields/SelectField.ts +0 -235
  184. package/src/fields/SliderField.test.ts +0 -50
  185. package/src/fields/SliderField.ts +0 -53
  186. package/src/fields/SlugField.ts +0 -24
  187. package/src/fields/TagsInputField.test.ts +0 -154
  188. package/src/fields/TagsInputField.ts +0 -133
  189. package/src/fields/TextField.test.ts +0 -213
  190. package/src/fields/TextField.ts +0 -177
  191. package/src/fields/TextareaField.test.ts +0 -58
  192. package/src/fields/TextareaField.ts +0 -59
  193. package/src/fields/ToggleButtonsField.test.ts +0 -106
  194. package/src/fields/ToggleButtonsField.ts +0 -59
  195. package/src/fields/ToggleField.ts +0 -16
  196. package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
  197. package/src/fields/optionsResolver.ts +0 -95
  198. package/src/fields/resolveField.ts +0 -28
  199. package/src/filters/BooleanFilter.ts +0 -35
  200. package/src/filters/DateRangeFilter.test.ts +0 -194
  201. package/src/filters/DateRangeFilter.ts +0 -148
  202. package/src/filters/Filter.test.ts +0 -268
  203. package/src/filters/Filter.ts +0 -184
  204. package/src/filters/FormFilter.test.ts +0 -238
  205. package/src/filters/FormFilter.ts +0 -215
  206. package/src/filters/MultiSelectFilter.test.ts +0 -119
  207. package/src/filters/MultiSelectFilter.ts +0 -78
  208. package/src/filters/QueryBuilderFilter.test.ts +0 -662
  209. package/src/filters/QueryBuilderFilter.ts +0 -398
  210. package/src/filters/SelectFilter.ts +0 -46
  211. package/src/filters/TernaryFilter.test.ts +0 -160
  212. package/src/filters/TernaryFilter.ts +0 -72
  213. package/src/filters/TrashedFilter.test.ts +0 -149
  214. package/src/filters/TrashedFilter.ts +0 -55
  215. package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
  216. package/src/filters/queryBuilder/Constraint.ts +0 -115
  217. package/src/filters/queryBuilder/DateConstraint.ts +0 -69
  218. package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
  219. package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
  220. package/src/filters/queryBuilder/TextConstraint.ts +0 -64
  221. package/src/filters/queryBuilder/index.ts +0 -12
  222. package/src/icons/index.ts +0 -2
  223. package/src/icons/lucide.ts +0 -204
  224. package/src/icons/registry.test.ts +0 -56
  225. package/src/icons/registry.ts +0 -41
  226. package/src/icons/types.ts +0 -47
  227. package/src/index.ts +0 -525
  228. package/src/io/csv.test.ts +0 -142
  229. package/src/io/csv.ts +0 -170
  230. package/src/nestedRelationManagerData.test.ts +0 -547
  231. package/src/notifications/Notification.test.ts +0 -210
  232. package/src/notifications/Notification.ts +0 -354
  233. package/src/notifications/broadcast.test.ts +0 -110
  234. package/src/notifications/broadcast.ts +0 -95
  235. package/src/notifications/database.test.ts +0 -383
  236. package/src/notifications/database.ts +0 -398
  237. package/src/notifications/databaseNotifications.test.ts +0 -187
  238. package/src/notifications/dispatchNotificationAction.test.ts +0 -341
  239. package/src/notifications/dispatchNotificationAction.ts +0 -142
  240. package/src/notifications/flash.test.ts +0 -89
  241. package/src/notifications/flash.ts +0 -71
  242. package/src/notifications/index.ts +0 -45
  243. package/src/notifications/registerBroadcastAuth.test.ts +0 -134
  244. package/src/notifications/registerBroadcastAuth.ts +0 -100
  245. package/src/notifications/resolveSavedNotification.test.ts +0 -82
  246. package/src/notifications/resolveSavedNotification.ts +0 -59
  247. package/src/notifications/types.ts +0 -93
  248. package/src/orm/m2mAccessor.ts +0 -66
  249. package/src/orm/modelDefaults.test.ts +0 -633
  250. package/src/orm/modelDefaults.ts +0 -666
  251. package/src/pageData/breadcrumbs.ts +0 -288
  252. package/src/pageData/forms.ts +0 -578
  253. package/src/pageData/helpers.ts +0 -857
  254. package/src/pageData/misc.ts +0 -347
  255. package/src/pageData/navigation.ts +0 -842
  256. package/src/pageData/relationPages.ts +0 -1248
  257. package/src/pageData/relationTabs.ts +0 -286
  258. package/src/pageData/resourcePages.ts +0 -609
  259. package/src/pageData.test.ts +0 -1545
  260. package/src/pageData.ts +0 -341
  261. package/src/plugins/index.ts +0 -8
  262. package/src/plugins/themeEditor.test.ts +0 -36
  263. package/src/plugins/themeEditor.ts +0 -45
  264. package/src/react/AppShell.tsx +0 -251
  265. package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
  266. package/src/react/CollabRoomContext.ts +0 -98
  267. package/src/react/CollabTextRendererRegistry.ts +0 -102
  268. package/src/react/CommandPalette.tsx +0 -375
  269. package/src/react/CurrentUserContext.tsx +0 -50
  270. package/src/react/CustomPageWrapperGate.tsx +0 -69
  271. package/src/react/CustomPageWrapperRegistry.ts +0 -45
  272. package/src/react/FieldFocusReporterRegistry.ts +0 -37
  273. package/src/react/FieldLabelSlotRegistry.ts +0 -30
  274. package/src/react/FieldPresenceRegistry.ts +0 -46
  275. package/src/react/FormCollabBindingRegistry.ts +0 -242
  276. package/src/react/FormStateContext.tsx +0 -591
  277. package/src/react/HeadHooks.tsx +0 -126
  278. package/src/react/MarkdownEditorRegistry.test.ts +0 -38
  279. package/src/react/MarkdownEditorRegistry.ts +0 -107
  280. package/src/react/NotificationActionStrip.tsx +0 -263
  281. package/src/react/NotificationBell.tsx +0 -426
  282. package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
  283. package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
  284. package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
  285. package/src/react/PendingSuggestionsContext.tsx +0 -172
  286. package/src/react/RecordWrapperGate.tsx +0 -58
  287. package/src/react/RecordWrapperRegistry.ts +0 -39
  288. package/src/react/RenderHookSlot.tsx +0 -32
  289. package/src/react/RightSidebar.tsx +0 -257
  290. package/src/react/RightSidebarContext.tsx +0 -234
  291. package/src/react/RightSidebarTrigger.tsx +0 -53
  292. package/src/react/RowCoordsContext.tsx +0 -23
  293. package/src/react/SchemaRenderer.tsx +0 -549
  294. package/src/react/SearchTrigger.tsx +0 -46
  295. package/src/react/ThemeProvider.tsx +0 -93
  296. package/src/react/ThemeSettingsPage.tsx +0 -579
  297. package/src/react/ThemeToggle.tsx +0 -20
  298. package/src/react/Toaster.tsx +0 -158
  299. package/src/react/UserMenu.tsx +0 -196
  300. package/src/react/WidgetDataContext.tsx +0 -157
  301. package/src/react/cells/EditableCell.tsx +0 -389
  302. package/src/react/component-slots.test.ts +0 -103
  303. package/src/react/component-slots.ts +0 -116
  304. package/src/react/fieldJsHandler.test.ts +0 -166
  305. package/src/react/fieldJsHandler.ts +0 -79
  306. package/src/react/fields/BuilderInput.tsx +0 -1078
  307. package/src/react/fields/CheckboxInput.tsx +0 -39
  308. package/src/react/fields/CheckboxListInput.tsx +0 -102
  309. package/src/react/fields/ColorInput.tsx +0 -71
  310. package/src/react/fields/DateFieldInput.tsx +0 -70
  311. package/src/react/fields/DateTimeInput.tsx +0 -62
  312. package/src/react/fields/FieldShell.tsx +0 -348
  313. package/src/react/fields/FileUploadInput.tsx +0 -639
  314. package/src/react/fields/HiddenInput.tsx +0 -17
  315. package/src/react/fields/KeyValueInput.tsx +0 -230
  316. package/src/react/fields/MarkdownInput.tsx +0 -560
  317. package/src/react/fields/RadioInput.tsx +0 -81
  318. package/src/react/fields/RepeaterInput.test.ts +0 -116
  319. package/src/react/fields/RepeaterInput.tsx +0 -1420
  320. package/src/react/fields/SelectFieldInput.tsx +0 -280
  321. package/src/react/fields/SliderInput.tsx +0 -81
  322. package/src/react/fields/TagsInput.tsx +0 -283
  323. package/src/react/fields/TextLikeInput.tsx +0 -256
  324. package/src/react/fields/ToggleButtonsInput.tsx +0 -60
  325. package/src/react/fields/ToggleFieldInput.tsx +0 -56
  326. package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
  327. package/src/react/fields/relationshipRenameDispatch.ts +0 -97
  328. package/src/react/fields/repeaterReconcile.test.ts +0 -114
  329. package/src/react/fields/repeaterReconcile.ts +0 -104
  330. package/src/react/fields/rowChromeButton.tsx +0 -336
  331. package/src/react/fields/rowState.ts +0 -106
  332. package/src/react/fields/syncRowGates.test.ts +0 -202
  333. package/src/react/fields/syncRowGates.ts +0 -66
  334. package/src/react/fields/textInputControls.tsx +0 -238
  335. package/src/react/fields/useRowReorderDnd.ts +0 -78
  336. package/src/react/formStateHelpers.test.ts +0 -508
  337. package/src/react/formStateHelpers.ts +0 -381
  338. package/src/react/hooks/use-mobile.ts +0 -19
  339. package/src/react/icon-context.tsx +0 -60
  340. package/src/react/index.ts +0 -194
  341. package/src/react/layouts/SidebarLayout.tsx +0 -250
  342. package/src/react/layouts/TopbarLayout.tsx +0 -258
  343. package/src/react/navigate.tsx +0 -37
  344. package/src/react/onProviderSynced.test.ts +0 -90
  345. package/src/react/parseRecordEditUrl.test.ts +0 -122
  346. package/src/react/parseRecordEditUrl.ts +0 -94
  347. package/src/react/persistedState.ts +0 -40
  348. package/src/react/registry.ts +0 -48
  349. package/src/react/right-panel-registry.tsx +0 -47
  350. package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
  351. package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
  352. package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
  353. package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
  354. package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
  355. package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
  356. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
  357. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
  358. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
  359. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
  360. package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
  361. package/src/react/schemaRenderer/action/buttons.tsx +0 -99
  362. package/src/react/schemaRenderer/action/helpers.ts +0 -140
  363. package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
  364. package/src/react/schemaRenderer/columnFormat.ts +0 -65
  365. package/src/react/schemaRenderer/constants.ts +0 -50
  366. package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
  367. package/src/react/schemaRenderer/form/renderField.tsx +0 -511
  368. package/src/react/schemaRenderer/helpers.tsx +0 -81
  369. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
  370. package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
  371. package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
  372. package/src/react/schemaRenderer/table/filters.tsx +0 -1233
  373. package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
  374. package/src/react/schemaRenderer/table/links.tsx +0 -112
  375. package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
  376. package/src/react/schemaRenderer/table/url.tsx +0 -143
  377. package/src/react/theme-preview/apply.ts +0 -99
  378. package/src/react/theme-preview/build-html.ts +0 -436
  379. package/src/react/ui/button.tsx +0 -51
  380. package/src/react/ui/calendar.tsx +0 -67
  381. package/src/react/ui/checkbox.tsx +0 -29
  382. package/src/react/ui/dialog.tsx +0 -108
  383. package/src/react/ui/dropdown-menu.tsx +0 -97
  384. package/src/react/ui/input.tsx +0 -20
  385. package/src/react/ui/label.tsx +0 -21
  386. package/src/react/ui/popover.tsx +0 -50
  387. package/src/react/ui/select.tsx +0 -169
  388. package/src/react/ui/separator.tsx +0 -25
  389. package/src/react/ui/sheet.tsx +0 -136
  390. package/src/react/ui/sidebar.tsx +0 -723
  391. package/src/react/ui/skeleton.tsx +0 -13
  392. package/src/react/ui/slider.tsx +0 -34
  393. package/src/react/ui/switch.tsx +0 -28
  394. package/src/react/ui/table.tsx +0 -105
  395. package/src/react/ui/tabs.tsx +0 -63
  396. package/src/react/ui/textarea.tsx +0 -18
  397. package/src/react/ui/tooltip.tsx +0 -64
  398. package/src/react/useResizableWidth.ts +0 -139
  399. package/src/react/utils.ts +0 -6
  400. package/src/react/widgetRegistry.test.ts +0 -43
  401. package/src/react/widgetRegistry.ts +0 -50
  402. package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
  403. package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
  404. package/src/react/widgets/ViewRenderer.tsx +0 -71
  405. package/src/relationManagerData.test.ts +0 -1595
  406. package/src/richtext/index.ts +0 -8
  407. package/src/richtext/registry.ts +0 -89
  408. package/src/routes/globals.ts +0 -148
  409. package/src/routes/guard.test.ts +0 -325
  410. package/src/routes/helpers.ts +0 -704
  411. package/src/routes/pages.ts +0 -175
  412. package/src/routes/panel.ts +0 -204
  413. package/src/routes/relations.ts +0 -1243
  414. package/src/routes/resources.ts +0 -781
  415. package/src/routes/theme.ts +0 -91
  416. package/src/routes-nested-relations.test.ts +0 -676
  417. package/src/routes-relations.test.ts +0 -972
  418. package/src/routes.test.ts +0 -2027
  419. package/src/routes.ts +0 -303
  420. package/src/schema/Alert.test.ts +0 -109
  421. package/src/schema/Alert.ts +0 -131
  422. package/src/schema/Block.ts +0 -169
  423. package/src/schema/Breadcrumbs.ts +0 -40
  424. package/src/schema/Card.ts +0 -35
  425. package/src/schema/Divider.ts +0 -20
  426. package/src/schema/Element.ts +0 -219
  427. package/src/schema/EmptyState.test.ts +0 -37
  428. package/src/schema/EmptyState.ts +0 -63
  429. package/src/schema/Fieldset.ts +0 -43
  430. package/src/schema/Grid.ts +0 -43
  431. package/src/schema/Group.ts +0 -30
  432. package/src/schema/Heading.ts +0 -39
  433. package/src/schema/Html.ts +0 -67
  434. package/src/schema/Icon.ts +0 -54
  435. package/src/schema/Image.ts +0 -57
  436. package/src/schema/LinkTag.ts +0 -41
  437. package/src/schema/Markdown.ts +0 -85
  438. package/src/schema/MetaTag.ts +0 -41
  439. package/src/schema/RelationTabs.ts +0 -71
  440. package/src/schema/ScriptTag.ts +0 -55
  441. package/src/schema/Section.ts +0 -160
  442. package/src/schema/ServerDataElement.test.ts +0 -140
  443. package/src/schema/ServerDataElement.ts +0 -156
  444. package/src/schema/SlotComponent.test.ts +0 -77
  445. package/src/schema/SlotComponent.ts +0 -71
  446. package/src/schema/Split.ts +0 -50
  447. package/src/schema/Stat.test.ts +0 -118
  448. package/src/schema/Stat.ts +0 -154
  449. package/src/schema/StatsOverview.test.ts +0 -141
  450. package/src/schema/StatsOverview.ts +0 -119
  451. package/src/schema/StyleTag.ts +0 -35
  452. package/src/schema/TableWidget.test.ts +0 -297
  453. package/src/schema/TableWidget.ts +0 -289
  454. package/src/schema/Tabs.ts +0 -79
  455. package/src/schema/Text.ts +0 -58
  456. package/src/schema/UnorderedList.ts +0 -49
  457. package/src/schema/View.test.ts +0 -111
  458. package/src/schema/View.ts +0 -127
  459. package/src/schema/Wizard.ts +0 -220
  460. package/src/schema/containers.test.ts +0 -564
  461. package/src/schema/headTags.test.ts +0 -134
  462. package/src/schema/index.ts +0 -40
  463. package/src/schema/primes.test.ts +0 -269
  464. package/src/schema/resolveSchema.test.ts +0 -379
  465. package/src/schema/resolveSchema.ts +0 -917
  466. package/src/schema/sanitize.ts +0 -58
  467. package/src/search.test.ts +0 -446
  468. package/src/search.ts +0 -178
  469. package/src/sessionFilters.test.ts +0 -375
  470. package/src/sessionFilters.ts +0 -143
  471. package/src/slot-components/index.ts +0 -10
  472. package/src/slot-components/registry.ts +0 -56
  473. package/src/styles/file-upload.css +0 -13
  474. package/src/summarizers/Summarizer.test.ts +0 -84
  475. package/src/summarizers/Summarizer.ts +0 -123
  476. package/src/summarizers/index.ts +0 -11
  477. package/src/theme/base-colors.ts +0 -68
  478. package/src/theme/chart-colors.ts +0 -50
  479. package/src/theme/colors.ts +0 -447
  480. package/src/theme/generate-css.test.ts +0 -139
  481. package/src/theme/generate-css.ts +0 -44
  482. package/src/theme/generate-scale.test.ts +0 -106
  483. package/src/theme/generate-scale.ts +0 -97
  484. package/src/theme/icon-map.ts +0 -42
  485. package/src/theme/index.ts +0 -34
  486. package/src/theme/migrate.test.ts +0 -178
  487. package/src/theme/migrate.ts +0 -81
  488. package/src/theme/presets.ts +0 -135
  489. package/src/theme/radius.ts +0 -18
  490. package/src/theme/resolve.test.ts +0 -238
  491. package/src/theme/resolve.ts +0 -96
  492. package/src/theme/spacing.ts +0 -18
  493. package/src/theme/storage.test.ts +0 -126
  494. package/src/theme/storage.ts +0 -106
  495. package/src/theme/theme-colors.ts +0 -88
  496. package/src/theme/types.ts +0 -125
  497. package/src/uploads/UploadAdapter.ts +0 -35
  498. package/src/uploads/index.ts +0 -2
  499. package/src/uploads/localUpload.test.ts +0 -70
  500. package/src/uploads/localUpload.ts +0 -84
  501. package/src/validation/Validator.ts +0 -49
  502. package/src/validation/index.ts +0 -28
  503. package/src/validation/rules.ts +0 -78
  504. package/src/validation/runValidators.ts +0 -435
  505. package/src/validation/uniqueValidator.test.ts +0 -196
  506. package/src/validation/uniqueValidator.ts +0 -133
  507. package/src/validation/validators.test.ts +0 -268
  508. package/src/vite.test.ts +0 -184
  509. package/src/vite.ts +0 -787
  510. package/src/widgets/index.ts +0 -10
  511. package/src/widgets/registry.ts +0 -45
  512. package/src/widgets.test.ts +0 -592
  513. package/tsconfig.build.json +0 -11
  514. package/tsconfig.json +0 -4
  515. package/tsconfig.test.json +0 -10
  516. package/views/react/Dashboard.tsx +0 -27
  517. package/views/react/Resources/Form.tsx +0 -102
  518. package/views/react/Resources/Index.tsx +0 -49
@@ -1,1993 +0,0 @@
1
- import { Element } from '../schema/Element.js'
2
- import { Field, type AfterStateUpdatedContext } from '../fields/Field.js'
3
- import { RepeaterField, isRepeaterField } from '../fields/RepeaterField.js'
4
- import type { RepeaterRelationshipConfig, RepeaterRowContext } from '../fields/RepeaterField.js'
5
- import { BuilderField, isBuilderField } from '../fields/BuilderField.js'
6
- import type { BuilderRelationshipConfig } from '../fields/BuilderField.js'
7
- import { Form, type FormContext } from './Form.js'
8
- import { validateSchema, type ValidationErrors } from '../validation/index.js'
9
- import { resolveSavedNotification, type NotificationMeta } from '../notifications/index.js'
10
- import {
11
- getParentRelationDescriptor,
12
- getMorphRelationDescriptor,
13
- getM2MRelationDescriptor,
14
- computeMorphPayload,
15
- getPrimaryKey,
16
- resolveRelatedQuery,
17
- type ModelLike,
18
- type MorphRelationDescriptor,
19
- } from '../orm/modelDefaults.js'
20
- import { resolveM2MAccessor } from '../orm/m2mAccessor.js'
21
-
22
- /**
23
- * Server-emitted rename of a `Repeater.relationship` / `Builder.relationship`
24
- * row's stable id. When a brand-new row is submitted with a renderer-minted
25
- * UUID `__id`, `persistRelationshipRows` calls `model.create(...)` and the
26
- * DB assigns a real primary key — the row's identity then switches from
27
- * the UUID to `String(pk)`. The submitter learns the new id from the
28
- * reloaded form's `initialRows`; other collab peers don't, leaving their
29
- * Y.Doc row state keyed by the orphan UUID. Phase B (see
30
- * `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`) lets a
31
- * collab adapter subscribe to these renames from the form-submit JSON
32
- * response and rename the row in the shared CRDT so other peers converge
33
- * without reloading. Carries no opinion about transport — emitted unconditionally
34
- * on every relationship-backed row create; consumers without a collab
35
- * binding ignore the field.
36
- */
37
- export interface RelationshipRename {
38
- /** Field name on the form (the `Repeater.make(...)` / `Builder.make(...)` name). */
39
- field: string
40
- /** The id the renderer submitted — usually a UUID, occasionally a numeric string
41
- * when the consumer pre-assigned an id. May equal `new` when the consumer's
42
- * pre-assigned id matched the DB-assigned PK; consumers can no-op in that case. */
43
- old: string
44
- /** The DB-assigned primary key, stringified. */
45
- new: string
46
- }
47
-
48
- export interface DispatchSuccess<R> {
49
- ok: true
50
- record: R
51
- redirect: string | undefined
52
- /**
53
- * Resolved success notifications to flash to the client. Empty when the
54
- * form has `disableSavedNotification()` or no spec configured. Currently
55
- * only delivered through the JSON action-modal path; the form-post 303
56
- * path drops them until a flash mechanism lands.
57
- */
58
- notifications: NotificationMeta[]
59
- /**
60
- * Per-row UUID → PK renames emitted by `Repeater.relationship` /
61
- * `Builder.relationship` creates. Empty when the submitted form had
62
- * no relationship-backed fields or no new rows. See {@link RelationshipRename}.
63
- */
64
- relationshipRenames: RelationshipRename[]
65
- }
66
-
67
- export interface DispatchFailure {
68
- ok: false
69
- errors: ValidationErrors
70
- }
71
-
72
- export type DispatchResult<R> = DispatchSuccess<R> | DispatchFailure
73
-
74
- /**
75
- * Run the full form submit lifecycle on a `Form` element. Mode is inferred
76
- * from `ctx.record`: undefined → create, set → update. Mode-specific hooks
77
- * fire after their generic counterparts so cross-cutting logic (auth
78
- * stamping, audit fields) lives above mode-specific business rules.
79
- *
80
- * Order:
81
- *
82
- * validateSchema
83
- * → form-level validators
84
- * → mutateData (both modes)
85
- * → mutateDataBeforeCreate / mutateDataBeforeUpdate
86
- * → beforeSave (both modes)
87
- * → beforeCreate / beforeUpdate
88
- * → handleCreate || handleUpdate || save ← persistence
89
- * → afterCreate / afterUpdate
90
- * → afterSave (both modes)
91
- * → redirectAfterSave
92
- *
93
- * Validation failures short-circuit and return `{ ok: false, errors }`. On
94
- * success the result includes the saved record and the resolved redirect URL
95
- * (when `redirectAfterSave` is configured).
96
- *
97
- * Form-level validator errors are keyed under `_form` so the renderer can
98
- * surface them as a top-of-form banner without colliding with field names.
99
- */
100
- export async function dispatchFormSubmit<R = unknown>(
101
- form: Form<R>,
102
- body: Record<string, unknown>,
103
- ctx: FormContext<R>,
104
- ): Promise<DispatchResult<R>> {
105
- const children = form.getChildren() ?? []
106
- const isCreate = ctx.record === undefined
107
-
108
- const fieldErrors = await validateSchema(children as Element[], body, ctx.record)
109
-
110
- const formValidatorErrors: string[] = []
111
- for (const v of form.getFormValidators()) {
112
- const msg = await v(body, { values: body, ...(ctx.record !== undefined ? { record: ctx.record } : {}) })
113
- if (msg) formValidatorErrors.push(msg)
114
- }
115
-
116
- const errors: ValidationErrors = { ...fieldErrors }
117
- if (formValidatorErrors.length > 0) {
118
- errors['_form'] = formValidatorErrors
119
- }
120
-
121
- if (Object.keys(errors).length > 0) {
122
- return { ok: false, errors }
123
- }
124
-
125
- let data: Record<string, unknown> = coerceFormValues(children as Element[], body)
126
- // Flatten `simple()` Repeaters from the wrapped `[{name: v}]` pipeline
127
- // shape to the user-declared `[v]` storage shape before any user-side
128
- // transform runs. Non-simple repeaters are untouched.
129
- data = unwrapSimpleRepeaters(children as Element[], data)
130
-
131
- // Pull relationship-backed Repeater values OUT of `data` so the
132
- // parent's save handler doesn't try to write them as a JSON column.
133
- // The deferral list holds the rows + the field reference; we run
134
- // the create/update/delete diff against the relation AFTER the
135
- // parent save returns (so we have a parent PK in create mode).
136
- const relationshipDeferrals = extractRelationshipRepeaters(children as Element[], data)
137
- // Same trick for Builders. Heterogeneous-row sibling — each row is a
138
- // `{ __id?, type, data }` envelope persisted as a child carrying a
139
- // discriminator column + a JSON payload column.
140
- const builderRelationshipDeferrals = extractRelationshipBuilders(children as Element[], data)
141
-
142
- const mutate = form.getMutateData()
143
- if (mutate) data = await mutate(data, { ...ctx, values: data })
144
-
145
- const modeMutate = isCreate ? form.getMutateDataBeforeCreate() : form.getMutateDataBeforeUpdate()
146
- if (modeMutate) data = await modeMutate(data, { ...ctx, values: data })
147
-
148
- const before = form.getBeforeSave()
149
- if (before) await before(data, { ...ctx, values: data })
150
-
151
- const modeBefore = isCreate ? form.getBeforeCreate() : form.getBeforeUpdate()
152
- if (modeBefore) await modeBefore(data, { ...ctx, values: data })
153
-
154
- const persist = (isCreate ? form.getHandleCreate() : form.getHandleUpdate()) ?? form.getSave()
155
- if (!persist) {
156
- throw new Error(
157
- '[Pilotiq] Form has no save() handler. Configure Form.save() (or handleCreate/handleUpdate) on the page schema, or override Resource.pages() with a Page that supplies one.',
158
- )
159
- }
160
- const record = await persist(data, { ...ctx, values: data })
161
-
162
- // Persist the relationship-backed Repeater diffs against the saved
163
- // parent. Runs BEFORE `afterCreate / afterUpdate` so user hooks can
164
- // observe the fully-saved tree (parent + children).
165
- const relationshipRenames: RelationshipRename[] = []
166
- if (relationshipDeferrals.length > 0 || builderRelationshipDeferrals.length > 0) {
167
- const parentModel = (ctx as { parentModel?: ModelLike }).parentModel
168
- if (!parentModel) {
169
- throw new Error(
170
- '[Pilotiq] Repeater/Builder.relationship: form has relationship-backed rows but no parentModel on the FormContext. ' +
171
- 'Routes that submit forms with relationship-backed Repeaters/Builders must set ctx.parentModel = R.model.',
172
- )
173
- }
174
- for (const deferral of relationshipDeferrals) {
175
- const renames = await persistRelationshipRows(record, deferral, parentModel)
176
- relationshipRenames.push(...renames)
177
- }
178
- for (const deferral of builderRelationshipDeferrals) {
179
- const renames = await persistRelationshipBuilderRows(record, deferral, parentModel)
180
- relationshipRenames.push(...renames)
181
- }
182
- }
183
-
184
- const modeAfter = isCreate ? form.getAfterCreate() : form.getAfterUpdate()
185
- if (modeAfter) await modeAfter(record, { ...ctx, record, values: data })
186
-
187
- const after = form.getAfterSave()
188
- if (after) await after(record, { ...ctx, record, values: data })
189
-
190
- const redirectFn = form.getRedirectAfterSave()
191
- const redirect = redirectFn ? redirectFn(record, { ...ctx, record, values: data }) : undefined
192
-
193
- const notification = resolveSavedNotification(
194
- form,
195
- isCreate ? 'create' : 'update',
196
- record,
197
- { ...ctx, record, values: data },
198
- )
199
- const notifications = notification ? [notification] : []
200
-
201
- return { ok: true, record, redirect, notifications, relationshipRenames }
202
- }
203
-
204
- /**
205
- * Coerce raw form-body strings into the runtime types each field expects:
206
- * booleans for toggles, numbers for number inputs, Dates for dates. The
207
- * browser submits everything as a string by default, but ORM layers (Prisma,
208
- * etc.) expect actual booleans/numbers/Dates. Runs after validation so
209
- * validators still see the raw submitted text.
210
- *
211
- * Empty / missing values are normalized:
212
- * - `toggle` → `false` when missing or 'false'/empty; `true` otherwise.
213
- * - `number` → `null` when empty; otherwise `Number(v)` (NaN passes through).
214
- * - `date` → `null` when empty; otherwise a `Date` parsed from the string.
215
- *
216
- * Other field types are passed through untouched.
217
- */
218
- /** Remove every occurrence of any character in `chars` from `value`.
219
- * O(n) — uses a Set for membership lookup. Multi-codepoint entries
220
- * in `chars` (e.g. an emoji passed as one mask token) are matched
221
- * whole; the function compares against `Array.from(value)` so
222
- * surrogate pairs round-trip correctly. */
223
- function stripChars(value: string, chars: string[]): string {
224
- const set = new Set(chars)
225
- let out = ''
226
- for (const ch of value) if (!set.has(ch)) out += ch
227
- return out
228
- }
229
-
230
- export function coerceFormValues(
231
- elements: Element[],
232
- body: Record<string, unknown>,
233
- ): Record<string, unknown> {
234
- const out: Record<string, unknown> = { ...body }
235
-
236
- // Plan #14 — Repeater pass. Run BEFORE the regular field coercion so
237
- // each row's body is coerced against the inner schema (recursive
238
- // `coerceFormValues` call), not against the parent form. Two body
239
- // shapes supported: array-valued JSON (`out[name]` already an array)
240
- // and flat-keyed form bodies (`name.0.childName=…`). Flat-shape keys
241
- // are removed from `out` after the Repeater value is composed so they
242
- // don't leak into the persisted record.
243
- walkRepeatersTopLevel(elements, repeater => {
244
- if (repeater.isDehydrated() === false) {
245
- delete out[repeater.name]
246
- return
247
- }
248
- out[repeater.name] = coerceRepeaterValue(repeater, out)
249
- const prefix = `${repeater.name}.`
250
- for (const key of Object.keys(out)) {
251
- if (key.startsWith(prefix)) delete out[key]
252
- }
253
- })
254
-
255
- // Plan #14 follow-up — Builder pass. Same disposition as Repeater
256
- // (run BEFORE the generic field walker so per-row inner-schema
257
- // coercion uses the row's own body, not the parent form's), but each
258
- // row's coercion is dispatched against the block matching the row's
259
- // `type` discriminator. Rows whose `type` doesn't match a registered
260
- // block have their `data` body passed through verbatim — better to
261
- // round-trip than to silently drop unknown content.
262
- walkBuildersTopLevel(elements, builder => {
263
- if (builder.isDehydrated() === false) {
264
- delete out[builder.name]
265
- return
266
- }
267
- out[builder.name] = coerceBuilderValue(builder, out)
268
- const prefix = `${builder.name}.`
269
- for (const key of Object.keys(out)) {
270
- if (key.startsWith(prefix)) delete out[key]
271
- }
272
- })
273
-
274
- walkFields(elements, field => {
275
- const name = field.name
276
-
277
- // Plan #6 — `dehydrated(false)` fields are decorative / computed;
278
- // their value never enters the persisted record. Drop the body key
279
- // before any coercion or validation runs so downstream code can't
280
- // see it.
281
- if (field.isDehydrated() === false) {
282
- delete out[name]
283
- return
284
- }
285
-
286
- const raw = out[name]
287
- switch (field.fieldType) {
288
- case 'toggle':
289
- case 'checkbox': {
290
- if (raw === undefined || raw === null || raw === '' || raw === 'false' || raw === '0' || raw === false) {
291
- out[name] = false
292
- } else {
293
- out[name] = true
294
- }
295
- break
296
- }
297
- case 'number':
298
- case 'slider': {
299
- if (raw === undefined || raw === null || raw === '') {
300
- out[name] = null
301
- } else if (typeof raw === 'string') {
302
- out[name] = Number(raw)
303
- }
304
- break
305
- }
306
- case 'date':
307
- case 'dateTime': {
308
- // Both 'date' and 'dateTime' accept ISO strings and
309
- // YYYY-MM-DD(THH:mm) shapes — `new Date()` handles both.
310
- if (raw === undefined || raw === null || raw === '') {
311
- out[name] = null
312
- } else if (typeof raw === 'string') {
313
- out[name] = new Date(raw)
314
- }
315
- break
316
- }
317
- case 'checkboxList': {
318
- // HTML form bodies post checkbox-lists as either an array (when
319
- // multiple boxes are checked) or a single string (one checked) or
320
- // undefined (none). Normalize all three to `string[]`.
321
- if (raw === undefined || raw === null) {
322
- out[name] = []
323
- } else if (Array.isArray(raw)) {
324
- out[name] = raw.map(v => String(v))
325
- } else {
326
- out[name] = [String(raw)]
327
- }
328
- break
329
- }
330
- case 'tagsInput': {
331
- // Client serializes the chip set as a JSON-encoded string in a
332
- // single hidden input. Parse back into `string[]`. Already-array
333
- // values pass through (e.g. when a `live()` partial-resolve has
334
- // already shipped structured data, or when a server-side default
335
- // landed pre-coerce). Empty / null / unparseable → `[]`.
336
- if (raw === undefined || raw === null || raw === '') {
337
- out[name] = []
338
- } else if (Array.isArray(raw)) {
339
- out[name] = raw.map(v => String(v))
340
- } else if (typeof raw === 'string') {
341
- try {
342
- const parsed = JSON.parse(raw)
343
- if (Array.isArray(parsed)) {
344
- out[name] = parsed.map(v => String(v))
345
- } else {
346
- out[name] = []
347
- }
348
- } catch {
349
- out[name] = []
350
- }
351
- } else {
352
- out[name] = []
353
- }
354
- break
355
- }
356
- case 'color': {
357
- // Empty string → null so DB nullable columns accept it. Otherwise
358
- // pass the hex string through verbatim.
359
- if (raw === undefined || raw === null || raw === '') {
360
- out[name] = null
361
- }
362
- break
363
- }
364
- case 'fileUpload': {
365
- // The browser already turned uploaded files into URLs via the
366
- // `_uploads` route; what arrives here is either a string, a
367
- // string[] (multi-mode), or a JSON-encoded array (when the
368
- // client serialized through a hidden input). Normalize to the
369
- // declared shape: array bodies → string[], string body → string.
370
- if (raw === undefined || raw === null || raw === '') {
371
- out[name] = null
372
- } else if (Array.isArray(raw)) {
373
- out[name] = raw.map(v => String(v))
374
- } else if (typeof raw === 'string') {
375
- // Try JSON-decode for multi-file fields encoded as JSON; otherwise pass through.
376
- if (raw.startsWith('[')) {
377
- try {
378
- const parsed = JSON.parse(raw)
379
- if (Array.isArray(parsed)) { out[name] = parsed.map(v => String(v)); break }
380
- } catch { /* fall through */ }
381
- }
382
- out[name] = raw
383
- }
384
- break
385
- }
386
- case 'keyValue': {
387
- // Client serializes the row map as a JSON string in a hidden
388
- // input. Parse back into a Record<string,string>; filter empty
389
- // rows (`{ "": "" }`) before yielding so the persisted record
390
- // doesn't carry placeholder noise. Already-object values pass
391
- // through (e.g. when the `live()` partial-resolve already shipped
392
- // structured data).
393
- let parsed: Record<string, string> = {}
394
- if (raw === undefined || raw === null || raw === '') {
395
- parsed = {}
396
- } else if (typeof raw === 'string') {
397
- try {
398
- const obj = JSON.parse(raw)
399
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
400
- for (const [k, v] of Object.entries(obj)) {
401
- parsed[String(k)] = v == null ? '' : String(v)
402
- }
403
- }
404
- } catch { parsed = {} }
405
- } else if (typeof raw === 'object' && !Array.isArray(raw)) {
406
- for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
407
- parsed[String(k)] = v == null ? '' : String(v)
408
- }
409
- }
410
- const filtered: Record<string, string> = {}
411
- for (const [k, v] of Object.entries(parsed)) {
412
- if (k === '' && v === '') continue
413
- filtered[k] = v
414
- }
415
- out[name] = filtered
416
- break
417
- }
418
- case 'richtext': {
419
- // Editor posts the document as a JSON-encoded string via a hidden
420
- // input. Prisma's Json column wants a real object, so parse here.
421
- // Empty / unparseable → null so the column accepts it.
422
- if (raw === undefined || raw === null || raw === '') {
423
- out[name] = null
424
- } else if (typeof raw === 'string') {
425
- try { out[name] = JSON.parse(raw) }
426
- catch { out[name] = null }
427
- }
428
- break
429
- }
430
- default:
431
- // text/textarea/email/select/slug — leave as string.
432
- break
433
- }
434
-
435
- // `TextField.trim()` — strips leading/trailing whitespace from the
436
- // submitted value. Runs BEFORE stripCharacters so a value like
437
- // `' (415) 555-1212 '` first trims, then has the listed mask
438
- // characters removed. Skipped for non-strings.
439
- const trimmer = (field as { getTrim?: () => boolean }).getTrim
440
- if (typeof trimmer === 'function' && trimmer.call(field)) {
441
- const cur = out[name]
442
- if (typeof cur === 'string') out[name] = cur.trim()
443
- }
444
-
445
- // `TextField.stripCharacters([…])` — applies after type-specific
446
- // coercion so the persisted value never carries the listed
447
- // characters. Duck-typed: any Field whose `getStripCharacters?`
448
- // returns a non-empty list opts in. Skipped for non-strings (the
449
- // pre-coerce switch may have produced numbers / booleans / arrays).
450
- const stripper = (field as { getStripCharacters?: () => string[] | undefined }).getStripCharacters
451
- if (typeof stripper === 'function') {
452
- const chars = stripper.call(field)
453
- if (chars && chars.length > 0) {
454
- const cur = out[name]
455
- if (typeof cur === 'string' && cur.length > 0) {
456
- out[name] = stripChars(cur, chars)
457
- }
458
- }
459
- }
460
- })
461
- return out
462
- }
463
-
464
- function walkFields(elements: Element[], visit: (f: Field) => void): void {
465
- for (const el of elements) {
466
- if (el instanceof Field) {
467
- visit(el)
468
- // Plan #14 — don't recurse into Repeater / Builder children. Their
469
- // inner schemas belong to row bodies, not the parent form's body,
470
- // so the parent walker would coerce siblings against the wrong
471
- // values map. The dedicated Repeater + Builder passes in
472
- // `coerceFormValues` recurse into rows with the proper per-row body.
473
- if (el instanceof RepeaterField) continue
474
- if (el instanceof BuilderField) continue
475
- }
476
- const children = el.getChildren()
477
- if (children && children.length > 0) walkFields(children as Element[], visit)
478
- }
479
- }
480
-
481
- /**
482
- * Walk an element tree and visit every top-level Repeater — i.e., every
483
- * `RepeaterField` that isn't itself nested inside another Repeater. Inner
484
- * Repeaters are handled recursively when the outer Repeater coerces its
485
- * row bodies against the inner schema (which then enters this walker
486
- * again from `coerceFormValues`).
487
- */
488
- function walkRepeatersTopLevel(
489
- elements: Element[],
490
- visit: (f: RepeaterField) => void,
491
- ): void {
492
- for (const el of elements) {
493
- if (el instanceof RepeaterField) {
494
- visit(el)
495
- continue
496
- }
497
- // Builder boundaries are also opaque — its inner schemas live per-row
498
- // and never need to be visited by the Repeater pass.
499
- if (el instanceof BuilderField) continue
500
- const children = el.getChildren()
501
- if (children && children.length > 0) walkRepeatersTopLevel(children as Element[], visit)
502
- }
503
- }
504
-
505
- /**
506
- * Walk an Element tree and visit every top-level Builder — i.e., every
507
- * `BuilderField` that isn't itself nested inside a Repeater or another
508
- * Builder. Inner Builders are reached recursively when the outer
509
- * array-row field coerces its row bodies (which then re-enters this
510
- * walker via `coerceFormValues`).
511
- */
512
- function walkBuildersTopLevel(
513
- elements: Element[],
514
- visit: (f: BuilderField) => void,
515
- ): void {
516
- for (const el of elements) {
517
- if (el instanceof BuilderField) {
518
- visit(el)
519
- continue
520
- }
521
- if (el instanceof RepeaterField) continue
522
- const children = el.getChildren()
523
- if (children && children.length > 0) walkBuildersTopLevel(children as Element[], visit)
524
- }
525
- }
526
-
527
- /**
528
- * Build the coerced array value for a single Builder field from the
529
- * parent form body. Two body shapes are supported:
530
- *
531
- * 1. **JSON-shape** — `body[name]` is `unknown[]`. Each entry should be
532
- * an object with shape `{ __id?, type, data?: {…} }`. Non-object
533
- * entries coerce to a sentinel empty row; missing / non-string `type`
534
- * rounds to `''` (resolver flags as `unknownType`).
535
- * 2. **Flat-shape** — body has keys like `${name}.${i}.type`,
536
- * `${name}.${i}.__id`, `${name}.${i}.data.${childName}`. Indices are
537
- * grouped, gaps filled with empty rows, and the per-block schema
538
- * drives coercion of `data.*` keys.
539
- *
540
- * Trailing rows whose `data` body is empty are trimmed (matching
541
- * Repeater's posture). The row's `__id` and `type` alone don't keep a
542
- * row alive — same trim semantics.
543
- */
544
- function coerceBuilderValue(
545
- field: BuilderField,
546
- body: Record<string, unknown>,
547
- ): Array<Record<string, unknown>> {
548
- const fieldName = field.name
549
- const raw = body[fieldName]
550
-
551
- type RawRow = { __id?: string; type: string; data: Record<string, unknown> }
552
- let rows: RawRow[] = []
553
-
554
- if (Array.isArray(raw)) {
555
- rows = raw.map(coerceBuilderRowEntry)
556
- } else {
557
- const prefix = `${fieldName}.`
558
- const grouped = new Map<number, RawRow>()
559
- let maxIdx = -1
560
- for (const key of Object.keys(body)) {
561
- if (!key.startsWith(prefix)) continue
562
- const rest = key.slice(prefix.length)
563
- const dot = rest.indexOf('.')
564
- if (dot < 0) continue
565
- const idxStr = rest.slice(0, dot)
566
- const tail = rest.slice(dot + 1)
567
- const idx = Number(idxStr)
568
- if (!Number.isInteger(idx) || idx < 0) continue
569
- if (idx > maxIdx) maxIdx = idx
570
- let row = grouped.get(idx)
571
- if (!row) { row = { type: '', data: {} }; grouped.set(idx, row) }
572
- const value = body[key]
573
- if (tail === '__id') {
574
- if (typeof value === 'string') row.__id = value
575
- } else if (tail === 'type') {
576
- if (typeof value === 'string') row.type = value
577
- } else if (tail.startsWith('data.')) {
578
- row.data[tail.slice('data.'.length)] = value
579
- } else if (tail === 'data') {
580
- // Whole `data` body posted as a single value (rare — typically
581
- // a stringified JSON blob from a hidden input). Best-effort parse.
582
- if (value && typeof value === 'object' && !Array.isArray(value)) {
583
- row.data = { ...(value as Record<string, unknown>) }
584
- } else if (typeof value === 'string' && value !== '') {
585
- try {
586
- const parsed = JSON.parse(value)
587
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
588
- row.data = parsed as Record<string, unknown>
589
- }
590
- } catch { /* leave row.data alone */ }
591
- }
592
- }
593
- }
594
- if (maxIdx >= 0) {
595
- rows = Array.from({ length: maxIdx + 1 }, (_, i) => grouped.get(i) ?? { type: '', data: {} })
596
- }
597
- }
598
-
599
- // Trim trailing empty rows (matches Repeater). A row counts as empty
600
- // when its `data` body has no values beyond round-tripped sentinels.
601
- // Note we don't gate on `type` — a freshly-picked block with no fields
602
- // typed in is still "untouched" for the purposes of submit-trim.
603
- while (rows.length > 0 && isBuilderRowEmpty(rows[rows.length - 1]!)) {
604
- rows.pop()
605
- }
606
-
607
- return rows.map(row => {
608
- const block = field.getBlock(row.type)
609
- let coercedData: Record<string, unknown>
610
- if (block) {
611
- coercedData = coerceFormValues(block.getSchema(), row.data)
612
- } else {
613
- // Unknown block type — pass `data` through verbatim so a stale
614
- // record with a since-removed block type doesn't lose its
615
- // contents on the next save. Validation will surface the issue.
616
- coercedData = { ...row.data }
617
- }
618
- const out: Record<string, unknown> = { type: row.type, data: coercedData }
619
- if (typeof row.__id === 'string') out['__id'] = row.__id
620
- return out
621
- })
622
- }
623
-
624
- function coerceBuilderRowEntry(raw: unknown): { __id?: string; type: string; data: Record<string, unknown> } {
625
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
626
- return { type: '', data: {} }
627
- }
628
- const r = raw as Record<string, unknown>
629
- const type = typeof r['type'] === 'string' ? (r['type'] as string) : ''
630
- const dataRaw = r['data']
631
- const data: Record<string, unknown> = (dataRaw && typeof dataRaw === 'object' && !Array.isArray(dataRaw))
632
- ? { ...(dataRaw as Record<string, unknown>) }
633
- : {}
634
- const out: { __id?: string; type: string; data: Record<string, unknown> } = { type, data }
635
- if (typeof r['__id'] === 'string') out.__id = r['__id'] as string
636
- return out
637
- }
638
-
639
- function isBuilderRowEmpty(row: { type: string; data: Record<string, unknown> }): boolean {
640
- for (const [k, v] of Object.entries(row.data)) {
641
- if (v === undefined || v === null || v === '') continue
642
- void k
643
- return false
644
- }
645
- return true
646
- }
647
-
648
- /**
649
- * Build the coerced array value for a single Repeater field from the
650
- * parent form body. Two body shapes are supported:
651
- *
652
- * 1. **JSON-shape** — `body[name]` is an `unknown[]`. Each element should
653
- * be an object; non-object entries coerce to `{}`. This is the SPA
654
- * `fetch+JSON` path (the default since `feedback_action_dispatch_fetch_vs_303.md`).
655
- * 2. **Flat-shape** — body has keys like `${name}.${i}.${childName}`.
656
- * The browser submits these for `application/x-www-form-urlencoded`
657
- * bodies when the form-post 303 fallback path is used. Indices are
658
- * grouped, gaps are filled with `{}`, and the resulting per-row
659
- * bodies feed into the recursive coercion call.
660
- *
661
- * Empty trailing rows (no entered values, only `__id` carrying through
662
- * from the previous render) are trimmed before the coerced array is
663
- * returned.
664
- */
665
- function coerceRepeaterValue(
666
- field: RepeaterField,
667
- body: Record<string, unknown>,
668
- ): Array<Record<string, unknown>> {
669
- const inner = field.getInnerSchema()
670
- const fieldName = field.name
671
- const raw = body[fieldName]
672
- const simpleInner = field.getSimpleInnerField()
673
-
674
- let rowBodies: Array<Record<string, unknown>> = []
675
- if (Array.isArray(raw)) {
676
- rowBodies = raw.map(r => simpleInner ? coerceSimpleEntry(r, simpleInner.name) : coerceRowEntry(r))
677
- } else {
678
- const prefix = `${fieldName}.`
679
- const grouped = new Map<number, Record<string, unknown>>()
680
- let maxIdx = -1
681
- for (const key of Object.keys(body)) {
682
- if (!key.startsWith(prefix)) continue
683
- const rest = key.slice(prefix.length)
684
- const dot = rest.indexOf('.')
685
- if (dot < 0) continue
686
- const idxStr = rest.slice(0, dot)
687
- const childKey = rest.slice(dot + 1)
688
- const idx = Number(idxStr)
689
- if (!Number.isInteger(idx) || idx < 0) continue
690
- if (idx > maxIdx) maxIdx = idx
691
- let row = grouped.get(idx)
692
- if (!row) { row = {}; grouped.set(idx, row) }
693
- row[childKey] = body[key]
694
- }
695
- if (maxIdx >= 0) {
696
- rowBodies = Array.from({ length: maxIdx + 1 }, (_, i) => grouped.get(i) ?? {})
697
- }
698
- }
699
-
700
- // Trim trailing rows where the user didn't enter anything beyond the
701
- // round-tripped `__id`. We trim BEFORE coercion so default fills (e.g.
702
- // toggle → false, number → null) don't disguise an untouched row as a
703
- // touched one. Only trailing emptiness — gaps in the middle survive.
704
- while (rowBodies.length > 0 && isRawRowEmpty(rowBodies[rowBodies.length - 1]!)) {
705
- rowBodies.pop()
706
- }
707
-
708
- return rowBodies.map(rowBody => {
709
- const coerced = coerceFormValues(inner, rowBody)
710
- if (typeof rowBody['__id'] === 'string') coerced['__id'] = rowBody['__id']
711
- return coerced
712
- })
713
- }
714
-
715
- function coerceRowEntry(raw: unknown): Record<string, unknown> {
716
- if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
717
- return { ...(raw as Record<string, unknown>) }
718
- }
719
- return {}
720
- }
721
-
722
- /**
723
- * Variant of `coerceRowEntry` for `Repeater.simple(field)`. Wraps a
724
- * primitive entry under the inner field's name so the rest of the
725
- * coerce pipeline keeps using `{ <innerName>: v }` row shape. Object
726
- * entries pass through. The unwrap (back to `[v]`) happens once at the
727
- * top of `dispatchFormSubmit` via `unwrapSimpleRepeaters`.
728
- */
729
- function coerceSimpleEntry(raw: unknown, innerName: string): Record<string, unknown> {
730
- if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
731
- return { ...(raw as Record<string, unknown>) }
732
- }
733
- if (raw === undefined) return {}
734
- return { [innerName]: raw }
735
- }
736
-
737
- /**
738
- * After `coerceFormValues` has produced wrapped `[{<innerName>: v}]`
739
- * rows for every Repeater in the schema, flatten the `simple()` ones
740
- * back to `[v, v, …]` for storage. Non-simple repeaters are left alone.
741
- *
742
- * Runs before `mutateData` / `save` so user-facing data already uses
743
- * the storage shape they declared via `.simple(field)` — they don't
744
- * have to remember the internal wrapping at the save site.
745
- *
746
- * Tolerates already-flat input (e.g. when a `dehydrated(false)` upstream
747
- * has dropped wrapping, or when the user manually fed a flat array
748
- * through `withValues`) by re-emitting verbatim.
749
- */
750
- export function unwrapSimpleRepeaters(
751
- elements: Element[],
752
- values: Record<string, unknown>,
753
- ): Record<string, unknown> {
754
- const out: Record<string, unknown> = { ...values }
755
- walkRepeatersTopLevel(elements, repeater => {
756
- const innerName = repeater.getSimpleInnerField()?.name
757
- if (!innerName) return
758
- const rows = out[repeater.name]
759
- if (!Array.isArray(rows)) return
760
- out[repeater.name] = rows.map(row => {
761
- if (row && typeof row === 'object' && !Array.isArray(row)) {
762
- return (row as Record<string, unknown>)[innerName]
763
- }
764
- return row
765
- })
766
- })
767
- return out
768
- }
769
-
770
- function isRawRowEmpty(rowBody: Record<string, unknown>): boolean {
771
- for (const [k, v] of Object.entries(rowBody)) {
772
- if (k === '__id') continue
773
- if (v === undefined || v === null || v === '') continue
774
- return false
775
- }
776
- return true
777
- }
778
-
779
- /**
780
- * Walk an Element tree and return every `Form` instance, in document order.
781
- * Used by route handlers to locate the form being submitted on a page that
782
- * may declare more than one.
783
- *
784
- * Uses a structural `getType() === 'form'` check rather than `instanceof
785
- * Form`. Vite's SSR module cache can load the package through two
786
- * different module paths during a single dev session — the path used by
787
- * the rudder SSR route and the path used by Vike's `+data` hook for SPA
788
- * navigations end up importing different `Form` classes, so `instanceof`
789
- * silently returns false and the form goes "missing" on SPA nav while
790
- * SSR keeps working. The structural check is robust to that and matches
791
- * the convention used elsewhere in the codebase (see Filter, Column,
792
- * Action — all keyed on the serialized type, not class identity).
793
- */
794
- export function findForms(elements: ReadonlyArray<Element>): Form[] {
795
- const forms: Form[] = []
796
- const walk = (els: ReadonlyArray<Element>): void => {
797
- for (const el of els) {
798
- if (el.getType() === 'form') forms.push(el as Form)
799
- // Plan #14 — don't dive into Repeater / Builder children. Forms
800
- // inside an array-row field don't have row context for dispatch,
801
- // so finding them at the parent level would mis-route submissions.
802
- // Use structural checks (not `instanceof`) per the Vite SSR module
803
- // duplication note above.
804
- if (isRepeaterField(el)) continue
805
- if (isBuilderField(el)) continue
806
- const children = el.getChildren()
807
- if (children && children.length > 0) walk(children)
808
- }
809
- }
810
- walk(elements)
811
- return forms
812
- }
813
-
814
- /**
815
- * Plan #8 — locate the Wizard step Element at the given index inside the
816
- * form's tree. Returns the live Step instance so callers can read both
817
- * its children (`step.getChildren()`) and any hooks attached to it
818
- * (`getBeforeValidation / getAfterValidation`). Walks structurally
819
- * (`getType() === 'wizard'/'step'`) to stay robust to Vite SSR
820
- * module-cache duplication. `undefined` when the form has no Wizard
821
- * descendant or the step index is out of range.
822
- */
823
- export function findWizardStep(
824
- formChildren: ReadonlyArray<Element>,
825
- stepIndex: number,
826
- ): Element | undefined {
827
- let wizard: Element | undefined
828
- const walk = (els: ReadonlyArray<Element>): void => {
829
- for (const el of els) {
830
- if (el.getType() === 'wizard') { wizard = el; return }
831
- const children = el.getChildren()
832
- if (children && children.length > 0) walk(children)
833
- if (wizard) return
834
- }
835
- }
836
- walk(formChildren)
837
- if (!wizard) return undefined
838
- const steps = (wizard.getChildren() ?? []).filter(c => c.getType() === 'step')
839
- return steps[stepIndex]
840
- }
841
-
842
- /**
843
- * Sibling helper: returns just the children of the Wizard step at the
844
- * given index. Thin wrapper over `findWizardStep` for callers that only
845
- * need to validate the step's fields without touching the Step instance
846
- * itself. `undefined` when the step is missing.
847
- */
848
- export function findWizardStepFields(
849
- formChildren: ReadonlyArray<Element>,
850
- stepIndex: number,
851
- ): Element[] | undefined {
852
- const step = findWizardStep(formChildren, stepIndex)
853
- if (!step) return undefined
854
- return step.getChildren() ?? []
855
- }
856
-
857
- /**
858
- * Pick the `Form` matching the submitted `_formId`, or fall back to the
859
- * first form on the page when no id was sent OR the submitted id misses.
860
- *
861
- * Use this on **legacy form-submit paths** (POST create / edit / global-edit
862
- * / custom-page) where a single page may host multiple forms and the
863
- * fallback to "first form" is a back-compat affordance for submissions that
864
- * predate the `_formId` hidden input.
865
- *
866
- * Do NOT use this on partial-resolve paths (Plan #5 form-state, Plan #8
867
- * wizard step-validate) — those must hard-fail on a mismatched id so the
868
- * client gets a 404 instead of silently writing the wrong form's state.
869
- * Use `selectFormById` there.
870
- */
871
- export function selectForm(forms: ReadonlyArray<Form>, submittedId: unknown): Form | undefined {
872
- if (typeof submittedId === 'string') {
873
- const match = forms.find(f => f.getFormId() === submittedId)
874
- if (match) return match
875
- }
876
- return forms[0]
877
- }
878
-
879
- /**
880
- * ID-match counterpart to `selectForm`, used by partial-resolve endpoints
881
- * (Plan #5 form-state, Plan #8 wizard step-validate).
882
- *
883
- * - If `id` matches a form, return it.
884
- * - If there's no match AND the page has exactly one form, return that
885
- * form. This is safe — there's no ambiguity about which form the POST
886
- * meant — and it removes the auto-counter desync footgun: the GET
887
- * render and the partial-resolve POST run through `Form.make()` in
888
- * different requests, so the process-global formId counter ticks
889
- * forward and a strict match would 404. See
890
- * `feedback_pilotiq_live_forms_pin_formid.md`.
891
- * - Otherwise return `undefined`. Multi-form pages with a missing/wrong
892
- * id must hard-fail so the client surfaces a 404 instead of writing
893
- * the wrong form's state.
894
- *
895
- * Pages with multiple reactive forms still need to pin a stable
896
- * `Form.make().formId(...)` to disambiguate.
897
- */
898
- export function selectFormById(forms: ReadonlyArray<Form>, id: string): Form | undefined {
899
- const match = forms.find(f => f.getFormId() === id)
900
- if (match) return match
901
- if (forms.length === 1) return forms[0]
902
- return undefined
903
- }
904
-
905
- // ─── Plan #5: applyStateUpdate ────────────────────────────
906
-
907
- export interface StateUpdateContext<R = unknown> {
908
- record?: R
909
- user?: unknown
910
- request?: unknown
911
- }
912
-
913
- export interface StateUpdateResult {
914
- /**
915
- * Updated values map after coercing the changed field and running
916
- * its `afterStateUpdated` hook. The same object the client should
917
- * rebind to its inputs on the next render.
918
- */
919
- values: Record<string, unknown>
920
- /**
921
- * Field names whose value was written via `$set` during this resolve.
922
- * Includes the changed field itself. The client uses this to decide
923
- * which inputs to update without disrupting focus on others.
924
- */
925
- dirty: string[]
926
- }
927
-
928
- /**
929
- * Apply a partial-resolve update from the client. Coerces the changed
930
- * field's value (other fields keep whatever the client sent), runs the
931
- * field's `afterStateUpdated` hook with bound `$get / $set` helpers,
932
- * and returns the updated values + names of fields whose values were
933
- * mutated. The caller (the partial-resolve route handler) feeds the
934
- * resulting values into `resolveSchema` to produce a fresh form meta.
935
- *
936
- * Returns `null` when the changed field name doesn't correspond to a
937
- * field on the form — the route handler turns this into a 404.
938
- *
939
- * Plan #14 — `changed` may be a dotted path into a Repeater row
940
- * (`items.2.quantity` or, for nested Repeaters, `items.0.modifiers.1.name`).
941
- * The dotted form routes through `applyRepeaterStateUpdate` which scopes
942
- * `$get / $set` to the innermost row by default; cross-row reads / writes
943
- * go through the parent `$get / $set` using a full dotted path.
944
- */
945
- export async function applyStateUpdate<R = unknown>(
946
- form: Form<R>,
947
- values: Record<string, unknown>,
948
- changed: string,
949
- ctx: StateUpdateContext<R> = {},
950
- ): Promise<StateUpdateResult | null> {
951
- const children = (form.getChildren() ?? []) as Element[]
952
-
953
- if (changed.includes('.')) {
954
- // Plan #14 — dotted paths route to the array-row field that owns
955
- // the path's first segment. Builder paths look like `name.<i>.data.<leaf>`
956
- // (the literal `data` segment is the giveaway); Repeater paths look
957
- // like `name.<i>.<leaf>`. Inspect the first segment's field on the
958
- // schema to dispatch.
959
- const head = changed.split('.', 1)[0]!
960
- const headField = findFieldDirect(children, head)
961
- if (headField instanceof BuilderField) {
962
- return applyBuilderStateUpdate(headField, values, changed, ctx)
963
- }
964
- return applyRepeaterStateUpdate(children, values, changed, ctx)
965
- }
966
-
967
- const target = findFieldByName(children, changed)
968
- if (!target) return null
969
-
970
- // Coerce the changed field only — other fields may have been mid-edit
971
- // on the client and we don't want to clobber their in-flight state.
972
- const coerced = { ...values }
973
- const subset: Record<string, unknown> = { [changed]: values[changed] }
974
- const after = coerceFormValues([target], subset)
975
- coerced[changed] = after[changed]
976
-
977
- const dirty = new Set<string>([changed])
978
-
979
- const hook = target.getAfterStateUpdated()
980
- if (hook) {
981
- const $get = (name: string): unknown => coerced[name]
982
- const $set = (name: string, v: unknown): void => {
983
- coerced[name] = v
984
- dirty.add(name)
985
- }
986
- const hookCtx: AfterStateUpdatedContext = {
987
- $get,
988
- $set,
989
- values: coerced,
990
- ...(ctx.record !== undefined ? { record: ctx.record } : {}),
991
- ...(ctx.user !== undefined ? { user: ctx.user } : {}),
992
- ...(ctx.request !== undefined ? { request: ctx.request } : {}),
993
- }
994
- await hook(coerced[changed], hookCtx)
995
- }
996
-
997
- return { values: coerced, dirty: Array.from(dirty) }
998
- }
999
-
1000
- function findFieldByName(elements: Element[], name: string): Field | undefined {
1001
- for (const el of elements) {
1002
- if (el instanceof Field && el.name === name) return el
1003
- // Plan #14 — don't dive into Repeater / Builder inner schemas when
1004
- // looking for a top-level field; row-local fields are addressed via
1005
- // dotted paths through `applyRepeaterStateUpdate` /
1006
- // `applyBuilderStateUpdate`.
1007
- if (el instanceof RepeaterField) continue
1008
- if (el instanceof BuilderField) continue
1009
- const children = el.getChildren()
1010
- if (children && children.length > 0) {
1011
- const hit = findFieldByName(children as Element[], name)
1012
- if (hit) return hit
1013
- }
1014
- }
1015
- return undefined
1016
- }
1017
-
1018
- /**
1019
- * Plan #14 — resolve a dotted-path live-update into a Repeater row.
1020
- *
1021
- * `changed` looks like `items.2.quantity` (one level) or
1022
- * `items.0.modifiers.1.name` (nested). Segments alternate field-name and
1023
- * row-index. The leaf must be a real Field inside the innermost
1024
- * Repeater's inner schema. Returns `null` (→ 404) when the path doesn't
1025
- * resolve.
1026
- *
1027
- * Mutates a shallow-cloned `values` so the caller gets a fresh map and
1028
- * the input isn't aliased. Row arrays + row maps along the path are
1029
- * cloned to avoid mutating shared state in the input.
1030
- */
1031
- async function applyRepeaterStateUpdate<R>(
1032
- children: Element[],
1033
- values: Record<string, unknown>,
1034
- changed: string,
1035
- ctx: StateUpdateContext<R>,
1036
- ): Promise<StateUpdateResult | null> {
1037
- const resolved = resolveRepeaterPath(children, changed)
1038
- if (!resolved) return null
1039
- const { field, rowPath } = resolved
1040
-
1041
- const coerced = { ...values }
1042
-
1043
- // Clone path-traversed arrays + row maps so we can mutate them without
1044
- // touching the caller's input. Final row map is the innermost row.
1045
- let rowMap = ensureRowAtPath(coerced, rowPath)
1046
-
1047
- // Coerce only the leaf field's value — read raw value from the existing
1048
- // row map, then run it through `coerceFormValues` against the leaf field
1049
- // alone, and write the coerced value back.
1050
- const rawAtPath = rowMap[field.name]
1051
- const coercedSubset = coerceFormValues([field], { [field.name]: rawAtPath })
1052
- rowMap[field.name] = coercedSubset[field.name]
1053
-
1054
- const dirty = new Set<string>([changed])
1055
-
1056
- const hook = field.getAfterStateUpdated()
1057
- if (hook) {
1058
- const innermost = rowPath[rowPath.length - 1]!
1059
- const rowPrefix = rowPath.map(r => `${r.repeater.name}.${r.index}`).join('.')
1060
-
1061
- const $get = (name: string): unknown => {
1062
- if (name.includes('.')) return readDottedPath(coerced, name)
1063
- return rowMap[name]
1064
- }
1065
- const $set = (name: string, v: unknown): void => {
1066
- if (name.includes('.')) {
1067
- writeDottedPath(coerced, name, v)
1068
- dirty.add(name)
1069
- return
1070
- }
1071
- rowMap[name] = v
1072
- dirty.add(`${rowPrefix}.${name}`)
1073
- }
1074
-
1075
- const row = {
1076
- index: innermost.index,
1077
- $get: (name: string): unknown => rowMap[name],
1078
- $set: (name: string, v: unknown): void => {
1079
- rowMap[name] = v
1080
- dirty.add(`${rowPrefix}.${name}`)
1081
- },
1082
- }
1083
-
1084
- const hookCtx: AfterStateUpdatedContext = {
1085
- $get,
1086
- $set,
1087
- values: coerced,
1088
- row,
1089
- ...(ctx.record !== undefined ? { record: ctx.record } : {}),
1090
- ...(ctx.user !== undefined ? { user: ctx.user } : {}),
1091
- ...(ctx.request !== undefined ? { request: ctx.request } : {}),
1092
- }
1093
-
1094
- await hook(rowMap[field.name], hookCtx)
1095
- }
1096
-
1097
- return { values: coerced, dirty: Array.from(dirty) }
1098
- }
1099
-
1100
- interface ResolvedPath {
1101
- field: Field
1102
- rowPath: Array<{ repeater: RepeaterField; index: number }>
1103
- }
1104
-
1105
- /**
1106
- * Walk a dotted path against an Element tree. Segments alternate
1107
- * field-name and row-index. Returns the leaf Field plus the chain of
1108
- * (Repeater, index) hops needed to reach it.
1109
- */
1110
- function resolveRepeaterPath(elements: Element[], path: string): ResolvedPath | null {
1111
- const segments = path.split('.')
1112
- const rowPath: Array<{ repeater: RepeaterField; index: number }> = []
1113
-
1114
- let currentElements = elements
1115
- let i = 0
1116
- while (i < segments.length) {
1117
- const seg = segments[i]!
1118
- const field = findFieldDirect(currentElements, seg)
1119
- if (!field) return null
1120
-
1121
- if (i === segments.length - 1) {
1122
- return { field, rowPath }
1123
- }
1124
-
1125
- if (!(field instanceof RepeaterField)) return null
1126
- const idxStr = segments[i + 1]
1127
- if (idxStr === undefined) return null
1128
- const idx = Number(idxStr)
1129
- if (!Number.isInteger(idx) || idx < 0) return null
1130
-
1131
- rowPath.push({ repeater: field, index: idx })
1132
- currentElements = field.getInnerSchema()
1133
- i += 2
1134
- }
1135
-
1136
- return null
1137
- }
1138
-
1139
- /**
1140
- * Find a top-level field by name inside an element tree, walking through
1141
- * non-Repeater containers but stopping at Repeater boundaries (those need
1142
- * a dotted path to address inner fields).
1143
- */
1144
- function findFieldDirect(elements: Element[], name: string): Field | undefined {
1145
- for (const el of elements) {
1146
- if (el instanceof Field && el.name === name) return el
1147
- if (el instanceof RepeaterField) continue
1148
- const children = el.getChildren()
1149
- if (children && children.length > 0) {
1150
- const hit = findFieldDirect(children as Element[], name)
1151
- if (hit) return hit
1152
- }
1153
- }
1154
- return undefined
1155
- }
1156
-
1157
- /**
1158
- * Walk + clone the row arrays/maps along `rowPath`, ensuring each row
1159
- * exists, then return the innermost row map. Mutations on the returned
1160
- * object propagate up to `coerced` because we replace each step's
1161
- * container with a fresh clone in the parent.
1162
- */
1163
- function ensureRowAtPath(
1164
- coerced: Record<string, unknown>,
1165
- rowPath: Array<{ repeater: RepeaterField; index: number }>,
1166
- ): Record<string, unknown> {
1167
- let parent: Record<string, unknown> | unknown[] = coerced
1168
- for (const { repeater, index } of rowPath) {
1169
- const arrName = repeater.name
1170
- let arr: unknown[]
1171
- if (Array.isArray(parent)) {
1172
- // Should never happen at the outer iteration (parent starts as
1173
- // `coerced`, an object); guard anyway.
1174
- arr = (parent as unknown[]).slice()
1175
- } else {
1176
- const existing = (parent as Record<string, unknown>)[arrName]
1177
- arr = Array.isArray(existing) ? existing.slice() : []
1178
- ;(parent as Record<string, unknown>)[arrName] = arr
1179
- }
1180
- while (arr.length <= index) arr.push({})
1181
- const existingRow = arr[index]
1182
- const row: Record<string, unknown> = (existingRow && typeof existingRow === 'object' && !Array.isArray(existingRow))
1183
- ? { ...(existingRow as Record<string, unknown>) }
1184
- : {}
1185
- arr[index] = row
1186
- parent = row
1187
- }
1188
- return parent as Record<string, unknown>
1189
- }
1190
-
1191
- function readDottedPath(values: Record<string, unknown>, path: string): unknown {
1192
- const segments = path.split('.')
1193
- let cur: unknown = values
1194
- for (const seg of segments) {
1195
- if (cur === null || cur === undefined) return undefined
1196
- if (Array.isArray(cur)) {
1197
- const idx = Number(seg)
1198
- if (!Number.isInteger(idx)) return undefined
1199
- cur = cur[idx]
1200
- } else if (typeof cur === 'object') {
1201
- cur = (cur as Record<string, unknown>)[seg]
1202
- } else {
1203
- return undefined
1204
- }
1205
- }
1206
- return cur
1207
- }
1208
-
1209
- function writeDottedPath(values: Record<string, unknown>, path: string, value: unknown): void {
1210
- const segments = path.split('.')
1211
- let cur: Record<string, unknown> | unknown[] = values
1212
- for (let i = 0; i < segments.length - 1; i++) {
1213
- const seg = segments[i]!
1214
- const nextSeg = segments[i + 1]!
1215
- const childIsIndex = /^\d+$/.test(nextSeg)
1216
- if (Array.isArray(cur)) {
1217
- const idx = Number(seg)
1218
- if (!Number.isInteger(idx)) return
1219
- while (cur.length <= idx) cur.push(childIsIndex ? [] : {})
1220
- let next = cur[idx]
1221
- if (next === undefined || next === null) {
1222
- next = childIsIndex ? [] : {}
1223
- cur[idx] = next
1224
- }
1225
- cur = next as Record<string, unknown> | unknown[]
1226
- } else {
1227
- let next = (cur as Record<string, unknown>)[seg]
1228
- if (next === undefined || next === null) {
1229
- next = childIsIndex ? [] : {}
1230
- ;(cur as Record<string, unknown>)[seg] = next
1231
- }
1232
- cur = next as Record<string, unknown> | unknown[]
1233
- }
1234
- }
1235
- const last = segments[segments.length - 1]!
1236
- if (Array.isArray(cur)) {
1237
- const idx = Number(last)
1238
- if (!Number.isInteger(idx)) return
1239
- cur[idx] = value
1240
- } else {
1241
- (cur as Record<string, unknown>)[last] = value
1242
- }
1243
- }
1244
-
1245
- // ─── Plan #14 follow-up: Builder partial-resolve ─────────
1246
-
1247
- /**
1248
- * Resolve a dotted-path live-update into a Builder row.
1249
- *
1250
- * Path shape: `<name>.<i>.data.<leaf>`. The literal `data` segment
1251
- * separates the row's envelope (`__id`, `type`) from the block-scoped
1252
- * inner field. The row's block schema is selected from the values map
1253
- * via `values[name][i].type` — Builder rows are heterogeneous, so the
1254
- * schema can't be derived from the field alone.
1255
- *
1256
- * Nested array-row fields inside a block (Repeater-in-Builder, etc.)
1257
- * aren't supported in v1 — same posture as nested Repeater leaf depth
1258
- * past one level. Returns `null` (→ 404) on any unsupported shape.
1259
- *
1260
- * Mutates a shallow-cloned `values` so the caller gets a fresh map; the
1261
- * row array + row map + `data` map along the path are cloned to avoid
1262
- * aliasing the input.
1263
- */
1264
- async function applyBuilderStateUpdate<R>(
1265
- field: BuilderField,
1266
- values: Record<string, unknown>,
1267
- changed: string,
1268
- ctx: StateUpdateContext<R>,
1269
- ): Promise<StateUpdateResult | null> {
1270
- const segments = changed.split('.')
1271
- // Expected: name (already matched by caller), <i>, 'data', <leaf>...
1272
- if (segments.length < 4) return null
1273
- const name = segments[0]!
1274
- if (name !== field.name) return null
1275
- const idxStr = segments[1]!
1276
- const idx = Number(idxStr)
1277
- if (!Number.isInteger(idx) || idx < 0) return null
1278
- if (segments[2] !== 'data') return null
1279
- const leafName = segments[3]!
1280
- // Nested-array path past `data.<leaf>` not supported in v1.
1281
- if (segments.length > 4) return null
1282
-
1283
- // Look up the row's block from the submitted values.
1284
- const arrRaw = values[name]
1285
- if (!Array.isArray(arrRaw)) return null
1286
- const rowRaw = arrRaw[idx]
1287
- if (!rowRaw || typeof rowRaw !== 'object' || Array.isArray(rowRaw)) return null
1288
- const blockName = (rowRaw as Record<string, unknown>)['type']
1289
- if (typeof blockName !== 'string' || blockName === '') return null
1290
- const block = field.getBlock(blockName)
1291
- if (!block) return null
1292
-
1293
- // Locate the leaf field inside the block's schema.
1294
- const leafField = findFieldDirect(block.getSchema(), leafName)
1295
- if (!leafField) return null
1296
-
1297
- // Clone path-traversed containers.
1298
- const coerced = { ...values }
1299
- const arrClone = (coerced[name] as unknown[]).slice()
1300
- coerced[name] = arrClone
1301
- const rowSrc = arrClone[idx] as Record<string, unknown>
1302
- const rowClone: Record<string, unknown> = { ...rowSrc }
1303
- arrClone[idx] = rowClone
1304
- const dataSrc = rowClone['data']
1305
- const dataClone: Record<string, unknown> = (dataSrc && typeof dataSrc === 'object' && !Array.isArray(dataSrc))
1306
- ? { ...(dataSrc as Record<string, unknown>) }
1307
- : {}
1308
- rowClone['data'] = dataClone
1309
-
1310
- // Coerce the leaf field's value only.
1311
- const rawLeaf = dataClone[leafName]
1312
- const coercedSubset = coerceFormValues([leafField], { [leafName]: rawLeaf })
1313
- dataClone[leafName] = coercedSubset[leafName]
1314
-
1315
- const dirty = new Set<string>([changed])
1316
-
1317
- const hook = leafField.getAfterStateUpdated()
1318
- if (hook) {
1319
- const rowPrefix = `${name}.${idx}.data`
1320
-
1321
- const $get = (n: string): unknown => {
1322
- if (n.includes('.')) return readDottedPath(coerced, n)
1323
- return dataClone[n]
1324
- }
1325
- const $set = (n: string, v: unknown): void => {
1326
- if (n.includes('.')) {
1327
- writeDottedPath(coerced, n, v)
1328
- dirty.add(n)
1329
- return
1330
- }
1331
- dataClone[n] = v
1332
- dirty.add(`${rowPrefix}.${n}`)
1333
- }
1334
-
1335
- const row = {
1336
- index: idx,
1337
- blockType: block.name,
1338
- $get: (n: string): unknown => dataClone[n],
1339
- $set: (n: string, v: unknown): void => {
1340
- dataClone[n] = v
1341
- dirty.add(`${rowPrefix}.${n}`)
1342
- },
1343
- }
1344
-
1345
- const hookCtx: AfterStateUpdatedContext = {
1346
- $get,
1347
- $set,
1348
- values: coerced,
1349
- row,
1350
- ...(ctx.record !== undefined ? { record: ctx.record } : {}),
1351
- ...(ctx.user !== undefined ? { user: ctx.user } : {}),
1352
- ...(ctx.request !== undefined ? { request: ctx.request } : {}),
1353
- }
1354
-
1355
- await hook(dataClone[leafName], hookCtx)
1356
- }
1357
-
1358
- return { values: coerced, dirty: Array.from(dirty) }
1359
- }
1360
-
1361
- // ─── Repeater.relationship — extraction + persistence ────────
1362
-
1363
- interface RelationshipDeferral {
1364
- field: RepeaterField
1365
- rows: Array<Record<string, unknown>>
1366
- cfg: RepeaterRelationshipConfig
1367
- }
1368
-
1369
- /**
1370
- * Walk the form's top-level Repeaters and extract values for any that
1371
- * have a `relationship(...)` config. Returns the deferral list and
1372
- * mutates `data` in place by deleting each extracted key — the parent's
1373
- * save handler doesn't need to see those values (they aren't real
1374
- * columns on the parent).
1375
- *
1376
- * Inner / nested Repeaters aren't supported in v1; we only walk the top
1377
- * level (consistent with the existing `walkRepeatersTopLevel` helper)
1378
- * so a relationship-backed Repeater nested inside a JSON-backed
1379
- * Repeater silently falls back to JSON storage. Documented as a
1380
- * v1 limitation in `docs/plans/repeater-relationship.md`.
1381
- */
1382
- export function extractRelationshipRepeaters(
1383
- elements: Element[],
1384
- data: Record<string, unknown>,
1385
- ): RelationshipDeferral[] {
1386
- const out: RelationshipDeferral[] = []
1387
- walkRepeatersTopLevel(elements, repeater => {
1388
- const cfg = repeater.getRelationship()
1389
- if (!cfg) return
1390
- const value = data[repeater.name]
1391
- delete data[repeater.name]
1392
- if (!Array.isArray(value)) return
1393
- out.push({
1394
- field: repeater,
1395
- rows: value as Array<Record<string, unknown>>,
1396
- cfg,
1397
- })
1398
- })
1399
- return out
1400
- }
1401
-
1402
- /**
1403
- * Resolved attachment shape for a relationship-backed Repeater. Five
1404
- * variants reflect the persisted-relation kinds we know how to write
1405
- * back from a Repeater submit:
1406
- *
1407
- * - `hasMany` — single FK column on the child.
1408
- * - `morphMany` — polymorphic owner side; `<morphName>Id` +
1409
- * `<morphName>Type` stamped on the child.
1410
- * `morphOne` collapses into this kind (storage
1411
- * shape is identical; "one row" is enforced
1412
- * upstream).
1413
- * - `belongsToMany` — pivot-table M2M; the child has no parent
1414
- * attachment column, so create + attach goes
1415
- * through `parent[rel]().attach([childPk])` and
1416
- * delete-from-row goes through `.detach([pk])`.
1417
- * - `morphToMany` — polymorphic pivot M2M; pivot row carries
1418
- * `<morphName>Type` + the parent's PK, written
1419
- * transparently by the accessor.
1420
- * - `morphedByMany` — inverse polymorphic pivot. Same accessor
1421
- * surface.
1422
- *
1423
- * The three M2M variants carry only the relation name — the persist
1424
- * pipeline reaches the accessor via `resolveM2MAccessor(parent, relation)`.
1425
- */
1426
- type RepeaterChildAttachment =
1427
- | { kind: 'hasMany'; model: ModelLike; foreignKey: string }
1428
- | { kind: 'morphMany'; model: ModelLike; morph: MorphRelationDescriptor }
1429
- | { kind: 'belongsToMany'; model: ModelLike; relation: string }
1430
- | { kind: 'morphToMany'; model: ModelLike; relation: string }
1431
- | { kind: 'morphedByMany'; model: ModelLike; relation: string }
1432
-
1433
- /**
1434
- * Resolve the child model + parent-attachment shape for a
1435
- * relationship-backed Repeater. Five supported modes:
1436
- *
1437
- * - `hasMany` — single foreign key on the child.
1438
- * - `morphMany` / `morphOne` — polymorphic owner side.
1439
- * - `belongsToMany` — pivot-table M2M.
1440
- * - `morphToMany` / `morphedByMany` — polymorphic pivot M2M.
1441
- *
1442
- * Detection order: M2M descriptor (covers all three M2M variants) →
1443
- * morph descriptor (morphMany / morphOne) → hasMany. The order matters
1444
- * because `getParentRelationDescriptor` accepts entries with
1445
- * `foreignKey: string` even if the type is M2M, so checking M2M first
1446
- * keeps mis-shaped entries from falling through to the hasMany branch.
1447
- *
1448
- * `cfg.orderColumn` is rejected under M2M because pivot-side ordering
1449
- * needs ORM `orderByPivot` which v1 doesn't expose. Throwing here
1450
- * beats silently writing into a non-existent column on the related
1451
- * model.
1452
- *
1453
- * Throws a clear configuration error when the relation type isn't one
1454
- * of the five, or when descriptor lookup fails entirely.
1455
- */
1456
- function resolveChildAndAttachment(
1457
- parentModel: ModelLike,
1458
- cfg: RepeaterRelationshipConfig,
1459
- ): RepeaterChildAttachment {
1460
- const m2mDescriptor = getM2MRelationDescriptor(parentModel, cfg.name)
1461
- if (m2mDescriptor) {
1462
- if (cfg.orderColumn !== undefined) {
1463
- throw new Error(
1464
- `[Pilotiq] Repeater.relationship("${cfg.name}"): orderColumn() is not supported under ` +
1465
- `'${m2mDescriptor.type}' v1. Pivot-side ordering needs ORM \`orderByPivot\` which is deferred.`,
1466
- )
1467
- }
1468
- const model = cfg.model ?? m2mDescriptor.model()
1469
- if (!model) {
1470
- throw new Error(
1471
- `[Pilotiq] Repeater.relationship("${cfg.name}"): could not resolve the related model. ` +
1472
- `Pass it explicitly via .relationship({ name, model: RelatedModel }) or declare ` +
1473
- `the relation's \`model\` thunk on the parent model's static relations map.`,
1474
- )
1475
- }
1476
- return { kind: m2mDescriptor.type, model, relation: cfg.name }
1477
- }
1478
-
1479
- const parentDescriptor = getParentRelationDescriptor(parentModel, cfg.name)
1480
- const morphDescriptor = getMorphRelationDescriptor(parentModel, cfg.name)
1481
- const type = parentDescriptor?.type
1482
- ?? (morphDescriptor ? 'morphMany' : 'hasMany')
1483
-
1484
- if (type === 'morphMany' || type === 'morphOne') {
1485
- const model = cfg.model ?? morphDescriptor?.model?.()
1486
- if (!model) {
1487
- throw new Error(
1488
- `[Pilotiq] Repeater.relationship("${cfg.name}"): could not resolve the child model. ` +
1489
- `Pass it explicitly via .relationship({ name, model: ChildModel }) or declare ` +
1490
- `the relation's \`model\` thunk on the parent model's static relations map.`,
1491
- )
1492
- }
1493
- if (!morphDescriptor) {
1494
- throw new Error(
1495
- `[Pilotiq] Repeater.relationship("${cfg.name}"): polymorphic relation entry is missing \`morphName\`. ` +
1496
- `Set \`relations.${cfg.name} = { type: 'morphMany', morphName: '<name>', model: () => ChildModel }\` on the parent.`,
1497
- )
1498
- }
1499
- return { kind: 'morphMany', model, morph: morphDescriptor }
1500
- }
1501
-
1502
- const model = cfg.model ?? parentDescriptor?.model()
1503
- const foreignKey = cfg.foreignKey ?? parentDescriptor?.foreignKey
1504
-
1505
- if (!model) {
1506
- throw new Error(
1507
- `[Pilotiq] Repeater.relationship("${cfg.name}"): could not resolve the child model. ` +
1508
- `Pass it explicitly via .relationship({ name, model: ChildModel }) or declare ` +
1509
- `the relation on the parent model's static relations map.`,
1510
- )
1511
- }
1512
- if (!foreignKey) {
1513
- throw new Error(
1514
- `[Pilotiq] Repeater.relationship("${cfg.name}"): could not resolve the foreign-key column. ` +
1515
- `Pass it explicitly via .relationship({ name, foreignKey: 'parentId' }) or declare ` +
1516
- `it on the parent model's static relations map.`,
1517
- )
1518
- }
1519
- if (type !== 'hasMany') {
1520
- throw new Error(
1521
- `[Pilotiq] Repeater.relationship("${cfg.name}"): unsupported relation type '${type}'. ` +
1522
- `Supported: hasMany, morphMany, morphOne, belongsToMany, morphToMany, morphedByMany.`,
1523
- )
1524
- }
1525
-
1526
- return { kind: 'hasMany', model, foreignKey }
1527
- }
1528
-
1529
- /**
1530
- * Diff submitted rows against the existing related rows and apply
1531
- * create / update / delete operations through the child model.
1532
- *
1533
- * Identity:
1534
- * - Submitted row with `__id` matching an existing PK → update.
1535
- * - Submitted row without `__id` (or with one not in the existing
1536
- * set) → create. The FK is stamped onto the create payload.
1537
- * - Existing PK not present in any submitted `__id` → delete.
1538
- *
1539
- * Order:
1540
- * - When `cfg.orderColumn` is set, every create / update payload
1541
- * stamps it with the row's 0-based index.
1542
- *
1543
- * Errors propagate. v1 isn't transactional — partial failure leaves
1544
- * the parent saved and some children unchanged. See plan doc for the
1545
- * follow-up.
1546
- */
1547
- async function persistRelationshipRows(
1548
- parent: unknown,
1549
- deferral: RelationshipDeferral,
1550
- parentModel: ModelLike,
1551
- ): Promise<RelationshipRename[]> {
1552
- const renames: RelationshipRename[] = []
1553
- const { rows, cfg, field } = deferral
1554
- const attachment = resolveChildAndAttachment(parentModel, cfg)
1555
- const { model } = attachment
1556
- const pk = getPrimaryKey(model)
1557
- const orderColumn = cfg.orderColumn
1558
- const parentPk = (parent as Record<string, unknown> | undefined)?.[getPrimaryKey(parentModel)]
1559
- if (parentPk === undefined || parentPk === null) {
1560
- throw new Error(
1561
- `[Pilotiq] Repeater.relationship("${cfg.name}"): parent record has no primary key after save. ` +
1562
- `Form.save() / handleCreate() must return a record with a primary key set.`,
1563
- )
1564
- }
1565
-
1566
- // Per-row hooks — fire after each create / update / delete completes.
1567
- // No-op when the field hasn't registered the corresponding handler.
1568
- // Errors propagate; v1 isn't transactional so a throwing handler
1569
- // leaves earlier rows persisted.
1570
- const afterCreate = field.getAfterCreate()
1571
- const afterUpdate = field.getAfterUpdate()
1572
- const afterDelete = field.getAfterDelete()
1573
- const buildRowCtx = (index: number): RepeaterRowContext => ({
1574
- parent,
1575
- parentId: parentPk as string | number,
1576
- field: field.name,
1577
- index,
1578
- mode: attachment.kind,
1579
- })
1580
-
1581
- // Compute the morph stamp once — `computeMorphPayload` is pure.
1582
- const morphStamp = attachment.kind === 'morphMany'
1583
- ? computeMorphPayload(parent, attachment.morph)
1584
- : undefined
1585
-
1586
- // Resolve the M2M pivot-mutation accessor once — fails closed with a
1587
- // clear error if the parent doesn't expose `parent[rel]()` or a
1588
- // legacy `parent.related(rel)` shape returning attach/detach.
1589
- const isM2M = attachment.kind === 'belongsToMany'
1590
- || attachment.kind === 'morphToMany'
1591
- || attachment.kind === 'morphedByMany'
1592
- const m2mAccessor = isM2M
1593
- ? resolveM2MAccessor(parent, (attachment as { relation: string }).relation)
1594
- : undefined
1595
- if (isM2M && !m2mAccessor) {
1596
- throw new Error(
1597
- `[Pilotiq] Repeater.relationship("${cfg.name}"): could not resolve the pivot-mutation accessor on the parent record. ` +
1598
- `Expected \`parent.${cfg.name}()\` to return \`{ attach, detach, sync }\` (rudder ORM convention). ` +
1599
- `Make sure the parent model declares the relation under \`static relations\` and that the prototype method is installed.`,
1600
- )
1601
- }
1602
-
1603
- const existing = await loadRelationRows(parentModel, parent, cfg.name)
1604
- const existingByPk = new Map<string, Record<string, unknown>>()
1605
- for (const row of existing) {
1606
- const key = String((row as Record<string, unknown>)[pk])
1607
- existingByPk.set(key, row as Record<string, unknown>)
1608
- }
1609
-
1610
- const keptPks = new Set<string>()
1611
-
1612
- // M2M-only: the user may have declared `pivotColumns([…])`. Those
1613
- // names live on the pivot table, NOT the child model — split them
1614
- // out before each create / update so the child writes never see
1615
- // them and the pivot writes only see them.
1616
- const pivotColumnSet = (isM2M && cfg.pivotColumns && cfg.pivotColumns.length > 0)
1617
- ? new Set(cfg.pivotColumns)
1618
- : undefined
1619
-
1620
- for (let idx = 0; idx < rows.length; idx++) {
1621
- const submitted = rows[idx] ?? {}
1622
- const submittedId = typeof submitted['__id'] === 'string' ? submitted['__id'] : undefined
1623
- const isUpdate = submittedId !== undefined && existingByPk.has(submittedId)
1624
-
1625
- // Strip framework keys before constructing the payload — the
1626
- // child model never sees `__id`, and the parent attachment cols
1627
- // are stamped explicitly below so user-supplied values are
1628
- // ignored (FK / morph cols can't be retargeted; order is
1629
- // canonical from row index).
1630
- const payload: Record<string, unknown> = {}
1631
- const pivotPayload: Record<string, unknown> = {}
1632
- for (const [k, v] of Object.entries(submitted)) {
1633
- if (k === '__id') continue
1634
- if (pivotColumnSet?.has(k)) {
1635
- pivotPayload[k] = v
1636
- } else {
1637
- payload[k] = v
1638
- }
1639
- }
1640
- if (orderColumn !== undefined) payload[orderColumn] = idx
1641
- const hasPivotPayload = pivotColumnSet !== undefined
1642
- && Object.keys(pivotPayload).length > 0
1643
-
1644
- if (isUpdate) {
1645
- // Don't overwrite the parent attachment on update — for hasMany
1646
- // the FK is already correct; for morphMany the `<morphName>Id`
1647
- // + `<morphName>Type` cols are too. Defense against a tampered
1648
- // client trying to re-link the child to a different (poly)
1649
- // parent. M2M variants have no parent-attachment column on the
1650
- // child to strip — pivot lives on its own table.
1651
- if (attachment.kind === 'hasMany') {
1652
- delete payload[attachment.foreignKey]
1653
- } else if (attachment.kind === 'morphMany') {
1654
- for (const k of Object.keys(morphStamp!)) delete payload[k]
1655
- }
1656
- // For M2M without pivot extras the row still benefits from a
1657
- // child-row update (user may have edited the child's own
1658
- // columns through the Repeater). Skip the child write only
1659
- // when the payload would be empty (M2M + pivot-only edits).
1660
- let updatedRecord: unknown = existingByPk.get(submittedId!)
1661
- if (Object.keys(payload).length > 0) {
1662
- const ret = await model.update(submittedId!, payload)
1663
- // ModelLike.update may return the updated record OR void; fall
1664
- // back to the existing snapshot merged with the payload so the
1665
- // hook always receives a usable record shape.
1666
- if (ret !== undefined && ret !== null) {
1667
- updatedRecord = ret
1668
- } else {
1669
- updatedRecord = { ...(existingByPk.get(submittedId!) ?? {}), ...payload }
1670
- }
1671
- }
1672
- if (hasPivotPayload) {
1673
- if (typeof m2mAccessor!.updatePivot !== 'function') {
1674
- throw new Error(
1675
- `[Pilotiq] Repeater.relationship("${cfg.name}").pivotColumns(...) requires a rudder ORM with \`updatePivot\` ` +
1676
- `on the M2M accessor (shipped via \`feat(orm): pivot-extras read/update\`). ` +
1677
- `Upgrade @rudderjs/orm or drop the pivotColumns call.`,
1678
- )
1679
- }
1680
- await m2mAccessor!.updatePivot(submittedId!, pivotPayload)
1681
- }
1682
- keptPks.add(submittedId!)
1683
- if (afterUpdate) await afterUpdate(updatedRecord, buildRowCtx(idx))
1684
- } else {
1685
- let createdRecord: unknown
1686
- if (attachment.kind === 'hasMany') {
1687
- payload[attachment.foreignKey] = parentPk
1688
- createdRecord = await model.create(payload)
1689
- } else if (attachment.kind === 'morphMany') {
1690
- Object.assign(payload, morphStamp)
1691
- createdRecord = await model.create(payload)
1692
- } else {
1693
- // M2M: create the related record first, then attach via the
1694
- // pivot accessor. The accessor handles polymorphic stamping
1695
- // (`<morphName>Type`) transparently for morphToMany /
1696
- // morphedByMany. When `pivotColumns` is set the per-id
1697
- // attach map ferries pivot extras into the new pivot row.
1698
- const created = await model.create(payload)
1699
- const newPk = (created as Record<string, unknown> | null | undefined)?.[pk]
1700
- if (newPk === undefined || newPk === null) {
1701
- throw new Error(
1702
- `[Pilotiq] Repeater.relationship("${cfg.name}"): newly created related record has no primary key — ` +
1703
- `cannot attach pivot row. Check that \`${(model as { name?: string }).name ?? 'related model'}.create()\` ` +
1704
- `returns a record with the primary key set.`,
1705
- )
1706
- }
1707
- if (hasPivotPayload) {
1708
- await m2mAccessor!.attach!({ [String(newPk)]: pivotPayload })
1709
- } else {
1710
- await m2mAccessor!.attach!([newPk as string | number])
1711
- }
1712
- createdRecord = created
1713
- }
1714
- // Phase B PK-switch — emit the rename so a collab adapter can swap
1715
- // the row's id in the shared CRDT. Skipped when the submitter didn't
1716
- // pass an `__id` (rare: only happens when consumer code constructs
1717
- // a row server-side); skipped when old === new (consumer pre-assigned
1718
- // the DB PK on the row).
1719
- const createdPk = (createdRecord as Record<string, unknown> | null | undefined)?.[pk]
1720
- if (submittedId !== undefined && createdPk !== undefined && createdPk !== null) {
1721
- const newId = String(createdPk)
1722
- if (submittedId !== newId) {
1723
- renames.push({ field: cfg.name, old: submittedId, new: newId })
1724
- }
1725
- }
1726
- if (afterCreate) await afterCreate(createdRecord, buildRowCtx(idx))
1727
- }
1728
- }
1729
-
1730
- for (const [pkVal, removedRow] of existingByPk) {
1731
- if (keptPks.has(pkVal)) continue
1732
- if (isM2M) {
1733
- // Detach the pivot link only — the related record may still be
1734
- // attached to other parents. `cascadeDelete` opt-in is a Tier-2
1735
- // follow-up.
1736
- await m2mAccessor!.detach!([pkVal])
1737
- } else {
1738
- await model.delete(pkVal)
1739
- }
1740
- if (afterDelete) await afterDelete(removedRow, buildRowCtx(-1))
1741
- }
1742
- return renames
1743
- }
1744
-
1745
- /**
1746
- * Read all rows from `parent.related(name)`. Used both by the load-
1747
- * side fill (in pageData) and the save-side diff (above). Caps at
1748
- * 10k — admin Repeaters should never get that large; if they do we'll
1749
- * add explicit pagination.
1750
- */
1751
- export async function loadRelationRows(
1752
- parentModel: ModelLike,
1753
- parent: unknown,
1754
- name: string,
1755
- pivotColumns?: readonly string[],
1756
- ): Promise<unknown[]> {
1757
- let q = resolveRelatedQuery(parentModel, parent, name)
1758
- if (pivotColumns && pivotColumns.length > 0 && typeof q.withPivot === 'function') {
1759
- q = q.withPivot(...pivotColumns)
1760
- }
1761
- const result = await q.paginate(1, 10000)
1762
- return result.data
1763
- }
1764
-
1765
- // ─── Builder.relationship — extraction + persistence ─────────
1766
-
1767
- interface BuilderRelationshipDeferral {
1768
- field: BuilderField
1769
- rows: Array<Record<string, unknown>>
1770
- cfg: BuilderRelationshipConfig
1771
- }
1772
-
1773
- /**
1774
- * Walk the form's top-level Builders and extract values for any that have
1775
- * a `relationship(...)` config. Same shape + posture as
1776
- * `extractRelationshipRepeaters`; mutates `data` in place by deleting each
1777
- * extracted key. Heterogeneous-row sibling — each row is a
1778
- * `{ __id?, type, data: {…} }` envelope after `coerceBuilderValue`.
1779
- */
1780
- export function extractRelationshipBuilders(
1781
- elements: Element[],
1782
- data: Record<string, unknown>,
1783
- ): BuilderRelationshipDeferral[] {
1784
- const out: BuilderRelationshipDeferral[] = []
1785
- walkBuildersTopLevel(elements, builder => {
1786
- const cfg = builder.getRelationship()
1787
- if (!cfg) return
1788
- const value = data[builder.name]
1789
- delete data[builder.name]
1790
- if (!Array.isArray(value)) return
1791
- out.push({
1792
- field: builder,
1793
- rows: value as Array<Record<string, unknown>>,
1794
- cfg,
1795
- })
1796
- })
1797
- return out
1798
- }
1799
-
1800
- /**
1801
- * Resolved attachment shape for a relationship-backed Builder. v1 of
1802
- * Builder.relationship handled `hasMany` only; the morphMany variant
1803
- * stamps `<morphName>Id` + `<morphName>Type` on every create instead of
1804
- * a single FK column. The two branches share the load path
1805
- * (`parent.related(name)` already filters morph cols) but differ in the
1806
- * persist payload.
1807
- */
1808
- type BuilderChildAttachment =
1809
- | { kind: 'hasMany'; model: ModelLike; foreignKey: string }
1810
- | { kind: 'morphMany'; model: ModelLike; morph: MorphRelationDescriptor }
1811
-
1812
- /**
1813
- * Resolve the child model + parent-attachment shape for a
1814
- * relationship-backed Builder. Two supported modes:
1815
- *
1816
- * - `hasMany` — single foreign key on the child. Falls back to
1817
- * `cfg.model` / `cfg.foreignKey` overrides when the
1818
- * parent's `static relations[name]` doesn't expose them.
1819
- * - `morphMany` — polymorphic owner side. Reads the morph descriptor
1820
- * off the parent's `static relations[name]` (no
1821
- * override path — the discriminator + id columns are
1822
- * driven entirely by `morphName`). `morphOne` collapses
1823
- * into the same branch (the storage shape is identical;
1824
- * "one row" is enforced upstream by the schema).
1825
- *
1826
- * Throws a clear configuration error when the relation type isn't one of
1827
- * those two, or when the descriptor lookup fails entirely.
1828
- */
1829
- function resolveBuilderChildAndAttachment(
1830
- parentModel: ModelLike,
1831
- cfg: BuilderRelationshipConfig,
1832
- ): BuilderChildAttachment {
1833
- // Detect M2M first — a `belongsToMany` / `morphToMany` /
1834
- // `morphedByMany` entry has no `foreignKey`, so it would silently
1835
- // fall through to the hasMany branch below and surface a less-useful
1836
- // "could not resolve foreign-key" error. Builder rows
1837
- // (`{ type, data }`) don't compose with M2M pivot semantics, so this
1838
- // is the surface where we point users at Repeater.relationship.
1839
- const m2mDescriptor = getM2MRelationDescriptor(parentModel, cfg.name)
1840
- if (m2mDescriptor) {
1841
- throw new Error(
1842
- `[Pilotiq] Builder.relationship("${cfg.name}"): unsupported relation type '${m2mDescriptor.type}'. ` +
1843
- `Only 'hasMany' and 'morphMany' / 'morphOne' are supported on Builder.relationship in v1. ` +
1844
- `belongsToMany / morphToMany / morphedByMany are not supported — the heterogeneous {type, data} ` +
1845
- `envelope doesn't compose cleanly with M2M pivot semantics. Use a hasMany or morphMany relation, ` +
1846
- `or use Repeater.relationship if your rows are homogeneous.`,
1847
- )
1848
- }
1849
-
1850
- const parentDescriptor = getParentRelationDescriptor(parentModel, cfg.name)
1851
- const morphDescriptor = getMorphRelationDescriptor(parentModel, cfg.name)
1852
- const type = parentDescriptor?.type
1853
- ?? (morphDescriptor ? 'morphMany' : 'hasMany')
1854
-
1855
- if (type === 'morphMany' || type === 'morphOne') {
1856
- const model = cfg.model ?? morphDescriptor?.model?.()
1857
- if (!model) {
1858
- throw new Error(
1859
- `[Pilotiq] Builder.relationship("${cfg.name}"): could not resolve the child model. ` +
1860
- `Pass it explicitly via .relationship({ name, model: ChildModel }) or declare ` +
1861
- `the relation's \`model\` thunk on the parent model's static relations map.`,
1862
- )
1863
- }
1864
- if (!morphDescriptor) {
1865
- throw new Error(
1866
- `[Pilotiq] Builder.relationship("${cfg.name}"): polymorphic relation entry is missing \`morphName\`. ` +
1867
- `Set \`relations.${cfg.name} = { type: 'morphMany', morphName: '<name>', model: () => ChildModel }\` on the parent.`,
1868
- )
1869
- }
1870
- return { kind: 'morphMany', model, morph: morphDescriptor }
1871
- }
1872
-
1873
- const model = cfg.model ?? parentDescriptor?.model()
1874
- const foreignKey = cfg.foreignKey ?? parentDescriptor?.foreignKey
1875
-
1876
- if (!model) {
1877
- throw new Error(
1878
- `[Pilotiq] Builder.relationship("${cfg.name}"): could not resolve the child model. ` +
1879
- `Pass it explicitly via .relationship({ name, model: ChildModel }) or declare ` +
1880
- `the relation on the parent model's static relations map.`,
1881
- )
1882
- }
1883
- if (!foreignKey) {
1884
- throw new Error(
1885
- `[Pilotiq] Builder.relationship("${cfg.name}"): could not resolve the foreign-key column. ` +
1886
- `Pass it explicitly via .relationship({ name, foreignKey: 'parentId' }) or declare ` +
1887
- `it on the parent model's static relations map.`,
1888
- )
1889
- }
1890
- if (type !== 'hasMany') {
1891
- throw new Error(
1892
- `[Pilotiq] Builder.relationship("${cfg.name}"): unsupported relation type '${type}'. ` +
1893
- `Only 'hasMany' and 'morphMany' / 'morphOne' are supported on Builder.relationship in v1. ` +
1894
- `belongsToMany / morphToMany / morphedByMany are not supported — the heterogeneous {type, data} ` +
1895
- `envelope doesn't compose cleanly with M2M pivot semantics. Use a hasMany or morphMany relation, ` +
1896
- `or use Repeater.relationship if your rows are homogeneous.`,
1897
- )
1898
- }
1899
-
1900
- return { kind: 'hasMany', model, foreignKey }
1901
- }
1902
-
1903
- /**
1904
- * Diff submitted Builder rows against the existing related rows and apply
1905
- * create / update / delete operations through the child model. Same
1906
- * identity rules as the Repeater pair — `__id` matches an existing PK →
1907
- * update, missing → create, existing PK absent from submitted set →
1908
- * delete. Each row writes its `type` discriminator + JSON `data` payload
1909
- * to the configured columns.
1910
- */
1911
- async function persistRelationshipBuilderRows(
1912
- parent: unknown,
1913
- deferral: BuilderRelationshipDeferral,
1914
- parentModel: ModelLike,
1915
- ): Promise<RelationshipRename[]> {
1916
- const renames: RelationshipRename[] = []
1917
- const { rows, cfg } = deferral
1918
- const attachment = resolveBuilderChildAndAttachment(parentModel, cfg)
1919
- const { model } = attachment
1920
- const pk = getPrimaryKey(model)
1921
- const typeColumn = cfg.typeColumn ?? 'type'
1922
- const dataColumn = cfg.dataColumn ?? 'data'
1923
- const orderColumn = cfg.orderColumn
1924
- const parentPk = (parent as Record<string, unknown> | undefined)?.[getPrimaryKey(parentModel)]
1925
- if (parentPk === undefined || parentPk === null) {
1926
- throw new Error(
1927
- `[Pilotiq] Builder.relationship("${cfg.name}"): parent record has no primary key after save. ` +
1928
- `Form.save() / handleCreate() must return a record with a primary key set.`,
1929
- )
1930
- }
1931
-
1932
- // Compute the morph stamp once — `computeMorphPayload` is pure.
1933
- const morphStamp = attachment.kind === 'morphMany'
1934
- ? computeMorphPayload(parent, attachment.morph)
1935
- : undefined
1936
-
1937
- const existing = await loadRelationRows(parentModel, parent, cfg.name)
1938
- const existingByPk = new Map<string, Record<string, unknown>>()
1939
- for (const row of existing) {
1940
- const key = String((row as Record<string, unknown>)[pk])
1941
- existingByPk.set(key, row as Record<string, unknown>)
1942
- }
1943
-
1944
- const keptPks = new Set<string>()
1945
-
1946
- for (let idx = 0; idx < rows.length; idx++) {
1947
- const submitted = rows[idx] ?? {}
1948
- const submittedId = typeof submitted['__id'] === 'string' ? submitted['__id'] : undefined
1949
- const isUpdate = submittedId !== undefined && existingByPk.has(submittedId)
1950
-
1951
- const blockType = typeof submitted['type'] === 'string' ? submitted['type'] : ''
1952
- const blockData = (submitted['data'] && typeof submitted['data'] === 'object')
1953
- ? submitted['data']
1954
- : {}
1955
-
1956
- const payload: Record<string, unknown> = {
1957
- [typeColumn]: blockType,
1958
- [dataColumn]: blockData,
1959
- }
1960
- if (orderColumn !== undefined) payload[orderColumn] = idx
1961
-
1962
- if (isUpdate) {
1963
- // Don't overwrite the parent attachment on update — for hasMany the
1964
- // FK is already correct; for morphMany the `<morphName>Id` +
1965
- // `<morphName>Type` cols are too. Defense against a tampered
1966
- // client trying to re-link the child to a different polymorphic
1967
- // parent.
1968
- await model.update(submittedId!, payload)
1969
- keptPks.add(submittedId!)
1970
- } else {
1971
- if (attachment.kind === 'hasMany') {
1972
- payload[attachment.foreignKey] = parentPk
1973
- } else {
1974
- Object.assign(payload, morphStamp)
1975
- }
1976
- const createdRecord = await model.create(payload)
1977
- // Phase B PK-switch — see persistRelationshipRows for the contract.
1978
- const createdPk = (createdRecord as Record<string, unknown> | null | undefined)?.[pk]
1979
- if (submittedId !== undefined && createdPk !== undefined && createdPk !== null) {
1980
- const newId = String(createdPk)
1981
- if (submittedId !== newId) {
1982
- renames.push({ field: cfg.name, old: submittedId, new: newId })
1983
- }
1984
- }
1985
- }
1986
- }
1987
-
1988
- for (const [pkVal] of existingByPk) {
1989
- if (keptPks.has(pkVal)) continue
1990
- await model.delete(pkVal)
1991
- }
1992
- return renames
1993
- }