import { buildProvisionableFieldParams, buildCustomObjectNameFieldDetails, } from '../../../core/platform/custom-objects/provisioning'; import { Category } from '../category'; import { CustomObject, type CustomObjectFieldInput, type CustomObjectStandardFieldIdentifier, } from '../custom-object'; import { Field } from '../field'; import { CustomObjectFieldSection } from '../custom-object-field-section'; import { CustomObjectPageLayout } from '../custom-object-page-layout'; import type { ManifestComponent } from '../manifest-builder'; import type { RipplingSDK } from '../../../client'; import { defineCustomObject as createObject, extendCustomObject as extendObject, } from '../../../core/platform/custom-objects/definition'; import type { CustomObjectCategoryDefinition } from '../../../core/platform/custom-objects/category'; import type { CustomObjectRecordsClient, TypedBatchWriteOptions, TypedRecordPageOptions, TypedRecordQueryOptions, TypedRecordPage, TypedRecordWriteResult, } from '../../../core/platform/custom-objects/manager'; import { createRqlHelpers, type RqlFieldToken, type RqlComparableValue, type RqlFragment, } from '../../../core/platform/custom-objects/rql'; import type { AnyCustomObjectDefinition, AnyFieldDefinition, CustomObjectDefinition, FieldDefinitions, SectionDefinitions, NameFieldDefinition, RecordShape, LogicalCustomObjectName, LogicalCategoryName, } from '../../../core/platform/custom-objects/types'; import type { RequestOptions } from '../../../internal/request-options'; import type { FieldListParams, CustomObjectField, CustomObjectFieldsPageCursorURL, } from '../../../resources/custom-objects/fields'; import type { CustomObject as DeployedCustomObject } from '../../../resources/custom-objects/custom-objects'; import type { APIPromise } from '../../../core/api-promise'; import type { PagePromise } from '../../../core/pagination'; import { categoryComponent, defineCategory } from './category'; import { defineComponent, detachedManifestScope, withApiName, validateCustomApiName, type MetadataConfig, registeredComponent, type CompileContext, type ComponentDefinition, } from './component'; import type { ValueParser } from './function'; import { assertObjectInput, createObjectRecords, type ObjectCreateInput, type ObjectUpdateInput, type ObjectBulkUpdateInput, } from './_object-records'; export type { ObjectCreateInput, ObjectUpdateInput, ObjectBulkUpdateInput } from './_object-records'; export type DefineCustomObjectProps< A extends `${string}__c`, F extends FieldDefinitions, C extends string = `${Unsuffixed}_category`, S extends SectionDefinitions = {}, N extends NameFieldDefinition = { kind: 'text' }, > = Omit, 'pluralLabel'> & { apiName: A; name: string; pluralName?: string; category?: CategoryInput; }; export type DefinedCustomObject = Omit< D, 'fields' | 'inputs' | 'records' | 'schema' > & { readonly fields: { readonly [K in keyof D['fields']]: Omit & { readonly apiName: `${Unsuffixed>}__c`; readonly objectApiName: D['apiName']; }; }; readonly records: D extends AnyCustomObjectDefinition ? Readonly> : never; readonly schema: D extends AnyCustomObjectDefinition ? Readonly : never; readonly inputs: D extends AnyCustomObjectDefinition ? { readonly create: ValueParser>; readonly update: ValueParser>; } : never; }; /** Authenticated records transport supplied at the operation's call site. */ export interface ObjectRecordsContext { readonly customObjects: { readonly records: CustomObjectRecordsClient }; } /** Schema resources supplied at the operation's call site. */ export interface ObjectSchemaContext { readonly customObjects: Pick; } export type ObjectRecordListOptions = TypedRecordPageOptions & { where?: ObjectFilter; }; /** Typed record operations; writes return readable records or ID-only receipts. Supply an authenticated client at each call. */ export interface ObjectRecords { create(ctx: ObjectRecordsContext, input: ObjectCreateInput): Promise>; createMany( ctx: ObjectRecordsContext, rows: readonly ObjectCreateInput[], options?: TypedBatchWriteOptions, ): Promise[]>; get(ctx: ObjectRecordsContext, recordID: string): Promise>; getByExternalID(ctx: ObjectRecordsContext, externalID: string): Promise>; update( ctx: ObjectRecordsContext, recordID: string, patch: ObjectUpdateInput, ): Promise>; updateMany( ctx: ObjectRecordsContext, rows: readonly ObjectBulkUpdateInput[], options?: TypedBatchWriteOptions, ): Promise[]>; delete(ctx: ObjectRecordsContext, recordID: string): Promise; deleteMany( ctx: ObjectRecordsContext, recordIDs: readonly string[], options?: TypedBatchWriteOptions, ): Promise; /** List records with optional typed filters and cursor pagination. */ list(ctx: ObjectRecordsContext, options?: ObjectRecordListOptions): Promise>; /** Run a raw RQL filter with cursor pagination. */ query(ctx: ObjectRecordsContext, options?: TypedRecordQueryOptions): Promise>; } /** Deployed object metadata, separate from the local field definitions. */ export interface ObjectSchema { retrieve(ctx: ObjectSchemaContext, options?: RequestOptions): APIPromise; readonly fields: Readonly; } export interface ObjectSchemaFields { list( ctx: ObjectSchemaContext, params?: FieldListParams, options?: RequestOptions, ): PagePromise; } export interface CustomObjectOperations { readonly records: Readonly>; readonly schema: Readonly; } /** The same operations with the authenticated client supplied by client.objects(definition). */ export interface ObjectClient { create(input: ObjectCreateInput): Promise>; createMany( rows: readonly ObjectCreateInput[], options?: TypedBatchWriteOptions, ): Promise[]>; get(recordID: string): Promise>; getByExternalID(externalID: string): Promise>; update(recordID: string, patch: ObjectUpdateInput): Promise>; updateMany( rows: readonly ObjectBulkUpdateInput[], options?: TypedBatchWriteOptions, ): Promise[]>; delete(recordID: string): Promise; deleteMany(recordIDs: readonly string[], options?: TypedBatchWriteOptions): Promise; list(options?: ObjectRecordListOptions): Promise>; query(options?: TypedRecordQueryOptions): Promise>; schema: { retrieve(options?: RequestOptions): APIPromise }; fields: { list( params?: FieldListParams, options?: RequestOptions, ): PagePromise; }; } export type ObjectFilter = RqlFragment & { readonly [filterOwner]: A }; export function defineCustomObject< const A extends `${string}__c`, const F extends FieldDefinitions, C extends string = `${Unsuffixed}_category`, S extends SectionDefinitions = {}, N extends NameFieldDefinition = { kind: 'text' }, >( props: DefineCustomObjectProps, ): DefinedCustomObject< CustomObjectDefinition>, LogicalCategoryName, S, F, N> >; export function defineCustomObject(props: Record): object { const apiName = props['apiName'] as string; validateCustomApiName(apiName); const name = apiName.slice(0, -3); const sourceFields = props['fields'] as FieldDefinitions; const fields = normalizeFieldNames(sourceFields); validateSections(props['sections']); const category = props['category'] ?? defineCategory({ apiName: `${name}_category__c`, name: props['name'], description: props['description'], }); validateCustomApiName(typeof category === 'string' ? category : category.apiName); const definition = createObject({ ...props, name, displayName: props['name'], ...(props['pluralName'] === undefined ? {} : { pluralLabel: props['pluralName'] }), category: typeof category === 'string' ? category.slice(0, -3) : category, fields, } as Parameters[0]); return customObjectComponent.register(withInputs(definition, Object.keys(sourceFields)), props); } /** Existing extension semantics, with the same additive field tokens and parsers. */ export function extendCustomObject< B extends AnyCustomObjectDefinition, S extends SectionDefinitions = {}, F extends FieldDefinitions = {}, >( base: B, extension: Omit>[1], 'fields'> & { fields?: Extract extends never ? F & ApiNamedMap : never; sections?: ApiNamedMap; }, ): DefinedCustomObject>> { const fields = normalizeFieldNames(base.fields); const addedFields = normalizeFieldNames(extension.fields ?? {}); validateSections(extension.sections); for (const key of Object.keys(extension.fields ?? {})) { if (Object.prototype.hasOwnProperty.call(fields, key.replace(/__c$/, ''))) throw new Error(`Cannot replace existing custom field ${key} on ${base.apiName}.`); } const definition = extendObject( { ...base, fields } as B, { ...extension, fields: addedFields } as unknown as Parameters>[1], ) as unknown as AnyCustomObjectDefinition; const keys = [...Object.keys(base.fields), ...Object.keys(extension.fields ?? {})]; return extendedObjectComponent.register( withInputs(definition, keys), base, extension, ) as unknown as DefinedCustomObject>>; } export const eq = ( field: FieldToken, value: RqlComparableValue> | null, ) => query.eq(field as unknown as RqlFieldToken, value) as ObjectFilter; export const ne = ( field: FieldToken, value: RqlComparableValue> | null, ) => query.ne(field as unknown as RqlFieldToken, value) as ObjectFilter; export const lt = ( field: FieldToken, value: NoInfer, ) => query.lt(field as unknown as RqlFieldToken, value) as ObjectFilter; export const lte = ( field: FieldToken, value: NoInfer, ) => query.lte(field as unknown as RqlFieldToken, value) as ObjectFilter; export const gt = ( field: FieldToken, value: NoInfer, ) => query.gt(field as unknown as RqlFieldToken, value) as ObjectFilter; export const gte = ( field: FieldToken, value: NoInfer, ) => query.gte(field as unknown as RqlFieldToken, value) as ObjectFilter; export const inArray = ( field: FieldToken, values: readonly RqlComparableValue>[], ) => query.in(field as unknown as RqlFieldToken, values) as ObjectFilter; export const notInArray = ( field: FieldToken, values: readonly RqlComparableValue>[], ) => query.notIn(field as unknown as RqlFieldToken, values) as ObjectFilter; export const isNull = (field: FieldToken) => query.isNull(field as unknown as RqlFieldToken) as ObjectFilter; export const isNotNull = (field: FieldToken) => query.isNotNull(field as unknown as RqlFieldToken) as ObjectFilter; export const and = (first: ObjectFilter, ...rest: ObjectFilter>[]) => query.and(first, ...rest) as ObjectFilter; export const or = (first: ObjectFilter, ...rest: ObjectFilter>[]) => query.or(first, ...rest) as ObjectFilter; export const not = (filter: ObjectFilter) => query.not(filter) as ObjectFilter; type Unsuffixed = N extends `${infer Name}__c` ? Name : N; /** Enrich the existing immutable schema; normalization and validation stay in the existing factory. */ function withInputs( definition: D, keys = Object.keys(definition.fields), ): DefinedCustomObject { const fields = Object.freeze( Object.fromEntries( keys.map((key) => [ key, Object.freeze({ ...definition.fields[key.replace(/__c$/, '')]!, logicalName: key, objectApiName: definition.apiName, }), ]), ), ); const enriched = { ...definition, fields } as D; const parser = (operation: 'create' | 'update'): ValueParser => ({ parse(value) { if (value == null || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Expected a record object.'); assertObjectInput(enriched, value as Record, operation); return value as T; }, }); return Object.freeze({ ...enriched, ...createObjectOperations(enriched), fields, inputs: Object.freeze({ create: parser>('create'), update: parser>('update'), }), }) as DefinedCustomObject; } function normalizeFieldNames(fields: Readonly>) { return Object.fromEntries( Object.entries(fields).map(([apiName, field]) => { validateCustomApiName(apiName); return [apiName.slice(0, -3), field]; }), ); } function validateSections(sections: SectionDefinitions = {}): void { for (const [apiName, section] of Object.entries(sections)) { validateCustomApiName(apiName); if (section.sectionId !== undefined) validateCustomApiName(section.sectionId); } } type ApiNamedMap = { [K in keyof T]: string extends K ? T[K] : K extends `${string}__c` ? T[K] : never; }; type CategoryInput = `${N}__c` | CustomObjectCategoryDefinition>; type ObjectOptions< S extends SectionDefinitions, F extends FieldDefinitions, N extends NameFieldDefinition, > = Pick< AnyCustomObjectDefinition, 'description' | 'pluralLabel' | 'icon' | 'ownerRole' | 'enableHistory' > & { sections?: S & ApiNamedMap; fields: F & ApiNamedMap & { [K in keyof F]: string extends Exclude ? F[K] : Exclude extends keyof NoInfer ? F[K] : never; }; nameField?: N; }; /** Operations capture only the definition; each call supplies its own authenticated transport. */ function createObjectOperations( definition: D, ): CustomObjectOperations { return { records: createObjectRecords(definition), schema: Object.freeze({ retrieve: (ctx, options) => ctx.customObjects.retrieve(definition.apiName, options), fields: Object.freeze({ list: (ctx, params, options) => ctx.customObjects.fields.list(definition.apiName, params, options), }), }), }; } /** @internal Forward client helpers to the definition operations. */ export function createObjectClient( ctx: ObjectRecordsContext & ObjectSchemaContext, definition: D, ): ObjectClient { const { records, schema } = createObjectOperations(definition); return { create: (input) => records.create(ctx, input), createMany: (rows, options) => records.createMany(ctx, rows, options), get: (recordID) => records.get(ctx, recordID), getByExternalID: (externalID) => records.getByExternalID(ctx, externalID), update: (recordID, patch) => records.update(ctx, recordID, patch), updateMany: (rows, options) => records.updateMany(ctx, rows, options), delete: (recordID) => records.delete(ctx, recordID), deleteMany: (recordIDs, options) => records.deleteMany(ctx, recordIDs, options), list: (options) => records.list(ctx, options), query: (options) => records.query(ctx, options), schema: { retrieve: (options) => schema.retrieve(ctx, options) }, fields: { list: (params, options) => schema.fields.list(ctx, params, options), }, }; } // Reuse the existing operators, carrying the schema's object identity through typed filters. const query = createRqlHelpers({ fields: {} } as AnyCustomObjectDefinition); declare const filterOwner: unique symbol; type FieldToken = Pick, 'apiName' | '__valueType__'> & { readonly objectApiName: A; }; declare const objectScope: unique symbol; /** Carry parent identity through helpers while still accepting existing constructor instances. */ export type ObjectScoped = TDefinition & { readonly [objectScope]: TObject['apiName']; }; export function scopedDefinition( _object: TObject, definition: TDefinition, ): ObjectScoped { return definition as ObjectScoped; } /** A complete schema is a local dependency; an identity-only target names an existing resource. */ function isObjectDefinition(value: object): value is AnyCustomObjectDefinition { return 'fields' in value && 'nameField' in value && 'categoryApiName' in value; } export function fieldReferences( owner: TObject, field: DefinitionFieldReference, ): object[] { return [typeof field === 'object' && 'getApiName' in field ? field : owner]; } const currentFieldSections = new WeakMap(); /** Keep dependency collection in sync with layout-driven field moves. */ export function trackFieldSection(field: T, section: CustomObjectFieldSection): T { currentFieldSections.set(field, section); const assignSection = field._assignSection.bind(field); field._assignSection = (section) => { assignSection(section); currentFieldSections.set(field, typeof section === 'string' ? undefined : section); }; return field; } export function fieldSectionReferences(field: Field): object[] { const section = currentFieldSections.get(field); // Schema-owned sections are emitted by the object compiler; authored sections are dependencies. return section != null && registeredComponent(section) != null ? [section] : []; } /** Shared by section serialization and authored/default conflict detection. */ function fieldSectionApiName(definition: AnyCustomObjectDefinition, key: string): string { const apiName = definition.sections[key]?.sectionId ?? key; validateCustomApiName(apiName); return apiName; } /** Reuse the typed SDK's field compiler and the metadata SDK's serializers. */ function objectMetadata(definition: AnyCustomObjectDefinition): ObjectMetadata { const existing = registrations.get(definition); if (existing != null) return existing; if ( definition.ownerRole != null || definition.enableHistory != null || Object.values(definition.fields).some( (field) => field.enableHistory != null || field.restSection != null || field.rqlDefinition != null, ) ) throw new Error('REST-only object options cannot be compiled to metadata.'); const category = new Category(detachedManifestScope, { apiName: definition.categoryApiName, name: definition.categoryDefinition?.displayName ?? definition.category, description: definition.categoryDefinition?.description ?? '', }); const components: ManifestComponent[] = []; const object = new CustomObject( { _register(component) { components.push(component); }, }, { apiName: definition.apiName, name: definition.displayName, category, description: definition.description, ...(definition.pluralLabel == null ? {} : { pluralLabel: definition.pluralLabel }), ...(definition.icon == null ? {} : { icon: definition.icon }), nameFieldDetails: buildCustomObjectNameFieldDetails(definition) ?? null, }, ); const sections = Object.fromEntries( Object.entries(definition.sections).map(([key, section]) => { const apiName = fieldSectionApiName(definition, key); return [ key, withApiName( new CustomObjectFieldSection(object, { name: section.name, sectionId: apiName }), apiName, ), ]; }), ); const needsFallback = Object.keys(sections).length === 0 || Object.values(definition.fields).some((field) => field.section == null); const generalKey = `${definition.name}_general__c`; const defaultSectionApiName = fieldSectionApiName(definition, generalKey); const defaultSection = needsFallback ? sections[generalKey] ?? withApiName( new CustomObjectFieldSection(object, { name: 'General', sectionId: defaultSectionApiName }), defaultSectionApiName, ) : undefined; const firstSection = defaultSection ?? Object.values(sections)[0]!; withApiName(object, definition.apiName, { default_field_section_api_name: firstSection.getSectionId() }); // Provisioning needs section handles; here they are deterministic API names instead of destination IDs. const provisionable = { ...definition, sections: Object.fromEntries( Object.entries(sections).map(([key, section]) => [ key, { name: section.getName(), sectionId: section.getSectionId() }, ]), ), }; const fields = Object.fromEntries( Object.entries(definition.fields).map(([key, field]) => { const section = field.section == null ? defaultSection : sections[field.section]; if (section == null) throw new Error(`Unknown section ${field.section} on ${definition.apiName}.`); const { create } = buildProvisionableFieldParams(provisionable, key); return [ key, trackFieldSection( new Field(object, { apiName: field.apiName, displayName: field.displayName, description: field.description, section, dataType: create.data_type, required: field.writable ? field.required : null, unique: create.is_unique ?? null, derivedFieldFormula: create.derived_field_formula ?? null, ...(field.formulaAttributeMetas == null ? {} : { formulaAttrMetas: field.formulaAttributeMetas }), ...(field.derivedAggregatedField == null ? {} : { derivedAggregatedField: field.derivedAggregatedField }), }), section, ), ]; }), ); const layoutSections = [ ...new Set([...Object.values(sections), ...(defaultSection == null ? [] : [defaultSection])]), ]; const layout = withApiName( new CustomObjectPageLayout(object, { apiName: 'default', layoutId: 'default', name: `${definition.displayName} Layout`, tabEdits: { newTabs: [ { key: 'details', name: 'Details', type: 'tab_with_sections', sections: layoutSections.map((section, index) => ({ name: section.getName(), type: 'fields_section', section, fields: [ ...(index === 0 ? [object.standardFields.name] : []), ...Object.values(fields).filter((field) => field.getSectionId() === section.getSectionId()), ], })), }, ], systemTabEdits: {}, tabsOrder: ['details'], }, }), 'default', ); const metadata = { object, sections, defaultSection, fields, layout, components: [...components], defaultFieldSectionApiName: firstSection.getSectionId(), }; registrations.set(definition, metadata); return metadata; } export type ObjectReference = AnyCustomObjectDefinition; export type DefinitionFieldReference = | TObject['fields'][keyof TObject['fields']] | ObjectScoped | CustomObjectStandardFieldIdentifier; interface ObjectMetadata { object: CustomObject; sections: Record; defaultSection: CustomObjectFieldSection | undefined; fields: Record; layout: CustomObjectPageLayout; components: ManifestComponent[]; defaultFieldSectionApiName: string; } const registrations = new WeakMap(); export type SectionReference = | ObjectScoped | Extract | 'general'; export function manifestSection( object: TObject, reference: SectionReference, ): CustomObjectFieldSection { if (typeof reference !== 'string') { if (reference.getModelApiName() !== manifestObject(object).getApiName()) throw new Error('Section reference belongs to another object.'); return reference; } const registration = objectMetadata(object); const section = registration.sections[reference] ?? (reference === 'general' ? registration.defaultSection : undefined); if (section == null) throw new Error(`Unknown section ${reference} on ${object.apiName}.`); return section; } /** References and compilation reuse one materialized schema; authored children remain explicit roots. */ export function manifestObject(reference: ObjectReference): CustomObject { return objectMetadata(reference).object; } /** Adapts a typed schema field to the existing layout, view, and rule component APIs. */ export function manifestField( object: TObject, field: NoInfer>, ): Field { const metadata = objectMetadata(object); const resolved = metadata.object; const candidate = field as CustomObjectFieldInput | AnyCustomObjectDefinition['fields'][string]; if (typeof candidate === 'string') return resolved.standardField(candidate); if ('getApiName' in candidate) { if (candidate.getCustomObjectApiName() !== resolved.getApiName()) throw new Error('Field reference belongs to another object.'); return candidate; } if ('objectApiName' in candidate && candidate.objectApiName !== object.apiName) throw new Error('Field reference belongs to another object.'); const registered = metadata.fields[candidate.logicalName]; if (registered == null) throw new Error(`Unknown field ${candidate.logicalName} on ${object.apiName}.`); return registered; } const behavior = { type: 'CUSTOM_OBJECT' as const, references(definition: AnyCustomObjectDefinition): () => object[] { const category = definition.categoryDefinition; if (category != null && registeredComponent(category) == null) categoryComponent.register(category, category); return () => [ ...(category == null ? [] : [category]), ...Object.values(registrations.get(definition)?.fields ?? {}).flatMap(fieldSectionReferences), ...(Object.values(definition.fields) as AnyFieldDefinition[]).flatMap((field) => field.kind === 'lookup' || field.kind === 'parent-child' ? isObjectDefinition(field.target) ? [field.target] : [] : [], ), ]; }, compileBatch(definitions: readonly AnyCustomObjectDefinition[], context: CompileContext) { const categories = new Set( context.definitions .filter((definition) => registeredComponent(definition)?.type === 'CUSTOM_CATEGORY') .flatMap((definition) => ('apiName' in definition ? [definition.apiName] : [])), ); // Generate defaults from the complete schema, including base definitions reached through children. const schemas = new Map(); for (const definition of definitions) { const previous = schemas.get(definition.apiName); if (previous == null) { schemas.set(definition.apiName, definition); continue; } schemas.set(definition.apiName, { ...previous, sections: { ...previous.sections, ...definition.sections }, fields: Object.fromEntries( [...Object.values(previous.fields), ...Object.values(definition.fields)].map((field) => [ field.apiName, field, ]), ), }); } const defaults = new Map([...schemas].map(([apiName, schema]) => [apiName, objectMetadata(schema)])); return definitions.flatMap((definition) => { if (!categories.has(definition.categoryApiName)) throw new Error( `Include a category definition for ${definition.categoryApiName}, or use a typed category in ${definition.apiName}.`, ); const generated = defaults.get(definition.apiName)!; const authoredSections = new Set( Object.keys(definition.sections).map((key) => fieldSectionApiName(definition, key)), ); const metadata = objectMetadata(definition); return metadata.components.map((component) => { const config = (component === metadata.layout ? generated.layout : component).toDict(); if (component === metadata.object) config['default_field_section_api_name'] = generated.defaultFieldSectionApiName; return { config: config as MetadataConfig, fallback: component === metadata.layout || (component === metadata.defaultSection && !authoredSections.has(config['api_name'])), }; }); }); }, }; const customObjectComponent: ComponentDefinition = defineComponent({ ...behavior, define: defineCustomObject, }); const extendedObjectComponent = defineComponent({ ...behavior, define: extendCustomObject, });