import type { RipplingSDK } from '../../../client'; import type { CustomObject, CustomObjectCreateParams, CustomObjectUpdateParams, } from '../../../resources/custom-objects/custom-objects'; import type { CustomObjectField, CustomObjectFieldDataType, CustomObjectNameFieldDetails, FieldCreateParams, FieldUpdateParams, } from '../../../resources/custom-objects/fields'; import type { AnyCustomObjectDefinition, AnyFieldDefinition, FieldNames } from './types'; import type { CustomObjectCategoryDefinition } from './category'; import { provisionCustomObjectCategoryDefinition } from './category'; /** * Provisioned custom-object summary returned by the SDK provisioning helpers. * * @typeParam TDefinition - Typed custom-object definition that was provisioned. */ export interface ProvisionedCustomObject< TDefinition extends AnyCustomObjectDefinition = AnyCustomObjectDefinition, > { /** Logical object definition used as the source of truth. */ readonly definition: TDefinition; /** Wire-level api name created on the backend. */ readonly customObjectApiName: TDefinition['apiName']; /** Backend response returned by `client.customObjects.create(...)`. */ readonly createdObject: CustomObject; /** Backend response returned by `client.customObjects.update(...)`. */ readonly updatedObject: CustomObject; /** Optional response returned when the helper updates the platform-managed `name` field. */ readonly updatedNameField?: CustomObjectField; /** Backend responses returned by field creation calls, keyed by logical field name. */ readonly createdFields: Readonly, CustomObjectField>>; /** Backend responses returned by field update calls, keyed by logical field name. */ readonly updatedFields: Readonly, CustomObjectField>>; } /** * Options for provisioning a single typed custom-object definition through the REST SDK. * * Assumptions baked into this helper: * - custom-object `create` derives the immutable `api_name` from the submitted logical name * - field `create` derives the immutable `api_name` from the submitted logical field name * - follow-up `update` calls can safely set human-facing display labels without changing api names */ export interface ProvisionCustomObjectOptions { /** * Optional typed category definition to create or update before provisioning the object. */ readonly categoryDefinition?: CustomObjectCategoryDefinition; /** * Resolved custom-category id or native category id used by the live REST object APIs. * * Supply this when the definition does not embed a typed category definition. */ readonly categoryIdentifier?: string; /** * Whether to issue the follow-up object update that applies the human-facing display name. * * Defaults to `true`. */ readonly updateObjectDisplayMetadata?: boolean; /** * Whether to issue the follow-up field updates that apply human-facing display names. * * Defaults to `true`. */ readonly updateFieldDisplayMetadata?: boolean; /** * Whether to update the platform-managed `name` field when the object definition uses autonumber metadata. * * Defaults to `true`. */ readonly updateNameFieldDetails?: boolean; } /** Options for provisioning multiple custom-object definitions in one batch. */ export interface ProvisionCustomObjectsOptions extends Omit { /** Optional typed categories to ensure before creating the objects in this batch. */ readonly categoryDefinitions?: readonly CustomObjectCategoryDefinition[]; /** * Resolved category ids keyed by object api name for definitions that do not embed a typed category definition. */ readonly categoryIdentifiersByObjectApiName?: Readonly>; } /** * Normalized field payload returned by the field-param builder helpers. */ export interface ProvisionableFieldParams { /** Payload passed to `client.customObjects.fields.create(...)`. */ readonly create: FieldCreateParams; /** Payload passed to `client.customObjects.fields.update(...)`. */ readonly update: FieldUpdateParams; } /** * Returns whether one field references another object and therefore benefits from being created after scalar fields. * * @param field - Typed field definition to inspect. * @returns Whether the field is a lookup or parent-child relationship. */ function isRelationshipField(field: AnyFieldDefinition): boolean { return field.kind === 'lookup' || field.kind === 'parent-child'; } /** * Returns whether one field uses a formula-field variant. * * @param field - Typed field definition to inspect. * @returns Whether the field is one of the `formula-*` kinds. */ function isFormulaField( field: AnyFieldDefinition, ): field is Extract { return field.kind.startsWith('formula-'); } /** * Builds the wire-level `data_type` payload expected by the custom-object field REST API. * * @param field - Typed field definition to compile. * @returns REST `data_type` payload for the supplied field definition. */ function buildFieldDataType(field: AnyFieldDefinition): CustomObjectFieldDataType | Record { switch (field.kind) { case 'text': return { field_type: 'TEXT', ...(field.maxLength != null ? { max_length: field.maxLength } : {}), }; case 'long-text': return { field_type: 'LONG_TEXT', ...(field.maxLength != null ? { max_length: field.maxLength } : {}), }; case 'email': return { field_type: 'EMAIL' }; case 'url': return { field_type: 'URL' }; case 'phone': return { field_type: 'PHONE_NUMBER' }; case 'number': return { field_type: 'NUMBER', ...(field.decimalPlaces != null ? { num_of_decimal_places: field.decimalPlaces } : {}), }; case 'currency': return { field_type: 'CURRENCY', ...(field.currency != null ? { currency: field.currency } : {}), ...(field.decimalPlaces != null ? { num_of_decimal_places: field.decimalPlaces } : {}), }; case 'percentage': return { field_type: 'PERCENTAGE', num_of_decimal_places: 2 }; case 'date': return { field_type: 'DATE' }; case 'datetime': return { field_type: 'DATETIME' }; case 'time': return { field_type: 'TIME' }; case 'select': return { field_type: 'SELECT', options_list: [...field.options] }; case 'radio': return { field_type: 'RADIO', options_list: [...field.options] }; case 'boolean': return { field_type: 'BOOLEAN' }; case 'file': return { field_type: 'FILE', supported_types: field.allowedType === 'any' ? ['*'] : [field.allowedType], }; case 'lookup': return { field_type: 'OG_REFERENCE_FIELD', og_model_rql_name: field.target.apiName, edge_type: 'LOOKUP', ...(field.relatedDataLabel != null ? { related_data_label: field.relatedDataLabel } : {}), ...(field.lookupFilterRqlCondition != null ? { lookup_filter_rql_condition: field.lookupFilterRqlCondition } : {}), }; case 'parent-child': return { field_type: 'OG_REFERENCE_FIELD', og_model_rql_name: field.target.apiName, edge_type: 'PARENT_CHILD', ...(field.relatedDataLabel != null ? { related_data_label: field.relatedDataLabel } : {}), ...(field.parentChildType != null ? { parent_child_type: field.parentChildType } : {}), ...(field.enableParentOwnerInheritance != null ? { enable_parent_owner_inheritance: field.enableParentOwnerInheritance } : {}), }; case 'formula-text': return { field_type: 'TEXT', ...(field.maxLength != null ? { max_length: field.maxLength } : {}) }; case 'formula-number': return { field_type: 'NUMBER' }; case 'formula-boolean': return { field_type: 'BOOLEAN' }; case 'formula-currency': return { field_type: 'CURRENCY' }; case 'formula-percentage': return { field_type: 'PERCENTAGE' }; case 'formula-date': return { field_type: 'DATE' }; case 'formula-url': return { field_type: 'URL' }; } } /** * Builds REST section metadata for one typed field definition. * * @param definition - Object definition that owns the field and section map. * @param field - Typed field definition whose section metadata should be compiled. * @returns Concrete section id string expected by the live REST API, when available. * @throws Error when REST provisioning would require a section id that cannot be resolved. * @typeParam TDefinition - Object definition that owns the field. */ function buildFieldSection( definition: TDefinition, field: AnyFieldDefinition, ): FieldCreateParams['section'] { const sectionKey = field.section; const sectionOverride = field.restSection; const overrideSectionId = typeof sectionOverride === 'string' ? sectionOverride : sectionOverride?.section_id; if (sectionKey == null) { if (sectionOverride == null) return undefined; if (overrideSectionId == null) { throw new Error('REST field provisioning currently requires a concrete section id string.'); } return overrideSectionId; } const sections = definition.sections as Readonly< Record >; const sectionDefinition = sections[sectionKey]; if (sectionDefinition == null) return overrideSectionId; const resolvedSectionId = overrideSectionId ?? sectionDefinition.sectionId; if (resolvedSectionId == null) { throw new Error( `Field section "${sectionKey}" for "${definition.name}" requires a sectionId for REST provisioning.`, ); } return resolvedSectionId; } /** * Builds name-field metadata for REST field updates when the object uses autonumber names. * * @param definition - Typed object definition whose name-field metadata should be compiled. * @returns Name-field metadata payload for the platform-managed `name` field, when needed. * @typeParam TDefinition - Object definition being compiled. */ export function buildCustomObjectNameFieldDetails( definition: TDefinition, ): CustomObjectNameFieldDetails | undefined { if (definition.nameField.kind !== 'autonumber') return undefined; return { is_autonumber_field: true, autonumber_format: definition.nameField.format, ...(definition.nameField.startingNumber != null ? { starting_number: definition.nameField.startingNumber } : {}), }; } /** * Builds the object-creation payload for the low-level REST API using a resolved category id. * * @param definition - Typed object definition to compile. * @param categoryIdentifier - Resolved custom-category id or native category id required by the live API. * @returns REST object-create payload. * @typeParam TDefinition - Object definition being compiled. */ export function buildCustomObjectCreateParams( definition: TDefinition, categoryIdentifier: string, ): CustomObjectCreateParams { return { category: categoryIdentifier, description: definition.description, name: definition.name, }; } /** * Builds the object-update payload used to restore human-facing labels after creation. * * @param definition - Typed object definition to compile. * @param categoryIdentifier - Resolved custom-category id or native category id required by the live API. * @returns REST object-update payload. * @typeParam TDefinition - Object definition being compiled. */ export function buildCustomObjectUpdateParams( definition: TDefinition, categoryIdentifier: string, ): CustomObjectUpdateParams { return { category: categoryIdentifier, description: definition.description, ...(definition.enableHistory != null ? { enable_history: definition.enableHistory } : {}), name: definition.displayName, ...(definition.ownerRole != null ? { owner_role: definition.ownerRole } : {}), ...(definition.pluralLabel != null ? { plural_label: definition.pluralLabel } : {}), }; } /** * Builds field create/update payloads for one logical field in a typed object definition. * * @param definition - Typed object definition that owns the field. * @param fieldName - Logical field name to compile. * @returns Frozen create/update payload pair for the field. * @typeParam TDefinition - Object definition being compiled. * @typeParam TFieldName - Logical field name being compiled. */ export function buildProvisionableFieldParams< TDefinition extends AnyCustomObjectDefinition, TFieldName extends FieldNames, >(definition: TDefinition, fieldName: TFieldName): ProvisionableFieldParams { const field = definition.fields[fieldName] as AnyFieldDefinition & { logicalName: string; apiName: string }; const dataType = buildFieldDataType(field); const section = buildFieldSection(definition, field); const shared = { description: field.description, data_type: dataType, required: field.required, ...(field.enableHistory != null ? { enable_history: field.enableHistory } : {}), ...(field.formulaAttributeMetas != null ? { formula_attr_metas: field.formulaAttributeMetas } : {}), ...(field.rqlDefinition != null ? { rql_definition: field.rqlDefinition } : {}), ...(section != null ? { section } : {}), ...(isFormulaField(field) ? { derived_field_formula: field.formula } : {}), ...('unique' in field && field.unique != null ? { is_unique: field.unique } : {}), }; const create: FieldCreateParams = { name: field.logicalName, ...shared, ...(field.derivedAggregatedField != null ? { derived_aggregated_field: field.derivedAggregatedField } : {}), }; // The live GA field update path currently does not accept `derived_aggregated_field`, so keep it create-only. const update: FieldUpdateParams = { name: field.displayName, ...shared, }; return Object.freeze({ create, update }); } /** * Requires a resolved category identifier for one provisioning operation. * * @param definition - Object definition that needs a category identifier. * @param categoryIdentifier - Resolved custom-category id or native category id, if available. * @returns Required category identifier. * @throws Error when no usable category identifier is available. * @typeParam TDefinition - Object definition being provisioned. */ function requireCategoryIdentifier( definition: TDefinition, categoryIdentifier: string | undefined, ): string { if (categoryIdentifier != null) return categoryIdentifier; throw new Error( `A category identifier is required to provision "${definition.apiName}". ` + `Provide options.categoryIdentifier or embed/provide a typed category definition.`, ); } /** * Rejects duplicate object definitions within one batch provisioning request. * * @param definitions - Typed object definitions to validate for uniqueness. * @throws Error when multiple definitions compile to the same object API name. */ function assertUniqueObjectDefinitions(definitions: readonly AnyCustomObjectDefinition[]): void { const seen = new Map(); for (const definition of definitions) { const existingName = seen.get(definition.apiName); if (existingName != null) { throw new Error( `Duplicate custom object definition for "${definition.apiName}" in batch: ` + `"${existingName}" and "${definition.name}".`, ); } seen.set(definition.apiName, definition.name); } } /** * Collects and deduplicates embedded or explicitly supplied category definitions for a batch provision. * * @param definitions - Object definitions participating in the batch. * @param explicitDefinitions - Optional explicit category definitions supplied by the caller. * @returns Frozen deduplicated category-definition list. * @throws Error when two definitions for the same category API name disagree on visible metadata. */ function collectCategoryDefinitions( definitions: readonly AnyCustomObjectDefinition[], explicitDefinitions: readonly CustomObjectCategoryDefinition[] | undefined, ): readonly CustomObjectCategoryDefinition[] { const categoryDefinitions = explicitDefinitions ?? definitions.map((definition) => definition.categoryDefinition); const uniqueDefinitions = new Map(); for (const definition of categoryDefinitions) { if (definition == null) continue; const existing = uniqueDefinitions.get(definition.apiName); if (existing != null) { if ( existing.displayName !== definition.displayName || existing.description !== definition.description ) { throw new Error( `Conflicting category definitions for "${definition.apiName}" in batch provisioning.`, ); } continue; } uniqueDefinitions.set(definition.apiName, definition); } return Object.freeze([...uniqueDefinitions.values()]); } /** * Resolves the category identifier that should be sent to the live object APIs during provisioning. * * @param client - SDK client used to provision embedded category definitions when needed. * @param definition - Typed object definition being provisioned. * @param options - Provisioning options that may already contain a category identifier. * @returns Resolved custom-category id or native category id. * @typeParam TDefinition - Object definition being provisioned. */ async function resolveProvisionedCategoryIdentifier( client: RipplingSDK, definition: TDefinition, options: ProvisionCustomObjectOptions, ): Promise { const categoryDefinition = options.categoryDefinition ?? definition.categoryDefinition; if (categoryDefinition == null) { return requireCategoryIdentifier(definition, options.categoryIdentifier); } const provisionedCategory = await provisionCustomObjectCategoryDefinition(client, categoryDefinition); return provisionedCategory.updatedCategory.id; } /** * Updates the platform-managed `name` field when the typed definition uses autonumber metadata. * * @param client - SDK client used to call the field APIs. * @param definition - Typed object definition whose name-field metadata should be applied. * @param options - Provisioning behavior flags. * @returns Updated `name` field DTO, when a name-field update was required. * @typeParam TDefinition - Object definition being provisioned. */ async function updateNameFieldDetailsIfNeeded( client: RipplingSDK, definition: TDefinition, options: ProvisionCustomObjectOptions, ): Promise { if (!(options.updateNameFieldDetails ?? true)) return undefined; const nameFieldDetails = buildCustomObjectNameFieldDetails(definition); if (nameFieldDetails == null) return undefined; return client.customObjects.fields.update(definition.apiName, 'name', { name_field_details: nameFieldDetails, }); } /** * Provisions every custom field for one object definition in relationship-safe order. * * @param client - SDK client used to call the field APIs. * @param definition - Typed object definition whose fields should be provisioned. * @param updateFieldDisplayMetadata - Whether to issue follow-up field updates for display metadata. * @returns Frozen maps of created and updated field DTOs keyed by logical field name. * @typeParam TDefinition - Object definition being provisioned. */ async function provisionFieldsForDefinition( client: RipplingSDK, definition: TDefinition, updateFieldDisplayMetadata: boolean, ): Promise<{ readonly createdFields: Readonly, CustomObjectField>>; readonly updatedFields: Readonly, CustomObjectField>>; }> { const createdFields: Partial, CustomObjectField>> = {}; const updatedFields: Partial, CustomObjectField>> = {}; const orderedFieldNames = Object.keys(definition.fields).sort((left, right) => { const leftField = definition.fields[left as FieldNames] as AnyFieldDefinition; const rightField = definition.fields[right as FieldNames] as AnyFieldDefinition; return Number(isRelationshipField(leftField)) - Number(isRelationshipField(rightField)); }) as FieldNames[]; for (const fieldName of orderedFieldNames) { const field = definition.fields[fieldName] as AnyFieldDefinition & { apiName: string }; const params = buildProvisionableFieldParams(definition, fieldName); const createdField = await client.customObjects.fields.create(definition.apiName, params.create); if (createdField.api_name !== field.apiName) { throw new Error( `Field api_name mismatch for "${definition.name}.${String(fieldName)}": expected "${ field.apiName }", got "${createdField.api_name}".`, ); } createdFields[fieldName] = createdField; updatedFields[fieldName] = updateFieldDisplayMetadata ? await client.customObjects.fields.update(definition.apiName, field.apiName, params.update) : createdField; } return Object.freeze({ createdFields: Object.freeze(createdFields) as Readonly< Record, CustomObjectField> >, updatedFields: Object.freeze(updatedFields) as Readonly< Record, CustomObjectField> >, }); } /** * Creates one typed custom object through the REST SDK and returns the backend responses. * * @param client - SDK client used to call the metadata APIs. * @param definition - Typed object definition to provision. * @param options - Provisioning behavior flags for categories, display metadata, and name fields. * @returns Frozen provisioning result describing the created object, fields, and optional name-field update. * @typeParam TDefinition - Object definition being provisioned. */ export async function provisionCustomObjectDefinition( client: RipplingSDK, definition: TDefinition, options: ProvisionCustomObjectOptions = {}, ): Promise> { const updateObjectDisplayMetadata = options.updateObjectDisplayMetadata ?? true; const updateFieldDisplayMetadata = options.updateFieldDisplayMetadata ?? true; const categoryIdentifier = await resolveProvisionedCategoryIdentifier(client, definition, options); const createdObject = await client.customObjects.create( buildCustomObjectCreateParams(definition, categoryIdentifier), ); if (createdObject.api_name !== definition.apiName) { throw new Error( `Custom object api_name mismatch for "${definition.name}": expected "${definition.apiName}", got "${ createdObject.api_name ?? '' }".`, ); } const updatedObject = updateObjectDisplayMetadata ? await client.customObjects.update( definition.apiName, buildCustomObjectUpdateParams(definition, categoryIdentifier), ) : createdObject; const updatedNameField = await updateNameFieldDetailsIfNeeded(client, definition, options); const fieldResults = await provisionFieldsForDefinition(client, definition, updateFieldDisplayMetadata); return Object.freeze({ definition, customObjectApiName: definition.apiName, createdObject, updatedObject, ...(updatedNameField != null ? { updatedNameField } : {}), createdFields: fieldResults.createdFields, updatedFields: fieldResults.updatedFields, }); } /** * Provisions multiple typed custom-object definitions through the REST SDK. * * @param client - SDK client used to call the metadata APIs. * @param definitions - Typed object definitions to provision. * @param options - Batch provisioning behavior flags. * @returns Frozen provisioning results in input order. */ export async function provisionCustomObjectDefinitions( client: RipplingSDK, definitions: readonly AnyCustomObjectDefinition[], options: ProvisionCustomObjectsOptions = {}, ): Promise> { assertUniqueObjectDefinitions(definitions); const categoryDefinitions = collectCategoryDefinitions(definitions, options.categoryDefinitions); const provisionedCategoryIds = new Map(); for (const categoryDefinition of categoryDefinitions) { const provisionedCategory = await provisionCustomObjectCategoryDefinition(client, categoryDefinition); provisionedCategoryIds.set(categoryDefinition.apiName, provisionedCategory.updatedCategory.id); } const objectResults = new Map(); const updatedNameFields = new Map(); for (const definition of definitions) { const categoryIdentifier = definition.categoryDefinition != null ? provisionedCategoryIds.get(definition.categoryDefinition.apiName) : options.categoryIdentifiersByObjectApiName?.[definition.apiName]; const resolvedCategoryIdentifier = requireCategoryIdentifier(definition, categoryIdentifier); const createdObject = await client.customObjects.create( buildCustomObjectCreateParams(definition, resolvedCategoryIdentifier), ); if (createdObject.api_name !== definition.apiName) { throw new Error( `Custom object api_name mismatch for "${definition.name}": expected "${definition.apiName}", got "${ createdObject.api_name ?? '' }".`, ); } const updatedObject = options.updateObjectDisplayMetadata ?? true ? await client.customObjects.update( definition.apiName, buildCustomObjectUpdateParams(definition, resolvedCategoryIdentifier), ) : createdObject; objectResults.set(definition.apiName, Object.freeze({ created: createdObject, updated: updatedObject })); const updatedNameField = await updateNameFieldDetailsIfNeeded(client, definition, options); if (updatedNameField != null) updatedNameFields.set(definition.apiName, updatedNameField); } const results: ProvisionedCustomObject[] = []; for (const definition of definitions) { const objectResult = objectResults.get(definition.apiName); if (objectResult == null) { throw new Error(`Missing created object response for "${definition.apiName}".`); } const fieldResults = await provisionFieldsForDefinition( client, definition, options.updateFieldDisplayMetadata ?? true, ); const updatedNameField = updatedNameFields.get(definition.apiName); results.push( Object.freeze({ definition, customObjectApiName: definition.apiName, createdObject: objectResult.created, updatedObject: objectResult.updated, ...(updatedNameField != null ? { updatedNameField } : {}), createdFields: fieldResults.createdFields, updatedFields: fieldResults.updatedFields, }), ); } return Object.freeze(results); }