@pilotiq/pilotiq 0.24.1 → 0.24.2
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.
- package/CHANGELOG.md +33 -0
- package/boost/guidelines.md +566 -0
- package/boost/skills/pilotiq-fields/SKILL.md +47 -0
- package/boost/skills/pilotiq-fields/rules/field-catalog.md +288 -0
- package/boost/skills/pilotiq-fields/rules/reactive-fields.md +199 -0
- package/boost/skills/pilotiq-fields/rules/validation.md +198 -0
- package/boost/skills/pilotiq-relations/SKILL.md +47 -0
- package/boost/skills/pilotiq-relations/rules/relation-managers.md +256 -0
- package/boost/skills/pilotiq-relations/rules/repeater-relationship.md +177 -0
- package/boost/skills/pilotiq-resource/SKILL.md +61 -0
- package/boost/skills/pilotiq-resource/rules/authorization.md +242 -0
- package/boost/skills/pilotiq-resource/rules/defining-resources.md +228 -0
- package/boost/skills/pilotiq-resource/rules/page-overrides.md +296 -0
- package/package.json +6 -1
- package/.turbo/turbo-build.log +0 -8
- package/CLAUDE.md +0 -265
- package/src/Cluster.test.ts +0 -283
- package/src/Cluster.ts +0 -83
- package/src/Column.test.ts +0 -199
- package/src/Column.ts +0 -710
- package/src/Global.test.ts +0 -367
- package/src/Global.ts +0 -169
- package/src/Page.test.ts +0 -114
- package/src/Page.ts +0 -208
- package/src/Pilotiq.perf.test.ts +0 -252
- package/src/Pilotiq.test.ts +0 -129
- package/src/Pilotiq.ts +0 -1158
- package/src/PilotiqRegistry.ts +0 -36
- package/src/PilotiqServiceProvider.ts +0 -121
- package/src/RelationManager.test.ts +0 -400
- package/src/RelationManager.ts +0 -527
- package/src/RenderHook.test.ts +0 -252
- package/src/RenderHook.ts +0 -242
- package/src/Resource.test.ts +0 -284
- package/src/Resource.ts +0 -526
- package/src/RightPanel.test.ts +0 -202
- package/src/RightPanel.ts +0 -132
- package/src/Tab.test.ts +0 -91
- package/src/Tab.ts +0 -156
- package/src/UserMenuItem.ts +0 -145
- package/src/actions/Action.test.ts +0 -2526
- package/src/actions/Action.ts +0 -1515
- package/src/actions/ActionGroup.test.ts +0 -112
- package/src/actions/ActionGroup.ts +0 -173
- package/src/actions/attachFactory.ts +0 -172
- package/src/actions/bulkFactories.ts +0 -168
- package/src/actions/crudFactories.ts +0 -220
- package/src/actions/exportFactory.ts +0 -225
- package/src/actions/factoryHelpers.ts +0 -177
- package/src/actions/importFactory.ts +0 -243
- package/src/actions/index.ts +0 -17
- package/src/actions/m2mFactories.ts +0 -193
- package/src/actions/relationFactories.ts +0 -372
- package/src/applyPageHooks.test.ts +0 -463
- package/src/applyPageHooks.ts +0 -330
- package/src/authorization.test.ts +0 -483
- package/src/breadcrumbs.test.ts +0 -238
- package/src/cells/coerce.test.ts +0 -85
- package/src/cells/coerce.ts +0 -84
- package/src/clusterPaths.ts +0 -35
- package/src/columns/BadgeColumn.test.ts +0 -54
- package/src/columns/BadgeColumn.ts +0 -32
- package/src/columns/BooleanColumn.test.ts +0 -41
- package/src/columns/BooleanColumn.ts +0 -18
- package/src/columns/ColorColumn.test.ts +0 -37
- package/src/columns/ColorColumn.ts +0 -38
- package/src/columns/IconColumn.test.ts +0 -54
- package/src/columns/IconColumn.ts +0 -37
- package/src/columns/ImageColumn.test.ts +0 -41
- package/src/columns/ImageColumn.ts +0 -28
- package/src/columns/SelectColumn.ts +0 -98
- package/src/columns/TextColumn.test.ts +0 -190
- package/src/columns/TextColumn.ts +0 -20
- package/src/columns/TextInputColumn.ts +0 -68
- package/src/columns/ToggleColumn.ts +0 -46
- package/src/columns/editableColumns.test.ts +0 -238
- package/src/columns/index.ts +0 -9
- package/src/defaultGlobalPages.ts +0 -95
- package/src/defaultPages.test.ts +0 -634
- package/src/defaultPages.ts +0 -617
- package/src/defaultViewPage.test.ts +0 -147
- package/src/elements/Form.test.ts +0 -223
- package/src/elements/Form.ts +0 -416
- package/src/elements/ListTabs.ts +0 -28
- package/src/elements/Table.test.ts +0 -422
- package/src/elements/Table.ts +0 -850
- package/src/elements/TableGroup.test.ts +0 -260
- package/src/elements/TableGroup.ts +0 -334
- package/src/elements/dispatchAction.test.ts +0 -463
- package/src/elements/dispatchAction.ts +0 -355
- package/src/elements/dispatchForm.test.ts +0 -477
- package/src/elements/dispatchForm.ts +0 -1993
- package/src/elements/dispatchTable.test.ts +0 -1514
- package/src/elements/dispatchTable.ts +0 -745
- package/src/elements/index.ts +0 -21
- package/src/entries/BadgeEntry.ts +0 -39
- package/src/entries/CodeEntry.test.ts +0 -40
- package/src/entries/CodeEntry.ts +0 -52
- package/src/entries/ColorEntry.ts +0 -63
- package/src/entries/ComponentEntry.test.ts +0 -173
- package/src/entries/ComponentEntry.ts +0 -95
- package/src/entries/Entry.ts +0 -304
- package/src/entries/IconEntry.ts +0 -49
- package/src/entries/ImageEntry.ts +0 -61
- package/src/entries/KeyValueEntry.ts +0 -47
- package/src/entries/RepeatableEntry.test.ts +0 -239
- package/src/entries/RepeatableEntry.ts +0 -173
- package/src/entries/TextEntry.test.ts +0 -394
- package/src/entries/TextEntry.ts +0 -60
- package/src/entries/index.ts +0 -12
- package/src/entries/leaves.test.ts +0 -306
- package/src/entries/registry.ts +0 -54
- package/src/fields/BuilderField.test.ts +0 -1188
- package/src/fields/BuilderField.ts +0 -605
- package/src/fields/BuilderRelationship.test.ts +0 -811
- package/src/fields/CheckboxField.test.ts +0 -44
- package/src/fields/CheckboxField.ts +0 -27
- package/src/fields/CheckboxListField.test.ts +0 -99
- package/src/fields/CheckboxListField.ts +0 -66
- package/src/fields/ColorPickerField.test.ts +0 -33
- package/src/fields/ColorPickerField.ts +0 -25
- package/src/fields/DateField.ts +0 -54
- package/src/fields/DateTimeField.test.ts +0 -55
- package/src/fields/EmailField.ts +0 -16
- package/src/fields/Field.test.ts +0 -654
- package/src/fields/Field.ts +0 -817
- package/src/fields/FileUploadField.test.ts +0 -143
- package/src/fields/FileUploadField.ts +0 -159
- package/src/fields/HiddenField.test.ts +0 -27
- package/src/fields/HiddenField.ts +0 -28
- package/src/fields/KeyValueField.test.ts +0 -105
- package/src/fields/KeyValueField.ts +0 -55
- package/src/fields/MarkdownField.test.ts +0 -167
- package/src/fields/MarkdownField.ts +0 -162
- package/src/fields/NumberField.ts +0 -33
- package/src/fields/RadioField.test.ts +0 -94
- package/src/fields/RadioField.ts +0 -67
- package/src/fields/RepeaterField.test.ts +0 -1806
- package/src/fields/RepeaterField.ts +0 -939
- package/src/fields/RepeaterRelationship.test.ts +0 -1923
- package/src/fields/RepeaterSimple.test.ts +0 -248
- package/src/fields/RowButton.test.ts +0 -219
- package/src/fields/RowButton.ts +0 -135
- package/src/fields/SelectField.test.ts +0 -192
- package/src/fields/SelectField.ts +0 -235
- package/src/fields/SliderField.test.ts +0 -50
- package/src/fields/SliderField.ts +0 -53
- package/src/fields/SlugField.ts +0 -24
- package/src/fields/TagsInputField.test.ts +0 -154
- package/src/fields/TagsInputField.ts +0 -133
- package/src/fields/TextField.test.ts +0 -213
- package/src/fields/TextField.ts +0 -177
- package/src/fields/TextareaField.test.ts +0 -58
- package/src/fields/TextareaField.ts +0 -59
- package/src/fields/ToggleButtonsField.test.ts +0 -106
- package/src/fields/ToggleButtonsField.ts +0 -59
- package/src/fields/ToggleField.ts +0 -16
- package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
- package/src/fields/optionsResolver.ts +0 -95
- package/src/fields/resolveField.ts +0 -28
- package/src/filters/BooleanFilter.ts +0 -35
- package/src/filters/DateRangeFilter.test.ts +0 -194
- package/src/filters/DateRangeFilter.ts +0 -148
- package/src/filters/Filter.test.ts +0 -268
- package/src/filters/Filter.ts +0 -184
- package/src/filters/FormFilter.test.ts +0 -238
- package/src/filters/FormFilter.ts +0 -215
- package/src/filters/MultiSelectFilter.test.ts +0 -119
- package/src/filters/MultiSelectFilter.ts +0 -78
- package/src/filters/QueryBuilderFilter.test.ts +0 -662
- package/src/filters/QueryBuilderFilter.ts +0 -398
- package/src/filters/SelectFilter.ts +0 -46
- package/src/filters/TernaryFilter.test.ts +0 -160
- package/src/filters/TernaryFilter.ts +0 -72
- package/src/filters/TrashedFilter.test.ts +0 -149
- package/src/filters/TrashedFilter.ts +0 -55
- package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
- package/src/filters/queryBuilder/Constraint.ts +0 -115
- package/src/filters/queryBuilder/DateConstraint.ts +0 -69
- package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
- package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
- package/src/filters/queryBuilder/TextConstraint.ts +0 -64
- package/src/filters/queryBuilder/index.ts +0 -12
- package/src/icons/index.ts +0 -2
- package/src/icons/lucide.ts +0 -204
- package/src/icons/registry.test.ts +0 -56
- package/src/icons/registry.ts +0 -41
- package/src/icons/types.ts +0 -47
- package/src/index.ts +0 -525
- package/src/io/csv.test.ts +0 -142
- package/src/io/csv.ts +0 -170
- package/src/nestedRelationManagerData.test.ts +0 -547
- package/src/notifications/Notification.test.ts +0 -210
- package/src/notifications/Notification.ts +0 -354
- package/src/notifications/broadcast.test.ts +0 -110
- package/src/notifications/broadcast.ts +0 -95
- package/src/notifications/database.test.ts +0 -383
- package/src/notifications/database.ts +0 -398
- package/src/notifications/databaseNotifications.test.ts +0 -187
- package/src/notifications/dispatchNotificationAction.test.ts +0 -341
- package/src/notifications/dispatchNotificationAction.ts +0 -142
- package/src/notifications/flash.test.ts +0 -89
- package/src/notifications/flash.ts +0 -71
- package/src/notifications/index.ts +0 -45
- package/src/notifications/registerBroadcastAuth.test.ts +0 -134
- package/src/notifications/registerBroadcastAuth.ts +0 -100
- package/src/notifications/resolveSavedNotification.test.ts +0 -82
- package/src/notifications/resolveSavedNotification.ts +0 -59
- package/src/notifications/types.ts +0 -93
- package/src/orm/m2mAccessor.ts +0 -66
- package/src/orm/modelDefaults.test.ts +0 -633
- package/src/orm/modelDefaults.ts +0 -666
- package/src/pageData/breadcrumbs.ts +0 -288
- package/src/pageData/forms.ts +0 -578
- package/src/pageData/helpers.ts +0 -857
- package/src/pageData/misc.ts +0 -347
- package/src/pageData/navigation.ts +0 -842
- package/src/pageData/relationPages.ts +0 -1248
- package/src/pageData/relationTabs.ts +0 -286
- package/src/pageData/resourcePages.ts +0 -609
- package/src/pageData.test.ts +0 -1545
- package/src/pageData.ts +0 -341
- package/src/plugins/index.ts +0 -8
- package/src/plugins/themeEditor.test.ts +0 -36
- package/src/plugins/themeEditor.ts +0 -45
- package/src/react/AppShell.tsx +0 -251
- package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
- package/src/react/CollabRoomContext.ts +0 -98
- package/src/react/CollabTextRendererRegistry.ts +0 -102
- package/src/react/CommandPalette.tsx +0 -375
- package/src/react/CurrentUserContext.tsx +0 -50
- package/src/react/CustomPageWrapperGate.tsx +0 -69
- package/src/react/CustomPageWrapperRegistry.ts +0 -45
- package/src/react/FieldFocusReporterRegistry.ts +0 -37
- package/src/react/FieldLabelSlotRegistry.ts +0 -30
- package/src/react/FieldPresenceRegistry.ts +0 -46
- package/src/react/FormCollabBindingRegistry.ts +0 -242
- package/src/react/FormStateContext.tsx +0 -591
- package/src/react/HeadHooks.tsx +0 -126
- package/src/react/MarkdownEditorRegistry.test.ts +0 -38
- package/src/react/MarkdownEditorRegistry.ts +0 -107
- package/src/react/NotificationActionStrip.tsx +0 -263
- package/src/react/NotificationBell.tsx +0 -426
- package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
- package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
- package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
- package/src/react/PendingSuggestionsContext.tsx +0 -172
- package/src/react/RecordWrapperGate.tsx +0 -58
- package/src/react/RecordWrapperRegistry.ts +0 -39
- package/src/react/RenderHookSlot.tsx +0 -32
- package/src/react/RightSidebar.tsx +0 -257
- package/src/react/RightSidebarContext.tsx +0 -234
- package/src/react/RightSidebarTrigger.tsx +0 -53
- package/src/react/RowCoordsContext.tsx +0 -23
- package/src/react/SchemaRenderer.tsx +0 -549
- package/src/react/SearchTrigger.tsx +0 -46
- package/src/react/ThemeProvider.tsx +0 -93
- package/src/react/ThemeSettingsPage.tsx +0 -579
- package/src/react/ThemeToggle.tsx +0 -20
- package/src/react/Toaster.tsx +0 -158
- package/src/react/UserMenu.tsx +0 -196
- package/src/react/WidgetDataContext.tsx +0 -157
- package/src/react/cells/EditableCell.tsx +0 -389
- package/src/react/component-slots.test.ts +0 -103
- package/src/react/component-slots.ts +0 -116
- package/src/react/fieldJsHandler.test.ts +0 -166
- package/src/react/fieldJsHandler.ts +0 -79
- package/src/react/fields/BuilderInput.tsx +0 -1078
- package/src/react/fields/CheckboxInput.tsx +0 -39
- package/src/react/fields/CheckboxListInput.tsx +0 -102
- package/src/react/fields/ColorInput.tsx +0 -71
- package/src/react/fields/DateFieldInput.tsx +0 -70
- package/src/react/fields/DateTimeInput.tsx +0 -62
- package/src/react/fields/FieldShell.tsx +0 -348
- package/src/react/fields/FileUploadInput.tsx +0 -639
- package/src/react/fields/HiddenInput.tsx +0 -17
- package/src/react/fields/KeyValueInput.tsx +0 -230
- package/src/react/fields/MarkdownInput.tsx +0 -560
- package/src/react/fields/RadioInput.tsx +0 -81
- package/src/react/fields/RepeaterInput.test.ts +0 -116
- package/src/react/fields/RepeaterInput.tsx +0 -1420
- package/src/react/fields/SelectFieldInput.tsx +0 -280
- package/src/react/fields/SliderInput.tsx +0 -81
- package/src/react/fields/TagsInput.tsx +0 -283
- package/src/react/fields/TextLikeInput.tsx +0 -256
- package/src/react/fields/ToggleButtonsInput.tsx +0 -60
- package/src/react/fields/ToggleFieldInput.tsx +0 -56
- package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
- package/src/react/fields/relationshipRenameDispatch.ts +0 -97
- package/src/react/fields/repeaterReconcile.test.ts +0 -114
- package/src/react/fields/repeaterReconcile.ts +0 -104
- package/src/react/fields/rowChromeButton.tsx +0 -336
- package/src/react/fields/rowState.ts +0 -106
- package/src/react/fields/syncRowGates.test.ts +0 -202
- package/src/react/fields/syncRowGates.ts +0 -66
- package/src/react/fields/textInputControls.tsx +0 -238
- package/src/react/fields/useRowReorderDnd.ts +0 -78
- package/src/react/formStateHelpers.test.ts +0 -508
- package/src/react/formStateHelpers.ts +0 -381
- package/src/react/hooks/use-mobile.ts +0 -19
- package/src/react/icon-context.tsx +0 -60
- package/src/react/index.ts +0 -194
- package/src/react/layouts/SidebarLayout.tsx +0 -250
- package/src/react/layouts/TopbarLayout.tsx +0 -258
- package/src/react/navigate.tsx +0 -37
- package/src/react/onProviderSynced.test.ts +0 -90
- package/src/react/parseRecordEditUrl.test.ts +0 -122
- package/src/react/parseRecordEditUrl.ts +0 -94
- package/src/react/persistedState.ts +0 -40
- package/src/react/registry.ts +0 -48
- package/src/react/right-panel-registry.tsx +0 -47
- package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
- package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
- package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
- package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
- package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
- package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
- package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
- package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
- package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
- package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
- package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
- package/src/react/schemaRenderer/action/buttons.tsx +0 -99
- package/src/react/schemaRenderer/action/helpers.ts +0 -140
- package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
- package/src/react/schemaRenderer/columnFormat.ts +0 -65
- package/src/react/schemaRenderer/constants.ts +0 -50
- package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
- package/src/react/schemaRenderer/form/renderField.tsx +0 -511
- package/src/react/schemaRenderer/helpers.tsx +0 -81
- package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
- package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
- package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
- package/src/react/schemaRenderer/table/filters.tsx +0 -1233
- package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
- package/src/react/schemaRenderer/table/links.tsx +0 -112
- package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
- package/src/react/schemaRenderer/table/url.tsx +0 -143
- package/src/react/theme-preview/apply.ts +0 -99
- package/src/react/theme-preview/build-html.ts +0 -436
- package/src/react/ui/button.tsx +0 -51
- package/src/react/ui/calendar.tsx +0 -67
- package/src/react/ui/checkbox.tsx +0 -29
- package/src/react/ui/dialog.tsx +0 -108
- package/src/react/ui/dropdown-menu.tsx +0 -97
- package/src/react/ui/input.tsx +0 -20
- package/src/react/ui/label.tsx +0 -21
- package/src/react/ui/popover.tsx +0 -50
- package/src/react/ui/select.tsx +0 -169
- package/src/react/ui/separator.tsx +0 -25
- package/src/react/ui/sheet.tsx +0 -136
- package/src/react/ui/sidebar.tsx +0 -723
- package/src/react/ui/skeleton.tsx +0 -13
- package/src/react/ui/slider.tsx +0 -34
- package/src/react/ui/switch.tsx +0 -28
- package/src/react/ui/table.tsx +0 -105
- package/src/react/ui/tabs.tsx +0 -63
- package/src/react/ui/textarea.tsx +0 -18
- package/src/react/ui/tooltip.tsx +0 -64
- package/src/react/useResizableWidth.ts +0 -139
- package/src/react/utils.ts +0 -6
- package/src/react/widgetRegistry.test.ts +0 -43
- package/src/react/widgetRegistry.ts +0 -50
- package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
- package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
- package/src/react/widgets/ViewRenderer.tsx +0 -71
- package/src/relationManagerData.test.ts +0 -1595
- package/src/richtext/index.ts +0 -8
- package/src/richtext/registry.ts +0 -89
- package/src/routes/globals.ts +0 -148
- package/src/routes/guard.test.ts +0 -325
- package/src/routes/helpers.ts +0 -704
- package/src/routes/pages.ts +0 -175
- package/src/routes/panel.ts +0 -204
- package/src/routes/relations.ts +0 -1243
- package/src/routes/resources.ts +0 -781
- package/src/routes/theme.ts +0 -91
- package/src/routes-nested-relations.test.ts +0 -676
- package/src/routes-relations.test.ts +0 -972
- package/src/routes.test.ts +0 -2027
- package/src/routes.ts +0 -303
- package/src/schema/Alert.test.ts +0 -109
- package/src/schema/Alert.ts +0 -131
- package/src/schema/Block.ts +0 -169
- package/src/schema/Breadcrumbs.ts +0 -40
- package/src/schema/Card.ts +0 -35
- package/src/schema/Divider.ts +0 -20
- package/src/schema/Element.ts +0 -219
- package/src/schema/EmptyState.test.ts +0 -37
- package/src/schema/EmptyState.ts +0 -63
- package/src/schema/Fieldset.ts +0 -43
- package/src/schema/Grid.ts +0 -43
- package/src/schema/Group.ts +0 -30
- package/src/schema/Heading.ts +0 -39
- package/src/schema/Html.ts +0 -67
- package/src/schema/Icon.ts +0 -54
- package/src/schema/Image.ts +0 -57
- package/src/schema/LinkTag.ts +0 -41
- package/src/schema/Markdown.ts +0 -85
- package/src/schema/MetaTag.ts +0 -41
- package/src/schema/RelationTabs.ts +0 -71
- package/src/schema/ScriptTag.ts +0 -55
- package/src/schema/Section.ts +0 -160
- package/src/schema/ServerDataElement.test.ts +0 -140
- package/src/schema/ServerDataElement.ts +0 -156
- package/src/schema/SlotComponent.test.ts +0 -77
- package/src/schema/SlotComponent.ts +0 -71
- package/src/schema/Split.ts +0 -50
- package/src/schema/Stat.test.ts +0 -118
- package/src/schema/Stat.ts +0 -154
- package/src/schema/StatsOverview.test.ts +0 -141
- package/src/schema/StatsOverview.ts +0 -119
- package/src/schema/StyleTag.ts +0 -35
- package/src/schema/TableWidget.test.ts +0 -297
- package/src/schema/TableWidget.ts +0 -289
- package/src/schema/Tabs.ts +0 -79
- package/src/schema/Text.ts +0 -58
- package/src/schema/UnorderedList.ts +0 -49
- package/src/schema/View.test.ts +0 -111
- package/src/schema/View.ts +0 -127
- package/src/schema/Wizard.ts +0 -220
- package/src/schema/containers.test.ts +0 -564
- package/src/schema/headTags.test.ts +0 -134
- package/src/schema/index.ts +0 -40
- package/src/schema/primes.test.ts +0 -269
- package/src/schema/resolveSchema.test.ts +0 -379
- package/src/schema/resolveSchema.ts +0 -917
- package/src/schema/sanitize.ts +0 -58
- package/src/search.test.ts +0 -446
- package/src/search.ts +0 -178
- package/src/sessionFilters.test.ts +0 -375
- package/src/sessionFilters.ts +0 -143
- package/src/slot-components/index.ts +0 -10
- package/src/slot-components/registry.ts +0 -56
- package/src/styles/file-upload.css +0 -13
- package/src/summarizers/Summarizer.test.ts +0 -84
- package/src/summarizers/Summarizer.ts +0 -123
- package/src/summarizers/index.ts +0 -11
- package/src/theme/base-colors.ts +0 -68
- package/src/theme/chart-colors.ts +0 -50
- package/src/theme/colors.ts +0 -447
- package/src/theme/generate-css.test.ts +0 -139
- package/src/theme/generate-css.ts +0 -44
- package/src/theme/generate-scale.test.ts +0 -106
- package/src/theme/generate-scale.ts +0 -97
- package/src/theme/icon-map.ts +0 -42
- package/src/theme/index.ts +0 -34
- package/src/theme/migrate.test.ts +0 -178
- package/src/theme/migrate.ts +0 -81
- package/src/theme/presets.ts +0 -135
- package/src/theme/radius.ts +0 -18
- package/src/theme/resolve.test.ts +0 -238
- package/src/theme/resolve.ts +0 -96
- package/src/theme/spacing.ts +0 -18
- package/src/theme/storage.test.ts +0 -126
- package/src/theme/storage.ts +0 -106
- package/src/theme/theme-colors.ts +0 -88
- package/src/theme/types.ts +0 -125
- package/src/uploads/UploadAdapter.ts +0 -35
- package/src/uploads/index.ts +0 -2
- package/src/uploads/localUpload.test.ts +0 -70
- package/src/uploads/localUpload.ts +0 -84
- package/src/validation/Validator.ts +0 -49
- package/src/validation/index.ts +0 -28
- package/src/validation/rules.ts +0 -78
- package/src/validation/runValidators.ts +0 -435
- package/src/validation/uniqueValidator.test.ts +0 -196
- package/src/validation/uniqueValidator.ts +0 -133
- package/src/validation/validators.test.ts +0 -268
- package/src/vite.test.ts +0 -184
- package/src/vite.ts +0 -787
- package/src/widgets/index.ts +0 -10
- package/src/widgets/registry.ts +0 -45
- package/src/widgets.test.ts +0 -592
- package/tsconfig.build.json +0 -11
- package/tsconfig.json +0 -4
- package/tsconfig.test.json +0 -10
- package/views/react/Dashboard.tsx +0 -27
- package/views/react/Resources/Form.tsx +0 -102
- 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
|
-
}
|