UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

700 lines 47.7 kB
import { isBoolean, isString } from 'es-toolkit/compat'; import { cloneDeep as clone } from 'es-toolkit/object'; import { isPlainObject } from 'es-toolkit/predicate'; import { isStringArray } from '../../../utils/value.js'; export const SUPPORTED_SEARCH_SOURCE_SYNC_CLASSES = ['append-only', 'delta-merge', 'snapshot-replace']; export const SUPPORTED_SEARCH_SOURCE_TYPES = ['firestore', 'http', 'sql']; export const SUPPORTED_PROJECT_SCOPED_SEARCH_SOURCE_TYPES = ['http', 'sql']; const SUPPORTED_HTTP_INCREMENTAL_STRATEGIES = ['cursor']; const SUPPORTED_HTTP_REQUEST_METHODS = ['get', 'post']; const SHARED_HTTP_SOURCE_DEFINITION_ALLOWED_KEYS = ['body', 'headers', 'incremental', 'method', 'query']; const SHARED_SEARCH_SOURCE_DEFINITION_ALLOWED_KEYS = ['description', 'http', 'indexes', 'mapper', 'routeTemplate', 'sql', 'syncClass', 'type']; const SUPPORTED_SEARCH_SOURCE_PROMOTION_MODES = ['automatic', 'manual']; const SHARED_SQL_SOURCE_DEFINITION_ALLOWED_KEYS = ['driver', 'incremental', 'promotion', 'snapshot']; const PROJECT_HTTP_SOURCE_BINDING_ALLOWED_KEYS = ['connectionSecret', 'url']; const PROJECT_SEARCH_SOURCE_BINDING_ALLOWED_KEYS = ['enabled', 'http', 'schedule', 'sql']; const PROJECT_SQL_SOURCE_BINDING_ALLOWED_KEYS = ['connectionSecret']; const SUPPORTED_SQL_INCREMENTAL_DELETE_STRATEGIES = ['none', 'soft-delete']; const SUPPORTED_SQL_INCREMENTAL_STRATEGIES = ['watermark']; const throwSearchConfigError = message => { throw new Error(`Invalid Atlas search config. ${message}`); }; const assertAllowedKeys = (value, propertyPath, allowedKeys) => { const invalidKeys = Object.keys(value ?? {}).filter(key => !allowedKeys.includes(key)); if (invalidKeys.length > 0) { throwSearchConfigError(`Unsupported properties found on "${propertyPath}": ${invalidKeys.join(', ')}.`); } }; const assertNonEmptyString = (value, propertyPath) => { if (!isString(value) || value.trim().length === 0) { throwSearchConfigError(`The "${propertyPath}" property must be a non-empty string.`); } }; const assertAllowedStringValue = (value, propertyPath, allowedValues) => { if (value === undefined) { return; } if (!isString(value) || !allowedValues.includes(value.trim().toLowerCase())) { throwSearchConfigError(`The "${propertyPath}" property must be one of: ${allowedValues.join(', ')}.`); } }; const assertObjectWhenProvided = (value, propertyPath) => { if (value !== undefined && !isPlainObject(value)) { throwSearchConfigError(`The "${propertyPath}" property must be an object when provided.`); } }; const assertPositiveIntegerWhenProvided = (value, propertyPath) => { if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { throwSearchConfigError(`The "${propertyPath}" property must be a positive integer when provided.`); } }; const assertStringArrayWhenProvided = (value, propertyPath) => { if (value !== undefined && !isStringArray(value)) { throwSearchConfigError(`The "${propertyPath}" property must be an array of strings when provided.`); } }; const assertStringOrNullWhenProvided = (value, propertyPath) => { if (value !== undefined && value !== null && !isString(value)) { throwSearchConfigError(`The "${propertyPath}" property must be a string or null when provided.`); } }; const assertStringRecordWhenProvided = (value, propertyPath) => { if (value === undefined) { return; } if (!isPlainObject(value)) { throwSearchConfigError(`The "${propertyPath}" property must be an object when provided.`); } for (const [entryKey, entryValue] of Object.entries(value)) { if (!isString(entryValue)) { throwSearchConfigError(`The "${propertyPath}.${entryKey}" property must be a string when provided.`); } } }; const assertStringWhenProvided = (value, propertyPath) => { if (value !== undefined && !isString(value)) { throwSearchConfigError(`The "${propertyPath}" property must be a string when provided.`); } }; const getNormalizedOptionalString = value => isString(value) && value.trim().length > 0 ? value.trim().toLowerCase() : null; const toCamelCase = value => isString(value) ? value.trim().toLowerCase().replace(/[_-]+([a-z0-9])/g, (_, character) => character.toUpperCase()) : null; const createSqlParameterAliases = (fieldName, genericAliases = []) => [...new Set([fieldName, `cursor_${fieldName}`, ...genericAliases, toCamelCase(fieldName), toCamelCase(`cursor_${fieldName}`)].filter(Boolean))]; const containsAnySqlPlaceholder = (sql, aliases) => aliases.some(alias => new RegExp(`@${alias}(?![A-Za-z0-9_])`, 'u').test(sql)); const cloneWhenProvided = value => value === undefined ? undefined : clone(value); const createCanonicalSqlIncrementalConfig = incrementalConfig => { if (!isPlainObject(incrementalConfig)) { return undefined; } const resolvedIncrementalConfig = clone(incrementalConfig); if (resolvedIncrementalConfig.query !== undefined) { resolvedIncrementalConfig.sql = resolvedIncrementalConfig.query; delete resolvedIncrementalConfig.query; } return resolvedIncrementalConfig; }; const createCanonicalSqlSnapshotConfig = snapshotConfig => { if (!isPlainObject(snapshotConfig)) { return undefined; } const resolvedSnapshotConfig = clone(snapshotConfig); if (resolvedSnapshotConfig.query !== undefined) { resolvedSnapshotConfig.sql = resolvedSnapshotConfig.query; delete resolvedSnapshotConfig.query; } return resolvedSnapshotConfig; }; const resolveSearchSourceBindingType = (sourcePath, sourceBinding, sourceType = null) => { const configuredBindingTypes = SUPPORTED_PROJECT_SCOPED_SEARCH_SOURCE_TYPES.filter(bindingType => sourceBinding?.[bindingType] !== undefined); if (configuredBindingTypes.length !== 1) { throwSearchConfigError(`The "${sourcePath}" entry must define exactly one type-specific binding via "http" or "sql".`); } const resolvedBindingType = configuredBindingTypes[0]; if (sourceType && resolvedBindingType !== sourceType) { throwSearchConfigError(`The "${sourcePath}" entry must use the "${sourceType}" binding shape because its shared source definition is type "${sourceType}".`); } return resolvedBindingType; }; const validateSqlDefinitionIncrementalConfig = (sourcePath, sourceDefinition) => { const incrementalConfig = sourceDefinition?.sql?.incremental; if (incrementalConfig === undefined) { return; } assertObjectWhenProvided(incrementalConfig, `${sourcePath}.sql.incremental`); assertStringWhenProvided(incrementalConfig.strategy, `${sourcePath}.sql.incremental.strategy`); assertStringWhenProvided(incrementalConfig.query, `${sourcePath}.sql.incremental.query`); assertAllowedStringValue(incrementalConfig.deleteStrategy, `${sourcePath}.sql.incremental.deleteStrategy`, SUPPORTED_SQL_INCREMENTAL_DELETE_STRATEGIES); assertStringWhenProvided(incrementalConfig.deletedColumn, `${sourcePath}.sql.incremental.deletedColumn`); assertStringWhenProvided(incrementalConfig.versionColumn, `${sourcePath}.sql.incremental.versionColumn`); assertStringWhenProvided(incrementalConfig.idColumn, `${sourcePath}.sql.incremental.idColumn`); assertStringWhenProvided(incrementalConfig.safetyLag, `${sourcePath}.sql.incremental.safetyLag`); assertPositiveIntegerWhenProvided(incrementalConfig.batchSize, `${sourcePath}.sql.incremental.batchSize`); if (incrementalConfig.cursor !== undefined) { assertObjectWhenProvided(incrementalConfig.cursor, `${sourcePath}.sql.incremental.cursor`); assertStringWhenProvided(incrementalConfig.cursor.versionColumn, `${sourcePath}.sql.incremental.cursor.versionColumn`); assertStringWhenProvided(incrementalConfig.cursor.idColumn, `${sourcePath}.sql.incremental.cursor.idColumn`); } }; const validateSqlDefinitionSnapshotConfig = (sourcePath, sourceDefinition) => { const snapshotConfig = sourceDefinition?.sql?.snapshot; if (snapshotConfig === undefined) { return; } assertObjectWhenProvided(snapshotConfig, `${sourcePath}.sql.snapshot`); assertStringWhenProvided(snapshotConfig.query, `${sourcePath}.sql.snapshot.query`); if (snapshotConfig.pagination !== undefined) { assertObjectWhenProvided(snapshotConfig.pagination, `${sourcePath}.sql.snapshot.pagination`); assertStringWhenProvided(snapshotConfig.pagination.mode, `${sourcePath}.sql.snapshot.pagination.mode`); assertStringWhenProvided(snapshotConfig.pagination.keyColumn, `${sourcePath}.sql.snapshot.pagination.keyColumn`); assertPositiveIntegerWhenProvided(snapshotConfig.pagination.batchSize, `${sourcePath}.sql.snapshot.pagination.batchSize`); } }; const validateSqlDefinitionPromotionConfig = (sourcePath, sourceDefinition) => { const promotionConfig = sourceDefinition?.sql?.promotion; if (promotionConfig === undefined) { return; } assertObjectWhenProvided(promotionConfig, `${sourcePath}.sql.promotion`); assertAllowedStringValue(promotionConfig.mode, `${sourcePath}.sql.promotion.mode`, SUPPORTED_SEARCH_SOURCE_PROMOTION_MODES); assertStringWhenProvided(promotionConfig.rollbackGrace, `${sourcePath}.sql.promotion.rollbackGrace`); }; const validateHttpDefinitionConfig = (sourcePath, sourceDefinition) => { const httpConfig = sourceDefinition?.http; if (httpConfig === undefined) { throwSearchConfigError(`The "${sourcePath}.http" property is required for http source definitions.`); } assertObjectWhenProvided(httpConfig, `${sourcePath}.http`); assertAllowedKeys(httpConfig, `${sourcePath}.http`, SHARED_HTTP_SOURCE_DEFINITION_ALLOWED_KEYS); assertAllowedStringValue(httpConfig.method, `${sourcePath}.http.method`, SUPPORTED_HTTP_REQUEST_METHODS); assertStringRecordWhenProvided(httpConfig.headers, `${sourcePath}.http.headers`); assertStringRecordWhenProvided(httpConfig.query, `${sourcePath}.http.query`); if (httpConfig.body !== undefined && !isPlainObject(httpConfig.body)) { throwSearchConfigError(`The "${sourcePath}.http.body" property must be an object when provided.`); } if (!getNormalizedOptionalString(httpConfig.method)) { throwSearchConfigError(`The "${sourcePath}.http.method" property must be a non-empty string for http source definitions.`); } if (getNormalizedOptionalString(httpConfig.method) === 'get' && httpConfig.body !== undefined) { throwSearchConfigError(`The "${sourcePath}.http.body" property is not supported when http.method is "GET".`); } }; const validateHttpDefinitionIncrementalConfig = (sourcePath, sourceDefinition) => { const httpConfig = sourceDefinition?.http; const incrementalConfig = httpConfig?.incremental; const requestMethod = getNormalizedOptionalString(httpConfig?.method); const syncClass = getNormalizedOptionalString(sourceDefinition?.syncClass); if (incrementalConfig === undefined) { throwSearchConfigError(`The "${sourcePath}.http.incremental" property is required for http source definitions.`); } assertObjectWhenProvided(incrementalConfig, `${sourcePath}.http.incremental`); assertAllowedStringValue(incrementalConfig.strategy, `${sourcePath}.http.incremental.strategy`, SUPPORTED_HTTP_INCREMENTAL_STRATEGIES); assertObjectWhenProvided(incrementalConfig.cursor, `${sourcePath}.http.incremental.cursor`); assertObjectWhenProvided(incrementalConfig.cursor?.request, `${sourcePath}.http.incremental.cursor.request`); assertObjectWhenProvided(incrementalConfig.cursor?.response, `${sourcePath}.http.incremental.cursor.response`); assertObjectWhenProvided(incrementalConfig.response, `${sourcePath}.http.incremental.response`); assertObjectWhenProvided(incrementalConfig.mapping, `${sourcePath}.http.incremental.mapping`); assertStringWhenProvided(incrementalConfig.cursor?.request?.bodyPath, `${sourcePath}.http.incremental.cursor.request.bodyPath`); assertStringWhenProvided(incrementalConfig.cursor?.request?.headerName, `${sourcePath}.http.incremental.cursor.request.headerName`); assertStringWhenProvided(incrementalConfig.cursor?.request?.queryParam, `${sourcePath}.http.incremental.cursor.request.queryParam`); assertStringWhenProvided(incrementalConfig.cursor?.response?.hasMorePath, `${sourcePath}.http.incremental.cursor.response.hasMorePath`); assertStringWhenProvided(incrementalConfig.cursor?.response?.nextCursorPath, `${sourcePath}.http.incremental.cursor.response.nextCursorPath`); assertStringWhenProvided(incrementalConfig.response?.itemsPath, `${sourcePath}.http.incremental.response.itemsPath`); assertStringWhenProvided(incrementalConfig.mapping?.afterPath, `${sourcePath}.http.incremental.mapping.afterPath`); assertStringWhenProvided(incrementalConfig.mapping?.beforePath, `${sourcePath}.http.incremental.mapping.beforePath`); assertStringWhenProvided(incrementalConfig.mapping?.deletedPath, `${sourcePath}.http.incremental.mapping.deletedPath`); assertStringWhenProvided(incrementalConfig.mapping?.idPath, `${sourcePath}.http.incremental.mapping.idPath`); assertStringWhenProvided(incrementalConfig.mapping?.versionPath, `${sourcePath}.http.incremental.mapping.versionPath`); for (const unsupportedIncrementalProperty of ['batchSize', 'deleteStrategy', 'deletedColumn', 'idColumn', 'sql', 'versionColumn']) { if (incrementalConfig[unsupportedIncrementalProperty] !== undefined) { throwSearchConfigError(`The "${sourcePath}.http.incremental.${unsupportedIncrementalProperty}" property is not supported for http source definitions.`); } } if (!getNormalizedOptionalString(incrementalConfig.strategy)) { throwSearchConfigError(`The "${sourcePath}.http.incremental.strategy" property must be a non-empty string for http source definitions.`); } const cursorTransportCount = [incrementalConfig.cursor?.request?.queryParam, incrementalConfig.cursor?.request?.headerName, incrementalConfig.cursor?.request?.bodyPath].filter(value => getNormalizedOptionalString(value) !== null).length; if (cursorTransportCount !== 1) { throwSearchConfigError(`The "${sourcePath}.http.incremental.cursor.request" property must define exactly one cursor transport via queryParam, headerName, or bodyPath.`); } if (requestMethod === 'get' && getNormalizedOptionalString(incrementalConfig.cursor?.request?.bodyPath) !== null) { throwSearchConfigError(`The "${sourcePath}.http.incremental.cursor.request.bodyPath" property is not supported when http.method is "GET".`); } for (const [requiredPath, requiredValue] of [['http.incremental.cursor.response.nextCursorPath', incrementalConfig.cursor?.response?.nextCursorPath], ['http.incremental.response.itemsPath', incrementalConfig.response?.itemsPath], ['http.incremental.mapping.afterPath', incrementalConfig.mapping?.afterPath], ['http.incremental.mapping.idPath', incrementalConfig.mapping?.idPath], ['http.incremental.mapping.versionPath', incrementalConfig.mapping?.versionPath]]) { if (!getNormalizedOptionalString(requiredValue)) { throwSearchConfigError(`The "${sourcePath}.${requiredPath}" property must be a non-empty string for http source definitions.`); } } if (syncClass === 'append-only') { if (incrementalConfig.mapping?.beforePath !== undefined) { throwSearchConfigError(`The "${sourcePath}.http.incremental.mapping.beforePath" property is not supported when syncClass is "append-only".`); } if (incrementalConfig.mapping?.deletedPath !== undefined) { throwSearchConfigError(`The "${sourcePath}.http.incremental.mapping.deletedPath" property is not supported when syncClass is "append-only".`); } } }; export const validateSearchSourceDefinitionConfig = (sourceName, sourceDefinition, options = {}) => { const sourceRegistryPath = options.sourceRegistryPath ?? 'workload.source'; const sourcePath = `${sourceRegistryPath}.${sourceName}`; if (!isPlainObject(sourceDefinition)) { throwSearchConfigError(`The "${sourcePath}" entry must be an object.`); } assertAllowedKeys(sourceDefinition, sourcePath, SHARED_SEARCH_SOURCE_DEFINITION_ALLOWED_KEYS); assertNonEmptyString(sourceDefinition.type, `${sourcePath}.type`); assertNonEmptyString(sourceDefinition.syncClass, `${sourcePath}.syncClass`); const sourceType = sourceDefinition.type.trim().toLowerCase(); const syncClass = sourceDefinition.syncClass.trim().toLowerCase(); if (!SUPPORTED_PROJECT_SCOPED_SEARCH_SOURCE_TYPES.includes(sourceType)) { throwSearchConfigError(`The "${sourcePath}.type" property must be one of: ${SUPPORTED_PROJECT_SCOPED_SEARCH_SOURCE_TYPES.join(', ')}.`); } if (!SUPPORTED_SEARCH_SOURCE_SYNC_CLASSES.includes(syncClass)) { throwSearchConfigError(`The "${sourcePath}.syncClass" property must be one of: ${SUPPORTED_SEARCH_SOURCE_SYNC_CLASSES.join(', ')}.`); } if (!isStringArray(sourceDefinition.indexes) || sourceDefinition.indexes.length === 0) { throwSearchConfigError(`The "${sourcePath}.indexes" property must be a non-empty array of strings.`); } assertNonEmptyString(sourceDefinition.mapper, `${sourcePath}.mapper`); assertStringWhenProvided(sourceDefinition.description, `${sourcePath}.description`); assertStringWhenProvided(sourceDefinition.routeTemplate, `${sourcePath}.routeTemplate`); if (sourceType === 'sql') { if (sourceDefinition.http !== undefined) { throwSearchConfigError(`The "${sourcePath}.http" property is not supported when type is "sql".`); } assertObjectWhenProvided(sourceDefinition.sql, `${sourcePath}.sql`); if (sourceDefinition.sql === undefined) { throwSearchConfigError(`The "${sourcePath}.sql" property is required for sql source definitions.`); } assertAllowedKeys(sourceDefinition.sql, `${sourcePath}.sql`, SHARED_SQL_SOURCE_DEFINITION_ALLOWED_KEYS); assertNonEmptyString(sourceDefinition.sql.driver, `${sourcePath}.sql.driver`); validateSqlDefinitionIncrementalConfig(sourcePath, sourceDefinition); validateSqlDefinitionSnapshotConfig(sourcePath, sourceDefinition); validateSqlDefinitionPromotionConfig(sourcePath, sourceDefinition); if (syncClass === 'snapshot-replace') { if (sourceDefinition.sql.incremental !== undefined) { throwSearchConfigError(`The "${sourcePath}.sql.incremental" property is not supported when syncClass is "snapshot-replace".`); } if (sourceDefinition.sql.snapshot === undefined) { throwSearchConfigError(`The "${sourcePath}.sql.snapshot" property is required when syncClass is "snapshot-replace".`); } } else { if (sourceDefinition.sql.snapshot !== undefined) { throwSearchConfigError(`The "${sourcePath}.sql.snapshot" property is only supported when syncClass is "snapshot-replace".`); } if (sourceDefinition.sql.incremental === undefined) { throwSearchConfigError(`The "${sourcePath}.sql.incremental" property is required for sql source definitions unless syncClass is "snapshot-replace".`); } const cursorConfig = isPlainObject(sourceDefinition.sql.incremental?.cursor) ? sourceDefinition.sql.incremental.cursor : sourceDefinition.sql.incremental; const deleteStrategy = getNormalizedOptionalString(sourceDefinition.sql.incremental?.deleteStrategy) ?? 'none'; const incrementalStrategy = getNormalizedOptionalString(sourceDefinition.sql.incremental?.strategy) ?? null; const deletedColumn = isString(sourceDefinition.sql.incremental?.deletedColumn) && sourceDefinition.sql.incremental.deletedColumn.trim().length > 0 ? sourceDefinition.sql.incremental.deletedColumn.trim() : null; if (!isString(cursorConfig.versionColumn) || cursorConfig.versionColumn.trim().length === 0) { throwSearchConfigError(`The "${sourcePath}.sql.incremental.cursor.versionColumn" property must be a non-empty string for incremental sql source definitions.`); } if (!isString(cursorConfig.idColumn) || cursorConfig.idColumn.trim().length === 0) { throwSearchConfigError(`The "${sourcePath}.sql.incremental.cursor.idColumn" property must be a non-empty string for incremental sql source definitions.`); } const sqlQuery = sourceDefinition.sql.incremental.query; const versionAliases = createSqlParameterAliases(cursorConfig.versionColumn, ['version', 'cursor_version']); const idAliases = createSqlParameterAliases(cursorConfig.idColumn, ['id', 'cursor_id']); if (!containsAnySqlPlaceholder(sqlQuery, versionAliases)) { throwSearchConfigError(`The "${sourcePath}.sql.incremental.query" property must reference the configured version cursor placeholder.`); } if (!containsAnySqlPlaceholder(sqlQuery, idAliases)) { throwSearchConfigError(`The "${sourcePath}.sql.incremental.query" property must reference the configured id cursor placeholder.`); } if (!containsAnySqlPlaceholder(sqlQuery, ['batchSize', 'batch_size', 'limit'])) { throwSearchConfigError(`The "${sourcePath}.sql.incremental.query" property must reference a batch-size placeholder such as @limit.`); } if (!SUPPORTED_SQL_INCREMENTAL_STRATEGIES.includes(incrementalStrategy)) { throwSearchConfigError(`The "${sourcePath}.sql.incremental.strategy" property must be one of: ${SUPPORTED_SQL_INCREMENTAL_STRATEGIES.join(', ')}.`); } if (syncClass === 'append-only') { if (sourceDefinition.sql.incremental.deleteStrategy !== undefined) { throwSearchConfigError(`The "${sourcePath}.sql.incremental.deleteStrategy" property is not supported when syncClass is "append-only".`); } if (sourceDefinition.sql.incremental.deletedColumn !== undefined) { throwSearchConfigError(`The "${sourcePath}.sql.incremental.deletedColumn" property is not supported when syncClass is "append-only".`); } } if (syncClass === 'delta-merge') { if (!SUPPORTED_SQL_INCREMENTAL_DELETE_STRATEGIES.includes(deleteStrategy)) { throwSearchConfigError(`The "${sourcePath}.sql.incremental.deleteStrategy" property must be one of: ${SUPPORTED_SQL_INCREMENTAL_DELETE_STRATEGIES.join(', ')}.`); } if (deleteStrategy === 'soft-delete' && deletedColumn === null) { throwSearchConfigError(`The "${sourcePath}.sql.incremental.deletedColumn" property is required when deleteStrategy is "soft-delete".`); } } } if (sourceDefinition.sql.promotion !== undefined && syncClass !== 'snapshot-replace') { throwSearchConfigError(`The "${sourcePath}.sql.promotion" property is only supported when syncClass is "snapshot-replace".`); } return sourceDefinition; } if (sourceDefinition.sql !== undefined) { throwSearchConfigError(`The "${sourcePath}.sql" property is not supported when type is "http".`); } validateHttpDefinitionConfig(sourcePath, sourceDefinition); validateHttpDefinitionIncrementalConfig(sourcePath, sourceDefinition); if (syncClass !== 'delta-merge' && syncClass !== 'append-only') { throwSearchConfigError(`The "${sourcePath}" entry must use syncClass "delta-merge" or "append-only" when type is "http".`); } return sourceDefinition; }; export const validateSearchSourceBindingConfig = (sourceName, sourceBinding, options = {}) => { const sourceRegistryPath = options.sourceRegistryPath ?? 'projects.project.sourceBindings'; const sourcePath = `${sourceRegistryPath}.${sourceName}`; const resolvedSourceType = getNormalizedOptionalString(options.sourceType); if (!isPlainObject(sourceBinding)) { throwSearchConfigError(`The "${sourcePath}" entry must be an object.`); } assertAllowedKeys(sourceBinding, sourcePath, PROJECT_SEARCH_SOURCE_BINDING_ALLOWED_KEYS); if (sourceBinding.enabled !== undefined && !isBoolean(sourceBinding.enabled)) { throwSearchConfigError(`The "${sourcePath}.enabled" property must be a boolean when provided.`); } assertNonEmptyString(sourceBinding.schedule, `${sourcePath}.schedule`); const sourceType = resolveSearchSourceBindingType(sourcePath, sourceBinding, resolvedSourceType); if (sourceType === 'sql') { assertObjectWhenProvided(sourceBinding.sql, `${sourcePath}.sql`); if (sourceBinding.sql === undefined) { throwSearchConfigError(`The "${sourcePath}.sql" property is required for sql source bindings.`); } assertAllowedKeys(sourceBinding.sql, `${sourcePath}.sql`, PROJECT_SQL_SOURCE_BINDING_ALLOWED_KEYS); assertNonEmptyString(sourceBinding.sql.connectionSecret, `${sourcePath}.sql.connectionSecret`); if (sourceBinding.http !== undefined) { throwSearchConfigError(`The "${sourcePath}.http" property is not supported when the binding type is "sql".`); } return sourceBinding; } assertObjectWhenProvided(sourceBinding.http, `${sourcePath}.http`); if (sourceBinding.http === undefined) { throwSearchConfigError(`The "${sourcePath}.http" property is required for http source bindings.`); } assertAllowedKeys(sourceBinding.http, `${sourcePath}.http`, PROJECT_HTTP_SOURCE_BINDING_ALLOWED_KEYS); assertNonEmptyString(sourceBinding.http.url, `${sourcePath}.http.url`); assertStringWhenProvided(sourceBinding.http.connectionSecret, `${sourcePath}.http.connectionSecret`); if (sourceBinding.sql !== undefined) { throwSearchConfigError(`The "${sourcePath}.sql" property is not supported when the binding type is "http".`); } return sourceBinding; }; export const resolveProjectScopedSearchSourceConfig = (sourceName, sourceDefinition, sourceBinding) => { validateSearchSourceDefinitionConfig(sourceName, sourceDefinition); validateSearchSourceBindingConfig(sourceName, sourceBinding, { sourceRegistryPath: 'projects.project.sourceBindings', sourceType: sourceDefinition?.type }); const sourceType = getNormalizedOptionalString(sourceDefinition.type); if (sourceType === 'sql') { const canonicalSourceConfig = { connectionSecret: sourceBinding.sql.connectionSecret, description: sourceDefinition.description, driver: sourceDefinition.sql.driver, enabled: sourceBinding.enabled !== false, incremental: createCanonicalSqlIncrementalConfig(sourceDefinition.sql.incremental), indexes: clone(sourceDefinition.indexes ?? []), mapper: sourceDefinition.mapper, promotion: cloneWhenProvided(sourceDefinition.sql.promotion), routeTemplate: sourceDefinition.routeTemplate, schedule: sourceBinding.schedule, snapshot: createCanonicalSqlSnapshotConfig(sourceDefinition.sql.snapshot), syncClass: sourceDefinition.syncClass, type: sourceDefinition.type }; validateSearchSourceConfig(sourceName, canonicalSourceConfig); return canonicalSourceConfig; } const canonicalSourceConfig = { connectionSecret: sourceBinding.http.connectionSecret, description: sourceDefinition.description, enabled: sourceBinding.enabled !== false, incremental: cloneWhenProvided(sourceDefinition.http.incremental), indexes: clone(sourceDefinition.indexes ?? []), mapper: sourceDefinition.mapper, request: { body: cloneWhenProvided(sourceDefinition.http.body), headers: cloneWhenProvided(sourceDefinition.http.headers), method: sourceDefinition.http.method, query: cloneWhenProvided(sourceDefinition.http.query), url: sourceBinding.http.url }, routeTemplate: sourceDefinition.routeTemplate, schedule: sourceBinding.schedule, syncClass: sourceDefinition.syncClass, type: sourceDefinition.type }; validateSearchSourceConfig(sourceName, canonicalSourceConfig); return canonicalSourceConfig; }; const validateSearchSourceEventarcConfig = (sourceName, sourceConfig) => { if (sourceConfig.eventarc === undefined) { return; } assertObjectWhenProvided(sourceConfig.eventarc, `sources.${sourceName}.eventarc`); assertStringWhenProvided(sourceConfig.eventarc.documentPathPattern, `sources.${sourceName}.eventarc.documentPathPattern`); assertStringOrNullWhenProvided(sourceConfig.eventarc.database, `sources.${sourceName}.eventarc.database`); assertStringOrNullWhenProvided(sourceConfig.eventarc.triggerRegion, `sources.${sourceName}.eventarc.triggerRegion`); }; const validateSqlSearchSourceIncrementalConfig = (sourceName, sourceConfig) => { if (sourceConfig.incremental === undefined) { return; } assertObjectWhenProvided(sourceConfig.incremental, `sources.${sourceName}.incremental`); assertStringWhenProvided(sourceConfig.incremental.strategy, `sources.${sourceName}.incremental.strategy`); assertStringWhenProvided(sourceConfig.incremental.sql, `sources.${sourceName}.incremental.sql`); assertAllowedStringValue(sourceConfig.incremental.deleteStrategy, `sources.${sourceName}.incremental.deleteStrategy`, SUPPORTED_SQL_INCREMENTAL_DELETE_STRATEGIES); assertStringWhenProvided(sourceConfig.incremental.deletedColumn, `sources.${sourceName}.incremental.deletedColumn`); assertStringWhenProvided(sourceConfig.incremental.versionColumn, `sources.${sourceName}.incremental.versionColumn`); assertStringWhenProvided(sourceConfig.incremental.idColumn, `sources.${sourceName}.incremental.idColumn`); assertStringWhenProvided(sourceConfig.incremental.safetyLag, `sources.${sourceName}.incremental.safetyLag`); assertPositiveIntegerWhenProvided(sourceConfig.incremental.batchSize, `sources.${sourceName}.incremental.batchSize`); if (sourceConfig.incremental.cursor !== undefined) { assertObjectWhenProvided(sourceConfig.incremental.cursor, `sources.${sourceName}.incremental.cursor`); assertStringWhenProvided(sourceConfig.incremental.cursor.versionColumn, `sources.${sourceName}.incremental.cursor.versionColumn`); assertStringWhenProvided(sourceConfig.incremental.cursor.idColumn, `sources.${sourceName}.incremental.cursor.idColumn`); } }; const validateSearchSourceSnapshotConfig = (sourceName, sourceConfig) => { if (sourceConfig.snapshot === undefined) { return; } assertObjectWhenProvided(sourceConfig.snapshot, `sources.${sourceName}.snapshot`); assertStringWhenProvided(sourceConfig.snapshot.sql, `sources.${sourceName}.snapshot.sql`); if (sourceConfig.snapshot.pagination !== undefined) { assertObjectWhenProvided(sourceConfig.snapshot.pagination, `sources.${sourceName}.snapshot.pagination`); assertStringWhenProvided(sourceConfig.snapshot.pagination.mode, `sources.${sourceName}.snapshot.pagination.mode`); assertStringWhenProvided(sourceConfig.snapshot.pagination.keyColumn, `sources.${sourceName}.snapshot.pagination.keyColumn`); assertPositiveIntegerWhenProvided(sourceConfig.snapshot.pagination.batchSize, `sources.${sourceName}.snapshot.pagination.batchSize`); } }; const validateSearchSourcePromotionConfig = (sourceName, sourceConfig) => { if (sourceConfig.promotion === undefined) { return; } assertObjectWhenProvided(sourceConfig.promotion, `sources.${sourceName}.promotion`); assertAllowedStringValue(sourceConfig.promotion.mode, `sources.${sourceName}.promotion.mode`, SUPPORTED_SEARCH_SOURCE_PROMOTION_MODES); assertStringWhenProvided(sourceConfig.promotion.rollbackGrace, `sources.${sourceName}.promotion.rollbackGrace`); }; const validateHttpSearchSourceRequestConfig = (sourceName, sourceConfig) => { if (sourceConfig.request === undefined) { throwSearchConfigError(`The source "${sourceName}.request" property is required for http sources.`); } assertObjectWhenProvided(sourceConfig.request, `sources.${sourceName}.request`); assertAllowedStringValue(sourceConfig.request.method, `sources.${sourceName}.request.method`, SUPPORTED_HTTP_REQUEST_METHODS); assertStringWhenProvided(sourceConfig.request.url, `sources.${sourceName}.request.url`); assertStringRecordWhenProvided(sourceConfig.request.headers, `sources.${sourceName}.request.headers`); assertStringRecordWhenProvided(sourceConfig.request.query, `sources.${sourceName}.request.query`); if (sourceConfig.request.body !== undefined && !isPlainObject(sourceConfig.request.body)) { throwSearchConfigError(`The "sources.${sourceName}.request.body" property must be an object when provided.`); } if (!getNormalizedOptionalString(sourceConfig.request.method)) { throwSearchConfigError(`The source "${sourceName}.request.method" property must be a non-empty string for http sources.`); } if (!getNormalizedOptionalString(sourceConfig.request.url)) { throwSearchConfigError(`The source "${sourceName}.request.url" property must be a non-empty string for http sources.`); } if (getNormalizedOptionalString(sourceConfig.request.method) === 'get' && sourceConfig.request.body !== undefined) { throwSearchConfigError(`The source "${sourceName}.request.body" property is not supported when request.method is "GET".`); } }; const validateHttpSearchSourceIncrementalConfig = (sourceName, sourceConfig) => { if (sourceConfig.incremental === undefined) { throwSearchConfigError(`The source "${sourceName}.incremental" property is required for http sources.`); } const requestMethod = getNormalizedOptionalString(sourceConfig.request?.method); const syncClass = getNormalizedOptionalString(sourceConfig.syncClass); const incrementalConfig = sourceConfig.incremental; assertObjectWhenProvided(incrementalConfig, `sources.${sourceName}.incremental`); assertAllowedStringValue(incrementalConfig.strategy, `sources.${sourceName}.incremental.strategy`, SUPPORTED_HTTP_INCREMENTAL_STRATEGIES); assertObjectWhenProvided(incrementalConfig.cursor, `sources.${sourceName}.incremental.cursor`); assertObjectWhenProvided(incrementalConfig.cursor?.request, `sources.${sourceName}.incremental.cursor.request`); assertObjectWhenProvided(incrementalConfig.cursor?.response, `sources.${sourceName}.incremental.cursor.response`); assertObjectWhenProvided(incrementalConfig.response, `sources.${sourceName}.incremental.response`); assertObjectWhenProvided(incrementalConfig.mapping, `sources.${sourceName}.incremental.mapping`); assertStringWhenProvided(incrementalConfig.cursor?.request?.bodyPath, `sources.${sourceName}.incremental.cursor.request.bodyPath`); assertStringWhenProvided(incrementalConfig.cursor?.request?.headerName, `sources.${sourceName}.incremental.cursor.request.headerName`); assertStringWhenProvided(incrementalConfig.cursor?.request?.queryParam, `sources.${sourceName}.incremental.cursor.request.queryParam`); assertStringWhenProvided(incrementalConfig.cursor?.response?.hasMorePath, `sources.${sourceName}.incremental.cursor.response.hasMorePath`); assertStringWhenProvided(incrementalConfig.cursor?.response?.nextCursorPath, `sources.${sourceName}.incremental.cursor.response.nextCursorPath`); assertStringWhenProvided(incrementalConfig.response?.itemsPath, `sources.${sourceName}.incremental.response.itemsPath`); assertStringWhenProvided(incrementalConfig.mapping?.afterPath, `sources.${sourceName}.incremental.mapping.afterPath`); assertStringWhenProvided(incrementalConfig.mapping?.beforePath, `sources.${sourceName}.incremental.mapping.beforePath`); assertStringWhenProvided(incrementalConfig.mapping?.deletedPath, `sources.${sourceName}.incremental.mapping.deletedPath`); assertStringWhenProvided(incrementalConfig.mapping?.idPath, `sources.${sourceName}.incremental.mapping.idPath`); assertStringWhenProvided(incrementalConfig.mapping?.versionPath, `sources.${sourceName}.incremental.mapping.versionPath`); for (const unsupportedIncrementalProperty of ['batchSize', 'deleteStrategy', 'deletedColumn', 'idColumn', 'sql', 'versionColumn']) { if (incrementalConfig[unsupportedIncrementalProperty] !== undefined) { throwSearchConfigError(`The source "${sourceName}.incremental.${unsupportedIncrementalProperty}" property is not supported for http sources.`); } } if (!getNormalizedOptionalString(incrementalConfig.strategy)) { throwSearchConfigError(`The source "${sourceName}.incremental.strategy" property must be a non-empty string for http sources.`); } const cursorTransportCount = [incrementalConfig.cursor?.request?.queryParam, incrementalConfig.cursor?.request?.headerName, incrementalConfig.cursor?.request?.bodyPath].filter(value => getNormalizedOptionalString(value) !== null).length; if (cursorTransportCount !== 1) { throwSearchConfigError(`The source "${sourceName}.incremental.cursor.request" property must define exactly one cursor transport via queryParam, headerName, or bodyPath.`); } if (requestMethod === 'get' && getNormalizedOptionalString(incrementalConfig.cursor?.request?.bodyPath) !== null) { throwSearchConfigError(`The source "${sourceName}.incremental.cursor.request.bodyPath" property is not supported when request.method is "GET".`); } for (const requiredPropertyPath of [['incremental.cursor.response.nextCursorPath', incrementalConfig.cursor?.response?.nextCursorPath], ['incremental.response.itemsPath', incrementalConfig.response?.itemsPath], ['incremental.mapping.afterPath', incrementalConfig.mapping?.afterPath], ['incremental.mapping.idPath', incrementalConfig.mapping?.idPath], ['incremental.mapping.versionPath', incrementalConfig.mapping?.versionPath]]) { if (!getNormalizedOptionalString(requiredPropertyPath[1])) { throwSearchConfigError(`The source "${sourceName}.${requiredPropertyPath[0]}" property must be a non-empty string for http sources.`); } } if (syncClass === 'append-only') { if (incrementalConfig.mapping?.beforePath !== undefined) { throwSearchConfigError(`The source "${sourceName}.incremental.mapping.beforePath" property is not supported when syncClass is "append-only".`); } if (incrementalConfig.mapping?.deletedPath !== undefined) { throwSearchConfigError(`The source "${sourceName}.incremental.mapping.deletedPath" property is not supported when syncClass is "append-only".`); } } }; export const validateSearchSourceConfig = (sourceName, sourceConfig) => { if (!isPlainObject(sourceConfig)) { throwSearchConfigError(`The source "${sourceName}" must be an object.`); } const allowedKeys = ['collection', 'connectionSecret', 'description', 'driver', 'enabled', 'eventarc', 'incremental', 'indexes', 'mapper', 'promotion', 'request', 'routeTemplate', 'schedule', 'snapshot', 'syncClass', 'type']; const invalidKeys = Object.keys(sourceConfig).filter(key => !allowedKeys.includes(key)); if (invalidKeys.length > 0) { throwSearchConfigError(`Unsupported properties found on source "${sourceName}": ${invalidKeys.join(', ')}.`); } if (!isString(sourceConfig.type) || sourceConfig.type.trim().length === 0) { throwSearchConfigError(`The source "${sourceName}.type" property must be a non-empty string.`); } const sourceType = sourceConfig.type.trim().toLowerCase(); if (!SUPPORTED_SEARCH_SOURCE_TYPES.includes(sourceType)) { throwSearchConfigError(`The source "${sourceName}.type" property must be one of: ${SUPPORTED_SEARCH_SOURCE_TYPES.join(', ')}.`); } if (!isString(sourceConfig.syncClass) || sourceConfig.syncClass.trim().length === 0) { throwSearchConfigError(`The source "${sourceName}.syncClass" property must be a non-empty string.`); } const syncClass = sourceConfig.syncClass.trim().toLowerCase(); if (!SUPPORTED_SEARCH_SOURCE_SYNC_CLASSES.includes(syncClass)) { throwSearchConfigError(`The source "${sourceName}.syncClass" property must be one of: ${SUPPORTED_SEARCH_SOURCE_SYNC_CLASSES.join(', ')}.`); } if (sourceConfig.enabled !== undefined && !isBoolean(sourceConfig.enabled)) { throwSearchConfigError(`The source "${sourceName}.enabled" property must be a boolean when provided.`); } assertStringWhenProvided(sourceConfig.connectionSecret, `sources.${sourceName}.connectionSecret`); assertStringWhenProvided(sourceConfig.description, `sources.${sourceName}.description`); assertStringArrayWhenProvided(sourceConfig.indexes, `sources.${sourceName}.indexes`); assertStringWhenProvided(sourceConfig.mapper, `sources.${sourceName}.mapper`); assertStringWhenProvided(sourceConfig.routeTemplate, `sources.${sourceName}.routeTemplate`); assertStringWhenProvided(sourceConfig.schedule, `sources.${sourceName}.schedule`); if (sourceType === 'firestore') { if (!isString(sourceConfig.collection) || sourceConfig.collection.trim().length === 0) { throwSearchConfigError(`The source "${sourceName}.collection" property must be a non-empty string for firestore sources.`); } if (syncClass !== 'delta-merge') { throwSearchConfigError(`The source "${sourceName}" must use syncClass "delta-merge" when type is "firestore".`); } if (sourceConfig.schedule !== undefined) { throwSearchConfigError(`The source "${sourceName}.schedule" property is not supported for firestore sources.`); } if (sourceConfig.connectionSecret !== undefined || sourceConfig.driver !== undefined || sourceConfig.request !== undefined) { throwSearchConfigError(`The source "${sourceName}" cannot define remote access settings when type is "firestore".`); } if (sourceConfig.incremental !== undefined || sourceConfig.snapshot !== undefined) { throwSearchConfigError(`The source "${sourceName}" cannot define incremental or snapshot settings when type is "firestore".`); } validateSearchSourceEventarcConfig(sourceName, sourceConfig); return; } if (sourceType === 'sql') { if (!isString(sourceConfig.driver) || sourceConfig.driver.trim().length === 0) { throwSearchConfigError(`The source "${sourceName}.driver" property must be a non-empty string for sql sources.`); } if (!isString(sourceConfig.connectionSecret) || sourceConfig.connectionSecret.trim().length === 0) { throwSearchConfigError(`The source "${sourceName}.connectionSecret" property must be a non-empty string for sql sources.`); } if (!isString(sourceConfig.schedule) || sourceConfig.schedule.trim().length === 0) { throwSearchConfigError(`The source "${sourceName}.schedule" property must be a non-empty string for sql sources.`); } if (sourceConfig.collection !== undefined || sourceConfig.eventarc !== undefined || sourceConfig.request !== undefined) { throwSearchConfigError(`The source "${sourceName}" cannot define firestore-only or http-only settings when type is "sql".`); } validateSqlSearchSourceIncrementalConfig(sourceName, sourceConfig); validateSearchSourceSnapshotConfig(sourceName, sourceConfig); validateSearchSourcePromotionConfig(sourceName, sourceConfig); if (syncClass === 'snapshot-replace') { if (sourceConfig.incremental !== undefined) { throwSearchConfigError(`The source "${sourceName}" cannot define incremental settings when syncClass is "snapshot-replace".`); } if (sourceConfig.snapshot === undefined) { throwSearchConfigError(`The source "${sourceName}.snapshot" property is required when syncClass is "snapshot-replace".`); } return; } if (sourceConfig.snapshot !== undefined) { throwSearchConfigError(`The source "${sourceName}" cannot define snapshot settings unless syncClass is "snapshot-replace".`); } if (sourceConfig.incremental === undefined) { throwSearchConfigError(`The source "${sourceName}.incremental" property is required for sql sources unless syncClass is "snapshot-replace".`); } const cursorConfig = isPlainObject(sourceConfig.incremental.cursor) ? sourceConfig.incremental.cursor : sourceConfig.incremental; const deleteStrategy = getNormalizedOptionalString(sourceConfig.incremental.deleteStrategy) ?? 'none'; const incrementalStrategy = getNormalizedOptionalString(sourceConfig.incremental.strategy) ?? null; const deletedColumn = isString(sourceConfig.incremental.deletedColumn) && sourceConfig.incremental.deletedColumn.trim().length > 0 ? sourceConfig.incremental.deletedColumn.trim() : null; if (!isString(cursorConfig.versionColumn) || cursorConfig.versionColumn.trim().length === 0) { throwSearchConfigError(`The source "${sourceName}.incremental.cursor.versionColumn" property must be a non-empty string for incremental sql sources.`); } if (!isString(cursorConfig.idColumn) || cursorConfig.idColumn.trim().length === 0) { throwSearchConfigError(`The source "${sourceName}.incremental.cursor.idColumn" property must be a non-empty string for incremental sql sources.`); } const sqlQuery = sourceConfig.incremental.sql; const versionAliases = createSqlParameterAliases(cursorConfig.versionColumn, ['version', 'cursor_version']); const idAliases = createSqlParameterAliases(cursorConfig.idColumn, ['id', 'cursor_id']); if (!containsAnySqlPlaceholder(sqlQuery, versionAliases)) { throwSearchConfigError(`The source "${sourceName}.incremental.sql" property must reference the configured version cursor placeholder.`); } if (!containsAnySqlPlaceholder(sqlQuery, idAliases)) { throwSearchConfigError(`The source "${sourceName}.incremental.sql" property must reference the configured id cursor placeholder.`); } if (!containsAnySqlPlaceholder(sqlQuery, ['batchSize', 'batch_size', 'limit'])) { throwSearchConfigError(`The source "${sourceName}.incremental.sql" property must reference a batch-size placeholder such as @limit.`); } if (!SUPPORTED_SQL_INCREMENTAL_STRATEGIES.includes(incrementalStrategy)) { throwSearchConfigError(`The source "${sourceName}.incremental.strategy" property must be one of: ${SUPPORTED_SQL_INCREMENTAL_STRATEGIES.join(', ')}.`); } if (syncClass === 'append-only') { if (sourceConfig.incremental.deleteStrategy !== undefined) { throwSearchConfigError(`The source "${sourceName}.incremental.deleteStrategy" property is not supported when syncClass is "append-only".`); } if (sourceConfig.incremental.deletedColumn !== undefined) { throwSearchConfigError(`The source "${sourceName}.incremental.deletedColumn" property is not supported when syncClass is "append-only".`); } } if (syncClass === 'delta-merge') { if (!SUPPORTED_SQL_INCREMENTAL_DELETE_STRATEGIES.includes(deleteStrategy)) { throwSearchConfigError(`The source "${sourceName}.incremental.deleteStrategy" property must be one of: ${SUPPORTED_SQL_INCREMENTAL_DELETE_STRATEGIES.join(', ')}.`); } if (deleteStrategy === 'soft-delete' && deletedColumn === null) { throwSearchConfigError(`The source "${sourceName}.incremental.deletedColumn" property is required when deleteStrategy is "soft-delete".`); } } if (sourceConfig.promotion !== undefined) { throwSearchConfigError(`The source "${sourceName}.promotion" property is only supported when syncClass is "snapshot-replace".`); } return; } if (!isString(sourceConfig.schedule) || sourceConfig.schedule.trim().length === 0) { throwSearchConfigError(`The source "${sourceName}.schedule" property must be a non-empty string for http sources.`); } if (syncClass !== 'delta-merge' && syncClass !== 'append-only') { throwSearchConfigError(`The source "${sourceName}" must use syncClass "delta-merge" or "append-only" when type is "http".`); } if (sourceConfig.collection !== undefined || sourceConfig.driver !== undefined || sourceConfig.eventarc !== undefined || sourceConfig.snapshot !== undefined || sourceConfig.promotion !== undefined) { throwSearchConfigError(`The source "${sourceName}" cannot define firestore-only, sql-only, or snapshot-only settings when type is "http".`); } validateHttpSearchSourceRequestConfig(sourceName, sourceConfig); validateHttpSearchSourceIncrementalConfig(sourceName, sourceConfig); }; export default { resolveProjectScopedSearchSourceConfig, SUPPORTED_PROJECT_SCOPED_SEARCH_SOURCE_TYPES, SUPPORTED_SEARCH_SOURCE_SYNC_CLASSES, SUPPORTED_SEARCH_SOURCE_TYPES, validateSearchSourceBindingConfig, validateSearchSourceDefinitionConfig, validateSearchSourceConfig };