import type { CustomObjectCategoryDefinition } from './category'; import type { AnyCustomObjectDefinition, AnyFieldDefinition, CategoryApiName, CustomObjectApiName, CustomObjectDefinition, FieldDefinitions, FieldNames, LogicalCategoryName, LogicalCustomFieldName, LogicalCustomObjectName, NameFieldDefinition, ResolvedFieldDefinition, SectionDefinitions, } from './types'; import { validateCustomObjectDefinition, validateLogicalCategoryName, validateLogicalCustomFieldName, validateLogicalCustomObjectName, } from './validation'; /** * Internal input shape accepted by {@link defineCustomObject} before normalization. * * @typeParam TObjectName - Logical custom-object name for the definition. * @typeParam TCategoryName - Logical category name associated with the object. * @typeParam TSections - Section map available to field definitions. * @typeParam TFields - Field map keyed by logical custom-field names. * @typeParam TNameField - Optional name-field behavior supplied by the caller. */ type CategoryInput = | LogicalCategoryName | CustomObjectCategoryDefinition>; /** * Internal input shape accepted by {@link defineCustomObject} before normalization. * * @typeParam TObjectName - Logical custom-object name for the definition. * @typeParam TCategoryName - Logical category name associated with the object. * @typeParam TSections - Section map available to field definitions. * @typeParam TFields - Field map keyed by logical custom-field names. * @typeParam TNameField - Optional name-field behavior supplied by the caller. */ interface CustomObjectDefinitionInput< TObjectName extends string, TCategoryName extends string, TSections extends SectionDefinitions, TFields extends FieldDefinitions, TNameField extends NameFieldDefinition, > { readonly name: LogicalCustomObjectName; readonly displayName: string; readonly pluralLabel?: string; readonly category: CategoryInput; readonly ownerRole?: string; readonly enableHistory?: boolean; readonly description: string; readonly icon?: { readonly emoji?: string | null; readonly ripplingIcon?: string | null; readonly s3Bucket?: string; readonly s3Key?: string; }; readonly nameField?: TNameField; readonly sections?: TSections; readonly fields: ValidateFieldMap; } /** * Rejects extension objects that try to redefine keys already present on the base definition. * * @typeParam TExisting - Existing key set on the base definition. * @typeParam TAdded - Proposed extension object. */ type NoOverlappingKeys = Extract< keyof TExisting, keyof TAdded > extends never ? TAdded : never; /** * Converts a logical field map into a version whose keys must all satisfy the * logical custom-field name contract. * * @typeParam TFields - Candidate logical field map supplied by callers. */ type ValidateFieldMap = { readonly [K in keyof TFields]: K extends string ? LogicalCustomFieldName extends never ? never : TFields[K] : never; }; /** * Recursively freezes author-authored schema definitions. * * The schema layer is intended to act like durable configuration data rather * than mutable runtime state, so this helper aggressively removes mutation as a * failure mode after validation succeeds. * * @typeParam T - Value being frozen. */ /** * Deeply freezes a schema fragment after validation succeeds. * * @param value - Value to recursively freeze in place. * @returns The same value, now deeply frozen where applicable. * @typeParam T - Value being frozen. */ function deepFreeze(value: T): T { if (value == null || typeof value !== 'object') return value; for (const child of Object.values(value as Record)) { deepFreeze(child); } return Object.freeze(value); } /** * Appends the platform-required `__c` suffix to a logical custom-object name. * * @param name - Logical unsuffixed custom-object name. * @returns Wire-level custom-object API name with the required `__c` suffix. * @typeParam TObjectName - Logical custom-object name to compile. */ export function toCustomObjectApiName( name: LogicalCustomObjectName, ): CustomObjectApiName { return `${name}__c` as CustomObjectApiName; } /** * Appends the platform-required `__c` suffix to a logical custom-field name. * * @param name - Logical unsuffixed custom-field name. * @returns Wire-level custom-field API name with the required `__c` suffix. * @typeParam TFieldName - Logical custom-field name to compile. */ export function toCustomFieldApiName( name: LogicalCustomFieldName, ): `${TFieldName}__c` { return `${name}__c`; } /** * Appends the platform-required `__c` suffix to a logical category name. * * @param name - Logical unsuffixed category name. * @returns Wire-level category API name with the required `__c` suffix. * @typeParam TCategoryName - Logical category name to compile. */ export function toCustomCategoryApiName( name: LogicalCategoryName, ): CategoryApiName { return `${name}__c` as CategoryApiName; } /** * Defines an immutable, strongly typed custom-object schema. * * Callers author only logical snake_case names. The returned value includes the * derived wire-level api names so downstream tooling can emit valid manifest * payloads without requiring authors to know about the `__c` suffix contract. * * @typeParam TObjectName - Logical custom-object name for the definition. * @typeParam TCategoryName - Logical category name associated with the object. * @typeParam TSections - Section map available to field definitions. * @typeParam TFields - Field map keyed by logical custom-field names. * @param definition - Author-facing typed schema definition to normalize, validate, and freeze. * @returns Immutable normalized custom-object definition with compiled field and object API names. * @typeParam TNameField - Name-field behavior for the object. */ export function defineCustomObject< TObjectName extends string, TCategoryName extends string, TSections extends SectionDefinitions = {}, TFields extends FieldDefinitions = FieldDefinitions, TNameField extends NameFieldDefinition = { kind: 'text' }, >( definition: CustomObjectDefinitionInput, ): Readonly< CustomObjectDefinition< LogicalCustomObjectName, LogicalCategoryName, TSections, TFields, TNameField > > { validateLogicalCustomObjectName(definition.name); const categoryDefinition = typeof definition.category === 'string' ? undefined : definition.category; const categoryName = typeof definition.category === 'string' ? definition.category : definition.category.name; const categoryApiName = typeof definition.category === 'string' ? toCustomCategoryApiName(definition.category) : definition.category.apiName; validateLogicalCategoryName(categoryName); const resolvedFields = Object.fromEntries( Object.entries(definition.fields).map(([logicalName, field]) => { validateLogicalCustomFieldName(logicalName); return [ logicalName, { ...field, logicalName, apiName: toCustomFieldApiName(logicalName), }, ]; }), ) as unknown as { readonly [K in Extract]: ResolvedFieldDefinition; }; const normalized = { name: definition.name, apiName: toCustomObjectApiName(definition.name) as CustomObjectApiName< LogicalCustomObjectName >, displayName: definition.displayName, ...(definition.pluralLabel != null ? { pluralLabel: definition.pluralLabel } : {}), category: categoryName, ...(categoryDefinition != null ? { categoryDefinition } : {}), ...(definition.ownerRole != null ? { ownerRole: definition.ownerRole } : {}), ...(definition.enableHistory != null ? { enableHistory: definition.enableHistory } : {}), categoryApiName: categoryApiName as CategoryApiName>, description: definition.description, ...(definition.icon != null ? { icon: definition.icon } : {}), nameField: (definition.nameField ?? { kind: 'text' }) as TNameField, sections: (definition.sections ?? ({} as TSections)) as TSections, fields: resolvedFields, }; const issues = validateCustomObjectDefinition(normalized as unknown as AnyCustomObjectDefinition); if (issues.length > 0) { throw new Error( `Invalid custom object definition for "${definition.name}":\n` + issues.map((issue) => `- ${issue.path}: ${issue.message}`).join('\n'), ); } return deepFreeze(normalized) as Readonly< CustomObjectDefinition< LogicalCustomObjectName, LogicalCategoryName, TSections, TFields, TNameField > >; } /** * Extends an existing object definition without losing type information. * * This helper is useful when a platform package wants to publish a base schema * and another package wants to add fields or sections in a type-safe way. * * @typeParam TBase - Existing object definition being extended. * @typeParam TAddedSections - New section definitions merged into the base object. * @param base - Existing typed object definition to extend. * @param extension - Additional metadata, sections, and fields to merge into the base definition. * @returns New immutable custom-object definition preserving the combined type information. * @typeParam TAddedFields - New field definitions merged into the base object. */ export function extendCustomObject< TBase extends AnyCustomObjectDefinition, TAddedSections extends SectionDefinitions = {}, TAddedFields extends FieldDefinitions = {}, >( base: TBase, extension: { readonly displayName?: string; readonly pluralLabel?: string; readonly ownerRole?: string; readonly enableHistory?: boolean; readonly description?: string; readonly icon?: TBase['icon']; readonly sections?: NoOverlappingKeys; readonly fields?: NoOverlappingKeys>; }, ): Readonly< CustomObjectDefinition< TBase['name'], TBase['category'], TBase['sections'] & TAddedSections, Omit & TAddedFields, TBase['nameField'] > > { const mergedSections = { ...base.sections, ...(extension.sections ?? {}), } as TBase['sections'] & TAddedSections; const mergedFields = Object.fromEntries( Object.entries({ ...base.fields, ...(extension.fields ?? {}) }).map(([logicalName, field]) => [ logicalName, { ...field, logicalName, apiName: toCustomFieldApiName(logicalName as LogicalCustomFieldName), }, ]), ) as unknown as Omit & TAddedFields; return defineCustomObject({ name: base.name, displayName: extension.displayName ?? base.displayName, ...(extension.pluralLabel != null || base.pluralLabel != null ? { pluralLabel: extension.pluralLabel ?? base.pluralLabel } : {}), ...(extension.ownerRole != null || base.ownerRole != null ? { ownerRole: extension.ownerRole ?? base.ownerRole } : {}), ...(extension.enableHistory != null || base.enableHistory != null ? { enableHistory: extension.enableHistory ?? base.enableHistory } : {}), category: base.categoryDefinition ?? base.category, description: extension.description ?? base.description, ...((extension.icon ?? base.icon) != null ? { icon: extension.icon ?? base.icon } : {}), nameField: base.nameField, sections: mergedSections, fields: mergedFields as ValidateFieldMap & TAddedFields>, }); } /** * Returns the logical custom field names defined on an object. * * @param definition - Object definition whose logical field names should be returned. * @returns Frozen array of logical field names from the definition. * @typeParam TDefinition - Object definition whose field names should be returned. */ export function fieldNames( definition: TDefinition, ): ReadonlyArray> { return Object.freeze(Object.keys(definition.fields) as FieldNames[]); } /** * Retrieves a field definition by logical field name with precise typing. * * @param definition - Object definition that owns the field. * @param fieldName - Logical field name to retrieve. * @returns Precisely typed field definition for the requested logical field name. * @typeParam TDefinition - Object definition that owns the field. * @typeParam TFieldName - Logical field name being retrieved. */ export function getFieldDefinition< TDefinition extends AnyCustomObjectDefinition, TFieldName extends FieldNames, >(definition: TDefinition, fieldName: TFieldName): TDefinition['fields'][TFieldName] { return (definition.fields as Record)[fieldName] as TDefinition['fields'][TFieldName]; } /** * Returns whether one typed field definition is writable by record create/update helpers. * * @param field - Typed field definition to inspect. * @returns Whether the field may be written by callers. */ export function isWritableField(field: AnyFieldDefinition): boolean { return field.writable; }