diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 922b00e266..570507337e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1927,7 +1927,9 @@ export class ObjectQL implements IDataEngine { fields: _findSchema.fields as any, searchableFields: (_findSchema as any).searchableFields, requestedFields: _reqFields, - displayField: (_findSchema as any).displayNameField, + // [ADR-0079] `nameField` is the canonical primary-title pointer; + // `displayNameField` is the deprecated alias (still honored). + displayField: (_findSchema as any).nameField ?? (_findSchema as any).displayNameField, }); if (_searchFilter) { ast.where = ast.where ? { $and: [ast.where, _searchFilter] } : _searchFilter; diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 01ec0a3a62..4cbf178b28 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -567,6 +567,21 @@ export class SchemaRegistry { // applySystemFields(). schema = applySystemFields(schema, { multiTenant: this.multiTenant }); + // TODO(ADR-0079): wire `provisionPrimary` (from `@objectstack/spec/data`) + // HERE — this is the object materialization seam. It should run for + // `ownership === 'own'` only (extensions must not synthesize a title) and + // AFTER `applySystemFields` (so a synthesized `name` co-exists with system + // columns). NOT wired yet on purpose: `provisionPrimary` SYNTHESIZES a real + // `name` text field when nothing is title-eligible, which the driver's + // `syncSchema` would materialize as a new DB column on dozens of title-less + // system/append-only tables (e.g. sys_record_share, sys_member) that today + // rely on `titleFormat`. Enabling that is a schema-migration-bearing change + // and must be staged behind the ADR-0079 required-title refine. Until then + // the canonical pointer is resolved on read via `resolveDisplayField`. + // To wire the DESIGNATE-only half safely (set nameField when derivable, + // never synthesize), split `provisionPrimary` or add a `{ synthesize:false }` + // option and call it here for own-objects. + const shortName = schema.name; const fqn = computeFQN(namespace, shortName); diff --git a/packages/platform-objects/src/audit/sys-email-template.object.ts b/packages/platform-objects/src/audit/sys-email-template.object.ts index 77aaec3d01..113076a678 100644 --- a/packages/platform-objects/src/audit/sys-email-template.object.ts +++ b/packages/platform-objects/src/audit/sys-email-template.object.ts @@ -25,6 +25,7 @@ export const SysEmailTemplate = ObjectSchema.create({ managedBy: 'config', description: 'Outbound email template (subject + body + variables) resolved by name+locale', displayNameField: 'label', + nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', compactLayout: ['name', 'label', 'category', 'locale', 'active'], diff --git a/packages/platform-objects/src/audit/sys-email.object.ts b/packages/platform-objects/src/audit/sys-email.object.ts index 90c620e0be..a9cfbfb91d 100644 --- a/packages/platform-objects/src/audit/sys-email.object.ts +++ b/packages/platform-objects/src/audit/sys-email.object.ts @@ -27,6 +27,7 @@ export const SysEmail = ObjectSchema.create({ managedBy: 'append-only', description: 'Outbound email delivery log', displayNameField: 'subject', + nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{subject}', compactLayout: ['subject', 'to', 'status', 'sent_at'], diff --git a/packages/platform-objects/src/audit/sys-job-queue.object.ts b/packages/platform-objects/src/audit/sys-job-queue.object.ts index 708701e2a6..3e55aec576 100644 --- a/packages/platform-objects/src/audit/sys-job-queue.object.ts +++ b/packages/platform-objects/src/audit/sys-job-queue.object.ts @@ -33,6 +33,7 @@ export const SysJobQueue = ObjectSchema.create({ managedBy: 'system', description: 'Durable job/message queue including dead letters', displayNameField: 'queue', + nameField: 'queue', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{queue} #{id}', compactLayout: ['queue', 'status', 'attempts', 'scheduled_for', 'last_error'], diff --git a/packages/platform-objects/src/audit/sys-job-run.object.ts b/packages/platform-objects/src/audit/sys-job-run.object.ts index fce4f7e6d2..dae3eeff5c 100644 --- a/packages/platform-objects/src/audit/sys-job-run.object.ts +++ b/packages/platform-objects/src/audit/sys-job-run.object.ts @@ -24,6 +24,7 @@ export const SysJobRun = ObjectSchema.create({ managedBy: 'append-only', description: 'Background job execution audit trail', displayNameField: 'job_name', + nameField: 'job_name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{job_name} @ {started_at}', compactLayout: ['job_name', 'status', 'started_at', 'duration_ms', 'attempt'], diff --git a/packages/platform-objects/src/audit/sys-job.object.ts b/packages/platform-objects/src/audit/sys-job.object.ts index ca61d0d9eb..34789680d6 100644 --- a/packages/platform-objects/src/audit/sys-job.object.ts +++ b/packages/platform-objects/src/audit/sys-job.object.ts @@ -24,6 +24,7 @@ export const SysJob = ObjectSchema.create({ managedBy: 'system', description: 'Catalogue of registered background jobs', displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', compactLayout: ['name', 'schedule_type', 'active', 'last_run_at', 'last_status'], diff --git a/packages/platform-objects/src/audit/sys-notification.object.ts b/packages/platform-objects/src/audit/sys-notification.object.ts index 17fff61221..242fd806ee 100644 --- a/packages/platform-objects/src/audit/sys-notification.object.ts +++ b/packages/platform-objects/src/audit/sys-notification.object.ts @@ -34,6 +34,7 @@ export const SysNotification = ObjectSchema.create({ managedBy: 'system', description: 'Notification events — one row per emit() (ADR-0030 Layer 2 ingress)', displayNameField: 'topic', + nameField: 'topic', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{topic}', compactLayout: ['topic', 'severity', 'source_object', 'created_at'], diff --git a/packages/platform-objects/src/audit/sys-saved-report.object.ts b/packages/platform-objects/src/audit/sys-saved-report.object.ts index 28f1a0bb82..9bd4481645 100644 --- a/packages/platform-objects/src/audit/sys-saved-report.object.ts +++ b/packages/platform-objects/src/audit/sys-saved-report.object.ts @@ -32,6 +32,7 @@ export const SysSavedReport = ObjectSchema.create({ managedBy: 'platform', description: 'Persisted ObjectQL report definition — re-runnable and schedulable', displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', compactLayout: ['name', 'object_name', 'format', 'owner_id', 'updated_at'], diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index e8e337807f..8586ff4760 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -30,6 +30,7 @@ export const SysApiKey = ObjectSchema.create({ }, description: 'API keys for programmatic access', displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', compactLayout: ['name', 'prefix', 'user_id', 'expires_at', 'revoked'], diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index 849c7589eb..2eed6e1193 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -29,6 +29,7 @@ export const SysBusinessUnit = ObjectSchema.create({ managedBy: 'platform', description: 'Canonical Business Unit tree — hierarchical org/data-partition node (company / division / department / region / office). ADR-0057 D2.', displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', compactLayout: ['name', 'kind', 'parent_business_unit_id', 'manager_user_id'], diff --git a/packages/platform-objects/src/identity/sys-device-code.object.ts b/packages/platform-objects/src/identity/sys-device-code.object.ts index 4cc6c287b0..7f856a8daa 100644 --- a/packages/platform-objects/src/identity/sys-device-code.object.ts +++ b/packages/platform-objects/src/identity/sys-device-code.object.ts @@ -35,6 +35,7 @@ export const SysDeviceCode = ObjectSchema.create({ docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', }, description: 'OAuth 2.0 Device Authorization Grant (RFC 8628) pending requests', + nameField: 'user_code', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) titleFormat: '{user_code}', compactLayout: ['user_code', 'status', 'client_id', 'expires_at'], diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index ef3b6bf9ce..906f105fea 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -34,6 +34,7 @@ export const SysOauthApplication = ObjectSchema.create({ }, description: 'Registered OAuth/OIDC client applications', displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', compactLayout: ['name', 'client_id', 'type', 'disabled'], diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index edb31b2294..7f6946bad2 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -27,6 +27,7 @@ export const SysOrganization = ObjectSchema.create({ }, description: 'Organizations for multi-tenant grouping', displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', compactLayout: ['name', 'slug'], diff --git a/packages/platform-objects/src/identity/sys-scim-provider.object.ts b/packages/platform-objects/src/identity/sys-scim-provider.object.ts index 3fd52248d5..312174dc38 100644 --- a/packages/platform-objects/src/identity/sys-scim-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-provider.object.ts @@ -41,6 +41,7 @@ export const SysScimProvider = ObjectSchema.create({ }, description: 'SCIM 2.0 connections (bearer tokens) external IdPs use to provision/deprovision this environment\'s users', displayNameField: 'provider_id', + nameField: 'provider_id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{provider_id}', compactLayout: ['provider_id', 'organization_id'], diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index d9501cde76..646af30308 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -31,6 +31,7 @@ export const SysSession = ObjectSchema.create({ }, description: 'Active user sessions', displayNameField: 'user_id', + nameField: 'user_id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: 'Session — {user_id}', compactLayout: ['user_id', 'ip_address', 'expires_at'], diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index b6c601699a..b6812b91d5 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -55,6 +55,7 @@ export const SysSsoProvider = ObjectSchema.create({ }, description: 'External SSO identity providers (OIDC / SAML) this environment federates login to', displayNameField: 'provider_id', + nameField: 'provider_id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{provider_id}', compactLayout: ['provider_id', 'issuer', 'domain'], diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 2ae512dfcf..1b572d1cd3 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -27,6 +27,7 @@ export const SysTeam = ObjectSchema.create({ }, description: 'Teams within organizations for fine-grained grouping', displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', compactLayout: ['name', 'organization_id'], diff --git a/packages/platform-objects/src/identity/sys-user-preference.object.ts b/packages/platform-objects/src/identity/sys-user-preference.object.ts index da18854213..c57c98bae9 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.object.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.object.ts @@ -27,6 +27,7 @@ export const SysUserPreference = ObjectSchema.create({ // surface in Setup is a support/diagnostic view only. managedBy: 'system', description: 'Per-user key-value preferences (theme, locale, etc.)', + nameField: 'key', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) titleFormat: '{key}', compactLayout: ['user_id', 'key'], diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index e60cf51511..22726b2f19 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -28,6 +28,7 @@ export const SysUser = ObjectSchema.create({ }, description: 'User accounts for authentication', displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', compactLayout: ['name', 'email', 'email_verified'], diff --git a/packages/platform-objects/src/system/sys-setting.object.ts b/packages/platform-objects/src/system/sys-setting.object.ts index d13f05b034..3e4a651864 100644 --- a/packages/platform-objects/src/system/sys-setting.object.ts +++ b/packages/platform-objects/src/system/sys-setting.object.ts @@ -40,6 +40,7 @@ export const SysSetting = ObjectSchema.create({ managedBy: 'system', description: 'Generic K/V store backing the SettingsManifest contract.', displayNameField: 'key', + nameField: 'key', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{namespace}.{key}', compactLayout: ['namespace', 'key', 'scope', 'updated_at'], diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 33fdbb05ad..ee18d72d25 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -1419,7 +1419,9 @@ export class ApprovalService implements IApprovalService { try { const schema: any = (this.engine as any).getSchema?.(object); const fields = schema?.fields ?? {}; - const declared = schema?.displayNameField; + // [ADR-0079] `nameField` is the canonical primary-title pointer; + // `displayNameField` is the deprecated alias (still honored). + const declared = schema?.nameField ?? schema?.displayNameField; if (declared && declared !== 'id' && fields[declared]) return declared; for (const cand of ['name', 'title', 'subject', 'label']) { if (fields[cand]) return cand; diff --git a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts index 915e48d8e3..4d7e9399d1 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts @@ -22,6 +22,7 @@ export const SysApprovalAction = ObjectSchema.create({ managedBy: 'append-only', description: 'Append-only audit trail for approval actions', displayNameField: 'id', + nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{action} · {step_name}', compactLayout: ['request_id', 'step_name', 'action', 'actor_id', 'created_at'], diff --git a/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts index 55c55f1b08..01275610ef 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts @@ -33,6 +33,7 @@ export const SysApprovalApprover = ObjectSchema.create({ managedBy: 'system', description: 'Normalized pending-approver rows for indexed inbox queries', displayNameField: 'id', + nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{approver} · {request_id}', compactLayout: ['request_id', 'approver', 'created_at'], diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index f976a08765..1a8fb74c85 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -31,6 +31,7 @@ export const SysApprovalRequest = ObjectSchema.create({ managedBy: 'system', description: 'Live approval instance tracked per submission', displayNameField: 'id', + nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{process_name} · {record_id}', compactLayout: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'], diff --git a/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts index 5051d4703d..3eac233a88 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts @@ -22,6 +22,7 @@ export const SysApprovalToken = ObjectSchema.create({ managedBy: 'system', description: 'Single-use tokens behind actionable approval links', displayNameField: 'id', + nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) fields: { id: Field.text({ label: 'Token ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts b/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts index 93543f0811..ab164f60d9 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts @@ -25,6 +25,7 @@ export const SysActivity = ObjectSchema.create({ managedBy: 'append-only', description: 'Recent activity stream entries (lightweight, denormalized)', displayNameField: 'summary', + nameField: 'summary', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{type} · {summary}', compactLayout: ['timestamp', 'type', 'actor_name', 'summary'], diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts index 3bc8c9fcf8..83c23c2e82 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts @@ -22,6 +22,7 @@ export const SysAuditLog = ObjectSchema.create({ managedBy: 'append-only', description: 'Immutable audit trail for platform events', displayNameField: 'action', + nameField: 'action', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{action} · {object_name}', compactLayout: ['created_at', 'action', 'object_name', 'record_id', 'user_id'], diff --git a/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts b/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts index 0ed7a9898f..bfdd0d5947 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts @@ -26,6 +26,7 @@ export const SysComment = ObjectSchema.create({ managedBy: 'platform', description: 'Threaded comments attached to records via thread_id', displayNameField: 'body', + nameField: 'body', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{author_name}: {body}', compactLayout: ['created_at', 'author_name', 'body'], diff --git a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts b/packages/plugins/plugin-security/src/objects/sys-capability.object.ts index d22ddf356a..0fa21fe9a6 100644 --- a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-capability.object.ts @@ -39,6 +39,7 @@ export const SysCapability = ObjectSchema.create({ }, description: 'Authorization capability definitions (ADR-0066 D1). Referenced by name from permission-set systemPermissions and resource requiredPermissions.', displayNameField: 'label', + nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', compactLayout: ['label', 'name', 'scope', 'managed_by', 'active'], diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index 8209db94bd..0fba1f35df 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -27,6 +27,7 @@ export const SysPermissionSet = ObjectSchema.create({ }, description: 'Named permission groupings for fine-grained access control', displayNameField: 'label', + nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', compactLayout: ['label', 'name', 'active'], diff --git a/packages/plugins/plugin-security/src/objects/sys-role.object.ts b/packages/plugins/plugin-security/src/objects/sys-role.object.ts index ac0fd9e229..01759f8002 100644 --- a/packages/plugins/plugin-security/src/objects/sys-role.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-role.object.ts @@ -30,6 +30,7 @@ export const SysRole = ObjectSchema.create({ }, description: 'Role definitions for RBAC access control', displayNameField: 'label', + nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', compactLayout: ['label', 'name', 'active', 'is_default'], diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index efe86be085..fea3661178 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -39,6 +39,7 @@ export const SysSharingRule = ObjectSchema.create({ userActions: { create: true, edit: true, delete: true, import: false }, description: 'Declarative sharing rule that auto-materialises sys_record_share grants. Authored via defineSharingRule() in code or the Studio criteria builder.', displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', compactLayout: ['name', 'object_name', 'recipient_type', 'recipient_id', 'access_level', 'active'], diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index 7a7cb340d8..45ef97770b 100644 --- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -45,6 +45,7 @@ export const SysWebhook = ObjectSchema.create({ userActions: { create: true, edit: true, delete: true, import: false }, description: 'Outbound HTTP webhook subscription. Authored via defineWebhook() in code or the Studio editor; executed by the HTTP connector plugin.', displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', compactLayout: ['name', 'object_name', 'url', 'active', 'updated_at'], diff --git a/packages/services/service-automation/src/sys-automation-run.object.ts b/packages/services/service-automation/src/sys-automation-run.object.ts index 8b610a4298..e4703fa566 100644 --- a/packages/services/service-automation/src/sys-automation-run.object.ts +++ b/packages/services/service-automation/src/sys-automation-run.object.ts @@ -37,6 +37,7 @@ export const SysAutomationRun = ObjectSchema.create({ managedBy: 'system', description: 'Durable state of a suspended automation flow run (ADR-0019)', displayNameField: 'id', + nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{flow_name} · {node_id}', compactLayout: ['flow_name', 'node_id', 'status', 'correlation', 'started_at', 'updated_at'], diff --git a/packages/services/service-messaging/src/objects/http-delivery.object.ts b/packages/services/service-messaging/src/objects/http-delivery.object.ts index 54333a3c85..18b2e52541 100644 --- a/packages/services/service-messaging/src/objects/http-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/http-delivery.object.ts @@ -36,6 +36,7 @@ export const HttpDelivery = ObjectSchema.create({ description: 'Durable outbox row for one outbound-HTTP attempt (ADR-0018). Managed by @objectstack/service-messaging; do not write directly.', displayNameField: 'id', + nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label} → {url}', compactLayout: ['source', 'url', 'status', 'attempts', 'next_retry_at'], diff --git a/packages/services/service-messaging/src/objects/inbox-message.object.ts b/packages/services/service-messaging/src/objects/inbox-message.object.ts index 4946f3ff0f..5046468cd4 100644 --- a/packages/services/service-messaging/src/objects/inbox-message.object.ts +++ b/packages/services/service-messaging/src/objects/inbox-message.object.ts @@ -23,6 +23,7 @@ export const InboxMessage = ObjectSchema.create({ pluralLabel: 'Inbox Messages', icon: 'inbox', description: 'User-facing in-app notification rows materialized by the inbox messaging channel.', + nameField: 'title', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) titleFormat: '{title}', compactLayout: ['title', 'user_id', 'severity', 'created_at'], diff --git a/packages/services/service-storage/src/objects/system-file.object.ts b/packages/services/service-storage/src/objects/system-file.object.ts index e794583a45..8a4a263687 100644 --- a/packages/services/service-storage/src/objects/system-file.object.ts +++ b/packages/services/service-storage/src/objects/system-file.object.ts @@ -24,6 +24,7 @@ export const SystemFile = ObjectSchema.create({ pluralLabel: 'System Files', icon: 'file', description: 'Storage service file metadata (fileId ↔ key mapping)', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) titleFormat: '{name}', compactLayout: ['name', 'mime_type', 'size', 'status', 'created_at'], diff --git a/packages/services/service-storage/src/objects/system-upload-session.object.ts b/packages/services/service-storage/src/objects/system-upload-session.object.ts index dadd1b7201..5452095916 100644 --- a/packages/services/service-storage/src/objects/system-upload-session.object.ts +++ b/packages/services/service-storage/src/objects/system-upload-session.object.ts @@ -17,6 +17,7 @@ export const SystemUploadSession = ObjectSchema.create({ pluralLabel: 'System Upload Sessions', icon: 'upload-cloud', description: 'Resumable multipart upload sessions tracked by service-storage', + nameField: 'filename', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) titleFormat: '{filename}', compactLayout: ['filename', 'status', 'uploaded_chunks', 'total_chunks', 'expires_at'], diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index f55cb6d72f..fd77a2d6ec 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -199,6 +199,7 @@ "Dimension (type)", "DimensionSchema (const)", "DimensionType (const)", + "DisplayNameObjectMeta (interface)", "Document (type)", "DocumentSchema (const)", "DocumentSchemaValidation (type)", @@ -346,6 +347,7 @@ "ObjectOwnership (type)", "ObjectOwnershipEnum (const)", "ObjectSchema (const)", + "ObjectTitleCompleteness (interface)", "OperatorKey (type)", "PoolConfig (type)", "PoolConfigSchema (const)", @@ -367,6 +369,7 @@ "RenderedAutonumber (interface)", "ReplicationConfig (type)", "ReplicationConfigSchema (const)", + "ResolveRecordDisplayNameOptions (interface)", "ResolvedHook (type)", "SQLDialect (type)", "SQLDialectSchema (const)", @@ -418,9 +421,13 @@ "StateMachineValidationSchema (const)", "StringOperatorSchema (const)", "SubscriptionEventType (type)", + "TITLE_ELIGIBLE (const)", + "TITLE_ELIGIBLE_TYPES (const)", + "TITLE_INELIGIBLE_TYPES (const)", "TenancyConfig (type)", "TenancyConfigSchema (const)", "TimeUpdateInterval (const)", + "TitleEligibleFieldDef (interface)", "TransformType (const)", "VALID_AST_OPERATORS (const)", "ValidationRule (type)", @@ -449,14 +456,19 @@ "isDateMacroToken (function)", "isFilterAST (function)", "isIncoherentAggregate (function)", + "isTitleEligible (function)", "missingFieldValues (function)", "objectForm (const)", + "objectTitleCompleteness (function)", "parseAutonumberFormat (function)", "parseDateMacroParam (function)", "parseFilterAST (function)", + "provisionPrimary (function)", "referencedFields (function)", "renderAutonumber (function)", "resolveCrudAffordances (function)", + "resolveDisplayField (function)", + "resolveRecordDisplayName (function)", "sequenceWidth (function)", "suggestFieldType (function)" ], diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index f4cf3c6976..09c04f1345 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -25,7 +25,11 @@ }, "displayNameField": { "status": "live", - "note": "objectui RecordDetailView." + "note": "Deprecated alias of nameField (ADR-0079); still read by objectui RecordDetailView + resolveDisplayField back-compat." + }, + "nameField": { + "status": "live", + "note": "ADR-0079 canonical record-title pointer; read by data/display-name.ts resolveDisplayField/resolveRecordDisplayName, objectql search displayField, and plugin-approvals." }, "titleFormat": { "status": "live", diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index d7e0009921..8b104fb4e2 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -47,6 +47,8 @@ export const BlueprintObjectSchema = lazySchema(() => z.object({ label: z.string().optional().describe('Human-readable singular label'), description: z.string().optional().describe('What this object represents'), fields: z.array(BlueprintFieldSchema).describe('Fields to create on the object'), + nameField: z.string().regex(SNAKE_CASE).optional() + .describe('The record title field — which field holds the human-readable name shown on cards, lookup chips, breadcrumbs and search (ADR-0079). Set it to the object\'s text label field (e.g. "product_name"). For a numbered entity (invoice/ticket), set it to a formula field that composes number + name (e.g. "{order_no} · {customer}"). Omitting it lets the platform auto-pick a text field, but declaring it is strongly preferred.'), })); export type BlueprintObject = z.infer; diff --git a/packages/spec/src/data/display-name.test.ts b/packages/spec/src/data/display-name.test.ts new file mode 100644 index 0000000000..18ead6b190 --- /dev/null +++ b/packages/spec/src/data/display-name.test.ts @@ -0,0 +1,259 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + isTitleEligible, + resolveDisplayField, + resolveRecordDisplayName, + provisionPrimary, + objectTitleCompleteness, + TITLE_ELIGIBLE, + TITLE_ELIGIBLE_TYPES, + TITLE_INELIGIBLE_TYPES, +} from './display-name'; + +describe('isTitleEligible', () => { + it('accepts text-ish types', () => { + for (const type of ['text', 'textarea', 'email', 'url', 'markdown', 'html', 'richtext']) { + expect(isTitleEligible({ type })).toBe(true); + } + }); + + it('excludes the ineligible type set (date/number/boolean/media/relational/choice/system)', () => { + const excluded = [ + 'date', 'datetime', 'time', + 'number', 'currency', 'percent', + 'boolean', 'toggle', + 'file', 'image', 'avatar', 'attachment', 'signature', + 'json', 'code', 'composite', 'repeater', 'record', 'vector', + 'geolocation', 'location', + 'select', 'multiselect', 'multi_select', 'radio', 'checkboxes', 'tags', 'color', 'rating', 'slider', 'progress', + 'lookup', 'master_detail', 'tree', 'user', + 'autonumber', 'password', 'secret', + ]; + for (const type of excluded) { + expect(isTitleEligible({ type }), `${type} should be ineligible`).toBe(false); + } + }); + + it('email IS eligible but phone is NOT', () => { + expect(isTitleEligible({ type: 'email' })).toBe(true); + expect(isTitleEligible({ type: 'phone' })).toBe(false); + }); + + it('formula eligible only when result type (returnType) is text', () => { + expect(isTitleEligible({ type: 'formula', returnType: 'text' })).toBe(true); + expect(isTitleEligible({ type: 'formula', returnType: 'number' })).toBe(false); + expect(isTitleEligible({ type: 'formula', returnType: 'date' })).toBe(false); + expect(isTitleEligible({ type: 'formula' })).toBe(false); // unknown result type + }); + + it('formula also reads valueType as a fallback', () => { + expect(isTitleEligible({ type: 'formula', valueType: 'text' })).toBe(true); + expect(isTitleEligible({ type: 'formula', valueType: 'number' })).toBe(false); + }); + + it('unknown/new field types are ineligible (fail-closed allowlist)', () => { + expect(isTitleEligible({ type: 'some_future_widget' })).toBe(false); + expect(isTitleEligible({})).toBe(false); + expect(isTitleEligible(undefined)).toBe(false); + expect(isTitleEligible(null)).toBe(false); + }); + + it('eligible/ineligible sets do not overlap', () => { + for (const t of TITLE_ELIGIBLE_TYPES) expect(TITLE_INELIGIBLE_TYPES.has(t)).toBe(false); + expect(TITLE_ELIGIBLE).toBe(TITLE_ELIGIBLE_TYPES); + }); +}); + +describe('resolveDisplayField — explicit pointer precedence', () => { + it('prefers nameField over displayNameField over derivation', () => { + expect(resolveDisplayField({ + nameField: 'a', displayNameField: 'b', + fields: { a: { type: 'text' }, b: { type: 'text' }, name: { type: 'text' } }, + })).toBe('a'); + }); + + it('falls back to displayNameField (deprecated alias) when nameField absent', () => { + expect(resolveDisplayField({ + displayNameField: 'b', + fields: { b: { type: 'text' }, name: { type: 'text' } }, + })).toBe('b'); + }); + + it('honors an explicit pointer even when the target is not title-eligible', () => { + // author asserted it — eligibility gates DERIVATION only + expect(resolveDisplayField({ nameField: 'amount', fields: { amount: { type: 'currency' } } })).toBe('amount'); + }); + + it('returns undefined for null/empty meta', () => { + expect(resolveDisplayField(undefined)).toBeUndefined(); + expect(resolveDisplayField({})).toBeUndefined(); + expect(resolveDisplayField({ fields: {} })).toBeUndefined(); + }); +}); + +describe('resolveDisplayField — derivation ranking', () => { + it('tier 1: name-ish exact wins over a non-name title-eligible field', () => { + expect(resolveDisplayField({ + fields: { description: { type: 'text' }, title: { type: 'text' } }, + })).toBe('title'); + }); + + it('tier 1: `name` beats `title`/`subject` regardless of declaration order', () => { + expect(resolveDisplayField({ + fields: { subject: { type: 'text' }, title: { type: 'text' }, name: { type: 'text' } }, + })).toBe('name'); + }); + + it('tier 1: full_name / display_name are recognized exacts', () => { + expect(resolveDisplayField({ fields: { display_name: { type: 'text' }, foo: { type: 'text' } } })).toBe('display_name'); + expect(resolveDisplayField({ fields: { foo: { type: 'text' }, full_name: { type: 'text' } } })).toBe('full_name'); + }); + + it('tier 2: name-ish affix (*_name, *_title, name_*) beats a generic field', () => { + expect(resolveDisplayField({ + fields: { notes: { type: 'text' }, account_name: { type: 'text' } }, + })).toBe('account_name'); + expect(resolveDisplayField({ + fields: { notes: { type: 'text' }, job_title: { type: 'text' } }, + })).toBe('job_title'); + expect(resolveDisplayField({ + fields: { notes: { type: 'text' }, name_en: { type: 'text' } }, + })).toBe('name_en'); + }); + + it('tier 1 outranks tier 2 (exact `name` beats `*_name` affix)', () => { + expect(resolveDisplayField({ + fields: { company_name: { type: 'text' }, name: { type: 'text' } }, + })).toBe('name'); + }); + + it('tier 3: first title-eligible field by declaration order when no name-ish field', () => { + expect(resolveDisplayField({ + fields: { + created: { type: 'datetime' }, // ineligible + amount: { type: 'currency' }, // ineligible + notes: { type: 'text' }, // first eligible + extra: { type: 'text' }, + }, + })).toBe('notes'); + }); + + it('derives a formula→text field when nothing else is eligible', () => { + expect(resolveDisplayField({ + fields: { qty: { type: 'number' }, computed_label: { type: 'formula', returnType: 'text' } }, + })).toBe('computed_label'); + }); + + it('returns undefined when no field is title-eligible', () => { + expect(resolveDisplayField({ + fields: { amount: { type: 'currency' }, when: { type: 'date' }, status: { type: 'select' }, ref: { type: 'lookup' } }, + })).toBeUndefined(); + }); +}); + +describe('resolveRecordDisplayName', () => { + const meta = { fields: { name: { type: 'text' } } }; + + it('uses the resolved display field value', () => { + expect(resolveRecordDisplayName(meta, { id: '7', name: 'Acme' })).toBe('Acme'); + }); + + it('viewTitleField overrides the resolved field', () => { + expect(resolveRecordDisplayName(meta, { id: '7', name: 'Acme', code: 'AC-1' }, { viewTitleField: 'code' })).toBe('AC-1'); + }); + + it('falls back to `Record #` on null/empty value (never bare "Untitled")', () => { + expect(resolveRecordDisplayName(meta, { id: '7', name: '' })).toBe('Record #7'); + expect(resolveRecordDisplayName(meta, { id: '7', name: ' ' })).toBe('Record #7'); + expect(resolveRecordDisplayName(meta, { id: '7' })).toBe('Record #7'); + }); + + it('uses _id when id is absent', () => { + expect(resolveRecordDisplayName(meta, { _id: 'abc' })).toBe('Record #abc'); + }); + + it('floors to `Record #` with no id rather than throwing or "Untitled"', () => { + expect(resolveRecordDisplayName(meta, {})).toBe('Record #'); + expect(resolveRecordDisplayName(meta, null)).toBe('Record #'); + }); + + it('non-string values stringify', () => { + expect(resolveRecordDisplayName({ fields: { count: { type: 'text' } } }, { count: 42 })).toBe('42'); + }); +}); + +describe('provisionPrimary', () => { + it('DESIGNATES an existing derivable title (sets nameField, no synthesis)', () => { + const out = provisionPrimary({ fields: { title: { type: 'text' }, notes: { type: 'text' } } }); + expect(out.nameField).toBe('title'); + expect(Object.keys(out.fields!)).toEqual(['title', 'notes']); // no field added + }); + + it('respects an explicit pointer (designates nameField from displayNameField alias)', () => { + const out = provisionPrimary({ displayNameField: 'subject', fields: { subject: { type: 'text' } } }); + expect(out.nameField).toBe('subject'); + }); + + it('SYNTHESIZES a `name` text field when nothing is title-eligible', () => { + const out = provisionPrimary({ fields: { amount: { type: 'currency' }, when: { type: 'date' } } }); + expect(out.nameField).toBe('name'); + expect(out.fields!.name).toMatchObject({ type: 'text' }); + expect(Object.keys(out.fields!)).toContain('amount'); + }); + + it('synthesizes `name` for an object with no fields at all', () => { + const out = provisionPrimary({ name: 'thing' }); + expect(out.nameField).toBe('name'); + expect(out.fields!.name).toMatchObject({ type: 'text' }); + }); + + it('is idempotent — a second pass is a no-op and returns the same instance', () => { + const once = provisionPrimary({ fields: { title: { type: 'text' } } }); + const twice = provisionPrimary(once); + expect(twice).toBe(once); + expect(twice.nameField).toBe('title'); + + const synthOnce = provisionPrimary({ fields: { amount: { type: 'currency' } } }); + const synthTwice = provisionPrimary(synthOnce); + expect(synthTwice.nameField).toBe('name'); + expect(Object.keys(synthTwice.fields!).filter((k) => k === 'name')).toHaveLength(1); + }); + + it('does not mutate the input object', () => { + const input = { fields: { amount: { type: 'currency' } } }; + const out = provisionPrimary(input); + expect(input.nameField).toBeUndefined(); + expect((input.fields as Record).name).toBeUndefined(); + expect(out).not.toBe(input); + }); +}); + +describe('objectTitleCompleteness', () => { + it('explicit — pointer present and field exists', () => { + expect(objectTitleCompleteness({ nameField: 'title', fields: { title: { type: 'text' } } })) + .toEqual({ status: 'explicit', field: 'title' }); + }); + + it('explicit via deprecated displayNameField alias', () => { + expect(objectTitleCompleteness({ displayNameField: 'subject', fields: { subject: { type: 'text' } } })) + .toEqual({ status: 'explicit', field: 'subject' }); + }); + + it('synthesized — pointer set but the field is not in fields (runtime must materialize)', () => { + expect(objectTitleCompleteness({ nameField: 'name', fields: { amount: { type: 'currency' } } })) + .toEqual({ status: 'synthesized', field: 'name' }); + }); + + it('derived — no pointer but a title-eligible field is derivable', () => { + expect(objectTitleCompleteness({ fields: { notes: { type: 'text' } } })) + .toEqual({ status: 'derived', field: 'notes' }); + }); + + it('none — no pointer and nothing derivable', () => { + expect(objectTitleCompleteness({ fields: { amount: { type: 'currency' }, when: { type: 'date' } } })) + .toEqual({ status: 'none' }); + expect(objectTitleCompleteness(undefined)).toEqual({ status: 'none' }); + }); +}); diff --git a/packages/spec/src/data/display-name.ts b/packages/spec/src/data/display-name.ts new file mode 100644 index 0000000000..34cc2034e7 --- /dev/null +++ b/packages/spec/src/data/display-name.ts @@ -0,0 +1,293 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0079 — Record display-name contract. + * + * A record's human title is a STRUCTURAL INVARIANT: every object has exactly one + * primary title field, which is a real STORED field (text / autonumber / formula + * whose result is text). This module is the single source of truth for: + * - which field types may back a title (`isTitleEligible` / `TITLE_ELIGIBLE`), + * - how the primary title field is resolved (`resolveDisplayField`) — explicit + * pointer first (`nameField`, then the deprecated `displayNameField` alias), + * then a deterministic derivation, + * - how a single record's display name is rendered (`resolveRecordDisplayName`) + * with a stable `Record #` floor — NEVER a bare "Untitled", + * - a pure transform that GUARANTEES a primary exists (`provisionPrimary`), + * - and a completeness classifier for lint/quality reporting + * (`objectTitleCompleteness`). + * + * Pure + framework-agnostic (no Zod, no engine). Shared by authoring, the + * approval/notification display-enrichment path, objectql search field + * resolution, and build-time lint. + * + * NOTE on the schema: `nameField` is the canonical pointer (pairs with + * `recordName` — the Salesforce Name/Record-Name model). `displayNameField` is + * accepted as a DEPRECATED ALIAS at the schema level (mapped onto `nameField`). + * Both are still read here so this resolver works against metadata that has not + * yet been normalized. + */ + +/** + * Minimal structural shape of a field definition this module needs to judge + * title-eligibility. Intentionally loose — callers pass `Data.Field`, + * `sys_metadata` rows, or hand-built fakes. + */ +export interface TitleEligibleFieldDef { + type?: string; + /** + * For `formula` fields: the declared result type. The framework field schema + * carries this as `returnType` (number/text/boolean/date); `valueType` is also + * accepted for forward/cross-repo compatibility. A formula is title-eligible + * ONLY when this is `text`. + */ + returnType?: string; + valueType?: string; + [k: string]: unknown; +} + +/** + * Minimal structural shape of an object's metadata this module reads. + * `fields` is a name-keyed map (declaration order = `Object.keys` order, which + * the framework preserves for the `record` field type and object materialization). + */ +export interface DisplayNameObjectMeta { + /** Canonical primary-title pointer (ADR-0079). */ + nameField?: string; + /** Deprecated alias for `nameField`; still honored as a fallback. */ + displayNameField?: string; + fields?: Record | undefined; + [k: string]: unknown; +} + +/** + * Field types that are NEVER a record title — they carry no human-readable + * short label (dates, numbers, booleans, media, structured/relational values, + * choice tokens, and system/auto values). `autonumber` is excluded from + * *derivation* here: an autonumber is a valid primary only when an author + * points at it explicitly (it pairs with `recordName.type='autonumber'`), not + * something we silently pick. `formula` is conditionally eligible — see + * `isTitleEligible`. + * + * `phone` is deliberately excluded (a phone number is not a title); `email` IS + * eligible (commonly the human handle on identity-ish objects). + */ +export const TITLE_INELIGIBLE_TYPES: ReadonlySet = new Set([ + // Date & time + 'date', 'datetime', 'time', + // Numbers + 'number', 'currency', 'percent', + // Logic + 'boolean', 'toggle', + // Media / files / attachments + 'file', 'image', 'avatar', 'video', 'audio', 'attachment', 'signature', 'qrcode', + // Structured / untyped + 'json', 'code', 'composite', 'repeater', 'record', 'address', 'vector', + // Geo + 'geolocation', 'location', + // Choice tokens (machine values, not titles) + 'select', 'multiselect', 'multi_select', 'radio', 'checkboxes', 'checkbox', 'tags', 'color', 'rating', 'slider', 'progress', + // Relational + 'lookup', 'master_detail', 'tree', 'user', + // System / auto / derived + 'autonumber', 'password', 'secret', + // Phone is not a title (email IS — handled in the eligible set) + 'phone', +]); + +/** + * Text-ish field types that ARE title-eligible. Used as a positive allowlist so + * an UNKNOWN/new field type does not accidentally become a title (fail-closed). + * `formula` is handled separately (eligible only when its result type is text). + */ +export const TITLE_ELIGIBLE_TYPES: ReadonlySet = new Set([ + 'text', 'textarea', 'email', 'url', 'markdown', 'html', 'richtext', +]); + +/** + * Convenience alias for `TITLE_ELIGIBLE_TYPES` named in ADR-0079 as the + * `TITLE_ELIGIBLE` rule. + */ +export const TITLE_ELIGIBLE = TITLE_ELIGIBLE_TYPES; + +/** + * Is `fieldDef` a field that can back a record's human title? + * + * Eligible: text-ish types (`text`, `textarea`, `email`, `url`, `markdown`, + * `html`, `richtext`) and a `formula` whose result type (`returnType`, or + * `valueType`) is `text`. Everything else — explicitly the + * {@link TITLE_INELIGIBLE_TYPES} set — is ineligible. Unknown types are + * ineligible (fail-closed against the positive allowlist). + */ +export function isTitleEligible(fieldDef: TitleEligibleFieldDef | undefined | null): boolean { + if (!fieldDef) return false; + const type = fieldDef.type; + if (!type) return false; + if (type === 'formula') { + const rt = fieldDef.returnType ?? fieldDef.valueType; + return rt === 'text'; + } + if (TITLE_INELIGIBLE_TYPES.has(type)) return false; + return TITLE_ELIGIBLE_TYPES.has(type); +} + +/** Exact name-ish field names (case-insensitive), highest derivation priority. */ +const NAME_ISH_EXACT: ReadonlySet = new Set([ + 'name', 'title', 'subject', 'label', 'full_name', 'display_name', +]); + +/** Affix tokens that make a field name "name-ish" (e.g. `first_name`, `job_title`, `name_en`). */ +const NAME_ISH_AFFIX_SUFFIX = ['_name', '_title']; +const NAME_ISH_AFFIX_PREFIX = ['name_']; + +function isNameIshExact(fieldName: string): boolean { + return NAME_ISH_EXACT.has(fieldName.toLowerCase()); +} + +function isNameIshAffix(fieldName: string): boolean { + const lower = fieldName.toLowerCase(); + if (NAME_ISH_EXACT.has(lower)) return false; // exact handled at a higher tier + return ( + NAME_ISH_AFFIX_SUFFIX.some((s) => lower.endsWith(s)) || + NAME_ISH_AFFIX_PREFIX.some((p) => lower.startsWith(p)) + ); +} + +/** + * Derive the title field from the object's fields when no explicit pointer is + * set. Ranking (highest first), restricted to title-eligible fields: + * 1. name-ish EXACT — `name`/`title`/`subject`/`label`/`full_name`/`display_name` + * 2. name-ish AFFIX — `*_name` / `*_title` / `name_*` + * 3. first title-eligible field by declaration order + * Returns `undefined` when no field is title-eligible. + */ +function deriveDisplayField(fields: Record | undefined): string | undefined { + if (!fields) return undefined; + const names = Object.keys(fields); + + // Tier 1: name-ish exact, in the EXACT priority order (name > title > subject > …) + // so `name` wins over `title` when both exist, independent of declaration order. + const exactOrder = ['name', 'title', 'subject', 'label', 'full_name', 'display_name']; + for (const wanted of exactOrder) { + for (const fname of names) { + if (fname.toLowerCase() === wanted && isTitleEligible(fields[fname])) return fname; + } + } + + // Tier 2: name-ish affix, by declaration order. + for (const fname of names) { + if (isNameIshAffix(fname) && isTitleEligible(fields[fname])) return fname; + } + + // Tier 3: first title-eligible field by declaration order. + for (const fname of names) { + if (!isNameIshExact(fname) && isTitleEligible(fields[fname])) return fname; + } + + return undefined; +} + +/** + * Resolve the object's primary title field name: + * `nameField` (canonical) ?? `displayNameField` (deprecated alias) ?? derived. + * + * An explicit pointer is honored even if it is not currently title-eligible + * (the author asserted it; eligibility gates only DERIVATION). Returns + * `undefined` when there is no explicit pointer and nothing derivable. + */ +export function resolveDisplayField(objectMeta: DisplayNameObjectMeta | undefined | null): string | undefined { + if (!objectMeta) return undefined; + const explicit = objectMeta.nameField ?? objectMeta.displayNameField; + if (explicit) return explicit; + return deriveDisplayField(objectMeta.fields); +} + +/** Options for {@link resolveRecordDisplayName}. */ +export interface ResolveRecordDisplayNameOptions { + /** + * A view-declared title field that OVERRIDES the object's resolved display + * field for this render (e.g. a list view choosing a different column as the + * row label). When set, the record's value at this field is used directly. + */ + viewTitleField?: string; +} + +function isEmptyish(v: unknown): boolean { + return v == null || (typeof v === 'string' && v.trim() === ''); +} + +/** + * Render a single record's human display name. + * + * opts.viewTitleField ? record[viewTitleField] : record[resolveDisplayField(meta)] + * + * Falls back to a stable `Record #` floor (using `record.id` or `record._id`) + * when the resolved value is null/empty. NEVER returns a bare "Untitled". + */ +export function resolveRecordDisplayName( + objectMeta: DisplayNameObjectMeta | undefined | null, + record: Record | undefined | null, + opts?: ResolveRecordDisplayNameOptions, +): string { + const rec = record ?? {}; + const field = opts?.viewTitleField ?? resolveDisplayField(objectMeta); + const value = field ? (rec as Record)[field] : undefined; + if (!isEmptyish(value)) return String(value); + const id = (rec as Record).id ?? (rec as Record)._id; + return `Record #${id ?? ''}`.trimEnd(); +} + +/** + * Pure transform guaranteeing the object has a primary title field. + * + * - If a title can be resolved/derived, set `nameField` to it (idempotent — a + * second call is a no-op). + * - Otherwise SYNTHESIZE a `name` text field (added to `fields`) and set + * `nameField: 'name'`. + * + * Returns a NEW object (does not mutate the input). The deprecated + * `displayNameField` is left untouched for back-compat; `nameField` becomes the + * authoritative pointer. + */ +export function provisionPrimary(objectMeta: T): T { + const resolved = resolveDisplayField(objectMeta); + if (resolved) { + if (objectMeta.nameField === resolved) return objectMeta; // already canonical — no-op + return { ...objectMeta, nameField: resolved }; + } + // Nothing eligible — synthesize a primary `name` text field. + const fields = { ...(objectMeta.fields ?? {}) }; + if (!fields.name) { + fields.name = { type: 'text', label: 'Name', required: true } as TitleEligibleFieldDef; + } + return { ...objectMeta, fields, nameField: 'name' }; +} + +/** Result of {@link objectTitleCompleteness}. */ +export interface ObjectTitleCompleteness { + /** + * - `explicit` — an explicit pointer (`nameField`/`displayNameField`) is set. + * - `derived` — no explicit pointer, but a title-eligible field was derivable. + * - `synthesized` — `nameField` points at a field NOT present in `fields` + * (e.g. a synthesized/expected `name`); the runtime must materialize it. + * - `none` — no pointer and nothing derivable. Lint/quality should flag. + */ + status: 'explicit' | 'derived' | 'synthesized' | 'none'; + /** The resolved field name when one exists. */ + field?: string; +} + +/** + * Classify how an object's title is satisfied, for lint / quality reporting. + * Does not mutate; mirrors `resolveDisplayField` precedence. + */ +export function objectTitleCompleteness(objectMeta: DisplayNameObjectMeta | undefined | null): ObjectTitleCompleteness { + if (!objectMeta) return { status: 'none' }; + const explicit = objectMeta.nameField ?? objectMeta.displayNameField; + if (explicit) { + const present = !!objectMeta.fields && Object.prototype.hasOwnProperty.call(objectMeta.fields, explicit); + return { status: present ? 'explicit' : 'synthesized', field: explicit }; + } + const derived = deriveDisplayField(objectMeta.fields); + if (derived) return { status: 'derived', field: derived }; + return { status: 'none' }; +} diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 84afb283be..7450ac7771 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -43,6 +43,12 @@ export * from './analytics.zod'; // and build-time coherence validation. export * from './aggregation-policy'; +// Record display-name contract (ADR-0079) — title eligibility, primary-field +// resolution/derivation, record display-name rendering, primary provisioning, +// and title-completeness classification. Shared by authoring, display +// enrichment, search field resolution, and lint. +export * from './display-name'; + // Feed & Activity Protocol export * from './feed.zod'; diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 8dbbd902d0..9ab4e047bf 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -606,6 +606,46 @@ describe('ObjectSchema - displayNameField', () => { }); expect(result.displayNameField).toBeUndefined(); }); + + // ADR-0079: `nameField` is the canonical pointer; `displayNameField` is a + // deprecated alias that the schema maps onto `nameField` on parse. + it('should accept the canonical nameField pointer', () => { + const result = ObjectSchema.parse({ + name: 'ticket', + fields: { title: { type: 'text' } }, + nameField: 'title', + }); + expect(result.nameField).toBe('title'); + }); + + it('should map deprecated displayNameField onto nameField (back-compat alias)', () => { + const result = ObjectSchema.parse({ + name: 'ticket', + fields: { title: { type: 'text' } }, + displayNameField: 'title', + }); + expect(result.nameField).toBe('title'); + expect(result.displayNameField).toBe('title'); // preserved for cross-repo consumers + }); + + it('should map the alias through ObjectSchema.create() as well', () => { + const result = ObjectSchema.create({ + name: 'ticket', + fields: { title: { type: 'text' } }, + displayNameField: 'title', + }); + expect(result.nameField).toBe('title'); + }); + + it('explicit nameField takes precedence over displayNameField alias', () => { + const result = ObjectSchema.parse({ + name: 'ticket', + fields: { a: { type: 'text' }, b: { type: 'text' } }, + nameField: 'a', + displayNameField: 'b', + }); + expect(result.nameField).toBe('a'); + }); }); describe('ObjectSchema - recordName', () => { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 395f0e0188..5d3f5c9351 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -547,7 +547,23 @@ const ObjectSchemaBase = z.object({ /** * Display & UI Hints (Data-Layer) */ - displayNameField: z.string().optional().describe('Field to use as the record display name (e.g., "name", "title"). Defaults to "name" if present.'), + /** + * [ADR-0079] Canonical pointer to the object's PRIMARY title field — the one + * real stored field (text / autonumber / formula→text) that is a record's + * human name. Pairs with `recordName` (the Salesforce Name / Record-Name + * model). Optional at the schema level for now (a hard required-refine is + * staged so existing title-less metadata still parses). Resolve / derive via + * `resolveDisplayField` from `@objectstack/spec/data` (display-name.ts), which + * falls back to the deprecated `displayNameField` alias and then a derivation. + */ + nameField: z.string().optional().describe('[ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). Pairs with recordName.'), + /** + * @deprecated [ADR-0079] Renamed to `nameField`. Still ACCEPTED as an alias: + * the schema copies `displayNameField` onto `nameField` on parse when + * `nameField` is absent (both are preserved on the parsed output for + * cross-repo back-compat). New metadata should set `nameField`. + */ + displayNameField: z.string().optional().describe('[DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField.'), recordName: z.object({ type: z.enum(['text', 'autonumber']).describe('Record name type: text (user-entered) or autonumber (system-generated)'), displayFormat: z.string().optional().describe('Auto-number format pattern (e.g., "CASE-{0000}", "INV-{YYYY}-{0000}")'), @@ -835,10 +851,43 @@ function unknownKeyError(objectName: unknown, unknownKeys: string[], knownKeys: type NoExcessObjectKeys = T & Record>, never>; +/** + * [ADR-0079] Back-compat alias normalization: an object authored with the + * deprecated `displayNameField` key still parses by mapping it onto the + * canonical `nameField` when `nameField` is absent. `displayNameField` is + * PRESERVED on the output (cross-repo consumers / older tests still read it). + * Non-object inputs pass through untouched (Zod raises the real type error). + */ +function normalizeNameFieldAlias(input: unknown): unknown { + if (!input || typeof input !== 'object' || Array.isArray(input)) return input; + const obj = input as Record; + if (obj.nameField == null && typeof obj.displayNameField === 'string') { + return { ...obj, nameField: obj.displayNameField }; + } + return input; +} + /** * Enhanced ObjectSchema with Factory */ -export const ObjectSchema = lazySchema(() => Object.assign(ObjectSchemaBase, { +export const ObjectSchema = lazySchema(() => { + // Capture the ORIGINAL ZodObject parse/safeParse before `Object.assign` + // mutates `ObjectSchemaBase` in place (assign returns the same object, so + // overriding `.parse` on the result would otherwise recurse into itself). + const baseParse = ObjectSchemaBase.parse.bind(ObjectSchemaBase); + const baseSafeParse = ObjectSchemaBase.safeParse.bind(ObjectSchemaBase); + return Object.assign(ObjectSchemaBase, { + /** + * [ADR-0079] Parse with deprecated-`displayNameField`→`nameField` alias + * normalization applied first. Wraps the captured original ZodObject parse, + * so `.shape` / `.create()`'s internal `ObjectSchemaBase.parse` keep working. + */ + parse(data: unknown, params?: Parameters[1]) { + return baseParse(normalizeNameFieldAlias(data), params); + }, + safeParse(data: unknown, params?: Parameters[1]) { + return baseSafeParse(normalizeNameFieldAlias(data), params); + }, /** * Type-safe factory for creating business object definitions. * @@ -875,9 +924,14 @@ export const ObjectSchema = lazySchema(() => Object.assign(ObjectSchemaBase, { ...cfg, label: cfg.label ?? snakeCaseToLabel(cfg.name as string), }; + // [ADR-0079] `ObjectSchemaBase.parse` here is the alias-normalizing override + // assigned just below (Object.assign mutates the base in place), so the + // deprecated `displayNameField`→`nameField` mapping is applied for create() + // too — no need to normalize again at this call site. return ObjectSchemaBase.parse(withDefaults) as Omit & Pick; }, -})); + }); +}); export type ServiceObject = z.infer; export type ServiceObjectInput = z.input;