import type { AnyCustomObjectDefinition, AnyFieldDefinition, CreateInput, FieldNames, LookupReference, LogicalCategoryName, LogicalCustomFieldName, LogicalCustomObjectName, RecordShape, UpdateInput, } from './types'; /** Single validation failure returned by schema or record validators. */ export interface ValidationIssue { readonly path: string; readonly message: string; } /** Aggregate validation result returned by runtime validation helpers. */ export interface ValidationResult { readonly valid: boolean; readonly issues: readonly ValidationIssue[]; } /** Exact runtime regex used to validate logical snake_case names. */ const LOGICAL_NAME_PATTERN = /^[a-z](?:[a-z0-9_]*[a-z0-9])?$/; /** * Builds a single validation issue object. * * @param path - Logical path identifying the invalid field or property. * @param message - Human-readable validation failure message. * @returns Structured validation issue object. */ function issue(path: string, message: string): ValidationIssue { return { path, message }; } const AUTONUMBER_PLACEHOLDER_PATTERN = /\{(0+)\}/g; const AUTONUMBER_DATE_TOKENS = ['{YYYY}', '{MM}', '{DD}'] as const; /** * Validates autonumber metadata against the live backend rules. * * @param format - Autonumber format string to validate. * @param startingNumber - Optional starting number supplied by the caller. * @param pathPrefix - Validation path prefix used in returned issues. * @returns Validation issues describing any invalid autonumber metadata. */ function validateAutonumberNameField( format: string, startingNumber: number | undefined, pathPrefix: string, ): ValidationIssue[] { const issues: ValidationIssue[] = []; const trimmedFormat = format.trim(); if (trimmedFormat.length === 0) { issues.push(issue(`${pathPrefix}.format`, 'Autonumber format cannot be empty.')); return issues; } const placeholders = Array.from(trimmedFormat.matchAll(AUTONUMBER_PLACEHOLDER_PATTERN)); if (placeholders.length !== 1) { issues.push( issue(`${pathPrefix}.format`, 'Autonumber format must contain exactly one {0...} placeholder.'), ); } const placeholderDigits = placeholders[0]?.[1]; const placeholderWidth = placeholderDigits?.length; if (placeholderWidth != null && (placeholderWidth < 1 || placeholderWidth > 10)) { issues.push( issue(`${pathPrefix}.format`, 'Autonumber placeholder width must be between 1 and 10 digits.'), ); } if (startingNumber == null) { issues.push( issue(`${pathPrefix}.startingNumber`, 'startingNumber is required for autonumber name fields.'), ); } else if (!Number.isInteger(startingNumber) || startingNumber < 0) { issues.push(issue(`${pathPrefix}.startingNumber`, 'startingNumber must be a non-negative integer.')); } for (const token of AUTONUMBER_DATE_TOKENS) { const occurrences = trimmedFormat.split(token).length - 1; if (occurrences > 1) { issues.push(issue(`${pathPrefix}.format`, `${token} may appear at most once in autonumber format.`)); } } return issues; } /** * Returns whether one value is a plain object-like record. * * @param value - Candidate value to inspect. * @returns Whether the value is a non-array object. */ function isObject(value: unknown): value is Record { return value != null && typeof value === 'object' && !Array.isArray(value); } /** * Validates a logical snake_case name at runtime. * * Assumptions enforced by this helper: * - the name must be lowercase snake_case * - the name must not already include the platform-managed `__c` suffix * - the name must not contain consecutive underscores */ /** * Validates one logical snake_case name at runtime. * * @param value - Candidate logical name to validate. * @param path - Validation path to use in returned issues. * @returns Validation issues describing any logical-name contract violations. */ function validateLogicalName(value: string, path: string): ValidationIssue[] { const issues: ValidationIssue[] = []; if (value.endsWith('__c')) { issues.push(issue(path, 'Logical names must be unsuffixed; do not include __c.')); } if (!LOGICAL_NAME_PATTERN.test(value)) { issues.push(issue(path, 'Logical names must be snake_case and start with a lowercase letter.')); } if (value.includes('__')) { issues.push(issue(path, 'Logical names must not contain consecutive underscores.')); } return issues; } /** * Throws when a logical custom-object name is invalid. * * @param value - Candidate logical custom-object name to validate. * @typeParam TObjectName - Candidate logical custom-object name. */ export function validateLogicalCustomObjectName( value: LogicalCustomObjectName | string, ): asserts value is LogicalCustomObjectName { const issues = validateLogicalName(value, 'name'); if (issues.length === 0) return; throw new Error(issues.map((current) => `${current.path}: ${current.message}`).join('\n')); } /** * Throws when a logical category name is invalid. * * @param value - Candidate logical category name to validate. * @typeParam TCategoryName - Candidate logical category name. */ export function validateLogicalCategoryName( value: LogicalCategoryName | string, ): asserts value is LogicalCategoryName { const issues = validateLogicalName(value, 'category'); if (issues.length === 0) return; throw new Error(issues.map((current) => `${current.path}: ${current.message}`).join('\n')); } /** * Throws when a logical custom-field name is invalid. * * @param value - Candidate logical custom-field name to validate. * @typeParam TFieldName - Candidate logical custom-field name. */ export function validateLogicalCustomFieldName( value: LogicalCustomFieldName | string, ): asserts value is LogicalCustomFieldName { const issues = validateLogicalName(value, 'field'); if (issues.length === 0) return; throw new Error(issues.map((current) => `${current.path}: ${current.message}`).join('\n')); } /** * Recognizes lookup values accepted by the typed helpers. * * Assumption: callers either pass a bare id string or a small `{ id, apiName? }` * object. The validator does not attempt to resolve the referenced record. */ /** * Recognizes lookup references accepted by the typed helpers. * * @param value - Candidate field value. * @returns Whether the value is a string id or `{ id, apiName? }` lookup reference. */ function isLookupReference(value: unknown): value is LookupReference { return ( typeof value === 'string' || (isObject(value) && typeof value['id'] === 'string' && (value['apiName'] == null || typeof value['apiName'] === 'string')) ); } /** * Recognizes file references accepted by the typed helpers. * * @param value - Candidate field value. * @returns Whether the value is a file id string or `{ id }` object. */ function isFileReference(value: unknown): boolean { return typeof value === 'string' || (isObject(value) && typeof value['id'] === 'string'); } /** * Returns the number of decimal places represented by a finite JavaScript number. * * @param value - Numeric value to inspect. * @returns Decimal-place count after accounting for exponent notation. */ function countDecimalPlaces(value: number): number { if (!Number.isFinite(value)) return 0; const normalized = value.toString().toLowerCase(); if (!normalized.includes('e')) { const fraction = normalized.split('.')[1]; return fraction?.length ?? 0; } const [coefficient, exponentText] = normalized.split('e'); const exponent = Number(exponentText); const coefficientFractionLength = coefficient?.split('.')[1]?.length ?? 0; return Math.max(0, coefficientFractionLength - exponent); } /** * Validates one field value against the field-definition kind metadata. * * Assumption: this helper intentionally performs shape and primitive checks only. * It does not attempt to execute formulas, validate RQL, or verify that lookup * ids point at existing records. */ /** * Validates one field value against the field-definition kind metadata. * * @param field - Typed field definition that owns the value. * @param value - Candidate runtime value to validate. * @param path - Validation path to use in returned issues. * @returns Validation issues describing any mismatches. */ function validateFieldValue(field: AnyFieldDefinition, value: unknown, path: string): ValidationIssue[] { switch (field.kind) { case 'text': case 'long-text': case 'formula-text': { if (typeof value !== 'string') return [issue(path, 'Expected a string.')]; return field.maxLength != null && value.length > field.maxLength ? [issue(path, `Expected at most ${field.maxLength} characters.`)] : []; } case 'email': case 'url': case 'phone': case 'radio': case 'formula-url': if (typeof value !== 'string') return [issue(path, 'Expected a string.')]; if (field.kind === 'radio' && !field.options.includes(value)) { return [issue(path, `Expected one of: ${field.options.join(', ')}.`)]; } return []; case 'number': case 'currency': case 'percentage': case 'formula-number': case 'formula-currency': case 'formula-percentage': { if (typeof value !== 'number') return [issue(path, 'Expected a number.')]; if (!Number.isFinite(value)) return [issue(path, 'Expected a finite number.')]; if ('decimalPlaces' in field && typeof field.decimalPlaces === 'number') { return countDecimalPlaces(value) > field.decimalPlaces ? [issue(path, `Expected at most ${field.decimalPlaces} decimal places.`)] : []; } return []; } case 'date': case 'datetime': case 'time': case 'formula-date': return typeof value === 'string' ? [] : [issue(path, 'Expected an ISO-like date/time string.')]; case 'boolean': case 'formula-boolean': return typeof value === 'boolean' ? [] : [issue(path, 'Expected a boolean.')]; case 'select': if (!(Array.isArray(value) && value.every((entry) => typeof entry === 'string'))) { return [issue(path, 'Expected an array of strings.')]; } return value.every((entry) => field.options.includes(entry)) ? [] : [issue(path, `Expected each value to be one of: ${field.options.join(', ')}.`)]; case 'file': return isFileReference(value) ? [] : [issue(path, 'Expected a file id string or { id } object.')]; case 'lookup': case 'parent-child': return isLookupReference(value) ? [] : [issue(path, 'Expected a record id string or { id } object.')]; } } /** * Returns the logical field names defined on a schema object. * * @typeParam TDefinition - Object definition whose field names should be listed. */ /** * Returns the logical field names defined on one schema object. * * @param definition - Object definition whose field names should be listed. * @returns Logical field names from the supplied definition. * @typeParam TDefinition - Object definition whose field names should be listed. */ function definedFieldNames( definition: TDefinition, ): FieldNames[] { return Object.keys(definition.fields) as FieldNames[]; } /** * Validates a schema definition after the type system has done its part. * * @param definition - Typed object definition to validate at runtime. * @returns Validation issues describing any remaining runtime-level problems. */ export function validateCustomObjectDefinition(definition: AnyCustomObjectDefinition): ValidationIssue[] { const issues: ValidationIssue[] = []; issues.push(...validateLogicalName(definition.name, 'name')); issues.push(...validateLogicalName(definition.category, 'category')); if (!definition.apiName.endsWith('__c')) { issues.push(issue('apiName', 'Compiled custom-object api names must end with __c.')); } if (!definition.categoryApiName.endsWith('__c')) { issues.push(issue('categoryApiName', 'Compiled category api names must end with __c.')); } if (definition.categoryDefinition != null) { if (definition.categoryDefinition.name !== definition.category) { issues.push(issue('categoryDefinition.name', 'categoryDefinition.name must match category.')); } if (definition.categoryDefinition.apiName !== definition.categoryApiName) { issues.push( issue('categoryDefinition.apiName', 'categoryDefinition.apiName must match categoryApiName.'), ); } } if (definition.ownerRole != null && typeof definition.ownerRole !== 'string') { issues.push(issue('ownerRole', 'ownerRole must be a role id string.')); } if (definition.enableHistory != null && typeof definition.enableHistory !== 'boolean') { issues.push(issue('enableHistory', 'enableHistory must be a boolean when provided.')); } if (definition.nameField.kind === 'autonumber') { issues.push( ...validateAutonumberNameField( definition.nameField.format, definition.nameField.startingNumber, 'nameField', ), ); } const knownSections = new Set(Object.keys(definition.sections)); for (const [logicalFieldName, rawField] of Object.entries(definition.fields)) { const field = rawField as AnyFieldDefinition & { apiName?: string; logicalName?: string }; issues.push(...validateLogicalName(logicalFieldName, `fields.${logicalFieldName}`)); if (field.apiName == null || !field.apiName.endsWith('__c')) { issues.push( issue(`fields.${logicalFieldName}.apiName`, 'Compiled custom-field api names must end with __c.'), ); } if (field.logicalName !== logicalFieldName) { issues.push( issue(`fields.${logicalFieldName}.logicalName`, 'Field logicalName must match the field map key.'), ); } if (field.section != null && !knownSections.has(field.section)) { issues.push(issue(`fields.${logicalFieldName}.section`, `Unknown section key "${field.section}".`)); } if (field.enableHistory != null && typeof field.enableHistory !== 'boolean') { issues.push( issue(`fields.${logicalFieldName}.enableHistory`, 'enableHistory must be a boolean when provided.'), ); } if ((field.kind === 'select' || field.kind === 'radio') && field.options.length === 0) { issues.push( issue(`fields.${logicalFieldName}.options`, 'Choice fields must define at least one option.'), ); } if (field.kind === 'formula-text' && (field.maxLength == null || field.maxLength <= 0)) { issues.push( issue(`fields.${logicalFieldName}.maxLength`, 'Text formula fields require a positive maxLength.'), ); } if ('maxLength' in field && typeof field.maxLength === 'number' && field.maxLength <= 0) { issues.push(issue(`fields.${logicalFieldName}.maxLength`, 'maxLength must be positive.')); } if ('decimalPlaces' in field && typeof field.decimalPlaces === 'number' && field.decimalPlaces < 0) { issues.push(issue(`fields.${logicalFieldName}.decimalPlaces`, 'decimalPlaces cannot be negative.')); } } return issues; } /** * Shared implementation for create and update validation. * * @typeParam TDefinition - Object definition whose record payload is being validated. */ /** * Shared implementation for create and update payload validation. * * @param definition - Object definition whose payload is being validated. * @param input - Candidate logical payload to validate. * @param mode - Validation mode determining create-vs-update requirements. * @returns Validation issues describing any payload problems. * @typeParam TDefinition - Object definition whose record payload is being validated. */ function validateRecordInputInternal( definition: TDefinition, input: Record, mode: 'create' | 'update', ): ValidationIssue[] { const issues: ValidationIssue[] = []; const fields = definition.fields as Record; const names = definedFieldNames(definition); const knownFieldNames = new Set(names); const writableFieldNames = new Set(names.filter((name) => fields[name]!.writable) as readonly string[]); if (mode === 'create' && definition.nameField.kind !== 'autonumber' && typeof input['name'] !== 'string') { issues.push(issue('name', 'name is required when the object does not use an autonumber name field.')); } if (input['name'] != null && typeof input['name'] !== 'string') { issues.push(issue('name', 'Expected a string.')); } if (input['external_id'] != null && typeof input['external_id'] !== 'string') { issues.push(issue('external_id', 'Expected a string.')); } if (input['owner_role'] != null && !isLookupReference(input['owner_role'])) { issues.push(issue('owner_role', 'Expected a record id string or { id } object.')); } for (const name of names) { const field = fields[name]!; const hasValue = Object.prototype.hasOwnProperty.call(input, name); if (mode === 'create' && field.writable && field.required && !hasValue) { issues.push(issue(name, 'Missing required field.')); continue; } if (!hasValue) continue; const value = input[name]; if (!field.writable) { issues.push(issue(name, 'Read-only fields cannot be written.')); continue; } issues.push(...validateFieldValue(field, value, name)); } for (const key of Object.keys(input)) { if (key === 'name' || key === 'external_id' || key === 'owner_role') continue; if (!knownFieldNames.has(key as FieldNames)) { issues.push(issue(key, 'Unknown field.')); continue; } if (!writableFieldNames.has(key)) { issues.push(issue(key, 'Read-only fields cannot be written.')); } } return issues; } /** * Runtime validator for create payloads derived from a schema definition. * * @param definition - Object definition whose create payload is being validated. * @param input - Candidate create payload expressed with logical field names. * @returns Aggregate validation result for the supplied create payload. * @typeParam TDefinition - Object definition whose create payload is being validated. */ export function validateCreateInput( definition: TDefinition, input: Record, ): ValidationResult { const issues = validateRecordInputInternal(definition, input, 'create'); return { valid: issues.length === 0, issues }; } /** * Runtime validator for update payloads derived from a schema definition. * * @param definition - Object definition whose update payload is being validated. * @param input - Candidate update payload expressed with logical field names. * @returns Aggregate validation result for the supplied update payload. * @typeParam TDefinition - Object definition whose update payload is being validated. */ export function validateUpdateInput( definition: TDefinition, input: Record, ): ValidationResult { const issues = validateRecordInputInternal(definition, input, 'update'); return { valid: issues.length === 0, issues }; } /** * Throws a detailed error when a create payload is invalid. * * @param definition - Object definition whose create payload is being asserted. * @param input - Candidate create payload expressed with logical field names. * @typeParam TDefinition - Object definition whose create payload is being asserted. */ export function assertValidCreateInput( definition: TDefinition, input: Record, ): asserts input is CreateInput { const result = validateCreateInput(definition, input); if (result.valid) return; throw new Error(result.issues.map((current) => `${current.path}: ${current.message}`).join('\n')); } /** * Throws a detailed error when an update payload is invalid. * * @param definition - Object definition whose update payload is being asserted. * @param input - Candidate update payload expressed with logical field names. * @typeParam TDefinition - Object definition whose update payload is being asserted. */ export function assertValidUpdateInput( definition: TDefinition, input: Record, ): asserts input is UpdateInput { const result = validateUpdateInput(definition, input); if (result.valid) return; throw new Error(result.issues.map((current) => `${current.path}: ${current.message}`).join('\n')); } /** * Casts a generic record object into the strongly typed shape derived from the * schema. * * This helper does not perform runtime validation by itself; pair it with a * schema-aware check when the source data is not trusted. * * @param _definition - Object definition whose record shape should be applied. * @param record - Generic record object to cast into the typed record shape. * @returns Record cast to the schema-derived typed record shape. * @typeParam TDefinition - Object definition whose record shape should be applied. */ export function asTypedRecord( _definition: TDefinition, record: Record, ): RecordShape { return record as RecordShape; } /** * Convenience helper for pulling a known field value out of a typed record. * * @param record - Typed record to read from. * @param fieldName - Logical field name to read. * @returns Typed value stored at the requested field name. * @typeParam TDefinition - Object definition that produced the record shape. * @typeParam TFieldName - Logical field name being read from the record. */ export function getRecordValue< TDefinition extends AnyCustomObjectDefinition, TFieldName extends FieldNames, >(record: RecordShape, fieldName: TFieldName): RecordShape[TFieldName] { return record[fieldName]; }