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. 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..0c6cfe49cd1 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts @@ -0,0 +1,165 @@ +// 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, 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, + 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'; + +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', async () => { + const columns = Object.keys(await driver.getKnex()(OBJECT).columnInfo()); + expect(columns).toContain('delegator_id'); + expect(columns).not.toContain('display_title'); + }); + + 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 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-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-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/objects/sys-position-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts index 22a3b781910..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 @@ -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,19 @@ 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 — 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', + 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-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..366072aea6b --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts @@ -0,0 +1,206 @@ +// 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 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` + * 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, + 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'; +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`, async () => { + const columns = Object.keys(await driver.getKnex()(object).columnInfo()); + expect(columns).toContain(Object.keys(data)[0]); + expect(columns).not.toContain('display_title'); + }); + + 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 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-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index 5982129da6c..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 @@ -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,19 @@ 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 — 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', + 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..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 @@ -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,19 @@ 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 — 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', + 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/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/objects/notification-delivery.object.ts b/packages/services/service-messaging/src/objects/notification-delivery.object.ts index 1a4df874968..0d903749211 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 + // both 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-display-title.test.ts b/packages/services/service-messaging/src/objects/notification-display-title.test.ts new file mode 100644 index 00000000000..aaae125e24c --- /dev/null +++ b/packages/services/service-messaging/src/objects/notification-display-title.test.ts @@ -0,0 +1,251 @@ +// 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, 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, + 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'; +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 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'); + }); + + 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 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(); + }); + } +}); 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..2e3381cba6b 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..12a6a9041a7 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 both + // 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-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/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 039ac421f32..04530921b7c 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,16 @@ 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` 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})', highlightFields: ['user_id', 'status', 'last_seen'], @@ -32,6 +43,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()', 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", };