import { createCustomObjectRecordAdapter, CustomObjectRecordValidationError, type CustomObjectRecordWriteResult, type TypedRecordWriteResult, } from '../../../core/platform/custom-objects/manager'; import { validateCreateInput, validateUpdateInput, type ValidationIssue, } from '../../../core/platform/custom-objects/validation'; import type { AnyCustomObjectDefinition, AnyFieldDefinition, CreateInput, UpdateInput, FieldValue, RecordShape, } from '../../../core/platform/custom-objects/types'; import type { CustomObjectDataRow } from '../../../resources/custom-objects/records'; import type { ObjectRecords, ObjectRecordsContext } from './custom-object'; type NullableFields = { [K in keyof D['fields']]: D['fields'][K] extends { writable: true; required: false } ? K : never; }[keyof D['fields']]; /** Definition-based writes accept null for writable, non-required custom fields. */ export type ObjectCreateInput = Omit< CreateInput, NullableFields > & { [K in NullableFields]?: FieldValue | null; }; export type ObjectUpdateInput = Omit< UpdateInput, NullableFields > & { [K in NullableFields]?: FieldValue | null; }; export type ObjectBulkUpdateInput = { readonly id: string; } & ObjectUpdateInput; /** Reuse the existing value checks, excluding only explicitly allowed null custom-field values. */ function inputIssues( definition: AnyCustomObjectDefinition, input: Record, operation: 'create' | 'update', ): readonly ValidationIssue[] { if (input == null || typeof input !== 'object' || Array.isArray(input)) return [{ path: '', message: 'Expected a record object.' }]; const checked = { ...input }; for (const [key, field] of Object.entries(definition.fields)) { if (field.writable && !field.required && checked[key] === null) delete checked[key]; } return (operation === 'create' ? validateCreateInput : validateUpdateInput)(definition, checked).issues; } export function assertObjectInput( definition: AnyCustomObjectDefinition, input: Record, operation: 'create' | 'update', ): void { const issues = inputIssues(definition, input, operation); if (issues.length > 0) throw new CustomObjectRecordValidationError(definition, operation, issues); } function referenceID(value: unknown): unknown { return value != null && typeof value === 'object' && 'id' in value ? value.id : value; } function serializeInput( definition: AnyCustomObjectDefinition, input: Record, ): Record { const wire: Record = {}; for (const key of ['name', 'external_id', 'owner_role']) { if (Object.prototype.hasOwnProperty.call(input, key) && input[key] !== undefined) wire[key] = key === 'owner_role' ? referenceID(input[key]) : input[key]; } for (const [key, field] of Object.entries(definition.fields)) { if (!Object.prototype.hasOwnProperty.call(input, key) || input[key] === undefined) continue; wire[field.apiName] = field.kind === 'lookup' || field.kind === 'parent-child' || field.kind === 'file' ? referenceID(input[key]) : input[key]; } return wire; } function lookup(value: unknown, apiName: string) { if (typeof value === 'string') return { id: value, apiName }; if (value != null && typeof value === 'object' && 'id' in value && typeof value.id === 'string') { return { id: value.id, apiName: 'apiName' in value && typeof value.apiName === 'string' ? value.apiName : apiName, }; } return null; } function fieldValue(field: AnyFieldDefinition, value: unknown): unknown { if (value == null) return null; switch (field.kind) { case 'lookup': case 'parent-child': return lookup(value, field.targetApiName ?? 'unknown'); case 'file': return ( typeof value === 'string' ? value : value != null && typeof value === 'object' && 'id' in value && typeof value.id === 'string' ? { id: value.id } : null ); case 'number': case 'currency': case 'percentage': case 'formula-number': case 'formula-currency': case 'formula-percentage': { const numeric = typeof value === 'number' ? value : typeof value === 'string' && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value) ? Number(value) : NaN; if (!Number.isFinite(numeric)) throw new TypeError(`Invalid numeric value for ${field.apiName}: expected a finite number.`); return numeric; } default: return value; } } function decodeRecord( definition: D, row: CustomObjectDataRow, ): RecordShape { const record: Record = { id: row.id, name: row.name }; for (const key of ['created_at', 'updated_at', 'system_updated_at', 'external_id']) record[key] = typeof row[key] === 'string' ? row[key] : null; for (const key of ['owner_role', 'created_by', 'last_modified_by']) record[key] = lookup(row[key], 'Employee'); for (const [key, field] of Object.entries(definition.fields)) record[key] = fieldValue(field as AnyFieldDefinition, row[field.apiName]); return record as RecordShape; } /** Definition-specific nullability and decoding; the shared manager and legacy constructors are unchanged. */ export function createObjectRecords( definition: D, ): Readonly> { const adapter = (ctx: ObjectRecordsContext) => createCustomObjectRecordAdapter(ctx.customObjects.records); const decode = (row: CustomObjectDataRow) => decodeRecord(definition, row); const decodeWrite = (result: CustomObjectRecordWriteResult): TypedRecordWriteResult => 'name' in result ? decode(result) : { id: result.id }; const serializeBatch = (rows: readonly Record[], operation: 'create' | 'update') => { const issues = rows.flatMap((row, index) => { if (row == null || typeof row !== 'object' || Array.isArray(row)) return [{ path: `[${index}]`, message: 'Expected a record object.' }]; const { id, ...patch } = row; return [ ...(operation === 'update' && (typeof id !== 'string' || id.length === 0) ? [{ path: `[${index}].id`, message: 'Expected a record id string.' }] : []), ...inputIssues(definition, operation === 'update' ? patch : row, operation).map((issue) => ({ ...issue, path: `[${index}].${issue.path}`, })), ]; }); if (issues.length > 0) throw new CustomObjectRecordValidationError( definition, operation === 'create' ? 'bulk-create' : 'bulk-update', issues, ); return rows.map((row) => serializeInput(definition, row)); }; const query = async (ctx: ObjectRecordsContext, options: Parameters['query']>[1]) => { const page = await adapter(ctx).query(definition.apiName, options ?? {}); return { cursor: page.cursor, results: page.results.map(decode) }; }; return Object.freeze>({ async create(ctx, input) { assertObjectInput(definition, input, 'create'); return decodeWrite(await adapter(ctx).create(definition.apiName, serializeInput(definition, input))); }, async createMany(ctx, rows, options) { const rowsToWrite = serializeBatch(rows, 'create'); return (await adapter(ctx).bulkCreate(definition.apiName, { ...options, rowsToWrite })).map( decodeWrite, ); }, async get(ctx, recordID) { return decode(await adapter(ctx).retrieve(definition.apiName, recordID)); }, async getByExternalID(ctx, externalID) { return decode(await adapter(ctx).retrieveByExternalID(definition.apiName, externalID)); }, async update(ctx, recordID, patch) { assertObjectInput(definition, patch, 'update'); return decodeWrite( await adapter(ctx).update(definition.apiName, recordID, serializeInput(definition, patch)), ); }, async updateMany(ctx, rows, options) { const rowsToUpdate = serializeBatch(rows, 'update').map((patch, index) => ({ id: rows[index]!.id, ...patch, })); return (await adapter(ctx).bulkUpdate(definition.apiName, { ...options, rowsToUpdate })).map( decodeWrite, ); }, delete: (ctx, recordID) => adapter(ctx).delete(definition.apiName, recordID), deleteMany: (ctx, recordIDs, options) => adapter(ctx).bulkDelete(definition.apiName, { ...options, rowsToDelete: recordIDs }), list: (ctx, { where, ...options } = {}) => query(ctx, { ...options, ...(where === undefined ? {} : { query: where.text }) }), query, }); }