import type { RipplingSDK } from '../../../client'; import type { CustomObject } from '../../../resources/custom-objects/custom-objects'; import type { CustomObjectField } from '../../../resources/custom-objects/fields'; import type { AnyCustomObjectDefinition, AnyFieldDefinition, FieldNames } from './types'; import type { CustomObjectCategoryDefinition, ReconciledCustomObjectCategory } from './category'; import { reconcileCustomObjectCategoryDefinition } from './category'; import { buildCustomObjectNameFieldDetails, buildCustomObjectUpdateParams, buildProvisionableFieldParams, } from './provisioning'; /** Lifecycle action emitted by typed metadata reconciliation helpers. */ export type CustomObjectLifecycleAction = 'created' | 'updated' | 'deleted' | 'unchanged'; /** Reconciliation result for one logical custom-object field. */ export interface ReconciledCustomObjectField< TDefinition extends AnyCustomObjectDefinition = AnyCustomObjectDefinition, > { /** Source typed object definition. */ readonly definition: TDefinition; /** Logical field name from the typed definition. */ readonly fieldName: FieldNames; /** Action applied to the field. */ readonly action: CustomObjectLifecycleAction; /** Remote field after reconciliation, when present. */ readonly field?: CustomObjectField; } /** Result returned by custom-object reconciliation helpers. */ export interface ReconciledCustomObject< TDefinition extends AnyCustomObjectDefinition = AnyCustomObjectDefinition, > { /** Source typed object definition. */ readonly definition: TDefinition; /** Optional category reconciliation result, when a category definition was supplied. */ readonly category?: ReconciledCustomObjectCategory; /** Whether the object metadata was created, updated, or already matched. */ readonly objectAction: Exclude; /** Remote object after reconciliation. */ readonly object: CustomObject; /** Whether the platform-managed name field required an update. */ readonly nameFieldAction?: Exclude; /** Remote `name` field after applying `name_field_details`, when relevant. */ readonly nameField?: CustomObjectField; /** Per-field reconciliation results keyed by logical field name. */ readonly fields: Readonly, ReconciledCustomObjectField>>; /** Deleted extra remote fields keyed by api name. */ readonly deletedFieldApiNames: readonly string[]; } /** Options for reconciling one typed custom-object definition against the backend. */ export interface ReconcileCustomObjectDefinitionOptions { /** Optional typed category definition to reconcile before the object. */ readonly categoryDefinition?: CustomObjectCategoryDefinition; /** Resolved category id used by the live REST object APIs. */ readonly categoryIdentifier?: string; /** Whether to delete non-standard remote fields that are absent from the typed definition. */ readonly deleteMissingFields?: boolean; /** Whether to update the platform-managed `name` field when using autonumber metadata. Defaults to `true`. */ readonly updateNameFieldDetails?: boolean; } /** Options for reconciling multiple typed custom-object definitions in one batch. */ export interface ReconcileCustomObjectDefinitionsOptions extends Omit { /** Optional typed categories to reconcile before object reconciliation. */ readonly categoryDefinitions?: readonly CustomObjectCategoryDefinition[]; /** Resolved category ids keyed by object api name for definitions without embedded category definitions. */ readonly categoryIdentifiersByObjectApiName?: Readonly>; } /** * Returns whether one field should be treated as an inter-object relationship during reconciliation ordering. * * @param field - Typed field definition to inspect. * @returns Whether the field references another object. */ function isRelationshipField(field: AnyFieldDefinition): boolean { return field.kind === 'lookup' || field.kind === 'parent-child'; } /** * Compares two metadata payload fragments structurally using JSON serialization. * * @param left - First value to compare. * @param right - Second value to compare. * @returns Whether the two values serialize to the same JSON representation. */ function valuesEqual(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } /** * Returns the field name the backend should currently expose for comparison purposes. * * @param field - Typed field definition being reconciled. * @param useDisplayMetadata - Whether reconciliation should compare the display label rather than the logical name. * @returns Expected backend-visible field name. */ function expectedFieldName(field: AnyFieldDefinition, useDisplayMetadata: boolean): string { return useDisplayMetadata ? field.displayName : field.logicalName ?? field.displayName; } /** * Returns the runtime-only `required` value when the active API profile happens to expose it. * * @param existing - Public field DTO returned by the backend. * @returns Exposed required flag when present on the DTO. */ function existingFieldRequiredValue(existing: CustomObjectField): boolean | undefined { const value = (existing as unknown as Record)['required']; return typeof value === 'boolean' ? value : undefined; } /** * Returns true when the desired field metadata is accepted on write but not exposed by the public read DTO. * * In those cases reconciliation cannot prove equality from `list`/`retrieve`, so the safest current-API * behavior is to reapply the write-time metadata instead of treating the field as unchanged. */ /** * Returns whether reconciliation must blindly reapply write-only field metadata. * * @param expected - Precompiled field create/update payloads derived from the definition. * @returns Whether the public read DTO lacks enough information to prove equality. */ function fieldRequiresBlindMetadataUpdate( expected: ReturnType, ): boolean { const update = expected.update as Record; return ( update['section'] != null || update['rql_definition'] != null || update['formula_attr_metas'] != null || update['derived_field_formula'] != null ); } /** * Returns whether one public field DTO matches the currently observable subset of the typed field definition. * * @param existing - Public field DTO returned by the backend. * @param field - Typed field definition being reconciled. * @param expected - Precompiled field create/update payloads derived from the definition. * @param useDisplayMetadata - Whether to compare display metadata or raw logical naming. * @returns Whether the read-visible remote field already matches the definition. */ function remoteFieldMatchesDefinition( existing: CustomObjectField, field: AnyFieldDefinition & { logicalName: string }, expected: ReturnType, useDisplayMetadata: boolean, ): boolean { if (fieldRequiresBlindMetadataUpdate(expected)) return false; const uniqueMatches = expected.update.is_unique == null ? true : existing.is_unique === expected.update.is_unique; const enableHistoryMatches = expected.update.enable_history == null ? true : existing.enable_history === expected.update.enable_history; const requiredMatches = expected.update.required == null ? true : (existingFieldRequiredValue(existing) ?? expected.update.required) === expected.update.required; return ( existing.name === expectedFieldName(field, useDisplayMetadata) && (existing.description ?? null) === (field.description ?? null) && uniqueMatches && enableHistoryMatches && requiredMatches && valuesEqual(existing.data_type, expected.update.data_type) ); } /** * Retrieves one existing custom object by API name, returning `undefined` when it does not exist. * * @param client - SDK client used to call the object APIs. * @param customObjectApiName - Compiled custom-object API name to retrieve. * @returns Existing custom object DTO, if found. */ async function retrieveExistingObject( client: RipplingSDK, customObjectApiName: string, ): Promise { try { return await client.customObjects.retrieve(customObjectApiName); } catch (error) { if (error instanceof Error && 'status' in error && (error as { status?: unknown }).status === 404) { return undefined; } throw error; } } /** * Lists existing remote fields for one custom object keyed by API name. * * @param client - SDK client used to call the field APIs. * @param customObjectApiName - Compiled custom-object API name whose fields should be listed. * @returns Map of field API names to public field DTOs. */ async function listExistingFields( client: RipplingSDK, customObjectApiName: string, ): Promise> { const results = new Map(); for await (const field of client.customObjects.fields.list(customObjectApiName)) { results.set(field.api_name, field); } return results; } /** * Rejects duplicate object definitions within one batch reconciliation 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 reconcile. * * @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 reconciliation.`, ); } continue; } uniqueDefinitions.set(definition.apiName, definition); } return Object.freeze([...uniqueDefinitions.values()]); } /** * Requires a resolved category identifier for one object reconciliation 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 reconciled. */ function requireCategoryIdentifier( definition: TDefinition, categoryIdentifier: string | undefined, ): string { if (categoryIdentifier != null) return categoryIdentifier; throw new Error( `A category identifier is required to reconcile "${definition.apiName}". ` + `Provide options.categoryIdentifier or embed/provide a typed category definition.`, ); } /** * Ensures the remote custom object exists and matches the currently visible object metadata. * * @param client - SDK client used to call the object APIs. * @param definition - Typed object definition to reconcile. * @param categoryIdentifier - Resolved category id required by the live object APIs. * @returns Object action summary describing whether the object was created, updated, or already matched. * @typeParam TDefinition - Object definition being reconciled. */ async function ensureObjectExists( client: RipplingSDK, definition: TDefinition, categoryIdentifier: string, ): Promise<{ readonly action: 'created' | 'updated' | 'unchanged'; readonly object: CustomObject }> { const existing = await retrieveExistingObject(client, definition.apiName); if (existing == null) { const created = await client.customObjects.create({ category: categoryIdentifier, description: definition.description, name: definition.name, }); const updated = await client.customObjects.update( definition.apiName, buildCustomObjectUpdateParams(definition, categoryIdentifier), ); return { action: 'created', object: updated }; } const shouldUpdate = existing.category_id !== categoryIdentifier || existing.name !== definition.displayName || (existing.description ?? null) !== (definition.description ?? null) || (definition.enableHistory != null && existing.enable_history !== definition.enableHistory) || existing.plural_label !== (definition.pluralLabel ?? existing.plural_label) || (definition.ownerRole != null && existing.owner_id !== definition.ownerRole); if (!shouldUpdate) return { action: 'unchanged', object: existing }; const updated = await client.customObjects.update( definition.apiName, buildCustomObjectUpdateParams(definition, categoryIdentifier), ); return { action: 'updated', object: updated }; } /** * Reapplies platform-managed `name` field metadata when the typed definition uses autonumber settings. * * @param client - SDK client used to call the field APIs. * @param definition - Typed object definition whose name-field metadata should be enforced. * @param options - Reconciliation behavior flags. * @returns Optional update result for the platform-managed `name` field. * @typeParam TDefinition - Object definition being reconciled. */ async function reconcileNameField( client: RipplingSDK, definition: TDefinition, options: ReconcileCustomObjectDefinitionOptions, ): Promise<{ readonly action?: 'updated'; readonly field?: CustomObjectField }> { if (!(options.updateNameFieldDetails ?? true)) return {}; const nameFieldDetails = buildCustomObjectNameFieldDetails(definition); if (nameFieldDetails == null) return {}; const field = await client.customObjects.fields.update(definition.apiName, 'name', { name_field_details: nameFieldDetails, }); return { action: 'updated', field }; } /** * Reconciles all custom fields for one object definition, creating, updating, and optionally deleting fields. * * @param client - SDK client used to call the field APIs. * @param definition - Typed object definition whose fields should be reconciled. * @param options - Reconciliation behavior flags. * @returns Reconciled field results plus deleted extra remote field API names. * @typeParam TDefinition - Object definition being reconciled. */ async function reconcileFields( client: RipplingSDK, definition: TDefinition, options: ReconcileCustomObjectDefinitionOptions, ): Promise<{ readonly fields: Readonly, ReconciledCustomObjectField>>; readonly deletedFieldApiNames: readonly string[]; }> { const existingByApiName = await listExistingFields(client, definition.apiName); 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[]; const reconciled: Partial, ReconciledCustomObjectField>> = {}; for (const fieldName of orderedFieldNames) { const field = definition.fields[fieldName] as AnyFieldDefinition & { apiName: string; logicalName: string; }; const params = buildProvisionableFieldParams(definition, fieldName); const existing = existingByApiName.get(field.apiName); if (existing == null) { const created = await client.customObjects.fields.create(definition.apiName, params.create); const updated = await client.customObjects.fields.update( definition.apiName, field.apiName, params.update, ); reconciled[fieldName] = Object.freeze({ definition, fieldName, action: 'created', field: updated.api_name === field.apiName ? updated : created, }); continue; } if (remoteFieldMatchesDefinition(existing, field, params, true)) { reconciled[fieldName] = Object.freeze({ definition, fieldName, action: 'unchanged', field: existing }); continue; } const updated = await client.customObjects.fields.update( definition.apiName, field.apiName, params.update, ); reconciled[fieldName] = Object.freeze({ definition, fieldName, action: 'updated', field: updated }); } const deletedFieldApiNames: string[] = []; if (options.deleteMissingFields) { const expectedFieldApiNames = new Set( Object.values(definition.fields).map( (field) => (field as AnyFieldDefinition & { apiName: string }).apiName, ), ); for (const existing of existingByApiName.values()) { if (existing.is_standard || expectedFieldApiNames.has(existing.api_name)) continue; await client.customObjects.fields.delete(definition.apiName, existing.api_name); deletedFieldApiNames.push(existing.api_name); } } return { fields: Object.freeze(reconciled) as Readonly< Record, ReconciledCustomObjectField> >, deletedFieldApiNames: Object.freeze(deletedFieldApiNames), }; } /** * Reconciles one typed custom-object definition against the backend. * * @param client - SDK client used to call the metadata APIs. * @param definition - Typed object definition to reconcile. * @param options - Reconciliation behavior flags for categories, name fields, and deletions. * @returns Frozen reconciliation result describing the final backend state. * @typeParam TDefinition - Object definition being reconciled. */ export async function reconcileCustomObjectDefinition( client: RipplingSDK, definition: TDefinition, options: ReconcileCustomObjectDefinitionOptions = {}, ): Promise> { const categoryDefinition = options.categoryDefinition ?? definition.categoryDefinition; const category = categoryDefinition != null ? await reconcileCustomObjectCategoryDefinition(client, categoryDefinition) : undefined; const categoryIdentifier = categoryDefinition != null ? requireCategoryIdentifier(definition, category?.category.id) : requireCategoryIdentifier(definition, options.categoryIdentifier); const objectResult = await ensureObjectExists(client, definition, categoryIdentifier); const nameField = await reconcileNameField(client, definition, options); const fieldResults = await reconcileFields(client, definition, options); return Object.freeze({ definition, ...(category != null ? { category } : {}), objectAction: objectResult.action, object: objectResult.object, ...(nameField.action != null ? { nameFieldAction: nameField.action } : {}), ...(nameField.field != null ? { nameField: nameField.field } : {}), fields: fieldResults.fields, deletedFieldApiNames: fieldResults.deletedFieldApiNames, }); } /** * Reconciles multiple typed custom-object definitions in a relationship-safe batch. * * @param client - SDK client used to call the metadata APIs. * @param definitions - Typed object definitions to reconcile. * @param options - Batch reconciliation behavior flags. * @returns Frozen reconciliation results in input order. */ export async function reconcileCustomObjectDefinitions( client: RipplingSDK, definitions: readonly AnyCustomObjectDefinition[], options: ReconcileCustomObjectDefinitionsOptions = {}, ): Promise { assertUniqueObjectDefinitions(definitions); const categoryDefinitions = collectCategoryDefinitions(definitions, options.categoryDefinitions); const reconciledCategoryIds = new Map(); for (const categoryDefinition of categoryDefinitions) { const reconciledCategory = await reconcileCustomObjectCategoryDefinition(client, categoryDefinition); reconciledCategoryIds.set(categoryDefinition.apiName, reconciledCategory.category.id); } const objectResults = new Map< string, { readonly action: 'created' | 'updated' | 'unchanged'; readonly object: CustomObject } >(); const nameFields = new Map(); for (const definition of definitions) { const categoryIdentifier = definition.categoryDefinition != null ? reconciledCategoryIds.get(definition.categoryDefinition.apiName) : options.categoryIdentifiersByObjectApiName?.[definition.apiName]; objectResults.set( definition.apiName, await ensureObjectExists(client, definition, requireCategoryIdentifier(definition, categoryIdentifier)), ); nameFields.set(definition.apiName, await reconcileNameField(client, definition, options)); } const results: ReconciledCustomObject[] = []; for (const definition of definitions) { const objectResult = objectResults.get(definition.apiName); if (objectResult == null) throw new Error(`Missing object reconciliation result for "${definition.apiName}".`); const fieldResults = await reconcileFields(client, definition, options); const nameField = nameFields.get(definition.apiName) ?? {}; results.push( Object.freeze({ definition, objectAction: objectResult.action, object: objectResult.object, ...(nameField.action != null ? { nameFieldAction: nameField.action } : {}), ...(nameField.field != null ? { nameField: nameField.field } : {}), fields: fieldResults.fields, deletedFieldApiNames: fieldResults.deletedFieldApiNames, }), ); } return Object.freeze(results); } /** * Deletes one typed custom object definition by its compiled API name. * * @param client - SDK client used to call the object APIs. * @param definition - Typed object definition identifying the object to delete. * @typeParam TDefinition - Object definition being deleted. */ export async function deleteCustomObjectDefinition( client: RipplingSDK, definition: TDefinition, ): Promise { await client.customObjects.delete(definition.apiName); }