import type { CustomObjectDataRow, RecordQueryParams, RecordQueryResponse, } from '../../../resources/custom-objects/records'; import { validateCreateInput, validateUpdateInput, type ValidationIssue } from './validation'; import type { AnyCustomObjectDefinition, AnyFieldDefinition, CreateInput, LookupReference, RecordShape, UpdateInput, } from './types'; /** Successful write receipt when read access does not permit returning the record. */ export interface CustomObjectRecordWriteReceipt { readonly id: string; } /** A readable wire-level record or an ID-only write receipt. */ export type CustomObjectRecordWriteResult = CustomObjectDataRow | CustomObjectRecordWriteReceipt; /** A readable logical-key record or an ID-only write receipt. Check for `name` before reading record fields. */ export type TypedRecordWriteResult = | RecordShape | CustomObjectRecordWriteReceipt; /** * Minimal records-resource surface consumed by {@link CustomObjectManager}. * * This is intentionally narrower than the full SDK client. Callers can pass the * real `client.customObjects.records` resource or a lightweight test double * exposing the same methods. */ export interface CustomObjectRecordsClient { create( customObjectApiName: string, body: Record, ): Promise<{ data?: CustomObjectRecordWriteResult }>; retrieve(customObjectApiName: string, recordId: string): Promise; retrieveByExternalID(customObjectApiName: string, externalID: string): Promise; update( customObjectApiName: string, recordId: string, body: Record, ): Promise<{ data?: CustomObjectRecordWriteResult }>; delete(customObjectApiName: string, recordId: string): Promise; query(customObjectApiName: string, params: RecordQueryParams): Promise; bulkCreate( customObjectApiName: string, body: { all_or_nothing?: boolean; rows_to_write: Array<{ [key: string]: unknown }> }, ): Promise<{ data?: CustomObjectRecordWriteResult[] }>; bulkUpdate( customObjectApiName: string, body: { all_or_nothing?: boolean; rows_to_update: Array<{ id: string; [key: string]: unknown }> }, ): Promise<{ data?: CustomObjectRecordWriteResult[] }>; bulkDelete( customObjectApiName: string, body: { all_or_nothing?: boolean; rows_to_delete: string[] }, ): Promise; } /** Minimal SDK-shaped wrapper accepted by {@link CustomObjectManager.fromSDK}. */ interface CustomObjectManagerSDKLike { readonly customObjects: { readonly records: CustomObjectRecordsClient; }; } /** Response shape returned by low-level record queries. */ export interface CustomObjectRecordQueryResponse { /** Opaque pagination cursor returned by the backend, if any. */ readonly cursor: string | null; /** Raw backend rows returned for the query. */ readonly results: readonly CustomObjectDataRow[]; } /** Adapter request for batch creates. */ export interface CustomObjectRecordBulkCreateRequest { /** Whether the backend should reject the entire batch if any row fails. */ readonly allOrNothing?: boolean; /** Wire-level rows to create. */ readonly rowsToWrite: readonly Record[]; } /** Adapter request for batch updates. */ export interface CustomObjectRecordBulkUpdateRequest { /** Whether the backend should reject the entire batch if any row fails. */ readonly allOrNothing?: boolean; /** Wire-level rows to update, including each record id. */ readonly rowsToUpdate: readonly { id: string; [key: string]: unknown }[]; } /** Adapter request for batch deletes. */ export interface CustomObjectRecordBulkDeleteRequest { /** Whether the backend should reject the entire batch if any row fails. */ readonly allOrNothing?: boolean; /** Record ids to delete. */ readonly rowsToDelete: readonly string[]; } /** * Small transport boundary used by the typed manager. * * Production code can use {@link createCustomObjectRecordAdapter}. Unit tests * can provide an in-memory or hand-rolled adapter without depending on a real * backend client. */ export interface CustomObjectRecordAdapter { /** Creates one record, returning readable fields or an ID-only receipt. */ create(customObjectApiName: string, body: Record): Promise; /** Retrieves one wire-level record by id. */ retrieve(customObjectApiName: string, recordId: string): Promise; /** Retrieves one wire-level record by external id. */ retrieveByExternalID(customObjectApiName: string, externalID: string): Promise; /** Updates one record, returning readable fields or an ID-only receipt. */ update( customObjectApiName: string, recordId: string, body: Record, ): Promise; /** Deletes one wire-level record. */ delete(customObjectApiName: string, recordId: string): Promise; /** Runs a backend query and returns raw rows. */ query(customObjectApiName: string, params: RecordQueryParams): Promise; /** Creates many records, returning readable fields or ID-only receipts in response order. */ bulkCreate( customObjectApiName: string, request: CustomObjectRecordBulkCreateRequest, ): Promise; /** Updates many records, returning readable fields or ID-only receipts in response order. */ bulkUpdate( customObjectApiName: string, request: CustomObjectRecordBulkUpdateRequest, ): Promise; /** Deletes many wire-level rows. */ bulkDelete(customObjectApiName: string, request: CustomObjectRecordBulkDeleteRequest): Promise; } /** Constructor options for {@link CustomObjectManager}. */ export interface CustomObjectManagerOptions { /** Records resource to use for transport. */ readonly records?: CustomObjectRecordsClient; /** Custom adapter implementation for tests or non-SDK backends. */ readonly adapter?: CustomObjectRecordAdapter; } /** Lightweight pagination options shared by list and query helpers. */ export interface TypedRecordPageOptions { /** Opaque pagination cursor returned by a prior call. */ readonly cursor?: string; /** Maximum number of records to return. */ readonly limit?: number; } /** Raw-query options for typed record queries. */ export interface TypedRecordQueryOptions extends TypedRecordPageOptions { /** RQL string to execute against the underlying backend. */ readonly query?: string; } /** Pagination result returned by typed list/query helpers. */ export interface TypedRecordPage { /** Opaque backend cursor, if another page is available. */ readonly cursor: string | null; /** Strongly typed logical-key records. */ readonly results: readonly RecordShape[]; } /** Batch-write flags shared by bulk create/update/delete helpers. */ export interface TypedBatchWriteOptions { /** Whether the backend should reject the entire batch if any row fails. */ readonly allOrNothing?: boolean; } /** * One logical update item for {@link TypedCustomObjectRecordManager.updateMany}. * * @typeParam TDefinition - Object definition whose update payload should be enforced. */ export type TypedBulkUpdateInput = { readonly id: string; } & UpdateInput; /** * Structured validation failure thrown by typed record managers. * * @typeParam TDefinition - Object definition whose payload validation failed. */ export class CustomObjectRecordValidationError< TDefinition extends AnyCustomObjectDefinition = AnyCustomObjectDefinition, > extends Error { /** Definition whose payload validation failed. */ readonly definition: TDefinition; /** Operation that was being attempted. */ readonly operation: 'create' | 'update' | 'bulk-create' | 'bulk-update'; /** Field-level validation issues returned by the schema validators. */ readonly issues: readonly ValidationIssue[]; /** * Builds a validation error for a typed record operation. * * @typeParam TDefinition - Object definition whose payload validation failed. */ constructor( definition: TDefinition, operation: 'create' | 'update' | 'bulk-create' | 'bulk-update', issues: readonly ValidationIssue[], ) { super( `Invalid ${operation} payload for ${definition.apiName}: ${issues .map((current) => `${current.path}: ${current.message}`) .join('; ')}`, ); this.name = 'CustomObjectRecordValidationError'; this.definition = definition; this.operation = operation; this.issues = issues; } } /** Options for creating an in-memory adapter. */ export interface InMemoryCustomObjectRecordAdapterOptions { /** Optional starting rows keyed by custom-object api name. */ readonly seedRowsByObjectApiName?: Readonly>; /** Optional deterministic clock used for tests. */ readonly now?: () => string; /** Optional deterministic id generator used for tests. */ readonly nextID?: (customObjectApiName: string) => string; /** * Optional raw-query evaluator. * * If omitted, the in-memory adapter supports pagination-only list/query calls * and rejects query strings that would require actual RQL evaluation. */ readonly queryEvaluator?: (args: { customObjectApiName: string; query: string; row: CustomObjectDataRow; }) => boolean; } /** * 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); } /** * Requires a create/update response to contain a record or ID-only receipt. * * @param customObjectApiName - Custom-object API name involved in the operation. * @param operation - Human-readable operation name for error messages. * @param row - Optional write result returned by the backend. * @returns Required write result. * @throws Error when the backend response is missing the write result. */ function requireSingleWriteResult( customObjectApiName: string, operation: string, row: CustomObjectRecordWriteResult | undefined, ): CustomObjectRecordWriteResult { if (row != null) return row; throw new Error(`Expected ${operation} on ${customObjectApiName} to return a record payload.`); } /** Decode readable fields only when the write returned a record rather than a receipt. */ function deserializeWriteResult( definition: TDefinition, result: CustomObjectRecordWriteResult, ): TypedRecordWriteResult { return 'name' in result ? deserializeRecord(definition, result) : { id: result.id }; } /** * Normalizes a lookup-or-file value into the wire-level id string expected by the REST API. * * @param value - Logical lookup or file value to normalize. * @returns Wire-level id string when possible, otherwise the original value. */ function toWireReference(value: unknown): unknown { if (typeof value === 'string') return value; if (isObject(value) && typeof value['id'] === 'string') return value['id']; return value; } /** * Serializes one logical field value into the backend wire shape. * * @param field - Typed field definition that owns the value. * @param value - Logical field value to serialize. * @returns Wire-level field value suitable for the records API. */ function serializeFieldValue(field: AnyFieldDefinition, value: unknown): unknown { switch (field.kind) { case 'lookup': case 'parent-child': case 'file': return toWireReference(value); default: return value; } } /** * Serializes a logical create/update payload into wire-level API-name keys. * * @param definition - Typed object definition that owns the payload fields. * @param input - Logical create/update payload to serialize. * @returns Wire-level payload suitable for the records API. * @typeParam TDefinition - Object definition being serialized. */ function serializeRecordInput( definition: TDefinition, input: Record, ): Record { const wire: Record = {}; if (Object.prototype.hasOwnProperty.call(input, 'name') && input['name'] !== undefined) { wire['name'] = input['name']; } if (Object.prototype.hasOwnProperty.call(input, 'external_id') && input['external_id'] !== undefined) { wire['external_id'] = input['external_id']; } if (Object.prototype.hasOwnProperty.call(input, 'owner_role') && input['owner_role'] !== undefined) { wire['owner_role'] = toWireReference(input['owner_role']); } for (const [logicalName, rawField] of Object.entries(definition.fields)) { if (!Object.prototype.hasOwnProperty.call(input, logicalName)) continue; const value = input[logicalName]; if (value === undefined) continue; const field = rawField as AnyFieldDefinition & { apiName: string }; wire[field.apiName] = serializeFieldValue(field, value); } return wire; } /** * Normalizes a backend lookup value into the logical record shape. * * @param targetApiName - Expected target API name when the backend omits it. * @param value - Raw backend lookup value. * @returns Logical lookup reference or `null` when the backend value cannot be interpreted. * @typeParam TApiName - Lookup target API name. */ function deserializeLookupReference( targetApiName: TApiName, value: unknown, ): LookupReference | null { if (typeof value === 'string') { return { id: value, apiName: targetApiName }; } if (!isObject(value)) return null; if (typeof value['id'] !== 'string') return null; return { id: value['id'], ...(typeof value['apiName'] === 'string' ? { apiName: value['apiName'] as TApiName } : { apiName: targetApiName }), }; } /** * Deserializes one backend field value into the logical typed shape. * * @param field - Typed field definition that owns the value. * @param value - Raw backend field value. * @returns Logical field value suitable for typed record helpers. */ function deserializeFieldValue(field: AnyFieldDefinition, value: unknown): unknown { if (value == null) return null; switch (field.kind) { case 'lookup': case 'parent-child': return deserializeLookupReference(field.targetApiName ?? 'unknown', value); case 'file': return ( typeof value === 'string' ? value : isObject(value) && typeof value['id'] === 'string' ? { id: value['id'] } : null ); default: return value; } } /** * Converts one raw backend row into the logical typed record shape. * * @param definition - Typed object definition that owns the row shape. * @param row - Raw backend row to deserialize. * @returns Typed logical record. * @typeParam TDefinition - Object definition being deserialized. */ function deserializeRecord( definition: TDefinition, row: CustomObjectDataRow, ): RecordShape { const typedRecord: Record = { id: row.id, name: row.name, created_at: typeof row.created_at === 'string' ? row.created_at : null, updated_at: typeof row.updated_at === 'string' ? row.updated_at : null, system_updated_at: typeof row.system_updated_at === 'string' ? row.system_updated_at : null, external_id: typeof row.external_id === 'string' ? row.external_id : null, owner_role: deserializeLookupReference('Employee', row.owner_role), created_by: deserializeLookupReference('Employee', row.created_by), last_modified_by: deserializeLookupReference('Employee', row.last_modified_by), }; for (const [logicalName, rawField] of Object.entries(definition.fields)) { const field = rawField as AnyFieldDefinition & { apiName: string }; typedRecord[logicalName] = deserializeFieldValue(field, row[field.apiName]); } return typedRecord as RecordShape; } /** * Prefixes validation issue paths for batch operations. * * @param prefix - Path prefix to prepend to each issue. * @param issues - Validation issues to rewrite. * @returns Rewritten validation issues with prefixed paths. */ function prefixIssues(prefix: string, issues: readonly ValidationIssue[]): ValidationIssue[] { return issues.map((current) => ({ path: `${prefix}.${current.path}`, message: current.message })); } /** * Validates one create payload and throws a typed error when invalid. * * @param definition - Typed object definition whose create payload is being validated. * @param input - Typed create payload to validate. * @throws CustomObjectRecordValidationError when the payload is invalid. * @typeParam TDefinition - Object definition being validated. */ function assertValidCreatePayload( definition: TDefinition, input: CreateInput, ): void { const result = validateCreateInput(definition, input as Record); if (!result.valid) throw new CustomObjectRecordValidationError(definition, 'create', result.issues); } /** * Validates one update payload and throws a typed error when invalid. * * @param definition - Typed object definition whose update payload is being validated. * @param input - Typed update payload to validate. * @throws CustomObjectRecordValidationError when the payload is invalid. * @typeParam TDefinition - Object definition being validated. */ function assertValidUpdatePayload( definition: TDefinition, input: UpdateInput, ): void { const result = validateUpdateInput(definition, input as Record); if (!result.valid) throw new CustomObjectRecordValidationError(definition, 'update', result.issues); } /** * Validates a batch of create payloads and throws a typed error when any row is invalid. * * @param definition - Typed object definition whose payloads are being validated. * @param rows - Typed create payloads to validate as a batch. * @throws CustomObjectRecordValidationError when any row is invalid. * @typeParam TDefinition - Object definition being validated. */ function assertValidCreateBatch( definition: TDefinition, rows: readonly CreateInput[], ): void { const issues = rows.flatMap((row, index) => { const result = validateCreateInput(definition, row as Record); return result.valid ? [] : prefixIssues(`rows[${index}]`, result.issues); }); if (issues.length > 0) throw new CustomObjectRecordValidationError(definition, 'bulk-create', issues); } /** * Validates a batch of update payloads and throws a typed error when any row is invalid. * * @param definition - Typed object definition whose payloads are being validated. * @param rows - Typed update payloads to validate as a batch. * @throws CustomObjectRecordValidationError when any row is invalid. * @typeParam TDefinition - Object definition being validated. */ function assertValidUpdateBatch( definition: TDefinition, rows: readonly TypedBulkUpdateInput[], ): void { const issues = rows.flatMap((row, index) => { if (row.id.trim().length === 0) { return [{ path: `rows[${index}].id`, message: 'Record id cannot be empty.' } satisfies ValidationIssue]; } const { id: _id, ...patch } = row; const result = validateUpdateInput(definition, patch as Record); return result.valid ? [] : prefixIssues(`rows[${index}]`, result.issues); }); if (issues.length > 0) throw new CustomObjectRecordValidationError(definition, 'bulk-update', issues); } /** * Records-resource adapter implementation for typed record managers. * * @param records - Low-level records resource to adapt into the manager-friendly transport boundary. * @returns Adapter implementation backed by the supplied records resource. */ export function createCustomObjectRecordAdapter( records: CustomObjectRecordsClient, ): CustomObjectRecordAdapter { return { async create(customObjectApiName, body) { const response = await records.create(customObjectApiName, body); return requireSingleWriteResult(customObjectApiName, 'create', response.data); }, retrieve(customObjectApiName, recordId) { return records.retrieve(customObjectApiName, recordId); }, retrieveByExternalID(customObjectApiName, externalID) { return records.retrieveByExternalID(customObjectApiName, externalID); }, async update(customObjectApiName, recordId, body) { const response = await records.update(customObjectApiName, recordId, body); return requireSingleWriteResult(customObjectApiName, 'update', response.data); }, delete(customObjectApiName, recordId) { return records.delete(customObjectApiName, recordId); }, async query(customObjectApiName, params) { const response = await records.query(customObjectApiName, params); return { cursor: response.cursor ?? null, results: response.results ?? [], }; }, async bulkCreate(customObjectApiName, request) { const response = await records.bulkCreate(customObjectApiName, { ...(request.allOrNothing != null ? { all_or_nothing: request.allOrNothing } : {}), rows_to_write: [...request.rowsToWrite], }); return response.data ?? []; }, async bulkUpdate(customObjectApiName, request) { const response = await records.bulkUpdate(customObjectApiName, { ...(request.allOrNothing != null ? { all_or_nothing: request.allOrNothing } : {}), rows_to_update: [...request.rowsToUpdate], }); return response.data ?? []; }, async bulkDelete(customObjectApiName, request) { await records.bulkDelete(customObjectApiName, { ...(request.allOrNothing != null ? { all_or_nothing: request.allOrNothing } : {}), rows_to_delete: [...request.rowsToDelete], }); }, }; } /** * In-memory adapter useful for unit tests and local flows that should not depend * on a real SDK transport. */ export class InMemoryCustomObjectRecordAdapter implements CustomObjectRecordAdapter { private readonly rowsByObjectApiName: Map>; private readonly now: () => string; private readonly nextID: (customObjectApiName: string) => string; private readonly queryEvaluator?: InMemoryCustomObjectRecordAdapterOptions['queryEvaluator']; private sequence = 0; /** Builds a new in-memory adapter. */ constructor(options: InMemoryCustomObjectRecordAdapterOptions = {}) { this.rowsByObjectApiName = new Map( Object.entries(options.seedRowsByObjectApiName ?? {}).map(([customObjectApiName, rows]) => [ customObjectApiName, new Map(rows.map((row) => [row.id, { ...row }])), ]), ); this.now = options.now ?? (() => new Date().toISOString()); this.nextID = options.nextID ?? ((customObjectApiName) => { this.sequence += 1; return `${customObjectApiName.replace(/__c$/, '')}_row_${this.sequence}`; }); this.queryEvaluator = options.queryEvaluator; } private rowMap(customObjectApiName: string): Map { const existing = this.rowsByObjectApiName.get(customObjectApiName); if (existing != null) return existing; const created = new Map(); this.rowsByObjectApiName.set(customObjectApiName, created); return created; } private requiredRow(customObjectApiName: string, recordId: string): CustomObjectDataRow { const row = this.rowMap(customObjectApiName).get(recordId); if (row != null) return row; throw new Error(`Record ${recordId} was not found in ${customObjectApiName}.`); } private paginate( rows: readonly CustomObjectDataRow[], params: TypedRecordPageOptions, ): CustomObjectRecordQueryResponse { const startIndex = params.cursor == null ? 0 : Number(params.cursor); const safeStartIndex = Number.isFinite(startIndex) && startIndex >= 0 ? startIndex : 0; const endIndex = params.limit == null ? rows.length : safeStartIndex + params.limit; const page = rows.slice(safeStartIndex, endIndex); const cursor = endIndex < rows.length ? String(endIndex) : null; return { cursor, results: page }; } async create(customObjectApiName: string, body: Record): Promise { const id = this.nextID(customObjectApiName); const timestamp = this.now(); const row: CustomObjectDataRow = { id, name: typeof body['name'] === 'string' ? body['name'] : id, created_at: timestamp, updated_at: timestamp, ...body, }; this.rowMap(customObjectApiName).set(id, row); return { ...row }; } async retrieve(customObjectApiName: string, recordId: string): Promise { return { ...this.requiredRow(customObjectApiName, recordId) }; } async retrieveByExternalID(customObjectApiName: string, externalID: string): Promise { const row = [...this.rowMap(customObjectApiName).values()].find( (candidate) => candidate.external_id === externalID, ); if (row != null) return { ...row }; throw new Error(`No record with external_id ${externalID} exists in ${customObjectApiName}.`); } async update( customObjectApiName: string, recordId: string, body: Record, ): Promise { const previous = this.requiredRow(customObjectApiName, recordId); const next: CustomObjectDataRow = { ...previous, ...body, updated_at: this.now(), name: typeof body['name'] === 'string' ? body['name'] : previous.name, }; this.rowMap(customObjectApiName).set(recordId, next); return { ...next }; } async delete(customObjectApiName: string, recordId: string): Promise { const deleted = this.rowMap(customObjectApiName).delete(recordId); if (!deleted) throw new Error(`Record ${recordId} was not found in ${customObjectApiName}.`); } async query( customObjectApiName: string, params: RecordQueryParams, ): Promise { const rows = [...this.rowMap(customObjectApiName).values()]; const filtered = params.query == null || params.query.trim().length === 0 ? rows : this.queryEvaluator == null ? (() => { throw new Error( `In-memory adapter for ${customObjectApiName} does not support raw query strings without a queryEvaluator.`, ); })() : rows.filter((row) => this.queryEvaluator!({ customObjectApiName, query: params.query!, row })); return this.paginate(filtered, params); } async bulkCreate( customObjectApiName: string, request: CustomObjectRecordBulkCreateRequest, ): Promise { const created: CustomObjectDataRow[] = []; for (const row of request.rowsToWrite) { created.push(await this.create(customObjectApiName, { ...row })); } return created; } async bulkUpdate( customObjectApiName: string, request: CustomObjectRecordBulkUpdateRequest, ): Promise { const updated: CustomObjectDataRow[] = []; for (const row of request.rowsToUpdate) { if (typeof row['id'] !== 'string') { throw new Error(`Bulk update for ${customObjectApiName} requires each row to include an id.`); } const { id, ...patch } = row; updated.push(await this.update(customObjectApiName, row['id'], patch)); void id; } return updated; } async bulkDelete(customObjectApiName: string, request: CustomObjectRecordBulkDeleteRequest): Promise { for (const recordId of request.rowsToDelete) { await this.delete(customObjectApiName, recordId); } } } /** * Definition-bound CRUD manager returned by {@link CustomObjectManager.forObject}. * * @typeParam TDefinition - Object definition whose logical field names and value * types should be enforced. */ export class TypedCustomObjectRecordManager { private readonly definition: TDefinition; private readonly adapter: CustomObjectRecordAdapter; /** * Creates a bound typed manager for one object definition. * * @param definition - Typed object definition to bind to the manager. * @param adapter - Transport adapter used for all record operations. */ constructor(definition: TDefinition, adapter: CustomObjectRecordAdapter) { this.definition = definition; this.adapter = adapter; } /** * Creates one typed record after local schema validation. * * @param input - Typed logical create payload. * @returns Created typed record, or an ID-only receipt when read access is unavailable. */ async create(input: CreateInput): Promise> { assertValidCreatePayload(this.definition, input); const row = await this.adapter.create( this.definition.apiName, serializeRecordInput(this.definition, input), ); return deserializeWriteResult(this.definition, row); } /** * Creates many typed records after validating the full batch locally. * * @param rows - Typed logical create payloads. * @param options - Batch-write options forwarded to the adapter. * @returns Typed records or ID-only receipts in backend response order. */ async createMany( rows: readonly CreateInput[], options: TypedBatchWriteOptions = {}, ): Promise[]> { assertValidCreateBatch(this.definition, rows); const created = await this.adapter.bulkCreate(this.definition.apiName, { ...(options.allOrNothing != null ? { allOrNothing: options.allOrNothing } : {}), rowsToWrite: rows.map((row) => serializeRecordInput(this.definition, row)), }); return created.map((row) => deserializeWriteResult(this.definition, row)); } /** * Retrieves one typed record by id. * * @param recordId - Backend record id to fetch. * @returns Typed record for the requested id. */ async get(recordId: string): Promise> { const row = await this.adapter.retrieve(this.definition.apiName, recordId); return deserializeRecord(this.definition, row); } /** * Retrieves one typed record by external id. * * @param externalID - External id to look up. * @returns Typed record for the requested external id. */ async getByExternalID(externalID: string): Promise> { const row = await this.adapter.retrieveByExternalID(this.definition.apiName, externalID); return deserializeRecord(this.definition, row); } /** * Updates one typed record after local schema validation. * * @param recordId - Backend record id to update. * @param patch - Typed logical update payload. * @returns Updated typed record, or an ID-only receipt when read access is unavailable. */ async update( recordId: string, patch: UpdateInput, ): Promise> { assertValidUpdatePayload(this.definition, patch); const row = await this.adapter.update( this.definition.apiName, recordId, serializeRecordInput(this.definition, patch), ); return deserializeWriteResult(this.definition, row); } /** * Updates many typed records after validating the full batch locally. * * @param rows - Typed update payloads, each including a backend record id. * @param options - Batch-write options forwarded to the adapter. * @returns Typed records or ID-only receipts in backend response order. */ async updateMany( rows: readonly TypedBulkUpdateInput[], options: TypedBatchWriteOptions = {}, ): Promise[]> { assertValidUpdateBatch(this.definition, rows); const updated = await this.adapter.bulkUpdate(this.definition.apiName, { ...(options.allOrNothing != null ? { allOrNothing: options.allOrNothing } : {}), rowsToUpdate: rows.map((row) => { const { id, ...patch } = row; return { id, ...serializeRecordInput(this.definition, patch) }; }), }); return updated.map((row) => deserializeWriteResult(this.definition, row)); } /** * Deletes one record by id. * * @param recordId - Backend record id to delete. */ delete(recordId: string): Promise { return this.adapter.delete(this.definition.apiName, recordId); } /** * Deletes many records by id. * * @param recordIDs - Backend record ids to delete. * @param options - Batch-write options forwarded to the adapter. */ deleteMany(recordIDs: readonly string[], options: TypedBatchWriteOptions = {}): Promise { return this.adapter.bulkDelete(this.definition.apiName, { ...(options.allOrNothing != null ? { allOrNothing: options.allOrNothing } : {}), rowsToDelete: recordIDs, }); } /** * Lists records using backend pagination without applying any query filter. * * @param options - Pagination options for the backend list operation. * @returns Typed record page. */ async list(options: TypedRecordPageOptions = {}): Promise> { const page = await this.adapter.query(this.definition.apiName, options); return { cursor: page.cursor, results: page.results.map((row) => deserializeRecord(this.definition, row)), }; } /** * Queries records with an optional raw RQL filter string. * * @param options - Query and pagination options for the backend query operation. * @returns Typed record page. */ async query(options: TypedRecordQueryOptions = {}): Promise> { const page = await this.adapter.query(this.definition.apiName, options); return { cursor: page.cursor, results: page.results.map((row) => deserializeRecord(this.definition, row)), }; } } /** * Root manager that binds typed custom-object definitions to a transport adapter. * * Callers can pass either a records resource or a test adapter. If neither is * provided, the manager defaults to an in-memory adapter so unit tests can use * the same high-level API without bootstrapping a backend client. */ export class CustomObjectManager { private readonly adapter: CustomObjectRecordAdapter; /** * Creates a new custom-object manager. * * @param options - Optional records resource or adapter implementation. */ constructor(options: CustomObjectManagerOptions = {}) { if (options.records != null && options.adapter != null) { throw new Error('Pass either records or adapter to CustomObjectManager, not both.'); } this.adapter = options.adapter ?? (options.records != null ? createCustomObjectRecordAdapter(options.records) : new InMemoryCustomObjectRecordAdapter()); } /** * Builds a manager from a records resource. * * @param records - Low-level records resource to wrap. * @returns Custom-object manager backed by that records resource. */ static fromRecords(records: CustomObjectRecordsClient): CustomObjectManager { return new CustomObjectManager({ records }); } /** * Builds a manager from a real SDK client. * * @param sdk - SDK-shaped client exposing `customObjects.records`. * @returns Custom-object manager backed by the SDK records resource. */ static fromSDK(sdk: CustomObjectManagerSDKLike): CustomObjectManager { return new CustomObjectManager({ records: sdk.customObjects.records }); } /** * Builds a manager backed by an in-memory adapter. * * @param options - In-memory adapter options for seed data and deterministic behavior. * @returns Custom-object manager backed by an in-memory adapter. */ static inMemory(options: InMemoryCustomObjectRecordAdapterOptions = {}): CustomObjectManager { return new CustomObjectManager({ adapter: new InMemoryCustomObjectRecordAdapter(options) }); } /** Returns the low-level adapter used by this manager. */ getAdapter(): CustomObjectRecordAdapter { return this.adapter; } /** * Returns a typed record manager bound to one object definition. * * @param definition - Typed object definition to bind. * @returns Definition-bound typed record manager. * @typeParam TDefinition - Object definition bound to the manager. */ forObject( definition: TDefinition, ): TypedCustomObjectRecordManager { return new TypedCustomObjectRecordManager(definition, this.adapter); } /** * Short ergonomic alias for {@link forObject}. * * @param definition - Typed object definition to bind. * @returns Definition-bound typed record manager. * @typeParam TDefinition - Object definition bound to the manager. */ for( definition: TDefinition, ): TypedCustomObjectRecordManager { return this.forObject(definition); } } /** Short alias for teams that prefer concise manager names. */ export class COManager extends CustomObjectManager {}