@oneuptime/common 12.0.9 → 12.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  2. package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
  3. package/Models/DatabaseModels/RunnerJob.ts +17 -2
  4. package/Server/API/LlmProviderAPI.ts +8 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +240 -0
  8. package/Server/Services/NetworkDeviceLinkService.ts +338 -2
  9. package/Server/Services/NetworkDeviceService.ts +265 -12
  10. package/Server/Services/RunnerJobService.ts +24 -1
  11. package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
  12. package/Server/Types/Database/JSONColumnQuery.ts +42 -4
  13. package/Server/Utils/LLM/LLMService.ts +108 -19
  14. package/Server/Utils/Monitor/MonitorAlert.ts +22 -115
  15. package/Server/Utils/Monitor/MonitorIncident.ts +7 -337
  16. package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +27 -2
  17. package/Server/Utils/Monitor/SeriesResourceLabels.ts +32 -0
  18. package/Server/Utils/Monitor/SeriesResourceLinker.ts +469 -0
  19. package/Server/Utils/SSRFProtection.ts +5 -4
  20. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  21. package/Tests/App/AdminDashboard/AdminUsersBulkAddToProject.test.tsx +955 -0
  22. package/Tests/App/AdminDashboard/BulkAddUsersToProject.test.ts +778 -0
  23. package/Tests/App/AdminDashboard/BulkAddUsersToProjectModal.test.tsx +1051 -0
  24. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  25. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +81 -6
  26. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  27. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  28. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  29. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  30. package/Tests/App/Dashboard/UserElementEmail.test.tsx +447 -0
  31. package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +284 -0
  32. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  33. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  34. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  35. package/Tests/Server/Services/NetworkDeviceLabelRuleRun.test.ts +618 -0
  36. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  37. package/Tests/Server/Services/NetworkSiteAssignmentRuleRun.test.ts +706 -0
  38. package/Tests/Server/Services/RunnerJobEnqueue.test.ts +412 -0
  39. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  40. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  41. package/Tests/Server/Utils/AI/LLMServiceRequestPolicy.test.ts +2 -2
  42. package/Tests/Server/Utils/AI/LLMServiceRetryPolicy.test.ts +450 -0
  43. package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +556 -0
  44. package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +547 -0
  45. package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +30 -0
  46. package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +699 -0
  47. package/Tests/Server/Utils/SeriesResourceLabels.test.ts +55 -0
  48. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  49. package/Tests/Types/API/URLMalformedTolerance.test.ts +301 -0
  50. package/Tests/Types/API/URLTelScheme.test.ts +261 -0
  51. package/Tests/Types/API/URLUnsupportedScheme.test.ts +148 -0
  52. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  53. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  54. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  55. package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +184 -0
  56. package/Tests/Types/Runbook/RunbookStepType.test.ts +124 -0
  57. package/Tests/Types/Workflow/BaseModelComponents.test.ts +17 -0
  58. package/Tests/UI/Components/Button.test.tsx +279 -0
  59. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  60. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  61. package/Tests/UI/Components/DictionaryValuePlaceholderAndSuggestions.test.tsx +595 -0
  62. package/Tests/UI/Components/Icon.test.tsx +57 -0
  63. package/Tests/UI/Components/List.test.tsx +20 -3
  64. package/Tests/UI/Components/LogsSavedViewsDropdown.test.tsx +304 -0
  65. package/Tests/UI/Components/LogsViewerClearSavedView.test.tsx +179 -0
  66. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  67. package/Tests/UI/Components/Pagination.test.tsx +721 -146
  68. package/Tests/UI/Components/PaginationIntegration.test.tsx +386 -0
  69. package/Tests/UI/Components/PaginationUtil.test.ts +417 -0
  70. package/Tests/UI/Components/TelemetrySavedViewsDropdown.test.tsx +343 -0
  71. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnControl.test.ts +289 -0
  72. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnOperators.test.ts +271 -0
  73. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRow.test.ts +119 -0
  74. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.test.ts +968 -0
  75. package/Tests/UI/Components/Workflow/ModelColumnEditor.render.test.tsx +766 -0
  76. package/Tests/UI/Utils/RunNetworkRule.test.ts +320 -0
  77. package/Tests/Utils/APIRetryPolicy.test.ts +823 -0
  78. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  79. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  80. package/Tests/Utils/NetworkAutomation/RuleRunSummary.test.ts +286 -0
  81. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  82. package/Types/API/Protocol.ts +9 -0
  83. package/Types/API/URL.ts +286 -20
  84. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  85. package/Types/CustomField/CustomFieldType.ts +28 -0
  86. package/Types/Monitor/MonitorType.ts +11 -0
  87. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  88. package/Types/NetworkAutomation/RuleRunResult.ts +121 -0
  89. package/Types/Runbook/RunbookStepType.ts +23 -0
  90. package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +7 -0
  91. package/UI/Components/Button/Button.tsx +14 -22
  92. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  93. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  94. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  95. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  96. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  97. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  98. package/UI/Components/Dictionary/Dictionary.tsx +4 -39
  99. package/UI/Components/Icon/Icon.tsx +1 -1
  100. package/UI/Components/LogsViewer/LogsViewer.tsx +5 -1
  101. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +8 -0
  102. package/UI/Components/LogsViewer/components/LogsPagination.tsx +29 -91
  103. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +2 -0
  104. package/UI/Components/LogsViewer/components/SavedViewsDropdown.tsx +66 -1
  105. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  106. package/UI/Components/Pagination/Pagination.tsx +373 -328
  107. package/UI/Components/Pagination/PaginationUtil.ts +263 -0
  108. package/UI/Components/Pagination/Screenshots/README.md +15 -0
  109. package/UI/Components/Pagination/Screenshots/pagination-before-after.png +0 -0
  110. package/UI/Components/Pagination/Screenshots/pagination-go-to-page-modal.png +0 -0
  111. package/UI/Components/Pagination/Screenshots/pagination-long-list.png +0 -0
  112. package/UI/Components/Pagination/Screenshots/pagination-mobile.png +0 -0
  113. package/UI/Components/Pagination/Screenshots/pagination-telemetry.png +0 -0
  114. package/UI/Components/TelemetryViewer/components/SavedViewsDropdown.tsx +66 -1
  115. package/UI/Components/TelemetryViewer/components/TelemetryPagination.tsx +38 -92
  116. package/UI/Components/TextArea/TextArea.tsx +3 -0
  117. package/UI/Components/Workflow/ArgumentsForm.tsx +18 -1
  118. package/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.tsx +177 -0
  119. package/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.tsx +243 -0
  120. package/UI/Components/Workflow/ColumnEditor/ColumnControl.ts +255 -0
  121. package/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.tsx +178 -0
  122. package/UI/Components/Workflow/ColumnEditor/ColumnOperators.ts +206 -0
  123. package/UI/Components/Workflow/ColumnEditor/ColumnRow.ts +157 -0
  124. package/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.ts +517 -0
  125. package/UI/Components/Workflow/ColumnEditor/ColumnValueInput.tsx +482 -0
  126. package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +131 -0
  127. package/UI/Components/Workflow/ColumnEditor/ModelRecordForm.tsx +163 -0
  128. package/UI/Components/Workflow/ModelColumnEditor.tsx +284 -100
  129. package/UI/Utils/NetworkAutomation/RunNetworkRule.ts +122 -0
  130. package/Utils/API.ts +243 -36
  131. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  132. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  133. package/Utils/NetworkAutomation/RuleRunSummary.ts +147 -0
  134. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  135. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  136. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  137. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  138. package/build/dist/Models/DatabaseModels/RunnerJob.js +17 -2
  139. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  140. package/build/dist/Server/API/LlmProviderAPI.js +9 -1
  141. package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
  142. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  143. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  144. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  145. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  146. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +202 -0
  147. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
  148. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  149. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  150. package/build/dist/Server/Services/NetworkDeviceService.js +223 -12
  151. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  152. package/build/dist/Server/Services/RunnerJobService.js +20 -2
  153. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  154. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  155. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  156. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  157. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  158. package/build/dist/Server/Utils/LLM/LLMService.js +78 -22
  159. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  160. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -98
  161. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  162. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +7 -254
  163. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  164. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +14 -2
  165. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
  166. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +27 -0
  167. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  168. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +285 -0
  169. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -0
  170. package/build/dist/Server/Utils/SSRFProtection.js +5 -4
  171. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  172. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  173. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  174. package/build/dist/Types/API/Protocol.js +9 -0
  175. package/build/dist/Types/API/Protocol.js.map +1 -1
  176. package/build/dist/Types/API/URL.js +241 -18
  177. package/build/dist/Types/API/URL.js.map +1 -1
  178. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  179. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  180. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  181. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  182. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  183. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  184. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +39 -0
  185. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -0
  186. package/build/dist/Types/Runbook/RunbookStepType.js +21 -0
  187. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  188. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +1 -1
  189. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
  190. package/build/dist/UI/Components/Button/Button.js +10 -16
  191. package/build/dist/UI/Components/Button/Button.js.map +1 -1
  192. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  193. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  194. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  195. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  196. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  197. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  198. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  199. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  200. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  201. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  202. package/build/dist/UI/Components/Dictionary/Dictionary.js +4 -18
  203. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  204. package/build/dist/UI/Components/Icon/Icon.js +1 -1
  205. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  206. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +3 -3
  207. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  208. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +5 -1
  209. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  210. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js +14 -44
  211. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js.map +1 -1
  212. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
  213. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  214. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js +20 -2
  215. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js.map +1 -1
  216. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  217. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  218. package/build/dist/UI/Components/Pagination/Pagination.js +174 -199
  219. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  220. package/build/dist/UI/Components/Pagination/PaginationUtil.js +160 -0
  221. package/build/dist/UI/Components/Pagination/PaginationUtil.js.map +1 -0
  222. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js +20 -2
  223. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js.map +1 -1
  224. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js +23 -47
  225. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js.map +1 -1
  226. package/build/dist/UI/Components/TextArea/TextArea.js +1 -1
  227. package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
  228. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +14 -2
  229. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  230. package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js +86 -0
  231. package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js.map +1 -0
  232. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js +119 -0
  233. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js.map +1 -0
  234. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js +206 -0
  235. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js.map +1 -0
  236. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js +57 -0
  237. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js.map +1 -0
  238. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js +150 -0
  239. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js.map +1 -0
  240. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js +67 -0
  241. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js.map +1 -0
  242. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js +309 -0
  243. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js.map +1 -0
  244. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js +204 -0
  245. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js.map +1 -0
  246. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +66 -0
  247. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -0
  248. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js +83 -0
  249. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js.map +1 -0
  250. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +187 -68
  251. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
  252. package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js +75 -0
  253. package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js.map +1 -0
  254. package/build/dist/Utils/API.js +175 -34
  255. package/build/dist/Utils/API.js.map +1 -1
  256. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  257. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  258. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  259. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  260. package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js +78 -0
  261. package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js.map +1 -0
  262. package/package.json +1 -1
@@ -0,0 +1,968 @@
1
+ /*
2
+ * Stored JSON -> rows -> stored JSON.
3
+ *
4
+ * This is where the risk of the whole change lives. The editor is mounted every
5
+ * time someone opens a step's settings, so a value that does not survive being
6
+ * read and written back is a workflow silently rewritten by being looked at.
7
+ * Most of what follows is about that: fixed points, and bytes that match what
8
+ * the previous editor produced.
9
+ */
10
+
11
+ import TableColumnType from "../../../../../Types/Database/TableColumnType";
12
+ import Dictionary from "../../../../../Types/Dictionary";
13
+ import { JSONObject } from "../../../../../Types/JSON";
14
+ import JSONFunctions from "../../../../../Types/JSONFunctions";
15
+ import GreaterThan from "../../../../../Types/BaseDatabase/GreaterThan";
16
+ import Includes from "../../../../../Types/BaseDatabase/Includes";
17
+ import IncludesNone from "../../../../../Types/BaseDatabase/IncludesNone";
18
+ import IsNull from "../../../../../Types/BaseDatabase/IsNull";
19
+ import LessThan from "../../../../../Types/BaseDatabase/LessThan";
20
+ import NotEqual from "../../../../../Types/BaseDatabase/NotEqual";
21
+ import NotNull from "../../../../../Types/BaseDatabase/NotNull";
22
+ import Search from "../../../../../Types/BaseDatabase/Search";
23
+ import {
24
+ DictionaryEntryValue,
25
+ DictionaryFilterOperator,
26
+ buildDictionaryValue,
27
+ } from "../../../../../UI/Components/Dictionary/DictionaryFilterOperator";
28
+ import {
29
+ ColumnValueMode,
30
+ ModelColumnControl,
31
+ ModelColumnRow,
32
+ makeColumnRow,
33
+ } from "../../../../../UI/Components/Workflow/ColumnEditor/ColumnRow";
34
+ import {
35
+ buildOperatorValue,
36
+ classifyRowValue,
37
+ containsTemplateExpression,
38
+ emitRowValue,
39
+ rowIssue,
40
+ rowsFromParsedValue,
41
+ seedRequiredRows,
42
+ serializeColumnRows,
43
+ } from "../../../../../UI/Components/Workflow/ColumnEditor/ColumnRowSerialization";
44
+ import {
45
+ ModelColumnEditorMode,
46
+ buildColumnValueJson,
47
+ normalizeInitialValue,
48
+ } from "../../../../../UI/Components/Workflow/ModelColumnEditor";
49
+ import { ModelSchemaColumn } from "../../../../../UI/Components/Workflow/ModelSchema";
50
+ import { describe, expect, test } from "@jest/globals";
51
+
52
+ type MakeColumnFunction = (
53
+ overrides: Partial<ModelSchemaColumn> & { id: string },
54
+ ) => ModelSchemaColumn;
55
+
56
+ const makeColumn: MakeColumnFunction = (
57
+ overrides: Partial<ModelSchemaColumn> & { id: string },
58
+ ): ModelSchemaColumn => {
59
+ return {
60
+ title: overrides.id,
61
+ type: TableColumnType.ShortText,
62
+ isRelation: false,
63
+ ...overrides,
64
+ };
65
+ };
66
+
67
+ const COLUMNS: Array<ModelSchemaColumn> = [
68
+ makeColumn({ id: "_id", type: TableColumnType.ObjectID }),
69
+ makeColumn({ id: "name", type: TableColumnType.Name, required: true }),
70
+ makeColumn({ id: "color", type: TableColumnType.Color, required: true }),
71
+ makeColumn({ id: "description", type: TableColumnType.LongText }),
72
+ makeColumn({ id: "port", type: TableColumnType.Port }),
73
+ makeColumn({ id: "isEnabled", type: TableColumnType.Boolean }),
74
+ makeColumn({ id: "createdAt", type: TableColumnType.Date }),
75
+ makeColumn({
76
+ id: "projectId",
77
+ type: TableColumnType.ObjectID,
78
+ isTenantColumn: true,
79
+ required: true,
80
+ }),
81
+ makeColumn({
82
+ id: "order",
83
+ type: TableColumnType.Number,
84
+ required: true,
85
+ hasDefault: true,
86
+ }),
87
+ ];
88
+
89
+ type ParseFunction = (
90
+ text: string,
91
+ mode: ModelColumnEditorMode,
92
+ ) => Array<ModelColumnRow>;
93
+
94
+ const parse: ParseFunction = (
95
+ text: string,
96
+ mode: ModelColumnEditorMode,
97
+ ): Array<ModelColumnRow> => {
98
+ return rowsFromParsedValue(normalizeInitialValue(text).parsed, COLUMNS, mode);
99
+ };
100
+
101
+ type RoundTripFunction = (text: string, mode: ModelColumnEditorMode) => string;
102
+
103
+ /** Read the stored value and write it straight back out, touching nothing. */
104
+ const roundTrip: RoundTripFunction = (
105
+ text: string,
106
+ mode: ModelColumnEditorMode,
107
+ ): string => {
108
+ return serializeColumnRows(parse(text, mode), COLUMNS, mode);
109
+ };
110
+
111
+ describe("containsTemplateExpression", () => {
112
+ test("uses the runtime's own matcher, so it agrees with what will be substituted", () => {
113
+ expect(containsTemplateExpression("{{local.variables.apiKey}}")).toBe(true);
114
+ expect(containsTemplateExpression("prefix {{a}} suffix")).toBe(true);
115
+ expect(containsTemplateExpression("nothing here")).toBe(false);
116
+ expect(containsTemplateExpression("{ not a reference }")).toBe(false);
117
+ });
118
+
119
+ test("is not confused by a previous call's match position", () => {
120
+ /*
121
+ * A global RegExp carries lastIndex between calls. Built fresh each time,
122
+ * so the same string answers the same way twice.
123
+ */
124
+ expect(containsTemplateExpression("{{a}}")).toBe(true);
125
+ expect(containsTemplateExpression("{{a}}")).toBe(true);
126
+ });
127
+ });
128
+
129
+ describe("classifyRowValue — how a stored value is edited", () => {
130
+ test("a reference is a reference on any column type", () => {
131
+ for (const control of Object.values(ModelColumnControl)) {
132
+ expect(classifyRowValue(control, "{{local.variables.x}}").valueMode).toBe(
133
+ ColumnValueMode.Reference,
134
+ );
135
+ }
136
+ });
137
+
138
+ test("a value the control can hold is edited normally", () => {
139
+ expect(classifyRowValue(ModelColumnControl.Number, 8080)).toEqual({
140
+ valueMode: ColumnValueMode.Literal,
141
+ text: "8080",
142
+ });
143
+ expect(classifyRowValue(ModelColumnControl.Boolean, false)).toEqual({
144
+ valueMode: ColumnValueMode.Literal,
145
+ text: "false",
146
+ });
147
+ });
148
+
149
+ test("a value whose type disagrees with the column is kept raw", () => {
150
+ expect(classifyRowValue(ModelColumnControl.Number, "8080")).toEqual({
151
+ valueMode: ColumnValueMode.Raw,
152
+ text: "8080",
153
+ rawValue: "8080",
154
+ });
155
+ });
156
+
157
+ test('a value a boolean column cannot hold keeps its own text, not "false"', () => {
158
+ /*
159
+ * Spelling every non-true value "false" put a stored "true" on screen as
160
+ * the word false - the opposite of what the workflow does - and the same
161
+ * function names the text of a raw row, where the value is by definition
162
+ * not a boolean.
163
+ */
164
+ expect(classifyRowValue(ModelColumnControl.Boolean, "true")).toEqual({
165
+ valueMode: ColumnValueMode.Raw,
166
+ text: "true",
167
+ rawValue: "true",
168
+ });
169
+ expect(classifyRowValue(ModelColumnControl.Boolean, "yes").text).toBe(
170
+ "yes",
171
+ );
172
+ expect(classifyRowValue(ModelColumnControl.Boolean, 1).text).toBe("1");
173
+ });
174
+
175
+ test("a real boolean still reads as true or false", () => {
176
+ expect(classifyRowValue(ModelColumnControl.Boolean, true).text).toBe(
177
+ "true",
178
+ );
179
+ expect(classifyRowValue(ModelColumnControl.Boolean, false).text).toBe(
180
+ "false",
181
+ );
182
+ });
183
+
184
+ test("null is kept raw, because an empty box would mean something else", () => {
185
+ expect(classifyRowValue(ModelColumnControl.Text, null)).toEqual({
186
+ valueMode: ColumnValueMode.Raw,
187
+ text: "",
188
+ rawValue: null,
189
+ });
190
+ });
191
+ });
192
+
193
+ describe("rowsFromParsedValue — record mode", () => {
194
+ test("keys keep the order they were written in", () => {
195
+ const rows: Array<ModelColumnRow> = parse(
196
+ '{"color":"#fff","name":"Acknowledged","_id":"1"}',
197
+ ModelColumnEditorMode.Record,
198
+ );
199
+
200
+ expect(
201
+ rows.map((row: ModelColumnRow) => {
202
+ return row.columnId;
203
+ }),
204
+ ).toEqual(["color", "name", "_id"]);
205
+ });
206
+
207
+ test("every record row is an equality — a record cannot save a comparison", () => {
208
+ const rows: Array<ModelColumnRow> = parse(
209
+ '{"name":"Acknowledged"}',
210
+ ModelColumnEditorMode.Record,
211
+ );
212
+
213
+ expect(rows[0]!.operator).toBe(DictionaryFilterOperator.EqualTo);
214
+ });
215
+
216
+ test("a column the model does not describe still gets a row", () => {
217
+ const rows: Array<ModelColumnRow> = parse(
218
+ '{"id":"1"}',
219
+ ModelColumnEditorMode.Record,
220
+ );
221
+
222
+ expect(rows).toHaveLength(1);
223
+ expect(rows[0]!.columnId).toBe("id");
224
+ });
225
+
226
+ test("nothing stored is no rows, not one blank one", () => {
227
+ expect(parse("", ModelColumnEditorMode.Record)).toEqual([]);
228
+ });
229
+ });
230
+
231
+ describe("rowsFromParsedValue — query mode", () => {
232
+ test("an operator wrapper is recovered as its operator", () => {
233
+ const rows: Array<ModelColumnRow> = parse(
234
+ JSON.stringify({ name: new Search<string>("acme") }),
235
+ ModelColumnEditorMode.Query,
236
+ );
237
+
238
+ expect(rows[0]!.operator).toBe(DictionaryFilterOperator.Contains);
239
+ expect(rows[0]!.text).toBe("acme");
240
+ });
241
+
242
+ test("a value-less operator keeps no value", () => {
243
+ const rows: Array<ModelColumnRow> = parse(
244
+ JSON.stringify({ name: new IsNull() }),
245
+ ModelColumnEditorMode.Query,
246
+ );
247
+
248
+ expect(rows[0]!.operator).toBe(DictionaryFilterOperator.IsEmpty);
249
+ expect(rows[0]!.text).toBe("");
250
+ });
251
+
252
+ test("a membership list is recovered whole", () => {
253
+ const rows: Array<ModelColumnRow> = parse(
254
+ JSON.stringify({ name: new Includes(["a", "b"]) }),
255
+ ModelColumnEditorMode.Query,
256
+ );
257
+
258
+ expect(rows[0]!.operator).toBe(DictionaryFilterOperator.IsAnyOf);
259
+ expect(rows[0]!.values).toEqual(["a", "b"]);
260
+ });
261
+
262
+ test("the scalar inside a wrapper keeps its own type", () => {
263
+ /*
264
+ * detectOperatorFromValue stringifies for display; a row has to see the
265
+ * number so it does not decide the value is a mismatched string.
266
+ */
267
+ const rows: Array<ModelColumnRow> = parse(
268
+ JSON.stringify({ port: new GreaterThan<number>(8080) }),
269
+ ModelColumnEditorMode.Query,
270
+ );
271
+
272
+ expect(rows[0]!.valueMode).toBe(ColumnValueMode.Literal);
273
+ expect(rows[0]!.text).toBe("8080");
274
+ });
275
+ });
276
+
277
+ describe("serializeColumnRows — the value each control emits", () => {
278
+ test("a number column emits a JSON number", () => {
279
+ expect(
280
+ serializeColumnRows(
281
+ [makeColumnRow({ columnId: "port", text: "8080" })],
282
+ COLUMNS,
283
+ ModelColumnEditorMode.Record,
284
+ ),
285
+ ).toBe('{"port":8080}');
286
+ });
287
+
288
+ test("a boolean column emits a JSON boolean", () => {
289
+ expect(
290
+ serializeColumnRows(
291
+ [makeColumnRow({ columnId: "isEnabled", text: "false" })],
292
+ COLUMNS,
293
+ ModelColumnEditorMode.Record,
294
+ ),
295
+ ).toBe('{"isEnabled":false}');
296
+ });
297
+
298
+ test("a date column emits its ISO string", () => {
299
+ expect(
300
+ serializeColumnRows(
301
+ [
302
+ makeColumnRow({
303
+ columnId: "createdAt",
304
+ text: "2026-08-15T09:30:00.000Z",
305
+ }),
306
+ ],
307
+ COLUMNS,
308
+ ModelColumnEditorMode.Record,
309
+ ),
310
+ ).toBe('{"createdAt":"2026-08-15T09:30:00.000Z"}');
311
+ });
312
+
313
+ test("a text column emits a string", () => {
314
+ expect(
315
+ serializeColumnRows(
316
+ [makeColumnRow({ columnId: "name", text: "Acknowledged" })],
317
+ COLUMNS,
318
+ ModelColumnEditorMode.Record,
319
+ ),
320
+ ).toBe('{"name":"Acknowledged"}');
321
+ });
322
+
323
+ test("text that is not a number is written as text, not dropped and not NaN", () => {
324
+ /*
325
+ * The row shows why it is wrong. Throwing the value away would take the
326
+ * builder's work with it, and NaN would serialize to null.
327
+ */
328
+ expect(
329
+ serializeColumnRows(
330
+ [makeColumnRow({ columnId: "port", text: "eighty eighty" })],
331
+ COLUMNS,
332
+ ModelColumnEditorMode.Record,
333
+ ),
334
+ ).toBe('{"port":"eighty eighty"}');
335
+ });
336
+
337
+ test("a reference is emitted quoted, whatever the column type", () => {
338
+ const reference: string = "{{local.variables.port}}";
339
+
340
+ expect(
341
+ serializeColumnRows(
342
+ [
343
+ makeColumnRow({
344
+ columnId: "port",
345
+ text: reference,
346
+ valueMode: ColumnValueMode.Reference,
347
+ }),
348
+ ],
349
+ COLUMNS,
350
+ ModelColumnEditorMode.Record,
351
+ ),
352
+ ).toBe(`{"port":"${reference}"}`);
353
+ });
354
+
355
+ test("a raw value is written back exactly as it was read", () => {
356
+ expect(
357
+ serializeColumnRows(
358
+ [
359
+ makeColumnRow({
360
+ columnId: "port",
361
+ text: "8080",
362
+ valueMode: ColumnValueMode.Raw,
363
+ rawValue: "8080",
364
+ }),
365
+ ],
366
+ COLUMNS,
367
+ ModelColumnEditorMode.Record,
368
+ ),
369
+ ).toBe('{"port":"8080"}');
370
+ });
371
+ });
372
+
373
+ describe("serializeColumnRows — what is kept and what is dropped", () => {
374
+ test("a record drops a field with nothing in it", () => {
375
+ expect(
376
+ serializeColumnRows(
377
+ [
378
+ makeColumnRow({ columnId: "name", text: "Acknowledged" }),
379
+ makeColumnRow({ columnId: "color", text: "" }),
380
+ ],
381
+ COLUMNS,
382
+ ModelColumnEditorMode.Record,
383
+ ),
384
+ ).toBe('{"name":"Acknowledged"}');
385
+ });
386
+
387
+ test("a record keeps false, zero and null — they are values, not blanks", () => {
388
+ const stored: string = serializeColumnRows(
389
+ [
390
+ makeColumnRow({ columnId: "isEnabled", text: "false" }),
391
+ makeColumnRow({ columnId: "port", text: "0" }),
392
+ makeColumnRow({
393
+ columnId: "name",
394
+ valueMode: ColumnValueMode.Raw,
395
+ rawValue: null,
396
+ }),
397
+ ],
398
+ COLUMNS,
399
+ ModelColumnEditorMode.Record,
400
+ );
401
+
402
+ expect(JSON.parse(stored)).toEqual({
403
+ isEnabled: false,
404
+ port: 0,
405
+ name: null,
406
+ });
407
+ });
408
+
409
+ test('a query keeps an empty condition — matching against "" is a real filter', () => {
410
+ expect(
411
+ serializeColumnRows(
412
+ [makeColumnRow({ columnId: "name", text: "" })],
413
+ COLUMNS,
414
+ ModelColumnEditorMode.Query,
415
+ ),
416
+ ).toBe('{"name":""}');
417
+ });
418
+
419
+ test('a row with no column chosen is dropped rather than written as ""', () => {
420
+ expect(
421
+ serializeColumnRows(
422
+ [
423
+ makeColumnRow({ columnId: "", text: "orphan" }),
424
+ makeColumnRow({ columnId: "name", text: "Acknowledged" }),
425
+ ],
426
+ COLUMNS,
427
+ ModelColumnEditorMode.Record,
428
+ ),
429
+ ).toBe('{"name":"Acknowledged"}');
430
+ });
431
+
432
+ test("nothing at all collapses to the empty string, not to {}", () => {
433
+ /*
434
+ * Form validation and the graph linter both read "" as "this required
435
+ * argument has not been filled in". "{}" would read as filled.
436
+ */
437
+ expect(serializeColumnRows([], COLUMNS, ModelColumnEditorMode.Record)).toBe(
438
+ "",
439
+ );
440
+ expect(
441
+ serializeColumnRows(
442
+ [makeColumnRow({ columnId: "name", text: "" })],
443
+ COLUMNS,
444
+ ModelColumnEditorMode.Record,
445
+ ),
446
+ ).toBe("");
447
+ });
448
+ });
449
+
450
+ describe("serializeColumnRows — byte-for-byte with the editor it replaces", () => {
451
+ type CaseType = {
452
+ operator: DictionaryFilterOperator;
453
+ row: Partial<ModelColumnRow>;
454
+ entry: DictionaryEntryValue;
455
+ };
456
+
457
+ /*
458
+ * Each case is the same condition expressed twice: as a row, and as the
459
+ * dictionary the previous editor would have handed buildColumnValueJson. The
460
+ * two have to produce identical bytes, because the wire format is what the
461
+ * server's deserializer is written against.
462
+ */
463
+ const cases: Array<CaseType> = [
464
+ {
465
+ operator: DictionaryFilterOperator.EqualTo,
466
+ row: { text: "acme" },
467
+ entry: "acme",
468
+ },
469
+ {
470
+ operator: DictionaryFilterOperator.NotEqual,
471
+ row: { text: "acme" },
472
+ entry: buildDictionaryValue({
473
+ operator: DictionaryFilterOperator.NotEqual,
474
+ rawValue: "acme",
475
+ }),
476
+ },
477
+ {
478
+ operator: DictionaryFilterOperator.Contains,
479
+ row: { text: "acme" },
480
+ entry: buildDictionaryValue({
481
+ operator: DictionaryFilterOperator.Contains,
482
+ rawValue: "acme",
483
+ }),
484
+ },
485
+ {
486
+ operator: DictionaryFilterOperator.NotContains,
487
+ row: { text: "acme" },
488
+ entry: buildDictionaryValue({
489
+ operator: DictionaryFilterOperator.NotContains,
490
+ rawValue: "acme",
491
+ }),
492
+ },
493
+ {
494
+ operator: DictionaryFilterOperator.StartsWith,
495
+ row: { text: "ac" },
496
+ entry: buildDictionaryValue({
497
+ operator: DictionaryFilterOperator.StartsWith,
498
+ rawValue: "ac",
499
+ }),
500
+ },
501
+ {
502
+ operator: DictionaryFilterOperator.EndsWith,
503
+ row: { text: "me" },
504
+ entry: buildDictionaryValue({
505
+ operator: DictionaryFilterOperator.EndsWith,
506
+ rawValue: "me",
507
+ }),
508
+ },
509
+ {
510
+ operator: DictionaryFilterOperator.IsEmpty,
511
+ row: {},
512
+ entry: buildDictionaryValue({
513
+ operator: DictionaryFilterOperator.IsEmpty,
514
+ rawValue: "",
515
+ }),
516
+ },
517
+ {
518
+ operator: DictionaryFilterOperator.IsNotEmpty,
519
+ row: {},
520
+ entry: buildDictionaryValue({
521
+ operator: DictionaryFilterOperator.IsNotEmpty,
522
+ rawValue: "",
523
+ }),
524
+ },
525
+ {
526
+ operator: DictionaryFilterOperator.IsAnyOf,
527
+ row: { values: ["a", "b"] },
528
+ entry: buildDictionaryValue({
529
+ operator: DictionaryFilterOperator.IsAnyOf,
530
+ rawValue: "",
531
+ rawValues: ["a", "b"],
532
+ }),
533
+ },
534
+ {
535
+ operator: DictionaryFilterOperator.IsNoneOf,
536
+ row: { values: ["a", "b"] },
537
+ entry: buildDictionaryValue({
538
+ operator: DictionaryFilterOperator.IsNoneOf,
539
+ rawValue: "",
540
+ rawValues: ["a", "b"],
541
+ }),
542
+ },
543
+ ];
544
+
545
+ test.each(cases)(
546
+ "$operator on a text column emits what the dictionary form emitted",
547
+ (testCase: CaseType) => {
548
+ const fromRows: string = serializeColumnRows(
549
+ [
550
+ makeColumnRow({
551
+ columnId: "name",
552
+ operator: testCase.operator,
553
+ ...testCase.row,
554
+ }),
555
+ ],
556
+ COLUMNS,
557
+ ModelColumnEditorMode.Query,
558
+ );
559
+
560
+ const fromDictionary: string = buildColumnValueJson(
561
+ { name: testCase.entry } as Dictionary<DictionaryEntryValue>,
562
+ ModelColumnEditorMode.Query,
563
+ );
564
+
565
+ expect(fromRows).toBe(fromDictionary);
566
+ },
567
+ );
568
+
569
+ test("the numeric comparisons emit what the dictionary form emitted", () => {
570
+ const numericOperators: Array<DictionaryFilterOperator> = [
571
+ DictionaryFilterOperator.GreaterThan,
572
+ DictionaryFilterOperator.GreaterThanOrEqual,
573
+ DictionaryFilterOperator.LessThan,
574
+ DictionaryFilterOperator.LessThanOrEqual,
575
+ ];
576
+
577
+ for (const operator of numericOperators) {
578
+ const fromRows: string = serializeColumnRows(
579
+ [makeColumnRow({ columnId: "port", operator: operator, text: "80" })],
580
+ COLUMNS,
581
+ ModelColumnEditorMode.Query,
582
+ );
583
+
584
+ const fromDictionary: string = buildColumnValueJson(
585
+ {
586
+ port: buildDictionaryValue({ operator: operator, rawValue: "80" }),
587
+ } as Dictionary<DictionaryEntryValue>,
588
+ ModelColumnEditorMode.Query,
589
+ );
590
+
591
+ expect(fromRows).toBe(fromDictionary);
592
+ }
593
+ });
594
+ });
595
+
596
+ describe("serializeColumnRows — the comparison that used to become NaN", () => {
597
+ test('a reference under "is after" keeps the reference', () => {
598
+ /*
599
+ * buildDictionaryValue does Number(text) for the ordering operators, so a
600
+ * reference became NaN and JSON.stringify wrote it as null - a query
601
+ * against NULL that matches nothing and reports no error at all.
602
+ */
603
+ const stored: string = serializeColumnRows(
604
+ [
605
+ makeColumnRow({
606
+ columnId: "createdAt",
607
+ operator: DictionaryFilterOperator.GreaterThan,
608
+ valueMode: ColumnValueMode.Reference,
609
+ text: "{{local.variables.since}}",
610
+ }),
611
+ ],
612
+ COLUMNS,
613
+ ModelColumnEditorMode.Query,
614
+ );
615
+
616
+ expect(JSON.parse(stored)).toEqual({
617
+ createdAt: {
618
+ _type: "GreaterThan",
619
+ value: "{{local.variables.since}}",
620
+ },
621
+ });
622
+ });
623
+
624
+ test('a date under "is before" keeps the date', () => {
625
+ const stored: string = serializeColumnRows(
626
+ [
627
+ makeColumnRow({
628
+ columnId: "createdAt",
629
+ operator: DictionaryFilterOperator.LessThan,
630
+ text: "2026-08-15T09:30:00.000Z",
631
+ }),
632
+ ],
633
+ COLUMNS,
634
+ ModelColumnEditorMode.Query,
635
+ );
636
+
637
+ expect(JSON.parse(stored)).toEqual({
638
+ createdAt: {
639
+ _type: "LessThan",
640
+ value: "2026-08-15T09:30:00.000Z",
641
+ },
642
+ });
643
+ });
644
+
645
+ test("a real number is still emitted as a number", () => {
646
+ const stored: string = serializeColumnRows(
647
+ [
648
+ makeColumnRow({
649
+ columnId: "port",
650
+ operator: DictionaryFilterOperator.GreaterThan,
651
+ text: "8080",
652
+ }),
653
+ ],
654
+ COLUMNS,
655
+ ModelColumnEditorMode.Query,
656
+ );
657
+
658
+ expect(JSON.parse(stored)).toEqual({
659
+ port: { _type: "GreaterThan", value: 8080 },
660
+ });
661
+ });
662
+ });
663
+
664
+ describe("what the editor writes, the server can read", () => {
665
+ type DeserializeFunction = (stored: string) => JSONObject;
666
+
667
+ /*
668
+ * The path a real run takes: the runner JSON.parses the stored string and the
669
+ * database component hands the result to JSONFunctions.deserialize.
670
+ */
671
+ const deserialize: DeserializeFunction = (stored: string): JSONObject => {
672
+ return JSONFunctions.deserialize(JSON.parse(stored)) as JSONObject;
673
+ };
674
+
675
+ test("each operator comes back as its own query helper", () => {
676
+ const stored: string = serializeColumnRows(
677
+ [
678
+ makeColumnRow({
679
+ columnId: "name",
680
+ operator: DictionaryFilterOperator.NotEqual,
681
+ text: "acme",
682
+ }),
683
+ makeColumnRow({
684
+ columnId: "description",
685
+ operator: DictionaryFilterOperator.Contains,
686
+ text: "down",
687
+ }),
688
+ makeColumnRow({
689
+ columnId: "port",
690
+ operator: DictionaryFilterOperator.LessThan,
691
+ text: "9000",
692
+ }),
693
+ makeColumnRow({
694
+ columnId: "createdAt",
695
+ operator: DictionaryFilterOperator.IsEmpty,
696
+ }),
697
+ makeColumnRow({
698
+ columnId: "_id",
699
+ operator: DictionaryFilterOperator.IsNotEmpty,
700
+ }),
701
+ makeColumnRow({
702
+ columnId: "color",
703
+ operator: DictionaryFilterOperator.IsAnyOf,
704
+ values: ["#fff", "#000"],
705
+ }),
706
+ ],
707
+ COLUMNS,
708
+ ModelColumnEditorMode.Query,
709
+ );
710
+
711
+ const result: JSONObject = deserialize(stored);
712
+
713
+ expect(result["name"]).toBeInstanceOf(NotEqual);
714
+ expect((result["name"] as NotEqual<string>).value).toBe("acme");
715
+ expect(result["description"]).toBeInstanceOf(Search);
716
+ expect(result["port"]).toBeInstanceOf(LessThan);
717
+ expect((result["port"] as LessThan<number>).value).toBe(9000);
718
+ expect(result["createdAt"]).toBeInstanceOf(IsNull);
719
+ expect(result["_id"]).toBeInstanceOf(NotNull);
720
+ expect(result["color"]).toBeInstanceOf(Includes);
721
+ expect((result["color"] as Includes).values).toEqual(["#fff", "#000"]);
722
+ });
723
+
724
+ test("an exclusion list comes back as IncludesNone", () => {
725
+ const stored: string = serializeColumnRows(
726
+ [
727
+ makeColumnRow({
728
+ columnId: "name",
729
+ operator: DictionaryFilterOperator.IsNoneOf,
730
+ values: ["a"],
731
+ }),
732
+ ],
733
+ COLUMNS,
734
+ ModelColumnEditorMode.Query,
735
+ );
736
+
737
+ expect(deserialize(stored)["name"]).toBeInstanceOf(IncludesNone);
738
+ });
739
+
740
+ test("a record of typed values deserializes to plain values", () => {
741
+ const stored: string = serializeColumnRows(
742
+ [
743
+ makeColumnRow({ columnId: "name", text: "Acknowledged" }),
744
+ makeColumnRow({ columnId: "port", text: "8080" }),
745
+ makeColumnRow({ columnId: "isEnabled", text: "true" }),
746
+ ],
747
+ COLUMNS,
748
+ ModelColumnEditorMode.Record,
749
+ );
750
+
751
+ expect(deserialize(stored)).toEqual({
752
+ name: "Acknowledged",
753
+ port: 8080,
754
+ isEnabled: true,
755
+ });
756
+ });
757
+ });
758
+
759
+ describe("reading a stored value and writing it straight back changes nothing", () => {
760
+ const fixtures: Array<{ text: string; mode: ModelColumnEditorMode }> = [
761
+ { text: '{"name":"acme"}', mode: ModelColumnEditorMode.Record },
762
+ { text: '{"port":8080}', mode: ModelColumnEditorMode.Record },
763
+ { text: '{"port":"8080"}', mode: ModelColumnEditorMode.Record },
764
+ { text: '{"isEnabled":false}', mode: ModelColumnEditorMode.Record },
765
+ { text: '{"name":null}', mode: ModelColumnEditorMode.Record },
766
+ {
767
+ text: '{"createdAt":"2026-08-15T09:30:00.000Z"}',
768
+ mode: ModelColumnEditorMode.Record,
769
+ },
770
+ {
771
+ text: '{"name":"{{local.variables.name}}"}',
772
+ mode: ModelColumnEditorMode.Record,
773
+ },
774
+ { text: '{"id":"legacy"}', mode: ModelColumnEditorMode.Record },
775
+ {
776
+ text: '{"color":"#ffffff","name":"a"}',
777
+ mode: ModelColumnEditorMode.Record,
778
+ },
779
+ { text: '{"name":""}', mode: ModelColumnEditorMode.Query },
780
+ {
781
+ text: JSON.stringify({ name: new Search<string>("acme") }),
782
+ mode: ModelColumnEditorMode.Query,
783
+ },
784
+ {
785
+ text: JSON.stringify({ port: new GreaterThan<number>(8080) }),
786
+ mode: ModelColumnEditorMode.Query,
787
+ },
788
+ {
789
+ text: JSON.stringify({ createdAt: new IsNull() }),
790
+ mode: ModelColumnEditorMode.Query,
791
+ },
792
+ {
793
+ text: JSON.stringify({ color: new Includes(["#fff", "#000"]) }),
794
+ mode: ModelColumnEditorMode.Query,
795
+ },
796
+ {
797
+ text: JSON.stringify({ name: new IncludesNone(["a"]) }),
798
+ mode: ModelColumnEditorMode.Query,
799
+ },
800
+ ];
801
+
802
+ test.each(fixtures)(
803
+ "$text survives being opened and written back",
804
+ (fixture: { text: string; mode: ModelColumnEditorMode }) => {
805
+ expect(roundTrip(fixture.text, fixture.mode)).toBe(fixture.text);
806
+ },
807
+ );
808
+
809
+ test("and survives it a second time, so there is no slow drift", () => {
810
+ for (const fixture of fixtures) {
811
+ const once: string = roundTrip(fixture.text, fixture.mode);
812
+ expect(roundTrip(once, fixture.mode)).toBe(once);
813
+ }
814
+ });
815
+
816
+ test("key order is preserved rather than sorted into the picker's order", () => {
817
+ const text: string = '{"port":1,"_id":"x","name":"a"}';
818
+
819
+ expect(roundTrip(text, ModelColumnEditorMode.Record)).toBe(text);
820
+ });
821
+ });
822
+
823
+ describe("seedRequiredRows", () => {
824
+ test("adds a blank row for each column a create must be given", () => {
825
+ const rows: Array<ModelColumnRow> = seedRequiredRows([], COLUMNS);
826
+
827
+ expect(
828
+ rows.map((row: ModelColumnRow) => {
829
+ return row.columnId;
830
+ }),
831
+ ).toEqual(["name", "color"]);
832
+ });
833
+
834
+ test("leaves out anything with a default, and the column the runner stamps", () => {
835
+ const seededIds: Array<string> = seedRequiredRows([], COLUMNS).map(
836
+ (row: ModelColumnRow) => {
837
+ return row.columnId;
838
+ },
839
+ );
840
+
841
+ // `order` is required but carries a default; `projectId` is the tenant column.
842
+ expect(seededIds).not.toContain("order");
843
+ expect(seededIds).not.toContain("projectId");
844
+ });
845
+
846
+ test("appends after what was stored, so stored key order is untouched", () => {
847
+ const stored: Array<ModelColumnRow> = [
848
+ makeColumnRow({ columnId: "color", text: "#fff" }),
849
+ ];
850
+
851
+ expect(
852
+ seedRequiredRows(stored, COLUMNS).map((row: ModelColumnRow) => {
853
+ return row.columnId;
854
+ }),
855
+ ).toEqual(["color", "name"]);
856
+ });
857
+
858
+ test("never overwrites a value that is already there", () => {
859
+ const stored: Array<ModelColumnRow> = [
860
+ makeColumnRow({ columnId: "name", text: "Acknowledged" }),
861
+ ];
862
+
863
+ expect(seedRequiredRows(stored, COLUMNS)[0]!.text).toBe("Acknowledged");
864
+ });
865
+
866
+ test("seeded rows serialize away, so an untouched create still reads as empty", () => {
867
+ /*
868
+ * This is what stops a create that has only been looked at from passing the
869
+ * form's required check and the graph linter's.
870
+ */
871
+ expect(
872
+ serializeColumnRows(
873
+ seedRequiredRows([], COLUMNS),
874
+ COLUMNS,
875
+ ModelColumnEditorMode.Record,
876
+ ),
877
+ ).toBe("");
878
+ });
879
+ });
880
+
881
+ describe("emitRowValue and buildOperatorValue in isolation", () => {
882
+ test("an empty literal is the marker a record drops", () => {
883
+ expect(
884
+ emitRowValue(
885
+ makeColumnRow({ columnId: "name", text: "" }),
886
+ ModelColumnControl.Text,
887
+ ),
888
+ ).toBe("");
889
+ });
890
+
891
+ test("a value-less operator carries no value at all", () => {
892
+ const built: DictionaryEntryValue = buildOperatorValue(
893
+ makeColumnRow({
894
+ columnId: "name",
895
+ operator: DictionaryFilterOperator.IsEmpty,
896
+ text: "ignored",
897
+ }),
898
+ ModelColumnControl.Text,
899
+ );
900
+
901
+ expect(built).toBeInstanceOf(IsNull);
902
+ });
903
+
904
+ test("equality carries no wrapper, which is what every old query looks like", () => {
905
+ expect(
906
+ buildOperatorValue(
907
+ makeColumnRow({ columnId: "name", text: "acme" }),
908
+ ModelColumnControl.Text,
909
+ ),
910
+ ).toBe("acme");
911
+ });
912
+ });
913
+
914
+ describe("rowIssue", () => {
915
+ test("says so when a number column has been given words", () => {
916
+ expect(
917
+ rowIssue(makeColumnRow({ columnId: "port", text: "eighty" }), COLUMNS[4]),
918
+ ).toBe("This column takes a number.");
919
+ });
920
+
921
+ test("says nothing when the number is a number", () => {
922
+ expect(
923
+ rowIssue(makeColumnRow({ columnId: "port", text: "8080" }), COLUMNS[4]),
924
+ ).toBeNull();
925
+ });
926
+
927
+ test("says so when a date column has been given something unparseable", () => {
928
+ expect(
929
+ rowIssue(
930
+ makeColumnRow({ columnId: "createdAt", text: "yesterday" }),
931
+ COLUMNS[6],
932
+ ),
933
+ ).toBe("This column takes a date and time.");
934
+ });
935
+
936
+ test("a reference is never an issue, on any column", () => {
937
+ expect(
938
+ rowIssue(
939
+ makeColumnRow({
940
+ columnId: "port",
941
+ text: "{{local.variables.port}}",
942
+ valueMode: ColumnValueMode.Reference,
943
+ }),
944
+ COLUMNS[4],
945
+ ),
946
+ ).toBeNull();
947
+ });
948
+
949
+ test("an untouched row is never an issue", () => {
950
+ expect(
951
+ rowIssue(makeColumnRow({ columnId: "port", text: "" }), COLUMNS[4]),
952
+ ).toBeNull();
953
+ });
954
+
955
+ test("a raw value is not reported as an issue — it is reported as raw", () => {
956
+ expect(
957
+ rowIssue(
958
+ makeColumnRow({
959
+ columnId: "port",
960
+ text: "8080",
961
+ valueMode: ColumnValueMode.Raw,
962
+ rawValue: "8080",
963
+ }),
964
+ COLUMNS[4],
965
+ ),
966
+ ).toBeNull();
967
+ });
968
+ });