ga4-export-fixer 0.7.1 → 0.8.0-dev.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.
@@ -1,209 +1,234 @@
1
- const { isDataformTableReferenceObject } = require('../../utils.js');
2
- const { validateBaseConfig } = require('../../inputValidation.js');
3
-
4
- /**
5
- * Validates a GA4 export fixer configuration object.
6
- * Validation is performed on mergedConfig (default values merged with user input).
7
- * All fields are required in the merged config; optional fields are only optional for user input
8
- * and receive their values from the default configuration during merge.
9
- *
10
- * @param {Object} config - The merged configuration object to validate.
11
- * @throws {Error} If any configuration value is invalid or missing.
12
- */
13
- const validateEnhancedEventsConfig = (config, options = {}) => {
14
- try {
15
- if (!config || typeof config !== 'object' || Array.isArray(config)) {
16
- throw new Error(`config must be a non-null object. Received: ${JSON.stringify(config)}`);
17
- }
18
-
19
- // base config fields (self, incremental, test, testConfig, preOperations)
20
- validateBaseConfig(config, options);
21
-
22
- /*
23
- Rest of the validations are related to ga4_events_enhanced table specific fields
24
- */
25
-
26
- // sourceTable - required; string or Dataform table reference
27
- if (config.sourceTable === undefined || config.sourceTable === null) {
28
- throw new Error("config.sourceTable is required. Provide a Dataform table reference (using the ref() function) or a string in format '`project.dataset.table`'.");
29
- }
30
- if (isDataformTableReferenceObject(config.sourceTable)) {
31
- // Valid Dataform reference
32
- } else if (typeof config.sourceTable === 'string') {
33
- if (!config.sourceTable.trim()) {
34
- throw new Error("config.sourceTable must be a non-empty string. Received empty string.");
35
- }
36
- if (!/^`[^\.]+\.[^\.]+\.[^\.]+`$/.test(config.sourceTable.trim())) {
37
- throw new Error(`config.sourceTable must be in the format '\`project.dataset.table\`' (with backticks). Received: ${JSON.stringify(config.sourceTable)}`);
38
- }
39
- } else {
40
- throw new Error(`config.sourceTable must be a Dataform table reference object or a string in format '\`project.dataset.table\`'. Received: ${JSON.stringify(config.sourceTable)}`);
41
- }
42
-
43
- // schemaLock - optional; must be undefined or a GA4 export table suffix: "YYYYMMDD", "intraday_YYYYMMDD", or "fresh_YYYYMMDD"
44
- if (typeof config.schemaLock !== 'undefined') {
45
- if (typeof config.schemaLock !== 'string' || !/^(?:(?:intraday|fresh)_)?\d{8}$/.test(config.schemaLock)) {
46
- throw new Error(`config.schemaLock must be a string in "YYYYMMDD", "intraday_YYYYMMDD", or "fresh_YYYYMMDD" format (e.g., "20260101", "intraday_20260101"). Received: ${JSON.stringify(config.schemaLock)}`);
47
- }
48
- // Must be a valid date (extract date portion from the end)
49
- const datePart = config.schemaLock.slice(-8);
50
- const year = parseInt(datePart.slice(0, 4), 10);
51
- const month = parseInt(datePart.slice(4, 6), 10);
52
- const day = parseInt(datePart.slice(6, 8), 10);
53
- const date = new Date(year, month - 1, day);
54
- if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
55
- throw new Error(`config.schemaLock must contain a valid date. Received: ${JSON.stringify(config.schemaLock)}`);
56
- }
57
- // Must be at least 20241009
58
- if (datePart < "20241009") {
59
- throw new Error(`config.schemaLock date must be equal to or greater than "20241009". Received: ${JSON.stringify(config.schemaLock)}`);
60
- }
61
- }
62
-
63
- // includedExportTypes - required
64
- if (typeof config.includedExportTypes === 'undefined') {
65
- throw new Error("config.includedExportTypes is required.");
66
- }
67
- if (!config.includedExportTypes || typeof config.includedExportTypes !== 'object' || Array.isArray(config.includedExportTypes)) {
68
- throw new Error(`config.includedExportTypes must be an object. Received: ${JSON.stringify(config.includedExportTypes)}`);
69
- }
70
- for (const key of ['daily', 'fresh', 'intraday']) {
71
- if (!(key in config.includedExportTypes)) {
72
- throw new Error(`config.includedExportTypes.${key} is required.`);
73
- }
74
- if (typeof config.includedExportTypes[key] !== 'boolean') {
75
- throw new Error(`config.includedExportTypes.${key} must be a boolean. Received: ${JSON.stringify(config.includedExportTypes[key])}`);
76
- }
77
- }
78
- if (!config.includedExportTypes.daily && !config.includedExportTypes.fresh && !config.includedExportTypes.intraday) {
79
- throw new Error("At least one of config.includedExportTypes.daily, config.includedExportTypes.fresh, or config.includedExportTypes.intraday must be true.");
80
- }
81
-
82
- // timezone - required
83
- if (typeof config.timezone === 'undefined') {
84
- throw new Error("config.timezone is required.");
85
- }
86
- if (typeof config.timezone !== 'string' || !config.timezone.trim()) {
87
- throw new Error(`config.timezone must be a non-empty string (e.g. 'Etc/UTC', 'Europe/Helsinki'). Received: ${JSON.stringify(config.timezone)}`);
88
- }
89
-
90
- // customTimestampParam - optional; must be undefined or a non-empty string
91
- if (typeof config.customTimestampParam !== 'undefined') {
92
- if (typeof config.customTimestampParam !== 'string' || !config.customTimestampParam.trim()) {
93
- throw new Error(`config.customTimestampParam must be a non-empty string when provided. Received: ${JSON.stringify(config.customTimestampParam)}`);
94
- }
95
- }
96
-
97
- // dataIsFinal - required
98
- if (typeof config.dataIsFinal === 'undefined') {
99
- throw new Error("config.dataIsFinal is required.");
100
- }
101
- if (typeof config.dataIsFinal !== 'object' || Array.isArray(config.dataIsFinal)) {
102
- throw new Error(`config.dataIsFinal must be an object. Received: ${JSON.stringify(config.dataIsFinal)}`);
103
- }
104
- if (typeof config.dataIsFinal.detectionMethod === 'undefined') {
105
- throw new Error("config.dataIsFinal.detectionMethod is required.");
106
- }
107
- if (typeof config.dataIsFinal.detectionMethod !== 'string' || (config.dataIsFinal.detectionMethod !== 'EXPORT_TYPE' && config.dataIsFinal.detectionMethod !== 'DAY_THRESHOLD')) {
108
- throw new Error(`config.dataIsFinal.detectionMethod must be 'EXPORT_TYPE' or 'DAY_THRESHOLD'. Received: ${JSON.stringify(config.dataIsFinal.detectionMethod)}`);
109
- }
110
- if (
111
- config.dataIsFinal.detectionMethod === 'DAY_THRESHOLD' &&
112
- typeof config.dataIsFinal.dayThreshold === 'undefined'
113
- ) {
114
- throw new Error("config.dataIsFinal.dayThreshold is required when detectionMethod is 'DAY_THRESHOLD'.");
115
- }
116
- if (
117
- config.dataIsFinal.detectionMethod === 'DAY_THRESHOLD' &&
118
- (typeof config.dataIsFinal.dayThreshold !== 'number' || !Number.isInteger(config.dataIsFinal.dayThreshold) || config.dataIsFinal.dayThreshold < 0)
119
- ) {
120
- throw new Error(`config.dataIsFinal.dayThreshold must be a non-negative integer. Received: ${JSON.stringify(config.dataIsFinal.dayThreshold)}`);
121
- }
122
- // EXPORT_TYPE detection relies on daily export tables to mark data as final.
123
- // When daily is not enabled, all data would be marked as not final under EXPORT_TYPE,
124
- // so DAY_THRESHOLD must be used instead.
125
- if (
126
- !config.includedExportTypes.daily &&
127
- config.dataIsFinal.detectionMethod !== 'DAY_THRESHOLD'
128
- ) {
129
- throw new Error(`config.dataIsFinal.detectionMethod must be 'DAY_THRESHOLD' when daily export is not enabled (config.includedExportTypes.daily is false). A dayThreshold of 1 is recommended for intraday only setups. With fresh export, the GA4 data is subject to possible changes for up to 72 hours. Received: ${JSON.stringify(config.dataIsFinal.detectionMethod)}`);
130
- }
131
-
132
- // itemListAttribution - optional; must be undefined or a valid config object
133
- if (typeof config.itemListAttribution !== 'undefined') {
134
- if (!config.itemListAttribution || typeof config.itemListAttribution !== 'object' || Array.isArray(config.itemListAttribution)) {
135
- throw new Error(`config.itemListAttribution must be an object when provided. Received: ${JSON.stringify(config.itemListAttribution)}`);
136
- }
137
- if (typeof config.itemListAttribution.lookbackType === 'undefined') {
138
- throw new Error("config.itemListAttribution.lookbackType is required. Must be 'SESSION' or 'TIME'.");
139
- }
140
- if (config.itemListAttribution.lookbackType !== 'SESSION' && config.itemListAttribution.lookbackType !== 'TIME') {
141
- throw new Error(`config.itemListAttribution.lookbackType must be 'SESSION' or 'TIME'. Received: ${JSON.stringify(config.itemListAttribution.lookbackType)}`);
142
- }
143
- if (config.itemListAttribution.lookbackType === 'TIME') {
144
- if (typeof config.itemListAttribution.lookbackTimeMs === 'undefined') {
145
- throw new Error("config.itemListAttribution.lookbackTimeMs is required when lookbackType is 'TIME'.");
146
- }
147
- }
148
- if (typeof config.itemListAttribution.lookbackTimeMs !== 'undefined') {
149
- if (typeof config.itemListAttribution.lookbackTimeMs !== 'number' || !Number.isInteger(config.itemListAttribution.lookbackTimeMs) || config.itemListAttribution.lookbackTimeMs <= 0) {
150
- throw new Error(`config.itemListAttribution.lookbackTimeMs must be a positive integer. Received: ${JSON.stringify(config.itemListAttribution.lookbackTimeMs)}`);
151
- }
152
- }
153
- }
154
-
155
- // bufferDays - required
156
- if (typeof config.bufferDays !== 'number' || !Number.isInteger(config.bufferDays) || config.bufferDays < 0) {
157
- throw new Error(`config.bufferDays must be a non-negative integer. Received: ${JSON.stringify(config.bufferDays)}`);
158
- }
159
-
160
- // Array fields - all required
161
- const stringArrayKeys = ['defaultExcludedEventParams', 'excludedEventParams', 'sessionParams', 'defaultExcludedEvents', 'excludedEvents', 'excludedColumns'];
162
- for (const key of stringArrayKeys) {
163
- if (config[key] === undefined) {
164
- throw new Error(`config.${key} is required.`);
165
- }
166
- if (!Array.isArray(config[key])) {
167
- throw new Error(`config.${key} must be an array. Received: ${JSON.stringify(config[key])}`);
168
- }
169
- for (let i = 0; i < config[key].length; i++) {
170
- if (typeof config[key][i] !== 'string' || !config[key][i].trim()) {
171
- throw new Error(`config.${key}[${i}] must be a non-empty string. Received: ${JSON.stringify(config[key][i])}`);
172
- }
173
- }
174
- }
175
-
176
- // eventParamsToColumns - required
177
- if (config.eventParamsToColumns === undefined) {
178
- throw new Error("config.eventParamsToColumns is required.");
179
- }
180
- if (!Array.isArray(config.eventParamsToColumns)) {
181
- throw new Error(`config.eventParamsToColumns must be an array. Received: ${JSON.stringify(config.eventParamsToColumns)}`);
182
- }
183
- const validEventParamTypes = ['string', 'int', 'int64', 'double', 'float', 'float64'];
184
- for (let i = 0; i < config.eventParamsToColumns.length; i++) {
185
- const item = config.eventParamsToColumns[i];
186
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
187
- throw new Error(`config.eventParamsToColumns[${i}] must be an object with 'name' and 'type' properties. Received: ${JSON.stringify(item)}`);
188
- }
189
- if (!item.name || typeof item.name !== 'string' || !item.name.trim()) {
190
- throw new Error(`config.eventParamsToColumns[${i}].name must be a non-empty string. Received: ${JSON.stringify(item.name)}`);
191
- }
192
- if (item.type !== undefined && item.type !== null) {
193
- if (!validEventParamTypes.includes(item.type)) {
194
- throw new Error(`config.eventParamsToColumns[${i}].type must be one of: ${validEventParamTypes.join(', ')}. Received: ${JSON.stringify(item.type)}`);
195
- }
196
- }
197
- if (item.columnName !== undefined && item.columnName !== null && item.columnName !== '') {
198
- if (typeof item.columnName !== 'string' || !item.columnName.trim()) {
199
- throw new Error(`config.eventParamsToColumns[${i}].columnName must be a non-empty string when provided. Received: ${JSON.stringify(item.columnName)}`);
200
- }
201
- }
202
- }
203
- } catch (e) {
204
- e.message = `Config validation: ${e.message}`;
205
- throw e;
206
- }
207
- };
208
-
209
- module.exports = { validateEnhancedEventsConfig };
1
+ const { isDataformTableReferenceObject } = require('../../utils.js');
2
+ const { validateBaseConfig } = require('../../inputValidation.js');
3
+
4
+ /**
5
+ * Validates a GA4 export fixer configuration object.
6
+ * Validation is performed on mergedConfig (default values merged with user input).
7
+ * All fields are required in the merged config; optional fields are only optional for user input
8
+ * and receive their values from the default configuration during merge.
9
+ *
10
+ * @param {Object} config - The merged configuration object to validate.
11
+ * @throws {Error} If any configuration value is invalid or missing.
12
+ */
13
+ const validateEnhancedEventsConfig = (config, options = {}) => {
14
+ try {
15
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
16
+ throw new Error(`config must be a non-null object. Received: ${JSON.stringify(config)}`);
17
+ }
18
+
19
+ // base config fields (self, incremental, test, testConfig, preOperations)
20
+ validateBaseConfig(config, options);
21
+
22
+ /*
23
+ Rest of the validations are related to ga4_events_enhanced table specific fields
24
+ */
25
+
26
+ // sourceTable - required; string or Dataform table reference
27
+ if (config.sourceTable === undefined || config.sourceTable === null) {
28
+ throw new Error("config.sourceTable is required. Provide a Dataform table reference (using the ref() function) or a string in format '`project.dataset.table`'.");
29
+ }
30
+ if (isDataformTableReferenceObject(config.sourceTable)) {
31
+ // Valid Dataform reference
32
+ } else if (typeof config.sourceTable === 'string') {
33
+ if (!config.sourceTable.trim()) {
34
+ throw new Error("config.sourceTable must be a non-empty string. Received empty string.");
35
+ }
36
+ if (!/^`[^\.]+\.[^\.]+\.[^\.]+`$/.test(config.sourceTable.trim())) {
37
+ throw new Error(`config.sourceTable must be in the format '\`project.dataset.table\`' (with backticks). Received: ${JSON.stringify(config.sourceTable)}`);
38
+ }
39
+ } else {
40
+ throw new Error(`config.sourceTable must be a Dataform table reference object or a string in format '\`project.dataset.table\`'. Received: ${JSON.stringify(config.sourceTable)}`);
41
+ }
42
+
43
+ // schemaLock - optional; must be undefined or a GA4 export table suffix: "YYYYMMDD", "intraday_YYYYMMDD", or "fresh_YYYYMMDD"
44
+ if (typeof config.schemaLock !== 'undefined') {
45
+ if (typeof config.schemaLock !== 'string' || !/^(?:(?:intraday|fresh)_)?\d{8}$/.test(config.schemaLock)) {
46
+ throw new Error(`config.schemaLock must be a string in "YYYYMMDD", "intraday_YYYYMMDD", or "fresh_YYYYMMDD" format (e.g., "20260101", "intraday_20260101"). Received: ${JSON.stringify(config.schemaLock)}`);
47
+ }
48
+ // Must be a valid date (extract date portion from the end)
49
+ const datePart = config.schemaLock.slice(-8);
50
+ const year = parseInt(datePart.slice(0, 4), 10);
51
+ const month = parseInt(datePart.slice(4, 6), 10);
52
+ const day = parseInt(datePart.slice(6, 8), 10);
53
+ const date = new Date(year, month - 1, day);
54
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
55
+ throw new Error(`config.schemaLock must contain a valid date. Received: ${JSON.stringify(config.schemaLock)}`);
56
+ }
57
+ // Must be at least 20241009
58
+ if (datePart < "20241009") {
59
+ throw new Error(`config.schemaLock date must be equal to or greater than "20241009". Received: ${JSON.stringify(config.schemaLock)}`);
60
+ }
61
+ }
62
+
63
+ // includedExportTypes - required
64
+ if (typeof config.includedExportTypes === 'undefined') {
65
+ throw new Error("config.includedExportTypes is required.");
66
+ }
67
+ if (!config.includedExportTypes || typeof config.includedExportTypes !== 'object' || Array.isArray(config.includedExportTypes)) {
68
+ throw new Error(`config.includedExportTypes must be an object. Received: ${JSON.stringify(config.includedExportTypes)}`);
69
+ }
70
+ for (const key of ['daily', 'fresh', 'intraday']) {
71
+ if (!(key in config.includedExportTypes)) {
72
+ throw new Error(`config.includedExportTypes.${key} is required.`);
73
+ }
74
+ if (typeof config.includedExportTypes[key] !== 'boolean') {
75
+ throw new Error(`config.includedExportTypes.${key} must be a boolean. Received: ${JSON.stringify(config.includedExportTypes[key])}`);
76
+ }
77
+ }
78
+ if (!config.includedExportTypes.daily && !config.includedExportTypes.fresh && !config.includedExportTypes.intraday) {
79
+ throw new Error("At least one of config.includedExportTypes.daily, config.includedExportTypes.fresh, or config.includedExportTypes.intraday must be true.");
80
+ }
81
+
82
+ // timezone - required
83
+ if (typeof config.timezone === 'undefined') {
84
+ throw new Error("config.timezone is required.");
85
+ }
86
+ if (typeof config.timezone !== 'string' || !config.timezone.trim()) {
87
+ throw new Error(`config.timezone must be a non-empty string (e.g. 'Etc/UTC', 'Europe/Helsinki'). Received: ${JSON.stringify(config.timezone)}`);
88
+ }
89
+
90
+ // customTimestampParam - optional; must be undefined or a non-empty string
91
+ if (typeof config.customTimestampParam !== 'undefined') {
92
+ if (typeof config.customTimestampParam !== 'string' || !config.customTimestampParam.trim()) {
93
+ throw new Error(`config.customTimestampParam must be a non-empty string when provided. Received: ${JSON.stringify(config.customTimestampParam)}`);
94
+ }
95
+ }
96
+
97
+ // dataIsFinal - required
98
+ if (typeof config.dataIsFinal === 'undefined') {
99
+ throw new Error("config.dataIsFinal is required.");
100
+ }
101
+ if (typeof config.dataIsFinal !== 'object' || Array.isArray(config.dataIsFinal)) {
102
+ throw new Error(`config.dataIsFinal must be an object. Received: ${JSON.stringify(config.dataIsFinal)}`);
103
+ }
104
+ if (typeof config.dataIsFinal.detectionMethod === 'undefined') {
105
+ throw new Error("config.dataIsFinal.detectionMethod is required.");
106
+ }
107
+ if (typeof config.dataIsFinal.detectionMethod !== 'string' || (config.dataIsFinal.detectionMethod !== 'EXPORT_TYPE' && config.dataIsFinal.detectionMethod !== 'DAY_THRESHOLD')) {
108
+ throw new Error(`config.dataIsFinal.detectionMethod must be 'EXPORT_TYPE' or 'DAY_THRESHOLD'. Received: ${JSON.stringify(config.dataIsFinal.detectionMethod)}`);
109
+ }
110
+ if (
111
+ config.dataIsFinal.detectionMethod === 'DAY_THRESHOLD' &&
112
+ typeof config.dataIsFinal.dayThreshold === 'undefined'
113
+ ) {
114
+ throw new Error("config.dataIsFinal.dayThreshold is required when detectionMethod is 'DAY_THRESHOLD'.");
115
+ }
116
+ if (
117
+ config.dataIsFinal.detectionMethod === 'DAY_THRESHOLD' &&
118
+ (typeof config.dataIsFinal.dayThreshold !== 'number' || !Number.isInteger(config.dataIsFinal.dayThreshold) || config.dataIsFinal.dayThreshold < 0)
119
+ ) {
120
+ throw new Error(`config.dataIsFinal.dayThreshold must be a non-negative integer. Received: ${JSON.stringify(config.dataIsFinal.dayThreshold)}`);
121
+ }
122
+ // EXPORT_TYPE detection relies on daily export tables to mark data as final.
123
+ // When daily is not enabled, all data would be marked as not final under EXPORT_TYPE,
124
+ // so DAY_THRESHOLD must be used instead.
125
+ if (
126
+ !config.includedExportTypes.daily &&
127
+ config.dataIsFinal.detectionMethod !== 'DAY_THRESHOLD'
128
+ ) {
129
+ throw new Error(`config.dataIsFinal.detectionMethod must be 'DAY_THRESHOLD' when daily export is not enabled (config.includedExportTypes.daily is false). A dayThreshold of 1 is recommended for intraday only setups. With fresh export, the GA4 data is subject to possible changes for up to 72 hours. Received: ${JSON.stringify(config.dataIsFinal.detectionMethod)}`);
130
+ }
131
+
132
+ // itemListAttribution - optional; must be undefined or a valid config object
133
+ if (typeof config.itemListAttribution !== 'undefined') {
134
+ if (!config.itemListAttribution || typeof config.itemListAttribution !== 'object' || Array.isArray(config.itemListAttribution)) {
135
+ throw new Error(`config.itemListAttribution must be an object when provided. Received: ${JSON.stringify(config.itemListAttribution)}`);
136
+ }
137
+ if (typeof config.itemListAttribution.lookbackType === 'undefined') {
138
+ throw new Error("config.itemListAttribution.lookbackType is required. Must be 'SESSION' or 'TIME'.");
139
+ }
140
+ if (config.itemListAttribution.lookbackType !== 'SESSION' && config.itemListAttribution.lookbackType !== 'TIME') {
141
+ throw new Error(`config.itemListAttribution.lookbackType must be 'SESSION' or 'TIME'. Received: ${JSON.stringify(config.itemListAttribution.lookbackType)}`);
142
+ }
143
+ if (config.itemListAttribution.lookbackType === 'TIME') {
144
+ if (typeof config.itemListAttribution.lookbackTimeMs === 'undefined') {
145
+ throw new Error("config.itemListAttribution.lookbackTimeMs is required when lookbackType is 'TIME'.");
146
+ }
147
+ }
148
+ if (typeof config.itemListAttribution.lookbackTimeMs !== 'undefined') {
149
+ if (typeof config.itemListAttribution.lookbackTimeMs !== 'number' || !Number.isInteger(config.itemListAttribution.lookbackTimeMs) || config.itemListAttribution.lookbackTimeMs <= 0) {
150
+ throw new Error(`config.itemListAttribution.lookbackTimeMs must be a positive integer. Received: ${JSON.stringify(config.itemListAttribution.lookbackTimeMs)}`);
151
+ }
152
+ }
153
+ }
154
+
155
+ // bufferDays - required
156
+ if (typeof config.bufferDays !== 'number' || !Number.isInteger(config.bufferDays) || config.bufferDays < 0) {
157
+ throw new Error(`config.bufferDays must be a non-negative integer. Received: ${JSON.stringify(config.bufferDays)}`);
158
+ }
159
+
160
+ // Array fields - all required
161
+ const stringArrayKeys = ['defaultExcludedEventParams', 'excludedEventParams', 'sessionParams', 'defaultExcludedEvents', 'excludedEvents', 'excludedColumns'];
162
+ for (const key of stringArrayKeys) {
163
+ if (config[key] === undefined) {
164
+ throw new Error(`config.${key} is required.`);
165
+ }
166
+ if (!Array.isArray(config[key])) {
167
+ throw new Error(`config.${key} must be an array. Received: ${JSON.stringify(config[key])}`);
168
+ }
169
+ for (let i = 0; i < config[key].length; i++) {
170
+ if (typeof config[key][i] !== 'string' || !config[key][i].trim()) {
171
+ throw new Error(`config.${key}[${i}] must be a non-empty string. Received: ${JSON.stringify(config[key][i])}`);
172
+ }
173
+ }
174
+ }
175
+
176
+ // eventParamsToColumns - required
177
+ if (config.eventParamsToColumns === undefined) {
178
+ throw new Error("config.eventParamsToColumns is required.");
179
+ }
180
+ if (!Array.isArray(config.eventParamsToColumns)) {
181
+ throw new Error(`config.eventParamsToColumns must be an array. Received: ${JSON.stringify(config.eventParamsToColumns)}`);
182
+ }
183
+ const validEventParamTypes = ['string', 'int', 'int64', 'double', 'float', 'float64'];
184
+ for (let i = 0; i < config.eventParamsToColumns.length; i++) {
185
+ const item = config.eventParamsToColumns[i];
186
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
187
+ throw new Error(`config.eventParamsToColumns[${i}] must be an object with 'name' and 'type' properties. Received: ${JSON.stringify(item)}`);
188
+ }
189
+ if (!item.name || typeof item.name !== 'string' || !item.name.trim()) {
190
+ throw new Error(`config.eventParamsToColumns[${i}].name must be a non-empty string. Received: ${JSON.stringify(item.name)}`);
191
+ }
192
+ if (item.type !== undefined && item.type !== null) {
193
+ if (!validEventParamTypes.includes(item.type)) {
194
+ throw new Error(`config.eventParamsToColumns[${i}].type must be one of: ${validEventParamTypes.join(', ')}. Received: ${JSON.stringify(item.type)}`);
195
+ }
196
+ }
197
+ if (item.columnName !== undefined && item.columnName !== null && item.columnName !== '') {
198
+ if (typeof item.columnName !== 'string' || !item.columnName.trim()) {
199
+ throw new Error(`config.eventParamsToColumns[${i}].columnName must be a non-empty string when provided. Received: ${JSON.stringify(item.columnName)}`);
200
+ }
201
+ }
202
+ }
203
+
204
+ // customSteps - optional array of queryBuilder step objects appended to the pipeline
205
+ // Layer 1 (config shape): array, objects with non-empty name, no duplicates within customSteps.
206
+ // Step-shape validation (clause keys, etc.) deferred to queryBuilder.
207
+ // Collision-with-package-names check deferred to _generateEnhancedEventsSQL (Layer 2),
208
+ // since the reserved set is config-dependent (e.g. item_list_* only exist when itemListAttribution is on).
209
+ if (config.customSteps !== undefined) {
210
+ if (!Array.isArray(config.customSteps)) {
211
+ throw new Error(`config.customSteps must be an array. Received: ${JSON.stringify(config.customSteps)}`);
212
+ }
213
+ const seenNames = new Set();
214
+ for (let i = 0; i < config.customSteps.length; i++) {
215
+ const step = config.customSteps[i];
216
+ if (!step || typeof step !== 'object' || Array.isArray(step)) {
217
+ throw new Error(`config.customSteps[${i}] must be a non-null object. Received: ${JSON.stringify(step)}`);
218
+ }
219
+ if (typeof step.name !== 'string' || !step.name.trim()) {
220
+ throw new Error(`config.customSteps[${i}].name must be a non-empty string. Received: ${JSON.stringify(step.name)}`);
221
+ }
222
+ if (seenNames.has(step.name)) {
223
+ throw new Error(`config.customSteps contains duplicate name '${step.name}'. Each customSteps entry must have a unique name.`);
224
+ }
225
+ seenNames.add(step.name);
226
+ }
227
+ }
228
+ } catch (e) {
229
+ e.message = `Config validation: ${e.message}`;
230
+ throw e;
231
+ }
232
+ };
233
+
234
+ module.exports = { validateEnhancedEventsConfig };