From 3a1ea534a451490ed380a3dbcf5f3c8974bf2f34 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 01:38:43 +0000 Subject: [PATCH 1/6] fix(plugin-approvals, plugin-security, service-messaging, service-realtime): declare a title pointer on nine objects that relied on titleFormat Eight composite titles become a display_title text formula designated as nameField; sys_notification_receipt's single-field title points nameField at state. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../src/sys-approval-delegation.object.ts | 21 +++++++++++++++++++ .../sys-position-permission-set.object.ts | 21 +++++++++++++++++++ .../objects/sys-user-permission-set.object.ts | 21 +++++++++++++++++++ .../src/objects/sys-user-position.object.ts | 21 +++++++++++++++++++ .../objects/notification-delivery.object.ts | 20 ++++++++++++++++++ .../objects/notification-preference.object.ts | 20 ++++++++++++++++++ .../objects/notification-receipt.object.ts | 11 ++++++++++ .../notification-subscription.object.ts | 20 ++++++++++++++++++ .../src/objects/sys-presence.object.ts | 21 +++++++++++++++++++ 9 files changed, 176 insertions(+) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts index a0faafbaf97..777291e3a6e 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec'; /** * sys_approval_delegation — self-service out-of-office (OOO) delegation (#1322 M1). @@ -40,6 +41,15 @@ export const SysApprovalDelegation = ObjectSchema.create({ managedBy: 'system-data', description: 'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window (#1322 M1).', + // [ADR-0079] The record title is `display_title`, a text formula over the + // same two columns `titleFormat` names. With no pointer declared, the + // registry's designate-only pass stamped `nameField: 'id'` (the first + // title-eligible field), so a renderer honouring ADR-0079's order (an + // explicit `nameField` wins over `titleFormat`) drew the raw id as the record + // page's H1. `titleFormat` stays for renderers that still read it first; + // `sys-approval-delegation-display-title.test.ts` holds the two to the same text. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{delegator_id} → {delegate_id}', highlightFields: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until'], @@ -62,6 +72,17 @@ export const SysApprovalDelegation = ObjectSchema.create({ fields: { id: Field.text({ label: 'Delegation ID', required: true, readonly: true, group: 'System' }), + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. Both source columns are required, so the + // expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.delegator_id + ' → ' + record.delegate_id`, + description: 'Record title: the delegator and the delegate (computed on read)', + group: 'Delegation', + }), + delegator_id: Field.lookup('sys_user', { label: 'Delegator', required: true, diff --git a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts index 22a3b781910..76033747eb2 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec'; /** * sys_position_permission_set — Position ↔ PermissionSet binding. @@ -30,6 +31,15 @@ export const SysPositionPermissionSet = ObjectSchema.create({ // `userActions` block is needed — the DelegatedAdminGate is the authz. managedBy: 'system-data', description: 'Binds a permission set to a position.', + // [ADR-0079] The record title is `display_title`, a text formula over the + // same two columns `titleFormat` names. With no pointer declared, the + // registry's designate-only pass stamped `nameField: 'id'` (the first + // title-eligible field), so a renderer honouring ADR-0079's order (an + // explicit `nameField` wins over `titleFormat`) drew the raw id as the record + // page's H1. `titleFormat` stays for renderers that still read it first; + // `sys-security-assignment-display-title.test.ts` holds the two to the same text. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{position_id} → {permission_set_id}', highlightFields: ['position_id', 'permission_set_id'], @@ -41,6 +51,17 @@ export const SysPositionPermissionSet = ObjectSchema.create({ description: 'UUID of the position-permission-set binding.', }), + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. It reads only this row's own columns — + // the two foreign keys, never a field of the looked-up records — so it shows a reader nothing the row does not already + // show them. Both are required, so the expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.position_id + ' → ' + record.permission_set_id`, + description: 'Record title: the position and the permission set bound to it (computed on read)', + }), + position_id: Field.lookup('sys_position', { label: 'Position', required: true, diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index 5982129da6c..e4f8ffeacbd 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec'; /** * sys_user_permission_set — User ↔ PermissionSet assignment. @@ -29,6 +30,15 @@ export const SysUserPermissionSet = ObjectSchema.create({ // needed — the DelegatedAdminGate is the authz. managedBy: 'system-data', description: 'Direct assignment of a permission set to a user (optionally scoped to an organization).', + // [ADR-0079] The record title is `display_title`, a text formula over the + // same two columns `titleFormat` names. With no pointer declared, the + // registry's designate-only pass stamped `nameField: 'id'` (the first + // title-eligible field), so a renderer honouring ADR-0079's order (an + // explicit `nameField` wins over `titleFormat`) drew the raw id as the record + // page's H1. `titleFormat` stays for renderers that still read it first; + // `sys-security-assignment-display-title.test.ts` holds the two to the same text. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{user_id} → {permission_set_id}', highlightFields: ['user_id', 'permission_set_id', 'organization_id'], @@ -40,6 +50,17 @@ export const SysUserPermissionSet = ObjectSchema.create({ description: 'UUID of the assignment.', }), + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. It reads only this row's own columns — + // the two foreign keys, never a field of the looked-up records — so it shows a reader nothing the row does not already + // show them. Both are required, so the expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.user_id + ' → ' + record.permission_set_id`, + description: 'Record title: the user and the permission set assigned to them (computed on read)', + }), + user_id: Field.lookup('sys_user', { label: 'User', required: true, diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index c142203555c..a54d42baa38 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec'; import { reservedIdentityNamesCelList, reservedIdentityNameMessage } from './reserved-identity-names.js'; /** @@ -41,6 +42,15 @@ export const SysUserPosition = ObjectSchema.create({ // a declaration only; the DelegatedAdminGate is the authz. managedBy: 'system-data', description: 'Assigns a position (sys_position.name) to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).', + // [ADR-0079] The record title is `display_title`, a text formula over the + // same two columns `titleFormat` names. With no pointer declared, the + // registry's designate-only pass stamped `nameField: 'id'` (the first + // title-eligible field), so a renderer honouring ADR-0079's order (an + // explicit `nameField` wins over `titleFormat`) drew the raw id as the record + // page's H1. `titleFormat` stays for renderers that still read it first; + // `sys-security-assignment-display-title.test.ts` holds the two to the same text. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{user_id} → {position}', highlightFields: ['user_id', 'position', 'business_unit_id', 'organization_id'], @@ -52,6 +62,17 @@ export const SysUserPosition = ObjectSchema.create({ description: 'UUID of the user-position assignment.', }), + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. It reads only this row's own columns — + // the user foreign key and the position name, never a field of the user record — so it shows a reader nothing the row does not already + // show them. Both are required, so the expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.user_id + ' → ' + record.position`, + description: 'Record title: the user and the position they hold (computed on read)', + }), + user_id: Field.lookup('sys_user', { label: 'User', required: true, diff --git a/packages/services/service-messaging/src/objects/notification-delivery.object.ts b/packages/services/service-messaging/src/objects/notification-delivery.object.ts index 1a4df874968..e7269a52c84 100644 --- a/packages/services/service-messaging/src/objects/notification-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/notification-delivery.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec'; /** * `sys_notification_delivery` — the durable outbox (ADR-0030 Layer 4). @@ -69,12 +70,31 @@ export const NotificationDelivery = ObjectSchema.create({ }, }, description: 'Durable per-recipient × channel delivery outbox (ADR-0030 Layer 4).', + // [ADR-0079] The record title is `display_title`, a text formula over the + // same two columns `titleFormat` names. With no pointer declared, the + // registry's designate-only pass stamped `nameField: 'id'` (the first + // title-eligible field), so a renderer honouring ADR-0079's order (an + // explicit `nameField` wins over `titleFormat`) drew the raw id as the + // record page's H1. `titleFormat` stays for renderers that still read it + // first; `notification-display-title.test.ts` holds the two to the same text. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{channel} → {recipient_id}', highlightFields: ['notification_id', 'recipient_id', 'channel', 'status', 'attempts'], fields: { id: Field.text({ label: 'Delivery ID', required: true, readonly: true }), + // [ADR-0079] The record title (`nameField` above). A formula is computed + // on read and has no stored column. `channel` and `recipient_id` are all required, so the + // expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.channel + ' → ' + record.recipient_id`, + description: 'Record title: the delivery channel and its recipient (computed on read)', + }), + notification_id: Field.text({ label: 'Notification Event', required: true, diff --git a/packages/services/service-messaging/src/objects/notification-preference.object.ts b/packages/services/service-messaging/src/objects/notification-preference.object.ts index 79a19d9ef02..7eded11f3c0 100644 --- a/packages/services/service-messaging/src/objects/notification-preference.object.ts +++ b/packages/services/service-messaging/src/objects/notification-preference.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec'; /** * `sys_notification_preference` — per-user × topic × channel delivery toggle @@ -31,12 +32,31 @@ export const NotificationPreference = ObjectSchema.create({ // default is full CRUD, so no `userActions` block is needed — RLS is the authz. managedBy: 'system-data', description: 'Per-user × topic × channel notification toggle (mute/allow), with admin-global defaults.', + // [ADR-0079] The record title is `display_title`, a text formula over the + // same three columns `titleFormat` names. With no pointer declared, the + // registry's designate-only pass stamped `nameField: 'id'` (the first + // title-eligible field), so a renderer honouring ADR-0079's order (an + // explicit `nameField` wins over `titleFormat`) drew the raw id as the + // record page's H1. `titleFormat` stays for renderers that still read it + // first; `notification-display-title.test.ts` holds the two to the same text. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{user_id} · {topic} · {channel}', highlightFields: ['user_id', 'topic', 'channel', 'enabled', 'digest'], fields: { id: Field.text({ label: 'Preference ID', required: true, readonly: true }), + // [ADR-0079] The record title (`nameField` above). A formula is computed + // on read and has no stored column. `user_id`, `topic` and `channel` are all required, so the + // expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.user_id + ' · ' + record.topic + ' · ' + record.channel`, + description: 'Record title: the user, the topic and the channel this toggle covers (computed on read)', + }), + user_id: Field.text({ label: 'User', required: true, diff --git a/packages/services/service-messaging/src/objects/notification-receipt.object.ts b/packages/services/service-messaging/src/objects/notification-receipt.object.ts index b6a3fb0d154..85f87356c06 100644 --- a/packages/services/service-messaging/src/objects/notification-receipt.object.ts +++ b/packages/services/service-messaging/src/objects/notification-receipt.object.ts @@ -34,6 +34,17 @@ export const NotificationReceipt = ObjectSchema.create({ ttl: { field: 'created_at', expireAfter: '90d' }, }, description: 'Per-recipient × channel receipt; the source of truth for notification read-state.', + // [ADR-0079] The record title is `state`, the one column `titleFormat` + // names: a single-field title migrates to `nameField` directly, with no + // formula. With no pointer declared, the registry's designate-only pass + // stamped `nameField: 'id'` (the first title-eligible field; a `select` is + // never DERIVED), so a renderer honouring ADR-0079's order (an explicit + // `nameField` wins over `titleFormat`) drew the raw id as the record page's + // H1. An explicit pointer is honoured whatever the field's type (ADR-0079 + // D4). `titleFormat` stays for renderers that still read it first; + // `notification-display-title.test.ts` holds the two to the same text. + displayNameField: 'state', + nameField: 'state', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{state}', highlightFields: ['notification_id', 'user_id', 'channel', 'state', 'at'], diff --git a/packages/services/service-messaging/src/objects/notification-subscription.object.ts b/packages/services/service-messaging/src/objects/notification-subscription.object.ts index d244fdf976c..71eef54001d 100644 --- a/packages/services/service-messaging/src/objects/notification-subscription.object.ts +++ b/packages/services/service-messaging/src/objects/notification-subscription.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec'; /** * `sys_notification_subscription` — who is subscribed to a topic (ADR-0030 @@ -35,12 +36,31 @@ export const NotificationSubscription = ObjectSchema.create({ // default is full CRUD, so no `userActions` block is needed. managedBy: 'system-data', description: 'Standing subscription of a principal (role/team/user) to a notification topic.', + // [ADR-0079] The record title is `display_title`, a text formula over the + // same two columns `titleFormat` names. With no pointer declared, the + // registry's designate-only pass stamped `nameField: 'id'` (the first + // title-eligible field), so a renderer honouring ADR-0079's order (an + // explicit `nameField` wins over `titleFormat`) drew the raw id as the + // record page's H1. `titleFormat` stays for renderers that still read it + // first; `notification-display-title.test.ts` holds the two to the same text. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{principal} · {topic}', highlightFields: ['topic', 'principal', 'enabled', 'created_at'], fields: { id: Field.text({ label: 'Subscription ID', required: true, readonly: true }), + // [ADR-0079] The record title (`nameField` above). A formula is computed + // on read and has no stored column. `principal` and `topic` are all required, so the + // expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.principal + ' · ' + record.topic`, + description: 'Record title: the subscribing principal and the topic (computed on read)', + }), + topic: Field.text({ label: 'Topic', required: true, diff --git a/packages/services/service-realtime/src/objects/sys-presence.object.ts b/packages/services/service-realtime/src/objects/sys-presence.object.ts index 039ac421f32..8ce347a24dd 100644 --- a/packages/services/service-realtime/src/objects/sys-presence.object.ts +++ b/packages/services/service-realtime/src/objects/sys-presence.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec'; /** * sys_presence — System Presence Object @@ -22,6 +23,15 @@ export const SysPresence = ObjectSchema.create({ isSystem: true, managedBy: 'append-only', description: 'Real-time user presence and activity tracking', + // [ADR-0079] The record title is `display_title`, a text formula over the + // same two columns `titleFormat` names. With no pointer declared, the + // registry's designate-only pass stamped `nameField: 'id'` (the first + // title-eligible field), so a renderer honouring ADR-0079's order (an + // explicit `nameField` wins over `titleFormat`) drew the raw id as the record + // page's H1. `titleFormat` stays for renderers that still read it first; + // `sys-presence-display-title.test.ts` holds the two to the same text. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{user_id} ({status})', highlightFields: ['user_id', 'status', 'last_seen'], @@ -32,6 +42,17 @@ export const SysPresence = ObjectSchema.create({ readonly: true, }), + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. It reads only this row's own columns — + // the user foreign key and the status token, never a field of the user + // record. Both are required, so the expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.user_id + ' (' + record.status + ')'`, + description: 'Record title: the user and their presence status (computed on read)', + }), + created_at: Field.datetime({ label: 'Created At', defaultValue: 'NOW()', From 1fee9a4a2878f21ecc6b55490befb400229a840e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 01:55:06 +0000 Subject: [PATCH 2/6] test(plugin-approvals, plugin-security, service-messaging, service-realtime): pin the nine title pointers through the registry Per object: the registered body names the new pointer, a seeded row's H1 is the titleFormat text and never the id, a row missing a title column never reaches the formula with a NULL part, the formula reads exactly the titleFormat columns on its own row (required, none withheld), and nothing adds a stored column. sys_presence has no engine in its package's dependency closure, so its file pins the designation pass and the formula's inputs only. Also rewraps the inherited object comments and narrows the plugin-security note to what the declared read path shows. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- ...-approval-delegation-display-title.test.ts | 148 +++++++++++ .../sys-position-permission-set.object.ts | 6 +- ...-security-assignment-display-title.test.ts | 188 ++++++++++++++ .../objects/sys-user-permission-set.object.ts | 6 +- .../src/objects/sys-user-position.object.ts | 6 +- .../objects/notification-delivery.object.ts | 4 +- .../notification-display-title.test.ts | 234 ++++++++++++++++++ .../objects/notification-preference.object.ts | 4 +- .../notification-subscription.object.ts | 4 +- .../sys-presence-display-title.test.ts | 81 ++++++ .../src/objects/sys-presence.object.ts | 3 +- 11 files changed, 671 insertions(+), 13 deletions(-) create mode 100644 packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts create mode 100644 packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts create mode 100644 packages/services/service-messaging/src/objects/notification-display-title.test.ts create mode 100644 packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts new file mode 100644 index 00000000000..8edfd235fe4 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The record title of `sys_approval_delegation`, which declared a + * `titleFormat` and no title pointer (#20044). + * + * ADR-0079 resolves a record's title as `nameField ?? displayNameField ?? + * derivation`, and an explicit `nameField` takes precedence over the + * render-only `titleFormat`. With no pointer declared, the registry's + * designate-only pass (`provisionPrimary(…, { synthesize: false })`) stamped + * `nameField: 'id'` — the first title-eligible field — onto the registered + * body, and a `/meta` read serves that stamp as if the author had written it. + * A renderer honouring the order therefore drew the raw id as the record + * page's H1. + * + * The object now points at `display_title`, a text formula over the columns + * `titleFormat` names. Through the real engine this file asserts: + * + * 1. the body the registry holds after registration names `display_title`; + * 2. a seeded row's H1 is the `titleFormat` text, not the id, and the + * server-side accessor (`resolveRecordTitle`) agrees; + * 3. a row missing a title column is refused by the write path, so the + * formula never sees a NULL part (the sibling of #20015's nullable legs: + * here every title column is required); + * 4. the formula reads exactly the columns `titleFormat` names, on this row + * only, each required and none withheld from a reader of the row; + * 5. the formula adds no stored column. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL, resolveRecordTitle } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveDisplayField } from '@objectstack/spec/data'; +import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; + +const SYS = { context: { isSystem: true } } as any; +const OBJECT = 'sys_approval_delegation'; + +/** The `titleFormat` source: the parsed schema carries it as an envelope. */ +function titleFormatSource(schema: unknown): string { + const tf = (schema as { titleFormat?: unknown }).titleFormat; + const source = typeof tf === 'string' ? tf : (tf as { source?: unknown })?.source; + if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(tf)}`); + return source; +} + +/** + * The H1 a `titleFormat`-first renderer draws: each `{field}` placeholder + * substituted with the row's value. It is the reference the formula has to + * reproduce, not a second title resolver. + */ +function renderTitleFormat(schema: unknown, row: Record): string { + return titleFormatSource(schema).replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key: string) => String(row[key] ?? '')); +} + +/** The columns `titleFormat` names. */ +function titleFormatColumns(schema: unknown): string[] { + return [...titleFormatSource(schema).matchAll(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g)].map((m) => m[1]).sort(); +} + +/** Every `record.` the `display_title` expression reads, as written. */ +function formulaReads(schema: { fields: Record }): string[] { + const source = schema.fields.display_title?.expression?.source; + if (typeof source !== 'string') throw new Error('display_title carries no expression source'); + return [...source.matchAll(/record\.([A-Za-z_][A-Za-z0-9_.]*)/g)].map((m) => m[1]).sort(); +} + +/** The stored row as a hook body holds it: no formula value on it. */ +function storedOnly(row: Record): Record { + const { display_title: _omit, ...rest } = row; + return rest; +} + +describe('[#20044] sys_approval_delegation resolves a real record title under ADR-0079 order', () => { + let engine: ObjectQL; + let driver: SqlDriver; + + beforeAll(async () => { + engine = new ObjectQL(); + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(SysApprovalDelegation as any, 'com.objectstack.test.20044'); + await engine.syncSchemas(); + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('the registered body points at display_title, a text formula — not the id the designation pass stamped', () => { + const registered = engine.registry.getObject(OBJECT) as any; + expect(registered.nameField).toBe('display_title'); + expect(registered.displayNameField).toBe('display_title'); + expect(resolveDisplayField(registered)).toBe('display_title'); + expect(registered.fields.display_title?.type).toBe('formula'); + expect(registered.fields.display_title?.returnType).toBe('text'); + }); + + it('the H1 is "{delegator_id} → {delegate_id}", not the id', async () => { + const registered = engine.registry.getObject(OBJECT) as any; + const created = await engine.insert(OBJECT, { delegator_id: 'usr_alice', delegate_id: 'usr_bob' }, SYS); + // The write response carries the title too (read-your-write). + expect(created.display_title).toBe('usr_alice → usr_bob'); + const row = await engine.findOne(OBJECT, { where: { id: created.id } }, SYS); + expect(row).not.toBeNull(); + + const h1 = row![resolveDisplayField(registered)!]; + expect(h1).toBe('usr_alice → usr_bob'); + expect(h1).not.toBe(row!.id); + expect(h1).toBe(renderTitleFormat(SysApprovalDelegation, row!)); + expect(resolveRecordTitle(registered, storedOnly(row!))).toBe('usr_alice → usr_bob'); + }); + + it('a row missing a title column is refused, so the formula never sees a NULL part', async () => { + for (const [omit, keep] of [['delegator_id', { delegate_id: 'usr_bob' }], ['delegate_id', { delegator_id: 'usr_alice' }]] as const) { + await expect(engine.insert(OBJECT, keep, SYS)).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + fields: expect.arrayContaining([expect.objectContaining({ field: omit, code: 'required' })]), + }); + } + }); + + it('the formula reads exactly the titleFormat columns, on this row, each required and none withheld', () => { + const fields = SysApprovalDelegation.fields as Record; + const reads = formulaReads(SysApprovalDelegation as any); + // One level deep: a dotted path would read a looked-up record's field. + expect(reads).toEqual(titleFormatColumns(SysApprovalDelegation)); + for (const column of reads) { + expect(fields[column], column).toBeDefined(); + expect(fields[column].required, column).toBe(true); + expect(fields[column].hidden ?? false, column).toBe(false); + expect(fields[column].requiredPermissions ?? [], column).toEqual([]); + expect(fields[column].maskingRule, column).toBeUndefined(); + } + }); + + it('adds no stored column: the formula is computed on read, and no search companion appears', async () => { + const columns = Object.keys(await driver.getKnex()(OBJECT).columnInfo()); + expect(columns).toContain('delegator_id'); + expect(columns).not.toContain('display_title'); + expect(columns).not.toContain('__search'); + }); +}); diff --git a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts index 76033747eb2..e4966389f16 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts @@ -53,8 +53,10 @@ export const SysPositionPermissionSet = ObjectSchema.create({ // [ADR-0079] The record title (`nameField` above). A formula is computed on // read and has no stored column. It reads only this row's own columns — - // the two foreign keys, never a field of the looked-up records — so it shows a reader nothing the row does not already - // show them. Both are required, so the expression needs no null guard. + // the two foreign keys, never a field of the looked-up records — and + // neither is hidden, permission-guarded or masked on this object, so the + // title carries nothing the declared read path withholds. Both are + // required, so the expression needs no null guard. display_title: Field.formula({ label: 'Title', returnType: 'text', diff --git a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts new file mode 100644 index 00000000000..466b5ab8a10 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The record title of the three permission-assignment tables, which declared a + * `titleFormat` and no title pointer (#20044): `sys_position_permission_set`, + * `sys_user_permission_set` and `sys_user_position`. + * + * ADR-0079 resolves a record's title as `nameField ?? displayNameField ?? + * derivation`, and an explicit `nameField` takes precedence over the + * render-only `titleFormat`. With no pointer declared, the registry's + * designate-only pass (`provisionPrimary(…, { synthesize: false })`) stamped + * `nameField: 'id'` — the first title-eligible field — onto each registered + * body, and a `/meta` read serves that stamp as if the author had written it. + * A renderer honouring the order therefore drew the raw id as the record + * page's H1. + * + * Each object now points at `display_title`, a text formula over the columns + * its `titleFormat` names. Through the real engine this file asserts, per + * object: + * + * 1. the body the registry holds after registration names `display_title`; + * 2. a seeded row's H1 is the `titleFormat` text, not the id, and the + * server-side accessor (`resolveRecordTitle`) agrees; + * 3. a row missing a title column is refused by the write path, so the + * formula never sees a NULL part; + * 4. the formula adds no stored column. + * + * And, because these are permission-assignment tables, the property the new + * field must not break: the formula reads exactly the columns `titleFormat` + * names, on this row only — a foreign key, never a field of the record it + * points at — and none of them is hidden, guarded by `requiredPermissions` or + * masked. So the title carries nothing the object's declared read path + * withholds from a reader of the row. No row scope, permission set or + * `apiMethods` entry changes; the formula is a read-only computed field. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL, resolveRecordTitle } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveDisplayField } from '@objectstack/spec/data'; +import { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; +import { SysUserPosition } from './sys-user-position.object.js'; + +const SYS = { context: { isSystem: true } } as any; + +interface Case { + schema: any; + row: Record; + title: string; +} + +const CASES: Case[] = [ + { + schema: SysPositionPermissionSet, + row: { position_id: 'pos_sales', permission_set_id: 'ps_crm_edit' }, + title: 'pos_sales → ps_crm_edit', + }, + { + schema: SysUserPermissionSet, + row: { user_id: 'usr_alice', permission_set_id: 'ps_crm_edit' }, + title: 'usr_alice → ps_crm_edit', + }, + { + schema: SysUserPosition, + row: { user_id: 'usr_alice', position: 'sales_manager' }, + title: 'usr_alice → sales_manager', + }, +]; + +/** The `titleFormat` source: the parsed schema carries it as an envelope. */ +function titleFormatSource(schema: unknown): string { + const tf = (schema as { titleFormat?: unknown }).titleFormat; + const source = typeof tf === 'string' ? tf : (tf as { source?: unknown })?.source; + if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(tf)}`); + return source; +} + +/** + * The H1 a `titleFormat`-first renderer draws: each `{field}` placeholder + * substituted with the row's value. It is the reference the formula has to + * reproduce, not a second title resolver. + */ +function renderTitleFormat(schema: unknown, row: Record): string { + return titleFormatSource(schema).replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key: string) => String(row[key] ?? '')); +} + +/** The columns `titleFormat` names. */ +function titleFormatColumns(schema: unknown): string[] { + return [...titleFormatSource(schema).matchAll(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g)].map((m) => m[1]).sort(); +} + +/** Every `record.` the `display_title` expression reads, as written. */ +function formulaReads(schema: { fields: Record }): string[] { + const source = schema.fields.display_title?.expression?.source; + if (typeof source !== 'string') throw new Error('display_title carries no expression source'); + return [...source.matchAll(/record\.([A-Za-z_][A-Za-z0-9_.]*)/g)].map((m) => m[1]).sort(); +} + +/** The stored row as a hook body holds it: no formula value on it. */ +function storedOnly(row: Record): Record { + const { display_title: _omit, ...rest } = row; + return rest; +} + +describe('[#20044] permission-assignment tables resolve a real record title under ADR-0079 order', () => { + let engine: ObjectQL; + let driver: SqlDriver; + + beforeAll(async () => { + engine = new ObjectQL(); + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + engine.registerDriver(driver, true); + await engine.init(); + for (const { schema } of CASES) { + engine.registry.registerObject(schema, 'com.objectstack.test.20044'); + } + await engine.syncSchemas(); + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + for (const { schema, row: data, title } of CASES) { + const object: string = schema.name; + + it(`${object}: the registered body points at display_title, a text formula — not the id the designation pass stamped`, () => { + const registered = engine.registry.getObject(object) as any; + expect(registered.nameField).toBe('display_title'); + expect(registered.displayNameField).toBe('display_title'); + expect(resolveDisplayField(registered)).toBe('display_title'); + expect(registered.fields.display_title?.type).toBe('formula'); + expect(registered.fields.display_title?.returnType).toBe('text'); + }); + + it(`${object}: the H1 is "${titleFormatSource(schema)}", not the id`, async () => { + const registered = engine.registry.getObject(object) as any; + const created = await engine.insert(object, data, SYS); + expect(created.display_title).toBe(title); + const row = await engine.findOne(object, { where: { id: created.id } }, SYS); + expect(row).not.toBeNull(); + + const h1 = row![resolveDisplayField(registered)!]; + expect(h1).toBe(title); + expect(h1).not.toBe(row!.id); + expect(h1).toBe(renderTitleFormat(schema, row!)); + expect(resolveRecordTitle(registered, storedOnly(row!))).toBe(title); + }); + + it(`${object}: a row missing a title column is refused, so the formula never sees a NULL part`, async () => { + for (const omit of Object.keys(data)) { + const partial: Record = { ...data }; + delete partial[omit]; + await expect(engine.insert(object, partial, SYS)).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + fields: expect.arrayContaining([expect.objectContaining({ field: omit, code: 'required' })]), + }); + } + }); + + it(`${object}: the formula reads exactly the titleFormat columns, on this row, each required and none withheld`, () => { + const fields = schema.fields as Record; + const reads = formulaReads(schema); + // One level deep: a dotted path would read a looked-up record's field, + // which a reader of this row may not be allowed to see. + expect(reads).toEqual(titleFormatColumns(schema)); + for (const column of reads) { + expect(fields[column], column).toBeDefined(); + expect(fields[column].required, column).toBe(true); + expect(fields[column].hidden ?? false, column).toBe(false); + expect(fields[column].requiredPermissions ?? [], column).toEqual([]); + expect(fields[column].maskingRule, column).toBeUndefined(); + } + }); + + it(`${object}: adds no stored column — the formula is computed on read, and no search companion appears`, async () => { + const columns = Object.keys(await driver.getKnex()(object).columnInfo()); + expect(columns).toContain(Object.keys(data)[0]); + expect(columns).not.toContain('display_title'); + expect(columns).not.toContain('__search'); + }); + } +}); diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index e4f8ffeacbd..a73696a9e46 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -52,8 +52,10 @@ export const SysUserPermissionSet = ObjectSchema.create({ // [ADR-0079] The record title (`nameField` above). A formula is computed on // read and has no stored column. It reads only this row's own columns — - // the two foreign keys, never a field of the looked-up records — so it shows a reader nothing the row does not already - // show them. Both are required, so the expression needs no null guard. + // the two foreign keys, never a field of the looked-up records — and + // neither is hidden, permission-guarded or masked on this object, so the + // title carries nothing the declared read path withholds. Both are + // required, so the expression needs no null guard. display_title: Field.formula({ label: 'Title', returnType: 'text', diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index a54d42baa38..8b19ca3064c 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -64,8 +64,10 @@ export const SysUserPosition = ObjectSchema.create({ // [ADR-0079] The record title (`nameField` above). A formula is computed on // read and has no stored column. It reads only this row's own columns — - // the user foreign key and the position name, never a field of the user record — so it shows a reader nothing the row does not already - // show them. Both are required, so the expression needs no null guard. + // the user foreign key and the position name, never a field of the user + // record — and neither is hidden, permission-guarded or masked on this + // object, so the title carries nothing the declared read path withholds. + // Both are required, so the expression needs no null guard. display_title: Field.formula({ label: 'Title', returnType: 'text', diff --git a/packages/services/service-messaging/src/objects/notification-delivery.object.ts b/packages/services/service-messaging/src/objects/notification-delivery.object.ts index e7269a52c84..0d903749211 100644 --- a/packages/services/service-messaging/src/objects/notification-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/notification-delivery.object.ts @@ -86,8 +86,8 @@ export const NotificationDelivery = ObjectSchema.create({ id: Field.text({ label: 'Delivery ID', required: true, readonly: true }), // [ADR-0079] The record title (`nameField` above). A formula is computed - // on read and has no stored column. `channel` and `recipient_id` are all required, so the - // expression needs no null guard. + // on read and has no stored column. `channel` and `recipient_id` are + // both required, so the expression needs no null guard. display_title: Field.formula({ label: 'Title', returnType: 'text', diff --git a/packages/services/service-messaging/src/objects/notification-display-title.test.ts b/packages/services/service-messaging/src/objects/notification-display-title.test.ts new file mode 100644 index 00000000000..6f96bd82772 --- /dev/null +++ b/packages/services/service-messaging/src/objects/notification-display-title.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The record title of the four notification objects that declared a + * `titleFormat` and no title pointer (#20044): `sys_notification_delivery`, + * `sys_notification_preference`, `sys_notification_subscription` and + * `sys_notification_receipt`. + * + * ADR-0079 resolves a record's title as `nameField ?? displayNameField ?? + * derivation`, and an explicit `nameField` takes precedence over the + * render-only `titleFormat`. With no pointer declared, the registry's + * designate-only pass (`provisionPrimary(…, { synthesize: false })`) stamped + * `nameField: 'id'` — the first title-eligible field — onto each registered + * body, and a `/meta` read serves that stamp as if the author had written it. + * A renderer honouring the order therefore drew the raw id as the record + * page's H1. + * + * The three composite titles now point at `display_title`, a text formula over + * the columns `titleFormat` names. `sys_notification_receipt`'s title is one + * column (`{state}`), so its `nameField` names that column directly — the + * describe's own migration for a single-field title. An explicit pointer is + * honoured whatever the field's type (ADR-0079 D4); `select` is only kept out + * of DERIVATION, which is why the pass skipped `state` and stamped `id`. + * + * Through the real engine this file asserts, per object: + * + * 1. the body the registry holds after registration names the new pointer; + * 2. a seeded row's H1 is the `titleFormat` text, not the id, and the + * server-side accessor (`resolveRecordTitle`) agrees; + * 3. a row missing a title column never gives the title a NULL part: the + * write path refuses it, or fills the column's declared default; + * 4. the formula reads exactly the columns `titleFormat` names, on this row + * only, each required and none withheld from a reader of the row; + * 5. nothing adds a stored column — no formula column, no search companion. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL, resolveRecordTitle } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveDisplayField } from '@objectstack/spec/data'; +import { NotificationDelivery } from './notification-delivery.object.js'; +import { NotificationPreference } from './notification-preference.object.js'; +import { NotificationReceipt } from './notification-receipt.object.js'; +import { NotificationSubscription } from './notification-subscription.object.js'; + +const SYS = { context: { isSystem: true } } as any; +const NOW = new Date('2026-09-25T00:00:00Z'); + +interface Case { + schema: any; + /** The title pointer the object declares. */ + pointer: string; + /** A row carrying every column the object requires. */ + row: () => Record; + title: string; + /** Title columns the write path REFUSES to omit (no declared default). */ + refused: string[]; + /** Title columns an omission fills from the declared default, with the H1 that yields. */ + defaulted: Record; +} + +// `notification_id` is part of the delivery and receipt unique keys and of +// neither title, so each row gets its own. +let seq = 0; +const unique = (prefix: string) => `${prefix}_${++seq}`; + +const CASES: Case[] = [ + { + schema: NotificationDelivery, + pointer: 'display_title', + row: () => ({ + notification_id: unique('ntf'), recipient_id: 'usr_alice', channel: 'email', + status: 'pending', created_at: NOW, updated_at: NOW, + }), + title: 'email → usr_alice', + refused: ['channel', 'recipient_id'], + defaulted: {}, + }, + { + schema: NotificationPreference, + pointer: 'display_title', + row: () => ({ user_id: 'usr_alice', topic: 'billing.invoice', channel: 'email', created_at: NOW }), + title: 'usr_alice · billing.invoice · email', + refused: ['user_id'], + // `topic` / `channel` default to the '*' wildcard (ADR-0030 Layer 3). + defaulted: { topic: 'usr_alice · * · email', channel: 'usr_alice · billing.invoice · *' }, + }, + { + schema: NotificationSubscription, + pointer: 'display_title', + row: () => ({ topic: 'billing.invoice', principal: 'role:sales_manager', created_at: NOW }), + title: 'role:sales_manager · billing.invoice', + refused: ['principal', 'topic'], + defaulted: {}, + }, + { + schema: NotificationReceipt, + pointer: 'state', + row: () => ({ notification_id: unique('ntf'), user_id: 'usr_alice', channel: 'inbox', state: 'read', created_at: NOW }), + title: 'read', + refused: [], + // `state` defaults to 'delivered': the receipt a channel writes on delivery. + defaulted: { state: 'delivered' }, + }, +]; + +/** The `titleFormat` source: the parsed schema carries it as an envelope. */ +function titleFormatSource(schema: unknown): string { + const tf = (schema as { titleFormat?: unknown }).titleFormat; + const source = typeof tf === 'string' ? tf : (tf as { source?: unknown })?.source; + if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(tf)}`); + return source; +} + +/** + * The H1 a `titleFormat`-first renderer draws: each `{field}` placeholder + * substituted with the row's value. It is the reference the title has to + * reproduce, not a second title resolver. + */ +function renderTitleFormat(schema: unknown, row: Record): string { + return titleFormatSource(schema).replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key: string) => String(row[key] ?? '')); +} + +/** The columns `titleFormat` names. */ +function titleFormatColumns(schema: unknown): string[] { + return [...titleFormatSource(schema).matchAll(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g)].map((m) => m[1]).sort(); +} + +/** Every `record.` the `display_title` expression reads, as written. */ +function formulaReads(schema: { fields: Record }): string[] { + const source = schema.fields.display_title?.expression?.source; + if (typeof source !== 'string') throw new Error('display_title carries no expression source'); + return [...source.matchAll(/record\.([A-Za-z_][A-Za-z0-9_.]*)/g)].map((m) => m[1]).sort(); +} + +/** The stored row as a hook body holds it: no formula value on it. */ +function storedOnly(row: Record): Record { + const { display_title: _omit, ...rest } = row; + return rest; +} + +describe('[#20044] notification objects resolve a real record title under ADR-0079 order', () => { + let engine: ObjectQL; + let driver: SqlDriver; + + beforeAll(async () => { + engine = new ObjectQL(); + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + engine.registerDriver(driver, true); + await engine.init(); + for (const { schema } of CASES) { + engine.registry.registerObject(schema, 'com.objectstack.test.20044'); + } + await engine.syncSchemas(); + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + for (const c of CASES) { + const object: string = c.schema.name; + + it(`${object}: the registered body points at ${c.pointer} — not the id the designation pass stamped`, () => { + const registered = engine.registry.getObject(object) as any; + expect(registered.nameField).toBe(c.pointer); + expect(registered.displayNameField).toBe(c.pointer); + expect(resolveDisplayField(registered)).toBe(c.pointer); + if (c.pointer === 'display_title') { + expect(registered.fields.display_title?.type).toBe('formula'); + expect(registered.fields.display_title?.returnType).toBe('text'); + } + }); + + it(`${object}: the H1 is "${titleFormatSource(c.schema)}", not the id`, async () => { + const registered = engine.registry.getObject(object) as any; + const created = await engine.insert(object, c.row(), SYS); + const row = await engine.findOne(object, { where: { id: created.id } }, SYS); + expect(row).not.toBeNull(); + + const h1 = row![resolveDisplayField(registered)!]; + expect(h1).toBe(c.title); + expect(h1).not.toBe(row!.id); + expect(h1).toBe(renderTitleFormat(c.schema, row!)); + expect(resolveRecordTitle(registered, storedOnly(row!))).toBe(c.title); + }); + + it(`${object}: a row missing a title column never gives the title a NULL part`, async () => { + const registered = engine.registry.getObject(object) as any; + for (const omit of c.refused) { + const partial = c.row(); + delete partial[omit]; + await expect(engine.insert(object, partial, SYS)).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + fields: expect.arrayContaining([expect.objectContaining({ field: omit, code: 'required' })]), + }); + } + for (const [omit, expected] of Object.entries(c.defaulted)) { + const partial = c.row(); + delete partial[omit]; + const created = await engine.insert(object, partial, SYS); + const row = await engine.findOne(object, { where: { id: created.id } }, SYS); + expect(row![resolveDisplayField(registered)!]).toBe(expected); + expect(row![resolveDisplayField(registered)!]).toBe(renderTitleFormat(c.schema, row!)); + } + expect([...c.refused, ...Object.keys(c.defaulted)].sort()).toEqual(titleFormatColumns(c.schema)); + }); + + it(`${object}: the title reads exactly the titleFormat columns, on this row, each required and none withheld`, () => { + const fields = c.schema.fields as Record; + // One level deep: a dotted path would read a looked-up record's field. + const reads = c.pointer === 'display_title' ? formulaReads(c.schema) : [c.pointer]; + expect(reads).toEqual(titleFormatColumns(c.schema)); + for (const column of reads) { + expect(fields[column], column).toBeDefined(); + expect(fields[column].required, column).toBe(true); + expect(fields[column].hidden ?? false, column).toBe(false); + expect(fields[column].requiredPermissions ?? [], column).toEqual([]); + expect(fields[column].maskingRule, column).toBeUndefined(); + } + }); + + it(`${object}: adds no stored column — no formula column and no search companion`, async () => { + const columns = Object.keys(await driver.getKnex()(object).columnInfo()); + expect(columns).toContain(titleFormatColumns(c.schema)[0]); + expect(columns).not.toContain('display_title'); + expect(columns).not.toContain('__search'); + }); + } +}); diff --git a/packages/services/service-messaging/src/objects/notification-preference.object.ts b/packages/services/service-messaging/src/objects/notification-preference.object.ts index 7eded11f3c0..2e3381cba6b 100644 --- a/packages/services/service-messaging/src/objects/notification-preference.object.ts +++ b/packages/services/service-messaging/src/objects/notification-preference.object.ts @@ -48,8 +48,8 @@ export const NotificationPreference = ObjectSchema.create({ id: Field.text({ label: 'Preference ID', required: true, readonly: true }), // [ADR-0079] The record title (`nameField` above). A formula is computed - // on read and has no stored column. `user_id`, `topic` and `channel` are all required, so the - // expression needs no null guard. + // on read and has no stored column. `user_id`, `topic` and `channel` + // are all required, so the expression needs no null guard. display_title: Field.formula({ label: 'Title', returnType: 'text', diff --git a/packages/services/service-messaging/src/objects/notification-subscription.object.ts b/packages/services/service-messaging/src/objects/notification-subscription.object.ts index 71eef54001d..12a6a9041a7 100644 --- a/packages/services/service-messaging/src/objects/notification-subscription.object.ts +++ b/packages/services/service-messaging/src/objects/notification-subscription.object.ts @@ -52,8 +52,8 @@ export const NotificationSubscription = ObjectSchema.create({ id: Field.text({ label: 'Subscription ID', required: true, readonly: true }), // [ADR-0079] The record title (`nameField` above). A formula is computed - // on read and has no stored column. `principal` and `topic` are all required, so the - // expression needs no null guard. + // on read and has no stored column. `principal` and `topic` are both + // required, so the expression needs no null guard. display_title: Field.formula({ label: 'Title', returnType: 'text', diff --git a/packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts b/packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts new file mode 100644 index 00000000000..5ec8c42d0dd --- /dev/null +++ b/packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The record title of `sys_presence`, which declared a `titleFormat` and no + * title pointer (#20044). + * + * ADR-0079 resolves a record's title as `nameField ?? displayNameField ?? + * derivation`, and an explicit `nameField` takes precedence over the + * render-only `titleFormat`. With no pointer declared, the registry's + * designate-only pass stamped `nameField: 'id'` — the first title-eligible + * field — onto the registered body, and a `/meta` read serves that stamp as if + * the author had written it. A renderer honouring the order therefore drew the + * raw id as the record page's H1. + * + * The object now points at `display_title`, a text formula over the columns + * `titleFormat` names. This file asserts: + * + * 1. the designate-only pass the registry runs at registration + * (`provisionPrimary(…, { synthesize: false })`, ADR-0079 D7) keeps + * `display_title` and no longer lands on `id`; + * 2. the formula reads exactly the columns `titleFormat` names, on this row + * only, each required and none withheld from a reader of the row. + * + * What it does NOT assert, deliberately: the rendered text. That needs the + * engine to evaluate the formula, and this package's dependency closure has + * none (`@objectstack/objectql` and `@objectstack/driver-sql` are not among its + * dependencies). The sibling objects' suites in plugin-security and + * service-messaging hold the same formula shape to the `titleFormat` text + * through the real engine. + */ + +import { describe, it, expect } from 'vitest'; +import { provisionPrimary, resolveDisplayField } from '@objectstack/spec/data'; +import { SysPresence } from './sys-presence.object.js'; + +/** The `titleFormat` source: the parsed schema carries it as an envelope. */ +function titleFormatSource(schema: unknown): string { + const tf = (schema as { titleFormat?: unknown }).titleFormat; + const source = typeof tf === 'string' ? tf : (tf as { source?: unknown })?.source; + if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(tf)}`); + return source; +} + +/** The columns `titleFormat` names. */ +function titleFormatColumns(schema: unknown): string[] { + return [...titleFormatSource(schema).matchAll(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g)].map((m) => m[1]).sort(); +} + +/** Every `record.` the `display_title` expression reads, as written. */ +function formulaReads(schema: { fields: Record }): string[] { + const source = schema.fields.display_title?.expression?.source; + if (typeof source !== 'string') throw new Error('display_title carries no expression source'); + return [...source.matchAll(/record\.([A-Za-z_][A-Za-z0-9_.]*)/g)].map((m) => m[1]).sort(); +} + +describe('[#20044] sys_presence declares a real record title under ADR-0079 order', () => { + it('the designation pass keeps display_title, a text formula — it no longer stamps the id', () => { + const designated = provisionPrimary(SysPresence as any, { synthesize: false }) as any; + expect(designated.nameField).toBe('display_title'); + expect(designated.nameField).not.toBe('id'); + expect(SysPresence.displayNameField).toBe('display_title'); + expect(resolveDisplayField(SysPresence as any)).toBe('display_title'); + const field = (SysPresence.fields as Record).display_title; + expect(field?.type).toBe('formula'); + expect(field?.returnType).toBe('text'); + }); + + it('the formula reads exactly the titleFormat columns, on this row, each required and none withheld', () => { + const fields = SysPresence.fields as Record; + const reads = formulaReads(SysPresence as any); + // One level deep: a dotted path would read a looked-up record's field. + expect(reads).toEqual(titleFormatColumns(SysPresence)); + for (const column of reads) { + expect(fields[column], column).toBeDefined(); + expect(fields[column].required, column).toBe(true); + expect(fields[column].hidden ?? false, column).toBe(false); + expect(fields[column].requiredPermissions ?? [], column).toEqual([]); + expect(fields[column].maskingRule, column).toBeUndefined(); + } + }); +}); diff --git a/packages/services/service-realtime/src/objects/sys-presence.object.ts b/packages/services/service-realtime/src/objects/sys-presence.object.ts index 8ce347a24dd..04530921b7c 100644 --- a/packages/services/service-realtime/src/objects/sys-presence.object.ts +++ b/packages/services/service-realtime/src/objects/sys-presence.object.ts @@ -29,7 +29,8 @@ export const SysPresence = ObjectSchema.create({ // title-eligible field), so a renderer honouring ADR-0079's order (an // explicit `nameField` wins over `titleFormat`) drew the raw id as the record // page's H1. `titleFormat` stays for renderers that still read it first; - // `sys-presence-display-title.test.ts` holds the two to the same text. + // `sys-presence-display-title.test.ts` pins the pointer and the formula's + // inputs. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{user_id} ({status})', From 3fc935d944d74a57bdc426da88e7fb52a30f090d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 01:55:37 +0000 Subject: [PATCH 3/6] chore(i18n): regenerate the four packages' bundles for display_title Output of `node scripts/check-i18n-bundles.mjs --write --filter=` for plugin-approvals, plugin-security, service-messaging and service-realtime, unedited: the English leaves come from the new formula fields' label and description, the translated locales carry the generator's default fill, and the source-hash companions record those fills. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../src/translations/en.objects.generated.ts | 4 ++++ .../src/translations/es-ES.objects.generated.ts | 4 ++++ .../translations/es-ES.source-hashes.generated.ts | 2 ++ .../src/translations/ja-JP.objects.generated.ts | 4 ++++ .../translations/ja-JP.source-hashes.generated.ts | 2 ++ .../src/translations/zh-CN.objects.generated.ts | 4 ++++ .../translations/zh-CN.source-hashes.generated.ts | 2 ++ .../src/translations/en.objects.generated.ts | 12 ++++++++++++ .../src/translations/es-ES.objects.generated.ts | 12 ++++++++++++ .../translations/es-ES.source-hashes.generated.ts | 6 ++++++ .../src/translations/ja-JP.objects.generated.ts | 12 ++++++++++++ .../translations/ja-JP.source-hashes.generated.ts | 6 ++++++ .../src/translations/zh-CN.objects.generated.ts | 12 ++++++++++++ .../translations/zh-CN.source-hashes.generated.ts | 6 ++++++ .../src/translations/en.objects.generated.ts | 12 ++++++++++++ .../src/translations/es-ES.objects.generated.ts | 12 ++++++++++++ .../translations/es-ES.source-hashes.generated.ts | 6 ++++++ .../src/translations/ja-JP.objects.generated.ts | 12 ++++++++++++ .../translations/ja-JP.source-hashes.generated.ts | 6 ++++++ .../src/translations/zh-CN.objects.generated.ts | 12 ++++++++++++ .../translations/zh-CN.source-hashes.generated.ts | 6 ++++++ .../src/translations/en.objects.generated.ts | 4 ++++ .../src/translations/es-ES.objects.generated.ts | 4 ++++ .../translations/es-ES.source-hashes.generated.ts | 2 ++ .../src/translations/ja-JP.objects.generated.ts | 4 ++++ .../translations/ja-JP.source-hashes.generated.ts | 2 ++ .../src/translations/zh-CN.objects.generated.ts | 4 ++++ .../translations/zh-CN.source-hashes.generated.ts | 2 ++ 28 files changed, 176 insertions(+) diff --git a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts index 31cb44d26b9..36b05f628d8 100644 --- a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts @@ -302,6 +302,10 @@ export const enObjects: NonNullable = { id: { label: "Delegation ID" }, + display_title: { + label: "Title", + help: "Record title: the delegator and the delegate (computed on read)" + }, delegator_id: { label: "Delegator", help: "The user going out of office; their individually-routed approver slots are rerouted while active." diff --git a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts index 946b5aef792..2076285dd1c 100644 --- a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts @@ -302,6 +302,10 @@ export const esESObjects: NonNullable = { id: { label: "Delegation ID" }, + display_title: { + label: "Title", + help: "Record title: the delegator and the delegate (computed on read)" + }, delegator_id: { label: "Delegator", help: "The user going out of office; their individually-routed approver slots are rerouted while active." diff --git a/packages/plugins/plugin-approvals/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-approvals/src/translations/es-ES.source-hashes.generated.ts index 5cc4abc43f3..f48e8b04945 100644 --- a/packages/plugins/plugin-approvals/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/es-ES.source-hashes.generated.ts @@ -28,6 +28,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14", "objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be", "objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff", + "objects.sys_approval_delegation.fields.display_title.help": "2831a1ffde72b425", + "objects.sys_approval_delegation.fields.display_title.label": "70f7aadecce647a5", "objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d", "objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca", "objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de", diff --git a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts index ff60085bfae..0d88fd82f7e 100644 --- a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts @@ -302,6 +302,10 @@ export const jaJPObjects: NonNullable = { id: { label: "Delegation ID" }, + display_title: { + label: "Title", + help: "Record title: the delegator and the delegate (computed on read)" + }, delegator_id: { label: "Delegator", help: "The user going out of office; their individually-routed approver slots are rerouted while active." diff --git a/packages/plugins/plugin-approvals/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-approvals/src/translations/ja-JP.source-hashes.generated.ts index 1ae3cd1db98..9e9a64105b0 100644 --- a/packages/plugins/plugin-approvals/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/ja-JP.source-hashes.generated.ts @@ -27,6 +27,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14", "objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be", "objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff", + "objects.sys_approval_delegation.fields.display_title.help": "2831a1ffde72b425", + "objects.sys_approval_delegation.fields.display_title.label": "70f7aadecce647a5", "objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d", "objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca", "objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de", diff --git a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts index 85f126e8122..b0fc03ce2e5 100644 --- a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts @@ -302,6 +302,10 @@ export const zhCNObjects: NonNullable = { id: { label: "委派 ID" }, + display_title: { + label: "Title", + help: "Record title: the delegator and the delegate (computed on read)" + }, delegator_id: { label: "委派人", help: "即将不在岗的用户;规则生效期间,路由到其个人的审批人槽位将被改派。" diff --git a/packages/plugins/plugin-approvals/src/translations/zh-CN.source-hashes.generated.ts b/packages/plugins/plugin-approvals/src/translations/zh-CN.source-hashes.generated.ts index db1ec71ba19..08320d78c50 100644 --- a/packages/plugins/plugin-approvals/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/zh-CN.source-hashes.generated.ts @@ -18,6 +18,8 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { + "objects.sys_approval_delegation.fields.display_title.help": "2831a1ffde72b425", + "objects.sys_approval_delegation.fields.display_title.label": "70f7aadecce647a5", "objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae", "objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c", "objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090", diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index 018f2123a14..0d7ac44750f 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -318,6 +318,10 @@ export const enObjects: NonNullable = { label: "Assignment ID", help: "UUID of the assignment." }, + display_title: { + label: "Title", + help: "Record title: the user and the permission set assigned to them (computed on read)" + }, user_id: { label: "User", help: "Foreign key to sys_user." @@ -371,6 +375,10 @@ export const enObjects: NonNullable = { label: "Binding ID", help: "UUID of the position-permission-set binding." }, + display_title: { + label: "Title", + help: "Record title: the position and the permission set bound to it (computed on read)" + }, position_id: { label: "Position", help: "Foreign key to sys_position." @@ -396,6 +404,10 @@ export const enObjects: NonNullable = { label: "Assignment ID", help: "UUID of the user-position assignment." }, + display_title: { + label: "Title", + help: "Record title: the user and the position they hold (computed on read)" + }, user_id: { label: "User", help: "Foreign key to sys_user." diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 3485d07ba03..ce61465e6b6 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -318,6 +318,10 @@ export const esESObjects: NonNullable = { label: "ID de asignación", help: "UUID de la asignación." }, + display_title: { + label: "Title", + help: "Record title: the user and the permission set assigned to them (computed on read)" + }, user_id: { label: "Usuario", help: "Clave foránea a sys_user." @@ -371,6 +375,10 @@ export const esESObjects: NonNullable = { label: "ID de vinculación", help: "UUID de la vinculación puesto-conjunto de permisos." }, + display_title: { + label: "Title", + help: "Record title: the position and the permission set bound to it (computed on read)" + }, position_id: { label: "Puesto", help: "Clave foránea a sys_position." @@ -396,6 +404,10 @@ export const esESObjects: NonNullable = { label: "ID de asignación", help: "UUID de la asignación usuario-puesto." }, + display_title: { + label: "Title", + help: "Record title: the user and the position they hold (computed on read)" + }, user_id: { label: "Usuario", help: "Clave foránea a sys_user." diff --git a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts index 859f9d0ddd0..ea72549b538 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts @@ -30,5 +30,11 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", + "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", + "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", + "objects.sys_user_permission_set.fields.display_title.help": "ee7ef063488cd6c3", + "objects.sys_user_permission_set.fields.display_title.label": "70f7aadecce647a5", "objects.sys_user_position._validations.reserved_identity_position.message": "f35df6c1ef1493b0", + "objects.sys_user_position.fields.display_title.help": "39b5571b48e7d800", + "objects.sys_user_position.fields.display_title.label": "70f7aadecce647a5", }; diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index a9a05673a9f..b2ff622896f 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -318,6 +318,10 @@ export const jaJPObjects: NonNullable = { label: "割り当て ID", help: "割り当ての UUID。" }, + display_title: { + label: "Title", + help: "Record title: the user and the permission set assigned to them (computed on read)" + }, user_id: { label: "ユーザー", help: "sys_user への外部キー。" @@ -371,6 +375,10 @@ export const jaJPObjects: NonNullable = { label: "バインド ID", help: "ポジション権限セットバインドの UUID。" }, + display_title: { + label: "Title", + help: "Record title: the position and the permission set bound to it (computed on read)" + }, position_id: { label: "ポジション", help: "sys_position への外部キー。" @@ -396,6 +404,10 @@ export const jaJPObjects: NonNullable = { label: "割り当て ID", help: "ユーザーポジション割り当ての UUID。" }, + display_title: { + label: "Title", + help: "Record title: the user and the position they hold (computed on read)" + }, user_id: { label: "ユーザー", help: "sys_user への外部キー。" diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts index 606307a04fd..fdc3773b416 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts @@ -30,5 +30,11 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", + "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", + "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", + "objects.sys_user_permission_set.fields.display_title.help": "ee7ef063488cd6c3", + "objects.sys_user_permission_set.fields.display_title.label": "70f7aadecce647a5", "objects.sys_user_position._validations.reserved_identity_position.message": "f35df6c1ef1493b0", + "objects.sys_user_position.fields.display_title.help": "39b5571b48e7d800", + "objects.sys_user_position.fields.display_title.label": "70f7aadecce647a5", }; diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index deed8c1448f..c9f175a080a 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -318,6 +318,10 @@ export const zhCNObjects: NonNullable = { label: "分配 ID", help: "该分配记录的 UUID。" }, + display_title: { + label: "Title", + help: "Record title: the user and the permission set assigned to them (computed on read)" + }, user_id: { label: "用户", help: "指向 sys_user 的外键。" @@ -371,6 +375,10 @@ export const zhCNObjects: NonNullable = { label: "绑定 ID", help: "岗位-权限集绑定记录的 UUID。" }, + display_title: { + label: "Title", + help: "Record title: the position and the permission set bound to it (computed on read)" + }, position_id: { label: "岗位", help: "指向 sys_position 的外键。" @@ -396,6 +404,10 @@ export const zhCNObjects: NonNullable = { label: "分配 ID", help: "用户-岗位分配的 UUID。" }, + display_title: { + label: "Title", + help: "Record title: the user and the position they hold (computed on read)" + }, user_id: { label: "用户", help: "指向 sys_user 的外键。" diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts index 828abf2f582..1151b17de31 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts @@ -30,5 +30,11 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", + "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", + "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", + "objects.sys_user_permission_set.fields.display_title.help": "ee7ef063488cd6c3", + "objects.sys_user_permission_set.fields.display_title.label": "70f7aadecce647a5", "objects.sys_user_position._validations.reserved_identity_position.message": "f35df6c1ef1493b0", + "objects.sys_user_position.fields.display_title.help": "39b5571b48e7d800", + "objects.sys_user_position.fields.display_title.label": "70f7aadecce647a5", }; diff --git a/packages/services/service-messaging/src/translations/en.objects.generated.ts b/packages/services/service-messaging/src/translations/en.objects.generated.ts index f1d9938dc5c..4aefa150907 100644 --- a/packages/services/service-messaging/src/translations/en.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts @@ -121,6 +121,10 @@ export const enObjects: NonNullable = { id: { label: "Delivery ID" }, + display_title: { + label: "Title", + help: "Record title: the delivery channel and its recipient (computed on read)" + }, notification_id: { label: "Notification Event", help: "FK → sys_notification (L2 event)" @@ -191,6 +195,10 @@ export const enObjects: NonNullable = { id: { label: "Preference ID" }, + display_title: { + label: "Title", + help: "Record title: the user, the topic and the channel this toggle covers (computed on read)" + }, user_id: { label: "User", help: "Recipient user id, or '*' for the admin-global default." @@ -236,6 +244,10 @@ export const enObjects: NonNullable = { id: { label: "Subscription ID" }, + display_title: { + label: "Title", + help: "Record title: the subscribing principal and the topic (computed on read)" + }, topic: { label: "Topic", help: "Notification topic this principal subscribes to." diff --git a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts index f4233f24290..cfd50c8c321 100644 --- a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts @@ -121,6 +121,10 @@ export const esESObjects: NonNullable = { id: { label: "Delivery ID" }, + display_title: { + label: "Title", + help: "Record title: the delivery channel and its recipient (computed on read)" + }, notification_id: { label: "Notification Event", help: "FK → sys_notification (L2 event)" @@ -191,6 +195,10 @@ export const esESObjects: NonNullable = { id: { label: "Preference ID" }, + display_title: { + label: "Title", + help: "Record title: the user, the topic and the channel this toggle covers (computed on read)" + }, user_id: { label: "User", help: "Recipient user id, or '*' for the admin-global default." @@ -236,6 +244,10 @@ export const esESObjects: NonNullable = { id: { label: "Subscription ID" }, + display_title: { + label: "Title", + help: "Record title: the subscribing principal and the topic (computed on read)" + }, topic: { label: "Topic", help: "Notification topic this principal subscribes to." diff --git a/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts index 4b63ef7de7c..d418c2f7faf 100644 --- a/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts @@ -68,6 +68,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_notification_delivery.fields.created_at.label": "1f02d416befb595b", "objects.sys_notification_delivery.fields.digest_key.help": "6277161d780a5a7f", "objects.sys_notification_delivery.fields.digest_key.label": "c67774ec9d8352cd", + "objects.sys_notification_delivery.fields.display_title.help": "0803d3f90a14907c", + "objects.sys_notification_delivery.fields.display_title.label": "70f7aadecce647a5", "objects.sys_notification_delivery.fields.error.label": "786fed84bd8d5a32", "objects.sys_notification_delivery.fields.id.label": "05179727cb19255c", "objects.sys_notification_delivery.fields.last_attempted_at.label": "2123f8266be603b8", @@ -98,6 +100,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_notification_preference.fields.digest.options.daily": "1dd810d2b5a02102", "objects.sys_notification_preference.fields.digest.options.none": "f0ac498bc08c6383", "objects.sys_notification_preference.fields.digest.options.weekly": "18ffbbec919e0d35", + "objects.sys_notification_preference.fields.display_title.help": "13aff17d09067e30", + "objects.sys_notification_preference.fields.display_title.label": "70f7aadecce647a5", "objects.sys_notification_preference.fields.enabled.help": "94e2727fd8c18296", "objects.sys_notification_preference.fields.enabled.label": "41fb04f20e64dc4f", "objects.sys_notification_preference.fields.id.label": "429cdf8675ce2833", @@ -131,6 +135,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_notification_receipt.pluralLabel": "405256a600be0880", "objects.sys_notification_subscription.description": "00de4abdcb0d43a8", "objects.sys_notification_subscription.fields.created_at.label": "1f02d416befb595b", + "objects.sys_notification_subscription.fields.display_title.help": "1100bd46caf9cb17", + "objects.sys_notification_subscription.fields.display_title.label": "70f7aadecce647a5", "objects.sys_notification_subscription.fields.enabled.help": "37dd96827553974c", "objects.sys_notification_subscription.fields.enabled.label": "41fb04f20e64dc4f", "objects.sys_notification_subscription.fields.id.label": "9dd5d9fded88d363", diff --git a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts index d902bf925e6..138da5fc2e7 100644 --- a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts @@ -121,6 +121,10 @@ export const jaJPObjects: NonNullable = { id: { label: "Delivery ID" }, + display_title: { + label: "Title", + help: "Record title: the delivery channel and its recipient (computed on read)" + }, notification_id: { label: "Notification Event", help: "FK → sys_notification (L2 event)" @@ -191,6 +195,10 @@ export const jaJPObjects: NonNullable = { id: { label: "Preference ID" }, + display_title: { + label: "Title", + help: "Record title: the user, the topic and the channel this toggle covers (computed on read)" + }, user_id: { label: "User", help: "Recipient user id, or '*' for the admin-global default." @@ -236,6 +244,10 @@ export const jaJPObjects: NonNullable = { id: { label: "Subscription ID" }, + display_title: { + label: "Title", + help: "Record title: the subscribing principal and the topic (computed on read)" + }, topic: { label: "Topic", help: "Notification topic this principal subscribes to." diff --git a/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts index b81556dd76c..9dea67fbc6e 100644 --- a/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts @@ -67,6 +67,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_notification_delivery.fields.created_at.label": "1f02d416befb595b", "objects.sys_notification_delivery.fields.digest_key.help": "6277161d780a5a7f", "objects.sys_notification_delivery.fields.digest_key.label": "c67774ec9d8352cd", + "objects.sys_notification_delivery.fields.display_title.help": "0803d3f90a14907c", + "objects.sys_notification_delivery.fields.display_title.label": "70f7aadecce647a5", "objects.sys_notification_delivery.fields.error.label": "786fed84bd8d5a32", "objects.sys_notification_delivery.fields.id.label": "05179727cb19255c", "objects.sys_notification_delivery.fields.last_attempted_at.label": "2123f8266be603b8", @@ -97,6 +99,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_notification_preference.fields.digest.options.daily": "1dd810d2b5a02102", "objects.sys_notification_preference.fields.digest.options.none": "f0ac498bc08c6383", "objects.sys_notification_preference.fields.digest.options.weekly": "18ffbbec919e0d35", + "objects.sys_notification_preference.fields.display_title.help": "13aff17d09067e30", + "objects.sys_notification_preference.fields.display_title.label": "70f7aadecce647a5", "objects.sys_notification_preference.fields.enabled.help": "94e2727fd8c18296", "objects.sys_notification_preference.fields.enabled.label": "41fb04f20e64dc4f", "objects.sys_notification_preference.fields.id.label": "429cdf8675ce2833", @@ -130,6 +134,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_notification_receipt.pluralLabel": "405256a600be0880", "objects.sys_notification_subscription.description": "00de4abdcb0d43a8", "objects.sys_notification_subscription.fields.created_at.label": "1f02d416befb595b", + "objects.sys_notification_subscription.fields.display_title.help": "1100bd46caf9cb17", + "objects.sys_notification_subscription.fields.display_title.label": "70f7aadecce647a5", "objects.sys_notification_subscription.fields.enabled.help": "37dd96827553974c", "objects.sys_notification_subscription.fields.enabled.label": "41fb04f20e64dc4f", "objects.sys_notification_subscription.fields.id.label": "9dd5d9fded88d363", diff --git a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts index 08bf60680bf..3b7bdf6dd6a 100644 --- a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts @@ -121,6 +121,10 @@ export const zhCNObjects: NonNullable = { id: { label: "投递 ID" }, + display_title: { + label: "Title", + help: "Record title: the delivery channel and its recipient (computed on read)" + }, notification_id: { label: "通知事件", help: "外键 → sys_notification(L2 事件)" @@ -191,6 +195,10 @@ export const zhCNObjects: NonNullable = { id: { label: "偏好 ID" }, + display_title: { + label: "Title", + help: "Record title: the user, the topic and the channel this toggle covers (computed on read)" + }, user_id: { label: "用户", help: "接收用户 ID,'*' 表示管理员全局默认值。" @@ -236,6 +244,10 @@ export const zhCNObjects: NonNullable = { id: { label: "订阅 ID" }, + display_title: { + label: "Title", + help: "Record title: the subscribing principal and the topic (computed on read)" + }, topic: { label: "主题", help: "该主体订阅的通知主题。" diff --git a/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts index 8e3ba6ad751..32e25116645 100644 --- a/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts @@ -21,4 +21,10 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_http_delivery.fields.error.help": "3edd3406757bedcf", "objects.sys_http_delivery.fields.headers_json.help": "b14e8e640874e0a6", "objects.sys_http_delivery.fields.status.help": "e437ecf81dfb4715", + "objects.sys_notification_delivery.fields.display_title.help": "0803d3f90a14907c", + "objects.sys_notification_delivery.fields.display_title.label": "70f7aadecce647a5", + "objects.sys_notification_preference.fields.display_title.help": "13aff17d09067e30", + "objects.sys_notification_preference.fields.display_title.label": "70f7aadecce647a5", + "objects.sys_notification_subscription.fields.display_title.help": "1100bd46caf9cb17", + "objects.sys_notification_subscription.fields.display_title.label": "70f7aadecce647a5", }; diff --git a/packages/services/service-realtime/src/translations/en.objects.generated.ts b/packages/services/service-realtime/src/translations/en.objects.generated.ts index e29bd4a38e2..b632528d4c7 100644 --- a/packages/services/service-realtime/src/translations/en.objects.generated.ts +++ b/packages/services/service-realtime/src/translations/en.objects.generated.ts @@ -23,6 +23,10 @@ export const enObjects: NonNullable = { id: { label: "Presence ID" }, + display_title: { + label: "Title", + help: "Record title: the user and their presence status (computed on read)" + }, created_at: { label: "Created At" }, diff --git a/packages/services/service-realtime/src/translations/es-ES.objects.generated.ts b/packages/services/service-realtime/src/translations/es-ES.objects.generated.ts index 3b656e84c5a..602fc83f514 100644 --- a/packages/services/service-realtime/src/translations/es-ES.objects.generated.ts +++ b/packages/services/service-realtime/src/translations/es-ES.objects.generated.ts @@ -23,6 +23,10 @@ export const esESObjects: NonNullable = { id: { label: "ID de presencia" }, + display_title: { + label: "Title", + help: "Record title: the user and their presence status (computed on read)" + }, created_at: { label: "Creado el" }, diff --git a/packages/services/service-realtime/src/translations/es-ES.source-hashes.generated.ts b/packages/services/service-realtime/src/translations/es-ES.source-hashes.generated.ts index 854c8b71cb2..0dc56d6d456 100644 --- a/packages/services/service-realtime/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/services/service-realtime/src/translations/es-ES.source-hashes.generated.ts @@ -18,4 +18,6 @@ */ export const esESGeneratedSourceHashes: Readonly> = { + "objects.sys_presence.fields.display_title.help": "fe510885df94967c", + "objects.sys_presence.fields.display_title.label": "70f7aadecce647a5", }; diff --git a/packages/services/service-realtime/src/translations/ja-JP.objects.generated.ts b/packages/services/service-realtime/src/translations/ja-JP.objects.generated.ts index 29bdfc34909..75467521d69 100644 --- a/packages/services/service-realtime/src/translations/ja-JP.objects.generated.ts +++ b/packages/services/service-realtime/src/translations/ja-JP.objects.generated.ts @@ -23,6 +23,10 @@ export const jaJPObjects: NonNullable = { id: { label: "在席 ID" }, + display_title: { + label: "Title", + help: "Record title: the user and their presence status (computed on read)" + }, created_at: { label: "作成日時" }, diff --git a/packages/services/service-realtime/src/translations/ja-JP.source-hashes.generated.ts b/packages/services/service-realtime/src/translations/ja-JP.source-hashes.generated.ts index 517826c13df..5118a655731 100644 --- a/packages/services/service-realtime/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/services/service-realtime/src/translations/ja-JP.source-hashes.generated.ts @@ -18,4 +18,6 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { + "objects.sys_presence.fields.display_title.help": "fe510885df94967c", + "objects.sys_presence.fields.display_title.label": "70f7aadecce647a5", }; diff --git a/packages/services/service-realtime/src/translations/zh-CN.objects.generated.ts b/packages/services/service-realtime/src/translations/zh-CN.objects.generated.ts index e41f5399f72..32991adb613 100644 --- a/packages/services/service-realtime/src/translations/zh-CN.objects.generated.ts +++ b/packages/services/service-realtime/src/translations/zh-CN.objects.generated.ts @@ -23,6 +23,10 @@ export const zhCNObjects: NonNullable = { id: { label: "在线状态 ID" }, + display_title: { + label: "Title", + help: "Record title: the user and their presence status (computed on read)" + }, created_at: { label: "创建时间" }, diff --git a/packages/services/service-realtime/src/translations/zh-CN.source-hashes.generated.ts b/packages/services/service-realtime/src/translations/zh-CN.source-hashes.generated.ts index f74e0d3d8a1..50d030178a4 100644 --- a/packages/services/service-realtime/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/services/service-realtime/src/translations/zh-CN.source-hashes.generated.ts @@ -18,4 +18,6 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { + "objects.sys_presence.fields.display_title.help": "fe510885df94967c", + "objects.sys_presence.fields.display_title.label": "70f7aadecce647a5", }; From d040485b5bba1430f9767ce350c0925285f34969 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 02:00:35 +0000 Subject: [PATCH 4/6] chore(changeset): patch the four packages for the nine title pointers Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .changeset/20044-services-title-pointers.md | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .changeset/20044-services-title-pointers.md diff --git a/.changeset/20044-services-title-pointers.md b/.changeset/20044-services-title-pointers.md new file mode 100644 index 00000000000..971e2f97747 --- /dev/null +++ b/.changeset/20044-services-title-pointers.md @@ -0,0 +1,33 @@ +--- +"@objectstack/plugin-approvals": patch +"@objectstack/plugin-security": patch +"@objectstack/service-messaging": patch +"@objectstack/service-realtime": patch +--- + +fix(plugin-approvals, plugin-security, service-messaging, service-realtime): nine system objects that relied on `titleFormat` declare a title pointer, so their record title is no longer the raw id (#20044) + +Clause-②: no + +ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Nine system objects declared a `titleFormat` and no pointer. When such an object is registered, the registry's designate-only pass picks the first title-eligible field as `nameField`, and for these nine that field is `id`. A `/meta` read serves that pointer as if it had been declared, so a renderer that follows ADR-0079's order showed the raw record id as the record page's title. + +Eight of the titles are composites. Each of those objects now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it: + +- `sys_approval_delegation`: `{delegator_id} → {delegate_id}`; +- `sys_position_permission_set`: `{position_id} → {permission_set_id}`; +- `sys_user_permission_set`: `{user_id} → {permission_set_id}`; +- `sys_user_position`: `{user_id} → {position}`; +- `sys_notification_delivery`: `{channel} → {recipient_id}`; +- `sys_notification_preference`: `{user_id} · {topic} · {channel}`; +- `sys_notification_subscription`: `{principal} · {topic}`; +- `sys_presence`: `{user_id} ({status})`. + +`sys_notification_receipt`'s title is the single column `{state}`, so its `nameField` and `displayNameField` now name `state` directly. + +This is the migration the `titleFormat` schema text prescribes: "Migrate a single-field title to nameField, a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Every column these titles read is required, so the formulas carry no null guard. Each formula reads only its own row's columns, never a field of a looked-up record. + +A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses of the eight objects now carry `display_title`, and the server-side title accessor (`resolveRecordTitle`) returns the title text instead of the raw id. No row scope, permission set or API method changes. + +`titleFormat` stays on all nine objects, unchanged, for renderers that still read it first. The set of fields `$search` scans is unchanged: a formula field is never a search target, and neither was `id`. On `sys_notification_receipt`, `state` was already in the set and now leads it. No search-companion column is provisioned for any of the nine. + +The new `display_title` label and help text are in each package's English bundle. The zh-CN, ja-JP and es-ES bundles carry the generator's English fill for them, recorded in the source-hash companions. From 796a5578b5719cef165b7b0711b1d2c4e0016e47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 02:17:34 +0000 Subject: [PATCH 5/6] test(plugin-approvals, plugin-security, service-messaging): ask a pinyin-enabled registry about the search companion The engine the tests boot builds its registry with the companion switched off (it follows OS_SEARCH_PINYIN_ENABLED), so "no __search column" could never fail there. The pin now registers each object in a registry built with `searchCompanion: true` and asserts no companion field is provisioned. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- ...-approval-delegation-display-title.test.ts | 20 +++++++++++++++---- ...-security-assignment-display-title.test.ts | 20 +++++++++++++++---- .../notification-display-title.test.ts | 20 +++++++++++++++---- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts index 8edfd235fe4..0307c4204a7 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts @@ -24,11 +24,13 @@ * here every title column is required); * 4. the formula reads exactly the columns `titleFormat` names, on this row * only, each required and none withheld from a reader of the row; - * 5. the formula adds no stored column. + * 5. the formula adds no stored column, and no search companion column + * either — not even where pinyin search provisions one (a formula is + * never a companion source). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ObjectQL, resolveRecordTitle } from '@objectstack/objectql'; +import { ObjectQL, SchemaRegistry, SEARCH_COMPANION_FIELD, resolveRecordTitle } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; @@ -139,10 +141,20 @@ describe('[#20044] sys_approval_delegation resolves a real record title under AD } }); - it('adds no stored column: the formula is computed on read, and no search companion appears', async () => { + it('adds no stored column: the formula is computed on read', async () => { const columns = Object.keys(await driver.getKnex()(OBJECT).columnInfo()); expect(columns).toContain('delegator_id'); expect(columns).not.toContain('display_title'); - expect(columns).not.toContain('__search'); + }); + + it('provisions no search companion column, even where pinyin search is on', () => { + // The companion (`__search`) is a real column fed by the title field; a + // registry only provisions it when pinyin search is enabled, so ask one + // that is. A formula title is never a companion source. + const companionRegistry = new SchemaRegistry({ searchCompanion: true }); + companionRegistry.registerObject(SysApprovalDelegation as any, 'com.objectstack.test.20044'); + const registered = companionRegistry.getObject(OBJECT) as any; + expect(registered.nameField).toBe('display_title'); + expect(registered.fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); }); }); diff --git a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts index 466b5ab8a10..a6ca98b879d 100644 --- a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts +++ b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts @@ -23,7 +23,9 @@ * server-side accessor (`resolveRecordTitle`) agrees; * 3. a row missing a title column is refused by the write path, so the * formula never sees a NULL part; - * 4. the formula adds no stored column. + * 4. the formula adds no stored column, and no search companion column + * either — not even where pinyin search provisions one (a formula is + * never a companion source). * * And, because these are permission-assignment tables, the property the new * field must not break: the formula reads exactly the columns `titleFormat` @@ -35,7 +37,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ObjectQL, resolveRecordTitle } from '@objectstack/objectql'; +import { ObjectQL, SchemaRegistry, SEARCH_COMPANION_FIELD, resolveRecordTitle } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; import { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; @@ -178,11 +180,21 @@ describe('[#20044] permission-assignment tables resolve a real record title unde } }); - it(`${object}: adds no stored column — the formula is computed on read, and no search companion appears`, async () => { + it(`${object}: adds no stored column — the formula is computed on read`, async () => { const columns = Object.keys(await driver.getKnex()(object).columnInfo()); expect(columns).toContain(Object.keys(data)[0]); expect(columns).not.toContain('display_title'); - expect(columns).not.toContain('__search'); + }); + + it(`${object}: provisions no search companion column, even where pinyin search is on`, () => { + // The companion (`__search`) is a real column fed by the title field; a + // registry only provisions it when pinyin search is enabled, so ask one + // that is. A formula title is never a companion source. + const companionRegistry = new SchemaRegistry({ searchCompanion: true }); + companionRegistry.registerObject(schema, 'com.objectstack.test.20044'); + const registered = companionRegistry.getObject(object) as any; + expect(registered.nameField).toBe('display_title'); + expect(registered.fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); }); } }); diff --git a/packages/services/service-messaging/src/objects/notification-display-title.test.ts b/packages/services/service-messaging/src/objects/notification-display-title.test.ts index 6f96bd82772..c2c88ed0487 100644 --- a/packages/services/service-messaging/src/objects/notification-display-title.test.ts +++ b/packages/services/service-messaging/src/objects/notification-display-title.test.ts @@ -31,11 +31,13 @@ * write path refuses it, or fills the column's declared default; * 4. the formula reads exactly the columns `titleFormat` names, on this row * only, each required and none withheld from a reader of the row; - * 5. nothing adds a stored column — no formula column, no search companion. + * 5. nothing adds a stored column: no formula column, and no search + * companion column even where pinyin search provisions one (a formula is + * never a companion source, and a `select` is not title text). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ObjectQL, resolveRecordTitle } from '@objectstack/objectql'; +import { ObjectQL, SchemaRegistry, SEARCH_COMPANION_FIELD, resolveRecordTitle } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; import { NotificationDelivery } from './notification-delivery.object.js'; @@ -224,11 +226,21 @@ describe('[#20044] notification objects resolve a real record title under ADR-00 } }); - it(`${object}: adds no stored column — no formula column and no search companion`, async () => { + it(`${object}: adds no stored column for the title`, async () => { const columns = Object.keys(await driver.getKnex()(object).columnInfo()); expect(columns).toContain(titleFormatColumns(c.schema)[0]); expect(columns).not.toContain('display_title'); - expect(columns).not.toContain('__search'); + }); + + it(`${object}: provisions no search companion column, even where pinyin search is on`, () => { + // The companion (`__search`) is a real column fed by the title field; a + // registry only provisions it when pinyin search is enabled, so ask one + // that is. + const companionRegistry = new SchemaRegistry({ searchCompanion: true }); + companionRegistry.registerObject(c.schema, 'com.objectstack.test.20044'); + const registered = companionRegistry.getObject(object) as any; + expect(registered.nameField).toBe(c.pointer); + expect(registered.fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); }); } }); From 17db356e015ace978e64e642025164acdd6607b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 02:23:46 +0000 Subject: [PATCH 6/6] test(plugin-approvals, plugin-security, service-messaging): run the companion step over the registered body instead of a second registry Constructing a SchemaRegistry in a test makes check:registry-log-declared require an OS_REGISTRY_LOG declaration in each package's vitest config, outside this change's surface. The registry provisions the companion by running `provisionSearchCompanion` over the body it has designated, so the pin now runs that pure step over the engine's registered body, and asks `resolveSearchCompanionSources` for the sources: none. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- ...-approval-delegation-display-title.test.ts | 23 +++++++++++------- ...-security-assignment-display-title.test.ts | 24 ++++++++++++------- .../notification-display-title.test.ts | 23 +++++++++++------- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts index 0307c4204a7..0c6cfe49cd1 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts @@ -30,7 +30,13 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ObjectQL, SchemaRegistry, SEARCH_COMPANION_FIELD, resolveRecordTitle } from '@objectstack/objectql'; +import { + ObjectQL, + SEARCH_COMPANION_FIELD, + provisionSearchCompanion, + resolveRecordTitle, + resolveSearchCompanionSources, +} from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; @@ -148,13 +154,12 @@ describe('[#20044] sys_approval_delegation resolves a real record title under AD }); it('provisions no search companion column, even where pinyin search is on', () => { - // The companion (`__search`) is a real column fed by the title field; a - // registry only provisions it when pinyin search is enabled, so ask one - // that is. A formula title is never a companion source. - const companionRegistry = new SchemaRegistry({ searchCompanion: true }); - companionRegistry.registerObject(SysApprovalDelegation as any, 'com.objectstack.test.20044'); - const registered = companionRegistry.getObject(OBJECT) as any; - expect(registered.nameField).toBe('display_title'); - expect(registered.fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); + // The companion (`__search`) is a real column fed by the title field. A + // registry provisions it only where pinyin search is on, by running + // `provisionSearchCompanion` over the body it has just designated, so run + // that step over the registered body. A formula title is never a source. + const registered = engine.registry.getObject(OBJECT) as any; + expect(resolveSearchCompanionSources(registered)).toEqual([]); + expect(provisionSearchCompanion(registered).fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); }); }); diff --git a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts index a6ca98b879d..366072aea6b 100644 --- a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts +++ b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts @@ -37,7 +37,13 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ObjectQL, SchemaRegistry, SEARCH_COMPANION_FIELD, resolveRecordTitle } from '@objectstack/objectql'; +import { + ObjectQL, + SEARCH_COMPANION_FIELD, + provisionSearchCompanion, + resolveRecordTitle, + resolveSearchCompanionSources, +} from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; import { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; @@ -187,14 +193,14 @@ describe('[#20044] permission-assignment tables resolve a real record title unde }); it(`${object}: provisions no search companion column, even where pinyin search is on`, () => { - // The companion (`__search`) is a real column fed by the title field; a - // registry only provisions it when pinyin search is enabled, so ask one - // that is. A formula title is never a companion source. - const companionRegistry = new SchemaRegistry({ searchCompanion: true }); - companionRegistry.registerObject(schema, 'com.objectstack.test.20044'); - const registered = companionRegistry.getObject(object) as any; - expect(registered.nameField).toBe('display_title'); - expect(registered.fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); + // The companion (`__search`) is a real column fed by the title field. A + // registry provisions it only where pinyin search is on, by running + // `provisionSearchCompanion` over the body it has just designated, so + // run that step over the registered body. A formula title is never a + // source. + const registered = engine.registry.getObject(object) as any; + expect(resolveSearchCompanionSources(registered)).toEqual([]); + expect(provisionSearchCompanion(registered).fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); }); } }); diff --git a/packages/services/service-messaging/src/objects/notification-display-title.test.ts b/packages/services/service-messaging/src/objects/notification-display-title.test.ts index c2c88ed0487..aaae125e24c 100644 --- a/packages/services/service-messaging/src/objects/notification-display-title.test.ts +++ b/packages/services/service-messaging/src/objects/notification-display-title.test.ts @@ -37,7 +37,13 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ObjectQL, SchemaRegistry, SEARCH_COMPANION_FIELD, resolveRecordTitle } from '@objectstack/objectql'; +import { + ObjectQL, + SEARCH_COMPANION_FIELD, + provisionSearchCompanion, + resolveRecordTitle, + resolveSearchCompanionSources, +} from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; import { NotificationDelivery } from './notification-delivery.object.js'; @@ -233,14 +239,13 @@ describe('[#20044] notification objects resolve a real record title under ADR-00 }); it(`${object}: provisions no search companion column, even where pinyin search is on`, () => { - // The companion (`__search`) is a real column fed by the title field; a - // registry only provisions it when pinyin search is enabled, so ask one - // that is. - const companionRegistry = new SchemaRegistry({ searchCompanion: true }); - companionRegistry.registerObject(c.schema, 'com.objectstack.test.20044'); - const registered = companionRegistry.getObject(object) as any; - expect(registered.nameField).toBe(c.pointer); - expect(registered.fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); + // The companion (`__search`) is a real column fed by the title field. A + // registry provisions it only where pinyin search is on, by running + // `provisionSearchCompanion` over the body it has just designated, so + // run that step over the registered body. + const registered = engine.registry.getObject(object) as any; + expect(resolveSearchCompanionSources(registered)).toEqual([]); + expect(provisionSearchCompanion(registered).fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); }); } });